@bycrux/editor 0.11.2 → 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 (268) 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 +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  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 +601 -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 +137 -0
  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 +2229 -243
  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/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,182 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, within, act, cleanup } from '@testing-library/react'
3
+ import VersionCompare from '../VersionCompare'
4
+
5
+ afterEach(() => {
6
+ cleanup()
7
+ vi.useRealTimers()
8
+ })
9
+
10
+ const VERSIONS = [
11
+ { hash: 'abc', message: 'v1', timestamp: '2026-01-01T00:00:00Z' },
12
+ { hash: 'def', message: 'v2', timestamp: '2026-01-02T00:00:00Z' },
13
+ ]
14
+
15
+ /** Distinctive, deterministic stand-in for the real frame-render URL builder —
16
+ * encodes exactly the two inputs under test (commit, t) into the string. */
17
+ function mockFrameUrl(_id: string, commit: string, t: number): string {
18
+ return `mock:${commit}:${t}`
19
+ }
20
+
21
+ describe('VersionCompare', () => {
22
+ it('renders two frame panes for two versions', () => {
23
+ render(
24
+ <VersionCompare
25
+ projectId="proj-1"
26
+ versions={VERSIONS}
27
+ initialLeftHash="abc"
28
+ frameUrl={mockFrameUrl}
29
+ durationSeconds={10}
30
+ onClose={vi.fn()}
31
+ />,
32
+ )
33
+
34
+ expect(screen.getAllByRole('img')).toHaveLength(2)
35
+ })
36
+
37
+ it('sets both <img> srcs from frameUrl with the correct commit ids', () => {
38
+ render(
39
+ <VersionCompare
40
+ projectId="proj-1"
41
+ versions={VERSIONS}
42
+ initialLeftHash="abc"
43
+ frameUrl={mockFrameUrl}
44
+ durationSeconds={10}
45
+ onClose={vi.fn()}
46
+ />,
47
+ )
48
+
49
+ // initialLeftHash="abc" seeds LEFT; RIGHT defaults to the "working"
50
+ // sentinel since LEFT isn't already "working". Initial t = duration/2 = 5,
51
+ // and sampleT starts equal to t (no debounce needed pre-interaction).
52
+ const imgs = screen.getAllByRole('img')
53
+ expect(imgs[0]).toHaveAttribute('src', 'mock:abc:5')
54
+ expect(imgs[1]).toHaveAttribute('src', 'mock:working:5')
55
+ })
56
+
57
+ it('re-points both <img> srcs to the new time after the scrub debounce elapses', async () => {
58
+ vi.useFakeTimers()
59
+ render(
60
+ <VersionCompare
61
+ projectId="proj-1"
62
+ versions={VERSIONS}
63
+ initialLeftHash="abc"
64
+ frameUrl={mockFrameUrl}
65
+ durationSeconds={10}
66
+ onClose={vi.fn()}
67
+ />,
68
+ )
69
+
70
+ const slider = screen.getByLabelText('Scrub time')
71
+ fireEvent.change(slider, { target: { value: '7' } })
72
+
73
+ // Pre-debounce: the img srcs must not have moved yet.
74
+ let imgs = screen.getAllByRole('img')
75
+ expect(imgs[0]).toHaveAttribute('src', 'mock:abc:5')
76
+
77
+ await act(async () => { await vi.advanceTimersByTimeAsync(200) })
78
+
79
+ imgs = screen.getAllByRole('img')
80
+ expect(imgs[0]).toHaveAttribute('src', 'mock:abc:7')
81
+ expect(imgs[1]).toHaveAttribute('src', 'mock:working:7')
82
+ })
83
+
84
+ it("changing the LEFT picker updates only the left pane's src", () => {
85
+ render(
86
+ <VersionCompare
87
+ projectId="proj-1"
88
+ versions={VERSIONS}
89
+ initialLeftHash="abc"
90
+ frameUrl={mockFrameUrl}
91
+ durationSeconds={10}
92
+ onClose={vi.fn()}
93
+ />,
94
+ )
95
+
96
+ const leftSelect = screen.getByLabelText('Left') as HTMLSelectElement
97
+ fireEvent.change(leftSelect, { target: { value: 'def' } })
98
+
99
+ const imgs = screen.getAllByRole('img')
100
+ expect(imgs[0]).toHaveAttribute('src', 'mock:def:5')
101
+ expect(imgs[1]).toHaveAttribute('src', 'mock:working:5')
102
+ })
103
+
104
+ it("changing the RIGHT picker updates only the right pane's src", () => {
105
+ render(
106
+ <VersionCompare
107
+ projectId="proj-1"
108
+ versions={VERSIONS}
109
+ initialLeftHash="abc"
110
+ frameUrl={mockFrameUrl}
111
+ durationSeconds={10}
112
+ onClose={vi.fn()}
113
+ />,
114
+ )
115
+
116
+ const rightSelect = screen.getByLabelText('Right') as HTMLSelectElement
117
+ fireEvent.change(rightSelect, { target: { value: 'def' } })
118
+
119
+ const imgs = screen.getAllByRole('img')
120
+ expect(imgs[0]).toHaveAttribute('src', 'mock:abc:5')
121
+ expect(imgs[1]).toHaveAttribute('src', 'mock:def:5')
122
+ })
123
+
124
+ it('Escape key closes', () => {
125
+ const onClose = vi.fn()
126
+ render(
127
+ <VersionCompare
128
+ projectId="proj-1"
129
+ versions={VERSIONS}
130
+ initialLeftHash="abc"
131
+ frameUrl={mockFrameUrl}
132
+ durationSeconds={10}
133
+ onClose={onClose}
134
+ />,
135
+ )
136
+
137
+ fireEvent.keyDown(document, { key: 'Escape' })
138
+ expect(onClose).toHaveBeenCalledTimes(1)
139
+ })
140
+
141
+ it('clicking the close button calls onClose', () => {
142
+ const onClose = vi.fn()
143
+ render(
144
+ <VersionCompare
145
+ projectId="proj-1"
146
+ versions={VERSIONS}
147
+ initialLeftHash="abc"
148
+ frameUrl={mockFrameUrl}
149
+ durationSeconds={10}
150
+ onClose={onClose}
151
+ />,
152
+ )
153
+
154
+ fireEvent.click(screen.getByLabelText('Close'))
155
+ expect(onClose).toHaveBeenCalledTimes(1)
156
+ })
157
+
158
+ it('offers the "working" option in both pickers and points a pane at commit "working" when chosen', () => {
159
+ render(
160
+ <VersionCompare
161
+ projectId="proj-1"
162
+ versions={VERSIONS}
163
+ initialLeftHash="abc"
164
+ frameUrl={mockFrameUrl}
165
+ durationSeconds={10}
166
+ onClose={vi.fn()}
167
+ />,
168
+ )
169
+
170
+ const leftSelect = screen.getByLabelText('Left') as HTMLSelectElement
171
+ const rightSelect = screen.getByLabelText('Right') as HTMLSelectElement
172
+ expect(within(leftSelect).getByText('Current (working)')).toBeInTheDocument()
173
+ expect(within(rightSelect).getByText('Current (working)')).toBeInTheDocument()
174
+
175
+ // RIGHT already defaults to "working" (LEFT isn't "working" initially);
176
+ // explicitly select it on LEFT too, proving the sentinel behaves
177
+ // identically wired into either picker.
178
+ fireEvent.change(leftSelect, { target: { value: 'working' } })
179
+ const imgs = screen.getAllByRole('img')
180
+ expect(imgs[0]).toHaveAttribute('src', 'mock:working:5')
181
+ })
182
+ })
@@ -0,0 +1,279 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import type { VersionEntry } from '../../types'
4
+ import VersionPanel, { listVersions, humanizeLabel } from '../VersionPanel'
5
+
6
+ afterEach(cleanup)
7
+
8
+ // Mirrors VersionPanel's own `formatTime` exactly, so the timestamp assertion
9
+ // doesn't hardcode a locale-specific string — it checks against whatever the
10
+ // panel actually renders for this environment's locale.
11
+ function expectedTimestamp(iso: string): string {
12
+ return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
13
+ }
14
+
15
+ describe('VersionPanel', () => {
16
+ it('renders every version as its own row, including two versions sharing a run number', () => {
17
+ const versions: VersionEntry[] = [
18
+ { hash: 'abc', message: 'version: run 2 — export', timestamp: '2026-01-01T12:00:00Z' },
19
+ { hash: 'def', message: 'version: run 2 — My Checkpoint', timestamp: '2026-01-01T13:00:00Z' },
20
+ { hash: 'ghi', message: 'version: run 1 — draft', timestamp: '2025-12-31T08:15:00Z' },
21
+ ]
22
+ const { container } = render(
23
+ <VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />,
24
+ )
25
+
26
+ expect(container.textContent).toContain('Exported')
27
+ expect(container.textContent).toContain('My Checkpoint')
28
+ expect(container.textContent).toContain('Draft')
29
+ expect(container.textContent).toContain(expectedTimestamp('2026-01-01T12:00:00Z'))
30
+ expect(container.textContent).toContain(expectedTimestamp('2026-01-01T13:00:00Z'))
31
+ expect(container.textContent).toContain(expectedTimestamp('2025-12-31T08:15:00Z'))
32
+ })
33
+
34
+ it('never renders the string "Run "', () => {
35
+ const versions: VersionEntry[] = [
36
+ { hash: 'abc', message: 'version: run 2 — export', timestamp: '2026-01-01T12:00:00Z' },
37
+ { hash: 'def', message: 'version: run 1 — draft', timestamp: '2025-12-31T08:15:00Z' },
38
+ ]
39
+ const { container } = render(
40
+ <VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />,
41
+ )
42
+
43
+ expect(container.textContent).not.toContain('Run ')
44
+ })
45
+
46
+ it('filters out run-0 init-baseline entries', () => {
47
+ const versions: VersionEntry[] = [
48
+ { hash: 'init', message: 'initial commit', timestamp: '2025-12-30T00:00:00Z' },
49
+ { hash: 'abc', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
50
+ ]
51
+ const { container } = render(
52
+ <VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />,
53
+ )
54
+
55
+ expect(container.textContent).toContain('Draft')
56
+ expect(container.textContent).not.toContain('initial commit')
57
+ // Header count reflects only the non-init entry, shown as an explicit
58
+ // "N versions" label on the right (no collapse control — Versions has its
59
+ // own tab now).
60
+ expect(screen.getByText('1 version')).toBeTruthy()
61
+ })
62
+
63
+ it('orders rows newest-first by timestamp regardless of input order', () => {
64
+ const versions: VersionEntry[] = [
65
+ { hash: 'oldest', message: 'version: run 1 — draft', timestamp: '2025-12-01T00:00:00Z' },
66
+ { hash: 'newest', message: 'version: run 3 — export', timestamp: '2026-01-15T00:00:00Z' },
67
+ { hash: 'middle', message: 'version: run 2 — final', timestamp: '2026-01-01T00:00:00Z' },
68
+ ]
69
+ const { container } = render(
70
+ <VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />,
71
+ )
72
+
73
+ const names = ['Exported', 'Final', 'Draft']
74
+ const positions = names.map(n => container.textContent!.indexOf(n))
75
+ expect(positions[0]).toBeLessThan(positions[1])
76
+ expect(positions[1]).toBeLessThan(positions[2])
77
+ })
78
+
79
+ describe('humanizeLabel', () => {
80
+ it('maps known labels to their humanized form, case-insensitively', () => {
81
+ expect(humanizeLabel('draft')).toBe('Draft')
82
+ expect(humanizeLabel('Draft')).toBe('Draft')
83
+ expect(humanizeLabel('export')).toBe('Exported')
84
+ expect(humanizeLabel('final')).toBe('Final')
85
+ expect(humanizeLabel('pending')).toBe('Pending')
86
+ expect(humanizeLabel('autosave before restore')).toBe('Auto-save before restore')
87
+ expect(humanizeLabel('AUTOSAVE BEFORE RESTORE')).toBe('Auto-save before restore')
88
+ })
89
+
90
+ it('returns "Untitled save" for an empty or whitespace-only label', () => {
91
+ expect(humanizeLabel('')).toBe('Untitled save')
92
+ expect(humanizeLabel(' ')).toBe('Untitled save')
93
+ })
94
+
95
+ it('returns an operator-typed name verbatim', () => {
96
+ expect(humanizeLabel('My Checkpoint')).toBe('My Checkpoint')
97
+ })
98
+ })
99
+
100
+ it('"Restore →" click calls onRestore with the entry\'s hash', () => {
101
+ const versions: VersionEntry[] = [
102
+ { hash: 'abc123', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
103
+ ]
104
+ const onRestore = vi.fn()
105
+ render(<VersionPanel versions={versions} restoring={null} onRestore={onRestore} />)
106
+
107
+ fireEvent.click(screen.getByText('Restore →'))
108
+ expect(onRestore).toHaveBeenCalledWith('abc123')
109
+ })
110
+
111
+ it('Restore and Compare call back with the right hash per row in a multi-row list', () => {
112
+ const versions: VersionEntry[] = [
113
+ { hash: 'abc', message: 'version: run 2 — export', timestamp: '2026-01-01T12:00:00Z' },
114
+ { hash: 'def', message: 'version: run 1 — draft', timestamp: '2025-12-31T08:15:00Z' },
115
+ ]
116
+ const onRestore = vi.fn()
117
+ const onCompareVersion = vi.fn()
118
+ render(
119
+ <VersionPanel
120
+ versions={versions}
121
+ restoring={null}
122
+ onRestore={onRestore}
123
+ onCompareVersion={onCompareVersion}
124
+ />,
125
+ )
126
+
127
+ const restoreButtons = screen.getAllByText('Restore →')
128
+ const compareButtons = screen.getAllByText('Compare')
129
+ expect(restoreButtons).toHaveLength(2)
130
+ expect(compareButtons).toHaveLength(2)
131
+
132
+ // Newest-first: row 0 is 'abc' (2026-01-01), row 1 is 'def' (2025-12-31).
133
+ fireEvent.click(restoreButtons[0])
134
+ expect(onRestore).toHaveBeenCalledWith('abc')
135
+ fireEvent.click(restoreButtons[1])
136
+ expect(onRestore).toHaveBeenCalledWith('def')
137
+
138
+ fireEvent.click(compareButtons[0])
139
+ expect(onCompareVersion).toHaveBeenCalledWith('abc')
140
+ fireEvent.click(compareButtons[1])
141
+ expect(onCompareVersion).toHaveBeenCalledWith('def')
142
+ })
143
+
144
+ it('"Save version" clicked with empty input calls onSaveVersion(undefined)', () => {
145
+ const onSaveVersion = vi.fn()
146
+ render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} onSaveVersion={onSaveVersion} />)
147
+
148
+ fireEvent.click(screen.getByText('Save version'))
149
+ expect(onSaveVersion).toHaveBeenCalledWith(undefined)
150
+ })
151
+
152
+ it('"Save version" clicked with a typed name calls onSaveVersion with the trimmed name', () => {
153
+ const onSaveVersion = vi.fn()
154
+ render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} onSaveVersion={onSaveVersion} />)
155
+
156
+ const input = screen.getByPlaceholderText('Name (optional)')
157
+ fireEvent.change(input, { target: { value: ' My Checkpoint ' } })
158
+ fireEvent.click(screen.getByText('Save version'))
159
+
160
+ expect(onSaveVersion).toHaveBeenCalledWith('My Checkpoint')
161
+ })
162
+
163
+ it('clears the name input after a save click', () => {
164
+ const onSaveVersion = vi.fn()
165
+ render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} onSaveVersion={onSaveVersion} />)
166
+
167
+ const input = screen.getByPlaceholderText('Name (optional)') as HTMLInputElement
168
+ fireEvent.change(input, { target: { value: 'temp name' } })
169
+ expect(input.value).toBe('temp name')
170
+
171
+ fireEvent.click(screen.getByText('Save version'))
172
+ expect(input.value).toBe('')
173
+ })
174
+
175
+ it('disables the save input and button when saving is true', () => {
176
+ render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} onSaveVersion={vi.fn()} saving />)
177
+
178
+ expect(screen.getByPlaceholderText('Name (optional)')).toBeDisabled()
179
+ expect(screen.getByText('Saving…')).toBeDisabled()
180
+ })
181
+
182
+ // Spec note: the Save row is NEVER unmounted — per VersionPanel's own
183
+ // comment it "always visible... Disabled rather than hidden when the host
184
+ // has no onSaveVersion", matching the Restore buttons' degrade-gracefully
185
+ // pattern. This test pins that actual (documented) behavior: the input and
186
+ // button stay in the DOM but disabled, rather than disappearing.
187
+ it('disables (rather than removing) the save input and button when onSaveVersion is undefined', () => {
188
+ render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} />)
189
+
190
+ expect(screen.getByPlaceholderText('Name (optional)')).toBeDisabled()
191
+ expect(screen.getByText('Save version')).toBeDisabled()
192
+ })
193
+
194
+ it('"Compare" button click calls onCompareVersion with the hash', () => {
195
+ const versions: VersionEntry[] = [
196
+ { hash: 'abc123', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
197
+ ]
198
+ const onCompareVersion = vi.fn()
199
+ render(
200
+ <VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} onCompareVersion={onCompareVersion} />,
201
+ )
202
+
203
+ fireEvent.click(screen.getByText('Compare'))
204
+ expect(onCompareVersion).toHaveBeenCalledWith('abc123')
205
+ })
206
+
207
+ it('does not render the "Compare" button when onCompareVersion is undefined', () => {
208
+ const versions: VersionEntry[] = [
209
+ { hash: 'abc123', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
210
+ ]
211
+ render(<VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />)
212
+
213
+ expect(screen.queryByText('Compare')).toBeNull()
214
+ })
215
+
216
+ it("disables only the matching entry's Restore button when restoring", () => {
217
+ const versions: VersionEntry[] = [
218
+ { hash: 'abc', message: 'version: run 2 — export', timestamp: '2026-01-01T12:00:00Z' },
219
+ { hash: 'def', message: 'version: run 1 — draft', timestamp: '2025-12-31T08:15:00Z' },
220
+ ]
221
+ render(<VersionPanel versions={versions} restoring="abc" onRestore={vi.fn()} />)
222
+
223
+ expect(screen.getByText('Restoring…')).toBeDisabled()
224
+ expect(screen.getByText('Restore →')).not.toBeDisabled()
225
+ })
226
+ })
227
+
228
+ describe('listVersions', () => {
229
+ it('filters out run-0 entries, keeps every remaining version flat (no per-run collapsing)', () => {
230
+ const versions: VersionEntry[] = [
231
+ { hash: 'init', message: 'initial commit', timestamp: '2025-12-30T00:00:00Z' },
232
+ { hash: 'a', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
233
+ { hash: 'b', message: 'version: run 1 — final', timestamp: '2026-01-02T00:00:00Z' },
234
+ ]
235
+ const result = listVersions(versions)
236
+ expect(result.map(v => v.hash)).toEqual(['b', 'a'])
237
+ })
238
+
239
+ it('sorts newest-first by timestamp', () => {
240
+ const versions: VersionEntry[] = [
241
+ { hash: 'old', message: 'version: run 1 — draft', timestamp: '2025-01-01T00:00:00Z' },
242
+ { hash: 'new', message: 'version: run 2 — export', timestamp: '2026-01-01T00:00:00Z' },
243
+ { hash: 'mid', message: 'version: run 3 — final', timestamp: '2025-06-01T00:00:00Z' },
244
+ ]
245
+ const result = listVersions(versions)
246
+ expect(result.map(v => v.hash)).toEqual(['new', 'mid', 'old'])
247
+ })
248
+
249
+ it('is stable for ties: same-timestamp entries keep their input order', () => {
250
+ const versions: VersionEntry[] = [
251
+ { hash: 'first', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
252
+ { hash: 'second', message: 'version: run 2 — export', timestamp: '2026-01-01T00:00:00Z' },
253
+ { hash: 'third', message: 'version: run 3 — final', timestamp: '2026-01-01T00:00:00Z' },
254
+ ]
255
+ const result = listVersions(versions)
256
+ expect(result.map(v => v.hash)).toEqual(['first', 'second', 'third'])
257
+ })
258
+
259
+ it('treats an unparseable timestamp as sorting last, without throwing', () => {
260
+ const versions: VersionEntry[] = [
261
+ { hash: 'bad', message: 'version: run 1 — draft', timestamp: 'not-a-date' },
262
+ { hash: 'good', message: 'version: run 2 — export', timestamp: '2026-01-01T00:00:00Z' },
263
+ ]
264
+ expect(() => listVersions(versions)).not.toThrow()
265
+ const result = listVersions(versions)
266
+ expect(result.map(v => v.hash)).toEqual(['good', 'bad'])
267
+ })
268
+
269
+ it('does not mutate the input array', () => {
270
+ const versions: VersionEntry[] = [
271
+ { hash: 'old', message: 'version: run 1 — draft', timestamp: '2025-01-01T00:00:00Z' },
272
+ { hash: 'new', message: 'version: run 2 — export', timestamp: '2026-01-01T00:00:00Z' },
273
+ ]
274
+ const original = [...versions]
275
+ listVersions(versions)
276
+ expect(versions).toEqual(original)
277
+ })
278
+
279
+ })