@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,182 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, within, act, cleanup } from '@testing-library/react'
|
|
3
|
+
import VersionCompare from '../VersionCompare'
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
cleanup()
|
|
7
|
+
vi.useRealTimers()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const VERSIONS = [
|
|
11
|
+
{ hash: 'abc', message: 'v1', timestamp: '2026-01-01T00:00:00Z' },
|
|
12
|
+
{ hash: 'def', message: 'v2', timestamp: '2026-01-02T00:00:00Z' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/** Distinctive, deterministic stand-in for the real frame-render URL builder —
|
|
16
|
+
* encodes exactly the two inputs under test (commit, t) into the string. */
|
|
17
|
+
function mockFrameUrl(_id: string, commit: string, t: number): string {
|
|
18
|
+
return `mock:${commit}:${t}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('VersionCompare', () => {
|
|
22
|
+
it('renders two frame panes for two versions', () => {
|
|
23
|
+
render(
|
|
24
|
+
<VersionCompare
|
|
25
|
+
projectId="proj-1"
|
|
26
|
+
versions={VERSIONS}
|
|
27
|
+
initialLeftHash="abc"
|
|
28
|
+
frameUrl={mockFrameUrl}
|
|
29
|
+
durationSeconds={10}
|
|
30
|
+
onClose={vi.fn()}
|
|
31
|
+
/>,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
expect(screen.getAllByRole('img')).toHaveLength(2)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('sets both <img> srcs from frameUrl with the correct commit ids', () => {
|
|
38
|
+
render(
|
|
39
|
+
<VersionCompare
|
|
40
|
+
projectId="proj-1"
|
|
41
|
+
versions={VERSIONS}
|
|
42
|
+
initialLeftHash="abc"
|
|
43
|
+
frameUrl={mockFrameUrl}
|
|
44
|
+
durationSeconds={10}
|
|
45
|
+
onClose={vi.fn()}
|
|
46
|
+
/>,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// initialLeftHash="abc" seeds LEFT; RIGHT defaults to the "working"
|
|
50
|
+
// sentinel since LEFT isn't already "working". Initial t = duration/2 = 5,
|
|
51
|
+
// and sampleT starts equal to t (no debounce needed pre-interaction).
|
|
52
|
+
const imgs = screen.getAllByRole('img')
|
|
53
|
+
expect(imgs[0]).toHaveAttribute('src', 'mock:abc:5')
|
|
54
|
+
expect(imgs[1]).toHaveAttribute('src', 'mock:working:5')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('re-points both <img> srcs to the new time after the scrub debounce elapses', async () => {
|
|
58
|
+
vi.useFakeTimers()
|
|
59
|
+
render(
|
|
60
|
+
<VersionCompare
|
|
61
|
+
projectId="proj-1"
|
|
62
|
+
versions={VERSIONS}
|
|
63
|
+
initialLeftHash="abc"
|
|
64
|
+
frameUrl={mockFrameUrl}
|
|
65
|
+
durationSeconds={10}
|
|
66
|
+
onClose={vi.fn()}
|
|
67
|
+
/>,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const slider = screen.getByLabelText('Scrub time')
|
|
71
|
+
fireEvent.change(slider, { target: { value: '7' } })
|
|
72
|
+
|
|
73
|
+
// Pre-debounce: the img srcs must not have moved yet.
|
|
74
|
+
let imgs = screen.getAllByRole('img')
|
|
75
|
+
expect(imgs[0]).toHaveAttribute('src', 'mock:abc:5')
|
|
76
|
+
|
|
77
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(200) })
|
|
78
|
+
|
|
79
|
+
imgs = screen.getAllByRole('img')
|
|
80
|
+
expect(imgs[0]).toHaveAttribute('src', 'mock:abc:7')
|
|
81
|
+
expect(imgs[1]).toHaveAttribute('src', 'mock:working:7')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("changing the LEFT picker updates only the left pane's src", () => {
|
|
85
|
+
render(
|
|
86
|
+
<VersionCompare
|
|
87
|
+
projectId="proj-1"
|
|
88
|
+
versions={VERSIONS}
|
|
89
|
+
initialLeftHash="abc"
|
|
90
|
+
frameUrl={mockFrameUrl}
|
|
91
|
+
durationSeconds={10}
|
|
92
|
+
onClose={vi.fn()}
|
|
93
|
+
/>,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const leftSelect = screen.getByLabelText('Left') as HTMLSelectElement
|
|
97
|
+
fireEvent.change(leftSelect, { target: { value: 'def' } })
|
|
98
|
+
|
|
99
|
+
const imgs = screen.getAllByRole('img')
|
|
100
|
+
expect(imgs[0]).toHaveAttribute('src', 'mock:def:5')
|
|
101
|
+
expect(imgs[1]).toHaveAttribute('src', 'mock:working:5')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("changing the RIGHT picker updates only the right pane's src", () => {
|
|
105
|
+
render(
|
|
106
|
+
<VersionCompare
|
|
107
|
+
projectId="proj-1"
|
|
108
|
+
versions={VERSIONS}
|
|
109
|
+
initialLeftHash="abc"
|
|
110
|
+
frameUrl={mockFrameUrl}
|
|
111
|
+
durationSeconds={10}
|
|
112
|
+
onClose={vi.fn()}
|
|
113
|
+
/>,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const rightSelect = screen.getByLabelText('Right') as HTMLSelectElement
|
|
117
|
+
fireEvent.change(rightSelect, { target: { value: 'def' } })
|
|
118
|
+
|
|
119
|
+
const imgs = screen.getAllByRole('img')
|
|
120
|
+
expect(imgs[0]).toHaveAttribute('src', 'mock:abc:5')
|
|
121
|
+
expect(imgs[1]).toHaveAttribute('src', 'mock:def:5')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('Escape key closes', () => {
|
|
125
|
+
const onClose = vi.fn()
|
|
126
|
+
render(
|
|
127
|
+
<VersionCompare
|
|
128
|
+
projectId="proj-1"
|
|
129
|
+
versions={VERSIONS}
|
|
130
|
+
initialLeftHash="abc"
|
|
131
|
+
frameUrl={mockFrameUrl}
|
|
132
|
+
durationSeconds={10}
|
|
133
|
+
onClose={onClose}
|
|
134
|
+
/>,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
138
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('clicking the close button calls onClose', () => {
|
|
142
|
+
const onClose = vi.fn()
|
|
143
|
+
render(
|
|
144
|
+
<VersionCompare
|
|
145
|
+
projectId="proj-1"
|
|
146
|
+
versions={VERSIONS}
|
|
147
|
+
initialLeftHash="abc"
|
|
148
|
+
frameUrl={mockFrameUrl}
|
|
149
|
+
durationSeconds={10}
|
|
150
|
+
onClose={onClose}
|
|
151
|
+
/>,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
fireEvent.click(screen.getByLabelText('Close'))
|
|
155
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('offers the "working" option in both pickers and points a pane at commit "working" when chosen', () => {
|
|
159
|
+
render(
|
|
160
|
+
<VersionCompare
|
|
161
|
+
projectId="proj-1"
|
|
162
|
+
versions={VERSIONS}
|
|
163
|
+
initialLeftHash="abc"
|
|
164
|
+
frameUrl={mockFrameUrl}
|
|
165
|
+
durationSeconds={10}
|
|
166
|
+
onClose={vi.fn()}
|
|
167
|
+
/>,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const leftSelect = screen.getByLabelText('Left') as HTMLSelectElement
|
|
171
|
+
const rightSelect = screen.getByLabelText('Right') as HTMLSelectElement
|
|
172
|
+
expect(within(leftSelect).getByText('Current (working)')).toBeInTheDocument()
|
|
173
|
+
expect(within(rightSelect).getByText('Current (working)')).toBeInTheDocument()
|
|
174
|
+
|
|
175
|
+
// RIGHT already defaults to "working" (LEFT isn't "working" initially);
|
|
176
|
+
// explicitly select it on LEFT too, proving the sentinel behaves
|
|
177
|
+
// identically wired into either picker.
|
|
178
|
+
fireEvent.change(leftSelect, { target: { value: 'working' } })
|
|
179
|
+
const imgs = screen.getAllByRole('img')
|
|
180
|
+
expect(imgs[0]).toHaveAttribute('src', 'mock:working:5')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
|
3
|
+
import type { VersionEntry } from '../../types'
|
|
4
|
+
import VersionPanel, { listVersions, humanizeLabel } from '../VersionPanel'
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup)
|
|
7
|
+
|
|
8
|
+
// Mirrors VersionPanel's own `formatTime` exactly, so the timestamp assertion
|
|
9
|
+
// doesn't hardcode a locale-specific string — it checks against whatever the
|
|
10
|
+
// panel actually renders for this environment's locale.
|
|
11
|
+
function expectedTimestamp(iso: string): string {
|
|
12
|
+
return new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('VersionPanel', () => {
|
|
16
|
+
it('renders every version as its own row, including two versions sharing a run number', () => {
|
|
17
|
+
const versions: VersionEntry[] = [
|
|
18
|
+
{ hash: 'abc', message: 'version: run 2 — export', timestamp: '2026-01-01T12:00:00Z' },
|
|
19
|
+
{ hash: 'def', message: 'version: run 2 — My Checkpoint', timestamp: '2026-01-01T13:00:00Z' },
|
|
20
|
+
{ hash: 'ghi', message: 'version: run 1 — draft', timestamp: '2025-12-31T08:15:00Z' },
|
|
21
|
+
]
|
|
22
|
+
const { container } = render(
|
|
23
|
+
<VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
expect(container.textContent).toContain('Exported')
|
|
27
|
+
expect(container.textContent).toContain('My Checkpoint')
|
|
28
|
+
expect(container.textContent).toContain('Draft')
|
|
29
|
+
expect(container.textContent).toContain(expectedTimestamp('2026-01-01T12:00:00Z'))
|
|
30
|
+
expect(container.textContent).toContain(expectedTimestamp('2026-01-01T13:00:00Z'))
|
|
31
|
+
expect(container.textContent).toContain(expectedTimestamp('2025-12-31T08:15:00Z'))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('never renders the string "Run "', () => {
|
|
35
|
+
const versions: VersionEntry[] = [
|
|
36
|
+
{ hash: 'abc', message: 'version: run 2 — export', timestamp: '2026-01-01T12:00:00Z' },
|
|
37
|
+
{ hash: 'def', message: 'version: run 1 — draft', timestamp: '2025-12-31T08:15:00Z' },
|
|
38
|
+
]
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
expect(container.textContent).not.toContain('Run ')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('filters out run-0 init-baseline entries', () => {
|
|
47
|
+
const versions: VersionEntry[] = [
|
|
48
|
+
{ hash: 'init', message: 'initial commit', timestamp: '2025-12-30T00:00:00Z' },
|
|
49
|
+
{ hash: 'abc', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
|
|
50
|
+
]
|
|
51
|
+
const { container } = render(
|
|
52
|
+
<VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(container.textContent).toContain('Draft')
|
|
56
|
+
expect(container.textContent).not.toContain('initial commit')
|
|
57
|
+
// Header count reflects only the non-init entry, shown as an explicit
|
|
58
|
+
// "N versions" label on the right (no collapse control — Versions has its
|
|
59
|
+
// own tab now).
|
|
60
|
+
expect(screen.getByText('1 version')).toBeTruthy()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('orders rows newest-first by timestamp regardless of input order', () => {
|
|
64
|
+
const versions: VersionEntry[] = [
|
|
65
|
+
{ hash: 'oldest', message: 'version: run 1 — draft', timestamp: '2025-12-01T00:00:00Z' },
|
|
66
|
+
{ hash: 'newest', message: 'version: run 3 — export', timestamp: '2026-01-15T00:00:00Z' },
|
|
67
|
+
{ hash: 'middle', message: 'version: run 2 — final', timestamp: '2026-01-01T00:00:00Z' },
|
|
68
|
+
]
|
|
69
|
+
const { container } = render(
|
|
70
|
+
<VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const names = ['Exported', 'Final', 'Draft']
|
|
74
|
+
const positions = names.map(n => container.textContent!.indexOf(n))
|
|
75
|
+
expect(positions[0]).toBeLessThan(positions[1])
|
|
76
|
+
expect(positions[1]).toBeLessThan(positions[2])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('humanizeLabel', () => {
|
|
80
|
+
it('maps known labels to their humanized form, case-insensitively', () => {
|
|
81
|
+
expect(humanizeLabel('draft')).toBe('Draft')
|
|
82
|
+
expect(humanizeLabel('Draft')).toBe('Draft')
|
|
83
|
+
expect(humanizeLabel('export')).toBe('Exported')
|
|
84
|
+
expect(humanizeLabel('final')).toBe('Final')
|
|
85
|
+
expect(humanizeLabel('pending')).toBe('Pending')
|
|
86
|
+
expect(humanizeLabel('autosave before restore')).toBe('Auto-save before restore')
|
|
87
|
+
expect(humanizeLabel('AUTOSAVE BEFORE RESTORE')).toBe('Auto-save before restore')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('returns "Untitled save" for an empty or whitespace-only label', () => {
|
|
91
|
+
expect(humanizeLabel('')).toBe('Untitled save')
|
|
92
|
+
expect(humanizeLabel(' ')).toBe('Untitled save')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('returns an operator-typed name verbatim', () => {
|
|
96
|
+
expect(humanizeLabel('My Checkpoint')).toBe('My Checkpoint')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('"Restore →" click calls onRestore with the entry\'s hash', () => {
|
|
101
|
+
const versions: VersionEntry[] = [
|
|
102
|
+
{ hash: 'abc123', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
|
|
103
|
+
]
|
|
104
|
+
const onRestore = vi.fn()
|
|
105
|
+
render(<VersionPanel versions={versions} restoring={null} onRestore={onRestore} />)
|
|
106
|
+
|
|
107
|
+
fireEvent.click(screen.getByText('Restore →'))
|
|
108
|
+
expect(onRestore).toHaveBeenCalledWith('abc123')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('Restore and Compare call back with the right hash per row in a multi-row list', () => {
|
|
112
|
+
const versions: VersionEntry[] = [
|
|
113
|
+
{ hash: 'abc', message: 'version: run 2 — export', timestamp: '2026-01-01T12:00:00Z' },
|
|
114
|
+
{ hash: 'def', message: 'version: run 1 — draft', timestamp: '2025-12-31T08:15:00Z' },
|
|
115
|
+
]
|
|
116
|
+
const onRestore = vi.fn()
|
|
117
|
+
const onCompareVersion = vi.fn()
|
|
118
|
+
render(
|
|
119
|
+
<VersionPanel
|
|
120
|
+
versions={versions}
|
|
121
|
+
restoring={null}
|
|
122
|
+
onRestore={onRestore}
|
|
123
|
+
onCompareVersion={onCompareVersion}
|
|
124
|
+
/>,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const restoreButtons = screen.getAllByText('Restore →')
|
|
128
|
+
const compareButtons = screen.getAllByText('Compare')
|
|
129
|
+
expect(restoreButtons).toHaveLength(2)
|
|
130
|
+
expect(compareButtons).toHaveLength(2)
|
|
131
|
+
|
|
132
|
+
// Newest-first: row 0 is 'abc' (2026-01-01), row 1 is 'def' (2025-12-31).
|
|
133
|
+
fireEvent.click(restoreButtons[0])
|
|
134
|
+
expect(onRestore).toHaveBeenCalledWith('abc')
|
|
135
|
+
fireEvent.click(restoreButtons[1])
|
|
136
|
+
expect(onRestore).toHaveBeenCalledWith('def')
|
|
137
|
+
|
|
138
|
+
fireEvent.click(compareButtons[0])
|
|
139
|
+
expect(onCompareVersion).toHaveBeenCalledWith('abc')
|
|
140
|
+
fireEvent.click(compareButtons[1])
|
|
141
|
+
expect(onCompareVersion).toHaveBeenCalledWith('def')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('"Save version" clicked with empty input calls onSaveVersion(undefined)', () => {
|
|
145
|
+
const onSaveVersion = vi.fn()
|
|
146
|
+
render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} onSaveVersion={onSaveVersion} />)
|
|
147
|
+
|
|
148
|
+
fireEvent.click(screen.getByText('Save version'))
|
|
149
|
+
expect(onSaveVersion).toHaveBeenCalledWith(undefined)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('"Save version" clicked with a typed name calls onSaveVersion with the trimmed name', () => {
|
|
153
|
+
const onSaveVersion = vi.fn()
|
|
154
|
+
render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} onSaveVersion={onSaveVersion} />)
|
|
155
|
+
|
|
156
|
+
const input = screen.getByPlaceholderText('Name (optional)')
|
|
157
|
+
fireEvent.change(input, { target: { value: ' My Checkpoint ' } })
|
|
158
|
+
fireEvent.click(screen.getByText('Save version'))
|
|
159
|
+
|
|
160
|
+
expect(onSaveVersion).toHaveBeenCalledWith('My Checkpoint')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('clears the name input after a save click', () => {
|
|
164
|
+
const onSaveVersion = vi.fn()
|
|
165
|
+
render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} onSaveVersion={onSaveVersion} />)
|
|
166
|
+
|
|
167
|
+
const input = screen.getByPlaceholderText('Name (optional)') as HTMLInputElement
|
|
168
|
+
fireEvent.change(input, { target: { value: 'temp name' } })
|
|
169
|
+
expect(input.value).toBe('temp name')
|
|
170
|
+
|
|
171
|
+
fireEvent.click(screen.getByText('Save version'))
|
|
172
|
+
expect(input.value).toBe('')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('disables the save input and button when saving is true', () => {
|
|
176
|
+
render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} onSaveVersion={vi.fn()} saving />)
|
|
177
|
+
|
|
178
|
+
expect(screen.getByPlaceholderText('Name (optional)')).toBeDisabled()
|
|
179
|
+
expect(screen.getByText('Saving…')).toBeDisabled()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Spec note: the Save row is NEVER unmounted — per VersionPanel's own
|
|
183
|
+
// comment it "always visible... Disabled rather than hidden when the host
|
|
184
|
+
// has no onSaveVersion", matching the Restore buttons' degrade-gracefully
|
|
185
|
+
// pattern. This test pins that actual (documented) behavior: the input and
|
|
186
|
+
// button stay in the DOM but disabled, rather than disappearing.
|
|
187
|
+
it('disables (rather than removing) the save input and button when onSaveVersion is undefined', () => {
|
|
188
|
+
render(<VersionPanel versions={[]} restoring={null} onRestore={vi.fn()} />)
|
|
189
|
+
|
|
190
|
+
expect(screen.getByPlaceholderText('Name (optional)')).toBeDisabled()
|
|
191
|
+
expect(screen.getByText('Save version')).toBeDisabled()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('"Compare" button click calls onCompareVersion with the hash', () => {
|
|
195
|
+
const versions: VersionEntry[] = [
|
|
196
|
+
{ hash: 'abc123', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
|
|
197
|
+
]
|
|
198
|
+
const onCompareVersion = vi.fn()
|
|
199
|
+
render(
|
|
200
|
+
<VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} onCompareVersion={onCompareVersion} />,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
fireEvent.click(screen.getByText('Compare'))
|
|
204
|
+
expect(onCompareVersion).toHaveBeenCalledWith('abc123')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('does not render the "Compare" button when onCompareVersion is undefined', () => {
|
|
208
|
+
const versions: VersionEntry[] = [
|
|
209
|
+
{ hash: 'abc123', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
|
|
210
|
+
]
|
|
211
|
+
render(<VersionPanel versions={versions} restoring={null} onRestore={vi.fn()} />)
|
|
212
|
+
|
|
213
|
+
expect(screen.queryByText('Compare')).toBeNull()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("disables only the matching entry's Restore button when restoring", () => {
|
|
217
|
+
const versions: VersionEntry[] = [
|
|
218
|
+
{ hash: 'abc', message: 'version: run 2 — export', timestamp: '2026-01-01T12:00:00Z' },
|
|
219
|
+
{ hash: 'def', message: 'version: run 1 — draft', timestamp: '2025-12-31T08:15:00Z' },
|
|
220
|
+
]
|
|
221
|
+
render(<VersionPanel versions={versions} restoring="abc" onRestore={vi.fn()} />)
|
|
222
|
+
|
|
223
|
+
expect(screen.getByText('Restoring…')).toBeDisabled()
|
|
224
|
+
expect(screen.getByText('Restore →')).not.toBeDisabled()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('listVersions', () => {
|
|
229
|
+
it('filters out run-0 entries, keeps every remaining version flat (no per-run collapsing)', () => {
|
|
230
|
+
const versions: VersionEntry[] = [
|
|
231
|
+
{ hash: 'init', message: 'initial commit', timestamp: '2025-12-30T00:00:00Z' },
|
|
232
|
+
{ hash: 'a', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
|
|
233
|
+
{ hash: 'b', message: 'version: run 1 — final', timestamp: '2026-01-02T00:00:00Z' },
|
|
234
|
+
]
|
|
235
|
+
const result = listVersions(versions)
|
|
236
|
+
expect(result.map(v => v.hash)).toEqual(['b', 'a'])
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('sorts newest-first by timestamp', () => {
|
|
240
|
+
const versions: VersionEntry[] = [
|
|
241
|
+
{ hash: 'old', message: 'version: run 1 — draft', timestamp: '2025-01-01T00:00:00Z' },
|
|
242
|
+
{ hash: 'new', message: 'version: run 2 — export', timestamp: '2026-01-01T00:00:00Z' },
|
|
243
|
+
{ hash: 'mid', message: 'version: run 3 — final', timestamp: '2025-06-01T00:00:00Z' },
|
|
244
|
+
]
|
|
245
|
+
const result = listVersions(versions)
|
|
246
|
+
expect(result.map(v => v.hash)).toEqual(['new', 'mid', 'old'])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('is stable for ties: same-timestamp entries keep their input order', () => {
|
|
250
|
+
const versions: VersionEntry[] = [
|
|
251
|
+
{ hash: 'first', message: 'version: run 1 — draft', timestamp: '2026-01-01T00:00:00Z' },
|
|
252
|
+
{ hash: 'second', message: 'version: run 2 — export', timestamp: '2026-01-01T00:00:00Z' },
|
|
253
|
+
{ hash: 'third', message: 'version: run 3 — final', timestamp: '2026-01-01T00:00:00Z' },
|
|
254
|
+
]
|
|
255
|
+
const result = listVersions(versions)
|
|
256
|
+
expect(result.map(v => v.hash)).toEqual(['first', 'second', 'third'])
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('treats an unparseable timestamp as sorting last, without throwing', () => {
|
|
260
|
+
const versions: VersionEntry[] = [
|
|
261
|
+
{ hash: 'bad', message: 'version: run 1 — draft', timestamp: 'not-a-date' },
|
|
262
|
+
{ hash: 'good', message: 'version: run 2 — export', timestamp: '2026-01-01T00:00:00Z' },
|
|
263
|
+
]
|
|
264
|
+
expect(() => listVersions(versions)).not.toThrow()
|
|
265
|
+
const result = listVersions(versions)
|
|
266
|
+
expect(result.map(v => v.hash)).toEqual(['good', 'bad'])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('does not mutate the input array', () => {
|
|
270
|
+
const versions: VersionEntry[] = [
|
|
271
|
+
{ hash: 'old', message: 'version: run 1 — draft', timestamp: '2025-01-01T00:00:00Z' },
|
|
272
|
+
{ hash: 'new', message: 'version: run 2 — export', timestamp: '2026-01-01T00:00:00Z' },
|
|
273
|
+
]
|
|
274
|
+
const original = [...versions]
|
|
275
|
+
listVersions(versions)
|
|
276
|
+
expect(versions).toEqual(original)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
})
|