@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,397 @@
1
+ /// <reference types="vite/client" />
2
+ /**
3
+ * CaptionListPanel — the text-styling block inside the "Caption style"
4
+ * subsection (font family, case, letter spacing, line height, alignment).
5
+ *
6
+ * Sits in `__tests__/` while the panel's other tests sit beside the component
7
+ * (`../CaptionListPanel.test.tsx`); the render helpers and fixtures below
8
+ * mirror that file's shape rather than importing from it, so neither test file
9
+ * can silently change the other's setup.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
12
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
13
+ import type { Project } from '../../types'
14
+ import type { Captions, CaptionSegment } from '../../schema'
15
+ import type { PlaybackClock } from '../playback-clock'
16
+ import CaptionListPanel from '../CaptionListPanel'
17
+
18
+ // The panel's active sub-tab persists to localStorage (usePersistentState).
19
+ // Clear it, then seed 'captions' for consistency with the sibling suites;
20
+ // every test here flips to the Format tab via `expandStyle()` anyway, since
21
+ // the font and text-styling controls live there now (moved off the retired
22
+ // "Style" tab, which split into "Styles" — a preset gallery, see
23
+ // CaptionStyleGallery.test.tsx — and "Format" — these fine controls).
24
+ //
25
+ // No local ResizeObserver stub: the specimen strip observes its own width and
26
+ // jsdom has no ResizeObserver, but src/test-setup.ts now installs a no-op one
27
+ // globally. That also means these tests run the specimen's "measurement never
28
+ // happens" floor-clamp path, which is why the size assertions below read the
29
+ // reported figure rather than a computed px.
30
+ beforeEach(() => {
31
+ window.localStorage.clear()
32
+ window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
33
+ })
34
+ afterEach(() => cleanup())
35
+
36
+ Element.prototype.scrollIntoView = vi.fn()
37
+
38
+ // The exact strings FONT_OPTIONS carries for these two entries
39
+ // (src/text/FontPicker.tsx). Hard-coded rather than imported: the whole point
40
+ // of the one-patch assertion below is that the persisted `fontFamily` and
41
+ // `googleFonts` are the REAL pair a template needs, so reading them back out
42
+ // of the same table the component reads would make the test agree with itself.
43
+ const BALOO_2_VALUE = '"Baloo 2", system-ui, sans-serif'
44
+ const BALOO_2_SPEC = 'Baloo+2:wght@400;500;600;700;800'
45
+ const SYSTEM_VALUE = 'system-ui, -apple-system, "Helvetica Neue", sans-serif'
46
+
47
+ const SEGS: CaptionSegment[] = [
48
+ {
49
+ id: 'cap-0',
50
+ text: 'hello world',
51
+ start: 0,
52
+ end: 2,
53
+ words: [
54
+ { word: 'hello', start: 0, end: 1 },
55
+ { word: 'world', start: 1, end: 2 },
56
+ ],
57
+ },
58
+ { id: 'cap-1', text: 'goodbye now', start: 2, end: 4 },
59
+ ]
60
+
61
+ function makeClock(): PlaybackClock {
62
+ return { get: () => 0, set: vi.fn(), subscribe: () => () => {} }
63
+ }
64
+
65
+ function renderPanel(extra: Partial<Captions> = {}, currentTime = 0, selectedIds: string[] = []) {
66
+ const project = {
67
+ id: 'p1',
68
+ captions: { style: 'pop', fontsize: 46, segments: SEGS, ...extra },
69
+ } as unknown as Project
70
+ const onCaptionEdit = vi.fn()
71
+ const onProjectChange = vi.fn()
72
+
73
+ const view = render(
74
+ <CaptionListPanel
75
+ captionTrack={project.captions}
76
+ project={project}
77
+ currentTime={currentTime}
78
+ selectedIds={selectedIds}
79
+ onSelectCaption={vi.fn()}
80
+ onCaptionSegmentChange={vi.fn()}
81
+ onCaptionEdit={onCaptionEdit}
82
+ onProjectChange={onProjectChange}
83
+ onCaptionSegmentDelete={vi.fn()}
84
+ fps={30}
85
+ clock={makeClock()}
86
+ editFocusId={null}
87
+ />,
88
+ )
89
+ return { ...view, project, onCaptionEdit, onProjectChange }
90
+ }
91
+
92
+ function expandStyle() {
93
+ // Font/text-styling controls moved from a collapse toggle, then from a
94
+ // single "Style" tab, onto the "Format" tab; these suites seed 'captions',
95
+ // so this click flips there.
96
+ fireEvent.click(screen.getByRole('button', { name: 'Format' }))
97
+ }
98
+
99
+ function pickFont(label: string) {
100
+ fireEvent.click(screen.getByLabelText('Font family'))
101
+ fireEvent.click(screen.getByRole('option', { name: label }))
102
+ }
103
+
104
+ describe('CaptionListPanel font family', () => {
105
+ // THE assertion this whole control exists for. A family whose font file is
106
+ // never fetched renders as the fallback face in BOTH the editor preview and
107
+ // the export — silently, with no error anywhere — so `fontFamily` and
108
+ // `googleFonts` have to travel together. And in ONE patch, not two: two
109
+ // commits would stack two undo entries and could interleave with another
110
+ // edit landing between them, leaving the pair split. A test that checked
111
+ // only `fontFamily` would pass with the feature fully broken.
112
+ it('writes fontFamily AND googleFonts in a single commit', () => {
113
+ const { onCaptionEdit, onProjectChange } = renderPanel()
114
+ expandStyle()
115
+ pickFont('Baloo 2')
116
+
117
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
118
+ const captions = onCaptionEdit.mock.calls[0][0].captions
119
+ expect(captions.fontFamily).toBe(BALOO_2_VALUE)
120
+ expect(captions.googleFonts).toEqual([BALOO_2_SPEC])
121
+ // A font pick is a settled choice, not a drag — no live-preview channel.
122
+ expect(onProjectChange).not.toHaveBeenCalled()
123
+ })
124
+
125
+ // The System option is the one entry with no `spec`. `[]` is the correct
126
+ // write, not "leave googleFonts alone": a previously picked family's spec
127
+ // would otherwise linger in the project and keep being fetched for a
128
+ // typeface nothing renders in any more.
129
+ it('picking System writes an explicitly empty googleFonts, clearing the previous family spec', () => {
130
+ const { onCaptionEdit } = renderPanel({ fontFamily: BALOO_2_VALUE, googleFonts: [BALOO_2_SPEC] })
131
+ expandStyle()
132
+ pickFont('System')
133
+
134
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
135
+ const captions = onCaptionEdit.mock.calls[0][0].captions
136
+ expect(captions.fontFamily).toBe(SYSTEM_VALUE)
137
+ expect(captions.googleFonts).toEqual([])
138
+ })
139
+
140
+ it('the picker reflects the family already stored on the track', () => {
141
+ renderPanel({ fontFamily: BALOO_2_VALUE })
142
+ expandStyle()
143
+ expect(screen.getByLabelText('Font family').textContent).toContain('Baloo 2')
144
+ })
145
+ })
146
+
147
+ describe('CaptionListPanel bold toggle', () => {
148
+ it('reads on when fontWeight is absent', () => {
149
+ renderPanel()
150
+ expandStyle()
151
+ expect(screen.getByLabelText('Bold')).toHaveAttribute('aria-pressed', 'true')
152
+ })
153
+
154
+ it('reads off for the explicit off value 400', () => {
155
+ renderPanel({ fontWeight: 400 })
156
+ expandStyle()
157
+ expect(screen.getByLabelText('Bold')).toHaveAttribute('aria-pressed', 'false')
158
+ })
159
+
160
+ it('reads on for a numeric weight of 600 or more', () => {
161
+ renderPanel({ fontWeight: 700 })
162
+ expandStyle()
163
+ expect(screen.getByLabelText('Bold')).toHaveAttribute('aria-pressed', 'true')
164
+ })
165
+
166
+ // schema.ts's `fontWeight` is `number | string`, and CSS accepts the
167
+ // string keywords 'bold'/'bolder' as legal font-weight values.
168
+ // `Number('bold')` is `NaN`, which used to fail the `>= 600` check and show
169
+ // Bold OFF for a project that actually renders bold.
170
+ it.each(['bold', 'bolder', 'Bold'])('reads on for the CSS keyword %s', (kw) => {
171
+ renderPanel({ fontWeight: kw })
172
+ expandStyle()
173
+ expect(screen.getByLabelText('Bold')).toHaveAttribute('aria-pressed', 'true')
174
+ })
175
+ })
176
+
177
+ describe('CaptionListPanel text case', () => {
178
+ it.each([
179
+ ['Uppercase', 'uppercase'],
180
+ ['Lowercase', 'lowercase'],
181
+ ['Capitalize', 'capitalize'],
182
+ ])('%s writes captions.textTransform = %s', (label, value) => {
183
+ const { onCaptionEdit } = renderPanel()
184
+ expandStyle()
185
+ fireEvent.click(screen.getByLabelText(label))
186
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
187
+ expect(onCaptionEdit.mock.calls[0][0].captions.textTransform).toBe(value)
188
+ })
189
+
190
+ // 'none', not an absent key: the patch is spread over the saved captions, so
191
+ // dropping the field would leave the stored 'uppercase' in place and the
192
+ // button would appear to do nothing.
193
+ it('clicking the ACTIVE case again clears it to none', () => {
194
+ const { onCaptionEdit } = renderPanel({ textTransform: 'uppercase' })
195
+ expandStyle()
196
+ const btn = screen.getByLabelText('Uppercase')
197
+ expect(btn).toHaveAttribute('aria-pressed', 'true')
198
+
199
+ fireEvent.click(btn)
200
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
201
+ expect(onCaptionEdit.mock.calls[0][0].captions.textTransform).toBe('none')
202
+ })
203
+
204
+ it('only the stored case reads as pressed', () => {
205
+ renderPanel({ textTransform: 'lowercase' })
206
+ expandStyle()
207
+ expect(screen.getByLabelText('Lowercase')).toHaveAttribute('aria-pressed', 'true')
208
+ expect(screen.getByLabelText('Uppercase')).toHaveAttribute('aria-pressed', 'false')
209
+ expect(screen.getByLabelText('Capitalize')).toHaveAttribute('aria-pressed', 'false')
210
+ })
211
+
212
+ // `outline` renders all-caps by default (its stencil look) even with no
213
+ // stored textTransform at all — the chip has to seed from that default the
214
+ // same way the Bold toggle seeds from an absent fontWeight, or it lies
215
+ // about the caption's actual on-screen case.
216
+ it("a fresh outline caption (no stored textTransform) seeds the Uppercase chip pressed from the style's default", () => {
217
+ renderPanel({ style: 'outline' })
218
+ expandStyle()
219
+ expect(screen.getByLabelText('Uppercase')).toHaveAttribute('aria-pressed', 'true')
220
+ })
221
+
222
+ // An explicit 'none' (written when the operator turns a case back off, see
223
+ // the test above) is not "absent" — it must NOT fall back to the style
224
+ // default.
225
+ it('an explicit "none" on outline overrides the style default — no chip reads pressed', () => {
226
+ renderPanel({ style: 'outline', textTransform: 'none' })
227
+ expandStyle()
228
+ expect(screen.getByLabelText('Uppercase')).toHaveAttribute('aria-pressed', 'false')
229
+ })
230
+
231
+ it('a style with no default case (pop) reads no case pressed when textTransform is absent', () => {
232
+ renderPanel({ style: 'pop' })
233
+ expandStyle()
234
+ expect(screen.getByLabelText('Uppercase')).toHaveAttribute('aria-pressed', 'false')
235
+ expect(screen.getByLabelText('Lowercase')).toHaveAttribute('aria-pressed', 'false')
236
+ expect(screen.getByLabelText('Capitalize')).toHaveAttribute('aria-pressed', 'false')
237
+ })
238
+ })
239
+
240
+ describe('CaptionListPanel alignment', () => {
241
+ it.each([
242
+ ['Align left', 'left'],
243
+ ['Align center', 'center'],
244
+ ['Align right', 'right'],
245
+ ])('%s writes captions.textAlign = %s', (label, value) => {
246
+ const { onCaptionEdit } = renderPanel()
247
+ expandStyle()
248
+ fireEvent.click(screen.getByLabelText(label))
249
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
250
+ expect(onCaptionEdit.mock.calls[0][0].captions.textAlign).toBe(value)
251
+ })
252
+
253
+ it('reflects the stored alignment as pressed', () => {
254
+ renderPanel({ textAlign: 'right' })
255
+ expandStyle()
256
+ expect(screen.getByLabelText('Align right')).toHaveAttribute('aria-pressed', 'true')
257
+ expect(screen.getByLabelText('Align left')).toHaveAttribute('aria-pressed', 'false')
258
+ })
259
+ })
260
+
261
+ describe('CaptionListPanel letter spacing and line height steppers', () => {
262
+ // The stored shapes differ on purpose and are what the caption templates
263
+ // read: letter-spacing is a CSS length (unitless is invalid and the template
264
+ // falls back), line-height is a unitless multiple of the font size. The
265
+ // operator types a bare number for both — the `em` is appended here, the
266
+ // same contract FontSizePicker has with `px`.
267
+ it('letter spacing previews live as an em string and commits once on blur', () => {
268
+ const { onProjectChange, onCaptionEdit } = renderPanel()
269
+ expandStyle()
270
+ const input = screen.getByLabelText('Caption letter spacing')
271
+
272
+ fireEvent.change(input, { target: { value: '0.05' } })
273
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
274
+ expect(onProjectChange.mock.calls[0][0].captions.letterSpacing).toBe('0.05em')
275
+ expect(onCaptionEdit).not.toHaveBeenCalled()
276
+
277
+ fireEvent.blur(input)
278
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
279
+ expect(onCaptionEdit.mock.calls[0][0].captions.letterSpacing).toBe('0.05em')
280
+ // The commit did not also fire another live preview.
281
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
282
+ })
283
+
284
+ it('letter spacing accepts negatives and shows a stored value without its unit', () => {
285
+ const { onCaptionEdit } = renderPanel({ letterSpacing: '-0.02em' })
286
+ expandStyle()
287
+ const input = screen.getByLabelText('Caption letter spacing')
288
+ expect(input).toHaveValue(-0.02)
289
+
290
+ fireEvent.change(input, { target: { value: '-0.06' } })
291
+ fireEvent.blur(input)
292
+ expect(onCaptionEdit.mock.calls[0][0].captions.letterSpacing).toBe('-0.06em')
293
+ })
294
+
295
+ it('line height previews live as a unitless NUMBER and commits once on blur', () => {
296
+ const { onProjectChange, onCaptionEdit } = renderPanel()
297
+ expandStyle()
298
+ const input = screen.getByLabelText('Caption line height')
299
+
300
+ fireEvent.change(input, { target: { value: '1.4' } })
301
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
302
+ expect(onProjectChange.mock.calls[0][0].captions.lineHeight).toBe(1.4)
303
+ expect(onCaptionEdit).not.toHaveBeenCalled()
304
+
305
+ fireEvent.blur(input)
306
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
307
+ const committed = onCaptionEdit.mock.calls[0][0].captions.lineHeight
308
+ expect(committed).toBe(1.4)
309
+ expect(typeof committed).toBe('number')
310
+ expect(onProjectChange).toHaveBeenCalledTimes(1)
311
+ })
312
+
313
+ // min/max on a number input are a spinner hint only — a typed or pasted
314
+ // value sails straight past them — so the clamp lives on the write path.
315
+ it('clamps a typed value to the control range', () => {
316
+ const { onProjectChange } = renderPanel()
317
+ expandStyle()
318
+ fireEvent.change(screen.getByLabelText('Caption line height'), { target: { value: '9' } })
319
+ expect(onProjectChange.mock.calls[0][0].captions.lineHeight).toBe(2.5)
320
+ })
321
+
322
+ // Focusing a field and leaving it untouched must not write anything: that
323
+ // would be a pointless PUT and an undo entry for a no-op.
324
+ it('a blur with no edit commits nothing', () => {
325
+ const { onProjectChange, onCaptionEdit } = renderPanel({ lineHeight: 1.2 })
326
+ expandStyle()
327
+ const input = screen.getByLabelText('Caption line height')
328
+ fireEvent.focus(input)
329
+ fireEvent.blur(input)
330
+ expect(onCaptionEdit).not.toHaveBeenCalled()
331
+ expect(onProjectChange).not.toHaveBeenCalled()
332
+ })
333
+
334
+ // Both fields read BLANK on a fresh caption track even though the template
335
+ // renders a real, non-default value — the placeholder is what makes the
336
+ // field honest about what's actually on screen, without writing that
337
+ // default into the project as an explicit value.
338
+ it("the letter-spacing field reports the active style's own default as a placeholder, not a stored value", () => {
339
+ renderPanel() // default renderPanel style is 'pop': letterSpacing defaults to -0.02em
340
+ expandStyle()
341
+ const input = screen.getByLabelText('Caption letter spacing') as HTMLInputElement
342
+ expect(input.value).toBe('')
343
+ expect(input).toHaveAttribute('placeholder', '-0.02')
344
+ })
345
+
346
+ it("the line-height field reports the active style's own default as a placeholder, not a stored value", () => {
347
+ renderPanel({ style: 'clean' }) // clean's lineHeight default is 1.3
348
+ expandStyle()
349
+ const input = screen.getByLabelText('Caption line height') as HTMLInputElement
350
+ expect(input.value).toBe('')
351
+ expect(input).toHaveAttribute('placeholder', '1.3')
352
+ })
353
+
354
+ it('an explicit stored value is shown as the value, not overridden by the placeholder', () => {
355
+ renderPanel({ letterSpacing: '0.08em' })
356
+ expandStyle()
357
+ const input = screen.getByLabelText('Caption letter spacing') as HTMLInputElement
358
+ expect(input.value).toBe('0.08')
359
+ // The placeholder is still there for when the value is cleared — it just
360
+ // isn't what's shown while a real value is present.
361
+ expect(input).toHaveAttribute('placeholder', '-0.02')
362
+ })
363
+
364
+ it('a style with no line-height default (karaoke) shows an empty placeholder', () => {
365
+ renderPanel({ style: 'karaoke' })
366
+ expandStyle()
367
+ expect(screen.getByLabelText('Caption line height')).toHaveAttribute('placeholder', '')
368
+ })
369
+
370
+ // The stepper's `onStep` closure nudges off whatever is ACTUALLY in force —
371
+ // see the comment on that closure in CaptionListPanel.tsx. With no stored
372
+ // letterSpacing, "in force" is the active style's own default, read via the
373
+ // `parseEmValue(stored) || parseEmValue(styleDefault) || 0` fallback chain.
374
+ // This is the one test that would catch that chain regressing to nudge off
375
+ // a bare 0 instead: default renderPanel() style is 'pop', whose default is
376
+ // '-0.02em', so one click has to land on -0.01em, not on 0.01em.
377
+ it('letter-spacing stepper nudges off the style default, not a bare 0, when nothing is stored', () => {
378
+ const { onCaptionEdit } = renderPanel()
379
+ expandStyle()
380
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Caption letter spacing' }))
381
+
382
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
383
+ expect(onCaptionEdit.mock.calls[0][0].captions.letterSpacing).toBe('-0.01em')
384
+ })
385
+
386
+ // A stepper click is a DISCRETE edit — one final change, one undo entry —
387
+ // never a preview/commit pair, on this headline field exactly like every
388
+ // other adopter of the shared stepper contract.
389
+ it('font-size stepper is a single discrete edit: one onCaptionEdit, no onProjectChange', () => {
390
+ const { onCaptionEdit, onProjectChange } = renderPanel()
391
+ expandStyle()
392
+ fireEvent.click(screen.getByRole('button', { name: 'Increase Caption font size' }))
393
+
394
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
395
+ expect(onProjectChange).not.toHaveBeenCalled()
396
+ })
397
+ })
@@ -0,0 +1,153 @@
1
+ /// <reference types="vite/client" />
2
+ /**
3
+ * CaptionListPanel — the Bold toggle and the manual generate/regenerate
4
+ * trigger (including its empty state).
5
+ *
6
+ * Sits in `__tests__/` alongside `CaptionListPanel.font.test.tsx` rather than
7
+ * beside the component, and does not modify or import from the sibling
8
+ * `../CaptionListPanel.test.tsx` — its own render helpers and fixtures mirror
9
+ * that file's shape independently, per the same convention.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
12
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
13
+ import type { Project } from '../../types'
14
+ import type { Captions, CaptionSegment } from '../../schema'
15
+ import type { PlaybackClock } from '../playback-clock'
16
+ import CaptionListPanel from '../CaptionListPanel'
17
+
18
+ // The panel's active sub-tab persists to localStorage (usePersistentState).
19
+ // Clear it, then seed 'captions' so the regenerate-trigger assertions render
20
+ // on the tab the footer lives on; the Bold tests flip to the Format tab via
21
+ // `expandStyle()`.
22
+ beforeEach(() => {
23
+ window.localStorage.clear()
24
+ window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
25
+ })
26
+ afterEach(() => cleanup())
27
+
28
+ Element.prototype.scrollIntoView = vi.fn()
29
+
30
+ const SEGS: CaptionSegment[] = [
31
+ { id: 'cap-0', text: 'hello world', start: 0, end: 2 },
32
+ { id: 'cap-1', text: 'goodbye now', start: 2, end: 4 },
33
+ ]
34
+
35
+ function makeClock(): PlaybackClock {
36
+ return { get: () => 0, set: vi.fn(), subscribe: () => () => {} }
37
+ }
38
+
39
+ function renderPanel(opts: {
40
+ captions?: Captions | undefined
41
+ withRegenerate?: boolean
42
+ captionsGenerating?: boolean
43
+ } = {}) {
44
+ // `'captions' in opts`, not a destructured default: a destructured default
45
+ // also kicks in for an explicitly-passed `captions: undefined`, which is
46
+ // exactly the "no captionTrack at all" case several tests below need to
47
+ // construct on purpose.
48
+ const captions = 'captions' in opts ? opts.captions : { style: 'pop', fontsize: 46, segments: SEGS }
49
+ const { withRegenerate = true, captionsGenerating } = opts
50
+
51
+ const project = { id: 'p1', captions } as unknown as Project
52
+ const onCaptionEdit = vi.fn()
53
+ const onProjectChange = vi.fn()
54
+ const onRegenerateCaptions = withRegenerate ? vi.fn() : undefined
55
+
56
+ const view = render(
57
+ <CaptionListPanel
58
+ captionTrack={project.captions}
59
+ project={project}
60
+ currentTime={0}
61
+ selectedIds={[]}
62
+ onSelectCaption={vi.fn()}
63
+ onCaptionSegmentChange={vi.fn()}
64
+ onCaptionEdit={onCaptionEdit}
65
+ onProjectChange={onProjectChange}
66
+ onCaptionSegmentDelete={vi.fn()}
67
+ onRegenerateCaptions={onRegenerateCaptions}
68
+ captionsGenerating={captionsGenerating}
69
+ fps={30}
70
+ clock={makeClock()}
71
+ editFocusId={null}
72
+ />,
73
+ )
74
+ return { ...view, project, onCaptionEdit, onProjectChange, onRegenerateCaptions }
75
+ }
76
+
77
+ function expandStyle() {
78
+ // Bold moved from a collapse toggle, then from a single "Style" tab, onto
79
+ // the "Format" tab; these suites seed 'captions', so this click flips there.
80
+ fireEvent.click(screen.getByRole('button', { name: 'Format' }))
81
+ }
82
+
83
+ describe('CaptionListPanel Bold toggle', () => {
84
+ it('reads as pressed when fontWeight is absent', () => {
85
+ renderPanel({ captions: { style: 'pop', fontsize: 46, segments: SEGS } })
86
+ expandStyle()
87
+ expect(screen.getByLabelText('Bold')).toHaveAttribute('aria-pressed', 'true')
88
+ })
89
+
90
+ it('clicking while pressed commits fontWeight: 400', () => {
91
+ const { onCaptionEdit } = renderPanel({ captions: { style: 'pop', fontsize: 46, segments: SEGS } })
92
+ expandStyle()
93
+ fireEvent.click(screen.getByLabelText('Bold'))
94
+
95
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
96
+ expect(onCaptionEdit.mock.calls[0][0].captions.fontWeight).toBe(400)
97
+ })
98
+
99
+ // THE assertion this toggle exists for: turning bold back on has to DELETE
100
+ // the key, not write a flat 700 back — a single shared weight would flatten
101
+ // all seven caption styles onto one look. A lazy `fontWeight: undefined`
102
+ // write would pass a `toBeUndefined()` check but still leave the key IN the
103
+ // object (and JSON.stringify would only drop it later, silently, on the
104
+ // trip to the server) — so this checks key ABSENCE directly.
105
+ it('clicking while off (fontWeight: 400) commits captions with no fontWeight key at all', () => {
106
+ const { onCaptionEdit } = renderPanel({ captions: { style: 'pop', fontsize: 46, segments: SEGS, fontWeight: 400 } })
107
+ expandStyle()
108
+ expect(screen.getByLabelText('Bold')).toHaveAttribute('aria-pressed', 'false')
109
+
110
+ fireEvent.click(screen.getByLabelText('Bold'))
111
+
112
+ expect(onCaptionEdit).toHaveBeenCalledTimes(1)
113
+ const captions = onCaptionEdit.mock.calls[0][0].captions
114
+ expect('fontWeight' in captions).toBe(false)
115
+ })
116
+ })
117
+
118
+ describe('CaptionListPanel generate/regenerate trigger', () => {
119
+ it('with no captions and a host that supports generation, shows the empty state and its button fires onRegenerateCaptions', () => {
120
+ const { onRegenerateCaptions } = renderPanel({ captions: undefined, withRegenerate: true })
121
+
122
+ const btn = screen.getByRole('button', { name: 'Generate captions' })
123
+ fireEvent.click(btn)
124
+ expect(onRegenerateCaptions).toHaveBeenCalledTimes(1)
125
+ })
126
+
127
+ it('with captions present, the trigger reads "Regenerate captions"', () => {
128
+ renderPanel({ captions: { style: 'pop', fontsize: 46, segments: SEGS } })
129
+ expect(screen.getByRole('button', { name: 'Regenerate captions' })).toBeTruthy()
130
+ })
131
+
132
+ it('with no captions and a host that does NOT support generation, shows explanatory text and no button', () => {
133
+ // A captions object with zero segments (not `undefined`) — the panel's
134
+ // own "nothing to show and nothing to offer" guard hides the WHOLE panel
135
+ // when both captionTrack and onRegenerateCaptions are absent, which is a
136
+ // different (already-covered) case from this one: an existing project
137
+ // that has a captions track but happens to have no rows left, on a host
138
+ // (Hub/LP) with no generation capability at all.
139
+ renderPanel({ captions: { style: 'pop', fontsize: 46, segments: [] }, withRegenerate: false })
140
+
141
+ expect(screen.getByText(/no captions yet/i)).toBeTruthy()
142
+ // With zero segments the panel shows a plain empty-state placeholder — no
143
+ // transcript list, no tab bar (there's nothing to tab between), and, with
144
+ // no generation capability, no button anywhere at all.
145
+ expect(screen.queryByRole('list', { name: 'Caption segments' })).toBeNull()
146
+ expect(screen.queryByRole('button')).toBeNull()
147
+ })
148
+
149
+ it('disables the trigger while a job is running', () => {
150
+ renderPanel({ captions: { style: 'pop', fontsize: 46, segments: SEGS }, captionsGenerating: true })
151
+ expect(screen.getByRole('button', { name: 'Regenerate captions' })).toBeDisabled()
152
+ })
153
+ })
@@ -38,6 +38,7 @@ describe('CaptionRegenModal', () => {
38
38
  <CaptionRegenModal
39
39
  adapter={makeAdapter()}
40
40
  projectId="vid-1"
41
+ existingRowCount={1}
41
42
  onDone={onDone}
42
43
  onClose={onClose}
43
44
  />,
@@ -56,10 +57,38 @@ describe('CaptionRegenModal', () => {
56
57
  <CaptionRegenModal
57
58
  adapter={errorAdapter}
58
59
  projectId="vid-1"
60
+ existingRowCount={1}
59
61
  onDone={vi.fn()}
60
62
  onClose={vi.fn()}
61
63
  />,
62
64
  )
63
65
  await waitFor(() => expect(screen.getByText('multi_source')).toBeTruthy())
64
66
  })
67
+
68
+ it('warns how many rows will be discarded when the project has more than one', async () => {
69
+ render(
70
+ <CaptionRegenModal
71
+ adapter={makeAdapter()}
72
+ projectId="vid-1"
73
+ existingRowCount={3}
74
+ onDone={vi.fn()}
75
+ onClose={vi.fn()}
76
+ />,
77
+ )
78
+ await waitFor(() => expect(screen.getByText(/replace all 3 caption rows with a single new row/i)).toBeTruthy())
79
+ })
80
+
81
+ it('shows no discard warning for a single-row (or empty) project', async () => {
82
+ render(
83
+ <CaptionRegenModal
84
+ adapter={makeAdapter()}
85
+ projectId="vid-1"
86
+ existingRowCount={1}
87
+ onDone={vi.fn()}
88
+ onClose={vi.fn()}
89
+ />,
90
+ )
91
+ await waitFor(() => expect(screen.getByText(/transcribing audio/i)).toBeTruthy())
92
+ expect(screen.queryByText(/caption rows/i)).toBeNull()
93
+ })
65
94
  })