| 1 | import 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 |
| 10 | func 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 | } |