1import AppKit
2
3/// Headless interaction test: `sequencer --uitest`.
4/// Hosts the real TimelineView in an offscreen window and drives the actual
5/// mouseDown/mouseDragged/mouseUp handlers with synthetic events, asserting
6/// against the model. Covers move, trim (incl. push-through), slip, stretch,
7/// vertical move, dynamic track creation, box select, overlaps + resolution,
8/// split (S), links, storyboard split semantics, comp parsing, fades.
9@MainActor
10func runUITest() {
11 var failures = 0
12 func check(_ cond: Bool, _ label: String) {
13 print("\(cond ? "PASS" : "FAIL") \(label)")
14 if !cond { failures += 1 }
15 }
16
17 let store = DocumentContext.headless.store
18
19 // Seed: 2 tracks, one 60s clip each at t=10 and t=30, 100s media.
20 var model = ProjectModel()
21 model.fps = 30
22 var media = MediaItem(path: "/tmp/fake.mov")
23 media.duration = 100
24 media.fps = 30
25 model.media = [media]
26 model.tracks = [Track(hue: 0.1), Track(hue: 0.5)]
27 let c0 = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 20, duration: 60)
28 let c1 = Clip(mediaId: media.id, track: .video(1), start: 30, srcIn: 0, duration: 40)
29 model.clips = [c0, c1]
30 store.replaceForTest(model)
31
32 let timeline = TimelineView(frame: NSRect(x: 0, y: 0, width: 1400, height: 400))
33 let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1400, height: 400),
34 styleMask: [.borderless], backing: .buffered, defer: false)
35 window.contentView = timeline
36 timeline.zoomToFit()
37
38 // Coordinate helpers mirroring the view's layout constants.
39 func x(_ sec: Double) -> CGFloat { timeline.testXFor(sec) }
40 // lane mid: ruler (26) + optimization strip (5) + row offset
41 func laneY(_ row: Int) -> CGFloat { 26 + 5 + CGFloat(row) * (64 + 4) + 4 + 32 }
42 // NSEvent locationInWindow is bottom-left origin; view is flipped & fills window.
43 func winPoint(_ vx: CGFloat, _ vy: CGFloat) -> NSPoint { NSPoint(x: vx, y: 400 - vy) }
44
45 func mouse(_ type: NSEvent.EventType, _ p: NSPoint, flags: NSEvent.ModifierFlags = []) -> NSEvent {
46 NSEvent.mouseEvent(with: type, location: p, modifierFlags: flags, timestamp: 0,
47 windowNumber: window.windowNumber, context: nil,
48 eventNumber: 0, clickCount: 1, pressure: 1)!
49 }
50 func drag(from: NSPoint, to: NSPoint, flags: NSEvent.ModifierFlags = [], steps: Int = 8) {
51 timeline.mouseDown(with: mouse(.leftMouseDown, from, flags: flags))
52 for i in 1...steps {
53 let f = CGFloat(i) / CGFloat(steps)
54 let p = NSPoint(x: from.x + (to.x - from.x) * f, y: from.y + (to.y - from.y) * f)
55 timeline.mouseDragged(with: mouse(.leftMouseDragged, p, flags: flags))
56 }
57 timeline.mouseUp(with: mouse(.leftMouseUp, to, flags: flags))
58 }
59 func clip(_ id: UUID) -> Clip? { store.project.clip(id) }
60
61 // 1. Move clip c0 right by ~20s.
62 DocumentContext.headless.session.snapping = false
63 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(60), laneY(0)))
64 let moved = clip(c0.id)!
65 check(abs(moved.start - 30) < 0.5, "move right: start 10 → ~30 (got \(moved.start))")
66 check(moved.track == .video(0), "move right: stays on track")
67
68 // 2. Undo restores.
69 store.undo()
70 check(abs(clip(c0.id)!.start - 10) < 0.001, "undo restores start=10")
71
72 // 3. Vertical move to track 1.
73 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(1)))
74 check(clip(c0.id)!.track == .video(1), "vertical move: c0 now on track 1")
75 store.undo()
76
77 // 4. Drag one row below the last lane: a new track appears and the
78 // emptied source track SURVIVES (empty tracks are allowed now).
79 let before4 = store.project.tracks.count
80 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(2)))
81 check(store.project.tracks.count == before4 + 1,
82 "ghost lane: +1 track, empty source track kept")
83 check(clip(c0.id)!.track == .video(store.project.tracks.count - 1),
84 "ghost lane: c0 on the new track")
85 store.undo()
86 check(store.project.tracks.count == before4, "undo removes the new track")
87
88 // 5. Drag TWO rows below: two tracks at once, clip on the deepest.
89 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(40), laneY(3)))
90 check(store.project.tracks.count == before4 + 2,
91 "two rows down: +2 tracks (got \(store.project.tracks.count))")
92 check(clip(c0.id)!.track == .video(store.project.tracks.count - 1)
93 && store.project.clips(onVideo: 2).isEmpty,
94 "two rows down: clip on deepest, middle track empty")
95 store.undo()
96
97 // 6. Trim out edge of c1 (end 70 → ~60).
98 drag(from: winPoint(x(70) - 3, laneY(1)), to: winPoint(x(60), laneY(1)))
99 let trimmed = clip(c1.id)!
100 check(abs(trimmed.end - 60) < 0.5, "trim out: end 70 → ~60 (got \(trimmed.end))")
101 store.undo()
102
103 // 7. Trim in edge of c1 (start 30 → ~40, srcIn 0 → ~10).
104 drag(from: winPoint(x(30) + 3, laneY(1)), to: winPoint(x(40), laneY(1)))
105 let trimmedIn = clip(c1.id)!
106 check(abs(trimmedIn.start - 40) < 0.5 && abs(trimmedIn.srcIn - 10) < 0.5,
107 "trim in: start→~40 srcIn→~10 (got \(trimmedIn.start), \(trimmedIn.srcIn))")
108 store.undo()
109
110 // 8. Slip (option-drag) c0: srcIn 20 → ~10 when dragging right.
111 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(50), laneY(0)), flags: [.option])
112 let slipped = clip(c0.id)!
113 check(abs(slipped.srcIn - 10) < 0.5 && abs(slipped.start - 10) < 0.001,
114 "slip: srcIn 20 → ~10, start unchanged (got \(slipped.srcIn), \(slipped.start))")
115 store.undo()
116
117 // 9. Time stretch (⌘-drag out edge): duration grows, speed drops,
118 // source range constant.
119 let srcLenBefore = clip(c0.id)!.sourceLength
120 drag(from: winPoint(x(70) - 3, laneY(0)), to: winPoint(x(72.8), laneY(0)),
121 flags: [.command])
122 let stretched = clip(c0.id)!
123 check(stretched.duration > 61 && stretched.speed < 1
124 && abs(stretched.sourceLength - srcLenBefore) < 0.2,
125 "stretch: dur \(String(format: "%.1f", stretched.duration)) " +
126 "speed \(String(format: "%.3f", stretched.speed)) srcLen constant")
127 store.undo()
128 check(clip(c0.id)!.speed == 1, "undo restores speed 1")
129
130 // 10. Snapping pulls a near-miss to a clip edge.
131 DocumentContext.headless.session.snapping = true
132 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(59.7), laneY(0)))
133 check(abs(clip(c0.id)!.start - 30) < 0.001,
134 "snapping: start snaps to 30 (got \(clip(c0.id)!.start))")
135 store.undo()
136 DocumentContext.headless.session.snapping = false
137
138 // 11. Selection click.
139 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(40), laneY(0))))
140 timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(x(40), laneY(0))))
141 check(store.selection == [c0.id], "click selects clip")
142
143 // 12. Box select from empty space over both clips.
144 store.selection = []
145 drag(from: winPoint(x(80), laneY(2) + 20), to: winPoint(x(15), laneY(0)))
146 check(store.selection == Set([c0.id, c1.id]),
147 "box select grabs both clips (got \(store.selection.count))")
148 store.selection = []
149
150 // 13. Split (S) selected at playhead.
151 store.selection = [c0.id]
152 DocumentContext.headless.playback.seek(to: 40)
153 timeline.split()
154 let onT0 = store.project.clips(onVideo: 0)
155 check(onT0.count == 2 && abs(onT0[0].end - 40) < 0.001 && abs(onT0[1].start - 40) < 0.001,
156 "split (S) cuts c0 at 40")
157 store.undo()
158 check(store.project.clips(onVideo: 0).count == 1, "undo unsplits")
159
160 // 14. Linked move: link c0+c1, drag c0, c1 follows.
161 store.selection = [c0.id, c1.id]
162 timeline.linkSelection()
163 check(clip(c0.id)!.linkId != nil && clip(c0.id)!.linkId == clip(c1.id)!.linkId,
164 "link assigns shared linkId")
165 store.selection = []
166 drag(from: winPoint(x(40), laneY(0)), to: winPoint(x(50), laneY(0)))
167 check(abs(clip(c0.id)!.start - 20) < 0.5 && abs(clip(c1.id)!.start - 40) < 0.5,
168 "linked move: both clips shift +10 (got \(clip(c0.id)!.start), \(clip(c1.id)!.start))")
169
170 // 15. Linked split: cutting c0 at 45 also cuts c1; right halves share a new link.
171 DocumentContext.headless.playback.seek(to: 45)
172 store.selection = [c0.id]
173 timeline.split()
174 check(store.project.clips.count == 4, "linked split cuts both clips")
175 let rights = store.project.clips.filter { abs($0.start - 45) < 0.001 }
176 check(rights.count == 2 && rights[0].linkId != nil && rights[0].linkId == rights[1].linkId
177 && rights[0].linkId != clip(c0.id)!.linkId,
178 "right halves share a fresh linkId")
179
180 // ---- Fresh model: overlaps, push-trim, storyboard, empty tracks ----
181 var m2 = ProjectModel()
182 m2.fps = 30
183 m2.media = [media]
184 m2.tracks = [Track(hue: 0.3)]
185 let a = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 30) // [10,40)
186 let b = Clip(mediaId: media.id, track: .video(0), start: 40, srcIn: 0, duration: 30) // [40,70)
187 m2.clips = [a, b]
188 store.replaceForTest(m2)
189 timeline.zoomToFit()
190
191 // 16. Trim-through push: dragging a's out edge to ~55 trims b's head.
192 drag(from: winPoint(x(40) - 3, laneY(0)), to: winPoint(x(55), laneY(0)))
193 let a16 = clip(a.id)!, b16 = clip(b.id)!
194 check(abs(a16.end - 55) < 0.5 && abs(b16.start - a16.end) < 0.001
195 && abs(b16.srcIn - (b16.start - 40)) < 0.01 && abs(b16.end - 70) < 0.001,
196 "push trim: a.end→\(String(format: "%.1f", a16.end)), b follows, b.end fixed")
197 check(store.project.overlaps().isEmpty, "push trim leaves no overlap")
198 store.undo()
199
200 // 17. Moving a clip onto another creates the overlap error.
201 drag(from: winPoint(x(25), laneY(0)), to: winPoint(x(50), laneY(0))) // a → [35,65)
202 let ovs = store.project.overlaps()
203 check(ovs.count == 1 && abs(ovs[0].start - 40) < 0.5 && abs(ovs[0].end - 65) < 0.5,
204 "move onto clip: overlap [\(String(format: "%.1f", ovs.first?.start ?? -1)), " +
205 "\(String(format: "%.1f", ovs.first?.end ?? -1))) detected")
206
207 // 18. Clicking inside the red overlap selects both clips.
208 store.selection = []
209 let ovMidX = (x(ovs[0].start) + x(ovs[0].end)) / 2
210 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(ovMidX, laneY(0))))
211 timeline.mouseUp(with: mouse(.leftMouseUp, winPoint(ovMidX, laneY(0))))
212 check(store.selection == Set([a.id, b.id]), "overlap click selects both clips")
213
214 // 19. S with the playhead INSIDE the overlap resolves it there.
215 DocumentContext.headless.playback.seek(to: 50)
216 timeline.split()
217 let a19 = clip(a.id)!, b19 = clip(b.id)!
218 check(store.project.overlaps().isEmpty
219 && abs(a19.end - 50) < 0.001 && abs(b19.start - 50) < 0.001,
220 "S at playhead in overlap: out/in meet at 50")
221 store.undo() // resolution
222
223 // 20. S with the playhead OUTSIDE the overlap splits normally, keeping it.
224 DocumentContext.headless.playback.seek(to: 38) // inside a=[35,65), before overlap [40,65)
225 let clipsBefore20 = store.project.clips.count
226 timeline.split()
227 check(store.project.clips.count == clipsBefore20 + 1
228 && !store.project.overlaps().isEmpty,
229 "S outside overlap: normal split, overlap kept")
230 store.undo() // split
231
232 // 21. O moves the overlapping clip to a separate track.
233 let tracksBefore21 = store.project.tracks.count
234 timeline.moveOverlapsToSeparateTracks()
235 check(store.project.overlaps().isEmpty
236 && clip(b.id)!.track != clip(a.id)!.track
237 && store.project.tracks.count == tracksBefore21 + 1,
238 "O moves overlap to a new separate track")
239 store.undo() // move to track
240 store.undo() // the drag that made the overlap
241
242 // 21. Deleting all clips keeps the (now empty) tracks.
243 store.selection = Set(store.project.clips.map(\.id))
244 timeline.deleteSelection()
245 check(store.project.tracks.count == 1 && store.project.clips.isEmpty,
246 "empty tracks survive deletion")
247
248 // ---- Storyboard split semantics ----
249 var m3 = ProjectModel()
250 m3.fps = 30
251 m3.tracks = [] // the storyboard lane is implied by its panels, not stored
252 var board = Board()
253 board.shapes = [BoardShape(kind: .rect, frame: CGRect(x: 10, y: 10, width: 100, height: 80))]
254 let sb = Clip(mediaId: nil, track: .storyboard, start: 0, srcIn: 0, duration: 6,
255 kind: .storyboard, board: board)
256 m3.clips = [sb]
257 store.replaceForTest(m3)
258
259 // 22. S duplicates the panel: both halves keep the drawing (fresh id).
260 store.selection = [sb.id]
261 DocumentContext.headless.playback.seek(to: 2)
262 timeline.split()
263 let panels = store.project.clips.sorted { $0.start < $1.start }
264 check(panels.count == 2
265 && panels[0].board?.shapes == panels[1].board?.shapes
266 && panels[0].board?.id != panels[1].board?.id,
267 "storyboard S: duplicate panel, same shapes, new board id")
268
269 // 23. ⇧B splits the panel under the playhead (ignoring the selection),
270 // duplicates the drawing, flags the new right half a NEW SHOT, and moves
271 // the selection onto it — so the names come out "1A, 1B, 2A".
272 store.selection = []
273 DocumentContext.headless.playback.seek(to: 4)
274 timeline.splitStoryboardAtPlayhead(newShot: true)
275 let panels23 = store.project.clips.sorted { $0.start < $1.start }
276 check(panels23.count == 3
277 && panels23[2].newShot == true
278 && panels23[2].board?.shapes == panels23[1].board?.shapes
279 && panels23[2].board?.id != panels23[1].board?.id,
280 "storyboard ⇧B: duplicate drawing, right half flagged new shot")
281 check(store.selection == [panels23[2].id],
282 "storyboard ⇧B: selection moves to the new panel")
283 let names = store.project.panelNames()
284 check(names[panels23[0].id] == "1A" && names[panels23[1].id] == "1B"
285 && names[panels23[2].id] == "2A",
286 "panel names: 1A, 1B, 2A (got \(panels23.compactMap { names[$0.id] }))")
287
288 // 23b. New shots are pure metadata now (panels are gapless): flagging a
289 // panel with newShot bumps the shot number.
290 store.mutate { m in
291 m.clips.append(Clip(mediaId: nil, track: .storyboard, start: 8,
292 srcIn: 0, duration: 2, kind: .storyboard, board: Board()))
293 }
294 let newest = store.project.clips.sorted { $0.start < $1.start }.last!
295 store.mutate { m in
296 if let i = m.clips.firstIndex(where: { $0.id == newest.id }) {
297 m.clips[i].newShot = true
298 }
299 }
300 let names23b = store.project.panelNames()
301 check(names23b[newest.id] == "3A",
302 "newShot metadata starts shot 3 (got \(names23b[newest.id] ?? "nil"))")
303
304 // 23c. Start-only panels: durations are DERIVED — each panel lasts until
305 // the next one, and the last extends past everything ("forever").
306 let derived = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
307 check(abs(derived[0].end - derived[1].start) < 1e-9
308 && abs(derived[1].end - derived[2].start) < 1e-9
309 && abs(derived[2].end - derived[3].start) < 1e-9
310 && derived[3].duration >= 10,
311 "storyboard durations derive from next starts; last is open-ended")
312
313 // 23d. Dragging a panel's OUT edge moves the NEXT panel's start.
314 timeline.zoomToFit()
315 let p23 = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
316 let secondStart = p23[1].start
317 drag(from: winPoint(x(p23[0].end) - 2, laneY(0)),
318 to: winPoint(x(p23[0].end + 1.0), laneY(0)))
319 let p23after = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
320 check(abs(p23after[1].start - (secondStart + 1)) < 0.35
321 && abs(p23after[0].end - p23after[1].start) < 1e-9,
322 "panel out-edge drag moves the next panel's start (got \(p23after[1].start))")
323 store.undo()
324
325 // 23e. Dragging a panel's BODY does nothing but park the playhead on it.
326 let bodyBefore = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
327 drag(from: winPoint((x(bodyBefore[1].start) + x(bodyBefore[1].end)) / 2, laneY(0)),
328 to: winPoint((x(bodyBefore[1].start) + x(bodyBefore[1].end)) / 2 + 120, laneY(0)))
329 let bodyAfter = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
330 check(abs(bodyAfter[1].start - bodyBefore[1].start) < 1e-9,
331 "panel body drag never moves it")
332 check(abs(DocumentContext.headless.playback.playhead - bodyBefore[1].start) < 0.05,
333 "clicking a panel parks the playhead on it")
334
335 // 23f. ⌥-drag a panel's out edge = ripple resize: later panels shift as
336 // one, spacing kept.
337 let rp = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
338 let gapBefore = rp[3].start - rp[2].start
339 drag(from: winPoint(x(rp[1].end) - 2, laneY(0)),
340 to: winPoint(x(rp[1].end + 1.0), laneY(0)), flags: [.option])
341 let rpAfter = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }
342 check(rpAfter[2].start > rp[2].start + 0.5
343 && abs((rpAfter[3].start - rpAfter[2].start) - gapBefore) < 1e-9,
344 "⌥ out-edge ripple pushes later panels, spacing kept")
345 store.undo()
346
347 // 23g. B splits the panel under the playhead regardless of the selection,
348 // duplicates the drawing, does NOT flag a new shot, and selects the right
349 // half.
350 store.selection = []
351 let firstPanel = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }[0]
352 DocumentContext.headless.playback.seek(to: firstPanel.start + 1)
353 let countBeforeB = store.project.clips.count
354 timeline.splitStoryboardAtPlayhead()
355 let afterB = store.project.clips.sorted { $0.start < $1.start }
356 let newPanel = afterB.first { abs($0.start - (firstPanel.start + 1)) < 0.05 }
357 check(store.project.clips.count == countBeforeB + 1
358 && newPanel?.newShot == false
359 && newPanel.map { store.selection == [$0.id] } == true,
360 "B splits under the playhead, selects the new panel, no new-shot flag")
361 store.undo()
362
363 // 23h. New Panel 1 s Later (menu action) adds a panel, playhead follows.
364 DocumentContext.headless.playback.seek(to: store.project.clips(on: .storyboard)
365 .sorted { $0.start < $1.start }[0].start)
366 let countBeforeLater = store.project.clips.count
367 timeline.addPanelOneSecondLater()
368 check(store.project.clips.count == countBeforeLater + 1
369 && abs(DocumentContext.headless.playback.playhead - 1) < 0.05,
370 "New Panel 1 s Later adds a panel and parks the playhead on it")
371 store.undo()
372
373 // 23i. N toggles the new-shot marker on the selected panel (no split).
374 let togglePanel = store.project.clips(on: .storyboard).sorted { $0.start < $1.start }[1]
375 store.selection = [togglePanel.id]
376 let wasNewShot = togglePanel.newShot
377 let countBeforeToggle = store.project.clips.count
378 timeline.toggleNewShot()
379 check(store.project.clip(togglePanel.id)?.newShot == !wasNewShot
380 && store.project.clips.count == countBeforeToggle,
381 "N toggles new-shot without splitting")
382 timeline.toggleNewShot()
383 check(store.project.clip(togglePanel.id)?.newShot == wasNewShot,
384 "N toggles new-shot back")
385
386 // 24. N never touches video clips.
387 var m4 = ProjectModel()
388 m4.fps = 30
389 m4.media = [media]
390 m4.tracks = [Track(hue: 0.3)]
391 m4.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)]
392 store.replaceForTest(m4)
393 store.selection = [m4.clips[0].id]
394 DocumentContext.headless.playback.seek(to: 5)
395 timeline.toggleNewShot()
396 check(store.project.clips.count == 1
397 && store.project.clip(m4.clips[0].id)?.newShot == false,
398 "N ignores video clips")
399
400 // ---- Pure logic ----
401
402 // 25. Comp filename parsing.
403 let p1 = FusionComps.parseCompName("0200-0681_intro.comp")
404 let p2 = FusionComps.parseCompName("1779-2100_walking_in_space001.comp")
405 check(p1?.start == 200 && p1?.end == 681 && p1?.title == "intro",
406 "comp name parse: range + title")
407 check(p2?.start == 1779 && p2?.end == 2100, "comp name parse: second sample")
408 check(FusionComps.parseCompName("notes.comp") == nil
409 && FusionComps.parseCompName("0100-0200_x.autocomp") == nil,
410 "comp name parse rejects non-ranged/autocomp")
411
412 // 26. Saver parsing prefers MainOutput.
413 let compText = """
414 Tools = ordered() {
415 Saver1 = Saver {
416 Inputs = { Clip = Input { Value = Clip {
417 Filename = "/renders/alt/seq.png", FormatID = "PNGFormat", }, }, },
418 },
419 MainOutput = Saver {
420 Inputs = { Clip = Input { Value = Clip {
421 Filename = "/renders/main/seq.png", FormatID = "PNGFormat", }, }, },
422 },
423 }
424 """
425 check(FusionComps.parseSaverPath(compText: compText) == "/renders/main/seq.png",
426 "saver parse prefers MainOutput")
427
428 // 27. Audio fade envelope.
429 var ac = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10,
430 kind: .audio)
431 ac.fadeIn = 2
432 ac.fadeOut = 4
433 check(abs(audioGain(ac, at: 1) - 0.5) < 0.001
434 && abs(audioGain(ac, at: 5) - 1.0) < 0.001
435 && abs(audioGain(ac, at: 8) - 0.5) < 0.001
436 && audioGain(ac, at: 11) == 0,
437 "audio fade envelope")
438
439 // 27b. Shuttle keeps doubling; the opposite key halves down to a stop.
440 let pc = DocumentContext.headless.playback
441 pc.setRate(0)
442 pc.shuttle(1); pc.shuttle(1); pc.shuttle(1); pc.shuttle(1) // 1,2,4,8
443 check(pc.rate == 8, "shuttle keeps doubling (got \(pc.rate))")
444 pc.shuttle(1)
445 check(pc.rate == 16, "shuttle passes 8x (got \(pc.rate))")
446 pc.shuttle(-1)
447 check(pc.rate == 8, "opposite key halves (got \(pc.rate))")
448 pc.shuttle(-1); pc.shuttle(-1); pc.shuttle(-1) // 4, 2, 1
449 pc.shuttle(-1)
450 check(pc.rate == 0, "opposite key slows to a stop (got \(pc.rate))")
451
452 // 27c. Nudge: ← / → move the selection by one frame.
453 var m6 = ProjectModel()
454 m6.fps = 30
455 m6.media = [media]
456 m6.tracks = [Track(hue: 0.3)]
457 let nc = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 5)
458 m6.clips = [nc]
459 store.replaceForTest(m6)
460 store.selection = [nc.id]
461 timeline.nudgeSelection(by: 1.0 / 30)
462 check(abs(store.project.clip(nc.id)!.start - (10 + 1.0 / 30)) < 1e-9,
463 "nudge right moves one frame")
464 timeline.nudgeSelection(by: -1.0 / 30)
465 check(abs(store.project.clip(nc.id)!.start - 10) < 1e-9, "nudge left returns")
466
467 // 27d. Drawing-layer orientation: a stroke near the TOP of the board
468 // must composite near the TOP (y must not invert anywhere in the chain).
469 var board27 = Board()
470 board27.width = 100
471 board27.height = 100
472 DocumentContext.headless.boards.beginStroke(board: board27)
473 DocumentContext.headless.boards.strokeSegment(board: board27,
474 from: CGPoint(x: 20, y: 15),
475 to: CGPoint(x: 80, y: 15),
476 width: 12, color: .black, erase: false)
477 DocumentContext.headless.boards.endStroke(board: board27)
478 let comp27 = DocumentContext.headless.boards.composite(for: board27)
479 func lum(_ img: NSImage, _ fx: CGFloat, _ fy: CGFloat) -> CGFloat { // fy: 0 = top
480 guard let tiff = img.tiffRepresentation,
481 let rep = NSBitmapImageRep(data: tiff),
482 let c = rep.colorAt(x: Int(fx * CGFloat(rep.pixelsWide - 1)),
483 y: Int(fy * CGFloat(rep.pixelsHigh - 1)))?
484 .usingColorSpace(.deviceRGB)
485 else { return -1 }
486 return c.brightnessComponent
487 }
488 let top = lum(comp27, 0.5, 0.15), bottom = lum(comp27, 0.5, 0.85)
489 check(top >= 0 && top < 0.5 && bottom > 0.9,
490 "stroke drawn at top STAYS at top (top=\(top), bottom=\(bottom))")
491
492 // 27e. The stroke rides the SHARED undo timeline: ⌘Z clears the drawing,
493 // ⌘⇧Z brings it back — no separate raster-undo lane.
494 store.undo()
495 let undone = lum(DocumentContext.headless.boards.composite(for: board27), 0.5, 0.15)
496 store.redo()
497 let redone = lum(DocumentContext.headless.boards.composite(for: board27), 0.5, 0.15)
498 check(undone > 0.9 && redone < 0.5,
499 "raster stroke undoes and redoes on the shared undo timeline "
500 + "(drawn=\(top) undone=\(undone) redone=\(redone))")
501 DocumentContext.headless.boards.saveRaster(nil, boardId: board27.id)
502
503 // 28. Overlaps ignore audio (layering is allowed).
504 var m5 = ProjectModel()
505 m5.fps = 30
506 m5.media = [media]
507 m5.tracks = [Track(hue: 0.3)]
508 m5.clips = [
509 Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10, kind: .audio),
510 Clip(mediaId: media.id, track: .video(0), start: 5, srcIn: 0, duration: 10, kind: .audio),
511 ]
512 check(m5.overlaps().isEmpty, "audio clips layer without overlap errors")
513
514 // ---- Wave 4: linked semantics, ripple delete, clipboard ----
515
516 // 29. Deleting one linked clip deletes the whole group.
517 var m7 = ProjectModel()
518 m7.fps = 30
519 m7.media = [media]
520 m7.tracks = [Track(hue: 0.2), Track(hue: 0.5), Track(hue: 0.8)]
521 let link = UUID()
522 let la = Clip(mediaId: media.id, track: .video(0), start: 10, srcIn: 0, duration: 20,
523 kind: .video, linkId: link)
524 let lb = Clip(mediaId: media.id, track: .video(1), start: 10, srcIn: 0, duration: 20,
525 kind: .video, linkId: link)
526 let solo = Clip(mediaId: media.id, track: .video(2), start: 40, srcIn: 0, duration: 10)
527 m7.clips = [la, lb, solo]
528 store.replaceForTest(m7)
529 store.selection = [la.id]
530 timeline.deleteSelection()
531 check(store.project.clips.count == 1 && store.project.clip(solo.id) != nil,
532 "deleting one linked clip deletes its link-mates")
533 store.undo()
534
535 // 30. Vertical GROUP move: dragging one linked clip a row down shifts the
536 // whole group a row down.
537 timeline.zoomToFit()
538 store.selection = []
539 drag(from: winPoint(x(20), laneY(0)), to: winPoint(x(20), laneY(1)))
540 check(store.project.clip(la.id)!.track == .video(1)
541 && store.project.clip(lb.id)!.track == .video(2),
542 "linked group moves vertically as one")
543 store.undo()
544
545 // 31. Ripple delete closes the gap on every track. The playhead slides with
546 // the content it sat on: it was at 80 (after the removed [10,30) span), so
547 // removing that 20s gap carries it to 60 — same frame, not parked at the cut.
548 store.selection = [la.id] // linked pair [10,30) — ripple shifts solo 40→20
549 DocumentContext.headless.playback.seek(to: 80)
550 timeline.rippleDelete()
551 check(store.project.clips.count == 1
552 && abs(store.project.clip(solo.id)!.start - 20) < 1e-9,
553 "ripple delete closes the gap across tracks (got \(store.project.clip(solo.id)?.start ?? -1))")
554 check(abs(DocumentContext.headless.playback.playhead - 60) < 1e-6,
555 "ripple delete slides the playhead with the content (got \(DocumentContext.headless.playback.playhead))")
556 store.undo()
557
558 // 31a. A playhead BEFORE the removed span is left untouched (content there
559 // didn't move); one INSIDE the span lands on the close point.
560 store.selection = [la.id]
561 DocumentContext.headless.playback.seek(to: 5)
562 timeline.rippleDelete()
563 check(abs(DocumentContext.headless.playback.playhead - 5) < 1e-6,
564 "ripple delete leaves an earlier playhead put (got \(DocumentContext.headless.playback.playhead))")
565 store.undo()
566 store.selection = [la.id]
567 DocumentContext.headless.playback.seek(to: 25)
568 timeline.rippleDelete()
569 check(abs(DocumentContext.headless.playback.playhead - 10) < 1e-6,
570 "ripple delete inside the span lands on the close point (got \(DocumentContext.headless.playback.playhead))")
571 store.undo()
572
573 // 31b. Trailing empty tracks collapse to the last used lane; interior and
574 // top empties stay (so dragging a clip down two rows still makes two).
575 var mp = ProjectModel(); mp.fps = 30; mp.media = [media]
576 mp.tracks = [Track(hue: 0.1), Track(hue: 0.2), Track(hue: 0.3)]
577 mp.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10)]
578 mp.pruneTrailingEmptyTracks()
579 check(mp.tracks.count == 1,
580 "trailing empty tracks collapse to the last used one")
581 var mi = ProjectModel(); mi.fps = 30; mi.media = [media]
582 mi.tracks = [Track(hue: 0.1), Track(hue: 0.2), Track(hue: 0.3)] // clip on the BOTTOM lane
583 mi.clips = [Clip(mediaId: media.id, track: .video(2), start: 0, srcIn: 0, duration: 10)]
584 mi.pruneTrailingEmptyTracks()
585 check(mi.tracks.count == 3,
586 "empty lanes above the used one are kept (drag-down-two-rows survives)")
587
588 // 31c. ⌥→ ripple-trims the RIGHT side to the playhead and closes the gap.
589 var mr = ProjectModel(); mr.fps = 30; mr.media = [media]
590 mr.tracks = [Track(hue: 0.3)]
591 mr.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 20),
592 Clip(mediaId: media.id, track: .video(0), start: 20, srcIn: 0, duration: 20)]
593 store.replaceForTest(mr)
594 store.selection = []
595 DocumentContext.headless.playback.seek(to: 10)
596 timeline.rippleTrimToPlayhead(deleteLeft: false)
597 let rcs = store.project.clips.sorted { $0.start < $1.start }
598 check(rcs.count == 2 && abs(rcs[0].duration - 10) < 1e-6
599 && abs(rcs[1].start - 10) < 1e-6 && abs(rcs[1].end - 30) < 1e-6,
600 "⌥→ ripple-trims the right side to the playhead")
601
602 // 31d. ⌥← ripple-trims the LEFT side of the clip under the playhead.
603 store.replaceForTest(mr)
604 store.selection = []
605 DocumentContext.headless.playback.seek(to: 25) // inside the second clip [20,40)
606 timeline.rippleTrimToPlayhead(deleteLeft: true)
607 let lcs = store.project.clips.sorted { $0.start < $1.start }
608 check(lcs.count == 2 && abs(lcs[1].start - 20) < 1e-6 && abs(lcs[1].duration - 15) < 1e-6,
609 "⌥← ripple-trims the left side to the playhead")
610
611 // 31d-ripple. ⌥-drag a clip's IN edge = ripple trim: the clip's start stays
612 // pinned, its in-point re-trims, and the follower shifts to stay gapless.
613 // Media out-point is fixed, so the source range only loses (or regains)
614 // frames at the head.
615 var mri = ProjectModel(); mri.fps = 30; mri.media = [media]
616 mri.tracks = [Track(hue: 0.6)]
617 let ra = Clip(mediaId: media.id, track: .video(0), start: 20, srcIn: 10, duration: 20)
618 let rb = Clip(mediaId: media.id, track: .video(0), start: 40, srcIn: 0, duration: 20)
619 mri.clips = [ra, rb]
620 store.replaceForTest(mri)
621 store.selection = []
622 DocumentContext.headless.session.snapping = false
623 timeline.zoomToFit()
624 // Drag ra's in edge right by 10s: trim the head, pull rb in with it.
625 drag(from: winPoint(x(20) + 3, laneY(0)), to: winPoint(x(30) + 3, laneY(0)), flags: [.option])
626 let ra1 = clip(ra.id)!, rb1 = clip(rb.id)!
627 check(abs(ra1.start - 20) < 1e-6 && abs(ra1.srcIn - 20) < 0.5
628 && abs(ra1.duration - 10) < 0.5 && abs(rb1.start - 30) < 0.5,
629 "⌥ in-edge ripple: start pinned, head trimmed, follower pulled in "
630 + "(got start \(ra1.start), srcIn \(ra1.srcIn), dur \(ra1.duration), rb \(rb1.start))")
631 check(abs((ra1.srcIn + ra1.duration) - (ra.srcIn + ra.duration)) < 0.5,
632 "⌥ in-edge ripple keeps the source out-point fixed")
633 store.undo()
634 check(abs(clip(ra.id)!.srcIn - 10) < 1e-6 && abs(clip(rb.id)!.start - 40) < 1e-6,
635 "⌥ in-edge ripple undoes as one step")
636 // Drag left by 10s: extend the head, push the follower out.
637 drag(from: winPoint(x(20) + 3, laneY(0)), to: winPoint(x(10) + 3, laneY(0)), flags: [.option])
638 let ra2 = clip(ra.id)!, rb2 = clip(rb.id)!
639 check(abs(ra2.start - 20) < 1e-6 && abs(ra2.srcIn - 0) < 0.5
640 && abs(ra2.duration - 30) < 0.5 && abs(rb2.start - 50) < 0.5,
641 "⌥ in-edge ripple (drag left): head extended, follower pushed out "
642 + "(got start \(ra2.start), srcIn \(ra2.srcIn), dur \(ra2.duration), rb \(rb2.start))")
643 store.undo()
644
645 // 31e. Delete-the-space closes a blank gap at the playhead.
646 var mb = ProjectModel(); mb.fps = 30; mb.media = [media]
647 mb.tracks = [Track(hue: 0.4)]
648 mb.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 10),
649 Clip(mediaId: media.id, track: .video(0), start: 25, srcIn: 0, duration: 10)]
650 store.replaceForTest(mb)
651 store.selection = []
652 timeline.closeBlankSpace(at: 15) // playhead sits in the [10,25) gap
653 let bcs = store.project.clips.sorted { $0.start < $1.start }
654 check(bcs.count == 2 && abs(bcs[1].start - 10) < 1e-6,
655 "deleting the space closes the blank gap (got \(bcs[1].start))")
656
657 // 31f. Dragging one selected clip's out edge resizes ALL selected clips.
658 DocumentContext.headless.session.laneScale = 1
659 var mm = ProjectModel(); mm.fps = 30; mm.media = [media]
660 mm.tracks = [Track(hue: 0.1), Track(hue: 0.5)]
661 let ec0 = Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 20)
662 let ec1 = Clip(mediaId: media.id, track: .video(1), start: 0, srcIn: 0, duration: 20)
663 mm.clips = [ec0, ec1]
664 store.replaceForTest(mm)
665 timeline.zoomToFit()
666 store.selection = [ec0.id, ec1.id]
667 let wasSnapping = DocumentContext.headless.session.snapping; DocumentContext.headless.session.snapping = false
668 // Grab a few px INSIDE the right edge (maxX is exclusive for hit-testing).
669 drag(from: winPoint(x(20) - 4, laneY(0)), to: winPoint(x(30) - 4, laneY(0)))
670 DocumentContext.headless.session.snapping = wasSnapping
671 check(abs(clip(ec0.id)!.duration - 30) < 0.3 && abs(clip(ec1.id)!.duration - 30) < 0.3,
672 "dragging one selected clip's out edge resizes all selected (got \(clip(ec1.id)!.duration))")
673
674 // 31g. The first storyboard panel is anchored to 0:00.
675 var ma = ProjectModel(); ma.fps = 30; ma.media = [media]
676 ma.tracks = [] // storyboard lane is implied by its panels
677 ma.clips = [Clip(mediaId: nil, track: .storyboard, start: 7, srcIn: 0, duration: 3,
678 kind: .storyboard)]
679 ma.normalizeStoryboards()
680 check(abs(ma.clips[0].start) < 1e-6, "first storyboard panel is anchored to 0:00")
681
682 // 31g'. Deleting every storyboard panel removes the storyboard lane.
683 ma.clips.removeAll { $0.kind == .storyboard }
684 ma.normalizeStoryboards()
685 check(!ma.hasStoryboard, "emptying the storyboard lane hides it")
686
687 // 31h. sync.json parses per-stream offsets (missing offset counts as 0).
688 let syncData = Data("""
689 {"streams":[{"file":"mic.m4a","offsetSeconds":0},
690 {"file":"cam.mov","offsetSeconds":0.5},
691 {"file":"screen.mov"}]}
692 """.utf8)
693 let man = try? JSONDecoder().decode(SyncManifest.self, from: syncData)
694 let offs = man?.offsetsByFile ?? [:]
695 check(offs["mic.m4a"] == 0 && abs((offs["cam.mov"] ?? -1) - 0.5) < 1e-9
696 && offs["screen.mov"] == 0,
697 "sync.json parses per-stream offsets (missing = 0)")
698
699 // Restore the m7 fixture for the clipboard tests that follow.
700 store.replaceForTest(m7)
701 store.selection = []
702
703 // 32. ⌘C puts Fusion Loader Lua on the pasteboard as TEXT, and ⌘V pastes
704 // the clips back at the playhead.
705 store.selection = [solo.id]
706 timeline.copy(nil)
707 let pbString = NSPasteboard.general.string(forType: .string) ?? ""
708 check(pbString.contains("Loader") && pbString.contains("TrimIn"),
709 "⌘C text is Fusion Loader Lua")
710 let clipCount32 = store.project.clips.count
711 DocumentContext.headless.playback.seek(to: 100)
712 timeline.paste(nil)
713 let pasted = store.project.clips.filter { abs($0.start - 100) < 1e-6 }
714 check(store.project.clips.count == clipCount32 + 1 && pasted.count == 1,
715 "⌘V pastes the copied clip at the playhead")
716 store.undo()
717
718 // 33. timelineDuration treats storyboard panels as start-only.
719 var m8 = ProjectModel()
720 m8.fps = 30
721 m8.media = [media]
722 m8.tracks = [Track(hue: 0.1)]
723 m8.clips = [Clip(mediaId: media.id, track: .video(0), start: 0, srcIn: 0, duration: 50)]
724 var m8b = m8
725 m8b.clips.append(Clip(mediaId: nil, track: .storyboard, start: 0, srcIn: 0,
726 duration: 3, kind: .storyboard, board: Board()))
727 check(m8.timelineDuration == 50, "timelineDuration from solid clips")
728 check(m8b.laneRefs.first == .storyboard,
729 "storyboard lane sits first (below the Fusion band)")
730
731 // 34. Scroll-zoom bars: dragging the horizontal thumb's right END left
732 // shrinks the visible span (zooms in).
733 store.replaceForTest(m8)
734 timeline.zoomToFit()
735 let pps0 = timeline.testPxPerSecond
736 let ht = timeline.testHThumb()
737 drag(from: winPoint(ht.maxX - 1, ht.midY), to: winPoint(ht.maxX - 401, ht.midY))
738 check(timeline.testPxPerSecond > pps0 * 1.15,
739 "h-bar end drag zooms in (pps \(String(format: "%.1f→%.1f", pps0, timeline.testPxPerSecond)))")
740
741 // 35. Vertical bar: dragging the thumb's bottom END up zooms the lanes.
742 DocumentContext.headless.session.laneScale = 1
743 let vt = timeline.testVThumb()
744 drag(from: winPoint(vt.midX, vt.maxY - 1), to: winPoint(vt.midX, vt.maxY - 120))
745 check(DocumentContext.headless.session.laneScale > 1.1,
746 "v-bar end drag scales lanes (got \(String(format: "%.2f", DocumentContext.headless.session.laneScale)))")
747 DocumentContext.headless.session.laneScale = 1
748
749 // 36. Adaptive proxy quality controller (pure decision function).
750 let nLevels = ChunkManager.qualities.count
751 // Fast build at full quality → hold (stay at 0).
752 check(ChunkManager.decideQuality(level: 0, norm: 0.3, normByLevel: [0: 0.3],
753 fastStreak: 0, sourceIsNetwork: true,
754 levelCount: nLevels).nextIndex == 0,
755 "adaptive: comfortable full-quality build holds")
756 // Slow build at full quality → degrade to level 1 (no network blame yet).
757 let d1 = ChunkManager.decideQuality(level: 0, norm: 1.2, normByLevel: [0: 1.2],
758 fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels)
759 check(d1.nextIndex == 1 && !d1.networkLimited, "adaptive: slow build degrades quality")
760 // Degrading helped (level 1 much faster than level 0) but still slow → degrade again.
761 let d2 = ChunkManager.decideQuality(level: 1, norm: 0.9, normByLevel: [0: 1.2, 1: 0.9],
762 fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels)
763 check(d2.nextIndex == 2 && !d2.networkLimited, "adaptive: still-slow useful degrade continues")
764 // Degrading DIDN'T help (level 1 ≈ level 0) + network source → blame network, restore quality.
765 let d3 = ChunkManager.decideQuality(level: 1, norm: 1.15, normByLevel: [0: 1.2, 1: 1.15],
766 fastStreak: 0, sourceIsNetwork: true, levelCount: nLevels)
767 check(d3.networkLimited && d3.nextIndex == 0, "adaptive: futile degrade blames network + restores")
768 // Same futile degrade but LOCAL source → never blame the network.
769 check(!ChunkManager.decideQuality(level: 1, norm: 1.15, normByLevel: [0: 1.2, 1: 1.15],
770 fastStreak: 0, sourceIsNetwork: false,
771 levelCount: nLevels).networkLimited,
772 "adaptive: local slow source is never flagged network-limited")
773 // Comfortable again after degrading: two fast builds recover one level.
774 let up = ChunkManager.decideQuality(level: 2, norm: 0.3, normByLevel: [2: 0.3],
775 fastStreak: 1, sourceIsNetwork: true, levelCount: nLevels)
776 check(up.nextIndex == 1 && !up.networkLimited, "adaptive: sustained fast builds recover quality")
777
778 // 37. Markers: toggle at the playhead, navigate, click-to-seek, clear.
779 store.replaceForTest(m8)
780 timeline.zoomToFit()
781 pc.seek(to: 20)
782 timeline.toggleMarkerAtPlayhead()
783 pc.seek(to: 40)
784 timeline.toggleMarkerAtPlayhead()
785 check(store.project.markers.count == 2, "⇧M drops a marker at the playhead")
786 check(store.project.markers.contains { abs($0.time - 20) < 1e-6 }
787 && store.project.markers.contains { abs($0.time - 40) < 1e-6 },
788 "markers land on the playhead frame")
789 timeline.goToPrevMarker()
790 check(abs(pc.playhead - 20) < 1e-6, "⌥[ jumps to the previous marker")
791 timeline.goToNextMarker()
792 check(abs(pc.playhead - 40) < 1e-6, "⌥] jumps to the next marker")
793 // Toggling on an existing marker removes it.
794 timeline.toggleMarkerAtPlayhead()
795 check(store.project.markers.count == 1
796 && store.project.markers.first.map { abs($0.time - 20) < 1e-6 } == true,
797 "⇧M on a marker removes it")
798 // Click the ruler flag to park the playhead there.
799 pc.seek(to: 0)
800 timeline.mouseDown(with: mouse(.leftMouseDown, winPoint(x(20) + 3, 4)))
801 check(abs(pc.playhead - 20) < 1e-6, "clicking a marker flag seeks to it")
802 timeline.clearAllMarkers()
803 check(store.project.markers.isEmpty, "Clear All Markers empties them")
804 // Markers survive a save/load round-trip.
805 var mk = m8
806 mk.markers = [Marker(time: 12.5, label: "cut"), Marker(time: 30)]
807 let mkData = try! JSONEncoder().encode(mk)
808 let mkBack = try! JSONDecoder().decode(ProjectModel.self, from: mkData)
809 check(mkBack.markers.count == 2
810 && mkBack.markers.contains { $0.label == "cut" && abs($0.time - 12.5) < 1e-6 },
811 "markers round-trip through Codable")
812
813 // 38. Export planning: flatten-topmost, audio auto-check, fusion gaps.
814 do {
815 var em = ProjectModel(); em.fps = 30
816 var vid = MediaItem(path: "/tmp/v.mov"); vid.duration = 100; vid.fps = 30
817 vid.width = 1920; vid.height = 1080; vid.hasAudio = false
818 var aud = MediaItem(path: "/tmp/a.wav"); aud.duration = 100; aud.fps = 30
819 aud.hasAudio = true; aud.isAudio = true
820 em.media = [vid, aud]
821 // Two overlapping video tracks (top = order 0) + one audio track.
822 em.tracks = [Track(hue: 0.1), Track(hue: 0.3), Track(hue: 0.6)]
823 // tv0: [0,10). tv1: [5,20) — overlap [5,10) goes to video 0 (topmost).
824 let cv0 = Clip(mediaId: vid.id, track: .video(0), start: 0, srcIn: 0, duration: 10)
825 let cv1 = Clip(mediaId: vid.id, track: .video(1), start: 5, srcIn: 50, duration: 15)
826 let ca = Clip(mediaId: aud.id, track: .video(2), start: 0, srcIn: 0,
827 duration: 20, kind: .audio)
828 em.clips = [cv0, cv1, ca]
829 store.replaceForTest(em)
830
831 let refs: [TrackRef] = [.video(0), .video(1), .video(2)]
832 let flat = ExportPlan.flattenTopmost(project: store.project, trackRefs: refs)
833 // Expect: tv0 covers [0,10), then tv1 covers [10,20).
834 check(flat.count == 2, "flatten yields two segments across the overlap")
835 check(flat.first.map { abs($0.start) < 1e-6 && abs($0.end - 10) < 1e-6 } == true,
836 "topmost track wins the overlap region")
837 check(flat.last.map { abs($0.start - 10) < 1e-6 && abs($0.end - 20) < 1e-6
838 && abs($0.srcIn - 55) < 1e-6 } == true,
839 "lower track fills only where the top has no clip, src offset carried")
840
841 let audioClips = ExportPlan.audioClips(project: store.project, trackRefs: Set(refs))
842 check(audioClips.count == 1 && audioClips.first?.id == ca.id,
843 "audioClips picks only the audio-bearing clip")
844
845 // Fusion gap detection.
846 func fc(_ a: Int, _ b: Int) -> FusionComp {
847 FusionComp(path: "/c\(a).comp", name: "c\(a)", title: "", startFrame: a, endFrame: b)
848 }
849 check(ExportPlan.fusionCoverageGaps([fc(0, 99), fc(100, 199)]).isEmpty,
850 "contiguous comps report no gap")
851 check(ExportPlan.fusionCoverageGaps([fc(0, 99), fc(101, 199)]).first.map { $0 == (100, 100) } == true,
852 "a one-frame hole is detected")
853 check(ExportPlan.fusionCoverageGaps([fc(0, 50), fc(20, 199)]).isEmpty,
854 "overlapping comps still count as gapless")
855 }
856
857 // ---- Wave 6: file-format hardening ----
858
859 // 40. A legacy (v1, UUID-keyed) .sq migrates to the numbered model: video
860 // tracks sorted by `order` become indices; clips resolve to .video(i) or
861 // .storyboard; the float aspect becomes a concrete resolution.
862 let legacyJSON = """
863 {
864 "fps": 24,
865 "boardAspect": 1.7777777777777777,
866 "tracks": [
867 {"id":"00000000-0000-0000-0000-0000000000B1","order":1,"hue":0.5,"kind":"video"},
868 {"id":"00000000-0000-0000-0000-0000000000A0","order":0,"hue":0.2,"kind":"video"},
869 {"id":"00000000-0000-0000-0000-00000000005B","order":-1,"hue":0.13,"kind":"storyboard"}
870 ],
871 "clips": [
872 {"id":"00000000-0000-0000-0000-0000000000C0","kind":"video","trackId":"00000000-0000-0000-0000-0000000000A0","start":0,"srcIn":0,"duration":10},
873 {"id":"00000000-0000-0000-0000-0000000000C1","kind":"video","trackId":"00000000-0000-0000-0000-0000000000B1","start":0,"srcIn":0,"duration":5},
874 {"id":"00000000-0000-0000-0000-0000000000C2","kind":"storyboard","trackId":"00000000-0000-0000-0000-00000000005B","start":0,"srcIn":0,"duration":3}
875 ],
876 "media": []
877 }
878 """
879 if let doc = try? JSONDecoder().decode(SequencerDocument.self,
880 from: Data(legacyJSON.utf8)) {
881 let p = doc.project
882 let c0ref = p.clips.first { $0.id.uuidString.hasSuffix("C0") }?.track
883 let c1ref = p.clips.first { $0.id.uuidString.hasSuffix("C1") }?.track
884 let sbref = p.clips.first { $0.id.uuidString.hasSuffix("C2") }?.track
885 check(p.tracks.count == 2 && abs(p.tracks[0].hue - 0.2) < 1e-9,
886 "legacy migrate: video tracks numbered by old order")
887 check(c0ref == .video(0) && c1ref == .video(1) && sbref == .storyboard,
888 "legacy migrate: clips resolve to numbered lanes / storyboard")
889 check(p.boardHeight == 1080 && p.boardWidth == 1920,
890 "legacy migrate: float aspect → concrete resolution")
891 } else {
892 check(false, "legacy .sq decodes")
893 }
894
895 // 41. A v2 envelope round-trips project + portable view state.
896 var vdoc = SequencerDocument(project: m8, view: ViewState())
897 vdoc.view.hiddenTracks = [.video(1)]
898 vdoc.view.previewsOnLeft = true
899 if let data = try? JSONEncoder().encode(vdoc),
900 let back = try? JSONDecoder().decode(SequencerDocument.self, from: data) {
901 check(back.formatVersion == 2 && back.project.tracks.count == m8.tracks.count
902 && back.view.hiddenTracks == [.video(1)] && back.view.previewsOnLeft,
903 "v2 envelope round-trips project + view state")
904 } else {
905 check(false, "v2 envelope round-trips")
906 }
907
908 // 42. cacheKey self-heals: an empty/garbage key for a missing file becomes a
909 // stable, valid 16-hex key (never blank, never a traversal).
910 var badMedia = MediaItem(path: "/tmp/does-not-exist-\(failures).mov")
911 badMedia.cacheKey = ""
912 let healed = MediaPipeline.normalizedCacheKey(for: badMedia)
913 var traversal = badMedia
914 traversal.cacheKey = "../../etc"
915 let healed2 = MediaPipeline.normalizedCacheKey(for: traversal)
916 check(MediaPipeline.isValidCacheKey(healed) && MediaPipeline.isValidCacheKey(healed2)
917 && healed == MediaPipeline.normalizedCacheKey(for: badMedia),
918 "cacheKey self-heals to a stable valid key")
919
920 print(failures == 0 ? "\nALL PASS" : "\n\(failures) FAILURES")
921 exit(failures == 0 ? 0 : 1)
922}