@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,171 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, cleanup, fireEvent } from '@testing-library/react'
3
+ import {
4
+ useKeymap,
5
+ matchesKey,
6
+ matchesUndo,
7
+ matchesRedo,
8
+ matchesModKey,
9
+ matchesPlainKey,
10
+ matchesDelete,
11
+ matchesShiftDelete,
12
+ isTypingTarget,
13
+ type KeyBinding,
14
+ } from '../keymap'
15
+
16
+ afterEach(() => cleanup())
17
+
18
+ function Harness({ bindings, modalOpen }: { bindings: KeyBinding[]; modalOpen?: boolean }) {
19
+ useKeymap(bindings, { modalOpen })
20
+ return (
21
+ <div>
22
+ <input data-testid="input" />
23
+ <textarea data-testid="textarea" />
24
+ <div data-testid="editable" contentEditable suppressContentEditableWarning>text</div>
25
+ <div data-testid="plain">plain</div>
26
+ </div>
27
+ )
28
+ }
29
+
30
+ describe('useKeymap — the shared base guard', () => {
31
+ it('fires on a plain target', () => {
32
+ const action = vi.fn()
33
+ const { getByTestId } = render(<Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />)
34
+ fireEvent.keyDown(getByTestId('plain'), { key: 's' })
35
+ expect(action).toHaveBeenCalledTimes(1)
36
+ })
37
+
38
+ it('never fires while an INPUT is the target', () => {
39
+ const action = vi.fn()
40
+ const { getByTestId } = render(<Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />)
41
+ fireEvent.keyDown(getByTestId('input'), { key: 's' })
42
+ expect(action).not.toHaveBeenCalled()
43
+ })
44
+
45
+ it('never fires while a TEXTAREA is the target', () => {
46
+ const action = vi.fn()
47
+ const { getByTestId } = render(<Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />)
48
+ fireEvent.keyDown(getByTestId('textarea'), { key: 's' })
49
+ expect(action).not.toHaveBeenCalled()
50
+ })
51
+
52
+ it('never fires while a contentEditable host is the target', () => {
53
+ const action = vi.fn()
54
+ const { getByTestId } = render(<Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />)
55
+ const editable = getByTestId('editable')
56
+ // jsdom doesn't implement `isContentEditable` (it's always `undefined`,
57
+ // even with the `contenteditable` attribute set) — force it so this
58
+ // exercises the same guard real browsers hit. See keymap.ts's
59
+ // `isTypingTarget` doc comment.
60
+ Object.defineProperty(editable, 'isContentEditable', { value: true, configurable: true })
61
+ fireEvent.keyDown(editable, { key: 's' })
62
+ expect(action).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('never fires while modalOpen is true, even on a plain target', () => {
66
+ const action = vi.fn()
67
+ const { getByTestId } = render(
68
+ <Harness modalOpen bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />,
69
+ )
70
+ fireEvent.keyDown(getByTestId('plain'), { key: 's' })
71
+ expect(action).not.toHaveBeenCalled()
72
+ })
73
+
74
+ it('resumes firing once modalOpen goes back to false', () => {
75
+ const action = vi.fn()
76
+ const bindings = [{ id: 'a', description: 'A', matches: matchesKey('s'), action }]
77
+ const { getByTestId, rerender } = render(<Harness modalOpen bindings={bindings} />)
78
+ fireEvent.keyDown(getByTestId('plain'), { key: 's' })
79
+ expect(action).not.toHaveBeenCalled()
80
+ rerender(<Harness modalOpen={false} bindings={bindings} />)
81
+ fireEvent.keyDown(getByTestId('plain'), { key: 's' })
82
+ expect(action).toHaveBeenCalledTimes(1)
83
+ })
84
+
85
+ it('is always ignored for Space — the playback hooks own it, never the keymap', () => {
86
+ // A deliberately over-broad matcher (would match ANY key) to prove Space
87
+ // is rejected structurally, before any binding is even consulted.
88
+ const action = vi.fn()
89
+ const { getByTestId } = render(
90
+ <Harness bindings={[{ id: 'catch-all', description: 'catch-all', matches: () => true, action }]} />,
91
+ )
92
+ fireEvent.keyDown(getByTestId('plain'), { key: ' ', code: 'Space' })
93
+ expect(action).not.toHaveBeenCalled()
94
+ // Sanity: the same catch-all binding DOES fire for an unrelated key,
95
+ // proving Space specifically (not everything) is what's rejected.
96
+ fireEvent.keyDown(getByTestId('plain'), { key: 'x' })
97
+ expect(action).toHaveBeenCalledTimes(1)
98
+ })
99
+
100
+ it('per-binding guard() gates the action independently of the base guard', () => {
101
+ const action = vi.fn()
102
+ let allowed = false
103
+ const { getByTestId } = render(
104
+ <Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), guard: () => allowed, action }]} />,
105
+ )
106
+ fireEvent.keyDown(getByTestId('plain'), { key: 's' })
107
+ expect(action).not.toHaveBeenCalled()
108
+ allowed = true
109
+ fireEvent.keyDown(getByTestId('plain'), { key: 's' })
110
+ expect(action).toHaveBeenCalledTimes(1)
111
+ })
112
+
113
+ it('evaluates bindings in order and stops at the first match', () => {
114
+ const first = vi.fn()
115
+ const second = vi.fn()
116
+ const { getByTestId } = render(
117
+ <Harness
118
+ bindings={[
119
+ { id: 'first', description: 'first', matches: matchesKey('s'), action: first },
120
+ { id: 'second', description: 'second', matches: matchesKey('s'), action: second },
121
+ ]}
122
+ />,
123
+ )
124
+ fireEvent.keyDown(getByTestId('plain'), { key: 's' })
125
+ expect(first).toHaveBeenCalledTimes(1)
126
+ expect(second).not.toHaveBeenCalled()
127
+ })
128
+ })
129
+
130
+ describe('isTypingTarget', () => {
131
+ it('is true for INPUT/TEXTAREA/contentEditable, false otherwise', () => {
132
+ const input = document.createElement('input')
133
+ const textarea = document.createElement('textarea')
134
+ // jsdom doesn't implement `isContentEditable` — force it (see the guard
135
+ // test above for the same workaround, with the full explanation).
136
+ const editable = document.createElement('div')
137
+ Object.defineProperty(editable, 'isContentEditable', { value: true, configurable: true })
138
+ const plain = document.createElement('div')
139
+ Object.defineProperty(plain, 'isContentEditable', { value: false, configurable: true })
140
+ for (const [el, expected] of [[input, true], [textarea, true], [editable, true], [plain, false]] as const) {
141
+ const ev = { target: el } as unknown as KeyboardEvent
142
+ expect(isTypingTarget(ev)).toBe(expected)
143
+ }
144
+ })
145
+ })
146
+
147
+ describe('combo matchers', () => {
148
+ it('matchesUndo requires mod+z without shift; matchesRedo requires mod+shift+z or mod+y', () => {
149
+ expect(matchesUndo({ metaKey: true, key: 'z', shiftKey: false } as KeyboardEvent)).toBe(true)
150
+ expect(matchesUndo({ metaKey: true, key: 'z', shiftKey: true } as KeyboardEvent)).toBe(false)
151
+ expect(matchesUndo({ metaKey: false, key: 'z', shiftKey: false } as KeyboardEvent)).toBe(false)
152
+
153
+ expect(matchesRedo({ metaKey: true, key: 'z', shiftKey: true } as KeyboardEvent)).toBe(true)
154
+ expect(matchesRedo({ metaKey: true, key: 'y', shiftKey: false } as KeyboardEvent)).toBe(true)
155
+ expect(matchesRedo({ metaKey: true, key: 'z', shiftKey: false } as KeyboardEvent)).toBe(false)
156
+ })
157
+
158
+ it('matchesDelete excludes Shift; matchesShiftDelete requires it', () => {
159
+ expect(matchesDelete({ key: 'Delete', shiftKey: false } as KeyboardEvent)).toBe(true)
160
+ expect(matchesDelete({ key: 'Delete', shiftKey: true } as KeyboardEvent)).toBe(false)
161
+ expect(matchesShiftDelete({ key: 'Backspace', shiftKey: true } as KeyboardEvent)).toBe(true)
162
+ expect(matchesShiftDelete({ key: 'Backspace', shiftKey: false } as KeyboardEvent)).toBe(false)
163
+ })
164
+
165
+ it('matchesPlainKey excludes mod/alt combos; matchesModKey requires mod', () => {
166
+ expect(matchesPlainKey('l')({ key: 'l', metaKey: false, ctrlKey: false, altKey: false } as KeyboardEvent)).toBe(true)
167
+ expect(matchesPlainKey('l')({ key: 'l', metaKey: true, ctrlKey: false, altKey: false } as KeyboardEvent)).toBe(false)
168
+ expect(matchesModKey('k')({ key: 'k', metaKey: true, ctrlKey: false } as KeyboardEvent)).toBe(true)
169
+ expect(matchesModKey('k')({ key: 'k', metaKey: false, ctrlKey: false } as KeyboardEvent)).toBe(false)
170
+ })
171
+ })
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest'
2
2
  import { render, screen, waitFor, act, cleanup } from '@testing-library/react'
3
3
  import type { EditorAdapter, ImageElement, Project, RenderStatus } from '../../types'
4
- import RenderModal, { phaseLabel, phaseIndex, RENDER_PHASES } from '../RenderModal'
4
+ import RenderModal, { phaseLabel, phaseIndex, RENDER_PHASES, stepperPhases } from '../RenderModal'
5
5
 
6
6
  afterEach(() => {
7
7
  cleanup()
@@ -16,18 +16,20 @@ describe('phaseLabel', () => {
16
16
  expect(phaseLabel('rendering')).toBe('Rendering graphics')
17
17
  expect(phaseLabel('captions')).toBe('Adding captions')
18
18
  expect(phaseLabel('encoding')).toBe('Encoding video')
19
+ expect(phaseLabel('sdr_derive')).toBe('Deriving SDR')
19
20
  expect(phaseLabel('saving')).toBe('Saving to your library')
20
21
  expect(phaseLabel('done')).toBe('Done')
21
22
  })
22
23
  })
23
24
 
24
25
  describe('phaseIndex / RENDER_PHASES', () => {
25
- it('orders the phases preparing → done', () => {
26
+ it('orders the phases preparing → done, with sdr_derive after encoding', () => {
26
27
  expect(RENDER_PHASES).toEqual([
27
28
  'preparing',
28
29
  'rendering',
29
30
  'captions',
30
31
  'encoding',
32
+ 'sdr_derive',
31
33
  'saving',
32
34
  'done',
33
35
  ])
@@ -38,8 +40,22 @@ describe('phaseIndex / RENDER_PHASES', () => {
38
40
  expect(phaseIndex('rendering')).toBe(1)
39
41
  expect(phaseIndex('captions')).toBe(2)
40
42
  expect(phaseIndex('encoding')).toBe(3)
41
- expect(phaseIndex('saving')).toBe(4)
42
- expect(phaseIndex('done')).toBe(5)
43
+ expect(phaseIndex('sdr_derive')).toBe(4)
44
+ expect(phaseIndex('saving')).toBe(5)
45
+ expect(phaseIndex('done')).toBe(6)
46
+ })
47
+ })
48
+
49
+ describe('stepperPhases', () => {
50
+ it('hides sdr_derive unless the render actually derives an SDR file', () => {
51
+ expect(stepperPhases()).not.toContain('sdr_derive')
52
+ expect(stepperPhases({ export: 'auto' })).not.toContain('sdr_derive')
53
+ expect(stepperPhases({ export: 'sdr' })).toContain('sdr_derive')
54
+ expect(stepperPhases({ export: 'both' })).toContain('sdr_derive')
55
+ })
56
+
57
+ it('never lists the terminal done phase', () => {
58
+ expect(stepperPhases({ export: 'both' })).not.toContain('done')
43
59
  })
44
60
  })
45
61
 
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { nextShuttleRate, createShuttleController } from '../shuttle'
3
+ import { createPlaybackClock } from '../playback-clock'
4
+
5
+ describe('nextShuttleRate — pure rate-stepping', () => {
6
+ it('steps the same direction 1x -> 2x -> 4x, capped at 4x', () => {
7
+ let rate = nextShuttleRate(0, 1)
8
+ expect(rate).toBe(1)
9
+ rate = nextShuttleRate(rate, 1)
10
+ expect(rate).toBe(2)
11
+ rate = nextShuttleRate(rate, 1)
12
+ expect(rate).toBe(4)
13
+ rate = nextShuttleRate(rate, 1) // capped
14
+ expect(rate).toBe(4)
15
+ })
16
+
17
+ it('mirrors the same stepping backward', () => {
18
+ let rate = nextShuttleRate(0, -1)
19
+ expect(rate).toBe(-1)
20
+ rate = nextShuttleRate(rate, -1)
21
+ expect(rate).toBe(-2)
22
+ rate = nextShuttleRate(rate, -1)
23
+ expect(rate).toBe(-4)
24
+ })
25
+
26
+ it('the opposite direction resets to that direction at 1x', () => {
27
+ const forward2x = nextShuttleRate(nextShuttleRate(0, 1), 1)
28
+ expect(forward2x).toBe(2)
29
+ expect(nextShuttleRate(forward2x, -1)).toBe(-1)
30
+
31
+ const backward4x = nextShuttleRate(nextShuttleRate(nextShuttleRate(0, -1), -1), -1)
32
+ expect(backward4x).toBe(-4)
33
+ expect(nextShuttleRate(backward4x, 1)).toBe(1)
34
+ })
35
+ })
36
+
37
+ describe('createShuttleController', () => {
38
+ // Deterministic fake rAF: callbacks queue up and only run when the test
39
+ // pumps them, so the rate-stepping/cancellation contract is tested without
40
+ // depending on real frame timing.
41
+ function makeFakeRaf() {
42
+ let pending: ((ms: number) => void) | null = null
43
+ return {
44
+ raf: (cb: (ms: number) => void) => { pending = cb; return 1 },
45
+ caf: () => { pending = null },
46
+ pump: (ms: number) => { const cb = pending; pending = null; cb?.(ms) },
47
+ hasPending: () => pending !== null,
48
+ }
49
+ }
50
+
51
+ // Model real playback as a flag the play/pause deps flip, so isPlaying()
52
+ // reflects what the shuttle just did — exactly like the engine transport.
53
+ function makeDeps(clock: ReturnType<typeof createPlaybackClock>, getDuration = () => 100) {
54
+ let playing = false
55
+ const play = vi.fn(() => { playing = true })
56
+ const pause = vi.fn(() => { playing = false })
57
+ const setRate = vi.fn<(rate: number) => void>()
58
+ const fake = makeFakeRaf()
59
+ return {
60
+ play, pause, setRate, fake,
61
+ setPlaying: (v: boolean) => { playing = v },
62
+ deps: {
63
+ clock, getDuration,
64
+ isPlaying: () => playing,
65
+ play, pause, setRate,
66
+ raf: fake.raf, caf: fake.caf,
67
+ },
68
+ }
69
+ }
70
+
71
+ it('forward from idle: real playback @1x with audio, setRate(1), no seek-loop', () => {
72
+ const clock = createPlaybackClock(10)
73
+ const { deps, play, pause, setRate, fake } = makeDeps(clock)
74
+ const controller = createShuttleController(deps)
75
+
76
+ controller.press(1)
77
+ expect(play).toHaveBeenCalledTimes(1) // engine transport (audio) started
78
+ expect(pause).not.toHaveBeenCalled()
79
+ expect(setRate).toHaveBeenLastCalledWith(1) // 1x re-anchor (harmless no-op)
80
+ expect(controller.getRate()).toBe(1)
81
+ expect(fake.hasPending()).toBe(false) // NOT the silent seek-loop
82
+ expect(clock.get()).toBe(10) // the shuttle isn't driving the clock
83
+ })
84
+
85
+ it('repeating forward bumps the engine rate 1x -> 2x -> 4x (audible), never the seek-loop', () => {
86
+ const clock = createPlaybackClock(0)
87
+ const { deps, play, setRate, fake } = makeDeps(clock)
88
+ const controller = createShuttleController(deps)
89
+
90
+ controller.press(1) // real play @1x
91
+ controller.press(1) // -> 2x, STILL real playback
92
+ expect(setRate).toHaveBeenLastCalledWith(2)
93
+ expect(controller.getRate()).toBe(2)
94
+ expect(fake.hasPending()).toBe(false) // audible, not a seek-loop
95
+ expect(play).toHaveBeenCalledTimes(1) // already playing — not re-started
96
+
97
+ controller.press(1) // -> 4x
98
+ expect(setRate).toHaveBeenLastCalledWith(4)
99
+ expect(controller.getRate()).toBe(4)
100
+
101
+ controller.press(1) // capped at 4x
102
+ expect(setRate).toHaveBeenLastCalledWith(4)
103
+ expect(controller.getRate()).toBe(4)
104
+ })
105
+
106
+ it('forward press while already playing (user-started via Space) does not claim ownership: stop() leaves it running', () => {
107
+ const clock = createPlaybackClock(0)
108
+ const { deps, play, pause, setRate, setPlaying } = makeDeps(clock)
109
+ const controller = createShuttleController(deps)
110
+
111
+ setPlaying(true) // the user hit Space before shuttling
112
+ controller.press(1)
113
+ expect(play).not.toHaveBeenCalled() // already playing — shuttle didn't start it
114
+ expect(setRate).toHaveBeenLastCalledWith(1)
115
+ expect(controller.getRate()).toBe(1)
116
+
117
+ controller.stop() // K
118
+ expect(pause).not.toHaveBeenCalled() // ownership never claimed — user's playback survives
119
+ expect(setRate).toHaveBeenLastCalledWith(1) // rate still resets to 1x
120
+ expect(controller.getRate()).toBe(0)
121
+ })
122
+
123
+ it('reverse is a silent seek-loop: resets the rate, pauses real playback, never plays', () => {
124
+ const clock = createPlaybackClock(50)
125
+ const { deps, play, pause, setRate, fake, setPlaying } = makeDeps(clock)
126
+ const controller = createShuttleController(deps)
127
+
128
+ setPlaying(true) // e.g. the user hit Space first
129
+ controller.press(-1)
130
+ expect(setRate).toHaveBeenLastCalledWith(1) // no stale forward rate during the scrub
131
+ expect(pause).toHaveBeenCalledTimes(1) // real playback muted before scrubbing
132
+ expect(play).not.toHaveBeenCalled() // reverse never plays
133
+ expect(controller.getRate()).toBe(-1)
134
+ expect(fake.hasPending()).toBe(true)
135
+ })
136
+
137
+ it('resetting from a reverse loop back to forward returns to real playback', () => {
138
+ const clock = createPlaybackClock(50)
139
+ const { deps, play, setRate, fake } = makeDeps(clock)
140
+ const controller = createShuttleController(deps)
141
+
142
+ controller.press(-1) // reverse loop (rate reset to 1, paused)
143
+ expect(fake.hasPending()).toBe(true)
144
+ controller.press(1) // opposite dir -> forward -> real play
145
+ expect(play).toHaveBeenCalledTimes(1)
146
+ expect(setRate).toHaveBeenLastCalledWith(1)
147
+ expect(controller.getRate()).toBe(1)
148
+ expect(fake.hasPending()).toBe(false) // loop cancelled
149
+ })
150
+
151
+ it('stop() resets the transport rate and pauses shuttle-started real playback', () => {
152
+ const clock = createPlaybackClock(0)
153
+ const { deps, pause, setRate } = makeDeps(clock)
154
+ const controller = createShuttleController(deps)
155
+ controller.press(1) // real play @1x (shuttle-started)
156
+ controller.stop()
157
+ expect(setRate).toHaveBeenLastCalledWith(1)
158
+ expect(pause).toHaveBeenCalledTimes(1)
159
+ expect(controller.getRate()).toBe(0)
160
+ })
161
+
162
+ it('K (stop) zeroes the rate, resets the transport rate and cancels a running seek-loop', () => {
163
+ const clock = createPlaybackClock(10)
164
+ const { deps, setRate, fake } = makeDeps(clock)
165
+ const controller = createShuttleController(deps)
166
+ controller.press(-1) // reverse seek-loop
167
+ expect(fake.hasPending()).toBe(true)
168
+ controller.stop()
169
+ expect(setRate).toHaveBeenLastCalledWith(1)
170
+ expect(controller.getRate()).toBe(0)
171
+ expect(fake.hasPending()).toBe(false)
172
+ })
173
+
174
+ it('steps the clock down in the reverse seek-loop, clamped at 0', () => {
175
+ const clock = createPlaybackClock(4)
176
+ const { deps, fake } = makeDeps(clock, () => 100)
177
+ const controller = createShuttleController(deps)
178
+ controller.press(-1) // -1x reverse seek-loop
179
+ controller.press(-1) // -> -2x
180
+ expect(controller.getRate()).toBe(-2)
181
+ fake.pump(0) // establishes the frame baseline, no time delta yet
182
+ fake.pump(1000) // -1s at 2x -> clock should be ~2
183
+ expect(clock.get()).toBeCloseTo(2, 5)
184
+ fake.pump(3000) // -2s at 2x would undershoot below 0 -> clamped, loop stops
185
+ expect(clock.get()).toBe(0)
186
+ expect(controller.getRate()).toBe(0)
187
+ })
188
+
189
+ it('cancels the seek-loop when an external transport change starts real playback', () => {
190
+ const clock = createPlaybackClock(0)
191
+ const { deps, fake, setPlaying } = makeDeps(clock)
192
+ const controller = createShuttleController(deps)
193
+ controller.press(-1) // reverse seek-loop
194
+ fake.pump(0)
195
+ setPlaying(true) // user hit play, outside the shuttle
196
+ fake.pump(16)
197
+ expect(controller.getRate()).toBe(0)
198
+ })
199
+
200
+ it('cancels the seek-loop when the clock is moved externally (a seek)', () => {
201
+ const clock = createPlaybackClock(0)
202
+ const { deps, fake } = makeDeps(clock)
203
+ const controller = createShuttleController(deps)
204
+ controller.press(-1) // reverse seek-loop
205
+ fake.pump(0)
206
+ clock.set(50) // an external seek, not written by the shuttle itself
207
+ fake.pump(16)
208
+ expect(controller.getRate()).toBe(0)
209
+ })
210
+ })
@@ -0,0 +1,60 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The source-preview override store. A sibling of `hover-scrub.ts`: it repaints
4
+ * the main preview with an OFF-TIMELINE clip's frame on hover and NOTHING else —
5
+ * it holds a proxy url + a fraction, notifies only on real change, and is inert
6
+ * for a host that never wires it.
7
+ */
8
+ import { describe, it, expect, vi } from 'vitest'
9
+ import { createSourcePreviewStore } from '../source-preview'
10
+
11
+ describe('createSourcePreviewStore', () => {
12
+ it('starts empty — the main preview follows the timeline', () => {
13
+ expect(createSourcePreviewStore().get()).toBeNull()
14
+ })
15
+
16
+ it('notifies subscribers when the value changes', () => {
17
+ const store = createSourcePreviewStore()
18
+ const sub = vi.fn()
19
+ store.subscribe(sub)
20
+
21
+ store.set({ url: 'a.mp4', fraction: 0.5 })
22
+ expect(store.get()).toEqual({ url: 'a.mp4', fraction: 0.5 })
23
+ expect(sub).toHaveBeenCalledTimes(1)
24
+
25
+ store.set(null)
26
+ expect(store.get()).toBeNull()
27
+ expect(sub).toHaveBeenCalledTimes(2)
28
+ })
29
+
30
+ it('stays silent on a repeat write of the same url+fraction', () => {
31
+ // The host may re-set the same value on repeated pointer events over one
32
+ // tile; without the guard that would notify (and repaint) needlessly.
33
+ const store = createSourcePreviewStore()
34
+ const sub = vi.fn()
35
+ store.subscribe(sub)
36
+
37
+ store.set(null)
38
+ expect(sub).not.toHaveBeenCalled()
39
+
40
+ store.set({ url: 'a.mp4', fraction: 0.2 })
41
+ store.set({ url: 'a.mp4', fraction: 0.2 })
42
+ expect(sub).toHaveBeenCalledTimes(1)
43
+
44
+ // A moved fraction (same clip) is a real change.
45
+ store.set({ url: 'a.mp4', fraction: 0.6 })
46
+ expect(sub).toHaveBeenCalledTimes(2)
47
+
48
+ // A different clip is a real change.
49
+ store.set({ url: 'b.mp4', fraction: 0.6 })
50
+ expect(sub).toHaveBeenCalledTimes(3)
51
+ })
52
+
53
+ it('unsubscribes cleanly', () => {
54
+ const store = createSourcePreviewStore()
55
+ const sub = vi.fn()
56
+ store.subscribe(sub)()
57
+ store.set({ url: 'a.mp4', fraction: 0.1 })
58
+ expect(sub).not.toHaveBeenCalled()
59
+ })
60
+ })
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { formatTimecode, parseTimecode } from '../timecode'
3
+
4
+ describe('parseTimecode', () => {
5
+ it('parses bare seconds', () => {
6
+ expect(parseTimecode('90')).toBe(90)
7
+ expect(parseTimecode('12.5')).toBe(12.5)
8
+ expect(parseTimecode('0')).toBe(0)
9
+ })
10
+
11
+ it('parses mm:ss', () => {
12
+ expect(parseTimecode('1:30')).toBe(90)
13
+ expect(parseTimecode('12:03.5')).toBe(12 * 60 + 3.5)
14
+ })
15
+
16
+ it('parses hh:mm:ss', () => {
17
+ expect(parseTimecode('01:02:03')).toBe(1 * 3600 + 2 * 60 + 3)
18
+ expect(parseTimecode('1:02:03.25')).toBe(3600 + 120 + 3.25)
19
+ })
20
+
21
+ it('trims surrounding whitespace', () => {
22
+ expect(parseTimecode(' 1:30 ')).toBe(90)
23
+ })
24
+
25
+ it('rejects out-of-range minutes/seconds', () => {
26
+ expect(parseTimecode('1:60')).toBeNull()
27
+ expect(parseTimecode('1:60:00')).toBeNull()
28
+ expect(parseTimecode('60:00:00')).not.toBeNull() // hours component is unbounded
29
+ })
30
+
31
+ it('rejects garbage', () => {
32
+ expect(parseTimecode('')).toBeNull()
33
+ expect(parseTimecode(' ')).toBeNull()
34
+ expect(parseTimecode('abc')).toBeNull()
35
+ expect(parseTimecode('1:2:3:4')).toBeNull()
36
+ expect(parseTimecode('1.5:30')).toBeNull()
37
+ expect(parseTimecode('-5')).toBeNull()
38
+ expect(parseTimecode('1:')).toBeNull()
39
+ })
40
+ })
41
+
42
+ describe('formatTimecode', () => {
43
+ it('formats zero', () => {
44
+ expect(formatTimecode(0)).toBe('0:00.0')
45
+ })
46
+
47
+ it('formats sub-minute values with one decimal', () => {
48
+ expect(formatTimecode(5.34)).toBe('0:05.3')
49
+ expect(formatTimecode(59.96)).toBe('1:00.0') // rounds up and carries into minutes
50
+ })
51
+
52
+ it('formats minute-plus values without an hours component', () => {
53
+ expect(formatTimecode(65)).toBe('1:05.0')
54
+ expect(formatTimecode(599.9)).toBe('9:59.9')
55
+ })
56
+
57
+ it('formats hour-plus values with an hh:mm:ss.f shape', () => {
58
+ expect(formatTimecode(3600)).toBe('1:00:00.0')
59
+ expect(formatTimecode(3725.6)).toBe('1:02:05.6')
60
+ })
61
+
62
+ it('never emits a frames-style trailing group (parseTimecode has no concept of frames)', () => {
63
+ expect(formatTimecode(3725.6).split(':')).toHaveLength(3) // h:mm:ss.f, not h:mm:ss:ff
64
+ })
65
+
66
+ it('clamps negatives and non-finite input to 0', () => {
67
+ expect(formatTimecode(-5)).toBe('0:00.0')
68
+ expect(formatTimecode(NaN)).toBe('0:00.0')
69
+ })
70
+
71
+ it('round-trips through parseTimecode', () => {
72
+ const values = [0, 0.1, 5.3, 42.7, 65.0, 599.9, 3600, 3661.2, 7325.8]
73
+ for (const t of values) {
74
+ expect(parseTimecode(formatTimecode(t))).toBeCloseTo(t, 1)
75
+ }
76
+ })
77
+ })
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { renderHook, act } from '@testing-library/react'
3
+
4
+ import { createPlaybackClock } from '../playback-clock'
5
+ import { useReportContext, REPORT_INTERVAL_MS } from '../use-report-context'
6
+
7
+ beforeEach(() => { vi.useFakeTimers() })
8
+ afterEach(() => { vi.useRealTimers() })
9
+
10
+ describe('useReportContext', () => {
11
+ it('does nothing when the adapter omits reportContext', () => {
12
+ const clock = createPlaybackClock()
13
+ expect(() =>
14
+ renderHook(() => useReportContext({
15
+ adapter: {} as never, projectId: 'p1', clock,
16
+ selectedIds: [], selectedCaptionId: null,
17
+ })),
18
+ ).not.toThrow()
19
+ })
20
+
21
+ it('reports once on mount', () => {
22
+ const reportContext = vi.fn().mockResolvedValue(undefined)
23
+ const clock = createPlaybackClock(4)
24
+ renderHook(() => useReportContext({
25
+ adapter: { reportContext } as never, projectId: 'p1', clock,
26
+ selectedIds: [], selectedCaptionId: null,
27
+ }))
28
+ expect(reportContext).toHaveBeenCalledWith('p1', {
29
+ playheadSec: 4, selectedIds: [], selectedCaptionId: null,
30
+ })
31
+ })
32
+
33
+ it('throttles a 60Hz clock to one report per interval', () => {
34
+ const reportContext = vi.fn().mockResolvedValue(undefined)
35
+ const clock = createPlaybackClock()
36
+ renderHook(() => useReportContext({
37
+ adapter: { reportContext } as never, projectId: 'p1', clock,
38
+ selectedIds: [], selectedCaptionId: null,
39
+ }))
40
+ reportContext.mockClear()
41
+
42
+ act(() => {
43
+ for (let i = 1; i <= 120; i++) clock.set(i / 60)
44
+ vi.advanceTimersByTime(REPORT_INTERVAL_MS)
45
+ })
46
+ expect(reportContext).toHaveBeenCalledTimes(1)
47
+ expect(reportContext.mock.calls[0][1].playheadSec).toBeCloseTo(2)
48
+ })
49
+
50
+ it('reports a selection change immediately, without waiting for the interval', () => {
51
+ const reportContext = vi.fn().mockResolvedValue(undefined)
52
+ const clock = createPlaybackClock()
53
+ // Hoisted deliberately. The playhead effect depends on `adapter` by
54
+ // reference, so an inline object literal would be a new adapter every
55
+ // render and would re-fire that effect on rerender too — giving 2 calls
56
+ // and testing object identity instead of the selection path. Real call
57
+ // sites (EditorPage.tsx, MobileLiveView.tsx, MobileVideoPreview.tsx) all
58
+ // wrap createMontajAdapter() in useMemo, so a stable adapter is what
59
+ // production actually does.
60
+ const adapter = { reportContext } as never
61
+ const { rerender } = renderHook(
62
+ (props: { ids: string[] }) => useReportContext({
63
+ adapter, projectId: 'p1', clock,
64
+ selectedIds: props.ids, selectedCaptionId: null,
65
+ }),
66
+ { initialProps: { ids: [] as string[] } },
67
+ )
68
+ reportContext.mockClear()
69
+ rerender({ ids: ['c3'] })
70
+ expect(reportContext).toHaveBeenCalledTimes(1)
71
+ expect(reportContext.mock.calls[0][1].selectedIds).toEqual(['c3'])
72
+ })
73
+
74
+ it('swallows a rejected report — context sync never breaks the editor', async () => {
75
+ const reportContext = vi.fn().mockRejectedValue(new Error('serve is down'))
76
+ const clock = createPlaybackClock()
77
+ expect(() =>
78
+ renderHook(() => useReportContext({
79
+ adapter: { reportContext } as never, projectId: 'p1', clock,
80
+ selectedIds: [], selectedCaptionId: null,
81
+ })),
82
+ ).not.toThrow()
83
+ await act(async () => { await Promise.resolve() })
84
+ })
85
+
86
+ it('stops reporting after unmount', () => {
87
+ const reportContext = vi.fn().mockResolvedValue(undefined)
88
+ const clock = createPlaybackClock()
89
+ const { unmount } = renderHook(() => useReportContext({
90
+ adapter: { reportContext } as never, projectId: 'p1', clock,
91
+ selectedIds: [], selectedCaptionId: null,
92
+ }))
93
+ unmount()
94
+ reportContext.mockClear()
95
+ act(() => {
96
+ clock.set(9)
97
+ vi.advanceTimersByTime(REPORT_INTERVAL_MS * 3)
98
+ })
99
+ expect(reportContext).not.toHaveBeenCalled()
100
+ })
101
+ })