@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,171 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { render, cleanup, fireEvent } from '@testing-library/react'
|
|
3
|
+
import {
|
|
4
|
+
useKeymap,
|
|
5
|
+
matchesKey,
|
|
6
|
+
matchesUndo,
|
|
7
|
+
matchesRedo,
|
|
8
|
+
matchesModKey,
|
|
9
|
+
matchesPlainKey,
|
|
10
|
+
matchesDelete,
|
|
11
|
+
matchesShiftDelete,
|
|
12
|
+
isTypingTarget,
|
|
13
|
+
type KeyBinding,
|
|
14
|
+
} from '../keymap'
|
|
15
|
+
|
|
16
|
+
afterEach(() => cleanup())
|
|
17
|
+
|
|
18
|
+
function Harness({ bindings, modalOpen }: { bindings: KeyBinding[]; modalOpen?: boolean }) {
|
|
19
|
+
useKeymap(bindings, { modalOpen })
|
|
20
|
+
return (
|
|
21
|
+
<div>
|
|
22
|
+
<input data-testid="input" />
|
|
23
|
+
<textarea data-testid="textarea" />
|
|
24
|
+
<div data-testid="editable" contentEditable suppressContentEditableWarning>text</div>
|
|
25
|
+
<div data-testid="plain">plain</div>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('useKeymap — the shared base guard', () => {
|
|
31
|
+
it('fires on a plain target', () => {
|
|
32
|
+
const action = vi.fn()
|
|
33
|
+
const { getByTestId } = render(<Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />)
|
|
34
|
+
fireEvent.keyDown(getByTestId('plain'), { key: 's' })
|
|
35
|
+
expect(action).toHaveBeenCalledTimes(1)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('never fires while an INPUT is the target', () => {
|
|
39
|
+
const action = vi.fn()
|
|
40
|
+
const { getByTestId } = render(<Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />)
|
|
41
|
+
fireEvent.keyDown(getByTestId('input'), { key: 's' })
|
|
42
|
+
expect(action).not.toHaveBeenCalled()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('never fires while a TEXTAREA is the target', () => {
|
|
46
|
+
const action = vi.fn()
|
|
47
|
+
const { getByTestId } = render(<Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />)
|
|
48
|
+
fireEvent.keyDown(getByTestId('textarea'), { key: 's' })
|
|
49
|
+
expect(action).not.toHaveBeenCalled()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('never fires while a contentEditable host is the target', () => {
|
|
53
|
+
const action = vi.fn()
|
|
54
|
+
const { getByTestId } = render(<Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />)
|
|
55
|
+
const editable = getByTestId('editable')
|
|
56
|
+
// jsdom doesn't implement `isContentEditable` (it's always `undefined`,
|
|
57
|
+
// even with the `contenteditable` attribute set) — force it so this
|
|
58
|
+
// exercises the same guard real browsers hit. See keymap.ts's
|
|
59
|
+
// `isTypingTarget` doc comment.
|
|
60
|
+
Object.defineProperty(editable, 'isContentEditable', { value: true, configurable: true })
|
|
61
|
+
fireEvent.keyDown(editable, { key: 's' })
|
|
62
|
+
expect(action).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('never fires while modalOpen is true, even on a plain target', () => {
|
|
66
|
+
const action = vi.fn()
|
|
67
|
+
const { getByTestId } = render(
|
|
68
|
+
<Harness modalOpen bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), action }]} />,
|
|
69
|
+
)
|
|
70
|
+
fireEvent.keyDown(getByTestId('plain'), { key: 's' })
|
|
71
|
+
expect(action).not.toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('resumes firing once modalOpen goes back to false', () => {
|
|
75
|
+
const action = vi.fn()
|
|
76
|
+
const bindings = [{ id: 'a', description: 'A', matches: matchesKey('s'), action }]
|
|
77
|
+
const { getByTestId, rerender } = render(<Harness modalOpen bindings={bindings} />)
|
|
78
|
+
fireEvent.keyDown(getByTestId('plain'), { key: 's' })
|
|
79
|
+
expect(action).not.toHaveBeenCalled()
|
|
80
|
+
rerender(<Harness modalOpen={false} bindings={bindings} />)
|
|
81
|
+
fireEvent.keyDown(getByTestId('plain'), { key: 's' })
|
|
82
|
+
expect(action).toHaveBeenCalledTimes(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('is always ignored for Space — the playback hooks own it, never the keymap', () => {
|
|
86
|
+
// A deliberately over-broad matcher (would match ANY key) to prove Space
|
|
87
|
+
// is rejected structurally, before any binding is even consulted.
|
|
88
|
+
const action = vi.fn()
|
|
89
|
+
const { getByTestId } = render(
|
|
90
|
+
<Harness bindings={[{ id: 'catch-all', description: 'catch-all', matches: () => true, action }]} />,
|
|
91
|
+
)
|
|
92
|
+
fireEvent.keyDown(getByTestId('plain'), { key: ' ', code: 'Space' })
|
|
93
|
+
expect(action).not.toHaveBeenCalled()
|
|
94
|
+
// Sanity: the same catch-all binding DOES fire for an unrelated key,
|
|
95
|
+
// proving Space specifically (not everything) is what's rejected.
|
|
96
|
+
fireEvent.keyDown(getByTestId('plain'), { key: 'x' })
|
|
97
|
+
expect(action).toHaveBeenCalledTimes(1)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('per-binding guard() gates the action independently of the base guard', () => {
|
|
101
|
+
const action = vi.fn()
|
|
102
|
+
let allowed = false
|
|
103
|
+
const { getByTestId } = render(
|
|
104
|
+
<Harness bindings={[{ id: 'a', description: 'A', matches: matchesKey('s'), guard: () => allowed, action }]} />,
|
|
105
|
+
)
|
|
106
|
+
fireEvent.keyDown(getByTestId('plain'), { key: 's' })
|
|
107
|
+
expect(action).not.toHaveBeenCalled()
|
|
108
|
+
allowed = true
|
|
109
|
+
fireEvent.keyDown(getByTestId('plain'), { key: 's' })
|
|
110
|
+
expect(action).toHaveBeenCalledTimes(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('evaluates bindings in order and stops at the first match', () => {
|
|
114
|
+
const first = vi.fn()
|
|
115
|
+
const second = vi.fn()
|
|
116
|
+
const { getByTestId } = render(
|
|
117
|
+
<Harness
|
|
118
|
+
bindings={[
|
|
119
|
+
{ id: 'first', description: 'first', matches: matchesKey('s'), action: first },
|
|
120
|
+
{ id: 'second', description: 'second', matches: matchesKey('s'), action: second },
|
|
121
|
+
]}
|
|
122
|
+
/>,
|
|
123
|
+
)
|
|
124
|
+
fireEvent.keyDown(getByTestId('plain'), { key: 's' })
|
|
125
|
+
expect(first).toHaveBeenCalledTimes(1)
|
|
126
|
+
expect(second).not.toHaveBeenCalled()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('isTypingTarget', () => {
|
|
131
|
+
it('is true for INPUT/TEXTAREA/contentEditable, false otherwise', () => {
|
|
132
|
+
const input = document.createElement('input')
|
|
133
|
+
const textarea = document.createElement('textarea')
|
|
134
|
+
// jsdom doesn't implement `isContentEditable` — force it (see the guard
|
|
135
|
+
// test above for the same workaround, with the full explanation).
|
|
136
|
+
const editable = document.createElement('div')
|
|
137
|
+
Object.defineProperty(editable, 'isContentEditable', { value: true, configurable: true })
|
|
138
|
+
const plain = document.createElement('div')
|
|
139
|
+
Object.defineProperty(plain, 'isContentEditable', { value: false, configurable: true })
|
|
140
|
+
for (const [el, expected] of [[input, true], [textarea, true], [editable, true], [plain, false]] as const) {
|
|
141
|
+
const ev = { target: el } as unknown as KeyboardEvent
|
|
142
|
+
expect(isTypingTarget(ev)).toBe(expected)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('combo matchers', () => {
|
|
148
|
+
it('matchesUndo requires mod+z without shift; matchesRedo requires mod+shift+z or mod+y', () => {
|
|
149
|
+
expect(matchesUndo({ metaKey: true, key: 'z', shiftKey: false } as KeyboardEvent)).toBe(true)
|
|
150
|
+
expect(matchesUndo({ metaKey: true, key: 'z', shiftKey: true } as KeyboardEvent)).toBe(false)
|
|
151
|
+
expect(matchesUndo({ metaKey: false, key: 'z', shiftKey: false } as KeyboardEvent)).toBe(false)
|
|
152
|
+
|
|
153
|
+
expect(matchesRedo({ metaKey: true, key: 'z', shiftKey: true } as KeyboardEvent)).toBe(true)
|
|
154
|
+
expect(matchesRedo({ metaKey: true, key: 'y', shiftKey: false } as KeyboardEvent)).toBe(true)
|
|
155
|
+
expect(matchesRedo({ metaKey: true, key: 'z', shiftKey: false } as KeyboardEvent)).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('matchesDelete excludes Shift; matchesShiftDelete requires it', () => {
|
|
159
|
+
expect(matchesDelete({ key: 'Delete', shiftKey: false } as KeyboardEvent)).toBe(true)
|
|
160
|
+
expect(matchesDelete({ key: 'Delete', shiftKey: true } as KeyboardEvent)).toBe(false)
|
|
161
|
+
expect(matchesShiftDelete({ key: 'Backspace', shiftKey: true } as KeyboardEvent)).toBe(true)
|
|
162
|
+
expect(matchesShiftDelete({ key: 'Backspace', shiftKey: false } as KeyboardEvent)).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('matchesPlainKey excludes mod/alt combos; matchesModKey requires mod', () => {
|
|
166
|
+
expect(matchesPlainKey('l')({ key: 'l', metaKey: false, ctrlKey: false, altKey: false } as KeyboardEvent)).toBe(true)
|
|
167
|
+
expect(matchesPlainKey('l')({ key: 'l', metaKey: true, ctrlKey: false, altKey: false } as KeyboardEvent)).toBe(false)
|
|
168
|
+
expect(matchesModKey('k')({ key: 'k', metaKey: true, ctrlKey: false } as KeyboardEvent)).toBe(true)
|
|
169
|
+
expect(matchesModKey('k')({ key: 'k', metaKey: false, ctrlKey: false } as KeyboardEvent)).toBe(false)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
2
|
import { render, screen, waitFor, act, cleanup } from '@testing-library/react'
|
|
3
3
|
import type { EditorAdapter, ImageElement, Project, RenderStatus } from '../../types'
|
|
4
|
-
import RenderModal, { phaseLabel, phaseIndex, RENDER_PHASES } from '../RenderModal'
|
|
4
|
+
import RenderModal, { phaseLabel, phaseIndex, RENDER_PHASES, stepperPhases } from '../RenderModal'
|
|
5
5
|
|
|
6
6
|
afterEach(() => {
|
|
7
7
|
cleanup()
|
|
@@ -16,18 +16,20 @@ describe('phaseLabel', () => {
|
|
|
16
16
|
expect(phaseLabel('rendering')).toBe('Rendering graphics')
|
|
17
17
|
expect(phaseLabel('captions')).toBe('Adding captions')
|
|
18
18
|
expect(phaseLabel('encoding')).toBe('Encoding video')
|
|
19
|
+
expect(phaseLabel('sdr_derive')).toBe('Deriving SDR')
|
|
19
20
|
expect(phaseLabel('saving')).toBe('Saving to your library')
|
|
20
21
|
expect(phaseLabel('done')).toBe('Done')
|
|
21
22
|
})
|
|
22
23
|
})
|
|
23
24
|
|
|
24
25
|
describe('phaseIndex / RENDER_PHASES', () => {
|
|
25
|
-
it('orders the phases preparing → done', () => {
|
|
26
|
+
it('orders the phases preparing → done, with sdr_derive after encoding', () => {
|
|
26
27
|
expect(RENDER_PHASES).toEqual([
|
|
27
28
|
'preparing',
|
|
28
29
|
'rendering',
|
|
29
30
|
'captions',
|
|
30
31
|
'encoding',
|
|
32
|
+
'sdr_derive',
|
|
31
33
|
'saving',
|
|
32
34
|
'done',
|
|
33
35
|
])
|
|
@@ -38,8 +40,22 @@ describe('phaseIndex / RENDER_PHASES', () => {
|
|
|
38
40
|
expect(phaseIndex('rendering')).toBe(1)
|
|
39
41
|
expect(phaseIndex('captions')).toBe(2)
|
|
40
42
|
expect(phaseIndex('encoding')).toBe(3)
|
|
41
|
-
expect(phaseIndex('
|
|
42
|
-
expect(phaseIndex('
|
|
43
|
+
expect(phaseIndex('sdr_derive')).toBe(4)
|
|
44
|
+
expect(phaseIndex('saving')).toBe(5)
|
|
45
|
+
expect(phaseIndex('done')).toBe(6)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('stepperPhases', () => {
|
|
50
|
+
it('hides sdr_derive unless the render actually derives an SDR file', () => {
|
|
51
|
+
expect(stepperPhases()).not.toContain('sdr_derive')
|
|
52
|
+
expect(stepperPhases({ export: 'auto' })).not.toContain('sdr_derive')
|
|
53
|
+
expect(stepperPhases({ export: 'sdr' })).toContain('sdr_derive')
|
|
54
|
+
expect(stepperPhases({ export: 'both' })).toContain('sdr_derive')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('never lists the terminal done phase', () => {
|
|
58
|
+
expect(stepperPhases({ export: 'both' })).not.toContain('done')
|
|
43
59
|
})
|
|
44
60
|
})
|
|
45
61
|
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { nextShuttleRate, createShuttleController } from '../shuttle'
|
|
3
|
+
import { createPlaybackClock } from '../playback-clock'
|
|
4
|
+
|
|
5
|
+
describe('nextShuttleRate — pure rate-stepping', () => {
|
|
6
|
+
it('steps the same direction 1x -> 2x -> 4x, capped at 4x', () => {
|
|
7
|
+
let rate = nextShuttleRate(0, 1)
|
|
8
|
+
expect(rate).toBe(1)
|
|
9
|
+
rate = nextShuttleRate(rate, 1)
|
|
10
|
+
expect(rate).toBe(2)
|
|
11
|
+
rate = nextShuttleRate(rate, 1)
|
|
12
|
+
expect(rate).toBe(4)
|
|
13
|
+
rate = nextShuttleRate(rate, 1) // capped
|
|
14
|
+
expect(rate).toBe(4)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('mirrors the same stepping backward', () => {
|
|
18
|
+
let rate = nextShuttleRate(0, -1)
|
|
19
|
+
expect(rate).toBe(-1)
|
|
20
|
+
rate = nextShuttleRate(rate, -1)
|
|
21
|
+
expect(rate).toBe(-2)
|
|
22
|
+
rate = nextShuttleRate(rate, -1)
|
|
23
|
+
expect(rate).toBe(-4)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('the opposite direction resets to that direction at 1x', () => {
|
|
27
|
+
const forward2x = nextShuttleRate(nextShuttleRate(0, 1), 1)
|
|
28
|
+
expect(forward2x).toBe(2)
|
|
29
|
+
expect(nextShuttleRate(forward2x, -1)).toBe(-1)
|
|
30
|
+
|
|
31
|
+
const backward4x = nextShuttleRate(nextShuttleRate(nextShuttleRate(0, -1), -1), -1)
|
|
32
|
+
expect(backward4x).toBe(-4)
|
|
33
|
+
expect(nextShuttleRate(backward4x, 1)).toBe(1)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('createShuttleController', () => {
|
|
38
|
+
// Deterministic fake rAF: callbacks queue up and only run when the test
|
|
39
|
+
// pumps them, so the rate-stepping/cancellation contract is tested without
|
|
40
|
+
// depending on real frame timing.
|
|
41
|
+
function makeFakeRaf() {
|
|
42
|
+
let pending: ((ms: number) => void) | null = null
|
|
43
|
+
return {
|
|
44
|
+
raf: (cb: (ms: number) => void) => { pending = cb; return 1 },
|
|
45
|
+
caf: () => { pending = null },
|
|
46
|
+
pump: (ms: number) => { const cb = pending; pending = null; cb?.(ms) },
|
|
47
|
+
hasPending: () => pending !== null,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Model real playback as a flag the play/pause deps flip, so isPlaying()
|
|
52
|
+
// reflects what the shuttle just did — exactly like the engine transport.
|
|
53
|
+
function makeDeps(clock: ReturnType<typeof createPlaybackClock>, getDuration = () => 100) {
|
|
54
|
+
let playing = false
|
|
55
|
+
const play = vi.fn(() => { playing = true })
|
|
56
|
+
const pause = vi.fn(() => { playing = false })
|
|
57
|
+
const setRate = vi.fn<(rate: number) => void>()
|
|
58
|
+
const fake = makeFakeRaf()
|
|
59
|
+
return {
|
|
60
|
+
play, pause, setRate, fake,
|
|
61
|
+
setPlaying: (v: boolean) => { playing = v },
|
|
62
|
+
deps: {
|
|
63
|
+
clock, getDuration,
|
|
64
|
+
isPlaying: () => playing,
|
|
65
|
+
play, pause, setRate,
|
|
66
|
+
raf: fake.raf, caf: fake.caf,
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
it('forward from idle: real playback @1x with audio, setRate(1), no seek-loop', () => {
|
|
72
|
+
const clock = createPlaybackClock(10)
|
|
73
|
+
const { deps, play, pause, setRate, fake } = makeDeps(clock)
|
|
74
|
+
const controller = createShuttleController(deps)
|
|
75
|
+
|
|
76
|
+
controller.press(1)
|
|
77
|
+
expect(play).toHaveBeenCalledTimes(1) // engine transport (audio) started
|
|
78
|
+
expect(pause).not.toHaveBeenCalled()
|
|
79
|
+
expect(setRate).toHaveBeenLastCalledWith(1) // 1x re-anchor (harmless no-op)
|
|
80
|
+
expect(controller.getRate()).toBe(1)
|
|
81
|
+
expect(fake.hasPending()).toBe(false) // NOT the silent seek-loop
|
|
82
|
+
expect(clock.get()).toBe(10) // the shuttle isn't driving the clock
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('repeating forward bumps the engine rate 1x -> 2x -> 4x (audible), never the seek-loop', () => {
|
|
86
|
+
const clock = createPlaybackClock(0)
|
|
87
|
+
const { deps, play, setRate, fake } = makeDeps(clock)
|
|
88
|
+
const controller = createShuttleController(deps)
|
|
89
|
+
|
|
90
|
+
controller.press(1) // real play @1x
|
|
91
|
+
controller.press(1) // -> 2x, STILL real playback
|
|
92
|
+
expect(setRate).toHaveBeenLastCalledWith(2)
|
|
93
|
+
expect(controller.getRate()).toBe(2)
|
|
94
|
+
expect(fake.hasPending()).toBe(false) // audible, not a seek-loop
|
|
95
|
+
expect(play).toHaveBeenCalledTimes(1) // already playing — not re-started
|
|
96
|
+
|
|
97
|
+
controller.press(1) // -> 4x
|
|
98
|
+
expect(setRate).toHaveBeenLastCalledWith(4)
|
|
99
|
+
expect(controller.getRate()).toBe(4)
|
|
100
|
+
|
|
101
|
+
controller.press(1) // capped at 4x
|
|
102
|
+
expect(setRate).toHaveBeenLastCalledWith(4)
|
|
103
|
+
expect(controller.getRate()).toBe(4)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('forward press while already playing (user-started via Space) does not claim ownership: stop() leaves it running', () => {
|
|
107
|
+
const clock = createPlaybackClock(0)
|
|
108
|
+
const { deps, play, pause, setRate, setPlaying } = makeDeps(clock)
|
|
109
|
+
const controller = createShuttleController(deps)
|
|
110
|
+
|
|
111
|
+
setPlaying(true) // the user hit Space before shuttling
|
|
112
|
+
controller.press(1)
|
|
113
|
+
expect(play).not.toHaveBeenCalled() // already playing — shuttle didn't start it
|
|
114
|
+
expect(setRate).toHaveBeenLastCalledWith(1)
|
|
115
|
+
expect(controller.getRate()).toBe(1)
|
|
116
|
+
|
|
117
|
+
controller.stop() // K
|
|
118
|
+
expect(pause).not.toHaveBeenCalled() // ownership never claimed — user's playback survives
|
|
119
|
+
expect(setRate).toHaveBeenLastCalledWith(1) // rate still resets to 1x
|
|
120
|
+
expect(controller.getRate()).toBe(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('reverse is a silent seek-loop: resets the rate, pauses real playback, never plays', () => {
|
|
124
|
+
const clock = createPlaybackClock(50)
|
|
125
|
+
const { deps, play, pause, setRate, fake, setPlaying } = makeDeps(clock)
|
|
126
|
+
const controller = createShuttleController(deps)
|
|
127
|
+
|
|
128
|
+
setPlaying(true) // e.g. the user hit Space first
|
|
129
|
+
controller.press(-1)
|
|
130
|
+
expect(setRate).toHaveBeenLastCalledWith(1) // no stale forward rate during the scrub
|
|
131
|
+
expect(pause).toHaveBeenCalledTimes(1) // real playback muted before scrubbing
|
|
132
|
+
expect(play).not.toHaveBeenCalled() // reverse never plays
|
|
133
|
+
expect(controller.getRate()).toBe(-1)
|
|
134
|
+
expect(fake.hasPending()).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('resetting from a reverse loop back to forward returns to real playback', () => {
|
|
138
|
+
const clock = createPlaybackClock(50)
|
|
139
|
+
const { deps, play, setRate, fake } = makeDeps(clock)
|
|
140
|
+
const controller = createShuttleController(deps)
|
|
141
|
+
|
|
142
|
+
controller.press(-1) // reverse loop (rate reset to 1, paused)
|
|
143
|
+
expect(fake.hasPending()).toBe(true)
|
|
144
|
+
controller.press(1) // opposite dir -> forward -> real play
|
|
145
|
+
expect(play).toHaveBeenCalledTimes(1)
|
|
146
|
+
expect(setRate).toHaveBeenLastCalledWith(1)
|
|
147
|
+
expect(controller.getRate()).toBe(1)
|
|
148
|
+
expect(fake.hasPending()).toBe(false) // loop cancelled
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('stop() resets the transport rate and pauses shuttle-started real playback', () => {
|
|
152
|
+
const clock = createPlaybackClock(0)
|
|
153
|
+
const { deps, pause, setRate } = makeDeps(clock)
|
|
154
|
+
const controller = createShuttleController(deps)
|
|
155
|
+
controller.press(1) // real play @1x (shuttle-started)
|
|
156
|
+
controller.stop()
|
|
157
|
+
expect(setRate).toHaveBeenLastCalledWith(1)
|
|
158
|
+
expect(pause).toHaveBeenCalledTimes(1)
|
|
159
|
+
expect(controller.getRate()).toBe(0)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('K (stop) zeroes the rate, resets the transport rate and cancels a running seek-loop', () => {
|
|
163
|
+
const clock = createPlaybackClock(10)
|
|
164
|
+
const { deps, setRate, fake } = makeDeps(clock)
|
|
165
|
+
const controller = createShuttleController(deps)
|
|
166
|
+
controller.press(-1) // reverse seek-loop
|
|
167
|
+
expect(fake.hasPending()).toBe(true)
|
|
168
|
+
controller.stop()
|
|
169
|
+
expect(setRate).toHaveBeenLastCalledWith(1)
|
|
170
|
+
expect(controller.getRate()).toBe(0)
|
|
171
|
+
expect(fake.hasPending()).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('steps the clock down in the reverse seek-loop, clamped at 0', () => {
|
|
175
|
+
const clock = createPlaybackClock(4)
|
|
176
|
+
const { deps, fake } = makeDeps(clock, () => 100)
|
|
177
|
+
const controller = createShuttleController(deps)
|
|
178
|
+
controller.press(-1) // -1x reverse seek-loop
|
|
179
|
+
controller.press(-1) // -> -2x
|
|
180
|
+
expect(controller.getRate()).toBe(-2)
|
|
181
|
+
fake.pump(0) // establishes the frame baseline, no time delta yet
|
|
182
|
+
fake.pump(1000) // -1s at 2x -> clock should be ~2
|
|
183
|
+
expect(clock.get()).toBeCloseTo(2, 5)
|
|
184
|
+
fake.pump(3000) // -2s at 2x would undershoot below 0 -> clamped, loop stops
|
|
185
|
+
expect(clock.get()).toBe(0)
|
|
186
|
+
expect(controller.getRate()).toBe(0)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('cancels the seek-loop when an external transport change starts real playback', () => {
|
|
190
|
+
const clock = createPlaybackClock(0)
|
|
191
|
+
const { deps, fake, setPlaying } = makeDeps(clock)
|
|
192
|
+
const controller = createShuttleController(deps)
|
|
193
|
+
controller.press(-1) // reverse seek-loop
|
|
194
|
+
fake.pump(0)
|
|
195
|
+
setPlaying(true) // user hit play, outside the shuttle
|
|
196
|
+
fake.pump(16)
|
|
197
|
+
expect(controller.getRate()).toBe(0)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('cancels the seek-loop when the clock is moved externally (a seek)', () => {
|
|
201
|
+
const clock = createPlaybackClock(0)
|
|
202
|
+
const { deps, fake } = makeDeps(clock)
|
|
203
|
+
const controller = createShuttleController(deps)
|
|
204
|
+
controller.press(-1) // reverse seek-loop
|
|
205
|
+
fake.pump(0)
|
|
206
|
+
clock.set(50) // an external seek, not written by the shuttle itself
|
|
207
|
+
fake.pump(16)
|
|
208
|
+
expect(controller.getRate()).toBe(0)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* The source-preview override store. A sibling of `hover-scrub.ts`: it repaints
|
|
4
|
+
* the main preview with an OFF-TIMELINE clip's frame on hover and NOTHING else —
|
|
5
|
+
* it holds a proxy url + a fraction, notifies only on real change, and is inert
|
|
6
|
+
* for a host that never wires it.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
9
|
+
import { createSourcePreviewStore } from '../source-preview'
|
|
10
|
+
|
|
11
|
+
describe('createSourcePreviewStore', () => {
|
|
12
|
+
it('starts empty — the main preview follows the timeline', () => {
|
|
13
|
+
expect(createSourcePreviewStore().get()).toBeNull()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('notifies subscribers when the value changes', () => {
|
|
17
|
+
const store = createSourcePreviewStore()
|
|
18
|
+
const sub = vi.fn()
|
|
19
|
+
store.subscribe(sub)
|
|
20
|
+
|
|
21
|
+
store.set({ url: 'a.mp4', fraction: 0.5 })
|
|
22
|
+
expect(store.get()).toEqual({ url: 'a.mp4', fraction: 0.5 })
|
|
23
|
+
expect(sub).toHaveBeenCalledTimes(1)
|
|
24
|
+
|
|
25
|
+
store.set(null)
|
|
26
|
+
expect(store.get()).toBeNull()
|
|
27
|
+
expect(sub).toHaveBeenCalledTimes(2)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('stays silent on a repeat write of the same url+fraction', () => {
|
|
31
|
+
// The host may re-set the same value on repeated pointer events over one
|
|
32
|
+
// tile; without the guard that would notify (and repaint) needlessly.
|
|
33
|
+
const store = createSourcePreviewStore()
|
|
34
|
+
const sub = vi.fn()
|
|
35
|
+
store.subscribe(sub)
|
|
36
|
+
|
|
37
|
+
store.set(null)
|
|
38
|
+
expect(sub).not.toHaveBeenCalled()
|
|
39
|
+
|
|
40
|
+
store.set({ url: 'a.mp4', fraction: 0.2 })
|
|
41
|
+
store.set({ url: 'a.mp4', fraction: 0.2 })
|
|
42
|
+
expect(sub).toHaveBeenCalledTimes(1)
|
|
43
|
+
|
|
44
|
+
// A moved fraction (same clip) is a real change.
|
|
45
|
+
store.set({ url: 'a.mp4', fraction: 0.6 })
|
|
46
|
+
expect(sub).toHaveBeenCalledTimes(2)
|
|
47
|
+
|
|
48
|
+
// A different clip is a real change.
|
|
49
|
+
store.set({ url: 'b.mp4', fraction: 0.6 })
|
|
50
|
+
expect(sub).toHaveBeenCalledTimes(3)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('unsubscribes cleanly', () => {
|
|
54
|
+
const store = createSourcePreviewStore()
|
|
55
|
+
const sub = vi.fn()
|
|
56
|
+
store.subscribe(sub)()
|
|
57
|
+
store.set({ url: 'a.mp4', fraction: 0.1 })
|
|
58
|
+
expect(sub).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { formatTimecode, parseTimecode } from '../timecode'
|
|
3
|
+
|
|
4
|
+
describe('parseTimecode', () => {
|
|
5
|
+
it('parses bare seconds', () => {
|
|
6
|
+
expect(parseTimecode('90')).toBe(90)
|
|
7
|
+
expect(parseTimecode('12.5')).toBe(12.5)
|
|
8
|
+
expect(parseTimecode('0')).toBe(0)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('parses mm:ss', () => {
|
|
12
|
+
expect(parseTimecode('1:30')).toBe(90)
|
|
13
|
+
expect(parseTimecode('12:03.5')).toBe(12 * 60 + 3.5)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('parses hh:mm:ss', () => {
|
|
17
|
+
expect(parseTimecode('01:02:03')).toBe(1 * 3600 + 2 * 60 + 3)
|
|
18
|
+
expect(parseTimecode('1:02:03.25')).toBe(3600 + 120 + 3.25)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('trims surrounding whitespace', () => {
|
|
22
|
+
expect(parseTimecode(' 1:30 ')).toBe(90)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('rejects out-of-range minutes/seconds', () => {
|
|
26
|
+
expect(parseTimecode('1:60')).toBeNull()
|
|
27
|
+
expect(parseTimecode('1:60:00')).toBeNull()
|
|
28
|
+
expect(parseTimecode('60:00:00')).not.toBeNull() // hours component is unbounded
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('rejects garbage', () => {
|
|
32
|
+
expect(parseTimecode('')).toBeNull()
|
|
33
|
+
expect(parseTimecode(' ')).toBeNull()
|
|
34
|
+
expect(parseTimecode('abc')).toBeNull()
|
|
35
|
+
expect(parseTimecode('1:2:3:4')).toBeNull()
|
|
36
|
+
expect(parseTimecode('1.5:30')).toBeNull()
|
|
37
|
+
expect(parseTimecode('-5')).toBeNull()
|
|
38
|
+
expect(parseTimecode('1:')).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('formatTimecode', () => {
|
|
43
|
+
it('formats zero', () => {
|
|
44
|
+
expect(formatTimecode(0)).toBe('0:00.0')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('formats sub-minute values with one decimal', () => {
|
|
48
|
+
expect(formatTimecode(5.34)).toBe('0:05.3')
|
|
49
|
+
expect(formatTimecode(59.96)).toBe('1:00.0') // rounds up and carries into minutes
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('formats minute-plus values without an hours component', () => {
|
|
53
|
+
expect(formatTimecode(65)).toBe('1:05.0')
|
|
54
|
+
expect(formatTimecode(599.9)).toBe('9:59.9')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('formats hour-plus values with an hh:mm:ss.f shape', () => {
|
|
58
|
+
expect(formatTimecode(3600)).toBe('1:00:00.0')
|
|
59
|
+
expect(formatTimecode(3725.6)).toBe('1:02:05.6')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('never emits a frames-style trailing group (parseTimecode has no concept of frames)', () => {
|
|
63
|
+
expect(formatTimecode(3725.6).split(':')).toHaveLength(3) // h:mm:ss.f, not h:mm:ss:ff
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('clamps negatives and non-finite input to 0', () => {
|
|
67
|
+
expect(formatTimecode(-5)).toBe('0:00.0')
|
|
68
|
+
expect(formatTimecode(NaN)).toBe('0:00.0')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('round-trips through parseTimecode', () => {
|
|
72
|
+
const values = [0, 0.1, 5.3, 42.7, 65.0, 599.9, 3600, 3661.2, 7325.8]
|
|
73
|
+
for (const t of values) {
|
|
74
|
+
expect(parseTimecode(formatTimecode(t))).toBeCloseTo(t, 1)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { renderHook, act } from '@testing-library/react'
|
|
3
|
+
|
|
4
|
+
import { createPlaybackClock } from '../playback-clock'
|
|
5
|
+
import { useReportContext, REPORT_INTERVAL_MS } from '../use-report-context'
|
|
6
|
+
|
|
7
|
+
beforeEach(() => { vi.useFakeTimers() })
|
|
8
|
+
afterEach(() => { vi.useRealTimers() })
|
|
9
|
+
|
|
10
|
+
describe('useReportContext', () => {
|
|
11
|
+
it('does nothing when the adapter omits reportContext', () => {
|
|
12
|
+
const clock = createPlaybackClock()
|
|
13
|
+
expect(() =>
|
|
14
|
+
renderHook(() => useReportContext({
|
|
15
|
+
adapter: {} as never, projectId: 'p1', clock,
|
|
16
|
+
selectedIds: [], selectedCaptionId: null,
|
|
17
|
+
})),
|
|
18
|
+
).not.toThrow()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('reports once on mount', () => {
|
|
22
|
+
const reportContext = vi.fn().mockResolvedValue(undefined)
|
|
23
|
+
const clock = createPlaybackClock(4)
|
|
24
|
+
renderHook(() => useReportContext({
|
|
25
|
+
adapter: { reportContext } as never, projectId: 'p1', clock,
|
|
26
|
+
selectedIds: [], selectedCaptionId: null,
|
|
27
|
+
}))
|
|
28
|
+
expect(reportContext).toHaveBeenCalledWith('p1', {
|
|
29
|
+
playheadSec: 4, selectedIds: [], selectedCaptionId: null,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('throttles a 60Hz clock to one report per interval', () => {
|
|
34
|
+
const reportContext = vi.fn().mockResolvedValue(undefined)
|
|
35
|
+
const clock = createPlaybackClock()
|
|
36
|
+
renderHook(() => useReportContext({
|
|
37
|
+
adapter: { reportContext } as never, projectId: 'p1', clock,
|
|
38
|
+
selectedIds: [], selectedCaptionId: null,
|
|
39
|
+
}))
|
|
40
|
+
reportContext.mockClear()
|
|
41
|
+
|
|
42
|
+
act(() => {
|
|
43
|
+
for (let i = 1; i <= 120; i++) clock.set(i / 60)
|
|
44
|
+
vi.advanceTimersByTime(REPORT_INTERVAL_MS)
|
|
45
|
+
})
|
|
46
|
+
expect(reportContext).toHaveBeenCalledTimes(1)
|
|
47
|
+
expect(reportContext.mock.calls[0][1].playheadSec).toBeCloseTo(2)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('reports a selection change immediately, without waiting for the interval', () => {
|
|
51
|
+
const reportContext = vi.fn().mockResolvedValue(undefined)
|
|
52
|
+
const clock = createPlaybackClock()
|
|
53
|
+
// Hoisted deliberately. The playhead effect depends on `adapter` by
|
|
54
|
+
// reference, so an inline object literal would be a new adapter every
|
|
55
|
+
// render and would re-fire that effect on rerender too — giving 2 calls
|
|
56
|
+
// and testing object identity instead of the selection path. Real call
|
|
57
|
+
// sites (EditorPage.tsx, MobileLiveView.tsx, MobileVideoPreview.tsx) all
|
|
58
|
+
// wrap createMontajAdapter() in useMemo, so a stable adapter is what
|
|
59
|
+
// production actually does.
|
|
60
|
+
const adapter = { reportContext } as never
|
|
61
|
+
const { rerender } = renderHook(
|
|
62
|
+
(props: { ids: string[] }) => useReportContext({
|
|
63
|
+
adapter, projectId: 'p1', clock,
|
|
64
|
+
selectedIds: props.ids, selectedCaptionId: null,
|
|
65
|
+
}),
|
|
66
|
+
{ initialProps: { ids: [] as string[] } },
|
|
67
|
+
)
|
|
68
|
+
reportContext.mockClear()
|
|
69
|
+
rerender({ ids: ['c3'] })
|
|
70
|
+
expect(reportContext).toHaveBeenCalledTimes(1)
|
|
71
|
+
expect(reportContext.mock.calls[0][1].selectedIds).toEqual(['c3'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('swallows a rejected report — context sync never breaks the editor', async () => {
|
|
75
|
+
const reportContext = vi.fn().mockRejectedValue(new Error('serve is down'))
|
|
76
|
+
const clock = createPlaybackClock()
|
|
77
|
+
expect(() =>
|
|
78
|
+
renderHook(() => useReportContext({
|
|
79
|
+
adapter: { reportContext } as never, projectId: 'p1', clock,
|
|
80
|
+
selectedIds: [], selectedCaptionId: null,
|
|
81
|
+
})),
|
|
82
|
+
).not.toThrow()
|
|
83
|
+
await act(async () => { await Promise.resolve() })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('stops reporting after unmount', () => {
|
|
87
|
+
const reportContext = vi.fn().mockResolvedValue(undefined)
|
|
88
|
+
const clock = createPlaybackClock()
|
|
89
|
+
const { unmount } = renderHook(() => useReportContext({
|
|
90
|
+
adapter: { reportContext } as never, projectId: 'p1', clock,
|
|
91
|
+
selectedIds: [], selectedCaptionId: null,
|
|
92
|
+
}))
|
|
93
|
+
unmount()
|
|
94
|
+
reportContext.mockClear()
|
|
95
|
+
act(() => {
|
|
96
|
+
clock.set(9)
|
|
97
|
+
vi.advanceTimersByTime(REPORT_INTERVAL_MS * 3)
|
|
98
|
+
})
|
|
99
|
+
expect(reportContext).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
})
|