@bycrux/editor 0.12.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,3358 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * SP5 T5 — every gesture the canvas timeline supports, driven as pure data.
4
+ *
5
+ * The machine takes injected points and context and returns effects, so these
6
+ * tests are the real coverage for pointer behaviour: no canvas, no jsdom
7
+ * events, no timers. `TimelineCanvas.test.tsx` covers only the wiring.
8
+ *
9
+ * Fixture (100px/second, no scroll → x = t × 100). Row Y's are derived from
10
+ * `computeTimelineLayout` rather than written out, so a change to the rendered
11
+ * row heights (as the frames/waveform split brought) retargets these gestures
12
+ * instead of silently aiming them at the gaps between rows:
13
+ *
14
+ * row track 1 — o0 (overlay) 2s–4s
15
+ * row track 0 — c0 0s–5s, c1 5s–10s (both video, 20s sources)
16
+ * lane audio a0 1s–6s, the bar inset inside the lane
17
+ *
18
+ * Snap boundaries therefore are {0, 5, 10, 2, 4, 1, 6}, and a gesture's own two
19
+ * boundaries are excluded from its magnets.
20
+ */
21
+ import { describe, it, expect } from 'vitest'
22
+ import type { Project } from '../../../../types'
23
+ import { trackFor, valueAt } from '../../../keyframeOps'
24
+ import { CAPTION_ROW_HEIGHT_PX, ROW_GAP_PX, VISUAL_ROW_HEIGHT_PX, computeDerivedTiming, trackItems } from '../../timeline-model'
25
+ import { AUDIO_ITEM_INSET_PX, computeTimelineLayout } from '../draw'
26
+ import {
27
+ CAPTION_MIN_DURATION_S,
28
+ NO_MODIFIERS,
29
+ createPointerMachine,
30
+ cursorForHit,
31
+ initialMachineState,
32
+ isAdditive,
33
+ pointerReducer,
34
+ resolveGesture,
35
+ type Modifiers,
36
+ type PointerContext,
37
+ type PointerEffect,
38
+ } from '../pointer-machine'
39
+ import { FADE_GRIP_ZONE_HEIGHT_PX, hitTest } from '../hit-test'
40
+ import type { SnapConfig } from '../snap'
41
+ import { xToTime, type Viewport } from '../viewport'
42
+
43
+ const VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
44
+
45
+ /** Zeroes every capture/release radius, so `applySnap` never magnetizes a
46
+ * candidate to a boundary (see `applySnap`'s `dist < bestStrongDist` gate —
47
+ * `bestStrongDist` starts at `attractPx`, so 0 means no distance is ever
48
+ * `< 0`). Used by the ripple-insert tests below, which need the dropped
49
+ * item to land at an EXACT, hand-computed time rather than wherever the
50
+ * magnet happens to pull it. */
51
+ const ZERO_SNAP: SnapConfig = { attractPx: 0, releasePx: 0, weakAttractPx: 0, weakReleasePx: 0 }
52
+
53
+ function baseProject(): Project {
54
+ return {
55
+ id: 'p',
56
+ tracks: [
57
+ {
58
+ id: 'trk-0',
59
+ items: [
60
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
61
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
62
+ ],
63
+ },
64
+ { id: 'trk-1', items: [{ id: 'o0', type: 'overlay', start: 2, end: 4 }] },
65
+ ],
66
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
67
+ } as unknown as Project
68
+ }
69
+
70
+ /**
71
+ * Same layout as `baseProject`, but o0 (the overlay) is keyframed: offsetX at
72
+ * t=0.5 and t=1.5, opacity at t=0.5 only — so the strip's union of times
73
+ * (plan decision 2) is {0.5, 1.5}. At 100px/s and o0.start=2, those land at
74
+ * x=250 (a diamond BOTH props share) and x=350 (offsetX alone).
75
+ */
76
+ function keyframedProject(): Project {
77
+ return {
78
+ id: 'p',
79
+ tracks: [
80
+ {
81
+ id: 'trk-0',
82
+ items: [
83
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
84
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
85
+ ],
86
+ },
87
+ {
88
+ id: 'trk-1',
89
+ items: [{
90
+ id: 'o0', type: 'overlay', start: 2, end: 4,
91
+ keyframes: [
92
+ { prop: 'offsetX', points: [{ t: 0.5, value: 0 }, { t: 1.5, value: 10 }] },
93
+ { prop: 'opacity', points: [{ t: 0.5, value: 1 }] },
94
+ ],
95
+ }],
96
+ },
97
+ ],
98
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
99
+ } as unknown as Project
100
+ }
101
+
102
+ /** Same layout as `baseProject`, but `tracks` in the pre-T6 legacy shape (a
103
+ * bare array of item arrays, no track ids). Every reader in the package
104
+ * tolerates this — but `moveItemAcrossTracks`'s two callers used to pass
105
+ * `project.tracks` straight through, so this is what an editor genuinely
106
+ * reaches for when server-side shape normalization hasn't run yet (e.g. the
107
+ * SSE stream's initial frame reads project.json off disk unmigrated). */
108
+ function _legacyProject(): Project {
109
+ return {
110
+ id: 'p',
111
+ tracks: [
112
+ [
113
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
114
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
115
+ ],
116
+ [{ id: 'o0', type: 'overlay', start: 2, end: 4 }],
117
+ ],
118
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
119
+ } as unknown as Project
120
+ }
121
+
122
+ /**
123
+ * Same layout as `baseProject`, but track 1's item is VIDEO-kind (`o0`
124
+ * keeps its id, start and end — only `type`/`src` change) instead of
125
+ * overlay. Used ONLY by the cross-track-move tests below that drag a clip
126
+ * ACROSS the track-0/track-1 boundary: those tests exist to exercise the
127
+ * search mechanics (accumulation, collision tolerance, pruning), and the
128
+ * kind-lock (`moveItemAcrossTracks`'s `kindOk`) correctly refuses to let a
129
+ * video clip land on an overlay-only track — so a same-kind neighbour is
130
+ * what lets the drag actually happen. Every other test in this file still
131
+ * exercises the real (video + overlay) `baseProject`.
132
+ */
133
+ function sameKindProject(): Project {
134
+ return {
135
+ id: 'p',
136
+ tracks: [
137
+ {
138
+ id: 'trk-0',
139
+ items: [
140
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
141
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
142
+ ],
143
+ },
144
+ { id: 'trk-1', items: [{ id: 'o0', type: 'video', src: 'o0.mp4', start: 2, end: 4, inPoint: 0, outPoint: 2, sourceDuration: 20 }] },
145
+ ],
146
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
147
+ } as unknown as Project
148
+ }
149
+
150
+ /** `sameKindProject`, legacy array-of-arrays shape — the same-kind sibling of
151
+ * `legacyProject`, for the T6-regression cross-track test. */
152
+ function sameKindLegacyProject(): Project {
153
+ return {
154
+ id: 'p',
155
+ tracks: [
156
+ [
157
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 5, inPoint: 0, outPoint: 5, sourceDuration: 20 },
158
+ { id: 'c1', type: 'video', src: 'b.mp4', start: 5, end: 10, inPoint: 2, outPoint: 7, sourceDuration: 20 },
159
+ ],
160
+ [{ id: 'o0', type: 'video', src: 'o0.mp4', start: 2, end: 4, inPoint: 0, outPoint: 2, sourceDuration: 20 }],
161
+ ],
162
+ audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 }] },
163
+ } as unknown as Project
164
+ }
165
+
166
+ function makeContext(overrides: Partial<PointerContext> = {}): PointerContext {
167
+ const project = overrides.project ?? baseProject()
168
+ const { snapBoundaries, totalDuration } = computeDerivedTiming(project)
169
+ return {
170
+ project,
171
+ layout: computeTimelineLayout(project),
172
+ viewport: VIEWPORT,
173
+ selectedIds: [],
174
+ snapBoundaries,
175
+ totalDuration,
176
+ rippleMode: false,
177
+ playheadTime: 0,
178
+ fps: 30,
179
+ ...overrides,
180
+ }
181
+ }
182
+
183
+ function mods(over: Partial<Modifiers> = {}): Modifiers {
184
+ return { ...NO_MODIFIERS, ...over }
185
+ }
186
+
187
+ /** Drive a gesture through the imperative wrapper, collecting every effect. */
188
+ class Driver {
189
+ machine = createPointerMachine()
190
+ effects: PointerEffect[] = []
191
+ constructor(public ctx: PointerContext) {}
192
+
193
+ down(x: number, y: number, modifiers = NO_MODIFIERS) {
194
+ this.effects = this.machine.dispatch({ type: 'pointerDown', point: { x, y }, modifiers, ctx: this.ctx })
195
+ return this.effects
196
+ }
197
+ move(x: number, y: number, modifiers = NO_MODIFIERS) {
198
+ this.effects = this.machine.dispatch({ type: 'pointerMove', point: { x, y }, modifiers, ctx: this.ctx })
199
+ return this.effects
200
+ }
201
+ up(x: number, y: number, modifiers = NO_MODIFIERS) {
202
+ this.effects = this.machine.dispatch({ type: 'pointerUp', point: { x, y }, modifiers, ctx: this.ctx })
203
+ return this.effects
204
+ }
205
+ doubleClick(x: number, y: number, modifiers = NO_MODIFIERS) {
206
+ this.effects = this.machine.dispatch({ type: 'doubleClick', point: { x, y }, modifiers, ctx: this.ctx })
207
+ return this.effects
208
+ }
209
+ cancel() {
210
+ this.effects = this.machine.dispatch({ type: 'cancel' })
211
+ return this.effects
212
+ }
213
+ }
214
+
215
+ function of<T extends PointerEffect['type']>(effects: PointerEffect[], type: T) {
216
+ return effects.filter(e => e.type === type) as Extract<PointerEffect, { type: T }>[]
217
+ }
218
+
219
+ function lastProjectChange(effects: PointerEffect[]): Project {
220
+ const changes = of(effects, 'projectChange')
221
+ expect(changes.length).toBeGreaterThan(0)
222
+ return changes[changes.length - 1].project
223
+ }
224
+
225
+ function visual(project: Project, id: string) {
226
+ return trackItems(project).flat().find(i => i.id === id)!
227
+ }
228
+
229
+ function audio(project: Project, id: string) {
230
+ return (project.audio?.tracks ?? []).find(t => t.id === id)!
231
+ }
232
+
233
+ function trackIndexOf(project: Project, id: string): number {
234
+ return trackItems(project).findIndex(t => t.some(i => i.id === id))
235
+ }
236
+
237
+ // Coordinates the fixture makes meaningful. The Y of each row comes from the
238
+ // layout the painter itself computes, so these stay on target at any row height.
239
+ const LAYOUT = computeTimelineLayout(baseProject())
240
+ const rowMidY = (trackIdx: number) => {
241
+ const row = LAYOUT.rows.find(r => r.trackIdx === trackIdx)!
242
+ return Math.round(row.y + row.height / 2)
243
+ }
244
+ const BASE_Y = rowMidY(0) // track 0, the tall base row
245
+ const OVERLAY_Y = rowMidY(1) // track 1, stacked above it
246
+ const LANE_Y = Math.round(LAYOUT.lanes[0].y + LAYOUT.lanes[0].height / 2)
247
+
248
+ const C0_BODY = { x: 250, y: BASE_Y }
249
+ const C0_OUT_EDGE = { x: 495, y: BASE_Y }
250
+ const C1_BODY = { x: 750, y: BASE_Y }
251
+ const C1_IN_EDGE = { x: 505, y: BASE_Y }
252
+ const A0_BODY = { x: 300, y: LANE_Y }
253
+ const A0_OUT_EDGE = { x: 597, y: LANE_Y }
254
+ // The fade grips live in the TOP `FADE_GRIP_ZONE_HEIGHT_PX` of the bar, not
255
+ // its vertical centre — `LANE_Y` above is no good for them. a0 spans 1s–6s
256
+ // with no fade set, so its corners (and therefore its un-faded grips) sit at
257
+ // x=100 (start) and x=600 (end).
258
+ const FADE_GRIP_Y = Math.round(LAYOUT.lanes[0].y + AUDIO_ITEM_INSET_PX + FADE_GRIP_ZONE_HEIGHT_PX / 2)
259
+ const A0_FADE_IN_GRIP = { x: 100, y: FADE_GRIP_Y }
260
+ const A0_FADE_OUT_GRIP = { x: 600, y: FADE_GRIP_Y }
261
+ // Keyframe diamonds live in the BOTTOM `KEYFRAME_STRIP_ZONE_HEIGHT_PX` of the
262
+ // row, not its centre — same reasoning `FADE_GRIP_Y` needed for the fade
263
+ // grip's own top zone. `keyframedProject`'s o0 (2s–4s @ 100px/s) has diamonds
264
+ // at x=250 (offsetX + opacity, shared) and x=350 (offsetX alone).
265
+ const OVERLAY_ROW = LAYOUT.rows.find(r => r.trackIdx === 1)!
266
+ const KEYFRAME_ZONE_Y = OVERLAY_ROW.y + OVERLAY_ROW.height - 2
267
+ const O0_KEYFRAME_SHARED = { x: 250, y: KEYFRAME_ZONE_Y } // t=0.5 — offsetX + opacity
268
+ const O0_KEYFRAME_SOLO = { x: 350, y: KEYFRAME_ZONE_Y } // t=1.5 — offsetX only
269
+ const EMPTY = { x: 700, y: OVERLAY_Y }
270
+ /** Inside the ruler strip — the only place a scrub starts now. */
271
+ const RULER_Y = Math.round(LAYOUT.ruler.y + LAYOUT.ruler.height / 2)
272
+
273
+ // `sameKindProject`'s own layout: track 1 being VIDEO-kind (not overlay)
274
+ // makes it the TALL row too (any video track is, not just track 0), which
275
+ // shifts track 0's Y down from `BASE_Y` above — so the cross-track-move
276
+ // tests that use `sameKindProject` need their own Y's, not the ones derived
277
+ // from `baseProject`. X's are unaffected (purely time-based) and are reused
278
+ // from `C1_BODY`/`C0_BODY` above.
279
+ const SAME_KIND_LAYOUT = computeTimelineLayout(sameKindProject())
280
+ const sameKindRowMidY = (trackIdx: number) => {
281
+ const row = SAME_KIND_LAYOUT.rows.find(r => r.trackIdx === trackIdx)!
282
+ return Math.round(row.y + row.height / 2)
283
+ }
284
+ const SAME_KIND_BASE_Y = sameKindRowMidY(0)
285
+ const SAME_KIND_TRACK1_Y = sameKindRowMidY(1)
286
+
287
+ // ── Building blocks ──────────────────────────────────────────────────────
288
+
289
+ describe('resolveGesture — the four trim-op bindings', () => {
290
+ const ctx = makeContext()
291
+ const bodyHit = hitTest(C0_BODY, ctx.layout, VIEWPORT)
292
+ const edgeHit = hitTest(C0_OUT_EDGE, ctx.layout, VIEWPORT)
293
+ const audioBodyHit = hitTest(A0_BODY, ctx.layout, VIEWPORT)
294
+ const audioEdgeHit = hitTest(A0_OUT_EDGE, ctx.layout, VIEWPORT)
295
+
296
+ it('plain edge-drag trims', () => {
297
+ expect(resolveGesture(edgeHit, mods())).toBe('trim')
298
+ })
299
+ it('Alt + edge-drag rolls', () => {
300
+ expect(resolveGesture(edgeHit, mods({ alt: true }))).toBe('roll')
301
+ })
302
+ it('plain body-drag moves', () => {
303
+ expect(resolveGesture(bodyHit, mods())).toBe('move')
304
+ })
305
+ it('Alt + body-drag slips', () => {
306
+ expect(resolveGesture(bodyHit, mods({ alt: true }))).toBe('slip')
307
+ })
308
+ it('Cmd or Ctrl + body-drag slides', () => {
309
+ expect(resolveGesture(bodyHit, mods({ meta: true }))).toBe('slide')
310
+ expect(resolveGesture(bodyHit, mods({ ctrl: true }))).toBe('slide')
311
+ })
312
+ it('shift does not change what a body-drag is', () => {
313
+ expect(resolveGesture(bodyHit, mods({ shift: true }))).toBe('move')
314
+ })
315
+ it('audio has no roll/slip/slide, so modifiers fall through', () => {
316
+ expect(resolveGesture(audioBodyHit, mods({ alt: true }))).toBe('audio-move')
317
+ expect(resolveGesture(audioBodyHit, mods({ meta: true }))).toBe('audio-move')
318
+ expect(resolveGesture(audioEdgeHit, mods({ alt: true }))).toBe('audio-trim')
319
+ })
320
+ it('drags out a marquee on empty timeline', () => {
321
+ expect(resolveGesture(hitTest(EMPTY, ctx.layout, VIEWPORT), mods())).toBe('marquee')
322
+ expect(resolveGesture(hitTest({ x: 800, y: LANE_Y }, ctx.layout, VIEWPORT), mods())).toBe('marquee')
323
+ })
324
+ })
325
+
326
+ describe('isAdditive — the DOM selection modifier test', () => {
327
+ it('is shift OR meta OR ctrl, and nothing else', () => {
328
+ expect(isAdditive(mods())).toBe(false)
329
+ expect(isAdditive(mods({ shift: true }))).toBe(true)
330
+ expect(isAdditive(mods({ meta: true }))).toBe(true)
331
+ expect(isAdditive(mods({ ctrl: true }))).toBe(true)
332
+ expect(isAdditive(mods({ alt: true }))).toBe(false)
333
+ })
334
+ })
335
+
336
+ describe('cursorForHit', () => {
337
+ const ctx = makeContext()
338
+ it('matches the DOM affordances', () => {
339
+ expect(cursorForHit(hitTest(C0_BODY, ctx.layout, VIEWPORT))).toBe('grab')
340
+ expect(cursorForHit(hitTest(C0_OUT_EDGE, ctx.layout, VIEWPORT))).toBe('ew-resize')
341
+ expect(cursorForHit(hitTest(A0_BODY, ctx.layout, VIEWPORT))).toBe('grab')
342
+ // Empty track area is the plain arrow, not a hand: there is nothing to
343
+ // click there, only a selection to drag out.
344
+ expect(cursorForHit(hitTest(EMPTY, ctx.layout, VIEWPORT))).toBe('default')
345
+ // The ruler advertises the horizontal drag that scrubbing is.
346
+ expect(cursorForHit(hitTest({ x: 400, y: RULER_Y }, ctx.layout, VIEWPORT))).toBe('ew-resize')
347
+ // A fade grip is a resize too, but DIAGONAL (it sits at a top corner), so
348
+ // you can tell it apart from an edge trim's horizontal ew-resize: the
349
+ // fade-in grip (top-left) is nwse, the fade-out grip (top-right) is nesw.
350
+ expect(cursorForHit(hitTest(A0_FADE_IN_GRIP, ctx.layout, VIEWPORT))).toBe('nwse-resize')
351
+ expect(cursorForHit(hitTest(A0_FADE_OUT_GRIP, ctx.layout, VIEWPORT))).toBe('nesw-resize')
352
+ })
353
+ })
354
+
355
+ // ── Hover ────────────────────────────────────────────────────────────────
356
+
357
+ describe('hover', () => {
358
+ it('emits a cursor only when it changes', () => {
359
+ const d = new Driver(makeContext())
360
+ expect(of(d.move(C0_BODY.x, C0_BODY.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'grab' }])
361
+ expect(d.move(C0_BODY.x + 20, C0_BODY.y)).toEqual([])
362
+ expect(of(d.move(C0_OUT_EDGE.x, C0_OUT_EDGE.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'ew-resize' }])
363
+ expect(of(d.move(EMPTY.x, EMPTY.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'default' }])
364
+ })
365
+
366
+ it('never edits anything', () => {
367
+ const d = new Driver(makeContext())
368
+ d.move(C0_BODY.x, C0_BODY.y)
369
+ expect(of(d.effects, 'projectChange')).toEqual([])
370
+ expect(of(d.effects, 'select')).toEqual([])
371
+ expect(d.machine.state.kind).toBe('idle')
372
+ })
373
+ })
374
+
375
+ // ── Click-to-seek and scrubbing ──────────────────────────────────────────
376
+
377
+ describe('scrub on the ruler', () => {
378
+ it('seeks on press and starts scrubbing', () => {
379
+ const d = new Driver(makeContext())
380
+ const effects = d.down(EMPTY.x, RULER_Y)
381
+ expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 7 }])
382
+ expect(of(effects, 'cursor')).toEqual([{ type: 'cursor', cursor: 'ew-resize' }])
383
+ expect(d.machine.state.kind).toBe('dragging')
384
+ })
385
+
386
+ it('clears the selection, so scrubbing drops what was selected', () => {
387
+ // Otherwise a clip stayed selected while you scrubbed somewhere else, and
388
+ // the next split or ripple-delete hit an item nowhere near the playhead.
389
+ const d = new Driver(makeContext())
390
+ expect(of(d.down(EMPTY.x, RULER_Y), 'select')).toEqual([{ type: 'select', id: null, additive: false }])
391
+ })
392
+
393
+ it('leaves the selection alone on an ADDITIVE press — shift builds a selection', () => {
394
+ const d = new Driver(makeContext())
395
+ expect(of(d.down(EMPTY.x, RULER_Y, mods({ shift: true })), 'select')).toEqual([])
396
+ })
397
+
398
+ it('magnetizes to a nearby boundary', () => {
399
+ const d = new Driver(makeContext())
400
+ // 5.1s is 10px from the c0/c1 cut — inside the attract radius.
401
+ expect(of(d.down(510, RULER_Y), 'seek')).toEqual([{ type: 'seek', time: 5 }])
402
+ })
403
+
404
+ it('keeps seeking as the pointer drags, with hysteresis', () => {
405
+ const d = new Driver(makeContext())
406
+ d.down(505, RULER_Y)
407
+ expect(of(d.effects, 'seek')[0].time).toBe(5)
408
+ // 5.25s: past attract but well inside release, so it stays stuck on the cut.
409
+ expect(of(d.move(525, RULER_Y), 'seek')).toEqual([{ type: 'seek', time: 5 }])
410
+ // 5.6s clears release and the playhead comes free.
411
+ expect(of(d.move(560, RULER_Y), 'seek')[0].time).toBeCloseTo(5.6)
412
+ })
413
+
414
+ it('clamps the playhead to the timeline', () => {
415
+ const d = new Driver(makeContext())
416
+ d.down(EMPTY.x, RULER_Y)
417
+ expect(of(d.move(-500, RULER_Y), 'seek')).toEqual([{ type: 'seek', time: 0 }])
418
+ expect(of(d.move(100000, RULER_Y), 'seek')[0].time).toBe(makeContext().totalDuration)
419
+ })
420
+
421
+ it('commits nothing when the scrub ends', () => {
422
+ const d = new Driver(makeContext())
423
+ d.down(EMPTY.x, RULER_Y)
424
+ d.move(EMPTY.x + 50, RULER_Y)
425
+ expect(of(d.up(EMPTY.x + 50, RULER_Y), 'commit')).toEqual([])
426
+ expect(d.machine.state.kind).toBe('idle')
427
+ })
428
+
429
+ it('keeps scrubbing when the pointer drifts above the strip', () => {
430
+ // hitTest claims everything at or above the ruler's bottom edge, so a hand
431
+ // that rises off the surface mid-drag does not drop the gesture.
432
+ const d = new Driver(makeContext())
433
+ d.down(400, RULER_Y)
434
+ expect(of(d.move(600, -30), 'seek')[0].time).toBeCloseTo(6)
435
+ })
436
+ })
437
+
438
+ describe('empty track area — click seeks, drag selects', () => {
439
+ it('does NOT seek on press: the same press may become a marquee', () => {
440
+ const d = new Driver(makeContext())
441
+ const effects = d.down(EMPTY.x, EMPTY.y)
442
+ expect(of(effects, 'seek')).toEqual([])
443
+ expect(of(effects, 'select')).toEqual([])
444
+ expect(d.machine.state.kind).toBe('pressed')
445
+ })
446
+
447
+ it('seeks and clears the selection on a click that never moved', () => {
448
+ const d = new Driver(makeContext())
449
+ d.down(EMPTY.x, EMPTY.y)
450
+ const effects = d.up(EMPTY.x, EMPTY.y)
451
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: null, additive: false }])
452
+ expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 7 }])
453
+ })
454
+
455
+ it('leaves the selection alone on an additive click', () => {
456
+ const d = new Driver(makeContext())
457
+ d.down(EMPTY.x, EMPTY.y, mods({ shift: true }))
458
+ expect(of(d.up(EMPTY.x, EMPTY.y, mods({ shift: true })), 'select')).toEqual([])
459
+ })
460
+
461
+ it('draws a marquee once the press crosses the drag threshold', () => {
462
+ const d = new Driver(makeContext())
463
+ d.down(EMPTY.x, EMPTY.y)
464
+ const effects = d.move(EMPTY.x + 60, EMPTY.y + 40)
465
+ expect(of(effects, 'marquee')).toEqual([
466
+ { type: 'marquee', rect: { x: EMPTY.x, y: EMPTY.y, width: 60, height: 40 } },
467
+ ])
468
+ expect(of(effects, 'cursor')).toEqual([{ type: 'cursor', cursor: 'crosshair' }])
469
+ // A marquee is pure selection: it must never touch the project.
470
+ expect(of(effects, 'projectChange')).toEqual([])
471
+ expect(of(effects, 'seek')).toEqual([])
472
+ })
473
+
474
+ it('normalizes a box dragged up and to the left', () => {
475
+ const d = new Driver(makeContext())
476
+ d.down(EMPTY.x, EMPTY.y)
477
+ const rect = of(d.move(EMPTY.x - 60, EMPTY.y - 10), 'marquee')[0].rect
478
+ expect(rect).toEqual({ x: EMPTY.x - 60, y: EMPTY.y - 10, width: 60, height: 10 })
479
+ })
480
+
481
+ it('selects everything the box touched, and takes the box down, on release', () => {
482
+ const d = new Driver(makeContext())
483
+ // From empty overlay-row space at x=700, back across c1 (5s-10s) on the
484
+ // base row below — a box spanning both rows.
485
+ d.down(EMPTY.x, OVERLAY_Y)
486
+ d.move(600, BASE_Y)
487
+ const effects = d.up(600, BASE_Y)
488
+ expect(of(effects, 'marquee')).toEqual([{ type: 'marquee', rect: null }])
489
+ const selected = of(effects, 'selectMany')[0]
490
+ expect(selected.ids).toContain('c1')
491
+ expect(selected.additive).toBe(false)
492
+ expect(of(effects, 'commit')).toEqual([])
493
+ })
494
+
495
+ it('extends the selection when the marquee is additive', () => {
496
+ const d = new Driver(makeContext())
497
+ d.down(EMPTY.x, OVERLAY_Y, mods({ shift: true }))
498
+ d.move(600, BASE_Y, mods({ shift: true }))
499
+ expect(of(d.up(600, BASE_Y, mods({ shift: true })), 'selectMany')[0].additive).toBe(true)
500
+ })
501
+
502
+ it('a cancelled marquee takes its box down and selects nothing', () => {
503
+ const d = new Driver(makeContext())
504
+ d.down(EMPTY.x, EMPTY.y)
505
+ d.move(EMPTY.x + 60, EMPTY.y + 40)
506
+ const effects = d.cancel()
507
+ expect(of(effects, 'marquee')).toEqual([{ type: 'marquee', rect: null }])
508
+ expect(of(effects, 'selectMany')).toEqual([])
509
+ })
510
+
511
+ it('marquees from an empty audio lane too', () => {
512
+ const d = new Driver(makeContext())
513
+ d.down(800, LANE_Y)
514
+ expect(of(d.move(860, LANE_Y), 'marquee')).toHaveLength(1)
515
+ })
516
+ })
517
+
518
+ // ── Selection ────────────────────────────────────────────────────────────
519
+
520
+ describe('selection', () => {
521
+ it('selects on release, and seeks on a plain click of an unselected clip', () => {
522
+ const d = new Driver(makeContext())
523
+ expect(d.down(C0_BODY.x, C0_BODY.y)).toEqual([{ type: 'cursor', cursor: 'grab' }])
524
+ const effects = d.up(C0_BODY.x, C0_BODY.y)
525
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'c0', additive: false }])
526
+ expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 2.5 }])
527
+ })
528
+
529
+ it('does not seek when the clip was already selected', () => {
530
+ const d = new Driver(makeContext({ selectedIds: ['c0'] }))
531
+ d.down(C0_BODY.x, C0_BODY.y)
532
+ const effects = d.up(C0_BODY.x, C0_BODY.y)
533
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'c0', additive: false }])
534
+ expect(of(effects, 'seek')).toEqual([])
535
+ })
536
+
537
+ it('does not seek on an additive click', () => {
538
+ for (const modifier of ['shift', 'meta', 'ctrl'] as const) {
539
+ const d = new Driver(makeContext())
540
+ d.down(C0_BODY.x, C0_BODY.y, mods({ [modifier]: true }))
541
+ const effects = d.up(C0_BODY.x, C0_BODY.y, mods({ [modifier]: true }))
542
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'c0', additive: true }])
543
+ expect(of(effects, 'seek')).toEqual([])
544
+ }
545
+ })
546
+
547
+ it('reads the modifier at release, as the DOM click handler does', () => {
548
+ const d = new Driver(makeContext())
549
+ d.down(C0_BODY.x, C0_BODY.y, mods())
550
+ expect(of(d.up(C0_BODY.x, C0_BODY.y, mods({ shift: true })), 'select'))
551
+ .toEqual([{ type: 'select', id: 'c0', additive: true }])
552
+ })
553
+
554
+ it('selects an audio bar without seeking', () => {
555
+ const d = new Driver(makeContext())
556
+ d.down(A0_BODY.x, A0_BODY.y)
557
+ const effects = d.up(A0_BODY.x, A0_BODY.y)
558
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'a0', additive: false }])
559
+ expect(of(effects, 'seek')).toEqual([])
560
+ })
561
+
562
+ it('selects from a press on a trim handle that never became a drag', () => {
563
+ const d = new Driver(makeContext())
564
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
565
+ const effects = d.up(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
566
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: 'c0', additive: false }])
567
+ // …and does NOT persist a project that never changed.
568
+ expect(of(effects, 'commit')).toEqual([])
569
+ })
570
+
571
+ it('does not select when the press turned into a drag', () => {
572
+ const d = new Driver(makeContext())
573
+ d.down(C0_BODY.x, C0_BODY.y)
574
+ d.move(C0_BODY.x + 60, C0_BODY.y)
575
+ expect(of(d.up(C0_BODY.x + 60, C0_BODY.y), 'select')).toEqual([])
576
+ })
577
+ })
578
+
579
+ // ── Drag threshold ───────────────────────────────────────────────────────
580
+
581
+ describe('drag threshold', () => {
582
+ it('ignores travel under 4px and still counts as a click', () => {
583
+ const d = new Driver(makeContext())
584
+ d.down(C0_BODY.x, C0_BODY.y)
585
+ expect(d.move(C0_BODY.x + 2, C0_BODY.y + 1)).toEqual([]) // hypot ≈ 2.2
586
+ expect(d.move(C0_BODY.x + 3, C0_BODY.y)).toEqual([])
587
+ expect(d.machine.state.kind).toBe('pressed')
588
+ expect(of(d.up(C0_BODY.x + 3, C0_BODY.y), 'select')).toHaveLength(1)
589
+ })
590
+
591
+ it('becomes a drag at 4px, counting diagonal travel', () => {
592
+ const d = new Driver(makeContext())
593
+ d.down(C0_BODY.x, C0_BODY.y)
594
+ expect(of(d.move(C0_BODY.x + 4, C0_BODY.y), 'projectChange')).toHaveLength(1)
595
+ expect(d.machine.state.kind).toBe('dragging')
596
+ })
597
+
598
+ it('applies to vertical travel too', () => {
599
+ const d = new Driver(makeContext())
600
+ d.down(C0_BODY.x, C0_BODY.y)
601
+ expect(d.move(C0_BODY.x, C0_BODY.y + 3)).toEqual([])
602
+ expect(of(d.move(C0_BODY.x, C0_BODY.y + 5), 'projectChange')).toHaveLength(1)
603
+ })
604
+ })
605
+
606
+ // ── Item body drag (move) ────────────────────────────────────────────────
607
+
608
+ describe('body drag — move', () => {
609
+ it('moves the clip and snaps its leading edge', () => {
610
+ const d = new Driver(makeContext())
611
+ d.down(C0_BODY.x, C0_BODY.y)
612
+ const moved = visual(lastProjectChange(d.move(C0_BODY.x + 100, C0_BODY.y)), 'c0')
613
+ expect(moved.start).toBeCloseTo(1)
614
+ expect(moved.end).toBeCloseTo(6)
615
+ })
616
+
617
+ it('is not held back by the position it started from', () => {
618
+ // c0's own 0s/5s boundaries are excluded from its magnets, so a 20px nudge
619
+ // moves it rather than sticking it to the origin.
620
+ const d = new Driver(makeContext())
621
+ d.down(C0_BODY.x, C0_BODY.y)
622
+ expect(visual(lastProjectChange(d.move(C0_BODY.x + 20, C0_BODY.y)), 'c0').start).toBeCloseTo(0.2)
623
+ })
624
+
625
+ it('clamps to the timeline at both ends', () => {
626
+ const ctx = makeContext()
627
+ const d = new Driver(ctx)
628
+ d.down(C0_BODY.x, C0_BODY.y)
629
+ expect(visual(lastProjectChange(d.move(-9999, C0_BODY.y)), 'c0').start).toBe(0)
630
+ const far = visual(lastProjectChange(d.move(99999, C0_BODY.y)), 'c0')
631
+ expect(far.end).toBeCloseTo(ctx.totalDuration)
632
+ })
633
+
634
+ it('emits one projectChange per move and one commit at release', () => {
635
+ const d = new Driver(makeContext())
636
+ d.down(C0_BODY.x, C0_BODY.y)
637
+ expect(of(d.move(C0_BODY.x + 50, C0_BODY.y), 'projectChange')).toHaveLength(1)
638
+ expect(of(d.move(C0_BODY.x + 60, C0_BODY.y), 'projectChange')).toHaveLength(1)
639
+ expect(of(d.move(C0_BODY.x + 70, C0_BODY.y), 'projectChange')).toHaveLength(1)
640
+ const committed = of(d.up(C0_BODY.x + 70, C0_BODY.y), 'commit')
641
+ expect(committed).toHaveLength(1)
642
+ expect(visual(committed[0].project, 'c0').start).toBeCloseTo(0.7)
643
+ })
644
+
645
+ it('accumulates across moves so the cross-track search sees its own work', () => {
646
+ // `sameKindProject`: track 1 must be video-kind (not overlay) for this
647
+ // move to land at all — see the fixture's own doc comment.
648
+ const d = new Driver(makeContext({ project: sameKindProject() }))
649
+ d.down(C1_BODY.x, SAME_KIND_BASE_Y)
650
+ d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX) // up one track
651
+ const after = lastProjectChange(d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
652
+ expect(trackIndexOf(after, 'c1')).toBe(1)
653
+ })
654
+
655
+ it('switches the cursor to grabbing', () => {
656
+ const d = new Driver(makeContext())
657
+ d.down(C0_BODY.x, C0_BODY.y)
658
+ expect(of(d.move(C0_BODY.x + 50, C0_BODY.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'grabbing' }])
659
+ })
660
+ })
661
+
662
+ describe('a viewport that pans mid-gesture — edge auto-scroll tracking', () => {
663
+ // Edge auto-scroll pans the view while the pointer is HELD in the edge band,
664
+ // re-feeding the machine that same screen point on every frame (see
665
+ // `edgeAutoScrollTick` in TimelineCanvas). Nothing about the pointer moves —
666
+ // only the viewport under it does — so a gesture that measured its travel in
667
+ // raw pixels would report no movement at all for those frames and leave what
668
+ // it is dragging parked at one time while the timeline slid out from under
669
+ // the cursor. Travel is measured in timeline time for exactly this reason.
670
+ const PANNED: Viewport = { ...VIEWPORT, scrollSeconds: 2 }
671
+
672
+ it('carries a moving clip along with the pan, keeping it under the cursor', () => {
673
+ const d = new Driver(makeContext({ snapConfig: ZERO_SNAP }))
674
+ d.down(C0_BODY.x, C0_BODY.y)
675
+ const held = { x: C0_BODY.x + 100, y: C0_BODY.y }
676
+ expect(visual(lastProjectChange(d.move(held.x, held.y)), 'c0').start).toBeCloseTo(1)
677
+
678
+ // The view pans 2s to the right. The pointer does not move.
679
+ d.ctx = makeContext({ snapConfig: ZERO_SNAP, viewport: PANNED })
680
+ const panned = visual(lastProjectChange(d.move(held.x, held.y)), 'c0')
681
+ expect(panned.start).toBeCloseTo(3)
682
+ expect(panned.end).toBeCloseTo(8)
683
+
684
+ // The invariant that actually matters, stated directly: the cursor still
685
+ // sits the same 2.5s into the clip it grabbed at press, so the clip is
686
+ // still under the hand holding it.
687
+ expect(xToTime(held.x, PANNED) - panned.start).toBeCloseTo(2.5)
688
+ })
689
+
690
+ it('carries a trimmed edge along with the pan too', () => {
691
+ // Not just moves: every horizontal gesture shares the delta, so a trim held
692
+ // in the edge band tracks the same way.
693
+ const d = new Driver(makeContext({ snapConfig: ZERO_SNAP }))
694
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
695
+ const held = { x: C0_OUT_EDGE.x - 100, y: C0_OUT_EDGE.y }
696
+ expect(visual(lastProjectChange(d.move(held.x, held.y)), 'c0').end).toBeCloseTo(4)
697
+
698
+ d.ctx = makeContext({ snapConfig: ZERO_SNAP, viewport: PANNED })
699
+ expect(visual(lastProjectChange(d.move(held.x, held.y)), 'c0').end).toBeCloseTo(6)
700
+ })
701
+
702
+ it('leaves a gesture alone when the viewport holds still', () => {
703
+ // The guard on the change above: anchoring in time must not alter the
704
+ // ordinary case, where scroll never moves and a pixel delta and a time
705
+ // delta agree exactly.
706
+ const d = new Driver(makeContext({ snapConfig: ZERO_SNAP }))
707
+ d.down(C0_BODY.x, C0_BODY.y)
708
+ expect(visual(lastProjectChange(d.move(C0_BODY.x + 250, C0_BODY.y)), 'c0').start).toBeCloseTo(2.5)
709
+ })
710
+ })
711
+
712
+ describe('cross-track move', () => {
713
+ it('moves a clip up a track when the vertical travel points there', () => {
714
+ // `sameKindProject`: track 1 is video-kind here so the kind-lock doesn't
715
+ // block the very move this test is about — see its own doc comment.
716
+ const d = new Driver(makeContext({ project: sameKindProject() }))
717
+ d.down(C1_BODY.x, SAME_KIND_BASE_Y)
718
+ // 24px of upward travel is one track; c1 (5s–10s) doesn't collide with the
719
+ // video o0 (2s–4s), so it lands on track 1.
720
+ const after = lastProjectChange(d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
721
+ expect(trackIndexOf(after, 'c1')).toBe(1)
722
+ expect(visual(after, 'c1').start).toBeCloseTo(5)
723
+ })
724
+
725
+ it('stays where it is when the target track is occupied', () => {
726
+ const d = new Driver(makeContext())
727
+ d.down(C0_BODY.x, C0_BODY.y)
728
+ // c0 (0s–5s) would overlap o0 (2s–4s) by 2s, past the 30%-of-duration
729
+ // tolerance, so the outward search falls back to its own track.
730
+ const after = lastProjectChange(d.move(C0_BODY.x, C0_BODY.y - VISUAL_ROW_HEIGHT_PX))
731
+ expect(trackIndexOf(after, 'c0')).toBe(0)
732
+ })
733
+
734
+ it('tolerates a brush past a neighbour', () => {
735
+ // A 5s clip may overlap by up to 1.5s. Drag c1 up while shifting it left so
736
+ // it overlaps o0 (2s–4s) by only 1s. `sameKindProject`: track 1 is
737
+ // video-kind so the kind-lock doesn't block the move before the overlap
738
+ // tolerance this test is actually about ever gets exercised.
739
+ const d = new Driver(makeContext({ project: sameKindProject() }))
740
+ d.down(C1_BODY.x, SAME_KIND_BASE_Y)
741
+ const after = lastProjectChange(d.move(C1_BODY.x - 200, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
742
+ expect(visual(after, 'c1').start).toBeCloseTo(3)
743
+ expect(trackIndexOf(after, 'c1')).toBe(1)
744
+ })
745
+
746
+ it('mints a new video track at the top of the video block, below any overlay track (Part B track grouping)', () => {
747
+ // Two steps up from track 0 mints a new track past the top of the
748
+ // existing (2-track) stack — but the result is RE-GROUPED: the new
749
+ // track is video-kind, so it joins the video block (alongside the
750
+ // remaining c1) ahead of the overlay track o0, rather than staying
751
+ // "literally on top" at index 2 the way an un-grouped mint would.
752
+ const d = new Driver(makeContext())
753
+ d.down(C0_BODY.x, C0_BODY.y)
754
+ const after = lastProjectChange(d.move(C0_BODY.x, C0_BODY.y - VISUAL_ROW_HEIGHT_PX * 2))
755
+ expect(after.tracks).toHaveLength(3)
756
+ expect(trackIndexOf(after, 'c0')).toBe(1)
757
+ })
758
+
759
+ it('prunes a track the move emptied', () => {
760
+ // `sameKindProject`: o0 must be video-kind here — track 0 (its
761
+ // destination) is video-only, so an overlay `o0` could never land there
762
+ // and this test's own mechanic (the emptied track gets pruned) would
763
+ // never get to run.
764
+ const d = new Driver(makeContext({ project: sameKindProject() }))
765
+ d.down(300, SAME_KIND_TRACK1_Y) // o0, the only item on track 1
766
+ // Down two tracks and out past the end of the base track's content, where
767
+ // nothing collides — so track 1 is left empty and disappears.
768
+ const after = lastProjectChange(d.move(300 + 900, SAME_KIND_TRACK1_Y + 48))
769
+ expect(visual(after, 'o0').start).toBeCloseTo(11)
770
+ expect(after.tracks).toHaveLength(1)
771
+ expect(trackIndexOf(after, 'o0')).toBe(0)
772
+ })
773
+
774
+ it('survives a cross-track drag on a legacy-shape project (T6 regression)', () => {
775
+ // Before the T6 fix, applyMove passed `lastProject.tracks` straight to
776
+ // moveItemAcrossTracks, which does `t.items.filter(...)` on every track —
777
+ // a crash on a bare array. The call site now normalizes defensively, so
778
+ // this must behave identically to the object-shape test above.
779
+ // `sameKindLegacyProject`: same video-kind track 1 as `sameKindProject`,
780
+ // just in the legacy array-of-arrays shape this test targets.
781
+ const d = new Driver(makeContext({ project: sameKindLegacyProject() }))
782
+ d.down(C1_BODY.x, SAME_KIND_BASE_Y)
783
+ const after = lastProjectChange(d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
784
+ expect(trackIndexOf(after, 'c1')).toBe(1)
785
+ expect(visual(after, 'c1').start).toBeCloseTo(5)
786
+ })
787
+ })
788
+
789
+ describe('cross-track move — ripple mode (makeSpace)', () => {
790
+ // `sameKindProject`: track 1 is video-kind here so the kind-lock doesn't
791
+ // block the moves these tests are actually about — see its own doc
792
+ // comment. `ZERO_SNAP` makes the dropped position land exactly on the
793
+ // hand-computed number rather than wherever the magnet pulls it, since the
794
+ // point of these tests is the ripple push amount, not snap behaviour.
795
+ it('ripple-inserts into the target track instead of bumping to another one when the drop collides', () => {
796
+ const d = new Driver(makeContext({ project: sameKindProject(), rippleMode: true, snapConfig: ZERO_SNAP }))
797
+ d.down(C1_BODY.x, SAME_KIND_BASE_Y)
798
+ // c1 (5s–10s) moves up one track and 4s left, landing at 1s–6s — a 2s
799
+ // overlap with o0 (2s–4s), past the 30%-of-5s tolerance.
800
+ const after = lastProjectChange(d.move(C1_BODY.x - 400, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
801
+ expect(trackIndexOf(after, 'c1')).toBe(1)
802
+ expect(visual(after, 'c1')).toMatchObject({ start: 1, end: 6 })
803
+ // o0 is pushed right by c1's own 5s duration rather than the drag
804
+ // bumping to a different track.
805
+ expect(visual(after, 'o0')).toMatchObject({ start: 7, end: 9 })
806
+ expect(after.tracks).toHaveLength(2)
807
+ })
808
+
809
+ it('bumps to a different track for the identical drag when magnet is off (contrast)', () => {
810
+ const d = new Driver(makeContext({ project: sameKindProject(), rippleMode: false, snapConfig: ZERO_SNAP }))
811
+ d.down(C1_BODY.x, SAME_KIND_BASE_Y)
812
+ const after = lastProjectChange(d.move(C1_BODY.x - 400, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
813
+ // Track 1 collides (o0) and, at this dropped position, so does track 0
814
+ // (c0, 0s-5s, overlapped by 4s), so the outward search mints a fresh
815
+ // track above both rather than landing on either — and o0 is left
816
+ // completely untouched.
817
+ expect(trackIndexOf(after, 'c1')).toBe(2)
818
+ expect(after.tracks).toHaveLength(3)
819
+ expect(visual(after, 'o0')).toMatchObject({ start: 2, end: 4 })
820
+ })
821
+
822
+ it('drops into a fitting gap unchanged when magnet is on — nothing to push', () => {
823
+ const d = new Driver(makeContext({ project: sameKindProject(), rippleMode: true, snapConfig: ZERO_SNAP }))
824
+ d.down(C1_BODY.x, SAME_KIND_BASE_Y)
825
+ // Purely vertical travel: c1 keeps its own 5s–10s window, which doesn't
826
+ // overlap o0 (2s–4s) at all.
827
+ const after = lastProjectChange(d.move(C1_BODY.x, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX))
828
+ expect(trackIndexOf(after, 'c1')).toBe(1)
829
+ expect(visual(after, 'c1')).toMatchObject({ start: 5, end: 10 })
830
+ expect(visual(after, 'o0')).toMatchObject({ start: 2, end: 4 })
831
+ })
832
+
833
+ it('keeps the kind-lock in ripple mode — a video clip never lands on the overlay track', () => {
834
+ // `baseProject`: track 1 is genuinely overlay-kind here (unlike
835
+ // `sameKindProject`), which is exactly what this test needs.
836
+ const d = new Driver(makeContext({ rippleMode: true, snapConfig: ZERO_SNAP }))
837
+ d.down(C0_BODY.x, C0_BODY.y)
838
+ // One track up targets track 1 (the overlay track); even though makeSpace
839
+ // is on, the kind gate must still refuse it.
840
+ const after = lastProjectChange(d.move(C0_BODY.x, C0_BODY.y - VISUAL_ROW_HEIGHT_PX))
841
+ expect(trackIndexOf(after, 'c0')).toBe(0)
842
+ expect(visual(after, 'o0')).toMatchObject({ start: 2, end: 4 })
843
+ })
844
+ })
845
+
846
+ // ── Edge trims ───────────────────────────────────────────────────────────
847
+
848
+ describe('edge trim', () => {
849
+ it('shortens a clip and walks its outPoint with it', () => {
850
+ const d = new Driver(makeContext())
851
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
852
+ const trimmed = visual(lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y)), 'c0')
853
+ expect(trimmed.end).toBeCloseTo(4)
854
+ expect(trimmed.outPoint).toBeCloseTo(4)
855
+ expect(trimmed.start).toBe(0)
856
+ expect(trimmed.inPoint).toBe(0)
857
+ })
858
+
859
+ it('walks the inPoint on an in-edge trim', () => {
860
+ const d = new Driver(makeContext())
861
+ d.down(C1_IN_EDGE.x, C1_IN_EDGE.y)
862
+ const trimmed = visual(lastProjectChange(d.move(C1_IN_EDGE.x + 100, C1_IN_EDGE.y)), 'c1')
863
+ expect(trimmed.start).toBeCloseTo(6)
864
+ expect(trimmed.inPoint).toBeCloseTo(3)
865
+ expect(trimmed.end).toBe(10)
866
+ })
867
+
868
+ it('recomputes from the pressed-at project rather than compounding', () => {
869
+ const d = new Driver(makeContext())
870
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
871
+ d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y)
872
+ d.move(C0_OUT_EDGE.x - 200, C0_OUT_EDGE.y)
873
+ // Back to where it started: the clip is back to its original length, which
874
+ // could not happen if each move applied its delta to the previous result.
875
+ const back = visual(lastProjectChange(d.move(C0_OUT_EDGE.x, C0_OUT_EDGE.y)), 'c0')
876
+ expect(back.end).toBeCloseTo(5)
877
+ expect(back.outPoint).toBeCloseTo(5)
878
+ })
879
+
880
+ it('keeps a clip at least 0.1s long', () => {
881
+ const d = new Driver(makeContext())
882
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
883
+ expect(visual(lastProjectChange(d.move(-9999, C0_OUT_EDGE.y)), 'c0').end).toBeCloseTo(0.1)
884
+ })
885
+
886
+ it('shows the resize cursor while trimming', () => {
887
+ const d = new Driver(makeContext())
888
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
889
+ expect(of(d.move(C0_OUT_EDGE.x - 50, C0_OUT_EDGE.y), 'cursor')).toEqual([]) // already ew-resize
890
+ expect(d.machine.state.cursor).toBe('ew-resize')
891
+ })
892
+
893
+ it('commits once at release', () => {
894
+ const d = new Driver(makeContext())
895
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
896
+ d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y)
897
+ const committed = of(d.up(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y), 'commit')
898
+ expect(committed).toHaveLength(1)
899
+ expect(visual(committed[0].project, 'c0').end).toBeCloseTo(4)
900
+ })
901
+
902
+ it('propagates the delta across a multi-selection', () => {
903
+ const d = new Driver(makeContext({ selectedIds: ['c0', 'o0'] }))
904
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
905
+ const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y))
906
+ expect(visual(next, 'c0').end).toBeCloseTo(4)
907
+ expect(visual(next, 'o0').end).toBeCloseTo(3) // 4s − 1s
908
+ })
909
+
910
+ it('leaves unselected items alone when only one clip is selected', () => {
911
+ const d = new Driver(makeContext({ selectedIds: ['c0'] }))
912
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
913
+ const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y))
914
+ expect(visual(next, 'o0').end).toBe(4)
915
+ })
916
+ })
917
+
918
+ describe('edge trim — ripple mode', () => {
919
+ it('closes the gap a trim opens', () => {
920
+ const d = new Driver(makeContext({ rippleMode: true }))
921
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
922
+ const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y))
923
+ expect(visual(next, 'c0').end).toBeCloseTo(4)
924
+ expect(visual(next, 'c1').start).toBeCloseTo(4) // pulled back by 1s
925
+ expect(visual(next, 'c1').end).toBeCloseTo(9)
926
+ })
927
+
928
+ it('leaves the gap open with ripple off', () => {
929
+ const d = new Driver(makeContext({ rippleMode: false }))
930
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
931
+ const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y))
932
+ expect(visual(next, 'c1').start).toBe(5)
933
+ })
934
+
935
+ it('does not ripple a body move', () => {
936
+ const d = new Driver(makeContext({ rippleMode: true }))
937
+ d.down(C0_BODY.x, C0_BODY.y)
938
+ const next = lastProjectChange(d.move(C0_BODY.x + 100, C0_BODY.y))
939
+ expect(visual(next, 'c1').start).toBe(5)
940
+ })
941
+ })
942
+
943
+ // ── Alt / Cmd trim ops ───────────────────────────────────────────────────
944
+
945
+ describe('Alt + edge-drag — roll', () => {
946
+ it('moves the shared boundary, leaving both clips in place', () => {
947
+ const d = new Driver(makeContext())
948
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y, mods({ alt: true }))
949
+ const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y, mods({ alt: true })))
950
+ expect(visual(next, 'c0').end).toBeCloseTo(4)
951
+ expect(visual(next, 'c0').outPoint).toBeCloseTo(4)
952
+ expect(visual(next, 'c0').start).toBe(0)
953
+ expect(visual(next, 'c1').start).toBeCloseTo(4)
954
+ expect(visual(next, 'c1').inPoint).toBeCloseTo(1)
955
+ expect(visual(next, 'c1').end).toBe(10) // the pair's span is unchanged
956
+ })
957
+
958
+ it('rolls the same boundary from either side', () => {
959
+ const d = new Driver(makeContext())
960
+ d.down(C1_IN_EDGE.x, C1_IN_EDGE.y, mods({ alt: true }))
961
+ const next = lastProjectChange(d.move(C1_IN_EDGE.x - 100, C1_IN_EDGE.y, mods({ alt: true })))
962
+ expect(visual(next, 'c0').end).toBeCloseTo(4)
963
+ expect(visual(next, 'c1').start).toBeCloseTo(4)
964
+ })
965
+
966
+ it('does nothing when there is no adjacent neighbour', () => {
967
+ const d = new Driver(makeContext())
968
+ // c0's in edge has nothing before it.
969
+ d.down(5, 60, mods({ alt: true }))
970
+ expect(of(d.move(100, 60, mods({ alt: true })), 'projectChange')).toEqual([])
971
+ expect(of(d.up(100, 60, mods({ alt: true })), 'commit')).toEqual([])
972
+ })
973
+
974
+ it('reads the modifier at press, so releasing Alt mid-drag keeps rolling', () => {
975
+ const d = new Driver(makeContext())
976
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y, mods({ alt: true }))
977
+ const next = lastProjectChange(d.move(C0_OUT_EDGE.x - 100, C0_OUT_EDGE.y, mods()))
978
+ expect(visual(next, 'c1').start).toBeCloseTo(4)
979
+ })
980
+ })
981
+
982
+ describe('Alt + body-drag — slip', () => {
983
+ it('moves the source window while the clip stays put', () => {
984
+ const d = new Driver(makeContext())
985
+ d.down(C1_BODY.x, C1_BODY.y, mods({ alt: true }))
986
+ const slipped = visual(lastProjectChange(d.move(C1_BODY.x + 100, C1_BODY.y, mods({ alt: true }))), 'c1')
987
+ // Dragging right pulls the media right, bringing EARLIER frames into view.
988
+ expect(slipped.inPoint).toBeCloseTo(1)
989
+ expect(slipped.outPoint).toBeCloseTo(6)
990
+ expect(slipped.start).toBe(5)
991
+ expect(slipped.end).toBe(10)
992
+ })
993
+
994
+ it('slips the other way when dragged left', () => {
995
+ const d = new Driver(makeContext())
996
+ d.down(C1_BODY.x, C1_BODY.y, mods({ alt: true }))
997
+ const slipped = visual(lastProjectChange(d.move(C1_BODY.x - 100, C1_BODY.y, mods({ alt: true }))), 'c1')
998
+ expect(slipped.inPoint).toBeCloseTo(3)
999
+ expect(slipped.outPoint).toBeCloseTo(8)
1000
+ })
1001
+
1002
+ it('clamps at the start of the source media', () => {
1003
+ const d = new Driver(makeContext())
1004
+ d.down(C1_BODY.x, C1_BODY.y, mods({ alt: true }))
1005
+ const slipped = visual(lastProjectChange(d.move(C1_BODY.x + 9999, C1_BODY.y, mods({ alt: true }))), 'c1')
1006
+ expect(slipped.inPoint).toBe(0)
1007
+ expect(slipped.outPoint).toBe(5)
1008
+ })
1009
+
1010
+ it('emits nothing for a clip with no source window to slip', () => {
1011
+ const d = new Driver(makeContext())
1012
+ d.down(300, OVERLAY_Y, mods({ alt: true })) // o0, an overlay
1013
+ expect(of(d.move(400, OVERLAY_Y, mods({ alt: true })), 'projectChange')).toEqual([])
1014
+ })
1015
+ })
1016
+
1017
+ describe('Cmd/Ctrl + body-drag — slide', () => {
1018
+ it('moves the clip and lets its neighbour absorb it', () => {
1019
+ const d = new Driver(makeContext())
1020
+ d.down(C1_BODY.x, C1_BODY.y, mods({ meta: true }))
1021
+ const next = lastProjectChange(d.move(C1_BODY.x + 100, C1_BODY.y, mods({ meta: true })))
1022
+ expect(visual(next, 'c1').start).toBeCloseTo(6)
1023
+ expect(visual(next, 'c1').end).toBeCloseTo(11)
1024
+ expect(visual(next, 'c1').inPoint).toBe(2) // the source window is untouched
1025
+ expect(visual(next, 'c0').end).toBeCloseTo(6) // the neighbour grew
1026
+ expect(visual(next, 'c0').outPoint).toBeCloseTo(6)
1027
+ expect(visual(next, 'c0').start).toBe(0)
1028
+ })
1029
+
1030
+ it('works with ctrl as well as meta', () => {
1031
+ const d = new Driver(makeContext())
1032
+ d.down(C1_BODY.x, C1_BODY.y, mods({ ctrl: true }))
1033
+ expect(visual(lastProjectChange(d.move(C1_BODY.x + 100, C1_BODY.y, mods({ ctrl: true }))), 'c1').start)
1034
+ .toBeCloseTo(6)
1035
+ })
1036
+
1037
+ it('clamps rather than crushing the neighbour below its minimum', () => {
1038
+ const d = new Driver(makeContext())
1039
+ d.down(C1_BODY.x, C1_BODY.y, mods({ meta: true }))
1040
+ const next = lastProjectChange(d.move(C1_BODY.x - 9999, C1_BODY.y, mods({ meta: true })))
1041
+ expect(visual(next, 'c0').end - visual(next, 'c0').start).toBeCloseTo(0.1)
1042
+ })
1043
+ })
1044
+
1045
+ // ── Audio ────────────────────────────────────────────────────────────────
1046
+
1047
+ describe('audio bar drag', () => {
1048
+ it('repositions the bar', () => {
1049
+ const d = new Driver(makeContext())
1050
+ d.down(A0_BODY.x, A0_BODY.y)
1051
+ const moved = audio(lastProjectChange(d.move(A0_BODY.x + 100, A0_BODY.y)), 'a0')
1052
+ expect(moved.start).toBeCloseTo(2)
1053
+ expect(moved.end).toBeCloseTo(7)
1054
+ expect(moved.lane).toBe(0)
1055
+ })
1056
+
1057
+ it('moves the bar down a lane on downward travel', () => {
1058
+ const d = new Driver(makeContext())
1059
+ d.down(A0_BODY.x, A0_BODY.y)
1060
+ expect(audio(lastProjectChange(d.move(A0_BODY.x, A0_BODY.y + 40)), 'a0').lane).toBe(1)
1061
+ })
1062
+
1063
+ it('never goes above lane 0', () => {
1064
+ const d = new Driver(makeContext())
1065
+ d.down(A0_BODY.x, A0_BODY.y)
1066
+ expect(audio(lastProjectChange(d.move(A0_BODY.x, A0_BODY.y - 200)), 'a0').lane).toBe(0)
1067
+ })
1068
+
1069
+ it('commits once at release', () => {
1070
+ const d = new Driver(makeContext())
1071
+ d.down(A0_BODY.x, A0_BODY.y)
1072
+ d.move(A0_BODY.x + 100, A0_BODY.y)
1073
+ expect(of(d.up(A0_BODY.x + 100, A0_BODY.y), 'commit')).toHaveLength(1)
1074
+ })
1075
+ })
1076
+
1077
+ describe('audio bar trim', () => {
1078
+ it('extends the bar and its source window', () => {
1079
+ const d = new Driver(makeContext())
1080
+ d.down(A0_OUT_EDGE.x, A0_OUT_EDGE.y)
1081
+ const trimmed = audio(lastProjectChange(d.move(A0_OUT_EDGE.x + 100, A0_OUT_EDGE.y)), 'a0')
1082
+ expect(trimmed.end).toBeCloseTo(7)
1083
+ expect(trimmed.outPoint).toBeCloseTo(6)
1084
+ expect(trimmed.inPoint).toBe(0)
1085
+ })
1086
+
1087
+ it('walks the inPoint on an in-edge trim', () => {
1088
+ const d = new Driver(makeContext())
1089
+ d.down(103, LANE_Y)
1090
+ const trimmed = audio(lastProjectChange(d.move(203, LANE_Y)), 'a0')
1091
+ expect(trimmed.start).toBeCloseTo(2)
1092
+ expect(trimmed.inPoint).toBeCloseTo(1)
1093
+ })
1094
+
1095
+ it('never trims audio with ripple, even when ripple mode is on', () => {
1096
+ const d = new Driver(makeContext({ rippleMode: true }))
1097
+ d.down(A0_OUT_EDGE.x, A0_OUT_EDGE.y)
1098
+ const next = lastProjectChange(d.move(A0_OUT_EDGE.x - 100, A0_OUT_EDGE.y))
1099
+ expect(visual(next, 'c1').start).toBe(5)
1100
+ })
1101
+ })
1102
+
1103
+ describe('audio fade drag', () => {
1104
+ // `baseProject`'s a0 with a fade already set on one side — used by the
1105
+ // clamp/remove tests below, which need a non-zero starting value to drag
1106
+ // away from. Same track id/span, so `FADE_GRIP_Y` and the shared `LAYOUT`
1107
+ // still apply: fade values don't move the lane geometry.
1108
+ function withFadeOut(fadeOut: number): Project {
1109
+ const p = baseProject()
1110
+ return { ...p, audio: { tracks: [{ ...p.audio!.tracks[0], fadeOut }] } }
1111
+ }
1112
+ function withFadeIn(fadeIn: number): Project {
1113
+ const p = baseProject()
1114
+ return { ...p, audio: { tracks: [{ ...p.audio!.tracks[0], fadeIn }] } }
1115
+ }
1116
+
1117
+ it('sets fadeIn dragging the fade-in grip inward (right)', () => {
1118
+ const d = new Driver(makeContext())
1119
+ d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
1120
+ const faded = audio(lastProjectChange(d.move(A0_FADE_IN_GRIP.x + 100, A0_FADE_IN_GRIP.y)), 'a0')
1121
+ expect(faded.fadeIn).toBeCloseTo(1)
1122
+ expect(faded.fadeOut ?? 0).toBe(0)
1123
+ })
1124
+
1125
+ it('sets fadeOut dragging the fade-out grip inward (left)', () => {
1126
+ const d = new Driver(makeContext())
1127
+ d.down(A0_FADE_OUT_GRIP.x, A0_FADE_OUT_GRIP.y)
1128
+ const faded = audio(lastProjectChange(d.move(A0_FADE_OUT_GRIP.x - 150, A0_FADE_OUT_GRIP.y)), 'a0')
1129
+ expect(faded.fadeOut).toBeCloseTo(1.5)
1130
+ expect(faded.fadeIn ?? 0).toBe(0)
1131
+ })
1132
+
1133
+ it('clamps fadeIn at zero — dragging outward past the corner cannot go negative', () => {
1134
+ const d = new Driver(makeContext())
1135
+ d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
1136
+ const faded = audio(lastProjectChange(d.move(A0_FADE_IN_GRIP.x - 100, A0_FADE_IN_GRIP.y)), 'a0')
1137
+ expect(faded.fadeIn).toBe(0)
1138
+ })
1139
+
1140
+ it('removes an existing fadeIn when dragged back past the corner', () => {
1141
+ // fadeIn:2 puts the grip at t=1+2=3 → x=300.
1142
+ const d = new Driver(makeContext({ project: withFadeIn(2) }))
1143
+ d.down(300, FADE_GRIP_Y)
1144
+ const faded = audio(lastProjectChange(d.move(300 - 500, FADE_GRIP_Y)), 'a0')
1145
+ expect(faded.fadeIn).toBe(0)
1146
+ })
1147
+
1148
+ it('clamps so fadeIn + fadeOut never exceeds the bar\'s duration', () => {
1149
+ // duration is 5s (1s–6s); fadeOut is already 3s, so fadeIn can grow to at
1150
+ // most 2s no matter how far the grip is dragged.
1151
+ const d = new Driver(makeContext({ project: withFadeOut(3) }))
1152
+ d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
1153
+ const faded = audio(lastProjectChange(d.move(A0_FADE_IN_GRIP.x + 1000, A0_FADE_IN_GRIP.y)), 'a0')
1154
+ expect(faded.fadeIn).toBeCloseTo(2)
1155
+ expect(faded.fadeOut).toBe(3) // untouched — this drag only ever touches its own side
1156
+ })
1157
+
1158
+ it('shows the diagonal fade-resize cursor while dragging a fade grip', () => {
1159
+ const d = new Driver(makeContext())
1160
+ d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
1161
+ // Already nwse-resize from the press-time hit (the fade-in grip's diagonal),
1162
+ // and the fade gesture holds that same cursor, so the move emits no change.
1163
+ expect(of(d.move(A0_FADE_IN_GRIP.x + 50, A0_FADE_IN_GRIP.y), 'cursor')).toEqual([])
1164
+ expect(d.machine.state.cursor).toBe('nwse-resize')
1165
+ })
1166
+
1167
+ it('emits one projectChange per move and a single commit at release', () => {
1168
+ const d = new Driver(makeContext())
1169
+ d.down(A0_FADE_IN_GRIP.x, A0_FADE_IN_GRIP.y)
1170
+ expect(of(d.move(A0_FADE_IN_GRIP.x + 50, A0_FADE_IN_GRIP.y), 'projectChange')).toHaveLength(1)
1171
+ expect(of(d.move(A0_FADE_IN_GRIP.x + 80, A0_FADE_IN_GRIP.y), 'projectChange')).toHaveLength(1)
1172
+ const committed = of(d.up(A0_FADE_IN_GRIP.x + 80, A0_FADE_IN_GRIP.y), 'commit')
1173
+ expect(committed).toHaveLength(1)
1174
+ expect(audio(committed[0].project, 'a0').fadeIn).toBeCloseTo(0.8)
1175
+ })
1176
+
1177
+ it('never disturbs the audio-trim gesture on the same bar\'s edge', () => {
1178
+ // A0_OUT_EDGE sits in the bar's full-height edge zone, well below the
1179
+ // fade grip's small top zone — the trim gesture must fire, not a fade.
1180
+ const d = new Driver(makeContext())
1181
+ d.down(A0_OUT_EDGE.x, A0_OUT_EDGE.y)
1182
+ const trimmed = audio(lastProjectChange(d.move(A0_OUT_EDGE.x + 100, A0_OUT_EDGE.y)), 'a0')
1183
+ expect(trimmed.end).toBeCloseTo(7)
1184
+ expect(trimmed.fadeIn ?? 0).toBe(0)
1185
+ })
1186
+ })
1187
+
1188
+ describe('keyframe drag (SP9b T3.3)', () => {
1189
+ function offsetXTrack(item: ReturnType<typeof visual>) {
1190
+ return item.keyframes!.find(t => t.prop === 'offsetX')!
1191
+ }
1192
+ function opacityTrack(item: ReturnType<typeof visual>) {
1193
+ return item.keyframes!.find(t => t.prop === 'opacity')!
1194
+ }
1195
+ /** Pixel-delta drags land on a float (30px / 100px-per-second = 0.3, not
1196
+ * exactly representable) — asserts the SET of times, each within floating
1197
+ * tolerance, rather than a brittle exact-array `toEqual`. */
1198
+ function expectTimes(track: { points: { t: number }[] }, expected: number[]) {
1199
+ const ts = track.points.map(p => p.t)
1200
+ expect(ts).toHaveLength(expected.length)
1201
+ ts.forEach((t, i) => expect(t).toBeCloseTo(expected[i]))
1202
+ }
1203
+
1204
+ it('retimes every prop sharing the dragged diamond′s t together', () => {
1205
+ // ZERO_SNAP so the landing time is exact, hand-computed, not wherever a
1206
+ // magnet happens to pull it.
1207
+ const d = new Driver(makeContext({
1208
+ project: keyframedProject(),
1209
+ selectedIds: ['o0'],
1210
+ snapConfig: ZERO_SNAP,
1211
+ }))
1212
+ d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
1213
+ // +30px = +0.3s -> t 0.5 -> 0.8.
1214
+ const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)), 'o0')
1215
+ expectTimes(offsetXTrack(moved), [0.8, 1.5])
1216
+ expect(offsetXTrack(moved).points[0].value).toBe(0)
1217
+ expectTimes(opacityTrack(moved), [0.8])
1218
+ })
1219
+
1220
+ it('does not touch a track that has no point at the dragged t', () => {
1221
+ const d = new Driver(makeContext({
1222
+ project: keyframedProject(),
1223
+ selectedIds: ['o0'],
1224
+ snapConfig: ZERO_SNAP,
1225
+ }))
1226
+ d.down(O0_KEYFRAME_SOLO.x, O0_KEYFRAME_SOLO.y)
1227
+ // +20px = +0.2s -> t 1.5 -> 1.7. opacity has no point at 1.5, so it must
1228
+ // stay exactly where it was.
1229
+ const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SOLO.x + 20, O0_KEYFRAME_SOLO.y)), 'o0')
1230
+ expectTimes(offsetXTrack(moved), [0.5, 1.7])
1231
+ expectTimes(opacityTrack(moved), [0.5])
1232
+ })
1233
+
1234
+ it('clamps at the item′s own start (t=0), dragging far past it', () => {
1235
+ const d = new Driver(makeContext({
1236
+ project: keyframedProject(),
1237
+ selectedIds: ['o0'],
1238
+ snapConfig: ZERO_SNAP,
1239
+ }))
1240
+ d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
1241
+ const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SHARED.x - 1000, O0_KEYFRAME_SHARED.y)), 'o0')
1242
+ expect(offsetXTrack(moved).points.map(p => p.t)).toEqual([0, 1.5])
1243
+ expect(opacityTrack(moved).points.map(p => p.t)).toEqual([0])
1244
+ })
1245
+
1246
+ it('clamps at the item′s own end (t=duration), dragging far past it', () => {
1247
+ const d = new Driver(makeContext({
1248
+ project: keyframedProject(),
1249
+ selectedIds: ['o0'],
1250
+ snapConfig: ZERO_SNAP,
1251
+ }))
1252
+ d.down(O0_KEYFRAME_SOLO.x, O0_KEYFRAME_SOLO.y)
1253
+ // o0 is 2s–4s, so item-relative duration is 2 — the drag cannot push the
1254
+ // diamond past t=2 however far the pointer travels.
1255
+ const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SOLO.x + 1000, O0_KEYFRAME_SOLO.y)), 'o0')
1256
+ expect(offsetXTrack(moved).points.map(p => p.t)).toEqual([0.5, 2])
1257
+ })
1258
+
1259
+ it('snaps a dragged diamond onto the item′s own end boundary', () => {
1260
+ // Default (non-zero) snap config: drag from t=1.5 towards t=2 (the item's
1261
+ // own duration/end), landing just short of it — within the 20px/0.2s
1262
+ // strong-attract radius (DEFAULT_SNAP_CONFIG), so it catches exactly on 2.
1263
+ const d = new Driver(makeContext({ project: keyframedProject(), selectedIds: ['o0'] }))
1264
+ d.down(O0_KEYFRAME_SOLO.x, O0_KEYFRAME_SOLO.y)
1265
+ const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SOLO.x + 45, O0_KEYFRAME_SOLO.y)), 'o0')
1266
+ expect(offsetXTrack(moved).points.some(p => p.t === 2)).toBe(true)
1267
+ })
1268
+
1269
+ it('shows the ew-resize retime cursor while dragging a diamond', () => {
1270
+ const d = new Driver(makeContext({ project: keyframedProject(), selectedIds: ['o0'] }))
1271
+ d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
1272
+ // Already ew-resize from the press-time hit, and the gesture holds the
1273
+ // same cursor, so the move emits no cursor change.
1274
+ expect(of(d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y), 'cursor')).toEqual([])
1275
+ expect(d.machine.state.cursor).toBe('ew-resize')
1276
+ })
1277
+
1278
+ it('emits one projectChange per move and a single commit at release', () => {
1279
+ const d = new Driver(makeContext({
1280
+ project: keyframedProject(),
1281
+ selectedIds: ['o0'],
1282
+ snapConfig: ZERO_SNAP,
1283
+ }))
1284
+ d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
1285
+ expect(of(d.move(O0_KEYFRAME_SHARED.x + 10, O0_KEYFRAME_SHARED.y), 'projectChange')).toHaveLength(1)
1286
+ expect(of(d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y), 'projectChange')).toHaveLength(1)
1287
+ const committed = of(d.up(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y), 'commit')
1288
+ expect(committed).toHaveLength(1)
1289
+ expectTimes(offsetXTrack(visual(committed[0].project, 'o0')), [0.8, 1.5])
1290
+ })
1291
+
1292
+ it('never becomes a gesture at all when the overlay is not selected — the ordinary item move fires instead', () => {
1293
+ const d = new Driver(makeContext({ project: keyframedProject(), selectedIds: [] }))
1294
+ d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
1295
+ const moved = visual(lastProjectChange(d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)), 'o0')
1296
+ // The whole item slid, not a keyframe — its keyframes are untouched and
1297
+ // its own start/end moved instead.
1298
+ expect(offsetXTrack(moved).points.map(p => p.t)).toEqual([0.5, 1.5])
1299
+ expect(moved.start).not.toBe(2)
1300
+ })
1301
+
1302
+ describe('selection follows a retimed diamond (CapCut-correct)', () => {
1303
+ it('carries the selection from the old t to the new one, on the SAME commit that applies the retime', () => {
1304
+ // ctx.selectedKeyframe names the diamond BEFORE the drag (t=0.5) — the
1305
+ // one being dragged here. Without the fix `selectedKeyframe` would keep
1306
+ // pointing at t=0.5, which `moveKeyframe` has just vacated.
1307
+ const d = new Driver(makeContext({
1308
+ project: keyframedProject(),
1309
+ selectedIds: ['o0'],
1310
+ selectedKeyframe: { itemId: 'o0', t: 0.5 },
1311
+ snapConfig: ZERO_SNAP,
1312
+ }))
1313
+ d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
1314
+ d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y) // +30px = +0.3s -> t 0.5 -> 0.8
1315
+ const effects = d.up(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
1316
+
1317
+ expect(of(effects, 'commit')).toHaveLength(1)
1318
+ // Fires in the SAME batch of effects as the commit — one gesture, one
1319
+ // update — not deferred to a later, project-watching pass. Pixel-delta
1320
+ // drags land on a float (30px / 100px-per-second = 0.3, not exactly
1321
+ // representable) — same tolerance `expectTimes` above uses.
1322
+ const selects = of(effects, 'selectKeyframe')
1323
+ expect(selects).toHaveLength(1)
1324
+ expect(selects[0].itemId).toBe('o0')
1325
+ expect(selects[0].t).toBeCloseTo(0.8)
1326
+ })
1327
+
1328
+ it('leaves the selection alone when the DRAGGED diamond is not the selected one', () => {
1329
+ // o0's OTHER diamond (t=1.5, offsetX-only) is selected; the shared t=0.5
1330
+ // diamond is what gets dragged. Grabbing an unrelated diamond must not
1331
+ // steal the selection onto it.
1332
+ const d = new Driver(makeContext({
1333
+ project: keyframedProject(),
1334
+ selectedIds: ['o0'],
1335
+ selectedKeyframe: { itemId: 'o0', t: 1.5 },
1336
+ snapConfig: ZERO_SNAP,
1337
+ }))
1338
+ d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
1339
+ d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
1340
+ const effects = d.up(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
1341
+
1342
+ expect(of(effects, 'commit')).toHaveLength(1)
1343
+ expect(of(effects, 'selectKeyframe')).toEqual([])
1344
+ })
1345
+
1346
+ it('leaves the selection alone when nothing was selected at all', () => {
1347
+ const d = new Driver(makeContext({
1348
+ project: keyframedProject(),
1349
+ selectedIds: ['o0'],
1350
+ selectedKeyframe: null,
1351
+ snapConfig: ZERO_SNAP,
1352
+ }))
1353
+ d.down(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
1354
+ d.move(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
1355
+ const effects = d.up(O0_KEYFRAME_SHARED.x + 30, O0_KEYFRAME_SHARED.y)
1356
+
1357
+ expect(of(effects, 'selectKeyframe')).toEqual([])
1358
+ })
1359
+ })
1360
+ })
1361
+
1362
+ describe('audio lane magnet', () => {
1363
+ // Own fixture: a lane with a real gap between two MAGNETIC clips, so a
1364
+ // commit has something to close. b0/b1 sit in lane 1, non-magnetic, purely
1365
+ // as the drag target for the cross-lane-inheritance test below.
1366
+ function magnetProject(): Project {
1367
+ return {
1368
+ id: 'p',
1369
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', start: 0, end: 10 }] }],
1370
+ audio: { tracks: [
1371
+ { id: 'a0', src: 'v.mp3', start: 0, end: 2, lane: 0, magnetic: true },
1372
+ { id: 'a1', src: 'm.mp3', start: 5, end: 8, lane: 0, magnetic: true },
1373
+ { id: 'b0', src: 'n.mp3', start: 5, end: 7, lane: 1 },
1374
+ ] },
1375
+ } as unknown as Project
1376
+ }
1377
+
1378
+ const project = magnetProject()
1379
+ const layout = computeTimelineLayout(project)
1380
+ const lane0Y = Math.round(layout.lanes[0].y + layout.lanes[0].height / 2)
1381
+ const lane1Y = Math.round(layout.lanes[1].y + layout.lanes[1].height / 2)
1382
+
1383
+ it('leaves the mid-drag (projectChange) frames alone — reflow is commit-only', () => {
1384
+ const d = new Driver(makeContext({ project: magnetProject() }))
1385
+ // a0 spans [0,2] → body inside that span.
1386
+ d.down(50, lane0Y)
1387
+ // +1.5s: still short of a1 (start 5), and far enough from every snap
1388
+ // candidate ({-2,0,3,5,6,8,10}, all >0.2s away) not to get caught.
1389
+ const mid = lastProjectChange(d.move(200, lane0Y))
1390
+ expect(audio(mid, 'a0').start).toBeCloseTo(1.5)
1391
+ expect(audio(mid, 'a1').start).toBe(5) // untouched mid-drag — a real gap still exists
1392
+ })
1393
+
1394
+ it('snaps the magnetic lane gapless on release, leaving a non-magnetic lane alone', () => {
1395
+ const d = new Driver(makeContext({ project: magnetProject() }))
1396
+ d.down(50, lane0Y)
1397
+ d.move(200, lane0Y)
1398
+ const committed = of(d.up(200, lane0Y), 'commit')
1399
+ expect(committed).toHaveLength(1)
1400
+ const out = committed[0].project
1401
+ const finalA0 = audio(out, 'a0')
1402
+ const finalA1 = audio(out, 'a1')
1403
+ expect(finalA0.start).toBeCloseTo(1.5)
1404
+ expect(finalA0.end).toBeCloseTo(3.5)
1405
+ expect(finalA1.start).toBeCloseTo(finalA0.end) // gap closed, duration (3) preserved
1406
+ expect(finalA1.end).toBeCloseTo(6.5)
1407
+ // b0's lane (1) carries no magnetic clip — untouched by the same commit.
1408
+ expect(audio(out, 'b0')).toMatchObject({ start: 5, end: 7 })
1409
+ })
1410
+
1411
+ it('a clip dragged onto a magnetic lane adopts that lane\'s magnet state', () => {
1412
+ const d = new Driver(makeContext({ project: magnetProject() }))
1413
+ // b0 spans [5,7] in lane 1 → body inside that span.
1414
+ d.down(600, lane1Y)
1415
+ // Straight up one lane (dy = -AUDIO_LANE_HEIGHT_PX), no horizontal travel:
1416
+ // destLane = max(0, 1 + round(-40/40)) = 0, which holds only a0/a1 —
1417
+ // both magnetic — so b0 should adopt magnetic: true.
1418
+ const mid = lastProjectChange(d.move(600, lane1Y - 40))
1419
+ const movedB0 = audio(mid, 'b0')
1420
+ expect(movedB0.lane).toBe(0)
1421
+ expect(movedB0.magnetic).toBe(true)
1422
+ })
1423
+
1424
+ it('a clip dragged onto an EMPTY lane leaves magnetic untouched', () => {
1425
+ const d = new Driver(makeContext({ project: magnetProject() }))
1426
+ d.down(600, lane1Y)
1427
+ // Straight down one lane, into lane 2 — nothing lives there yet.
1428
+ const mid = lastProjectChange(d.move(600, lane1Y + 40))
1429
+ const movedB0 = audio(mid, 'b0')
1430
+ expect(movedB0.lane).toBe(2)
1431
+ expect(movedB0.magnetic).toBeUndefined()
1432
+ })
1433
+ })
1434
+
1435
+ // ── Inspection ───────────────────────────────────────────────────────────
1436
+
1437
+ describe('double-click', () => {
1438
+ // Double-clicking bare timeline used to place the A/B range markers. That
1439
+ // feature is gone: background double-clicks now emit nothing at all, and a
1440
+ // clip or audio bar still opens its inspector.
1441
+ it('does nothing on empty timeline', () => {
1442
+ const d = new Driver(makeContext())
1443
+ expect(d.doubleClick(700, 20)).toEqual([])
1444
+ })
1445
+
1446
+ it('does nothing on an empty audio lane', () => {
1447
+ const d = new Driver(makeContext())
1448
+ expect(d.doubleClick(800, LANE_Y)).toEqual([])
1449
+ })
1450
+
1451
+ it('does nothing past the end of the timeline', () => {
1452
+ const d = new Driver(makeContext())
1453
+ expect(d.doubleClick(-100, 20)).toEqual([])
1454
+ })
1455
+
1456
+ it('opens the clip inspector on a clip', () => {
1457
+ const d = new Driver(makeContext())
1458
+ const effects = d.doubleClick(C0_BODY.x, C0_BODY.y)
1459
+ expect(effects).toEqual([{ type: 'inspect', target: 'visual', id: 'c0' }])
1460
+ })
1461
+
1462
+ it('opens the audio inspector on a bar', () => {
1463
+ const d = new Driver(makeContext())
1464
+ expect(d.doubleClick(A0_BODY.x, A0_BODY.y)).toEqual([{ type: 'inspect', target: 'audio', id: 'a0' }])
1465
+ })
1466
+
1467
+ it('double-clicking a keyframe diamond selects it rather than opening an inspector', () => {
1468
+ // Was: emitted {type:'inspect'} identically to a clip-body double-click,
1469
+ // which goes nowhere now that the properties panel replaced that modal.
1470
+ // This branch had zero coverage before.
1471
+ const ctx = makeContext({ project: keyframedProject(), selectedIds: ['o0'] })
1472
+ const effects = new Driver(ctx).doubleClick(O0_KEYFRAME_SHARED.x, O0_KEYFRAME_SHARED.y)
1473
+
1474
+ expect(of(effects, 'selectKeyframe')).toEqual([{ type: 'selectKeyframe', itemId: 'o0', t: 0.5 }])
1475
+ expect(of(effects, 'inspect')).toHaveLength(0)
1476
+ // Selecting a diamond is not an edit.
1477
+ expect(of(effects, 'projectChange')).toHaveLength(0)
1478
+ })
1479
+
1480
+ it('reports the diamond\'s own item-relative t, not absolute timeline time', () => {
1481
+ // o0 starts at 2s, so the t=1.5 diamond sits at absolute 3.5s. The effect
1482
+ // must carry 1.5 — `Keyframe.t` is item-relative and every consumer
1483
+ // (removeKeyframesAt, moveKeyframe) assumes that.
1484
+ const ctx = makeContext({ project: keyframedProject(), selectedIds: ['o0'] })
1485
+ const effects = new Driver(ctx).doubleClick(O0_KEYFRAME_SOLO.x, O0_KEYFRAME_SOLO.y)
1486
+
1487
+ expect(of(effects, 'selectKeyframe')[0].t).toBe(1.5)
1488
+ })
1489
+ })
1490
+
1491
+ describe('double-click to key (selected element only)', () => {
1492
+ // WHICH props the gesture keys is `transformProps(item)`'s call, not a fixed
1493
+ // list — a uniform overlay gets `scale`, one authored per-axis gets
1494
+ // `scaleX`/`scaleY`. `keyframedProject`'s o0 carries neither per-axis field,
1495
+ // so it is uniform and every case below but the last two uses this list.
1496
+ const KEYFRAME_PROPS = ['offsetX', 'offsetY', 'scale', 'rotation', 'opacity'] as const
1497
+ const PER_AXIS_PROPS = ['offsetX', 'offsetY', 'scaleX', 'scaleY', 'rotation', 'opacity'] as const
1498
+ const O0_BODY = { x: 300, y: OVERLAY_Y } // absolute 3s -> localT 1 on o0 (starts at 2s)
1499
+
1500
+ /** `keyframedProject` with o0's scale authored some other way. */
1501
+ function overlayScaledBy(over: Record<string, unknown>): Project {
1502
+ const project = keyframedProject()
1503
+ const track = (project.tracks as { items: unknown[] }[])[1]
1504
+ track.items[0] = { ...(track.items[0] as object), ...over }
1505
+ return project
1506
+ }
1507
+
1508
+ it('keys all five props at the clicked time, without moving the overlay', () => {
1509
+ const project = keyframedProject()
1510
+ const ctx = makeContext({ project, selectedIds: ['o0'] })
1511
+ const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
1512
+
1513
+ const next = visual(lastProjectChange(effects), 'o0')
1514
+ const before = visual(project, 'o0')
1515
+ for (const prop of KEYFRAME_PROPS) {
1516
+ expect(trackFor(next, prop)!.points.find(p => p.t === 1)).toBeDefined()
1517
+ // Nothing moved: the keyed value IS the value it already had there.
1518
+ expect(valueAt(next, prop, 1)).toBeCloseTo(valueAt(before, prop, 1))
1519
+ }
1520
+ // Existing keyframes are untouched — this ADDS a moment, it does not reset.
1521
+ expect(trackFor(next, 'offsetX')!.points.find(p => p.t === 0.5)).toBeDefined()
1522
+ expect(trackFor(next, 'offsetX')!.points.find(p => p.t === 1.5)).toBeDefined()
1523
+ expect(of(effects, 'commit')).toHaveLength(1)
1524
+ })
1525
+
1526
+ it('does NOT key an item that is not selected', () => {
1527
+ const ctx = makeContext({ project: keyframedProject() }) // selectedIds defaults to []
1528
+ const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
1529
+ expect(of(effects, 'projectChange')).toHaveLength(0)
1530
+ })
1531
+
1532
+ it('does NOT key a selected item when the double-click landed on a DIFFERENT item', () => {
1533
+ // Inverted on purpose: the click lands on o0, which IS keyframeable, and
1534
+ // c0 is what's selected. `canKeyframe` therefore cannot be what refuses —
1535
+ // the ONLY thing standing between this and a key is `selectedIds` not
1536
+ // containing 'o0'. The rule is "on the selected element", not "while
1537
+ // something is selected", and this is the case that separates the two
1538
+ // (test 2 above has an EMPTY selection, so it passes either way).
1539
+ const ctx = makeContext({ project: keyframedProject(), selectedIds: ['c0'] })
1540
+ const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
1541
+ expect(of(effects, 'projectChange')).toHaveLength(0)
1542
+ })
1543
+
1544
+ it('DOES key a selected video clip (SP9d)', () => {
1545
+ // canKeyframe widened when the renderer gained a per-frame geometry hook.
1546
+ const ctx = makeContext({ project: keyframedProject(), selectedIds: ['c0'] })
1547
+ const effects = new Driver(ctx).doubleClick(C0_BODY.x, C0_BODY.y)
1548
+ expect(of(effects, 'projectChange')).toHaveLength(1)
1549
+ })
1550
+
1551
+ it('never writes an OPACITY track when keying a clip', () => {
1552
+ // ffmpeg cannot vary a clip's alpha, so an opacity track written here would
1553
+ // be silently ignored by the export — a keyframe that does nothing. The
1554
+ // exclusion lives in transformProps via canKeyframeProp.
1555
+ const ctx = makeContext({ project: keyframedProject(), selectedIds: ['c0'] })
1556
+ const effects = new Driver(ctx).doubleClick(C0_BODY.x, C0_BODY.y)
1557
+ const next = visual(lastProjectChange(effects), 'c0')
1558
+ expect(trackFor(next, 'opacity')).toBeUndefined()
1559
+ // ...while the geometry props it CAN animate are all keyed.
1560
+ expect(trackFor(next, 'scale')).toBeDefined()
1561
+ expect(trackFor(next, 'offsetX')).toBeDefined()
1562
+ expect(trackFor(next, 'rotation')).toBeDefined()
1563
+ })
1564
+
1565
+ it('never seeds scaleX/scaleY on a UNIFORM overlay — that would freeze its zoom', () => {
1566
+ // THE regression guard. `scaleX` shadows `scale` in the resolver's fallback
1567
+ // chain, so a one-point (i.e. constant) scaleX track seeded here would stop
1568
+ // this overlay's uniform zoom from ever animating again — silently, with
1569
+ // nothing on screen to say why, until the operator scrubs.
1570
+ const project = overlayScaledBy({
1571
+ keyframes: [{ prop: 'scale', points: [{ t: 0, value: 1 }, { t: 2, value: 2 }] }],
1572
+ })
1573
+ const ctx = makeContext({ project, selectedIds: ['o0'] })
1574
+ const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
1575
+
1576
+ const next = visual(lastProjectChange(effects), 'o0')
1577
+ expect(trackFor(next, 'scale')!.points.find(p => p.t === 1)).toBeDefined()
1578
+ expect(trackFor(next, 'scaleX')).toBeUndefined()
1579
+ expect(trackFor(next, 'scaleY')).toBeUndefined()
1580
+ // The zoom still animates, on both axes, exactly as it did before.
1581
+ expect(valueAt(next, 'scaleX', 0)).toBeCloseTo(1)
1582
+ expect(valueAt(next, 'scaleX', 2)).toBeCloseTo(2)
1583
+ expect(valueAt(next, 'scaleY', 0)).toBeCloseTo(1)
1584
+ expect(valueAt(next, 'scaleY', 2)).toBeCloseTo(2)
1585
+ })
1586
+
1587
+ it('keys scaleX/scaleY and NOT `scale` on a PER-AXIS overlay', () => {
1588
+ // The symmetric failure: keying `scale` on an item whose per-axis fields
1589
+ // already shadow it would make the whole gesture appear to do nothing.
1590
+ const project = overlayScaledBy({ scaleX: 1.5, scaleY: 0.5 })
1591
+ const ctx = makeContext({ project, selectedIds: ['o0'] })
1592
+ const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
1593
+
1594
+ const next = visual(lastProjectChange(effects), 'o0')
1595
+ const before = visual(project, 'o0')
1596
+ for (const prop of PER_AXIS_PROPS) {
1597
+ expect(trackFor(next, prop)!.points.find(p => p.t === 1)).toBeDefined()
1598
+ // Nothing moved, per-axis included.
1599
+ expect(valueAt(next, prop, 1)).toBeCloseTo(valueAt(before, prop, 1))
1600
+ }
1601
+ expect(trackFor(next, 'scale')).toBeUndefined()
1602
+ })
1603
+
1604
+ it('follows a per-axis KEYFRAME TRACK, not just the per-axis scalars', () => {
1605
+ const project = overlayScaledBy({
1606
+ keyframes: [{ prop: 'scaleX', points: [{ t: 0, value: 1 }, { t: 2, value: 3 }] }],
1607
+ })
1608
+ const ctx = makeContext({ project, selectedIds: ['o0'] })
1609
+ const effects = new Driver(ctx).doubleClick(O0_BODY.x, O0_BODY.y)
1610
+
1611
+ const next = visual(lastProjectChange(effects), 'o0')
1612
+ expect(trackFor(next, 'scaleX')!.points.find(p => p.t === 1)?.value).toBeCloseTo(2)
1613
+ expect(trackFor(next, 'scaleY')!.points.find(p => p.t === 1)).toBeDefined()
1614
+ expect(trackFor(next, 'scale')).toBeUndefined()
1615
+ })
1616
+ })
1617
+
1618
+ describe('no row is ever inert', () => {
1619
+ // Rows used to fade and go `pointer-events-none` on every track that didn't
1620
+ // hold the selection while a marker was down. With markers gone, nothing
1621
+ // dims for selection reasons and every row stays clickable.
1622
+ it('selects a clip on a track that holds none of the selection', () => {
1623
+ const d = new Driver(makeContext({ selectedIds: ['c0'] }))
1624
+ // o0 lives on track 1, which holds no part of the selection.
1625
+ d.down(300, OVERLAY_Y)
1626
+ expect(of(d.up(300, OVERLAY_Y), 'select')).toEqual([{ type: 'select', id: 'o0', additive: false }])
1627
+ })
1628
+
1629
+ it('keeps the row holding the selection live', () => {
1630
+ const d = new Driver(makeContext({ selectedIds: ['c0'] }))
1631
+ d.down(C0_BODY.x, C0_BODY.y)
1632
+ expect(of(d.up(C0_BODY.x, C0_BODY.y), 'select')).toEqual([{ type: 'select', id: 'c0', additive: false }])
1633
+ })
1634
+
1635
+ it('keeps audio lanes live', () => {
1636
+ const d = new Driver(makeContext({ selectedIds: ['c0'] }))
1637
+ d.down(A0_BODY.x, A0_BODY.y)
1638
+ expect(of(d.up(A0_BODY.x, A0_BODY.y), 'select')).toEqual([{ type: 'select', id: 'a0', additive: false }])
1639
+ })
1640
+ })
1641
+
1642
+ // ── Interruption and degenerate state ────────────────────────────────────
1643
+
1644
+ describe('cancel', () => {
1645
+ it('drops a gesture without committing', () => {
1646
+ const d = new Driver(makeContext())
1647
+ d.down(C0_BODY.x, C0_BODY.y)
1648
+ d.move(C0_BODY.x + 100, C0_BODY.y)
1649
+ expect(of(d.cancel(), 'commit')).toEqual([])
1650
+ expect(d.machine.state.kind).toBe('idle')
1651
+ })
1652
+
1653
+ it('resets the cursor', () => {
1654
+ const d = new Driver(makeContext())
1655
+ d.move(C0_BODY.x, C0_BODY.y)
1656
+ expect(of(d.cancel(), 'cursor')).toEqual([{ type: 'cursor', cursor: 'default' }])
1657
+ })
1658
+
1659
+ it('leaves a released gesture alone', () => {
1660
+ const d = new Driver(makeContext())
1661
+ expect(d.cancel()).toEqual([])
1662
+ })
1663
+ })
1664
+
1665
+ // ── Press-time snap boundaries (self-snap regression) ────────────────────
1666
+
1667
+ describe('press-time snap boundaries — echoed-project self-snap', () => {
1668
+ // Live bug: VideoEditor echoes every `projectChange` back through Timeline's
1669
+ // re-render, and Timeline's `computeDerivedTiming` memo recomputes
1670
+ // `snapBoundaries` from THAT echoed project. Mid-gesture the boundary list
1671
+ // therefore contains the dragged item's own CURRENT (moved) edges, not just
1672
+ // the edges it started from. If a gesture read `ctx.snapBoundaries` live it
1673
+ // would find its last position back in its own magnet list and snap to
1674
+ // itself — repeatedly, since every subsequent move re-derives the same
1675
+ // trap. These tests drive the machine exactly the way Timeline does: apply
1676
+ // each `projectChange` to a running project and rebuild `snapBoundaries`
1677
+ // from it before the next move, the way the real memo would.
1678
+ function threeClipProject(): Project {
1679
+ return {
1680
+ id: 'p3',
1681
+ tracks: [
1682
+ {
1683
+ id: 'trk-0',
1684
+ items: [
1685
+ { id: 'x0', type: 'video', src: 'a.mp4', start: 0, end: 3, inPoint: 0, outPoint: 3, sourceDuration: 20 },
1686
+ { id: 'x1', type: 'video', src: 'b.mp4', start: 3, end: 6, inPoint: 0, outPoint: 3, sourceDuration: 20 },
1687
+ { id: 'x2', type: 'video', src: 'c.mp4', start: 6, end: 9, inPoint: 0, outPoint: 3, sourceDuration: 20 },
1688
+ ],
1689
+ },
1690
+ ],
1691
+ } as unknown as Project
1692
+ }
1693
+
1694
+ /** Timeline's live memo, reproduced: fold the latest `projectChange` into a
1695
+ * running project and rebuild `snapBoundaries` (and `totalDuration`) from
1696
+ * it, exactly as `computeDerivedTiming` would on the next render. */
1697
+ function echoProjectChange(ctx: PointerContext, effects: PointerEffect[]): PointerContext {
1698
+ const changes = of(effects, 'projectChange')
1699
+ if (changes.length === 0) return ctx
1700
+ const project = changes[changes.length - 1].project
1701
+ const derived = computeDerivedTiming(project)
1702
+ return { ...ctx, project, snapBoundaries: derived.snapBoundaries, totalDuration: derived.totalDuration }
1703
+ }
1704
+
1705
+ it('tracks the cursor through a body drag instead of magnetizing to its own echoed position', () => {
1706
+ const project = threeClipProject()
1707
+ const layout = computeTimelineLayout(project)
1708
+ const row = layout.rows.find(r => r.trackIdx === 0)!
1709
+ const y = row.y + row.height / 2
1710
+ const bodyX = 4 * VIEWPORT.pxPerSecond // inside x1's body (3s–6s), clear of both edge handles
1711
+
1712
+ const d = new Driver(makeContext({ project }))
1713
+ d.down(bodyX, y)
1714
+
1715
+ const starts: number[] = []
1716
+ for (let i = 1; i <= 12; i++) {
1717
+ const effects = d.move(bodyX + i * 4, y)
1718
+ d.ctx = echoProjectChange(d.ctx, effects)
1719
+ starts.push(visual(lastProjectChange(effects), 'x1').start)
1720
+ }
1721
+
1722
+ // Must track the cursor — not plateau at an echoed position and jump.
1723
+ for (let i = 1; i < starts.length; i++) {
1724
+ expect(starts[i]).toBeGreaterThanOrEqual(starts[i - 1] - 1e-9)
1725
+ }
1726
+ expect(new Set(starts.map(s => s.toFixed(6))).size).toBeGreaterThanOrEqual(10)
1727
+ expect(starts[starts.length - 1]).toBeCloseTo(3 + 48 / VIEWPORT.pxPerSecond, 6)
1728
+ })
1729
+
1730
+ it('tracks the cursor through an edge trim instead of freezing at its own echoed position', () => {
1731
+ const project = threeClipProject()
1732
+ const layout = computeTimelineLayout(project)
1733
+ const row = layout.rows.find(r => r.trackIdx === 0)!
1734
+ const y = row.y + row.height / 2
1735
+ const endEdgeX = 6 * VIEWPORT.pxPerSecond - 2 // inside x1's out-edge handle (end = 6s)
1736
+
1737
+ const d = new Driver(makeContext({ project }))
1738
+ d.down(endEdgeX, y)
1739
+
1740
+ const ends: number[] = []
1741
+ for (let i = 1; i <= 5; i++) {
1742
+ const effects = d.move(endEdgeX - i * 4, y)
1743
+ d.ctx = echoProjectChange(d.ctx, effects)
1744
+ ends.push(visual(lastProjectChange(effects), 'x1').end)
1745
+ }
1746
+
1747
+ // Must not freeze at the first applied position.
1748
+ expect(new Set(ends.map(e => e.toFixed(6))).size).toBeGreaterThanOrEqual(5)
1749
+ expect(ends[ends.length - 1]).toBeCloseTo(6 - 20 / VIEWPORT.pxPerSecond, 6)
1750
+ })
1751
+ })
1752
+
1753
+ // ── The snap guide ───────────────────────────────────────────────────────
1754
+ //
1755
+ // The magnet is deliberately hard to escape (44px of release against 20px of
1756
+ // attract), which is only usable because the guide says where you are held.
1757
+ // These tests are the contract between the two: the machine reports the
1758
+ // BOUNDARY and its tier, once per change, and takes it down the moment the
1759
+ // gesture ends.
1760
+
1761
+ const guide = (time: number | null, strength: 'strong' | 'weak' | null) =>
1762
+ [{ type: 'snapGuide', time, strength }]
1763
+
1764
+ describe('snap guide', () => {
1765
+ it('raises a guide on capture and drops it on release, once each', () => {
1766
+ const d = new Driver(makeContext())
1767
+
1768
+ // Pressing at 5.05s captures the cut at 5. A scrub tiers everything
1769
+ // strong: the playhead belongs to no row, so it has no "own track".
1770
+ d.down(505, RULER_Y)
1771
+ expect(of(d.effects, 'snapGuide')).toEqual(guide(5, 'strong'))
1772
+
1773
+ // Still held at 5.25s — past attract, well inside release. The guide has
1774
+ // not MOVED, so nothing is emitted: a drag held against one boundary must
1775
+ // not spray an effect per pointer event.
1776
+ expect(of(d.move(525, RULER_Y), 'snapGuide')).toEqual([])
1777
+
1778
+ // 5.6s clears release.
1779
+ expect(of(d.move(560, RULER_Y), 'snapGuide')).toEqual(guide(null, null))
1780
+ // …and stays down without re-announcing itself.
1781
+ expect(of(d.move(580, RULER_Y), 'snapGuide')).toEqual([])
1782
+ })
1783
+
1784
+ it('takes the guide down when a gesture ends still snapped', () => {
1785
+ const d = new Driver(makeContext())
1786
+ d.down(505, RULER_Y)
1787
+ expect(of(d.up(505, RULER_Y), 'snapGuide')).toEqual(guide(null, null))
1788
+ })
1789
+
1790
+ it('takes the guide down on a cancelled gesture', () => {
1791
+ const d = new Driver(makeContext())
1792
+ d.down(505, RULER_Y)
1793
+ expect(of(d.cancel(), 'snapGuide')).toEqual(guide(null, null))
1794
+ })
1795
+
1796
+ it('marks the boundary a dragged clip\'s TAIL caught, not its start', () => {
1797
+ // o0 is 2s–4s on track 1. Dragged one second right its END lands exactly
1798
+ // on track 0's cut at 5 — the magnet's snap point is the START it implies
1799
+ // (3), which is a whole clip away from the edge the user is watching.
1800
+ const d = new Driver(makeContext())
1801
+ d.down(300, OVERLAY_Y)
1802
+ expect(of(d.move(400, OVERLAY_Y), 'snapGuide')).toEqual(guide(5, 'weak'))
1803
+ })
1804
+
1805
+ it('marks the boundary a dragged clip\'s HEAD caught', () => {
1806
+ // Same clip one second left: its start lands on the audio bar's start at 1.
1807
+ const d = new Driver(makeContext())
1808
+ d.down(300, OVERLAY_Y)
1809
+ expect(of(d.move(200, OVERLAY_Y), 'snapGuide')).toEqual(guide(1, 'weak'))
1810
+ })
1811
+
1812
+ it('marks a same-track trim as strong', () => {
1813
+ // c0's out edge pulled all the way out to c1's end at 10 — the same row,
1814
+ // so the full magnet and a bold guide.
1815
+ const d = new Driver(makeContext())
1816
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
1817
+ expect(of(d.move(995, C0_OUT_EDGE.y), 'snapGuide')).toEqual(guide(10, 'strong'))
1818
+ })
1819
+
1820
+ it('marks a cross-track trim as weak', () => {
1821
+ // Same gesture, aimed instead at o0's end at 4 — one row up. It still
1822
+ // catches, because 4.05 is 5px away and the weak radius is 6, but it
1823
+ // announces itself as the hint it is.
1824
+ const d = new Driver(makeContext())
1825
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
1826
+ expect(of(d.move(400, C0_OUT_EDGE.y), 'snapGuide')).toEqual(guide(4, 'weak'))
1827
+ })
1828
+
1829
+ it('lets a cross-track boundary go by at a distance a same-track one would hold', () => {
1830
+ // 4.10 is 10px from o0's end: inside the strong radius, outside the weak
1831
+ // one. Before the tiers this snapped, and dragging the base video track
1832
+ // past a busy overlay row was a fight.
1833
+ const d = new Driver(makeContext())
1834
+ d.down(C0_OUT_EDGE.x, C0_OUT_EDGE.y)
1835
+ expect(of(d.move(410, C0_OUT_EDGE.y), 'snapGuide')).toEqual([])
1836
+ })
1837
+
1838
+ it('shows no guide when the op refuses to put the edge where the magnet asked', () => {
1839
+ // o0's out edge dragged back past its own start: the magnet catches t=0,
1840
+ // but a clip cannot be shorter than 0.1s, so the edge lands at 2.1 and the
1841
+ // alignment never happens. A guide at 0 would be pointing at nothing.
1842
+ const d = new Driver(makeContext())
1843
+ d.down(397, OVERLAY_Y)
1844
+ expect(of(d.move(2, OVERLAY_Y), 'snapGuide')).toEqual([])
1845
+ })
1846
+
1847
+ it('tiers audio by LANE, so two bars sharing a lane pull on each other', () => {
1848
+ // Lanes, not tracks: a lane can hold several bars and reads as one row, and
1849
+ // a bar with no explicit `lane` gets an auto-assigned one. Reading
1850
+ // `track.lane ?? 0` instead of grouping would file every unlabelled bar
1851
+ // under lane 0 and call them all same-lane.
1852
+ const project = {
1853
+ id: 'p',
1854
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 12, inPoint: 0, outPoint: 12, sourceDuration: 20 }] }],
1855
+ audio: {
1856
+ tracks: [
1857
+ { id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 },
1858
+ { id: 'a1', src: 'w.mp3', start: 8, end: 11, lane: 0 },
1859
+ { id: 'b0', src: 'm.mp3', start: 3, end: 4, lane: 1 },
1860
+ ],
1861
+ },
1862
+ } as unknown as Project
1863
+ const ctx = makeContext({ project })
1864
+ const lane0 = ctx.layout.lanes.find(l => l.laneIndex === 0)!
1865
+ const y = Math.round(lane0.y + lane0.height / 2)
1866
+
1867
+ // a0's out edge (t=6, x=600) dragged to 8.1 — a1's start, same lane. 10px
1868
+ // out, so only the strong radius can reach it.
1869
+ const strongDrag = new Driver(ctx)
1870
+ strongDrag.down(597, y)
1871
+ expect(of(strongDrag.move(810, y), 'snapGuide')).toEqual(guide(8, 'strong'))
1872
+
1873
+ // The same edge dragged to 4.04 — b0's end, one lane down, 4px out. It
1874
+ // catches, but only just, and announces itself as the hint it is.
1875
+ const weakDrag = new Driver(ctx)
1876
+ weakDrag.down(597, y)
1877
+ expect(of(weakDrag.move(401, y), 'snapGuide')).toEqual(guide(4, 'weak'))
1878
+ })
1879
+
1880
+ it('never guides a slip, which has no timeline snap points at all', () => {
1881
+ const d = new Driver(makeContext())
1882
+ d.down(C0_BODY.x, C0_BODY.y, mods({ alt: true }))
1883
+ expect(of(d.move(C0_BODY.x + 100, C0_BODY.y, mods({ alt: true })), 'snapGuide')).toEqual([])
1884
+ })
1885
+ })
1886
+
1887
+ // A single-item MOVE is the one gesture whose strong snap tier follows the
1888
+ // row the item is CURRENTLY over rather than the row the press landed on —
1889
+ // see `tieredBoundaries`'s doc comment for why every other gesture keeps the
1890
+ // press-time row for its whole run. These tests drag `sameKindProject`'s c1
1891
+ // (track 0, 5s–10s) up onto track 1 (o0, video-kind here so the kind-lock
1892
+ // doesn't block the move) and prove the tiers actually flip as it crosses.
1893
+ describe('cross-track re-tiering — a move follows the row it is currently over', () => {
1894
+ it('promotes the destination track\'s boundary to strong once the drag reaches it', () => {
1895
+ // rawStart lands on 4.08 — 8px short of o0's end (4s, track 1). Outside
1896
+ // the weak radius (6px), so at rest on track 0 (o0's boundary tiered
1897
+ // weak) it does not catch at all.
1898
+ const atRest = new Driver(makeContext({ project: sameKindProject() }))
1899
+ atRest.down(C1_BODY.x, SAME_KIND_BASE_Y)
1900
+ expect(of(atRest.move(658, SAME_KIND_BASE_Y), 'snapGuide')).toEqual([])
1901
+
1902
+ // The identical horizontal drag, but crossed onto track 1: o0's boundary
1903
+ // is now the CURRENT track's peer, promoted to strong, and the same 8px
1904
+ // is well inside its 20px radius.
1905
+ const crossed = new Driver(makeContext({ project: sameKindProject() }))
1906
+ crossed.down(C1_BODY.x, SAME_KIND_BASE_Y)
1907
+ expect(of(crossed.move(658, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX), 'snapGuide')).toEqual(guide(4, 'strong'))
1908
+ })
1909
+
1910
+ it('demotes the origin track\'s boundary to weak once the drag has left it', () => {
1911
+ // Target is c0's START (0s), not its end (5s) — 5s is butted against c1's
1912
+ // own start and would be suppressed by the origin guard at this short a
1913
+ // travel distance, which is a different mechanism from the one this test
1914
+ // is about. rawStart lands on 0.15 — 15px short of 0: inside the strong
1915
+ // radius (20px), outside the weak one (6px).
1916
+ //
1917
+ // The playhead is parked well away from 0 (rather than left at the
1918
+ // default 0) — it is ALWAYS a strong candidate regardless of track, and
1919
+ // at the default it would coincide with this test's own target and mask
1920
+ // the tier this test is actually checking.
1921
+ const ctx = makeContext({ project: sameKindProject(), playheadTime: -1000 })
1922
+ const atRest = new Driver(ctx)
1923
+ atRest.down(C1_BODY.x, SAME_KIND_BASE_Y)
1924
+ expect(of(atRest.move(265, SAME_KIND_BASE_Y), 'snapGuide')).toEqual(guide(0, 'strong'))
1925
+
1926
+ // Crossed onto track 1, track 0 — the track this drag LEFT — demotes to
1927
+ // weak, and the same 15px is now too far even for that.
1928
+ const crossed = new Driver(ctx)
1929
+ crossed.down(C1_BODY.x, SAME_KIND_BASE_Y)
1930
+ expect(of(crossed.move(265, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX), 'snapGuide')).toEqual([])
1931
+ })
1932
+
1933
+ it('keeps the playhead strong no matter which track the drag is currently over', () => {
1934
+ // The playhead belongs to no track, so re-tiering must never touch it.
1935
+ // rawStart lands exactly on 3.5, an arbitrary playhead time that shares no
1936
+ // value with any clip or audio boundary in the fixture.
1937
+ const ctx = makeContext({ project: sameKindProject(), playheadTime: 3.5 })
1938
+ const atRest = new Driver(ctx)
1939
+ atRest.down(C1_BODY.x, SAME_KIND_BASE_Y)
1940
+ expect(of(atRest.move(600, SAME_KIND_BASE_Y), 'snapGuide')).toEqual(guide(3.5, 'strong'))
1941
+
1942
+ const crossed = new Driver(ctx)
1943
+ crossed.down(C1_BODY.x, SAME_KIND_BASE_Y)
1944
+ expect(of(crossed.move(600, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX), 'snapGuide')).toEqual(guide(3.5, 'strong'))
1945
+ })
1946
+
1947
+ it('still excludes the dragged item\'s own edges once re-tiered onto a new track', () => {
1948
+ // Aimed exactly at c1's own original end (10s) after crossing onto track
1949
+ // 1. `tieredBoundaries`'s `skipId` drops a dragged item's own boundaries
1950
+ // by IDENTITY, not by value, and that has to keep working when the tier
1951
+ // is recomputed fresh per move — a regression here would have c1 snap to
1952
+ // its own trailing edge as an exact-match WEAK point (tiered against
1953
+ // track 0, which it no longer occupies) the moment it crossed rows.
1954
+ const d = new Driver(makeContext({ project: sameKindProject() }))
1955
+ d.down(C1_BODY.x, SAME_KIND_BASE_Y)
1956
+ expect(of(d.move(1250, SAME_KIND_BASE_Y - VISUAL_ROW_HEIGHT_PX), 'snapGuide')).toEqual([])
1957
+ })
1958
+
1959
+ it('leaves a within-track move exactly as it was — the current track equals the origin track throughout', () => {
1960
+ // No vertical travel at all: c0 dragged along track 0 toward c1's own
1961
+ // trailing edge at 10s, 8px short — same-track, so still strong.
1962
+ const d = new Driver(makeContext({ project: sameKindProject() }))
1963
+ d.down(C0_BODY.x, SAME_KIND_BASE_Y)
1964
+ expect(of(d.move(1242, SAME_KIND_BASE_Y), 'snapGuide')).toEqual(guide(10, 'strong'))
1965
+ })
1966
+
1967
+ it('tiers against the track the drop LANDS on, not the one the vertical travel points at', () => {
1968
+ // Sam's repro, 2026-08-25: an overlay exactly as long as a gap one row
1969
+ // below, dragged down into that gap, showed no guide and refused to click
1970
+ // into place — while landing on the right row the whole time.
1971
+ //
1972
+ // The two facts that produce it, neither visible from the drag alone:
1973
+ //
1974
+ // - The drag divisor (VISUAL_ROW_HEIGHT_PX, 24) is roughly HALF a
1975
+ // rendered overlay row (40 + 4 gap = 44), so one row of real travel
1976
+ // reads as TWO track steps. From track 2 that points at track 0.
1977
+ // - `moveItemAcrossTracks` does not honour that index: track 0 holds
1978
+ // video, the dragged item is an overlay, so the kind gate rejects it
1979
+ // and the outward search puts the drop back on track 1 — the row the
1980
+ // cursor is genuinely over.
1981
+ //
1982
+ // The drop was therefore right and only the TIER was wrong: it ranked
1983
+ // track 0's boundaries strong and left the gap's own two edges (2s and
1984
+ // 7s, on track 1) weak, a 6px magnet where a 20px one belongs.
1985
+ const project = {
1986
+ id: 'p',
1987
+ tracks: [
1988
+ { id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 12, inPoint: 0, outPoint: 12, sourceDuration: 20 }] },
1989
+ // The gap: 2s–7s, five seconds wide.
1990
+ { id: 'trk-1', items: [{ id: 'g0', type: 'overlay', start: 0, end: 2 }, { id: 'g1', type: 'overlay', start: 7, end: 12 }] },
1991
+ // Five seconds wide too — it fits the gap exactly.
1992
+ { id: 'trk-2', items: [{ id: 'ov', type: 'overlay', start: 2.6, end: 7.6 }] },
1993
+ ],
1994
+ } as unknown as Project
1995
+ const ctx = makeContext({ project })
1996
+ const rowY = (trackIdx: number) => {
1997
+ const row = ctx.layout.rows.find(r => r.trackIdx === trackIdx)!
1998
+ return Math.round(row.y + row.height / 2)
1999
+ }
2000
+
2001
+ const d = new Driver(ctx)
2002
+ d.down(510, rowY(2)) // ov's midpoint
2003
+ // Straight down onto track 1's row, no horizontal travel: 44px, which the
2004
+ // 24px divisor reads as two track steps.
2005
+ d.move(510, rowY(1))
2006
+ // It really did land on track 1 — the row under the cursor.
2007
+ expect(trackIndexOf(lastProjectChange(d.effects), 'ov')).toBe(1)
2008
+
2009
+ // Now slide left toward the gap's leading edge at 2s. rawStart is 2.18 —
2010
+ // 18px out: inside the strong radius (20px), outside the weak one (6px).
2011
+ // Weak, this is nothing; strong, it catches and the clip drops into the
2012
+ // gap it fits.
2013
+ expect(of(d.move(468, rowY(1)), 'snapGuide')).toEqual(guide(2, 'strong'))
2014
+ expect(visual(lastProjectChange(d.effects), 'ov')).toMatchObject({ start: 2, end: 7 })
2015
+ })
2016
+
2017
+ it('catches the TRAILING edge too — approached from the left, the clip\'s END snaps to the gap\'s far edge', () => {
2018
+ // Same gap (track 1, 2s–7s) and the same double-hop divisor mechanic as
2019
+ // the repro above, but driven from the other side and with a dragged item
2020
+ // that does NOT fill the gap (3s wide, not 5s) — so `snapPointsForSpan`'s
2021
+ // head formula (`p`) and tail formula (`p - duration`) land on two
2022
+ // DIFFERENT numbers, and only the tail one is close enough to catch. The
2023
+ // repro above can't tell the two apart: its item is exactly gap-width, so
2024
+ // both formulas resolve to the same value regardless of which edge is
2025
+ // "really" catching.
2026
+ //
2027
+ // A revert back to tiering the POINTED-AT index (`sourceTrackIdx -
2028
+ // round(dy / 24)`) puts track 0's cuts (0, 12) in the strong tier instead
2029
+ // and drops the gap's own edges (2, 7) to weak — 15px is inside the
2030
+ // strong radius (20px) but outside the weak one (6px), so on a revert
2031
+ // nothing catches at all and this assertion fails.
2032
+ const project = {
2033
+ id: 'p',
2034
+ tracks: [
2035
+ { id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 12, inPoint: 0, outPoint: 12, sourceDuration: 20 }] },
2036
+ // The gap: 2s–7s, same as the repro above.
2037
+ { id: 'trk-1', items: [{ id: 'g0', type: 'overlay', start: 0, end: 2 }, { id: 'g1', type: 'overlay', start: 7, end: 12 }] },
2038
+ // 3s wide — shorter than the gap, unlike the repro above — and parked
2039
+ // INSIDE it (3s–6s) rather than butted against either neighbour, so
2040
+ // the first, purely-vertical move below doesn't trip the (unrelated)
2041
+ // collision gate the real drop still runs: `resolveTargetTrackIdx`'s
2042
+ // `kindOnly` skip only applies to the TIER computation, not to
2043
+ // `moveItemAcrossTracks`'s own search for where the item actually
2044
+ // lands.
2045
+ { id: 'trk-2', items: [{ id: 'ov', type: 'overlay', start: 3, end: 6 }] },
2046
+ ],
2047
+ } as unknown as Project
2048
+ const ctx = makeContext({ project })
2049
+ const rowY = (trackIdx: number) => {
2050
+ const row = ctx.layout.rows.find(r => r.trackIdx === trackIdx)!
2051
+ return Math.round(row.y + row.height / 2)
2052
+ }
2053
+
2054
+ const d = new Driver(ctx)
2055
+ d.down(450, rowY(2)) // ov's body (3s–6s)
2056
+ d.move(450, rowY(1)) // straight down onto track 1's row
2057
+ // It really did land on track 1, same as the repro above.
2058
+ expect(trackIndexOf(lastProjectChange(d.effects), 'ov')).toBe(1)
2059
+
2060
+ // Slide right so the clip's END approaches the gap's far edge (7s).
2061
+ // rawStart lands on 4.15 — 15px past the tail candidate (7 − 3 = 4):
2062
+ // inside the strong radius, outside the weak one, and nothing else is
2063
+ // anywhere near 4.15 — so this is the tail formula catching alone.
2064
+ expect(of(d.move(565, rowY(1)), 'snapGuide')).toEqual(guide(7, 'strong'))
2065
+ expect(visual(lastProjectChange(d.effects), 'ov')).toMatchObject({ start: 4, end: 7 })
2066
+ })
2067
+ })
2068
+
2069
+ describe('cross-lane re-tiering — a single-item audio move follows the lane it is currently over', () => {
2070
+ it('promotes the destination lane\'s boundary to strong, and demotes the origin lane\'s', () => {
2071
+ const project = {
2072
+ id: 'p',
2073
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 12, inPoint: 0, outPoint: 12, sourceDuration: 20 }] }],
2074
+ audio: {
2075
+ tracks: [
2076
+ { id: 'a0', src: 'v.mp3', start: 1, end: 6, lane: 0 },
2077
+ { id: 'a1', src: 'w.mp3', start: 8, end: 11, lane: 0 },
2078
+ { id: 'b0', src: 'm.mp3', start: 3, end: 4, lane: 1 },
2079
+ ],
2080
+ },
2081
+ } as unknown as Project
2082
+ const ctx = makeContext({ project })
2083
+ const lane0 = ctx.layout.lanes.find(l => l.laneIndex === 0)!
2084
+ const y0 = Math.round(lane0.y + lane0.height / 2)
2085
+ const lane1 = ctx.layout.lanes.find(l => l.laneIndex === 1)!
2086
+ const y1 = Math.round(lane1.y + lane1.height / 2)
2087
+
2088
+ // a0 (1s–6s) dragged toward b0's end at 4s, 8px short — outside the weak
2089
+ // radius, so at rest in lane 0 (b0's boundary tiered weak) it misses.
2090
+ const missesAtRest = new Driver(ctx)
2091
+ missesAtRest.down(300, y0)
2092
+ expect(of(missesAtRest.move(608, y0), 'snapGuide')).toEqual([])
2093
+
2094
+ // The same drag, crossed into lane 1: b0's boundary promotes to strong
2095
+ // and the same 8px catches.
2096
+ const catchesCrossed = new Driver(ctx)
2097
+ catchesCrossed.down(300, y0)
2098
+ expect(of(catchesCrossed.move(608, y1), 'snapGuide')).toEqual(guide(4, 'strong'))
2099
+
2100
+ // a1 (lane 0, a0's own origin lane) is a strong peer at rest — 15px is
2101
+ // inside the strong radius and outside the weak one.
2102
+ const holdsAtRest = new Driver(ctx)
2103
+ holdsAtRest.down(300, y0)
2104
+ expect(of(holdsAtRest.move(1015, y0), 'snapGuide')).toEqual(guide(8, 'strong'))
2105
+
2106
+ // Crossed into lane 1, lane 0 — the lane this drag LEFT — demotes to
2107
+ // weak, and the same 15px is now too far even for that.
2108
+ const releasesCrossed = new Driver(ctx)
2109
+ releasesCrossed.down(300, y0)
2110
+ expect(of(releasesCrossed.move(1015, y1), 'snapGuide')).toEqual([])
2111
+ })
2112
+ })
2113
+
2114
+ describe('degenerate context', () => {
2115
+ it('edits nothing before the surface has a scale', () => {
2116
+ const unscaled: Viewport = { pxPerSecond: 0, scrollSeconds: 0, widthPx: 0 }
2117
+ const d = new Driver(makeContext({ viewport: unscaled }))
2118
+ // Every clip collapses to zero width, so a press lands on empty track area.
2119
+ d.down(0, 60)
2120
+ expect(of(d.move(200, 60), 'projectChange')).toEqual([])
2121
+ })
2122
+
2123
+ it('starts idle with the default cursor', () => {
2124
+ expect(initialMachineState()).toEqual({ kind: 'idle', cursor: 'default' })
2125
+ })
2126
+
2127
+ it('is a pure reducer — the same input twice gives the same output', () => {
2128
+ const ctx = makeContext()
2129
+ const event = { type: 'pointerDown' as const, point: { x: 700, y: 20 }, modifiers: NO_MODIFIERS, ctx }
2130
+ const a = pointerReducer(initialMachineState(), event)
2131
+ const b = pointerReducer(initialMachineState(), event)
2132
+ expect(a.effects).toEqual(b.effects)
2133
+ expect(a.state).toEqual(b.state)
2134
+ })
2135
+
2136
+ it('never mutates the project it is given', () => {
2137
+ const ctx = makeContext()
2138
+ const before = JSON.stringify(ctx.project)
2139
+ const d = new Driver(ctx)
2140
+ d.down(C0_BODY.x, C0_BODY.y)
2141
+ d.move(C0_BODY.x + 100, C0_BODY.y)
2142
+ d.up(C0_BODY.x + 100, C0_BODY.y)
2143
+ expect(JSON.stringify(ctx.project)).toBe(before)
2144
+ })
2145
+ })
2146
+
2147
+
2148
+ // ── Butted neighbours (the narration-snapping regression) ────────────────
2149
+
2150
+ /**
2151
+ * Voice-over is recorded as takes that butt end-to-end, so a voice clip's start
2152
+ * is numerically equal to its neighbour's end. Snap origins used to be excluded
2153
+ * by VALUE, which deleted the neighbour's boundary along with the clip's own —
2154
+ * handing every butted clip a timeline whose one useful magnet had been quietly
2155
+ * removed. Excluding by identity keeps the neighbour; `originGuard` suppresses
2156
+ * it only while the gesture is still sitting on it.
2157
+ */
2158
+ describe('butted neighbours snap back', () => {
2159
+ /** Two audio bars sharing lane 0, butted at `join`, over a video bed. */
2160
+ function butted(join: number, secondEnd: number): Project {
2161
+ return {
2162
+ id: 'p',
2163
+ tracks: [{ id: 'trk-0', items: [
2164
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 30, inPoint: 0, outPoint: 30, sourceDuration: 60 },
2165
+ ] }],
2166
+ audio: { tracks: [
2167
+ { id: 'vo1', src: 'vo1.wav', start: 0, end: join, lane: 0 },
2168
+ { id: 'vo2', src: 'vo2.wav', start: join, end: secondEnd, lane: 0 },
2169
+ ] },
2170
+ } as unknown as Project
2171
+ }
2172
+
2173
+ /** Grab vo2's body, optionally pull well clear first, then land its start at
2174
+ * `landAt` seconds. `viaEscape` is what a real drag away-and-back does; the
2175
+ * origin boundary only re-arms once the gesture has cleared it. */
2176
+ function dragVo2(
2177
+ ctx: PointerContext, grabT: number, from: number, landAt: number,
2178
+ { viaEscape = true } = {},
2179
+ ) {
2180
+ const lane = ctx.layout.lanes[0]
2181
+ const y = Math.round(lane.y + lane.height / 2)
2182
+ const d = new Driver(ctx)
2183
+ const originX = grabT * 100
2184
+ d.down(originX, y)
2185
+ if (viaEscape) d.move(originX + 300, y) // 300px out, far past the 44px release
2186
+ return d.move(originX + (landAt - from) * 100, y)
2187
+ }
2188
+
2189
+ it('snaps a butted bar back onto its neighbour once pulled clear', () => {
2190
+ const project = butted(4, 9)
2191
+ const ctx = makeContext({ project, viewport: VIEWPORT, ...computeDerivedTiming(project) })
2192
+ // Grabbed at t=6, aiming vo2's start at 4.08 — 8px past vo1's end, well
2193
+ // inside the strong radius and well outside the release radius of the
2194
+ // origin it started on, so the boundary is armed.
2195
+ const effects = dragVo2(ctx, 6, 4, 4.08)
2196
+ expect(audio(lastProjectChange(effects), 'vo2').start).toBe(4)
2197
+ expect(of(effects, 'snapGuide')).toEqual(guide(4, 'strong'))
2198
+ })
2199
+
2200
+ it('still lets a butted bar start moving without a 44px fight', () => {
2201
+ // The origin is suppressed while the gesture sits on it, so the first small
2202
+ // drag moves the clip rather than being swallowed by the magnet.
2203
+ const project = butted(4, 9)
2204
+ const ctx = makeContext({ project, viewport: VIEWPORT, ...computeDerivedTiming(project) })
2205
+ const effects = dragVo2(ctx, 6, 4, 4.12, { viaEscape: false })
2206
+ expect(audio(lastProjectChange(effects), 'vo2').start).toBeCloseTo(4.12)
2207
+ })
2208
+
2209
+ it('a gapped neighbour was always fine, and still is', () => {
2210
+ const project = butted(4, 9)
2211
+ ;(project.audio!.tracks as unknown as Array<Record<string, unknown>>)[1] = {
2212
+ id: 'vo2', src: 'vo2.wav', start: 5, end: 10, lane: 0,
2213
+ }
2214
+ const ctx = makeContext({ project, viewport: VIEWPORT, ...computeDerivedTiming(project) })
2215
+ expect(audio(lastProjectChange(dragVo2(ctx, 7, 5, 4.08)), 'vo2').start).toBe(4)
2216
+ })
2217
+
2218
+ it('a bar never snaps to its own edges — only to a real neighbour', () => {
2219
+ // vo2 alone in its lane: pulled clear of the origin there is nothing at 4
2220
+ // for it to catch, so it lands exactly where it was put.
2221
+ const project = {
2222
+ id: 'p',
2223
+ tracks: [{ id: 'trk-0', items: [
2224
+ { id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 30, inPoint: 0, outPoint: 30, sourceDuration: 60 },
2225
+ ] }],
2226
+ audio: { tracks: [{ id: 'vo2', src: 'vo2.wav', start: 4, end: 9, lane: 0 }] },
2227
+ } as unknown as Project
2228
+ const ctx = makeContext({ project, viewport: VIEWPORT, ...computeDerivedTiming(project) })
2229
+ expect(audio(lastProjectChange(dragVo2(ctx, 6, 4, 4.08)), 'vo2').start).toBeCloseTo(4.08)
2230
+ })
2231
+
2232
+ it('applies to butted VIDEO clips too — the same bug, less often seen', () => {
2233
+ // c0 0-5 and c1 5-10 are butted on track 0. Drag c1 left toward 5.08.
2234
+ const d = new Driver(makeContext())
2235
+ d.down(C1_BODY.x, C1_BODY.y)
2236
+ const effects = d.move(C1_BODY.x - (5 - 5.08) * 100 - 300, C1_BODY.y)
2237
+ // Pulled well clear (3s left), then the assertion that matters is simply
2238
+ // that c0's end is a live magnet rather than having been filtered away.
2239
+ const boundaries = of(effects, 'snapGuide')
2240
+ expect(boundaries.length).toBeGreaterThanOrEqual(0)
2241
+ const d2 = new Driver(makeContext())
2242
+ d2.down(C1_BODY.x, C1_BODY.y)
2243
+ d2.move(C1_BODY.x - 300, C1_BODY.y) // break away
2244
+ const back = d2.move(C1_BODY.x + (5.08 - 5) * 100 - 0, C1_BODY.y) // return to 5.08
2245
+ expect(visual(lastProjectChange(back), 'c1').start).toBe(5)
2246
+ })
2247
+ })
2248
+
2249
+ // ── Playhead grab ──────────────────────────────────────────────────────────
2250
+ // Grabbing the full-height playhead line anywhere it is reachable scrubs, on
2251
+ // the same path the ruler uses. The fixture playhead sits at x = playheadTime
2252
+ // × 100 (100px/s, no scroll).
2253
+ describe('playhead grab', () => {
2254
+ it('grabs the playhead through a clip body and scrubs from the press', () => {
2255
+ // Playhead at x=700, inside c1's body (x 500–1000).
2256
+ const d = new Driver(makeContext({ playheadTime: 7 }))
2257
+ const eff = d.down(700, BASE_Y)
2258
+ const st = d.machine.state
2259
+ expect(st.kind).toBe('dragging')
2260
+ if (st.kind === 'dragging') expect(st.gesture).toBe('scrub')
2261
+ expect(of(eff, 'seek').length).toBeGreaterThan(0)
2262
+ expect(of(d.move(760, BASE_Y), 'seek').length).toBeGreaterThan(0)
2263
+ })
2264
+
2265
+ it('does not clear the selection when grabbing the playhead (D3)', () => {
2266
+ const d = new Driver(makeContext({ playheadTime: 7, selectedIds: ['c1'] }))
2267
+ expect(of(d.down(700, BASE_Y), 'select')).toEqual([])
2268
+ })
2269
+
2270
+ it('yields to a trim edge: a handle under the playhead still trims (D1)', () => {
2271
+ // Playhead at x=505, right on c1's in-edge handle.
2272
+ const d = new Driver(makeContext({ playheadTime: 5.05 }))
2273
+ d.down(C1_IN_EDGE.x, C1_IN_EDGE.y)
2274
+ expect(d.machine.state.kind).toBe('pressed') // not a scrub
2275
+ d.move(C1_IN_EDGE.x + 20, C1_IN_EDGE.y)
2276
+ const st = d.machine.state
2277
+ expect(st.kind).toBe('dragging')
2278
+ if (st.kind === 'dragging') expect(st.gesture).toBe('trim')
2279
+ })
2280
+
2281
+ it('an empty press away from the playhead is still a press, not a scrub', () => {
2282
+ const d = new Driver(makeContext({ playheadTime: 1 })) // playhead at x=100
2283
+ d.down(EMPTY.x, EMPTY.y) // x=700, far away
2284
+ expect(d.machine.state.kind).toBe('pressed')
2285
+ })
2286
+
2287
+ it('hovering the playhead shows the scrub cursor (D2)', () => {
2288
+ const d = new Driver(makeContext({ playheadTime: 7 }))
2289
+ expect(of(d.move(700, BASE_Y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'ew-resize' }])
2290
+ })
2291
+
2292
+ it('the ruler still clears the selection, unlike a playhead grab', () => {
2293
+ const d = new Driver(makeContext({ selectedIds: ['c1'] }))
2294
+ expect(of(d.down(400, RULER_Y), 'select')).toEqual([{ type: 'select', id: null, additive: false }])
2295
+ })
2296
+ })
2297
+
2298
+ // ── Multi-select move: the selection travels as one ────────────────────────
2299
+ describe('multi-select move', () => {
2300
+ it('shifts every selected item by one delta, preserving the gaps', () => {
2301
+ const d = new Driver(makeContext({ selectedIds: ['c0', 'c1'] }))
2302
+ d.down(C0_BODY.x, C0_BODY.y)
2303
+ const eff = d.move(C0_BODY.x + 120, C0_BODY.y)
2304
+ // A group move never re-selects — the selection is preserved as-is.
2305
+ expect(of(eff, 'selectMany')).toEqual([])
2306
+ const proj = lastProjectChange(eff)
2307
+ const c0 = visual(proj, 'c0'), c1 = visual(proj, 'c1')
2308
+ const delta = c0.start - 0
2309
+ expect(delta).toBeGreaterThan(0)
2310
+ expect(c1.start - 5).toBeCloseTo(delta) // c1 shifted by the SAME delta
2311
+ expect(c0.end - c0.start).toBeCloseTo(5) // durations preserved
2312
+ expect(c1.end - c1.start).toBeCloseTo(5)
2313
+ })
2314
+
2315
+ it('a drag on an item NOT in the selection selects just it and moves it alone', () => {
2316
+ const d = new Driver(makeContext({ selectedIds: ['c1'] }))
2317
+ d.down(C0_BODY.x, C0_BODY.y) // press c0, which is not selected
2318
+ const eff = d.move(C0_BODY.x + 120, C0_BODY.y)
2319
+ expect(of(eff, 'selectMany')).toEqual([{ type: 'selectMany', ids: ['c0'], additive: false }])
2320
+ const proj = lastProjectChange(eff)
2321
+ expect(visual(proj, 'c0').start).toBeGreaterThan(0) // c0 moved
2322
+ expect(visual(proj, 'c1').start).toBe(5) // c1 untouched
2323
+ })
2324
+
2325
+ it('clamps the group as a body so no member leaves the timeline', () => {
2326
+ // c0 already starts at 0, so dragging the pair hard left cannot move it.
2327
+ const d = new Driver(makeContext({ selectedIds: ['c0', 'c1'] }))
2328
+ d.down(C0_BODY.x, C0_BODY.y)
2329
+ const changes = of(d.move(C0_BODY.x - 300, C0_BODY.y), 'projectChange')
2330
+ if (changes.length) {
2331
+ const proj = changes[changes.length - 1].project
2332
+ expect(visual(proj, 'c0').start).toBe(0)
2333
+ expect(visual(proj, 'c1').start).toBe(5)
2334
+ }
2335
+ })
2336
+ })
2337
+
2338
+ // ── Captions ─────────────────────────────────────────────────────────────
2339
+ //
2340
+ // Caption blocks live in the canvas timeline now, and behave exactly like
2341
+ // overlay clips: click to select, drag the body to re-time, drag an edge to
2342
+ // trim, group-move with whatever else is selected. Their ids share the ONE
2343
+ // `selectedIds` array with clips and audio, which is what makes a mixed
2344
+ // clip+caption drag land as a single undo.
2345
+ //
2346
+ // Its own fixture — one clip and a caption band, nothing else — so the base
2347
+ // project's row geometry above stays exactly as those tests assume:
2348
+ //
2349
+ // band caption s0 1s–2s (with word timings), s1 3.44s–4.5s, one id-less
2350
+ // row track 0 — c0 0s–10s
2351
+ //
2352
+ // s1's start is 3.44 on purpose: at 30fps that is frame 103.2, the fractional
2353
+ // part below 0.5 that makes the naive seek-to-`start` land in the PREVIOUS
2354
+ // segment. See the click-seek suite.
2355
+
2356
+ const CAPTION_FPS = 30
2357
+
2358
+ function captionProject(): Project {
2359
+ return {
2360
+ id: 'p',
2361
+ tracks: [{
2362
+ id: 'trk-0',
2363
+ items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
2364
+ }],
2365
+ captions: {
2366
+ style: 'pop',
2367
+ segments: [
2368
+ {
2369
+ id: 's0',
2370
+ text: 'hello there',
2371
+ start: 1,
2372
+ end: 2,
2373
+ words: [
2374
+ { word: 'hello', start: 1, end: 1.6 },
2375
+ { word: 'there', start: 1.6, end: 2 },
2376
+ ],
2377
+ },
2378
+ { id: 's1', text: 'second', start: 3.44, end: 4.5 },
2379
+ // No id yet — `backfillCaptionIds` has not run. Never hittable.
2380
+ { text: 'unminted', start: 7, end: 8 },
2381
+ ],
2382
+ },
2383
+ } as unknown as Project
2384
+ }
2385
+
2386
+ const CAPTION_LAYOUT = computeTimelineLayout(captionProject())
2387
+ const CAPTION_Y = Math.round(CAPTION_LAYOUT.captions![0].y + CAPTION_LAYOUT.captions![0].height / 2)
2388
+
2389
+ const S0_BODY = { x: 150, y: CAPTION_Y }
2390
+ const S0_IN_EDGE = { x: 102, y: CAPTION_Y }
2391
+ const S0_OUT_EDGE = { x: 198, y: CAPTION_Y }
2392
+ const S1_BODY = { x: 400, y: CAPTION_Y }
2393
+ /** s1's in handle (s1 spans x 344–450). */
2394
+ const S1_IN_EDGE = { x: 346, y: CAPTION_Y }
2395
+ /** Over the id-less segment (x 700–800), which is band background. */
2396
+ const UNMINTED = { x: 750, y: CAPTION_Y }
2397
+ /** The gap between s0 and s1. */
2398
+ const CAPTION_GAP = { x: 260, y: CAPTION_Y }
2399
+
2400
+ function captionCtx(overrides: Partial<PointerContext> = {}): PointerContext {
2401
+ return makeContext({ project: captionProject(), fps: CAPTION_FPS, ...overrides })
2402
+ }
2403
+
2404
+ function caption(project: Project, id: string) {
2405
+ return (project.captions?.segments ?? []).find(s => s.id === id)!
2406
+ }
2407
+
2408
+ describe('caption gestures — resolution and affordances', () => {
2409
+ const ctx = captionCtx()
2410
+ const bodyHit = hitTest(S0_BODY, ctx.layout, VIEWPORT)
2411
+ const edgeHit = hitTest(S0_OUT_EDGE, ctx.layout, VIEWPORT)
2412
+
2413
+ it('a body-drag re-times and an edge-drag trims', () => {
2414
+ expect(resolveGesture(bodyHit, mods())).toBe('caption-move')
2415
+ expect(resolveGesture(edgeHit, mods())).toBe('caption-trim')
2416
+ })
2417
+
2418
+ it('has no roll/slip/slide, so modifiers fall through as they do for audio', () => {
2419
+ expect(resolveGesture(bodyHit, mods({ alt: true }))).toBe('caption-move')
2420
+ expect(resolveGesture(bodyHit, mods({ meta: true }))).toBe('caption-move')
2421
+ expect(resolveGesture(edgeHit, mods({ alt: true }))).toBe('caption-trim')
2422
+ })
2423
+
2424
+ it('shows the resize cursor on an edge and the grab cursor on a body', () => {
2425
+ expect(cursorForHit(edgeHit)).toBe('ew-resize')
2426
+ expect(cursorForHit(bodyHit)).toBe('grab')
2427
+ })
2428
+
2429
+ it('switches to the gesture cursor once the drag starts', () => {
2430
+ const move = new Driver(captionCtx())
2431
+ move.down(S0_BODY.x, S0_BODY.y)
2432
+ expect(of(move.move(S0_BODY.x + 50, S0_BODY.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'grabbing' }])
2433
+
2434
+ // A trim PRESS already set `ew-resize` from the hit, and the gesture's own
2435
+ // cursor is the same one — so the machine says nothing more, because it
2436
+ // only speaks up when the cursor actually changes.
2437
+ const trim = new Driver(captionCtx())
2438
+ expect(of(trim.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y), 'cursor')).toEqual([{ type: 'cursor', cursor: 'ew-resize' }])
2439
+ expect(of(trim.move(S0_OUT_EDGE.x + 50, S0_OUT_EDGE.y), 'cursor')).toEqual([])
2440
+ expect(trim.machine.state.cursor).toBe('ew-resize')
2441
+ })
2442
+ })
2443
+
2444
+ describe('caption click — select, and seek half a frame IN', () => {
2445
+ it('selects on release and seeks to start + half a frame', () => {
2446
+ const d = new Driver(captionCtx())
2447
+ d.down(S1_BODY.x, S1_BODY.y)
2448
+ const effects = d.up(S1_BODY.x, S1_BODY.y)
2449
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: 's1', additive: false }])
2450
+ expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 3.44 + 0.5 / CAPTION_FPS }])
2451
+ })
2452
+
2453
+ it('lands inside the segment once the preview snaps the clock to the frame grid', () => {
2454
+ // The regression this exists for. CaptionPreview runs
2455
+ // `t = Math.round(currentTime * fps) / fps` before the templates' own
2456
+ // `t >= start && t < end` test, and 3.44 × 30 = 103.2 rounds DOWN.
2457
+ const snapToGrid = (t: number) => Math.round(t * CAPTION_FPS) / CAPTION_FPS
2458
+ expect(snapToGrid(3.44)).toBeLessThan(3.44) // seeking to `start` misses
2459
+ const d = new Driver(captionCtx())
2460
+ d.down(S1_BODY.x, S1_BODY.y)
2461
+ const seeked = of(d.up(S1_BODY.x, S1_BODY.y), 'seek')[0].time
2462
+ expect(snapToGrid(seeked)).toBeGreaterThanOrEqual(3.44) // half a frame in does not
2463
+ expect(snapToGrid(seeked)).toBeLessThan(4.5)
2464
+ })
2465
+
2466
+ it('seeks to the segment start, NOT to where the pointer clicked', () => {
2467
+ // x=400 is t=4, well inside s1 — a visual clip would seek there.
2468
+ const d = new Driver(captionCtx())
2469
+ d.down(S1_BODY.x, S1_BODY.y)
2470
+ expect(of(d.up(S1_BODY.x, S1_BODY.y), 'seek')[0].time).not.toBeCloseTo(4)
2471
+ })
2472
+
2473
+ it('does not seek when the caption was already selected', () => {
2474
+ const d = new Driver(captionCtx({ selectedIds: ['s1'] }))
2475
+ d.down(S1_BODY.x, S1_BODY.y)
2476
+ const effects = d.up(S1_BODY.x, S1_BODY.y)
2477
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: 's1', additive: false }])
2478
+ expect(of(effects, 'seek')).toEqual([])
2479
+ })
2480
+
2481
+ it('does not seek on an additive click', () => {
2482
+ const d = new Driver(captionCtx())
2483
+ d.down(S1_BODY.x, S1_BODY.y, mods({ shift: true }))
2484
+ const effects = d.up(S1_BODY.x, S1_BODY.y, mods({ shift: true }))
2485
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: 's1', additive: true }])
2486
+ expect(of(effects, 'seek')).toEqual([])
2487
+ })
2488
+
2489
+ it('selects from a press on a trim handle that never became a drag', () => {
2490
+ const d = new Driver(captionCtx())
2491
+ d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2492
+ const effects = d.up(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2493
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: 's0', additive: false }])
2494
+ expect(of(effects, 'commit')).toEqual([])
2495
+ })
2496
+
2497
+ it('treats a gap in the band as background: clears the selection and seeks there', () => {
2498
+ const d = new Driver(captionCtx({ selectedIds: ['s0'] }))
2499
+ d.down(CAPTION_GAP.x, CAPTION_GAP.y)
2500
+ const effects = d.up(CAPTION_GAP.x, CAPTION_GAP.y)
2501
+ expect(of(effects, 'select')).toEqual([{ type: 'select', id: null, additive: false }])
2502
+ expect(of(effects, 'seek')).toEqual([{ type: 'seek', time: 2.6 }])
2503
+ })
2504
+
2505
+ it('never selects a segment that has no id yet', () => {
2506
+ const d = new Driver(captionCtx())
2507
+ d.down(UNMINTED.x, UNMINTED.y)
2508
+ expect(of(d.up(UNMINTED.x, UNMINTED.y), 'select')).toEqual([{ type: 'select', id: null, additive: false }])
2509
+ })
2510
+ })
2511
+
2512
+ describe('caption body drag — move', () => {
2513
+ it('MOVES a caption that was not selected when the drag began', () => {
2514
+ // The stale-selection trap. `selectMany` selects the grabbed caption, but
2515
+ // the host has not echoed it back into `ctx.selectedIds` yet, so a move
2516
+ // that keyed off `ctx.selectedIds` alone would shift nothing at all and
2517
+ // the block would sit still under the pointer.
2518
+ const d = new Driver(captionCtx())
2519
+ d.down(S0_BODY.x, S0_BODY.y)
2520
+ const eff = d.move(S0_BODY.x + 100, S0_BODY.y)
2521
+ expect(of(eff, 'selectMany')).toEqual([{ type: 'selectMany', ids: ['s0'], additive: false }])
2522
+ const moved = caption(lastProjectChange(eff), 's0')
2523
+ expect(moved.start).toBeCloseTo(2)
2524
+ expect(moved.end).toBeCloseTo(3)
2525
+ })
2526
+
2527
+ it('moves ONLY the grabbed caption when something else holds the selection', () => {
2528
+ const d = new Driver(captionCtx({ selectedIds: ['c0'] }))
2529
+ d.down(S0_BODY.x, S0_BODY.y)
2530
+ const proj = lastProjectChange(d.move(S0_BODY.x + 100, S0_BODY.y))
2531
+ expect(caption(proj, 's0').start).toBeCloseTo(2)
2532
+ expect(visual(proj, 'c0').start).toBe(0) // the old selection stayed put
2533
+ })
2534
+
2535
+ it('carries every word timing with the segment', () => {
2536
+ const d = new Driver(captionCtx())
2537
+ d.down(S0_BODY.x, S0_BODY.y)
2538
+ const moved = caption(lastProjectChange(d.move(S0_BODY.x + 100, S0_BODY.y)), 's0')
2539
+ expect(moved.words).toEqual([
2540
+ { word: 'hello', start: 2, end: 2.6 },
2541
+ { word: 'there', start: 2.6, end: 3 },
2542
+ ])
2543
+ expect(moved.text).toBe('hello there')
2544
+ })
2545
+
2546
+ it('emits one projectChange per move and exactly ONE commit at release', () => {
2547
+ const d = new Driver(captionCtx({ selectedIds: ['s0'] }))
2548
+ d.down(S0_BODY.x, S0_BODY.y)
2549
+ expect(of(d.move(S0_BODY.x + 50, S0_BODY.y), 'projectChange')).toHaveLength(1)
2550
+ expect(of(d.move(S0_BODY.x + 80, S0_BODY.y), 'projectChange')).toHaveLength(1)
2551
+ expect(of(d.move(S0_BODY.x + 100, S0_BODY.y), 'projectChange')).toHaveLength(1)
2552
+ const committed = of(d.up(S0_BODY.x + 100, S0_BODY.y), 'commit')
2553
+ expect(committed).toHaveLength(1)
2554
+ expect(caption(committed[0].project, 's0').start).toBeCloseTo(2)
2555
+ })
2556
+
2557
+ it('co-moves a whole caption selection under one delta', () => {
2558
+ const d = new Driver(captionCtx({ selectedIds: ['s0', 's1'] }))
2559
+ d.down(S0_BODY.x, S0_BODY.y)
2560
+ const eff = d.move(S0_BODY.x + 100, S0_BODY.y)
2561
+ // A group move never re-selects.
2562
+ expect(of(eff, 'selectMany')).toEqual([])
2563
+ const proj = lastProjectChange(eff)
2564
+ expect(caption(proj, 's0')).toMatchObject({ start: 2, end: 3 })
2565
+ expect(caption(proj, 's1').start).toBeCloseTo(4.44)
2566
+ expect(caption(proj, 's1').end).toBeCloseTo(5.5)
2567
+ expect(of(d.up(S0_BODY.x + 100, S0_BODY.y), 'commit')).toHaveLength(1)
2568
+ })
2569
+
2570
+ it('co-moves a MIXED clip+caption selection, and commits it once', () => {
2571
+ // The point of putting caption ids in the same `selectedIds` array: one
2572
+ // gesture, one `projectChange` stream, one undo entry.
2573
+ const d = new Driver(captionCtx({ selectedIds: ['c0', 's0'] }))
2574
+ d.down(S0_BODY.x, S0_BODY.y)
2575
+ const proj = lastProjectChange(d.move(S0_BODY.x + 100, S0_BODY.y))
2576
+ expect(visual(proj, 'c0')).toMatchObject({ start: 1, end: 11 })
2577
+ expect(caption(proj, 's0')).toMatchObject({ start: 2, end: 3 })
2578
+ const committed = of(d.up(S0_BODY.x + 100, S0_BODY.y), 'commit')
2579
+ expect(committed).toHaveLength(1)
2580
+ expect(caption(committed[0].project, 's0').start).toBeCloseTo(2)
2581
+ expect(visual(committed[0].project, 'c0').start).toBeCloseTo(1)
2582
+ })
2583
+
2584
+ it('clamps the group as a body so no member leaves the timeline', () => {
2585
+ // Single caption, no other captions in the project — isolates the
2586
+ // timeline-edge clamp from the caption-neighbour clamp (its own describe
2587
+ // block below), which would otherwise stop s0 at s1's start (3.44) long
2588
+ // before it ever reached `ctx.totalDuration`.
2589
+ const soloProject: Project = {
2590
+ ...captionProject(),
2591
+ captions: { style: 'pop', segments: [{ id: 's0', text: 'hello there', start: 1, end: 2 }] },
2592
+ } as unknown as Project
2593
+ const ctx = captionCtx({ project: soloProject, selectedIds: ['s0'] })
2594
+ const d = new Driver(ctx)
2595
+ d.down(S0_BODY.x, S0_BODY.y)
2596
+ expect(caption(lastProjectChange(d.move(-9999, S0_BODY.y)), 's0').start).toBe(0)
2597
+ expect(caption(lastProjectChange(d.move(99999, S0_BODY.y)), 's0').end).toBeCloseTo(ctx.totalDuration)
2598
+ })
2599
+
2600
+ it('commits nothing when the drag ends back where it started', () => {
2601
+ const d = new Driver(captionCtx({ selectedIds: ['s0'] }))
2602
+ d.down(S0_BODY.x, S0_BODY.y)
2603
+ d.move(S0_BODY.x + 100, S0_BODY.y)
2604
+ d.move(S0_BODY.x, S0_BODY.y)
2605
+ expect(of(d.up(S0_BODY.x, S0_BODY.y), 'commit')).toEqual([])
2606
+ })
2607
+ })
2608
+
2609
+ describe('caption edge drag — trim', () => {
2610
+ it('moves ONLY the dragged out edge', () => {
2611
+ const d = new Driver(captionCtx())
2612
+ d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2613
+ // The edge lands at its own time plus the pointer's travel: the press was
2614
+ // at x=198 (inside the 6px handle), so 250 is 0.52s further on.
2615
+ const trimmed = caption(lastProjectChange(d.move(250, S0_OUT_EDGE.y)), 's0')
2616
+ expect(trimmed.end).toBeCloseTo(2.52)
2617
+ expect(trimmed.start).toBe(1)
2618
+ })
2619
+
2620
+ it('moves ONLY the dragged in edge', () => {
2621
+ const d = new Driver(captionCtx())
2622
+ d.down(S0_IN_EDGE.x, S0_IN_EDGE.y)
2623
+ const trimmed = caption(lastProjectChange(d.move(150, S0_IN_EDGE.y)), 's0')
2624
+ expect(trimmed.start).toBeCloseTo(1.48)
2625
+ expect(trimmed.end).toBe(2)
2626
+ })
2627
+
2628
+ it('never touches text or words — a retime is not a respread', () => {
2629
+ const before = captionProject()
2630
+ const d = new Driver(makeContext({ project: before, fps: CAPTION_FPS }))
2631
+ d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2632
+ const trimmed = caption(lastProjectChange(d.move(250, S0_OUT_EDGE.y)), 's0')
2633
+ expect(trimmed.text).toBe('hello there')
2634
+ // The same array object, not merely the same values: nothing respread it.
2635
+ expect(trimmed.words).toBe(caption(before, 's0').words)
2636
+ })
2637
+
2638
+ it('leaves every OTHER segment alone', () => {
2639
+ const d = new Driver(captionCtx())
2640
+ d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2641
+ const proj = lastProjectChange(d.move(250, S0_OUT_EDGE.y))
2642
+ expect(caption(proj, 's1')).toMatchObject({ start: 3.44, end: 4.5 })
2643
+ })
2644
+
2645
+ it('never trims below the minimum duration, from either edge', () => {
2646
+ // The floor the click-seek leans on: no segment is ever under one frame.
2647
+ expect(CAPTION_MIN_DURATION_S).toBeGreaterThan(1 / CAPTION_FPS)
2648
+
2649
+ const out = new Driver(captionCtx())
2650
+ out.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2651
+ expect(caption(lastProjectChange(out.move(20, S0_OUT_EDGE.y)), 's0').end)
2652
+ .toBeCloseTo(1 + CAPTION_MIN_DURATION_S)
2653
+
2654
+ const inn = new Driver(captionCtx())
2655
+ inn.down(S0_IN_EDGE.x, S0_IN_EDGE.y)
2656
+ expect(caption(lastProjectChange(inn.move(600, S0_IN_EDGE.y)), 's0').start)
2657
+ .toBeCloseTo(2 - CAPTION_MIN_DURATION_S)
2658
+ })
2659
+
2660
+ it('does not trim a multi-selection — v1 trims one segment', () => {
2661
+ const d = new Driver(captionCtx({ selectedIds: ['s0', 's1'] }))
2662
+ d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2663
+ const proj = lastProjectChange(d.move(250, S0_OUT_EDGE.y))
2664
+ expect(caption(proj, 's0').end).toBeCloseTo(2.52)
2665
+ expect(caption(proj, 's1')).toMatchObject({ start: 3.44, end: 4.5 })
2666
+ })
2667
+
2668
+ it('commits once, and only what actually changed', () => {
2669
+ const d = new Driver(captionCtx())
2670
+ d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2671
+ d.move(250, S0_OUT_EDGE.y)
2672
+ const committed = of(d.up(250, S0_OUT_EDGE.y), 'commit')
2673
+ expect(committed).toHaveLength(1)
2674
+ expect(caption(committed[0].project, 's0')).toMatchObject({ start: 1, end: 2.52 })
2675
+ })
2676
+
2677
+ it('commits nothing when the edge ends back where it started', () => {
2678
+ const d = new Driver(captionCtx())
2679
+ d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2680
+ d.move(250, S0_OUT_EDGE.y)
2681
+ d.move(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2682
+ expect(of(d.up(S0_OUT_EDGE.x, S0_OUT_EDGE.y), 'commit')).toEqual([])
2683
+ })
2684
+
2685
+ it("stops an end-edge trim at the next caption's start — captions may not overlap", () => {
2686
+ // Dragged far past s1 (x=2000, t=20). Without the neighbour clamp this
2687
+ // would land at `Math.max(ctx.totalDuration, seg.end)` = 15; with it, s1
2688
+ // starts at 3.44 and that is the real ceiling.
2689
+ const d = new Driver(captionCtx())
2690
+ d.down(S0_OUT_EDGE.x, S0_OUT_EDGE.y)
2691
+ const trimmed = caption(lastProjectChange(d.move(2000, S0_OUT_EDGE.y)), 's0')
2692
+ expect(trimmed.end).toBeCloseTo(3.44)
2693
+ expect(trimmed.start).toBe(1)
2694
+ })
2695
+
2696
+ it("stops a start-edge trim at the previous caption's end — captions may not overlap", () => {
2697
+ // Dragged far past s0 (x=0, t=0). Without the neighbour clamp this would
2698
+ // land at 0 (`lo`'s flat floor); with it, s0 ends at 2 and that is the
2699
+ // real floor.
2700
+ const d = new Driver(captionCtx())
2701
+ d.down(S1_IN_EDGE.x, S1_IN_EDGE.y)
2702
+ const trimmed = caption(lastProjectChange(d.move(0, S1_IN_EDGE.y)), 's1')
2703
+ expect(trimmed.start).toBeCloseTo(2)
2704
+ expect(trimmed.end).toBeCloseTo(4.5)
2705
+ })
2706
+
2707
+ it('a drag clamped to zero movement by a butted neighbour still emits no commit', () => {
2708
+ // Two captions already touching (a's end === b's start === 2, the
2709
+ // `resolveRow` tie-break case): a's out edge has no legal room to move
2710
+ // right at all, so `hi` equals its own current value from the first
2711
+ // frame — the clamp lands it back on `initTime` every time.
2712
+ const butted: Project = {
2713
+ id: 'p',
2714
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }] }],
2715
+ captions: {
2716
+ style: 'pop',
2717
+ segments: [
2718
+ { id: 'a', text: 'first', start: 1, end: 2 },
2719
+ { id: 'b', text: 'second', start: 2, end: 3 },
2720
+ ],
2721
+ },
2722
+ } as unknown as Project
2723
+ const d = new Driver(captionCtx({ project: butted }))
2724
+ // a's out edge sits at x=198 (200 - 2, same offset every other edge point
2725
+ // in this file uses), safely inside a and not on the shared boundary.
2726
+ d.down(198, CAPTION_Y)
2727
+ const move = d.move(280, CAPTION_Y)
2728
+ expect(of(move, 'projectChange')).toEqual([])
2729
+ expect(caption(butted, 'a')).toMatchObject({ start: 1, end: 2 })
2730
+ expect(of(d.up(280, CAPTION_Y), 'commit')).toEqual([])
2731
+ })
2732
+ })
2733
+
2734
+ describe('caption playhead grab', () => {
2735
+ it('a grab over a caption BODY scrubs instead of selecting', () => {
2736
+ // Playhead at x=150, inside s0's body.
2737
+ const d = new Driver(captionCtx({ playheadTime: 1.5 }))
2738
+ const eff = d.down(150, CAPTION_Y)
2739
+ const st = d.machine.state
2740
+ expect(st.kind).toBe('dragging')
2741
+ if (st.kind === 'dragging') expect(st.gesture).toBe('scrub')
2742
+ expect(of(eff, 'seek').length).toBeGreaterThan(0)
2743
+ expect(of(eff, 'select')).toEqual([])
2744
+ })
2745
+
2746
+ it('a grab over a caption EDGE trims — the precise target wins (D1)', () => {
2747
+ // Playhead at x=102, right on s0's in handle.
2748
+ const d = new Driver(captionCtx({ playheadTime: 1.02 }))
2749
+ d.down(S0_IN_EDGE.x, S0_IN_EDGE.y)
2750
+ expect(d.machine.state.kind).toBe('pressed') // not a scrub
2751
+ d.move(S0_IN_EDGE.x + 30, S0_IN_EDGE.y)
2752
+ const st = d.machine.state
2753
+ expect(st.kind).toBe('dragging')
2754
+ if (st.kind === 'dragging') expect(st.gesture).toBe('caption-trim')
2755
+ })
2756
+ })
2757
+
2758
+ describe('caption double-click', () => {
2759
+ it('opens the caption for editing rather than an inspector', () => {
2760
+ const d = new Driver(captionCtx())
2761
+ expect(d.doubleClick(S0_BODY.x, S0_BODY.y)).toEqual([{ type: 'editCaption', id: 's0' }])
2762
+ })
2763
+
2764
+ it('does the same from a caption edge', () => {
2765
+ const d = new Driver(captionCtx())
2766
+ expect(d.doubleClick(S0_OUT_EDGE.x, S0_OUT_EDGE.y)).toEqual([{ type: 'editCaption', id: 's0' }])
2767
+ })
2768
+
2769
+ it('does nothing in the gaps between blocks', () => {
2770
+ const d = new Driver(captionCtx())
2771
+ expect(d.doubleClick(CAPTION_GAP.x, CAPTION_GAP.y)).toEqual([])
2772
+ expect(d.doubleClick(UNMINTED.x, UNMINTED.y)).toEqual([])
2773
+ })
2774
+ })
2775
+
2776
+ // ── Caption trim at the timeline's own edges ──────────────────────────────
2777
+ //
2778
+ // The trim has two invariants that can fight each other: the edge stays inside
2779
+ // [0, totalDuration], and the segment stays at least `CAPTION_MIN_DURATION_S`
2780
+ // long. They only collide on a segment ALREADY shorter than the floor sitting
2781
+ // flush against a boundary — which Whisper produces routinely (a one-syllable
2782
+ // interjection) and which a caption move can push against `totalDuration`.
2783
+ //
2784
+ // Zoomed to 1000px/s: these blocks are 50px wide, so their handles are
2785
+ // separately grabbable. At the fixture's usual 100px/s a 5px block would
2786
+ // resolve every point to its OUT handle and the in-edge probes would miss.
2787
+
2788
+ const TINY_VIEWPORT: Viewport = { pxPerSecond: 1000, scrollSeconds: 0, widthPx: 1000 }
2789
+
2790
+ function tinyCaptionProject(): Project {
2791
+ return {
2792
+ id: 'p',
2793
+ tracks: [{
2794
+ id: 'trk-0',
2795
+ items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
2796
+ }],
2797
+ captions: {
2798
+ style: 'pop',
2799
+ segments: [
2800
+ // Shorter than the floor, flush against t=0.
2801
+ { id: 'head', text: 'uh', start: 0, end: 0.05 },
2802
+ // Shorter than the floor, flush against totalDuration (10 + 5 = 15).
2803
+ { id: 'tail', text: 'hm', start: 14.95, end: 15 },
2804
+ // Shorter than the floor, but nowhere near either boundary.
2805
+ { id: 'middle', text: 'so', start: 5, end: 5.05 },
2806
+ ],
2807
+ },
2808
+ } as unknown as Project
2809
+ }
2810
+
2811
+ const TINY_LAYOUT = computeTimelineLayout(tinyCaptionProject())
2812
+ const TINY_Y = Math.round(TINY_LAYOUT.captions![0].y + TINY_LAYOUT.captions![0].height / 2)
2813
+
2814
+ function tinyCtx(overrides: Partial<PointerContext> = {}): PointerContext {
2815
+ return makeContext({ project: tinyCaptionProject(), fps: CAPTION_FPS, viewport: TINY_VIEWPORT, ...overrides })
2816
+ }
2817
+
2818
+ describe('caption trim — a sub-floor segment pinned at a timeline edge', () => {
2819
+ it('the fixture is the collision case: sub-floor segments, flush at both ends', () => {
2820
+ const ctx = tinyCtx()
2821
+ expect(ctx.totalDuration).toBe(15)
2822
+ for (const id of ['head', 'tail', 'middle']) {
2823
+ const seg = caption(ctx.project, id)
2824
+ expect(seg.end - seg.start).toBeLessThan(CAPTION_MIN_DURATION_S)
2825
+ }
2826
+ })
2827
+
2828
+ it('never drags the in edge below t=0 — it declines to move at all', () => {
2829
+ // head is 0s–0.05s, so its in edge has NO legal position: t=0 is already
2830
+ // the floor's ceiling minus a negative. Bounds-then-floor put it at -0.05.
2831
+ const ctx = tinyCtx()
2832
+ const d = new Driver(ctx)
2833
+ d.down(2, TINY_Y) // head's in handle (x 0–50, in zone ≤ 6)
2834
+ const eff = d.move(-100, TINY_Y)
2835
+ expect(of(eff, 'projectChange')).toEqual([])
2836
+ const st = d.machine.state
2837
+ expect(st.kind).toBe('dragging')
2838
+ if (st.kind === 'dragging') expect(st.lastProject).toBe(ctx.project)
2839
+ expect(caption(ctx.project, 'head')).toMatchObject({ start: 0, end: 0.05 })
2840
+ expect(of(d.up(-100, TINY_Y), 'commit')).toEqual([])
2841
+ })
2842
+
2843
+ it('never drags the out edge past totalDuration — it declines to move at all', () => {
2844
+ // tail is 14.95s–15s against a 15s horizon. Bounds-then-floor put its out
2845
+ // edge at 15.05.
2846
+ const ctx = tinyCtx()
2847
+ const d = new Driver(ctx)
2848
+ d.down(14998, TINY_Y) // tail's out handle (x 14950–15000)
2849
+ const eff = d.move(15100, TINY_Y)
2850
+ expect(of(eff, 'projectChange')).toEqual([])
2851
+ const st = d.machine.state
2852
+ expect(st.kind).toBe('dragging')
2853
+ if (st.kind === 'dragging') expect(st.lastProject).toBe(ctx.project)
2854
+ expect(caption(ctx.project, 'tail')).toMatchObject({ start: 14.95, end: 15 })
2855
+ expect(of(d.up(15100, TINY_Y), 'commit')).toEqual([])
2856
+ })
2857
+
2858
+ it('still trims a sub-floor segment that is NOT pinned — the range is not empty', () => {
2859
+ // The guard must decline only when there is genuinely nowhere legal to go.
2860
+ // middle is 5s–5.05s with room on both sides, so dragging its out edge left
2861
+ // grows it to the floor rather than refusing.
2862
+ const d = new Driver(tinyCtx())
2863
+ d.down(5048, TINY_Y) // middle's out handle (x 5000–5050)
2864
+ const trimmed = caption(lastProjectChange(d.move(4900, TINY_Y)), 'middle')
2865
+ expect(trimmed.end).toBeCloseTo(5 + CAPTION_MIN_DURATION_S)
2866
+ expect(trimmed.start).toBe(5)
2867
+ })
2868
+
2869
+ it('keeps every caption inside the timeline however far the pointer travels', () => {
2870
+ // A blanket guard over both edges of all three segments: no drag, at any
2871
+ // distance, may put a caption edge outside [0, totalDuration].
2872
+ const ctx = tinyCtx()
2873
+ // head's in handle, tail's out handle, then both of middle's.
2874
+ for (const x of [2, 14998, 5002, 5048]) {
2875
+ const d = new Driver(ctx)
2876
+ d.down(x, TINY_Y)
2877
+ for (const to of [-9999, 0, 5000, 15000, 99999]) {
2878
+ const changes = of(d.move(to, TINY_Y), 'projectChange')
2879
+ if (changes.length === 0) continue
2880
+ for (const seg of changes[changes.length - 1].project.captions!.segments) {
2881
+ expect(seg.start).toBeGreaterThanOrEqual(0)
2882
+ expect(seg.end).toBeLessThanOrEqual(ctx.totalDuration)
2883
+ }
2884
+ }
2885
+ }
2886
+ })
2887
+ })
2888
+
2889
+ // ── Caption trim on a caption that already overhangs the timeline ─────────
2890
+ //
2891
+ // `totalDuration` is derived from clips and audio only, so a caption can end
2892
+ // past it once clips are trimmed or deleted after captioning. The rule is that
2893
+ // a trim never pushes a caption FURTHER out than it already was — it may not
2894
+ // make an overhang worse, and it may not "helpfully" shrink one either.
2895
+ //
2896
+ // `over` straddles the horizon: start 14.5 inside a 15s timeline, end 16
2897
+ // beyond it. Same 1000px/s viewport, so x = t × 1000.
2898
+
2899
+ function overhangCaptionProject(): Project {
2900
+ return {
2901
+ id: 'p',
2902
+ tracks: [{
2903
+ id: 'trk-0',
2904
+ items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
2905
+ }],
2906
+ captions: {
2907
+ style: 'pop',
2908
+ segments: [{ id: 'over', text: 'trailing', start: 14.5, end: 16 }],
2909
+ },
2910
+ } as unknown as Project
2911
+ }
2912
+
2913
+ const OVERHANG_LAYOUT = computeTimelineLayout(overhangCaptionProject())
2914
+ const OVERHANG_Y = Math.round(OVERHANG_LAYOUT.captions![0].y + OVERHANG_LAYOUT.captions![0].height / 2)
2915
+ /** `over`'s handles: the block spans x 14500–16000. */
2916
+ const OVER_IN_EDGE = 14502
2917
+ const OVER_OUT_EDGE = 15998
2918
+
2919
+ function overhangCtx(overrides: Partial<PointerContext> = {}): PointerContext {
2920
+ return makeContext({ project: overhangCaptionProject(), fps: CAPTION_FPS, viewport: TINY_VIEWPORT, ...overrides })
2921
+ }
2922
+
2923
+ describe('caption trim — a caption that already overhangs the timeline', () => {
2924
+ it('the fixture really does overhang', () => {
2925
+ const ctx = overhangCtx()
2926
+ expect(ctx.totalDuration).toBe(15)
2927
+ expect(caption(ctx.project, 'over').end).toBeGreaterThan(ctx.totalDuration)
2928
+ expect(caption(ctx.project, 'over').start).toBeLessThan(ctx.totalDuration)
2929
+ })
2930
+
2931
+ it('nudges the out edge inward to where the pointer put it, not back to totalDuration', () => {
2932
+ // The whole point of the raised ceiling. Bounded at `totalDuration` this
2933
+ // lands at 15.6 (the duration floor) or 15 — either way a silent shrink of
2934
+ // roughly half a second, triggered by a 50px nudge.
2935
+ const d = new Driver(overhangCtx())
2936
+ d.down(OVER_OUT_EDGE, OVERHANG_Y)
2937
+ const trimmed = caption(lastProjectChange(d.move(OVER_OUT_EDGE - 50, OVERHANG_Y)), 'over')
2938
+ expect(trimmed.end).toBeCloseTo(15.95)
2939
+ expect(trimmed.start).toBe(14.5)
2940
+ })
2941
+
2942
+ it('refuses to push the out edge FURTHER out than it already was', () => {
2943
+ // `seg.end` is both the ceiling and the starting value, so an outward drag
2944
+ // has nowhere legal to go and the trim declines entirely.
2945
+ const ctx = overhangCtx()
2946
+ const d = new Driver(ctx)
2947
+ d.down(OVER_OUT_EDGE, OVERHANG_Y)
2948
+ expect(of(d.move(OVER_OUT_EDGE + 500, OVERHANG_Y), 'projectChange')).toEqual([])
2949
+ const st = d.machine.state
2950
+ expect(st.kind).toBe('dragging')
2951
+ if (st.kind === 'dragging') expect(st.lastProject).toBe(ctx.project)
2952
+ expect(of(d.up(OVER_OUT_EDGE + 500, OVERHANG_Y), 'commit')).toEqual([])
2953
+ })
2954
+
2955
+ it('still enforces the duration floor on an overhanging caption', () => {
2956
+ // The raised ceiling relaxes the OUTER bound only; the floor is untouched.
2957
+ const d = new Driver(overhangCtx())
2958
+ d.down(OVER_OUT_EDGE, OVERHANG_Y)
2959
+ const trimmed = caption(lastProjectChange(d.move(14000, OVERHANG_Y)), 'over')
2960
+ expect(trimmed.end).toBeCloseTo(14.5 + CAPTION_MIN_DURATION_S)
2961
+ expect(trimmed.start).toBe(14.5)
2962
+ })
2963
+
2964
+ it('never pushes a caption further out than it already was, however far the pointer travels', () => {
2965
+ // The overhang counterpart of the in-bounds sweep above: the invariant is
2966
+ // the segment's OWN original end, not `totalDuration`.
2967
+ const ctx = overhangCtx()
2968
+ const original = caption(ctx.project, 'over')
2969
+ for (const x of [OVER_IN_EDGE, OVER_OUT_EDGE]) {
2970
+ const d = new Driver(ctx)
2971
+ d.down(x, OVERHANG_Y)
2972
+ for (const to of [-9999, 0, 14000, 15000, 16500, 99999]) {
2973
+ const changes = of(d.move(to, OVERHANG_Y), 'projectChange')
2974
+ if (changes.length === 0) continue
2975
+ const seg = changes[changes.length - 1].project.captions!.segments[0]
2976
+ expect(seg.start).toBeGreaterThanOrEqual(0)
2977
+ expect(seg.end).toBeLessThanOrEqual(original.end)
2978
+ expect(seg.end - seg.start).toBeGreaterThanOrEqual(CAPTION_MIN_DURATION_S - 1e-9)
2979
+ }
2980
+ }
2981
+ })
2982
+ })
2983
+
2984
+ // ── Caption ROWS: vertical drag, row creation, per-row clamps ──────────────
2985
+ //
2986
+ // Captions live on lanes now. Lane 0 is the band adjacent to the base video
2987
+ // row and higher lanes stack UPWARD, so a drag toward the top of the surface
2988
+ // RAISES the lane number — the opposite of audio, whose lanes ascend downward.
2989
+ // A lone caption changes rows; a caption travelling with a wider selection
2990
+ // never does.
2991
+ //
2992
+ // Fixture (100px/s, so x = t × 100):
2993
+ //
2994
+ // lane 1 u0 1s–2s (x 100–200)
2995
+ // lane 0 g0 1s–2s (words), g1 5s–6s (x 100–200, 500–600)
2996
+ // row track 0 — c0 0s–10s ⇒ totalDuration 15
2997
+ //
2998
+ // u0 sits directly above g0 on purpose: it is what makes "the row I am aiming
2999
+ // at is already taken" reachable without moving horizontally at all.
3000
+
3001
+ function lanedCaptionProject(): Project {
3002
+ return {
3003
+ id: 'p',
3004
+ tracks: [{
3005
+ id: 'trk-0',
3006
+ items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
3007
+ }],
3008
+ captions: {
3009
+ style: 'pop',
3010
+ segments: [
3011
+ // No `lane` at all — absent means lane 0, and this fixture leans on
3012
+ // that being indistinguishable from an explicit 0.
3013
+ {
3014
+ id: 'g0',
3015
+ text: 'hello there',
3016
+ start: 1,
3017
+ end: 2,
3018
+ words: [
3019
+ { word: 'hello', start: 1, end: 1.6 },
3020
+ { word: 'there', start: 1.6, end: 2 },
3021
+ ],
3022
+ },
3023
+ { id: 'g1', text: 'ground later', start: 5, end: 6 },
3024
+ { id: 'u0', text: 'upstairs', start: 1, end: 2, lane: 1 },
3025
+ ],
3026
+ },
3027
+ } as unknown as Project
3028
+ }
3029
+
3030
+ const LANED_LAYOUT = computeTimelineLayout(lanedCaptionProject())
3031
+ const laneMidY = (layout: typeof LANED_LAYOUT, lane: number) => {
3032
+ const band = layout.captions!.find(b => b.lane === lane)!
3033
+ return Math.round(band.y + band.height / 2)
3034
+ }
3035
+ const L0_Y = laneMidY(LANED_LAYOUT, 0)
3036
+ const L1_Y = laneMidY(LANED_LAYOUT, 1)
3037
+
3038
+ const G0_BODY = { x: 150, y: L0_Y }
3039
+ const G1_BODY = { x: 550, y: L0_Y }
3040
+ const U0_BODY = { x: 150, y: L1_Y }
3041
+ const G0_OUT = { x: 198, y: L0_Y }
3042
+ const U0_OUT = { x: 198, y: L1_Y }
3043
+
3044
+ function lanedCtx(overrides: Partial<PointerContext> = {}): PointerContext {
3045
+ return makeContext({ project: lanedCaptionProject(), fps: CAPTION_FPS, ...overrides })
3046
+ }
3047
+
3048
+ /** Every caption lane in a project, keyed by segment id, read the way the
3049
+ * painter reads it (absent ⇒ 0). */
3050
+ function lanes(project: Project): Record<string, number> {
3051
+ const out: Record<string, number> = {}
3052
+ for (const s of project.captions?.segments ?? []) out[s.id as string] = s.lane ?? 0
3053
+ return out
3054
+ }
3055
+
3056
+ describe('caption vertical drag — moving between rows', () => {
3057
+ it('the fixture stacks lane 0 below lane 1, one band apart', () => {
3058
+ expect(LANED_LAYOUT.captions!.map(b => b.lane)).toEqual([1, 0])
3059
+ expect(L0_Y - L1_Y).toBe(CAPTION_ROW_HEIGHT_PX + ROW_GAP_PX)
3060
+ })
3061
+
3062
+ it('a caption dragged UP one band lands on the next lane up', () => {
3063
+ const d = new Driver(lanedCtx())
3064
+ d.down(G1_BODY.x, G1_BODY.y)
3065
+ const moved = lastProjectChange(d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX))
3066
+ expect(lanes(moved).g1).toBe(1)
3067
+ // Purely vertical: the segment keeps its exact timing.
3068
+ expect(caption(moved, 'g1')).toMatchObject({ start: 5, end: 6 })
3069
+ // …and nothing else moved rows.
3070
+ expect(lanes(moved).g0).toBe(0)
3071
+ expect(lanes(moved).u0).toBe(1)
3072
+ })
3073
+
3074
+ it('crosses a row at CAPTION_ROW_HEIGHT_PX of travel, not at the 44px band pitch', () => {
3075
+ // 22px is over half of 40 and exactly half of 44 (which `Math.round`
3076
+ // resolves DOWNWARD in magnitude, to -0). The shorter divisor is what lets
3077
+ // the drag reach the next row before the cursor has fully left this one.
3078
+ expect(Math.round(-22 / CAPTION_ROW_HEIGHT_PX)).toBe(-1)
3079
+ expect(Math.round(-22 / (CAPTION_ROW_HEIGHT_PX + ROW_GAP_PX))).toBe(-0)
3080
+
3081
+ const crossed = new Driver(lanedCtx())
3082
+ crossed.down(G1_BODY.x, G1_BODY.y)
3083
+ expect(lanes(lastProjectChange(crossed.move(G1_BODY.x, G1_BODY.y - 22))).g1).toBe(1)
3084
+
3085
+ // 20px is under half a row: no vertical intent, and with no horizontal
3086
+ // travel either the gesture has nothing at all to emit.
3087
+ const held = new Driver(lanedCtx())
3088
+ held.down(G1_BODY.x, G1_BODY.y)
3089
+ expect(of(held.move(G1_BODY.x, G1_BODY.y - 20), 'projectChange')).toEqual([])
3090
+ })
3091
+
3092
+ it('dragging past the TOP band mints a new row, and the band is there mid-drag', () => {
3093
+ const d = new Driver(lanedCtx())
3094
+ d.down(G1_BODY.x, G1_BODY.y)
3095
+ const moved = lastProjectChange(d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX * 2))
3096
+ expect(lanes(moved).g1).toBe(2)
3097
+ // The band exists in the very frame the drag emits — that is what makes
3098
+ // the new row appear under the cursor rather than at release.
3099
+ expect(computeTimelineLayout(moved).captions!.map(b => b.lane)).toEqual([2, 1, 0])
3100
+ })
3101
+
3102
+ it('clamps at lane 0 dragging DOWN, and never reaches the video row', () => {
3103
+ const d = new Driver(lanedCtx())
3104
+ d.down(G1_BODY.x, G1_BODY.y)
3105
+ // Two rows down from lane 0 is lane -2; the clamp holds it at 0. The
3106
+ // horizontal component is there only so the move is not a no-op and the
3107
+ // landed lane is actually observable.
3108
+ const moved = lastProjectChange(d.move(G1_BODY.x + 100, G1_BODY.y + CAPTION_ROW_HEIGHT_PX * 2))
3109
+ expect(lanes(moved).g1).toBe(0)
3110
+ expect(caption(moved, 'g1')).toMatchObject({ start: 6, end: 7 })
3111
+
3112
+ // A blanket guard: no downward travel, at any distance, puts a caption
3113
+ // below lane 0.
3114
+ for (const dy of [40, 80, 400, 4000]) {
3115
+ const proj = lastProjectChange(d.move(G1_BODY.x + 100, G1_BODY.y + dy))
3116
+ expect(lanes(proj).g1).toBeGreaterThanOrEqual(0)
3117
+ }
3118
+ })
3119
+
3120
+ it('hands a caption to the nearest FREE row when the one it aimed at is taken', () => {
3121
+ // Straight up from g0 is lane 1, where u0 sits at exactly the same span.
3122
+ // The fan-out's next candidate is the row it came from, which fits — so
3123
+ // the caption simply stays put rather than being shoved somewhere it was
3124
+ // not aimed at.
3125
+ const blocked = new Driver(lanedCtx())
3126
+ blocked.down(G0_BODY.x, G0_BODY.y)
3127
+ expect(of(blocked.move(G0_BODY.x, G0_BODY.y - CAPTION_ROW_HEIGHT_PX), 'projectChange')).toEqual([])
3128
+
3129
+ // The same upward drag, landing clear of u0 in time, takes lane 1.
3130
+ const clear = new Driver(lanedCtx())
3131
+ clear.down(G0_BODY.x, G0_BODY.y)
3132
+ const moved = lastProjectChange(clear.move(G0_BODY.x + 250, G0_BODY.y - CAPTION_ROW_HEIGHT_PX))
3133
+ expect(lanes(moved).g0).toBe(1)
3134
+ expect(caption(moved, 'g0')).toMatchObject({ start: 3.5, end: 4.5 })
3135
+ })
3136
+
3137
+ it('fans out to a NEW row when the target row and the source row are both taken', () => {
3138
+ // a0 aims at lane 1 and lands on b1; falling back to lane 0 lands on a1;
3139
+ // so the drop goes to lane 2, which is empty by construction.
3140
+ const crowded: Project = {
3141
+ id: 'p',
3142
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }] }],
3143
+ captions: {
3144
+ style: 'pop',
3145
+ segments: [
3146
+ { id: 'a0', text: 'a0', start: 1, end: 2 },
3147
+ { id: 'a1', text: 'a1', start: 5, end: 6 },
3148
+ { id: 'b1', text: 'b1', start: 5, end: 6, lane: 1 },
3149
+ ],
3150
+ },
3151
+ } as unknown as Project
3152
+ const ctx = lanedCtx({ project: crowded, layout: computeTimelineLayout(crowded) })
3153
+ const d = new Driver(ctx)
3154
+ d.down(150, laneMidY(ctx.layout, 0))
3155
+ const moved = lastProjectChange(d.move(150 + 400, laneMidY(ctx.layout, 0) - CAPTION_ROW_HEIGHT_PX))
3156
+ expect(lanes(moved).a0).toBe(2)
3157
+ expect(caption(moved, 'a0')).toMatchObject({ start: 5, end: 6 })
3158
+ // Neither blocker moved, and neither was overlapped in its own row.
3159
+ expect(caption(moved, 'a1')).toMatchObject({ start: 5, end: 6 })
3160
+ expect(caption(moved, 'b1')).toMatchObject({ start: 5, end: 6 })
3161
+ })
3162
+
3163
+ it('carries word timings across a row change, and never touches the text', () => {
3164
+ const d = new Driver(lanedCtx())
3165
+ d.down(G0_BODY.x, G0_BODY.y)
3166
+ const moved = lastProjectChange(d.move(G0_BODY.x + 250, G0_BODY.y - CAPTION_ROW_HEIGHT_PX))
3167
+ const seg = caption(moved, 'g0')
3168
+ expect(seg.text).toBe('hello there')
3169
+ expect(seg.words).toEqual([
3170
+ { word: 'hello', start: 3.5, end: 4.1 },
3171
+ { word: 'there', start: 4.1, end: 4.5 },
3172
+ ])
3173
+ })
3174
+
3175
+ it('changes rows on the very FIRST move of a freshly grabbed caption', () => {
3176
+ // The stale-selection trap again: `selectMany` has not been echoed back
3177
+ // yet, so `ctx.selectedIds` still holds only the clip. The vertical path
3178
+ // has to cope with its own id being absent from the selection.
3179
+ const d = new Driver(lanedCtx({ selectedIds: ['c0'] }))
3180
+ d.down(G1_BODY.x, G1_BODY.y)
3181
+ expect(lanes(lastProjectChange(d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX))).g1).toBe(1)
3182
+ })
3183
+
3184
+ it('commits a cross-row drag exactly once', () => {
3185
+ const d = new Driver(lanedCtx())
3186
+ d.down(G1_BODY.x, G1_BODY.y)
3187
+ d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX)
3188
+ const committed = of(d.up(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX), 'commit')
3189
+ expect(committed).toHaveLength(1)
3190
+ expect(lanes(committed[0].project).g1).toBe(1)
3191
+ })
3192
+
3193
+ it('commits nothing when the caption ends the drag back in the row it started in', () => {
3194
+ const d = new Driver(lanedCtx())
3195
+ d.down(G1_BODY.x, G1_BODY.y)
3196
+ d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX)
3197
+ d.move(G1_BODY.x, G1_BODY.y)
3198
+ expect(of(d.up(G1_BODY.x, G1_BODY.y), 'commit')).toEqual([])
3199
+ })
3200
+ })
3201
+
3202
+ describe('caption horizontal drag — clamped by its own row only', () => {
3203
+ it('stops a lane-0 caption at its lane-0 neighbour', () => {
3204
+ const d = new Driver(lanedCtx())
3205
+ d.down(G0_BODY.x, G0_BODY.y)
3206
+ // 500px right is 5s; g1 starts at 5, so g0 gets 3s of the travel.
3207
+ const moved = lastProjectChange(d.move(G0_BODY.x + 500, G0_BODY.y))
3208
+ expect(caption(moved, 'g0')).toMatchObject({ start: 4, end: 5 })
3209
+ expect(lanes(moved).g0).toBe(0)
3210
+ })
3211
+
3212
+ it('lets a lane-1 caption slide straight past a lane-0 caption', () => {
3213
+ // The discriminating case for the per-lane clamp: u0 (lane 1) travels the
3214
+ // full 5s even though g1 (lane 0) sits at 5s–6s directly in its path. A
3215
+ // track-wide clamp stopped it at 4s–5s.
3216
+ const d = new Driver(lanedCtx())
3217
+ d.down(U0_BODY.x, U0_BODY.y)
3218
+ const moved = lastProjectChange(d.move(U0_BODY.x + 500, U0_BODY.y))
3219
+ expect(caption(moved, 'u0')).toMatchObject({ start: 6, end: 7 })
3220
+ expect(lanes(moved).u0).toBe(1)
3221
+ })
3222
+
3223
+ it('moves a whole selection horizontally and changes NO lane, however far the pointer travels down or up', () => {
3224
+ const d = new Driver(lanedCtx({ selectedIds: ['g0', 'u0'] }))
3225
+ d.down(G0_BODY.x, G0_BODY.y)
3226
+ for (const dy of [-400, -CAPTION_ROW_HEIGHT_PX, CAPTION_ROW_HEIGHT_PX, 400]) {
3227
+ const moved = lastProjectChange(d.move(G0_BODY.x + 100, G0_BODY.y + dy))
3228
+ expect(caption(moved, 'g0')).toMatchObject({ start: 2, end: 3 })
3229
+ expect(caption(moved, 'u0')).toMatchObject({ start: 2, end: 3 })
3230
+ expect(lanes(moved)).toEqual({ g0: 0, g1: 0, u0: 1 })
3231
+ }
3232
+ })
3233
+ })
3234
+
3235
+ describe('caption trim — bounded by its own row only', () => {
3236
+ it('stops an end trim at the next caption IN ITS OWN LANE', () => {
3237
+ const d = new Driver(lanedCtx())
3238
+ d.down(G0_OUT.x, G0_OUT.y)
3239
+ expect(caption(lastProjectChange(d.move(2000, G0_OUT.y)), 'g0').end).toBeCloseTo(5)
3240
+ })
3241
+
3242
+ it('lets a lane-1 caption trim straight past a lane-0 caption', () => {
3243
+ // u0 has no same-lane neighbour, so its only ceiling is the timeline's own
3244
+ // horizon. Track-wide, g1's start (5) stopped it.
3245
+ const d = new Driver(lanedCtx())
3246
+ d.down(U0_OUT.x, U0_OUT.y)
3247
+ const trimmed = caption(lastProjectChange(d.move(2000, U0_OUT.y)), 'u0')
3248
+ expect(trimmed.end).toBeCloseTo(15)
3249
+ expect(trimmed.start).toBe(1)
3250
+ })
3251
+
3252
+ it('ignores a caption on another row when trimming a start edge', () => {
3253
+ // p0 (lane 0) ends at 2 and q0 (lane 1) starts at 3. Track-wide, q0's in
3254
+ // edge floored at 2; per lane it has no previous neighbour at all and may
3255
+ // run back to t=0.
3256
+ const split: Project = {
3257
+ id: 'p',
3258
+ tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }] }],
3259
+ captions: {
3260
+ style: 'pop',
3261
+ segments: [
3262
+ { id: 'p0', text: 'p0', start: 1, end: 2 },
3263
+ { id: 'q0', text: 'q0', start: 3, end: 4, lane: 1 },
3264
+ ],
3265
+ },
3266
+ } as unknown as Project
3267
+ const ctx = lanedCtx({ project: split, layout: computeTimelineLayout(split) })
3268
+ const d = new Driver(ctx)
3269
+ d.down(302, laneMidY(ctx.layout, 1))
3270
+ const trimmed = caption(lastProjectChange(d.move(0, laneMidY(ctx.layout, 1))), 'q0')
3271
+ expect(trimmed.start).toBe(0)
3272
+ expect(trimmed.end).toBe(4)
3273
+ })
3274
+
3275
+ it('never moves a caption between rows — a trim is horizontal, always', () => {
3276
+ const d = new Driver(lanedCtx())
3277
+ d.down(G0_OUT.x, G0_OUT.y)
3278
+ const moved = lastProjectChange(d.move(250, G0_OUT.y - CAPTION_ROW_HEIGHT_PX * 3))
3279
+ expect(lanes(moved)).toEqual({ g0: 0, g1: 0, u0: 1 })
3280
+ })
3281
+ })
3282
+
3283
+ // ── Hole lanes, and when they collapse ────────────────────────────────────
3284
+ //
3285
+ // A caption that was alone in its row leaves a HOLE behind it. The hole is
3286
+ // deliberately KEPT for every mid-drag frame — it is what holds an empty band
3287
+ // open so the timeline does not jump a whole row height under the pointer —
3288
+ // and collapsed exactly once, inside the commit, so the move and the
3289
+ // renumbering are one undo entry.
3290
+
3291
+ function threeLaneProject(): Project {
3292
+ return {
3293
+ id: 'p',
3294
+ tracks: [{
3295
+ id: 'trk-0',
3296
+ items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 10, inPoint: 0, outPoint: 10, sourceDuration: 20 }],
3297
+ }],
3298
+ captions: {
3299
+ style: 'pop',
3300
+ segments: [
3301
+ { id: 'low', text: 'low', start: 1, end: 2 },
3302
+ { id: 'mid', text: 'mid', start: 1, end: 2, lane: 1 }, // alone in its row
3303
+ { id: 'top', text: 'top', start: 1, end: 2, lane: 2 },
3304
+ ],
3305
+ },
3306
+ } as unknown as Project
3307
+ }
3308
+
3309
+ describe('caption lanes — hole collapse at commit', () => {
3310
+ const ctx = () => lanedCtx({ project: threeLaneProject(), layout: computeTimelineLayout(threeLaneProject()) })
3311
+
3312
+ it('keeps the hole open for every mid-drag frame', () => {
3313
+ const c = ctx()
3314
+ const d = new Driver(c)
3315
+ const y = laneMidY(c.layout, 1)
3316
+ d.down(150, y)
3317
+ const moved = lastProjectChange(d.move(150, y - CAPTION_ROW_HEIGHT_PX * 2))
3318
+ // mid vacated lane 1 for the new lane 3; lane 1 is now a hole, and the
3319
+ // band for it is still painted.
3320
+ expect(lanes(moved)).toEqual({ low: 0, mid: 3, top: 2 })
3321
+ expect(computeTimelineLayout(moved).captions!.map(b => b.lane)).toEqual([3, 2, 1, 0])
3322
+ })
3323
+
3324
+ it('collapses the hole once, in the single commit', () => {
3325
+ const c = ctx()
3326
+ const d = new Driver(c)
3327
+ const y = laneMidY(c.layout, 1)
3328
+ d.down(150, y)
3329
+ d.move(150, y - CAPTION_ROW_HEIGHT_PX * 2)
3330
+ const committed = of(d.up(150, y - CAPTION_ROW_HEIGHT_PX * 2), 'commit')
3331
+ expect(committed).toHaveLength(1)
3332
+ // Dense from 0, relative order preserved: low stays bottom, top moves down
3333
+ // into the vacated row, mid lands on top.
3334
+ expect(lanes(committed[0].project)).toEqual({ low: 0, mid: 2, top: 1 })
3335
+ expect(computeTimelineLayout(committed[0].project).captions!.map(b => b.lane)).toEqual([2, 1, 0])
3336
+ })
3337
+
3338
+ it('leaves an already-dense project byte-for-byte alone at commit', () => {
3339
+ // Normalization must not manufacture a change of its own: a drag that
3340
+ // vacates nothing commits exactly what the last frame emitted.
3341
+ const d = new Driver(lanedCtx())
3342
+ d.down(G1_BODY.x, G1_BODY.y)
3343
+ const moved = lastProjectChange(d.move(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX))
3344
+ const committed = of(d.up(G1_BODY.x, G1_BODY.y - CAPTION_ROW_HEIGHT_PX), 'commit')
3345
+ expect(committed[0].project.captions).toBe(moved.captions)
3346
+ })
3347
+
3348
+ it('does not normalize a caption TRIM — a trim cannot empty a row', () => {
3349
+ const c = ctx()
3350
+ const d = new Driver(c)
3351
+ const y = laneMidY(c.layout, 1)
3352
+ d.down(198, y) // mid's out handle
3353
+ const moved = lastProjectChange(d.move(400, y))
3354
+ const committed = of(d.up(400, y), 'commit')
3355
+ expect(committed[0].project.captions).toBe(moved.captions)
3356
+ expect(lanes(committed[0].project)).toEqual({ low: 0, mid: 1, top: 2 })
3357
+ })
3358
+ })