@bycrux/editor 0.12.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- 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
|
+
})
|