@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.
- 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 +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- 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 +601 -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 +137 -0
- 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 +2229 -243
- 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/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -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,213 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* CaptionSpecimen tests. jsdom never lays anything out, so ResizeObserver
|
|
4
|
+
* never fires on its own — a no-op stub (mirrors VideoEditor.test.tsx) is
|
|
5
|
+
* installed before every render, which also exercises the "measurement
|
|
6
|
+
* never happens" floor-clamp path: the specimen word must still render at a
|
|
7
|
+
* legible size when `measuredWidth` stays 0 forever.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
|
10
|
+
import { render, cleanup } from '@testing-library/react'
|
|
11
|
+
import type { Captions } from '../../schema'
|
|
12
|
+
import CaptionSpecimen from '../CaptionSpecimen'
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
16
|
+
observe() {}
|
|
17
|
+
unobserve() {}
|
|
18
|
+
disconnect() {}
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => cleanup())
|
|
23
|
+
|
|
24
|
+
const BALOO_2 = '"Baloo 2", system-ui, sans-serif'
|
|
25
|
+
|
|
26
|
+
function captions(over: Partial<Captions> = {}): Captions {
|
|
27
|
+
return {
|
|
28
|
+
style: 'word-by-word',
|
|
29
|
+
fontsize: 46,
|
|
30
|
+
segments: [
|
|
31
|
+
{
|
|
32
|
+
id: 'seg-1',
|
|
33
|
+
text: 'hello world',
|
|
34
|
+
start: 0,
|
|
35
|
+
end: 2,
|
|
36
|
+
words: [
|
|
37
|
+
{ word: 'hello', start: 0, end: 1 },
|
|
38
|
+
{ word: 'world', start: 1, end: 2 },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
...over,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('CaptionSpecimen — active word', () => {
|
|
47
|
+
it('renders the word under the playhead', () => {
|
|
48
|
+
const { getByTestId } = render(
|
|
49
|
+
<CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
|
|
50
|
+
)
|
|
51
|
+
expect(getByTestId('caption-specimen-word').textContent).toBe('hello')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('carries fontFamily, textTransform and letterSpacing on the word element', () => {
|
|
55
|
+
const { getByTestId } = render(
|
|
56
|
+
<CaptionSpecimen
|
|
57
|
+
captions={captions()}
|
|
58
|
+
currentTime={0.5}
|
|
59
|
+
fontSize={46}
|
|
60
|
+
fontFamily={BALOO_2}
|
|
61
|
+
textTransform="uppercase"
|
|
62
|
+
letterSpacing="0.02em"
|
|
63
|
+
/>,
|
|
64
|
+
)
|
|
65
|
+
const word = getByTestId('caption-specimen-word')
|
|
66
|
+
expect(word.style.fontFamily).toBe(BALOO_2)
|
|
67
|
+
expect(word.style.textTransform).toBe('uppercase')
|
|
68
|
+
expect(word.style.letterSpacing).toBe('0.02em')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('carries fontWeight on the word element', () => {
|
|
72
|
+
const { getByTestId } = render(
|
|
73
|
+
<CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} fontWeight={400} />,
|
|
74
|
+
)
|
|
75
|
+
expect(getByTestId('caption-specimen-word').style.fontWeight).toBe('400')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// An absent fontWeight/fontFamily must resolve to the ACTIVE STYLE's own
|
|
79
|
+
// default, not render blank / the editor UI's own font — that's the whole
|
|
80
|
+
// point of captionStyleDefaults.ts. Two styles with different weights
|
|
81
|
+
// (subtitle 600 vs outline 900) prove the resolution is keyed off the
|
|
82
|
+
// right style, not a single hardcoded fallback.
|
|
83
|
+
it('resolves an absent fontWeight to the active style\'s own default', () => {
|
|
84
|
+
const { getByTestId, rerender } = render(
|
|
85
|
+
<CaptionSpecimen captions={captions({ style: 'subtitle' })} currentTime={0.5} fontSize={46} />,
|
|
86
|
+
)
|
|
87
|
+
expect(getByTestId('caption-specimen-word').style.fontWeight).toBe('600')
|
|
88
|
+
|
|
89
|
+
rerender(<CaptionSpecimen captions={captions({ style: 'outline' })} currentTime={0.5} fontSize={46} />)
|
|
90
|
+
expect(getByTestId('caption-specimen-word').style.fontWeight).toBe('900')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('resolves an absent fontFamily to the active style\'s own default', () => {
|
|
94
|
+
const { getByTestId, rerender } = render(
|
|
95
|
+
<CaptionSpecimen captions={captions({ style: 'clean' })} currentTime={0.5} fontSize={46} />,
|
|
96
|
+
)
|
|
97
|
+
expect(getByTestId('caption-specimen-word').style.fontFamily).toBe('"Figtree", system-ui, sans-serif')
|
|
98
|
+
|
|
99
|
+
rerender(<CaptionSpecimen captions={captions({ style: 'outline' })} currentTime={0.5} fontSize={46} />)
|
|
100
|
+
expect(getByTestId('caption-specimen-word').style.fontFamily).toBe('system-ui, -apple-system, sans-serif')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// An EXPLICIT fontWeight always wins over the style default, e.g. the Bold
|
|
104
|
+
// toggle's "off" state (a stored 400) on a style that would otherwise
|
|
105
|
+
// default to 900.
|
|
106
|
+
it('an explicit fontWeight overrides the style default', () => {
|
|
107
|
+
const { getByTestId } = render(
|
|
108
|
+
<CaptionSpecimen captions={captions({ style: 'outline' })} currentTime={0.5} fontSize={46} fontWeight={400} />,
|
|
109
|
+
)
|
|
110
|
+
expect(getByTestId('caption-specimen-word').style.fontWeight).toBe('400')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('does not set textTransform/letterSpacing when they are not passed', () => {
|
|
114
|
+
const { getByTestId } = render(
|
|
115
|
+
<CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
|
|
116
|
+
)
|
|
117
|
+
const word = getByTestId('caption-specimen-word')
|
|
118
|
+
expect(word.style.textTransform).toBe('')
|
|
119
|
+
expect(word.style.letterSpacing).toBe('')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('renders the word at a legible size even though jsdom never measures a width', () => {
|
|
123
|
+
const { getByTestId } = render(
|
|
124
|
+
<CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
|
|
125
|
+
)
|
|
126
|
+
const px = parseFloat(getByTestId('caption-specimen-word').style.fontSize)
|
|
127
|
+
expect(px).toBeGreaterThanOrEqual(11)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('CaptionSpecimen — family label', () => {
|
|
132
|
+
it('resolves a recognized family via findFontOption', () => {
|
|
133
|
+
const { getByText } = render(
|
|
134
|
+
<CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} fontFamily={BALOO_2} />,
|
|
135
|
+
)
|
|
136
|
+
expect(getByText('Baloo 2')).toBeTruthy()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('shows Default when fontFamily is unset', () => {
|
|
140
|
+
const { getByText } = render(
|
|
141
|
+
<CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
|
|
142
|
+
)
|
|
143
|
+
expect(getByText('Default')).toBeTruthy()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('shows Custom for an unrecognized family', () => {
|
|
147
|
+
const { getByText } = render(
|
|
148
|
+
<CaptionSpecimen
|
|
149
|
+
captions={captions()}
|
|
150
|
+
currentTime={0.5}
|
|
151
|
+
fontSize={46}
|
|
152
|
+
fontFamily='"Some Unlisted Font", sans-serif'
|
|
153
|
+
/>,
|
|
154
|
+
)
|
|
155
|
+
expect(getByText('Custom')).toBeTruthy()
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('CaptionSpecimen — size label', () => {
|
|
160
|
+
it('shows the real caption render size, not the scaled-down on-screen size', () => {
|
|
161
|
+
const { getByText } = render(
|
|
162
|
+
<CaptionSpecimen captions={captions()} currentTime={0.5} fontSize={46} />,
|
|
163
|
+
)
|
|
164
|
+
expect(getByText('46px')).toBeTruthy()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('CaptionSpecimen — no active word', () => {
|
|
169
|
+
it('shows the empty-state line, not a placeholder glyph, when the playhead is outside every segment and nothing is selected', () => {
|
|
170
|
+
const { getByTestId, queryByText } = render(
|
|
171
|
+
<CaptionSpecimen captions={captions()} currentTime={10} fontSize={46} />,
|
|
172
|
+
)
|
|
173
|
+
expect(getByTestId('caption-specimen-word').textContent).toBe(
|
|
174
|
+
'Move the playhead over a caption to preview it',
|
|
175
|
+
)
|
|
176
|
+
expect(queryByText('Aa')).toBeNull()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('still shows the family and size labels in the empty state', () => {
|
|
180
|
+
const { getByText } = render(
|
|
181
|
+
<CaptionSpecimen captions={captions()} currentTime={10} fontSize={46} fontFamily={BALOO_2} />,
|
|
182
|
+
)
|
|
183
|
+
expect(getByText('Baloo 2')).toBeTruthy()
|
|
184
|
+
expect(getByText('46px')).toBeTruthy()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('CaptionSpecimen — selected-segment fallback', () => {
|
|
189
|
+
it('shows the selected segment\'s first word, plus a "not live" tag, when the playhead is outside every segment', () => {
|
|
190
|
+
const { getByTestId, getByText } = render(
|
|
191
|
+
<CaptionSpecimen
|
|
192
|
+
captions={captions()}
|
|
193
|
+
currentTime={10}
|
|
194
|
+
selectedSegmentId="seg-1"
|
|
195
|
+
fontSize={46}
|
|
196
|
+
/>,
|
|
197
|
+
)
|
|
198
|
+
expect(getByTestId('caption-specimen-word').textContent).toBe('hello')
|
|
199
|
+
expect(getByText('Selected caption')).toBeTruthy()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('does not show the fallback tag when the word is live under the playhead', () => {
|
|
203
|
+
const { queryByText } = render(
|
|
204
|
+
<CaptionSpecimen
|
|
205
|
+
captions={captions()}
|
|
206
|
+
currentTime={0.5}
|
|
207
|
+
selectedSegmentId="seg-1"
|
|
208
|
+
fontSize={46}
|
|
209
|
+
/>,
|
|
210
|
+
)
|
|
211
|
+
expect(queryByText('Selected caption')).toBeNull()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// CaptionStyleGallery.test.tsx
|
|
2
|
+
//
|
|
3
|
+
// Covers the five behaviours the Style-tab gallery owes its caller:
|
|
4
|
+
// 1. one card per style, each with a real accessible name
|
|
5
|
+
// 2. clicking a card commits `captions.style` through `onCaptionEdit`
|
|
6
|
+
// 3. the active style's card reads selected
|
|
7
|
+
// 4. hovering starts the frame advance; unhovering stops it and resets
|
|
8
|
+
// 5. a host with no `compileOverlay` degrades to the static specimen
|
|
9
|
+
//
|
|
10
|
+
// Two pieces of the environment are faked, both deliberately:
|
|
11
|
+
//
|
|
12
|
+
// - `requestAnimationFrame` / `cancelAnimationFrame`. The card's loop is
|
|
13
|
+
// driven off the rAF TIMESTAMP, so handing it chosen timestamps makes the
|
|
14
|
+
// frame it computes exact and assertable, instead of racing real frames.
|
|
15
|
+
// `cancelAnimationFrame` really removes the pending callback, which is what
|
|
16
|
+
// lets the unhover half of test 4 mean anything.
|
|
17
|
+
//
|
|
18
|
+
// - `compileOverlay`, which returns a factory that records every call AND
|
|
19
|
+
// renders a node carrying the frame it was given. The DOM assertion is the
|
|
20
|
+
// guard against a vacuous test: jsdom lays nothing out, so if the card had
|
|
21
|
+
// copied CaptionPreview's `scale === null` gate it would render nothing at
|
|
22
|
+
// all and a call-count-only assertion would pass while the gallery was
|
|
23
|
+
// blank. See FALLBACK_CARD_W in CaptionStyleGallery.tsx.
|
|
24
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
25
|
+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
26
|
+
import type { Captions } from '../../schema'
|
|
27
|
+
import type { OverlayFactory, Project } from '../../types'
|
|
28
|
+
import CaptionStyleGallery, {
|
|
29
|
+
CAPTION_STYLES,
|
|
30
|
+
CAPTION_STYLE_LABELS,
|
|
31
|
+
SAMPLE_SEGMENT,
|
|
32
|
+
} from '../CaptionStyleGallery'
|
|
33
|
+
|
|
34
|
+
// ── rAF harness ─────────────────────────────────────────────────────────────
|
|
35
|
+
let pendingFrames: Map<number, FrameRequestCallback>
|
|
36
|
+
let nextFrameId: number
|
|
37
|
+
|
|
38
|
+
/** Run every callback currently scheduled, at `now` ms. Callbacks that
|
|
39
|
+
* re-schedule (the card's loop does) repopulate the queue for the next call. */
|
|
40
|
+
function flushFrame(now: number) {
|
|
41
|
+
const due = Array.from(pendingFrames.values())
|
|
42
|
+
pendingFrames.clear()
|
|
43
|
+
act(() => { due.forEach(cb => cb(now)) })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
pendingFrames = new Map()
|
|
48
|
+
nextFrameId = 1
|
|
49
|
+
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
|
|
50
|
+
const id = nextFrameId++
|
|
51
|
+
pendingFrames.set(id, cb)
|
|
52
|
+
return id
|
|
53
|
+
})
|
|
54
|
+
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id: number) => {
|
|
55
|
+
pendingFrames.delete(id)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
cleanup()
|
|
61
|
+
vi.restoreAllMocks()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
65
|
+
interface RecordedCall {
|
|
66
|
+
src: string
|
|
67
|
+
frame: number
|
|
68
|
+
fps: number
|
|
69
|
+
totalFrames: number
|
|
70
|
+
props: Record<string, unknown>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeProject(captions: Captions): Project {
|
|
74
|
+
return { id: 'p1', captions } as unknown as Project
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function renderGallery(opts: {
|
|
78
|
+
style?: Captions['style']
|
|
79
|
+
extra?: Partial<Captions>
|
|
80
|
+
withCompiler?: boolean
|
|
81
|
+
} = {}) {
|
|
82
|
+
const { style = 'karaoke', extra = {}, withCompiler = true } = opts
|
|
83
|
+
|
|
84
|
+
const calls: RecordedCall[] = []
|
|
85
|
+
// One factory per template src, so a card's calls are attributable to it.
|
|
86
|
+
// `resolveCaptionTemplate` is identity below, so src === style name.
|
|
87
|
+
const compileOverlay = vi.fn(async (src: string): Promise<OverlayFactory> => {
|
|
88
|
+
return (frame, fps, totalFrames, props) => {
|
|
89
|
+
calls.push({ src, frame, fps, totalFrames, props })
|
|
90
|
+
return <div data-testid={`live-${src}`} data-frame={String(frame)} />
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
const resolveCaptionTemplate = vi.fn((s: string) => s)
|
|
94
|
+
|
|
95
|
+
const captions: Captions = { style, segments: [], ...extra }
|
|
96
|
+
const project = makeProject(captions)
|
|
97
|
+
const onCaptionEdit = vi.fn()
|
|
98
|
+
|
|
99
|
+
const view = render(
|
|
100
|
+
<CaptionStyleGallery
|
|
101
|
+
captions={captions}
|
|
102
|
+
project={project}
|
|
103
|
+
onCaptionEdit={onCaptionEdit}
|
|
104
|
+
compileOverlay={withCompiler ? compileOverlay : undefined}
|
|
105
|
+
resolveCaptionTemplate={withCompiler ? resolveCaptionTemplate : undefined}
|
|
106
|
+
/>,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Let every card's compile resolve before any assertion, so no test races
|
|
110
|
+
// the async setFactory (and none of them leak an un-acted state update).
|
|
111
|
+
if (withCompiler) {
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
expect(document.querySelectorAll('[data-testid^="live-"]')).toHaveLength(CAPTION_STYLES.length)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { view, calls, compileOverlay, resolveCaptionTemplate, captions, project, onCaptionEdit }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cardFor = (style: Captions['style']) =>
|
|
121
|
+
screen.getByRole('button', { name: CAPTION_STYLE_LABELS[style] })
|
|
122
|
+
|
|
123
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
124
|
+
describe('CaptionStyleGallery', () => {
|
|
125
|
+
it('renders one labelled card per style', async () => {
|
|
126
|
+
await renderGallery()
|
|
127
|
+
|
|
128
|
+
expect(screen.getAllByRole('button')).toHaveLength(CAPTION_STYLES.length)
|
|
129
|
+
for (const style of CAPTION_STYLES) {
|
|
130
|
+
expect(cardFor(style)).toHaveAttribute('data-style', style)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('compiles every style template eagerly on mount, before any hover', async () => {
|
|
135
|
+
const { compileOverlay } = await renderGallery()
|
|
136
|
+
|
|
137
|
+
expect(compileOverlay).toHaveBeenCalledTimes(CAPTION_STYLES.length)
|
|
138
|
+
const compiled = compileOverlay.mock.calls.map(c => c[0]).sort()
|
|
139
|
+
expect(compiled).toEqual([...CAPTION_STYLES].sort())
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('feeds each template the synthetic sample and the track theme', async () => {
|
|
143
|
+
const { calls } = await renderGallery({ extra: { color: '#ff0000', fontsize: 46 } })
|
|
144
|
+
|
|
145
|
+
const popCall = calls.filter(c => c.src === 'pop')[0]
|
|
146
|
+
expect(popCall.props.segments).toEqual([SAMPLE_SEGMENT])
|
|
147
|
+
// Pin the sample's own shape too, not just that it round-trips: four
|
|
148
|
+
// contiguous words, each clearing pop.jsx's `wordDuration > 6` (frames)
|
|
149
|
+
// exit-fade guard at SAMPLE_FPS with room to spare.
|
|
150
|
+
const sampleWords = SAMPLE_SEGMENT.words ?? []
|
|
151
|
+
expect(SAMPLE_SEGMENT.text).toBe('The quick brown fox')
|
|
152
|
+
expect(sampleWords.map(w => w.word)).toEqual(['The', 'quick', 'brown', 'fox'])
|
|
153
|
+
expect(sampleWords.map(w => w.start)).toEqual([0, 0.65, 1.3, 1.95])
|
|
154
|
+
expect(sampleWords.map(w => w.end)).toEqual([0.65, 1.3, 1.95, 2.6])
|
|
155
|
+
for (const w of sampleWords) {
|
|
156
|
+
expect((w.end - w.start) * 30).toBeGreaterThan(6)
|
|
157
|
+
}
|
|
158
|
+
expect(popCall.fps).toBe(30)
|
|
159
|
+
// Track theme reaches the card...
|
|
160
|
+
expect(popCall.props.color).toBe('#ff0000')
|
|
161
|
+
// ...but the project's own font size does NOT: a card renders at a fixed,
|
|
162
|
+
// legible size (see CARD_FONT_SIZE) because the frame is scaled to ~1/8.
|
|
163
|
+
expect(popCall.props.fontSize).not.toBe(46)
|
|
164
|
+
expect(popCall.props.fontSize).toBeGreaterThan(46)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('commits captions.style when a card is clicked', async () => {
|
|
168
|
+
const { onCaptionEdit, project } = await renderGallery({ style: 'karaoke' })
|
|
169
|
+
|
|
170
|
+
fireEvent.click(cardFor('pop'))
|
|
171
|
+
|
|
172
|
+
expect(onCaptionEdit).toHaveBeenCalledTimes(1)
|
|
173
|
+
expect(onCaptionEdit.mock.calls[0][0]).toEqual({
|
|
174
|
+
...project,
|
|
175
|
+
captions: { ...project.captions, style: 'pop' },
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('marks only the active style card as selected', async () => {
|
|
180
|
+
await renderGallery({ style: 'outline' })
|
|
181
|
+
|
|
182
|
+
expect(cardFor('outline')).toHaveAttribute('aria-pressed', 'true')
|
|
183
|
+
for (const style of CAPTION_STYLES.filter(s => s !== 'outline')) {
|
|
184
|
+
expect(cardFor(style)).toHaveAttribute('aria-pressed', 'false')
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('advances the frame while hovered and stops on unhover', async () => {
|
|
189
|
+
const { calls } = await renderGallery()
|
|
190
|
+
|
|
191
|
+
// The still every card rests at before any pointer interaction.
|
|
192
|
+
const posterFrame = calls.filter(c => c.src === 'pop')[0].frame
|
|
193
|
+
expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', String(posterFrame))
|
|
194
|
+
expect(pendingFrames.size).toBe(0)
|
|
195
|
+
|
|
196
|
+
const card = cardFor('pop')
|
|
197
|
+
fireEvent.mouseEnter(card)
|
|
198
|
+
calls.length = 0
|
|
199
|
+
|
|
200
|
+
// Timestamps are chosen, not real: elapsed 0s / 0.4s / 0.9s at 30fps.
|
|
201
|
+
flushFrame(1000)
|
|
202
|
+
flushFrame(1400)
|
|
203
|
+
flushFrame(1900)
|
|
204
|
+
|
|
205
|
+
expect(calls.filter(c => c.src === 'pop').map(c => c.frame)).toEqual([0, 12, 27])
|
|
206
|
+
// Not vacuous: the advancing frame reached the DOM, so the card really is
|
|
207
|
+
// rendering the template rather than gating on an unmeasured scale.
|
|
208
|
+
expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', '27')
|
|
209
|
+
// The loop is per-card, not per-gallery: an unhovered card stays put.
|
|
210
|
+
expect(calls.filter(c => c.src === 'karaoke')).toHaveLength(0)
|
|
211
|
+
|
|
212
|
+
fireEvent.mouseLeave(card)
|
|
213
|
+
|
|
214
|
+
// The loop is torn down — nothing is left scheduled — and the card snaps
|
|
215
|
+
// back to its poster frame rather than freezing mid-animation.
|
|
216
|
+
expect(pendingFrames.size).toBe(0)
|
|
217
|
+
expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', String(posterFrame))
|
|
218
|
+
|
|
219
|
+
calls.length = 0
|
|
220
|
+
flushFrame(3000)
|
|
221
|
+
expect(calls).toHaveLength(0)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('tears the frame loop down on unmount, leaving nothing scheduled', async () => {
|
|
225
|
+
const { view } = await renderGallery()
|
|
226
|
+
|
|
227
|
+
fireEvent.mouseEnter(cardFor('pop'))
|
|
228
|
+
flushFrame(1000)
|
|
229
|
+
expect(pendingFrames.size).toBe(1)
|
|
230
|
+
|
|
231
|
+
view.unmount()
|
|
232
|
+
|
|
233
|
+
expect(pendingFrames.size).toBe(0)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('falls back to the static specimen when the host has no compiler', async () => {
|
|
237
|
+
await renderGallery({ withCompiler: false })
|
|
238
|
+
|
|
239
|
+
expect(screen.getAllByTestId('caption-specimen')).toHaveLength(CAPTION_STYLES.length)
|
|
240
|
+
expect(document.querySelectorAll('[data-testid^="live-"]')).toHaveLength(0)
|
|
241
|
+
|
|
242
|
+
// The specimen shows the sample's own word (POSTER_FRAME lands on the
|
|
243
|
+
// SECOND word, "quick" — see POSTER_FRAME in CaptionStyleGallery.tsx),
|
|
244
|
+
// not an empty-state prompt.
|
|
245
|
+
const outlineWord = within(cardFor('outline')).getByTestId('caption-specimen-word')
|
|
246
|
+
expect(outlineWord).toHaveTextContent('quick')
|
|
247
|
+
// ...and in THAT style's look: `outline` is the one style whose template
|
|
248
|
+
// defaults to uppercase (captionStyleDefaults.ts), and CaptionSpecimen has
|
|
249
|
+
// no fallback of its own for textTransform, so the `toHaveStyle` below
|
|
250
|
+
// only holds because the card applies the per-style default. The word
|
|
251
|
+
// itself is stored lower-case ("quick", not "QUICK") — `toHaveTextContent`
|
|
252
|
+
// reads the raw DOM text node, unaffected by CSS, so the CASE assertion
|
|
253
|
+
// above and the CSS `text-transform` assertion below are independent
|
|
254
|
+
// checks, not one relying on the other.
|
|
255
|
+
expect(outlineWord).toHaveStyle({ textTransform: 'uppercase' })
|
|
256
|
+
expect(within(cardFor('pop')).getByTestId('caption-specimen-word'))
|
|
257
|
+
.toHaveStyle({ letterSpacing: '-0.02em' })
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('commits captions.style from a fallback-mode card too', async () => {
|
|
261
|
+
// Deliberately the same assertion as the live-mode click test above, run
|
|
262
|
+
// against the compiler-less path. The click handler lives on the <button>
|
|
263
|
+
// that wraps BOTH the live frame and the specimen fallback, so this holds
|
|
264
|
+
// today — but nothing else in this file would catch a refactor that moved
|
|
265
|
+
// it inside the live branch, and every live-mode test would stay green
|
|
266
|
+
// while it happened.
|
|
267
|
+
//
|
|
268
|
+
// The fallback path IS the Hub / Los Parceros experience (neither host
|
|
269
|
+
// supplies `compileOverlay`), so that regression would ship a gallery
|
|
270
|
+
// where clicking a style does nothing on exactly the hosts nobody here
|
|
271
|
+
// exercises by hand.
|
|
272
|
+
const { onCaptionEdit, project } = await renderGallery({ style: 'karaoke', withCompiler: false })
|
|
273
|
+
|
|
274
|
+
fireEvent.click(cardFor('pop'))
|
|
275
|
+
|
|
276
|
+
expect(onCaptionEdit).toHaveBeenCalledTimes(1)
|
|
277
|
+
expect(onCaptionEdit.mock.calls[0][0]).toEqual({
|
|
278
|
+
...project,
|
|
279
|
+
captions: { ...project.captions, style: 'pop' },
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('falls back to the static specimen for a card whose compile rejects, while the rest stay live', async () => {
|
|
284
|
+
// A failed compile must not leave that one card permanently on the black
|
|
285
|
+
// `aspect-ratio` band with `element === null` — it should degrade to the
|
|
286
|
+
// same static specimen a no-compiler host gets, same as compileCached's
|
|
287
|
+
// eviction-on-reject already does for the module cache.
|
|
288
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
289
|
+
|
|
290
|
+
const compileOverlay = vi.fn(async (src: string): Promise<OverlayFactory> => {
|
|
291
|
+
if (src === 'outline') throw new Error('template fetch failed')
|
|
292
|
+
return (frame) => <div data-testid={`live-${src}`} data-frame={String(frame)} />
|
|
293
|
+
})
|
|
294
|
+
const resolveCaptionTemplate = vi.fn((s: string) => s)
|
|
295
|
+
const captions: Captions = { style: 'karaoke', segments: [] }
|
|
296
|
+
const project = makeProject(captions)
|
|
297
|
+
|
|
298
|
+
render(
|
|
299
|
+
<CaptionStyleGallery
|
|
300
|
+
captions={captions}
|
|
301
|
+
project={project}
|
|
302
|
+
onCaptionEdit={vi.fn()}
|
|
303
|
+
compileOverlay={compileOverlay}
|
|
304
|
+
resolveCaptionTemplate={resolveCaptionTemplate}
|
|
305
|
+
/>,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
// Six of the seven compiles resolve; `outline`'s rejects and never
|
|
309
|
+
// produces a `live-outline` node.
|
|
310
|
+
await waitFor(() => {
|
|
311
|
+
expect(document.querySelectorAll('[data-testid^="live-"]')).toHaveLength(CAPTION_STYLES.length - 1)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
expect(screen.queryByTestId('live-outline')).not.toBeInTheDocument()
|
|
315
|
+
// Not vacuous: the failed card actually renders the specimen fallback,
|
|
316
|
+
// not just "no live node" (which an infinitely-pending compile would also
|
|
317
|
+
// produce).
|
|
318
|
+
expect(within(cardFor('outline')).getByTestId('caption-specimen')).toBeInTheDocument()
|
|
319
|
+
|
|
320
|
+
for (const style of CAPTION_STYLES.filter(s => s !== 'outline')) {
|
|
321
|
+
expect(within(cardFor(style)).getByTestId(`live-${style}`)).toBeInTheDocument()
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('advances the frame on focus and stops on blur, mirroring hover', async () => {
|
|
326
|
+
// Keyboard/touch/pen parity with the hover test above: the frame loop
|
|
327
|
+
// must also run off focus/blur, not just mouseenter/mouseleave, or a
|
|
328
|
+
// keyboard user tabbing through the grid never sees anything past the
|
|
329
|
+
// poster frame.
|
|
330
|
+
const { calls } = await renderGallery()
|
|
331
|
+
|
|
332
|
+
const posterFrame = calls.filter(c => c.src === 'pop')[0].frame
|
|
333
|
+
expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', String(posterFrame))
|
|
334
|
+
expect(pendingFrames.size).toBe(0)
|
|
335
|
+
|
|
336
|
+
const card = cardFor('pop')
|
|
337
|
+
fireEvent.focus(card)
|
|
338
|
+
calls.length = 0
|
|
339
|
+
|
|
340
|
+
// Same chosen timestamps as the hover test: elapsed 0s / 0.4s / 0.9s at 30fps.
|
|
341
|
+
flushFrame(1000)
|
|
342
|
+
flushFrame(1400)
|
|
343
|
+
flushFrame(1900)
|
|
344
|
+
|
|
345
|
+
expect(calls.filter(c => c.src === 'pop').map(c => c.frame)).toEqual([0, 12, 27])
|
|
346
|
+
// Not vacuous: without the onFocus handler `hovered` never flips, so this
|
|
347
|
+
// would still read the poster frame and the assertion above would fail.
|
|
348
|
+
expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', '27')
|
|
349
|
+
expect(calls.filter(c => c.src === 'karaoke')).toHaveLength(0)
|
|
350
|
+
|
|
351
|
+
fireEvent.blur(card)
|
|
352
|
+
|
|
353
|
+
// Torn down on blur, same as mouseleave, and reset to the poster frame
|
|
354
|
+
// rather than freezing mid-animation.
|
|
355
|
+
expect(pendingFrames.size).toBe(0)
|
|
356
|
+
expect(screen.getByTestId('live-pop')).toHaveAttribute('data-frame', String(posterFrame))
|
|
357
|
+
|
|
358
|
+
calls.length = 0
|
|
359
|
+
flushFrame(3000)
|
|
360
|
+
expect(calls).toHaveLength(0)
|
|
361
|
+
})
|
|
362
|
+
})
|