0.8.7: duplicate track via right-click (layer / clip / script)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d60684edd1
commit
517705bf55
4 files changed
+254
-1
No files matched your search
@@ -2345,6 +2345,143 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('scene store — duplicateTrack', () => {
|
||||
test('duplicates a layer with fresh keyframe ids right below the original', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'D1', width: 10, height: 10, fill: '#abc' })
|
||||
const l = s.addLayer(a.id, 'Orig')!
|
||||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||||
s.upsertKeyframe(l.id, 2, { x: 20 })
|
||||
const origKfIds = new Set(l.keyframes.map((k) => k.id))
|
||||
|
||||
const newId = s.duplicateTrack(l.id)
|
||||
expect(newId).toBeTruthy()
|
||||
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||||
expect(dup).toBeTruthy()
|
||||
expect(dup.id === l.id).toBe(false)
|
||||
expect(dup.name).toBe('Orig')
|
||||
expect(dup.assetId).toBe(a.id)
|
||||
expect(dup.keyframes.length).toBe(l.keyframes.length)
|
||||
// Fresh ids for every keyframe so history/undo never aliases.
|
||||
for (const k of dup.keyframes) expect(origKfIds.has(k.id)).toBe(false)
|
||||
// Clone sits immediately after the original in trackOrder.
|
||||
const order = s.scene.trackOrder!
|
||||
expect(order.indexOf(newId!)).toBe(order.indexOf(l.id) + 1)
|
||||
})
|
||||
|
||||
test('layer duplicate is deeply independent of the original', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'D2', width: 10, height: 10, fill: '#bcd' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 1, { x: 10 })
|
||||
|
||||
const newId = s.duplicateTrack(l.id)!
|
||||
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||||
// Mutate the clone — the original must not see the change.
|
||||
dup.base.x = 999
|
||||
dup.keyframes[0].props.x = 999
|
||||
const origAfter = s.scene.layers.find((x) => x.id === l.id)!
|
||||
expect(origAfter.base.x === 999).toBe(false)
|
||||
expect(origAfter.keyframes[0].props.x === 999).toBe(false)
|
||||
})
|
||||
|
||||
test('layer duplicate copies separated position/rotation kf arrays with fresh ids', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'D3', width: 10, height: 10, fill: '#cde' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 1, { x: 5, y: 5, rotation: 45 })
|
||||
s.setLayerSeparated(l.id, true)
|
||||
const before = s.scene.layers.find((x) => x.id === l.id)!
|
||||
const origPosIds = new Set((before.positionKeyframes ?? []).map((k) => k.id))
|
||||
const origRotIds = new Set((before.rotationKeyframes ?? []).map((k) => k.id))
|
||||
|
||||
const newId = s.duplicateTrack(l.id)!
|
||||
const dup = s.scene.layers.find((x) => x.id === newId)!
|
||||
expect(dup.separated).toBe(true)
|
||||
expect((dup.positionKeyframes ?? []).length).toBe((before.positionKeyframes ?? []).length)
|
||||
expect((dup.rotationKeyframes ?? []).length).toBe((before.rotationKeyframes ?? []).length)
|
||||
for (const k of dup.positionKeyframes ?? []) expect(origPosIds.has(k.id)).toBe(false)
|
||||
for (const k of dup.rotationKeyframes ?? []) expect(origRotIds.has(k.id)).toBe(false)
|
||||
})
|
||||
|
||||
test('duplicating a grouped layer keeps the clone in the same group', () => {
|
||||
const s = makeFreshStore()
|
||||
const a = s.addAsset({ kind: 'rect', name: 'D4', width: 10, height: 10, fill: '#def' })
|
||||
const l = s.addLayer(a.id)!
|
||||
const g = s.addTrackGroup('G')
|
||||
s.moveTrackIntoGroup(l.id, g.id)
|
||||
const newId = s.duplicateTrack(l.id)!
|
||||
const group = s.scene.groups!.find((x) => x.id === g.id)!
|
||||
// Duplicate lands in the group, directly after the original.
|
||||
expect(group.childIds.indexOf(newId)).toBe(group.childIds.indexOf(l.id) + 1)
|
||||
// Grouped children never leak into trackOrder.
|
||||
expect((s.scene.trackOrder ?? []).includes(newId)).toBe(false)
|
||||
})
|
||||
|
||||
test('duplicates a clip track along with its clip instances', () => {
|
||||
const s = makeFreshStore()
|
||||
const clip = s.addClip('C')
|
||||
clip.duration = 5
|
||||
const origTrack = s.addClipTrack('track-A')
|
||||
const ci1 = s.addClipInstance(clip.id, 0, origTrack.id)!
|
||||
const ci2 = s.addClipInstance(clip.id, 2, origTrack.id)!
|
||||
|
||||
const newId = s.duplicateTrack(origTrack.id)
|
||||
expect(newId).toBeTruthy()
|
||||
expect(newId === origTrack.id).toBe(false)
|
||||
const newTrack = s.scene.clipTracks!.find((t) => t.id === newId)!
|
||||
expect(newTrack.name).toBe('track-A')
|
||||
// Original instances untouched.
|
||||
expect(s.scene.clipInstances!.find((ci) => ci.id === ci1.id)!.clipTrackId).toBe(origTrack.id)
|
||||
expect(s.scene.clipInstances!.find((ci) => ci.id === ci2.id)!.clipTrackId).toBe(origTrack.id)
|
||||
// Clone track carries copies with fresh ids and same clipId/startTime.
|
||||
const cloneInstances = s.scene.clipInstances!.filter((ci) => ci.clipTrackId === newId)
|
||||
expect(cloneInstances).toHaveLength(2)
|
||||
const startTimes = cloneInstances.map((ci) => ci.startTime).sort((a, b) => a - b)
|
||||
expect(startTimes).toEqual([0, 2])
|
||||
for (const ci of cloneInstances) {
|
||||
expect(ci.clipId).toBe(clip.id)
|
||||
expect(ci.id === ci1.id).toBe(false)
|
||||
expect(ci.id === ci2.id).toBe(false)
|
||||
}
|
||||
// Slotted right after the original in trackOrder.
|
||||
const order = s.scene.trackOrder!
|
||||
expect(order.indexOf(newId!)).toBe(order.indexOf(origTrack.id) + 1)
|
||||
})
|
||||
|
||||
test('duplicates a script track along with its frames', () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
const origTrack = s.addScriptTrack('triggers')
|
||||
const f1 = s.addScriptFrame(origTrack.id, 1, [{ type: 'pause' }])!
|
||||
const f2 = s.addScriptFrame(origTrack.id, 3, [{ type: 'play' }])!
|
||||
|
||||
const newId = s.duplicateTrack(origTrack.id)!
|
||||
expect(newId === origTrack.id).toBe(false)
|
||||
const newTrack = s.scene.scriptTracks!.find((t) => t.id === newId)!
|
||||
expect(newTrack.name).toBe('triggers')
|
||||
// Original frames untouched.
|
||||
expect(s.scene.scriptFrames!.find((f) => f.id === f1.id)!.trackId).toBe(origTrack.id)
|
||||
expect(s.scene.scriptFrames!.find((f) => f.id === f2.id)!.trackId).toBe(origTrack.id)
|
||||
// Clone has its own frames at the same times with matching actions.
|
||||
const cloneFrames = s.scene.scriptFrames!.filter((f) => f.trackId === newId)
|
||||
expect(cloneFrames).toHaveLength(2)
|
||||
const times = cloneFrames.map((f) => f.time).sort((a, b) => a - b)
|
||||
expect(times).toEqual([1, 3])
|
||||
for (const f of cloneFrames) {
|
||||
expect(f.id === f1.id).toBe(false)
|
||||
expect(f.id === f2.id).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('returns null for unknown id and for group ids', () => {
|
||||
const s = makeFreshStore()
|
||||
expect(s.duplicateTrack('nope-no-such-track')).toBe(null)
|
||||
const g = s.addTrackGroup('G')
|
||||
expect(s.duplicateTrack(g.id)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
suite('app smoke — live DOM', () => {
|
||||
test('app root, topbar, timeline, stage are mounted', () => {
|
||||
expect(document.querySelector('.app')).toBeTruthy()
|
||||
|
||||
Reference in new issue
Block a user