@bycrux/editor 0.12.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +75 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +134 -2
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +586 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +6 -2
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2202 -251
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from 'vitest'
|
|
2
2
|
import { render, waitFor, act, fireEvent } from '@testing-library/react'
|
|
3
3
|
import type {
|
|
4
4
|
CaptionEvent,
|
|
@@ -9,9 +9,10 @@ import type {
|
|
|
9
9
|
VersionEntry,
|
|
10
10
|
WaveformChunk,
|
|
11
11
|
} from '../../types'
|
|
12
|
-
import type { VisualItem } from '../../schema'
|
|
12
|
+
import type { Captions, VisualItem } from '../../schema'
|
|
13
13
|
import type { OverlayChanges } from '../preview/useDragOverlay'
|
|
14
14
|
import VideoEditor from '../VideoEditor'
|
|
15
|
+
import { dragCanvasItem, installCanvasHarness, selectCanvasItem } from '../timeline/__tests__/_canvasSelect'
|
|
15
16
|
|
|
16
17
|
// ── Fake adapter ──────────────────────────────────────────────────────────────
|
|
17
18
|
// Full EditorAdapter with the video-editor capabilities VideoEditor threads:
|
|
@@ -94,6 +95,10 @@ function makeFakeAdapter(): FakeAdapter {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
beforeEach(() => {
|
|
98
|
+
// The caption panel now splits into Style / Captions sub-tabs and defaults
|
|
99
|
+
// to Style; these tests want the transcript + Regenerate trigger visible, so
|
|
100
|
+
// pin the sub-tab to 'captions' (usePersistentState reads this at mount).
|
|
101
|
+
window.localStorage.setItem('montaj.editor.captionPanelTab', JSON.stringify('captions'))
|
|
97
102
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
98
103
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
99
104
|
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
@@ -140,7 +145,7 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
140
145
|
|
|
141
146
|
it('shows the host pendingStatus slot for a pending project', async () => {
|
|
142
147
|
const adapter = makeFakeAdapter()
|
|
143
|
-
const initial = makeVideoProject({ status: 'pending', tracks: [[]] })
|
|
148
|
+
const initial = makeVideoProject({ status: 'pending', tracks: [{ id: 'trk-0', items: [] }] })
|
|
144
149
|
const { getByTestId } = render(
|
|
145
150
|
<VideoEditor
|
|
146
151
|
project={initial}
|
|
@@ -170,7 +175,7 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
170
175
|
|
|
171
176
|
it('invokes onBackToSetup affordance only when the host supplies it', async () => {
|
|
172
177
|
const adapter = makeFakeAdapter()
|
|
173
|
-
const initial = makeVideoProject({ status: 'pending', tracks: [[]] })
|
|
178
|
+
const initial = makeVideoProject({ status: 'pending', tracks: [{ id: 'trk-0', items: [] }] })
|
|
174
179
|
const onBackToSetup = vi.fn()
|
|
175
180
|
const { findByText } = render(
|
|
176
181
|
<VideoEditor
|
|
@@ -217,7 +222,7 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
217
222
|
it('shows the skill-path card on the pending surface when getInfo returns a path', async () => {
|
|
218
223
|
const adapter = makeFakeAdapter()
|
|
219
224
|
adapter.getInfo = vi.fn(async () => ({ root_skill_path: 'skills/video-skill.md' }))
|
|
220
|
-
const initial = makeVideoProject({ status: 'pending', tracks: [[]] })
|
|
225
|
+
const initial = makeVideoProject({ status: 'pending', tracks: [{ id: 'trk-0', items: [] }] })
|
|
221
226
|
const { findByText } = render(
|
|
222
227
|
<VideoEditor
|
|
223
228
|
project={initial}
|
|
@@ -360,26 +365,85 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
360
365
|
)
|
|
361
366
|
})
|
|
362
367
|
|
|
363
|
-
// Regression:
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
368
|
+
// Regression: a timeline gesture is ONE undo step, not one per mousemove.
|
|
369
|
+
// `onProjectChange` (the per-move channel) used to be a full `sync.mutate`,
|
|
370
|
+
// so dragging a clip across the timeline pushed dozens of undo entries and
|
|
371
|
+
// Undo walked it back a few pixels at a time instead of returning it to where
|
|
372
|
+
// the drag started. Drives the real DOM drag so the wiring is what's under
|
|
373
|
+
// test, not the sync core (which already had transient/commit).
|
|
374
|
+
it('undoes a whole drag in one step, not one step per mousemove', async () => {
|
|
375
|
+
onTestFinished(installCanvasHarness())
|
|
376
|
+
const adapter = makeFakeAdapter()
|
|
377
|
+
const initial = makeVideoProject({
|
|
378
|
+
tracks: [{
|
|
379
|
+
id: 'trk-0',
|
|
380
|
+
items: [
|
|
381
|
+
{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4, sourceDuration: 40 },
|
|
382
|
+
],
|
|
383
|
+
}],
|
|
384
|
+
})
|
|
385
|
+
const { container } = render(
|
|
386
|
+
<VideoEditor project={initial} adapter={adapter} slots={{ exportActions: <div /> }} />,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
// Press, then travel in several steps — each one used to be its own undo
|
|
390
|
+
// entry. `steps` defaults to 5, matching the DOM version's five mousemoves.
|
|
391
|
+
dragCanvasItem(container, initial, { type: 'video' }, { dxPx: 300 })
|
|
392
|
+
|
|
393
|
+
// The gesture moved the clip and persisted exactly one save for it.
|
|
394
|
+
await waitFor(() => expect(adapter.saveCalls.length).toBeGreaterThan(0))
|
|
395
|
+
const moved = adapter.saveCalls[adapter.saveCalls.length - 1].project.tracks![0].items[0]
|
|
396
|
+
expect(moved.start).toBeGreaterThan(0)
|
|
397
|
+
|
|
398
|
+
// ONE undo returns it all the way to the start — not to an intermediate
|
|
399
|
+
// position partway through the drag.
|
|
400
|
+
await act(async () => {
|
|
401
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
402
|
+
})
|
|
403
|
+
await waitFor(() => {
|
|
404
|
+
const after = adapter.saveCalls[adapter.saveCalls.length - 1].project.tracks![0].items[0]
|
|
405
|
+
expect(after.start).toBe(0)
|
|
406
|
+
expect(after.end).toBe(4)
|
|
407
|
+
})
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
// Regression (now against the Content-tab commit model): the right panel's
|
|
411
|
+
// Content tab previews an edit transiently through sync.mutateTransient — no
|
|
412
|
+
// undo entry, no save — and commits it on the field's blur through
|
|
413
|
+
// sync.commit() — exactly one undo entry + one queued save. This replaced
|
|
414
|
+
// the floating OverlayPropsModal's Save/Cancel model (see VideoEditor.tsx's
|
|
415
|
+
// `selectOverlayForEditing` comment): there is no Cancel any more, and no
|
|
416
|
+
// pre-open snapshot for one to restore — Undo is the only revert path now,
|
|
417
|
+
// and `editOriginalRef` (the stale pre-edit baseline the old version of this
|
|
418
|
+
// test guarded surviving a Cancel) is deleted along with the modal, so that
|
|
419
|
+
// exact bug is structurally impossible.
|
|
420
|
+
//
|
|
421
|
+
// The underlying risk it stood in for — a transient-gesture baseline
|
|
422
|
+
// surviving stale across an external frame that lands between two gestures —
|
|
423
|
+
// is a SYNC-CORE concern, not a VideoEditor-wiring one, and it is already
|
|
424
|
+
// covered directly at that layer: see
|
|
425
|
+
// src/state/__tests__/use-project-sync.test.tsx, describe('useProjectSync —
|
|
426
|
+
// stale baseline regression'), `it('undo after a gesture that follows an
|
|
427
|
+
// external frame restores the external state, not the stale pre-gesture
|
|
428
|
+
// baseline', ...)`. That test drives mutateTransient → applyExternal
|
|
429
|
+
// (mid-gesture) → mutateTransient → commit → undo directly against the hook,
|
|
430
|
+
// which is a strictly better place to guard it (no modal, no DOM, no
|
|
431
|
+
// dependency on VideoEditor's plumbing existing at all). This test's job is
|
|
432
|
+
// narrower and DOM-specific: prove VideoEditor's Content-tab wiring
|
|
433
|
+
// (previewOverlayProps → mutateTransient, commitOverlayEdit → sync.commit(),
|
|
434
|
+
// fired on the field's change/blur) is correct.
|
|
435
|
+
it('an overlay-props edit previews transiently, then commits on blur as one undo step', async () => {
|
|
436
|
+
onTestFinished(installCanvasHarness())
|
|
373
437
|
const adapter = makeFakeAdapter()
|
|
374
438
|
const onProjectChange = vi.fn()
|
|
375
439
|
const initial = makeVideoProject({
|
|
376
440
|
name: 'Original',
|
|
377
441
|
tracks: [
|
|
378
|
-
[{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }],
|
|
379
|
-
[{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Old text' } }],
|
|
442
|
+
{ id: 'trk-0', items: [{ id: 'clip-0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4 }] },
|
|
443
|
+
{ id: 'trk-1', items: [{ id: 'overlay-1', type: 'overlay', src: 'overlay.jsx', start: 0, end: 4, props: { text: 'Old text' } }] },
|
|
380
444
|
],
|
|
381
445
|
})
|
|
382
|
-
const {
|
|
446
|
+
const { container, findByLabelText } = render(
|
|
383
447
|
<VideoEditor
|
|
384
448
|
project={initial}
|
|
385
449
|
adapter={adapter}
|
|
@@ -388,56 +452,138 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
388
452
|
/>,
|
|
389
453
|
)
|
|
390
454
|
|
|
391
|
-
// Select the overlay item — additive (metaKey) click
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
fireEvent.click(editBtn)
|
|
455
|
+
// Select the overlay item — additive (metaKey) click, matching the DOM
|
|
456
|
+
// version's modifier. Selecting IS opening it now (no Pencil/dialog step):
|
|
457
|
+
// the right panel's Content tab shows the overlay's fields immediately —
|
|
458
|
+
// see VideoEditor.tsx's `overlayPropertiesPanel` / `selectOverlayForEditing`.
|
|
459
|
+
selectCanvasItem(container, initial, { type: 'overlay' }, { metaKey: true })
|
|
460
|
+
const textField = await findByLabelText('text')
|
|
398
461
|
|
|
399
462
|
// Preview an edit — mutateTransient baselines against the pre-gesture state.
|
|
400
|
-
const textField = await findByLabelText('text')
|
|
401
463
|
fireEvent.change(textField, { target: { value: 'Live preview' } })
|
|
402
464
|
await waitFor(() => expect(onProjectChange).toHaveBeenLastCalledWith(
|
|
403
465
|
expect.objectContaining({
|
|
404
466
|
tracks: expect.arrayContaining([
|
|
405
|
-
expect.
|
|
467
|
+
expect.objectContaining({
|
|
468
|
+
items: expect.arrayContaining([expect.objectContaining({ id: 'overlay-1', props: { text: 'Live preview' } })]),
|
|
469
|
+
}),
|
|
406
470
|
]),
|
|
407
471
|
}),
|
|
408
472
|
))
|
|
409
473
|
|
|
410
|
-
//
|
|
411
|
-
|
|
474
|
+
// Still transient: nothing has been pushed to the undo stack yet, so Undo
|
|
475
|
+
// here is a no-op — sync core's undo() pops an empty stack and returns
|
|
476
|
+
// before touching project state, so onProjectChange doesn't fire again.
|
|
477
|
+
const callsBeforeUndo = onProjectChange.mock.calls.length
|
|
478
|
+
await act(async () => {
|
|
479
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
480
|
+
})
|
|
481
|
+
expect(onProjectChange.mock.calls.length).toBe(callsBeforeUndo)
|
|
482
|
+
|
|
483
|
+
// Blur commits the gesture as ONE undo step. commit() doesn't itself
|
|
484
|
+
// change project content (it only pushes the pre-gesture baseline and
|
|
485
|
+
// queues a save), so the Undo right after is what proves the whole typing
|
|
486
|
+
// gesture collapsed to exactly one entry — it lands all the way back on
|
|
487
|
+
// the pre-edit text, not some intermediate preview.
|
|
488
|
+
fireEvent.blur(textField)
|
|
489
|
+
await act(async () => {
|
|
490
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
|
|
491
|
+
})
|
|
412
492
|
await waitFor(() => expect(onProjectChange).toHaveBeenLastCalledWith(
|
|
413
493
|
expect.objectContaining({
|
|
414
494
|
name: 'Original',
|
|
415
495
|
tracks: expect.arrayContaining([
|
|
416
|
-
expect.
|
|
496
|
+
expect.objectContaining({
|
|
497
|
+
items: expect.arrayContaining([expect.objectContaining({ id: 'overlay-1', props: { text: 'Old text' } })]),
|
|
498
|
+
}),
|
|
417
499
|
]),
|
|
418
500
|
}),
|
|
419
501
|
))
|
|
502
|
+
})
|
|
420
503
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
504
|
+
// Caption LANES are dense from 0 by contract — the bands the painter emits,
|
|
505
|
+
// the row the hit-test addresses and the fan-out a cross-row drag searches
|
|
506
|
+
// all assume it. A project.json written by hand or by an agent need not
|
|
507
|
+
// honour that, so the same defensive pass that backfills ids normalizes
|
|
508
|
+
// lanes on load.
|
|
509
|
+
it('normalizes sparse caption lanes on load, and settles instead of looping', async () => {
|
|
510
|
+
const adapter = makeFakeAdapter()
|
|
511
|
+
const onProjectChange = vi.fn()
|
|
512
|
+
render(
|
|
513
|
+
<VideoEditor
|
|
514
|
+
project={makeVideoProject({
|
|
515
|
+
status: 'draft',
|
|
516
|
+
captions: {
|
|
517
|
+
style: 'clean',
|
|
518
|
+
segments: [
|
|
519
|
+
// `lane: 7` with nothing on lanes 1–6: eight rows of mostly
|
|
520
|
+
// nothing unless this is collapsed to row 1.
|
|
521
|
+
{ id: 's0', text: 'one', start: 0, end: 1, lane: 7, words: [{ word: 'one', start: 0, end: 1 }] },
|
|
522
|
+
{ id: 's1', text: 'two', start: 1, end: 2, words: [{ word: 'two', start: 1, end: 2 }] },
|
|
523
|
+
],
|
|
524
|
+
},
|
|
525
|
+
} as Partial<Project>)}
|
|
526
|
+
adapter={adapter}
|
|
527
|
+
onProjectChange={onProjectChange}
|
|
528
|
+
slots={{ exportActions: <div /> }}
|
|
529
|
+
/>,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
await waitFor(() => {
|
|
533
|
+
const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
534
|
+
// s0 drops from 7 to 1; s1's absent lane already means 0 and is left
|
|
535
|
+
// absent rather than rewritten.
|
|
536
|
+
expect(last.captions?.segments.map((s) => s.lane)).toEqual([1, undefined])
|
|
439
537
|
})
|
|
440
|
-
|
|
538
|
+
|
|
539
|
+
// Settles: `normalizeCaptionLanes` hands back the same reference once the
|
|
540
|
+
// lanes are dense, so the pass that follows its own applyExternal is a true
|
|
541
|
+
// no-op rather than another write.
|
|
542
|
+
const settledCallCount = onProjectChange.mock.calls.length
|
|
543
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
544
|
+
expect(onProjectChange.mock.calls.length).toBe(settledCallCount)
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('never publishes a half-normalized project — ids and lanes land in one write', async () => {
|
|
548
|
+
// Both defensive passes share ONE effect on purpose. Run as two effects
|
|
549
|
+
// with the same deps they both close over the pre-effect project, so the
|
|
550
|
+
// second applyExternal lands a project derived from before the first and
|
|
551
|
+
// drops the ids the backfill just minted — recovered on the next pass, but
|
|
552
|
+
// only after the host has already been handed that half-normalized frame.
|
|
553
|
+
const adapter = makeFakeAdapter()
|
|
554
|
+
const onProjectChange = vi.fn()
|
|
555
|
+
render(
|
|
556
|
+
<VideoEditor
|
|
557
|
+
project={makeVideoProject({
|
|
558
|
+
status: 'draft',
|
|
559
|
+
captions: {
|
|
560
|
+
style: 'clean',
|
|
561
|
+
segments: [
|
|
562
|
+
{ text: 'one', start: 0, end: 1, lane: 4, words: [{ word: 'one', start: 0, end: 1 }] },
|
|
563
|
+
{ text: 'two', start: 1, end: 2, words: [{ word: 'two', start: 1, end: 2 }] },
|
|
564
|
+
],
|
|
565
|
+
},
|
|
566
|
+
} as Partial<Project>)}
|
|
567
|
+
adapter={adapter}
|
|
568
|
+
onProjectChange={onProjectChange}
|
|
569
|
+
slots={{ exportActions: <div /> }}
|
|
570
|
+
/>,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
await waitFor(() => {
|
|
574
|
+
const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
575
|
+
expect(last.captions?.segments.map((s) => s.id)).toEqual(['cap-0', 'cap-1'])
|
|
576
|
+
expect(last.captions?.segments.map((s) => s.lane)).toEqual([1, undefined])
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
// Every frame the host saw is either the raw input or the finished result:
|
|
580
|
+
// no frame has the lanes collapsed while the ids are still missing.
|
|
581
|
+
for (const call of onProjectChange.mock.calls) {
|
|
582
|
+
const segments = (call[0] as Project).captions?.segments ?? []
|
|
583
|
+
if (segments.some((s) => s.lane === 1)) {
|
|
584
|
+
expect(segments.every((s) => !!s.id)).toBe(true)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
441
587
|
})
|
|
442
588
|
|
|
443
589
|
// Regression: the caption-repair effect (VideoEditor.tsx, near backfillCaptionIds)
|
|
@@ -469,7 +615,10 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
469
615
|
/>,
|
|
470
616
|
)
|
|
471
617
|
|
|
472
|
-
|
|
618
|
+
// This project starts with no captions, so the panel's trigger is the
|
|
619
|
+
// empty state's "Generate captions" button; the two tests below seed a
|
|
620
|
+
// caption track and therefore get "Regenerate captions" instead.
|
|
621
|
+
const regenBtn = await findByText('Generate captions')
|
|
473
622
|
await act(async () => { regenBtn.click() })
|
|
474
623
|
|
|
475
624
|
// Repair fired for a captions-only replacement — words[] derived from the
|
|
@@ -486,4 +635,208 @@ describe('VideoEditor — editor-package integration', () => {
|
|
|
486
635
|
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
|
487
636
|
expect(onProjectChange.mock.calls.length).toBe(settledCallCount)
|
|
488
637
|
})
|
|
638
|
+
|
|
639
|
+
// Round-trip regression: the caption pipeline (serve/routes/projects.py,
|
|
640
|
+
// _run_caption_pipeline) writes the SAME freshly-regenerated track to two
|
|
641
|
+
// places — the `done` frame on this fetch's own SSE response (read by
|
|
642
|
+
// CaptionRegenModal → onDone → applyExternal), and project.json, broadcast
|
|
643
|
+
// over the AMBIENT per-project stream (read by adapter.subscribe →
|
|
644
|
+
// applyExternal). Those are two independent connections with no ordering
|
|
645
|
+
// guarantee between them. Neither arrival order may leave project.captions
|
|
646
|
+
// stale or half-merged — each segment already carries a fixed-point `words[]`
|
|
647
|
+
// so the caption-repair effect is a no-op and doesn't obscure the assertion.
|
|
648
|
+
const regeneratedTrack: Captions = {
|
|
649
|
+
style: 'pop',
|
|
650
|
+
segments: [{
|
|
651
|
+
id: 's1',
|
|
652
|
+
text: 'fresh take',
|
|
653
|
+
start: 0,
|
|
654
|
+
end: 1,
|
|
655
|
+
words: [{ word: 'fresh', start: 0, end: 0.5 }, { word: 'take', start: 0.5, end: 1 }],
|
|
656
|
+
}],
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
it('captions land as the new track when the ambient SSE broadcast arrives AFTER the done event', async () => {
|
|
660
|
+
const adapter = makeFakeAdapter()
|
|
661
|
+
adapter.generateCaptions = async function* (): AsyncIterable<CaptionEvent> {
|
|
662
|
+
yield { type: 'done', captions: regeneratedTrack }
|
|
663
|
+
}
|
|
664
|
+
const onProjectChange = vi.fn()
|
|
665
|
+
const { findByText } = render(
|
|
666
|
+
<VideoEditor
|
|
667
|
+
project={makeVideoProject({
|
|
668
|
+
captions: { style: 'clean', segments: [{ id: 'old', text: 'stale', start: 0, end: 1 }] },
|
|
669
|
+
} as Partial<Project>)}
|
|
670
|
+
adapter={adapter}
|
|
671
|
+
onProjectChange={onProjectChange}
|
|
672
|
+
slots={{ exportActions: <div /> }}
|
|
673
|
+
/>,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
const regenBtn = await findByText('Regenerate captions')
|
|
677
|
+
await act(async () => { regenBtn.click() })
|
|
678
|
+
|
|
679
|
+
await waitFor(() => {
|
|
680
|
+
const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
681
|
+
expect(last.captions).toEqual(regeneratedTrack)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
// The server's whole-project broadcast for this SAME write lands next, on
|
|
685
|
+
// the ambient stream — carrying the identical track. Must be a no-op.
|
|
686
|
+
const afterDone = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
687
|
+
await act(async () => {
|
|
688
|
+
adapter.emit({ ...afterDone, captions: regeneratedTrack })
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
const final = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
692
|
+
expect(final.captions).toEqual(regeneratedTrack)
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
it('captions land as the new track when the ambient SSE broadcast arrives BEFORE the done event resolves', async () => {
|
|
696
|
+
const adapter = makeFakeAdapter()
|
|
697
|
+
const baseProject = makeVideoProject({
|
|
698
|
+
captions: { style: 'clean', segments: [{ id: 'old', text: 'stale', start: 0, end: 1 }] },
|
|
699
|
+
} as Partial<Project>)
|
|
700
|
+
adapter.generateCaptions = async function* (): AsyncIterable<CaptionEvent> {
|
|
701
|
+
// Simulate the server writing + broadcasting project.json BEFORE this
|
|
702
|
+
// request's own `done` frame is read — the two are separate connections
|
|
703
|
+
// (POST response body vs. GET .../stream), and the route publishes the
|
|
704
|
+
// broadcast strictly before it yields `done` (serve/routes/projects.py,
|
|
705
|
+
// _run_caption_pipeline: broadcaster.publish(...) then `return track`,
|
|
706
|
+
// ahead of the route's `yield f"event: done..."`).
|
|
707
|
+
adapter.emit({ ...baseProject, captions: regeneratedTrack })
|
|
708
|
+
yield { type: 'done', captions: regeneratedTrack }
|
|
709
|
+
}
|
|
710
|
+
const onProjectChange = vi.fn()
|
|
711
|
+
const { findByText } = render(
|
|
712
|
+
<VideoEditor
|
|
713
|
+
project={baseProject}
|
|
714
|
+
adapter={adapter}
|
|
715
|
+
onProjectChange={onProjectChange}
|
|
716
|
+
slots={{ exportActions: <div /> }}
|
|
717
|
+
/>,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
const regenBtn = await findByText('Regenerate captions')
|
|
721
|
+
await act(async () => { regenBtn.click() })
|
|
722
|
+
|
|
723
|
+
await waitFor(() => {
|
|
724
|
+
const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
|
|
725
|
+
expect(last.captions).toEqual(regeneratedTrack)
|
|
726
|
+
})
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
// ── Version history: save / restore / compare (SP8b T8) ──────────────────────
|
|
730
|
+
|
|
731
|
+
it('a completed render triggers listVersionHistory to be called again', async () => {
|
|
732
|
+
const adapter = makeFakeAdapter()
|
|
733
|
+
// Starting status is already 'final' — openRender always re-sets status to
|
|
734
|
+
// 'final' (`{ ...project, status: 'final' }`), which for an
|
|
735
|
+
// already-final project is the SAME primitive value. useVersionHistory's
|
|
736
|
+
// auto-refetch effect is keyed on `project.status` by reference-equal
|
|
737
|
+
// primitive, so it will NOT re-fire from that assignment here — isolating
|
|
738
|
+
// this test to the refetch RenderModal's onRenderComplete triggers once
|
|
739
|
+
// the fake adapter's render stream reaches its `done` event.
|
|
740
|
+
const initial = makeVideoProject({ status: 'final' })
|
|
741
|
+
const { findByText, findByRole } = render(
|
|
742
|
+
<VideoEditor
|
|
743
|
+
project={initial}
|
|
744
|
+
adapter={adapter}
|
|
745
|
+
onProjectChange={vi.fn()}
|
|
746
|
+
slots={{ exportActions: <div /> }}
|
|
747
|
+
/>,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
await waitFor(() => expect(adapter.listVersionHistory).toHaveBeenCalledTimes(1))
|
|
751
|
+
|
|
752
|
+
const renderBtn = await findByText('Render →')
|
|
753
|
+
await act(async () => { renderBtn.click() })
|
|
754
|
+
|
|
755
|
+
// ReviewSurface always supplies `preRenderOptions`, so RenderModal opens on
|
|
756
|
+
// its pre-render options dialog first — the render itself only starts once
|
|
757
|
+
// the "Export" action inside that dialog is clicked.
|
|
758
|
+
const startBtn = await findByRole('button', { name: 'Export' })
|
|
759
|
+
await act(async () => { fireEvent.click(startBtn) })
|
|
760
|
+
|
|
761
|
+
await waitFor(() => {
|
|
762
|
+
expect(adapter.listVersionHistory).toHaveBeenCalledTimes(2)
|
|
763
|
+
})
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('handleSaveVersion calls adapter.saveVersion and refetches history', async () => {
|
|
767
|
+
const adapter = makeFakeAdapter()
|
|
768
|
+
adapter.saveVersion = vi.fn(async () => [])
|
|
769
|
+
const initial = makeVideoProject({ status: 'draft' })
|
|
770
|
+
const { findByPlaceholderText, findByText } = render(
|
|
771
|
+
<VideoEditor
|
|
772
|
+
project={initial}
|
|
773
|
+
adapter={adapter}
|
|
774
|
+
onProjectChange={vi.fn()}
|
|
775
|
+
slots={{ exportActions: <div /> }}
|
|
776
|
+
/>,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
await waitFor(() => expect(adapter.listVersionHistory).toHaveBeenCalledTimes(1))
|
|
780
|
+
|
|
781
|
+
const input = await findByPlaceholderText('Name (optional)')
|
|
782
|
+
fireEvent.change(input, { target: { value: 'my checkpoint' } })
|
|
783
|
+
const saveBtn = await findByText('Save version')
|
|
784
|
+
fireEvent.click(saveBtn)
|
|
785
|
+
|
|
786
|
+
await waitFor(() => {
|
|
787
|
+
expect(adapter.saveVersion).toHaveBeenCalledWith('vid-1', 'my checkpoint')
|
|
788
|
+
})
|
|
789
|
+
await waitFor(() => {
|
|
790
|
+
expect(adapter.listVersionHistory).toHaveBeenCalledTimes(2)
|
|
791
|
+
})
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
it('handleRestoreVersion refetches history after restore', async () => {
|
|
795
|
+
const adapter = makeFakeAdapter()
|
|
796
|
+
const versionEntry = { hash: 'abc123', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' }
|
|
797
|
+
adapter.listVersionHistory = vi.fn(async () => [versionEntry])
|
|
798
|
+
const initial = makeVideoProject({ status: 'draft' })
|
|
799
|
+
const { findByText } = render(
|
|
800
|
+
<VideoEditor
|
|
801
|
+
project={initial}
|
|
802
|
+
adapter={adapter}
|
|
803
|
+
onProjectChange={vi.fn()}
|
|
804
|
+
slots={{ exportActions: <div /> }}
|
|
805
|
+
/>,
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
await waitFor(() => expect(adapter.listVersionHistory).toHaveBeenCalledTimes(1))
|
|
809
|
+
|
|
810
|
+
const restoreBtn = await findByText('Restore →')
|
|
811
|
+
fireEvent.click(restoreBtn)
|
|
812
|
+
|
|
813
|
+
await waitFor(() => {
|
|
814
|
+
expect(adapter.restoreVersion).toHaveBeenCalledWith('vid-1', 'abc123')
|
|
815
|
+
})
|
|
816
|
+
// Post-Phase-3 behavior: a restore refetches version history rather than
|
|
817
|
+
// relying on the restored project's status to have changed.
|
|
818
|
+
await waitFor(() => {
|
|
819
|
+
expect(adapter.listVersionHistory).toHaveBeenCalledTimes(2)
|
|
820
|
+
})
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
it('does not render the Compare button when adapter.versionFrameUrl is undefined', async () => {
|
|
824
|
+
const adapter = makeFakeAdapter()
|
|
825
|
+
const versionEntry = { hash: 'abc123', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' }
|
|
826
|
+
adapter.listVersionHistory = vi.fn(async () => [versionEntry])
|
|
827
|
+
// adapter.versionFrameUrl is intentionally left undefined — makeFakeAdapter's default.
|
|
828
|
+
const initial = makeVideoProject({ status: 'draft' })
|
|
829
|
+
const { queryByText } = render(
|
|
830
|
+
<VideoEditor
|
|
831
|
+
project={initial}
|
|
832
|
+
adapter={adapter}
|
|
833
|
+
onProjectChange={vi.fn()}
|
|
834
|
+
slots={{ exportActions: <div /> }}
|
|
835
|
+
/>,
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
await waitFor(() => expect(adapter.listVersionHistory).toHaveBeenCalled())
|
|
839
|
+
|
|
840
|
+
expect(queryByText('Compare')).toBeNull()
|
|
841
|
+
})
|
|
489
842
|
})
|