@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,944 @@
1
+ /// <reference types="vite/client" />
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
3
+ import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'
4
+ import type { Project } from '../types'
5
+ import type { Captions, CaptionSegment } from '../schema'
6
+ import type { PlaybackClock } from './playback-clock'
7
+ import CaptionListPanel, { type CaptionListPanelProps, nextEditFocus, reviveCaptionTab } from './CaptionListPanel'
8
+ import { formatTime } from './timeline/utils'
9
+ import src from './CaptionListPanel.tsx?raw'
10
+
11
+ // The panel's active sub-tab (Format / Styles / Captions) persists to
12
+ // localStorage (usePersistentState). Clear it between tests, then SEED it to
13
+ // 'captions' so the bulk of the suites below — which inspect the transcript
14
+ // list, search, row filters, and footer actions — render on the tab those
15
+ // live on. The dedicated 'tabs' suite at the bottom clears this seed itself
16
+ // to exercise the real default ('format').
17
+ beforeEach(() => {
18
+ window.localStorage.clear()
19
+ window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
20
+ })
21
+ afterEach(() => cleanup())
22
+
23
+ // jsdom doesn't implement scrollIntoView; the editFocusId effect calls it.
24
+ Element.prototype.scrollIntoView = vi.fn()
25
+
26
+ function makeProject(style: Captions['style'], segments: CaptionSegment[], extra: Partial<Captions> = {}): Project {
27
+ return { id: 'p1', captions: { style, segments, ...extra } } as unknown as Project
28
+ }
29
+
30
+ function makeClock(): PlaybackClock {
31
+ return { get: () => 0, set: vi.fn(), subscribe: () => () => {} }
32
+ }
33
+
34
+ const THREE_SEGS: CaptionSegment[] = [
35
+ { id: 'cap-0', text: 'hello world', start: 0, end: 2 },
36
+ { id: 'cap-1', text: 'goodbye now', start: 2, end: 4 },
37
+ { id: 'cap-2', text: 'the end', start: 4, end: 6 },
38
+ ]
39
+
40
+ // Two lanes (SP5-captions Phase 5's `groupCaptionLanes` grouping). Lane 0
41
+ // ("Row 1") is nearest the video; lane 1 ("Row 2") sits above it. Named
42
+ // alpha/beta per lane so a search query can target exactly one row's segment
43
+ // without also matching the other row's.
44
+ const TWO_LANE_SEGS: CaptionSegment[] = [
45
+ { id: 'r1-a', text: 'lower caption alpha', start: 0, end: 2, lane: 0 },
46
+ { id: 'r2-a', text: 'upper caption alpha', start: 0, end: 2, lane: 1 },
47
+ { id: 'r1-b', text: 'lower caption beta', start: 2, end: 4, lane: 0 },
48
+ { id: 'r2-b', text: 'upper caption beta', start: 2, end: 4, lane: 1 },
49
+ ]
50
+
51
+ function renderPanel(opts: {
52
+ style?: Captions['style']
53
+ segments?: CaptionSegment[]
54
+ extra?: Partial<Captions>
55
+ currentTime?: number
56
+ selectedIds?: string[]
57
+ fps?: number
58
+ editFocusId?: CaptionListPanelProps['editFocusId']
59
+ withRegenerate?: boolean
60
+ } = {}) {
61
+ const {
62
+ style = 'karaoke',
63
+ segments = THREE_SEGS,
64
+ extra = {},
65
+ currentTime = 0,
66
+ selectedIds = [],
67
+ fps = 30,
68
+ editFocusId = null,
69
+ withRegenerate = true,
70
+ } = opts
71
+
72
+ const project = makeProject(style, segments, extra)
73
+ const clock = makeClock()
74
+ const onSelectCaption = vi.fn()
75
+ const onCaptionSegmentChange = vi.fn()
76
+ const onCaptionEdit = vi.fn()
77
+ const onProjectChange = vi.fn()
78
+ const onCaptionSegmentDelete = vi.fn()
79
+ const onRegenerateCaptions = withRegenerate ? vi.fn() : undefined
80
+
81
+ const view = render(
82
+ <CaptionListPanel
83
+ captionTrack={project.captions}
84
+ project={project}
85
+ currentTime={currentTime}
86
+ selectedIds={selectedIds}
87
+ onSelectCaption={onSelectCaption}
88
+ onCaptionSegmentChange={onCaptionSegmentChange}
89
+ onCaptionEdit={onCaptionEdit}
90
+ onProjectChange={onProjectChange}
91
+ onCaptionSegmentDelete={onCaptionSegmentDelete}
92
+ onRegenerateCaptions={onRegenerateCaptions}
93
+ fps={fps}
94
+ clock={clock}
95
+ editFocusId={editFocusId}
96
+ />,
97
+ )
98
+
99
+ return {
100
+ ...view,
101
+ project,
102
+ clock,
103
+ onSelectCaption,
104
+ onCaptionSegmentChange,
105
+ onCaptionEdit,
106
+ onProjectChange,
107
+ onCaptionSegmentDelete,
108
+ onRegenerateCaptions,
109
+ }
110
+ }
111
+
112
+ describe('CaptionListPanel list', () => {
113
+ it('renders every segment, numbered', () => {
114
+ renderPanel()
115
+ const rows = screen.getAllByRole('listitem')
116
+ expect(rows).toHaveLength(3)
117
+ expect(within(rows[0]).getByText('1')).toBeTruthy()
118
+ expect(within(rows[1]).getByText('2')).toBeTruthy()
119
+ expect(within(rows[2]).getByText('3')).toBeTruthy()
120
+ expect(screen.getByText('hello world')).toBeTruthy()
121
+ expect(screen.getByText('goodbye now')).toBeTruthy()
122
+ expect(screen.getByText('the end')).toBeTruthy()
123
+ })
124
+
125
+ // R3: the retired TranscriptPanel/TranscriptModal both showed each segment's
126
+ // start time — restored alongside the index badge (not instead of it) since
127
+ // an index gives ordinal position while a timestamp gives where in the video
128
+ // the line actually lands, which is what you navigate by.
129
+ it('renders every segment\'s start timestamp alongside its index', () => {
130
+ renderPanel()
131
+ const rows = screen.getAllByRole('listitem')
132
+ expect(within(rows[0]).getByText(formatTime(THREE_SEGS[0].start))).toBeTruthy()
133
+ expect(within(rows[1]).getByText(formatTime(THREE_SEGS[1].start))).toBeTruthy()
134
+ expect(within(rows[2]).getByText(formatTime(THREE_SEGS[2].start))).toBeTruthy()
135
+ })
136
+
137
+ // Regression guard, source-level rather than rendered: Tailwind cannot
138
+ // generate a rule for an opacity modifier on an arbitrary var() color
139
+ // (`text-[var(--editor-text)]/40`) — the class is a silent no-op, so the
140
+ // element inherits whatever color its ancestor has instead. That shipped
141
+ // invisibly on these exact two spans (index number + start timestamp):
142
+ // measured in a real browser at rgb(17,24,39) text on an rgb(17,24,39) row,
143
+ // a 1.00:1 contrast ratio, across all 39 rows of a real caption list. jsdom
144
+ // doesn't evaluate Tailwind, so `getByText(...)` above passes either way —
145
+ // only a source-text check on the actual class string catches it.
146
+ //
147
+ // Scoped to just these two spans, not "no `/<number>` modifier anywhere in
148
+ // the file": ~12 OTHER `text-[var(--editor-text)]/N` uses remain elsewhere
149
+ // in this component (toolbar, buttons, search, empty states) and are
150
+ // equally no-ops, but they were individually measured to render legibly in
151
+ // their positions and are out of scope for this fix. A file-wide ban here
152
+ // would either force touching all of them unverified or need a fragile
153
+ // "count didn't grow" check that can't tell one class moving from banning
154
+ // one occurrence while a new one appears elsewhere.
155
+ it('the row index and timestamp spans do not use a no-op opacity-modifier color class', () => {
156
+ const indexSpan = src.match(/<span className="[^"]*">\{index \+ 1\}<\/span>/)?.[0] ?? ''
157
+ const timestampSpan = src.match(/<span className="[^"]*">\{formatTime\(seg\.start\)\}<\/span>/)?.[0] ?? ''
158
+ expect(indexSpan).toBeTruthy()
159
+ expect(timestampSpan).toBeTruthy()
160
+ expect(indexSpan).not.toMatch(/text-\[var\(--editor-text\)\]\/\d+/)
161
+ expect(timestampSpan).not.toMatch(/text-\[var\(--editor-text\)\]\/\d+/)
162
+ })
163
+
164
+ it('with no captionTrack but a regenerate capability, offers only Generate captions + the empty message — no style controls, no search, no count', () => {
165
+ const project = { id: 'p1' } as unknown as Project
166
+ const clock = makeClock()
167
+ render(
168
+ <CaptionListPanel
169
+ captionTrack={undefined}
170
+ project={project}
171
+ currentTime={0}
172
+ selectedIds={[]}
173
+ onSelectCaption={vi.fn()}
174
+ onRegenerateCaptions={vi.fn()}
175
+ fps={30}
176
+ clock={clock}
177
+ />,
178
+ )
179
+ // No captions yet, so the trigger is the empty state's primary
180
+ // "Generate captions" button, not the header's "Regenerate captions".
181
+ expect(screen.getByText('Generate captions')).toBeTruthy()
182
+ expect(screen.getByText(/generated from/i)).toBeTruthy()
183
+ expect(screen.queryByLabelText('Caption style controls')).toBeNull()
184
+ expect(screen.queryByLabelText('Search captions')).toBeNull()
185
+ expect(screen.queryByText('Remove all')).toBeNull()
186
+ })
187
+
188
+ it('renders nothing at all when there are no captions and no way to generate them', () => {
189
+ const project = { id: 'p1' } as unknown as Project
190
+ const clock = makeClock()
191
+ const { container } = render(
192
+ <CaptionListPanel
193
+ captionTrack={undefined}
194
+ project={project}
195
+ currentTime={0}
196
+ selectedIds={[]}
197
+ onSelectCaption={vi.fn()}
198
+ fps={30}
199
+ clock={clock}
200
+ />,
201
+ )
202
+ expect(container.firstChild).toBeNull()
203
+ })
204
+
205
+ it('search filters the list by segment text, case-insensitively', () => {
206
+ renderPanel()
207
+ const search = screen.getByLabelText('Search captions')
208
+ fireEvent.change(search, { target: { value: 'GOODBYE' } })
209
+ expect(screen.getAllByRole('listitem')).toHaveLength(1)
210
+ expect(screen.getByText('goodbye now')).toBeTruthy()
211
+ expect(screen.queryByText('hello world')).toBeNull()
212
+ })
213
+
214
+ it('search preserves each row\'s true position number even when filtered', () => {
215
+ renderPanel()
216
+ fireEvent.change(screen.getByLabelText('Search captions'), { target: { value: 'the end' } })
217
+ const rows = screen.getAllByRole('listitem')
218
+ expect(rows).toHaveLength(1)
219
+ expect(within(rows[0]).getByText('3')).toBeTruthy()
220
+ })
221
+
222
+ it('the selected row reflects selectedIds', () => {
223
+ renderPanel({ selectedIds: ['cap-1'] })
224
+ const rows = screen.getAllByRole('listitem')
225
+ // `aria-current`, not `aria-selected` — the row is `role="listitem"`, not
226
+ // `option`, since each one contains a contenteditable and a delete
227
+ // button and ARIA forbids interactive descendants inside `option`.
228
+ // Unselected rows omit the attribute entirely rather than carrying
229
+ // `aria-current="false"`.
230
+ expect(rows[0].getAttribute('aria-current')).toBeNull()
231
+ expect(rows[1].getAttribute('aria-current')).toBe('true')
232
+ expect(rows[2].getAttribute('aria-current')).toBeNull()
233
+ })
234
+
235
+ it('has no add button anywhere — captions come from transcription only', () => {
236
+ renderPanel()
237
+ expect(screen.queryByRole('button', { name: /^add$/i })).toBeNull()
238
+ expect(screen.queryByLabelText(/add caption/i)).toBeNull()
239
+ })
240
+ })
241
+
242
+ describe('CaptionListPanel row interactions', () => {
243
+ it('a row click selects the segment and seeks to start + 0.5/fps (half-frame nudge past the frame-snap boundary)', () => {
244
+ const { clock, onSelectCaption } = renderPanel({ fps: 30 })
245
+ fireEvent.click(screen.getByText('goodbye now'))
246
+ expect(onSelectCaption).toHaveBeenCalledWith('cap-1')
247
+ expect(clock.set).toHaveBeenCalledWith(2 + 0.5 / 30)
248
+ })
249
+
250
+ it('a text edit fires onCaptionSegmentChange(id, { text }) and nothing else', () => {
251
+ const { onCaptionSegmentChange, onCaptionEdit, onProjectChange } = renderPanel()
252
+ const span = screen.getByText('hello world')
253
+ span.textContent = 'hello universe'
254
+ fireEvent.blur(span)
255
+ expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
256
+ expect(onCaptionSegmentChange).toHaveBeenCalledWith('cap-0', { text: 'hello universe' })
257
+ expect(onCaptionEdit).not.toHaveBeenCalled()
258
+ expect(onProjectChange).not.toHaveBeenCalled()
259
+ })
260
+
261
+ it('a trash click fires the whole-track delete channel with the segment id, and does not also select/seek', () => {
262
+ const { onCaptionSegmentDelete, onSelectCaption, clock } = renderPanel()
263
+ const rows = screen.getAllByRole('listitem')
264
+ fireEvent.click(within(rows[1]).getByLabelText('Delete caption'))
265
+ expect(onCaptionSegmentDelete).toHaveBeenCalledTimes(1)
266
+ expect(onCaptionSegmentDelete).toHaveBeenCalledWith('cap-1')
267
+ expect(onSelectCaption).not.toHaveBeenCalled()
268
+ expect(clock.set).not.toHaveBeenCalled()
269
+ })
270
+ })
271
+
272
+ describe('CaptionListPanel relocated style controls', () => {
273
+ // The style controls used to hide behind a collapse toggle, then behind a
274
+ // single "Style" tab; they now split across two tabs — "Format" (the fine
275
+ // controls: size, colors, font, Bold, case, alignment, spacing;
276
+ // the panel's default) and "Styles" (the gallery of live style previews,
277
+ // see CaptionStyleGallery.tsx / .test.tsx). Selecting a tab is what makes
278
+ // its controls visible — these suites seed 'captions' in `beforeEach`, so
279
+ // each helper's click actually flips tabs here.
280
+ function expandFormat() {
281
+ fireEvent.click(screen.getByRole('button', { name: 'Format' }))
282
+ }
283
+ function expandStyles() {
284
+ fireEvent.click(screen.getByRole('button', { name: 'Styles' }))
285
+ }
286
+
287
+ // Was: click a style preset CHIP on the old single "Style" tab. The chip
288
+ // row is retired (CaptionStyleGallery replaces it); this proves the PANEL
289
+ // wires the gallery's click-to-commit through onCaptionEdit exactly as the
290
+ // chip row did — the gallery's own test file covers the gallery in
291
+ // isolation, this is the integration seam between the two.
292
+ it('a style gallery card click commits via onCaptionEdit, writing captions.style', () => {
293
+ const { onCaptionEdit } = renderPanel({ style: 'pop' })
294
+ expandStyles()
295
+ fireEvent.click(screen.getByRole('button', { name: 'Subtitle' }))
296
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
297
+ expect(onCaptionEdit.mock.calls[0][0].captions.style).toBe('subtitle')
298
+ })
299
+
300
+ it('fontsize previews live on change and commits once on blur — exactly one channel per gesture', () => {
301
+ // Was a `type="range"` slider (commit on pointer-up/key-up); now the
302
+ // shared NumberField box, whose commit gesture is blur/Enter instead —
303
+ // see NumberField's own doc comment for why.
304
+ const { onProjectChange, onCaptionEdit } = renderPanel()
305
+ expandFormat()
306
+ // Two controls now share this name (the shared Slider + the NumberField box);
307
+ // target the box (spinbutton) — this test types/commits an exact value.
308
+ const field = screen.getByRole('spinbutton', { name: 'Caption font size' })
309
+
310
+ fireEvent.change(field, { target: { value: '60' } })
311
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
312
+ expect(onProjectChange.mock.calls[0][0].captions.fontsize).toBe(60)
313
+ expect(onCaptionEdit).not.toHaveBeenCalled()
314
+
315
+ fireEvent.blur(field)
316
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
317
+ expect(onCaptionEdit.mock.calls[0][0].captions.fontsize).toBe(60)
318
+ // The commit did not also fire another live preview.
319
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
320
+ })
321
+
322
+ // NumberField never clamps a TYPED value against its own min/max (those are
323
+ // a native spinner hint only) — the call site is what enforces the ceiling,
324
+ // via `clampToRange`. 400 is CAPTION_FONT_SIZE_MAX, the widened ceiling this
325
+ // feature's changelog entry advertises; this is the only test proving the
326
+ // call-site clamp actually catches a value typed past it.
327
+ it('fontsize typed past the ceiling clamps to 400 on commit', () => {
328
+ const { onCaptionEdit } = renderPanel()
329
+ expandFormat()
330
+ // Two controls now share this name (the shared Slider + the NumberField box);
331
+ // target the box (spinbutton) — this test types/commits an exact value.
332
+ const field = screen.getByRole('spinbutton', { name: 'Caption font size' })
333
+
334
+ fireEvent.change(field, { target: { value: '500' } })
335
+ fireEvent.blur(field)
336
+
337
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
338
+ expect(onCaptionEdit.mock.calls[0][0].captions.fontsize).toBe(400)
339
+ })
340
+
341
+ it('base text color previews live via onProjectChange and commits via onCaptionEdit when no segment is selected', () => {
342
+ const { onProjectChange, onCaptionEdit } = renderPanel()
343
+ expandFormat()
344
+ const input = screen.getByLabelText('Caption text color') as HTMLInputElement
345
+
346
+ fireEvent.change(input, { target: { value: '#76b900' } })
347
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
348
+ expect(onProjectChange.mock.calls[0][0].captions.color).toBe('#76b900')
349
+ expect(onCaptionEdit).not.toHaveBeenCalled()
350
+
351
+ fireEvent.blur(input, { target: { value: '#76b900' } })
352
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
353
+ expect(onCaptionEdit.mock.calls[0][0].captions.color).toBe('#76b900')
354
+ // The commit did not also fire another live preview.
355
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
356
+ })
357
+
358
+ it("the swatch reflects the selected segment's own color when it has one, not the track color", () => {
359
+ renderPanel({
360
+ style: 'karaoke',
361
+ segments: [{ id: 'cap-0', text: 'hi', start: 0, end: 2, color: '#00ff00' }],
362
+ extra: { color: '#abcdef' },
363
+ selectedIds: ['cap-0'],
364
+ })
365
+ expandFormat()
366
+ expect((screen.getByLabelText('Selected segment text color') as HTMLInputElement).value).toBe('#00ff00')
367
+ })
368
+
369
+ it('when a segment is selected, the base swatch previews via onProjectChange (segment-scoped) and commits via onCaptionSegmentChange — never onCaptionEdit', () => {
370
+ const { onProjectChange, onCaptionEdit, onCaptionSegmentChange } = renderPanel({
371
+ selectedIds: ['cap-1'],
372
+ extra: { color: '#ffffff' },
373
+ })
374
+ expandFormat()
375
+ const input = screen.getByLabelText('Selected segment text color') as HTMLInputElement
376
+
377
+ fireEvent.change(input, { target: { value: '#123456' } })
378
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
379
+ const previewed = onProjectChange.mock.calls[0][0]
380
+ expect(previewed.captions.segments[1].color).toBe('#123456')
381
+ expect(previewed.captions.segments[0].color).toBeUndefined()
382
+ expect(previewed.captions.color).toBe('#ffffff')
383
+ expect(onCaptionSegmentChange).not.toHaveBeenCalled()
384
+ expect(onCaptionEdit).not.toHaveBeenCalled()
385
+
386
+ fireEvent.blur(input, { target: { value: '#123456' } })
387
+ expect(onCaptionSegmentChange).toHaveBeenCalledTimes(1)
388
+ expect(onCaptionSegmentChange).toHaveBeenCalledWith('cap-1', { color: '#123456' })
389
+ expect(onCaptionEdit).not.toHaveBeenCalled()
390
+ // The commit did not also fire another live preview.
391
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
392
+ })
393
+
394
+ // ACCENT is a 5-entry lookup (see CaptionListPanel.tsx) binding each caption
395
+ // style to the ONE prop its render template actually reads. A wrong entry
396
+ // means the swatch writes a field the template ignores — nothing errors,
397
+ // the preview just silently doesn't change — so this is exercised
398
+ // exhaustively across ALL FIVE entries, `outline` included. `outline` and
399
+ // `highlight-box` happen to agree on `accentColor` today, but that
400
+ // agreement is a property of the current table, not one this test may
401
+ // assume — a fat-fingered `outline` entry would be invisible from
402
+ // `highlight-box`'s case alone, so each style gets its own row regardless
403
+ // of what any other row asserts.
404
+ describe.each([
405
+ { style: 'karaoke' as const, label: 'Caption highlight color', field: 'highlightColor' },
406
+ { style: 'pop' as const, label: 'Caption active color', field: 'activeColor' },
407
+ { style: 'subtitle' as const, label: 'Caption box color', field: 'backgroundColor' },
408
+ { style: 'highlight-box' as const, label: 'Caption accent color', field: 'accentColor' },
409
+ { style: 'outline' as const, label: 'Caption accent color', field: 'accentColor' },
410
+ ])('accent color for style $style', ({ style, label, field }) => {
411
+ it(`writes to captions.${field}, live then commit, exactly once each`, () => {
412
+ const { onProjectChange, onCaptionEdit } = renderPanel({ style })
413
+ expandFormat()
414
+ const input = screen.getByLabelText(label) as HTMLInputElement
415
+
416
+ fireEvent.change(input, { target: { value: '#111111' } })
417
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
418
+ expect(onProjectChange.mock.calls[0][0].captions[field]).toBe('#111111')
419
+ expect(onCaptionEdit).not.toHaveBeenCalled()
420
+
421
+ fireEvent.blur(input, { target: { value: '#111111' } })
422
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
423
+ expect(onCaptionEdit.mock.calls[0][0].captions[field]).toBe('#111111')
424
+ // The commit did not also fire another live preview.
425
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
426
+ })
427
+ })
428
+
429
+ it.each(['clean', 'word-by-word'] as const)('hides the accent swatch for styles with no accent: %s', (style) => {
430
+ renderPanel({ style })
431
+ expandFormat()
432
+ expect(screen.getByLabelText('Caption text color')).toBeTruthy()
433
+ expect(screen.queryByLabelText(/(accent|highlight|active|box) color/i)).toBeNull()
434
+ })
435
+ })
436
+
437
+ describe('CaptionListPanel toolbar actions', () => {
438
+ it('Regenerate fires onRegenerateCaptions and is hidden when the prop is absent', () => {
439
+ const { onRegenerateCaptions } = renderPanel({ withRegenerate: true })
440
+ fireEvent.click(screen.getByText('Regenerate captions'))
441
+ expect(onRegenerateCaptions).toHaveBeenCalledTimes(1)
442
+ cleanup()
443
+
444
+ renderPanel({ withRegenerate: false })
445
+ expect(screen.queryByText('Regenerate captions')).toBeNull()
446
+ })
447
+
448
+ it('Remove all requires two clicks and commits captions: null on the second', () => {
449
+ const { onCaptionEdit } = renderPanel()
450
+ const removeBtn = screen.getByText('Remove all')
451
+ fireEvent.click(removeBtn)
452
+ expect(onCaptionEdit).not.toHaveBeenCalled()
453
+ expect(screen.getByText('Really remove?')).toBeTruthy()
454
+
455
+ fireEvent.click(screen.getByText('Really remove?'))
456
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
457
+ expect(onCaptionEdit.mock.calls[0][0].captions).toBeNull()
458
+ })
459
+ })
460
+
461
+ // Producer side (VideoEditor.tsx's `handleEditCaption` calls this directly) —
462
+ // the receiving-end tests below prove CaptionListPanel re-focuses correctly
463
+ // GIVEN a fresh nonce; this proves the nonce actually gets produced fresh.
464
+ // Behavioural, not source-text matching: a wrong-operator bug (`?? 1` instead
465
+ // of `?? 0`, or a dropped `+ 1`) would read as plausible source but fail here.
466
+ describe('nextEditFocus', () => {
467
+ it('starts a fresh request at nonce 1 when there was no previous one', () => {
468
+ expect(nextEditFocus(null, 'a')).toEqual({ id: 'a', nonce: 1 })
469
+ })
470
+
471
+ it('bumps the nonce by 1 on a repeat double-click of the SAME segment', () => {
472
+ expect(nextEditFocus({ id: 'a', nonce: 1 }, 'a')).toEqual({ id: 'a', nonce: 2 })
473
+ })
474
+
475
+ it('keeps climbing across DIFFERENT ids — the nonce is global, not per-id', () => {
476
+ expect(nextEditFocus({ id: 'a', nonce: 5 }, 'b')).toEqual({ id: 'b', nonce: 6 })
477
+ })
478
+ })
479
+
480
+ describe('CaptionListPanel editFocusId (Phase 6 receiving end)', () => {
481
+ it('scrolls the target row into view and focuses its EditableSegment when the nonce changes', () => {
482
+ const { rerender, project, clock } = (() => {
483
+ const project = makeProject('karaoke', THREE_SEGS)
484
+ const clock = makeClock()
485
+ const utils = render(
486
+ <CaptionListPanel
487
+ captionTrack={project.captions}
488
+ project={project}
489
+ currentTime={0}
490
+ selectedIds={[]}
491
+ onSelectCaption={vi.fn()}
492
+ onCaptionSegmentChange={vi.fn()}
493
+ fps={30}
494
+ clock={clock}
495
+ editFocusId={null}
496
+ />,
497
+ )
498
+ return { ...utils, project, clock }
499
+ })()
500
+
501
+ rerender(
502
+ <CaptionListPanel
503
+ captionTrack={project.captions}
504
+ project={project}
505
+ currentTime={0}
506
+ selectedIds={[]}
507
+ onSelectCaption={vi.fn()}
508
+ onCaptionSegmentChange={vi.fn()}
509
+ fps={30}
510
+ clock={clock}
511
+ editFocusId={{ id: 'cap-1', nonce: 1 }}
512
+ />,
513
+ )
514
+
515
+ const editable = screen.getByText('goodbye now')
516
+ expect(document.activeElement).toBe(editable)
517
+ })
518
+
519
+ // The test above only proves a FIRST arrival (`null` → a real request)
520
+ // re-focuses — on that transition the id changes too, so it would pass
521
+ // even if `lastHandledNonceRef` were keyed on `id` instead of `nonce`. This
522
+ // test isolates the nonce itself: same id throughout, only the nonce moves,
523
+ // which is the one axis an id-keyed guard could not get right.
524
+ it('re-focuses on a same-id nonce bump, but not on a re-render with an unchanged nonce', () => {
525
+ const project = makeProject('karaoke', THREE_SEGS)
526
+ const clock = makeClock()
527
+ const commonProps = {
528
+ captionTrack: project.captions,
529
+ project,
530
+ currentTime: 0,
531
+ selectedIds: [],
532
+ onSelectCaption: vi.fn(),
533
+ onCaptionSegmentChange: vi.fn(),
534
+ fps: 30,
535
+ clock,
536
+ }
537
+
538
+ const { rerender } = render(
539
+ <CaptionListPanel {...commonProps} editFocusId={{ id: 'cap-1', nonce: 1 }} />,
540
+ )
541
+ const editable = screen.getByText('goodbye now')
542
+ expect(document.activeElement).toBe(editable)
543
+
544
+ // A REAL DOM blur, not `fireEvent.blur` — the latter only dispatches the
545
+ // event and does not move jsdom's `document.activeElement`, which would
546
+ // let every assertion below pass on residual focus from the render above
547
+ // regardless of what the effect actually does.
548
+ editable.blur()
549
+ expect(document.activeElement).not.toBe(editable)
550
+
551
+ // Negative: the SAME { id, nonce } — a new object, same values — must NOT
552
+ // re-focus. This is `lastHandledNonceRef` doing its job; without it, any
553
+ // unrelated parent re-render while this request is still current would
554
+ // re-steal focus out from under whatever the operator is doing next.
555
+ rerender(<CaptionListPanel {...commonProps} editFocusId={{ id: 'cap-1', nonce: 1 }} />)
556
+ expect(document.activeElement).not.toBe(editable)
557
+
558
+ // Positive: SAME id, INCREMENTED nonce — must re-focus. An id-keyed guard
559
+ // (the bug this test exists to catch) would ALSO treat this as "already
560
+ // handled", since the id never changed, and wrongly stay silent here —
561
+ // this is the one assertion only a nonce-keyed guard can pass.
562
+ rerender(<CaptionListPanel {...commonProps} editFocusId={{ id: 'cap-1', nonce: 2 }} />)
563
+ expect(document.activeElement).toBe(editable)
564
+ })
565
+ })
566
+
567
+ // SP5-captions Phase 5: multi-lane captions (`CaptionSegment.lane`, grouped
568
+ // via captionLanes.ts's `groupCaptionLanes`) get their own row headers,
569
+ // per-lane numbering, and a row-filter chip strip. THREE_SEGS (used by every
570
+ // test above) has no `lane` field on any segment, so it is — and stays — the
571
+ // single-lane case: every project ever saved before this phase looks exactly
572
+ // like it.
573
+ describe('CaptionListPanel lanes (Phase 5)', () => {
574
+ it('single lane: no row headers, no row-filter chips, and the list has ONLY listitem children — the Fragment wrapper adds no DOM node', () => {
575
+ renderPanel() // THREE_SEGS: no `lane` field on any segment ⇒ one group
576
+ expect(screen.queryByRole('group', { name: 'Filter captions by row' })).toBeNull()
577
+ expect(screen.queryByText(/^Row \d+$/)).toBeNull()
578
+
579
+ const list = screen.getByRole('list', { name: 'Caption segments' })
580
+ const children = Array.from(list.children)
581
+ expect(children).toHaveLength(3)
582
+ children.forEach(child => expect(child.getAttribute('role')).toBe('listitem'))
583
+ })
584
+
585
+ it('a multi-lane project shows a sticky "Row N" header per lane, Row 1 (lane 0, nearest the video) first, each restarting its own 1-based numbering', () => {
586
+ renderPanel({ segments: TWO_LANE_SEGS })
587
+
588
+ // Distinguish the sticky LIST headers from the chip-strip BUTTONS that
589
+ // happen to carry the same "Row N" text.
590
+ const row1Header = screen.getAllByText('Row 1').find(el => el.tagName !== 'BUTTON')
591
+ const row2Header = screen.getAllByText('Row 2').find(el => el.tagName !== 'BUTTON')
592
+ expect(row1Header).toBeTruthy()
593
+ expect(row2Header).toBeTruthy()
594
+ // Row 1 (lane 0) lists FIRST, ahead of Row 2 (lane 1), in document order.
595
+ expect(row1Header!.compareDocumentPosition(row2Header!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
596
+
597
+ const rows = screen.getAllByRole('listitem')
598
+ expect(rows).toHaveLength(4)
599
+ // Row 1's two captions are numbered 1, 2 — their position within lane 0.
600
+ // Row 2's RESTART at 1, 2 too — their position within lane 1 — not 3, 4,
601
+ // which a single counter running across every lane would have produced.
602
+ expect(within(rows[0]).getByText('1')).toBeTruthy() // r1-a
603
+ expect(within(rows[1]).getByText('2')).toBeTruthy() // r1-b
604
+ expect(within(rows[2]).getByText('1')).toBeTruthy() // r2-a
605
+ expect(within(rows[3]).getByText('2')).toBeTruthy() // r2-b
606
+ expect(rows[0]).toHaveTextContent('lower caption alpha')
607
+ expect(rows[3]).toHaveTextContent('upper caption beta')
608
+ })
609
+
610
+ it('shows an All / Row 1 / Row 2 chip strip only once there is more than one lane, "All" active by default', () => {
611
+ renderPanel({ segments: TWO_LANE_SEGS })
612
+ const group = screen.getByRole('group', { name: 'Filter captions by row' })
613
+ const chips = within(group).getAllByRole('button')
614
+ expect(chips.map(c => c.textContent)).toEqual(['All', 'Row 1', 'Row 2'])
615
+ expect(within(group).getByText('All')).toHaveAttribute('aria-pressed', 'true')
616
+ expect(within(group).getByText('Row 1')).toHaveAttribute('aria-pressed', 'false')
617
+ })
618
+
619
+ it('clicking a "Row N" chip filters the list down to just that lane, and "All" restores it', () => {
620
+ renderPanel({ segments: TWO_LANE_SEGS })
621
+
622
+ fireEvent.click(screen.getByRole('button', { name: 'Row 2' }))
623
+ expect(screen.getByRole('button', { name: 'Row 2' })).toHaveAttribute('aria-pressed', 'true')
624
+ expect(screen.queryByText('lower caption alpha')).toBeNull()
625
+ expect(screen.queryByText('lower caption beta')).toBeNull()
626
+ expect(screen.getByText('upper caption alpha')).toBeTruthy()
627
+ expect(screen.getByText('upper caption beta')).toBeTruthy()
628
+ expect(screen.getAllByRole('listitem')).toHaveLength(2)
629
+
630
+ fireEvent.click(screen.getByRole('button', { name: 'All' }))
631
+ expect(screen.getAllByRole('listitem')).toHaveLength(4)
632
+ })
633
+
634
+ it('the row chip and search compose — both narrow the same list, neither disables the other', () => {
635
+ renderPanel({ segments: TWO_LANE_SEGS })
636
+
637
+ fireEvent.click(screen.getByRole('button', { name: 'Row 1' }))
638
+ fireEvent.change(screen.getByLabelText('Search captions'), { target: { value: 'beta' } })
639
+
640
+ const rows = screen.getAllByRole('listitem')
641
+ expect(rows).toHaveLength(1)
642
+ expect(within(rows[0]).getByText('lower caption beta')).toBeTruthy()
643
+ // Its per-lane index badge is unaffected by its lane-mate (r1-a) being
644
+ // filtered out by search — still "2", its true position within lane 0.
645
+ expect(within(rows[0]).getByText('2')).toBeTruthy()
646
+ })
647
+
648
+ it('text edit, delete, and row-click-to-select still commit through the existing channels for a row-2 (lane 1) caption', () => {
649
+ const { onCaptionSegmentChange, onCaptionSegmentDelete, onSelectCaption, clock } = renderPanel({ segments: TWO_LANE_SEGS })
650
+
651
+ // rows[2] is r2-a: Row 2's first caption (Row 1's two captions list first).
652
+ const row2A = screen.getAllByRole('listitem')[2]
653
+
654
+ fireEvent.click(within(row2A).getByText('upper caption alpha'))
655
+ expect(onSelectCaption).toHaveBeenCalledWith('r2-a')
656
+ expect(clock.set).toHaveBeenCalledWith(0 + 0.5 / 30)
657
+
658
+ const span = within(row2A).getByText('upper caption alpha')
659
+ span.textContent = 'upper caption ALPHA'
660
+ fireEvent.blur(span)
661
+ expect(onCaptionSegmentChange).toHaveBeenCalledWith('r2-a', { text: 'upper caption ALPHA' })
662
+
663
+ fireEvent.click(within(row2A).getByLabelText('Delete caption'))
664
+ expect(onCaptionSegmentDelete).toHaveBeenCalledTimes(1)
665
+ expect(onCaptionSegmentDelete).toHaveBeenCalledWith('r2-a')
666
+ })
667
+
668
+ it('double-clicking a row-2 caption on the canvas clears an active "Row 1" chip filter, then scrolls to and focuses it', () => {
669
+ const {
670
+ rerender, project, clock, onSelectCaption, onCaptionSegmentChange, onCaptionEdit,
671
+ onProjectChange, onCaptionSegmentDelete, onRegenerateCaptions,
672
+ } = renderPanel({ segments: TWO_LANE_SEGS })
673
+
674
+ // Activate "Row 1" — Row 2's captions, including the canvas double-click
675
+ // target below, drop out of the DOM entirely.
676
+ fireEvent.click(screen.getByRole('button', { name: 'Row 1' }))
677
+ expect(screen.queryByText('upper caption alpha')).toBeNull()
678
+
679
+ rerender(
680
+ <CaptionListPanel
681
+ captionTrack={project.captions}
682
+ project={project}
683
+ currentTime={0}
684
+ selectedIds={[]}
685
+ onSelectCaption={onSelectCaption}
686
+ onCaptionSegmentChange={onCaptionSegmentChange}
687
+ onCaptionEdit={onCaptionEdit}
688
+ onProjectChange={onProjectChange}
689
+ onCaptionSegmentDelete={onCaptionSegmentDelete}
690
+ onRegenerateCaptions={onRegenerateCaptions}
691
+ fps={30}
692
+ clock={clock}
693
+ editFocusId={{ id: 'r2-a', nonce: 1 }}
694
+ />,
695
+ )
696
+
697
+ // The chip filter fell back to "All" (the same guard the search query
698
+ // already carries, extended to the row chip)...
699
+ expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true')
700
+ expect(screen.getByRole('button', { name: 'Row 1' })).toHaveAttribute('aria-pressed', 'false')
701
+ // ...and the target row is now visible, scrolled into view, and focused.
702
+ const editable = screen.getByText('upper caption alpha')
703
+ expect(document.activeElement).toBe(editable)
704
+ })
705
+
706
+ // The chip-filter test above is a 2-pass convergence (one guard clears, the
707
+ // effect re-runs, the target is found). The pre-existing SEARCH guard this
708
+ // one composes with had no direct test of its own anywhere in this file —
709
+ // both editFocusId tests predating Phase 5 leave `search` empty throughout.
710
+ // Pin it now that a second guard's correctness depends on it actually
711
+ // clearing and re-running rather than swallowing the request.
712
+ it('editFocusId cleared by SEARCH ALONE (single lane): the effect clears the query, then converges on the target', () => {
713
+ const {
714
+ rerender, project, clock, onSelectCaption, onCaptionSegmentChange, onCaptionEdit,
715
+ onProjectChange, onCaptionSegmentDelete, onRegenerateCaptions,
716
+ } = renderPanel({ segments: THREE_SEGS })
717
+
718
+ // "hello" filters the list down to cap-0 only — cap-1, the canvas
719
+ // double-click target below, drops out of the DOM.
720
+ fireEvent.change(screen.getByLabelText('Search captions'), { target: { value: 'hello' } })
721
+ expect(screen.queryByText('goodbye now')).toBeNull()
722
+
723
+ rerender(
724
+ <CaptionListPanel
725
+ captionTrack={project.captions}
726
+ project={project}
727
+ currentTime={0}
728
+ selectedIds={[]}
729
+ onSelectCaption={onSelectCaption}
730
+ onCaptionSegmentChange={onCaptionSegmentChange}
731
+ onCaptionEdit={onCaptionEdit}
732
+ onProjectChange={onProjectChange}
733
+ onCaptionSegmentDelete={onCaptionSegmentDelete}
734
+ onRegenerateCaptions={onRegenerateCaptions}
735
+ fps={30}
736
+ clock={clock}
737
+ editFocusId={{ id: 'cap-1', nonce: 1 }}
738
+ />,
739
+ )
740
+
741
+ // The query cleared (the pre-existing guard, still doing its job)...
742
+ expect(screen.getByLabelText('Search captions')).toHaveValue('')
743
+ // ...and the target row is now visible, scrolled into view, and focused.
744
+ const editable = screen.getByText('goodbye now')
745
+ expect(document.activeElement).toBe(editable)
746
+ })
747
+
748
+ // The true 3-pass sequence: BOTH guards block the target on the first
749
+ // effect run, so it must clear one, re-run (still blocked), clear the
750
+ // other, re-run again, and only THEN reach the scroll+focus call. This is
751
+ // the case the multi-pass "one setState per invocation" design exists for
752
+ // — a version of the effect that resolved only one guard per nonce (or
753
+ // wrote `lastHandledNonceRef` too early) would stall here even though the
754
+ // chip-only and search-only cases above both still pass.
755
+ it('editFocusId blocked by BOTH search and the row chip: the effect clears each in turn and converges', () => {
756
+ const {
757
+ rerender, project, clock, onSelectCaption, onCaptionSegmentChange, onCaptionEdit,
758
+ onProjectChange, onCaptionSegmentDelete, onRegenerateCaptions,
759
+ } = renderPanel({ segments: TWO_LANE_SEGS })
760
+
761
+ // "Row 1" hides every Row 2 caption, including the target (r2-a). "beta"
762
+ // additionally excludes r2-a's own text ("upper caption alpha") from a
763
+ // search match, so if the chip were somehow not blocking it, the search
764
+ // guard would catch it anyway — both guards are independently active
765
+ // against this exact target, not just one of them incidentally.
766
+ fireEvent.click(screen.getByRole('button', { name: 'Row 1' }))
767
+ fireEvent.change(screen.getByLabelText('Search captions'), { target: { value: 'beta' } })
768
+ expect(screen.queryByText('upper caption alpha')).toBeNull()
769
+ // Only r1-b ("lower caption beta") survives both filters.
770
+ expect(screen.getAllByRole('listitem')).toHaveLength(1)
771
+
772
+ rerender(
773
+ <CaptionListPanel
774
+ captionTrack={project.captions}
775
+ project={project}
776
+ currentTime={0}
777
+ selectedIds={[]}
778
+ onSelectCaption={onSelectCaption}
779
+ onCaptionSegmentChange={onCaptionSegmentChange}
780
+ onCaptionEdit={onCaptionEdit}
781
+ onProjectChange={onProjectChange}
782
+ onCaptionSegmentDelete={onCaptionSegmentDelete}
783
+ onRegenerateCaptions={onRegenerateCaptions}
784
+ fps={30}
785
+ clock={clock}
786
+ editFocusId={{ id: 'r2-a', nonce: 1 }}
787
+ />,
788
+ )
789
+
790
+ // Both guards fired and converged: query cleared, chip back to "All",
791
+ // target visible, scrolled into view, and focused.
792
+ expect(screen.getByLabelText('Search captions')).toHaveValue('')
793
+ expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true')
794
+ expect(screen.getByRole('button', { name: 'Row 1' })).toHaveAttribute('aria-pressed', 'false')
795
+ const editable = screen.getByText('upper caption alpha')
796
+ expect(document.activeElement).toBe(editable)
797
+ })
798
+ })
799
+
800
+ // The panel splits into three sub-tabs once captions exist: "Format" (the
801
+ // default — the fine look-and-feel controls: size, colors, font, case,
802
+ // alignment, spacing), "Styles" (a gallery of live style previews, see
803
+ // CaptionStyleGallery.tsx) and "Captions" (the transcript, search, and
804
+ // actions). These tests clear the 'captions' seed the suites above set, so
805
+ // they see the REAL default. See CaptionListPanel.tsx's CAPTION_TABS and
806
+ // reviveCaptionTab.
807
+ describe('CaptionListPanel tabs', () => {
808
+ it('defaults to the Format tab; the transcript, search, and footer live under the Captions tab', () => {
809
+ window.localStorage.clear() // no seed → the real default ('format')
810
+ renderPanel()
811
+
812
+ // Format tab is active: the fine controls are visible, the style gallery
813
+ // and the transcript are not.
814
+ expect(screen.getByRole('spinbutton', { name: 'Caption font size' })).toBeTruthy()
815
+ expect(screen.queryByRole('group', { name: 'Caption style' })).toBeNull()
816
+ expect(screen.queryByRole('button', { name: 'Subtitle' })).toBeNull()
817
+ expect(screen.queryByRole('listitem')).toBeNull()
818
+ expect(screen.queryByLabelText('Search captions')).toBeNull()
819
+ expect(screen.queryByText('Regenerate captions')).toBeNull()
820
+ expect(screen.getByRole('button', { name: 'Format' })).toHaveAttribute('aria-pressed', 'true')
821
+
822
+ // Switching to Captions reveals the list, search, and the footer actions.
823
+ fireEvent.click(screen.getByRole('button', { name: 'Captions' }))
824
+ expect(screen.getAllByRole('listitem')).toHaveLength(3)
825
+ expect(screen.getByLabelText('Search captions')).toBeTruthy()
826
+ expect(screen.getByText('Regenerate captions')).toBeTruthy()
827
+ // The format controls are gone now.
828
+ expect(screen.queryByRole('spinbutton', { name: 'Caption font size' })).toBeNull()
829
+ })
830
+
831
+ it('shows all three tabs, in order Format, Styles, Captions', () => {
832
+ window.localStorage.clear()
833
+ renderPanel()
834
+ const tabGroup = screen.getByRole('group', { name: 'Caption panel view' })
835
+ const tabs = within(tabGroup).getAllByRole('button')
836
+ expect(tabs.map(t => t.textContent)).toEqual(['Format', 'Styles', 'Captions'])
837
+ })
838
+
839
+ // 'format' is BOTH the migration target for a stale pre-split 'style'
840
+ // value AND usePersistentState's fallback `initial` for an unrecognised
841
+ // one (see usePersistentState.ts: revive() -> null falls back to
842
+ // `initial`). So a DOM render can't tell "migrated" from "rejected and
843
+ // defaulted" — both land on the exact same Format tab. This test only
844
+ // proves a stale value never crashes and renders Format either way; it is
845
+ // NOT proof the migration line exists. The real migration coverage is the
846
+ // direct reviveCaptionTab() unit test in the 'reviveCaptionTab' describe
847
+ // below.
848
+ it('a stale persisted "style" value (pre-split) renders the Format tab without crashing', () => {
849
+ window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('style'))
850
+ renderPanel()
851
+
852
+ expect(screen.getByRole('button', { name: 'Format' })).toHaveAttribute('aria-pressed', 'true')
853
+ expect(screen.getByRole('spinbutton', { name: 'Caption font size' })).toBeTruthy()
854
+ expect(screen.queryByRole('listitem')).toBeNull()
855
+ })
856
+
857
+ it('the chosen tab persists across remounts (localStorage-backed)', () => {
858
+ window.localStorage.clear()
859
+ renderPanel()
860
+ fireEvent.click(screen.getByRole('button', { name: 'Captions' }))
861
+ cleanup()
862
+
863
+ renderPanel()
864
+ expect(screen.getByRole('button', { name: 'Captions' })).toHaveAttribute('aria-pressed', 'true')
865
+ expect(screen.getAllByRole('listitem')).toHaveLength(3)
866
+ })
867
+
868
+ it('shows a loading placeholder (not the Generate button) in the empty state while captions are generating', () => {
869
+ const project = { id: 'p1' } as unknown as Project
870
+ render(
871
+ <CaptionListPanel
872
+ captionTrack={undefined}
873
+ project={project}
874
+ currentTime={0}
875
+ selectedIds={[]}
876
+ onSelectCaption={vi.fn()}
877
+ onRegenerateCaptions={vi.fn()}
878
+ captionsGenerating
879
+ fps={30}
880
+ clock={makeClock()}
881
+ />,
882
+ )
883
+ expect(screen.getByText(/Generating captions/i)).toBeTruthy()
884
+ expect(screen.getByText(/Transcribing the timeline's audio/i)).toBeTruthy()
885
+ // While a job is running the idle prompt and its Generate trigger are gone.
886
+ expect(screen.queryByRole('button', { name: 'Generate captions' })).toBeNull()
887
+ expect(screen.queryByText(/generated from the timeline/i)).toBeNull()
888
+ })
889
+
890
+ it('a canvas edit-focus request flips from the Format tab to the Captions tab and focuses the target row', () => {
891
+ window.localStorage.clear() // real default = Format, where the row is NOT rendered
892
+ const project = makeProject('karaoke', THREE_SEGS)
893
+ const clock = makeClock()
894
+ const commonProps = {
895
+ captionTrack: project.captions,
896
+ project,
897
+ currentTime: 0,
898
+ selectedIds: [] as string[],
899
+ onSelectCaption: vi.fn(),
900
+ onCaptionSegmentChange: vi.fn(),
901
+ fps: 30,
902
+ clock,
903
+ }
904
+
905
+ const { rerender } = render(<CaptionListPanel {...commonProps} editFocusId={null} />)
906
+ // On the Format tab the transcript rows are not mounted at all.
907
+ expect(screen.queryByText('goodbye now')).toBeNull()
908
+
909
+ rerender(<CaptionListPanel {...commonProps} editFocusId={{ id: 'cap-1', nonce: 1 }} />)
910
+ // The effect flipped to the Captions tab so the row exists, then focused it.
911
+ expect(screen.getByRole('button', { name: 'Captions' })).toHaveAttribute('aria-pressed', 'true')
912
+ const editable = screen.getByText('goodbye now')
913
+ expect(document.activeElement).toBe(editable)
914
+ })
915
+ })
916
+
917
+ // Direct unit tests for reviveCaptionTab, exercised in isolation rather than
918
+ // through a DOM render. This matters specifically for the 'style' -> 'format'
919
+ // migration: 'format' is ALSO usePersistentState's `initial` fallback for a
920
+ // value revive() rejects (see usePersistentState.ts), so a rendered panel
921
+ // looks identical whether 'style' was migrated or simply rejected and
922
+ // defaulted. Only calling reviveCaptionTab() directly and checking its
923
+ // return value can tell those two cases apart — that's what proves the
924
+ // migration line in CaptionListPanel.tsx is actually there.
925
+ describe('reviveCaptionTab', () => {
926
+ it('migrates the pre-split "style" value to "format"', () => {
927
+ expect(reviveCaptionTab('style')).toBe('format')
928
+ })
929
+
930
+ it('passes "styles" through unchanged (not double-migrated)', () => {
931
+ expect(reviveCaptionTab('styles')).toBe('styles')
932
+ })
933
+
934
+ it('passes the other current tab values through unchanged', () => {
935
+ expect(reviveCaptionTab('format')).toBe('format')
936
+ expect(reviveCaptionTab('captions')).toBe('captions')
937
+ })
938
+
939
+ it('rejects unrecognised values to null (caller falls back to the default)', () => {
940
+ expect(reviveCaptionTab('bogus')).toBeNull()
941
+ expect(reviveCaptionTab(null)).toBeNull()
942
+ expect(reviveCaptionTab(42)).toBeNull()
943
+ })
944
+ })