@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,329 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
import { useRef } from 'react'
|
|
3
|
+
import { act, render } from '@testing-library/react'
|
|
4
|
+
import type { EditorProject, VisualItem } from '../../../schema'
|
|
5
|
+
import type { OverlayFactory } from '../../../types'
|
|
6
|
+
import { useDragOverlay, type OverlayChanges } from '../useDragOverlay'
|
|
7
|
+
import OverlayItemsLayer from '../OverlayItemsLayer'
|
|
8
|
+
|
|
9
|
+
// The selected-overlay treatment (Part C).
|
|
10
|
+
//
|
|
11
|
+
// A selected OVERLAY and a selected base CLIP must look like the same object:
|
|
12
|
+
// one 2px `--editor-selection` outline plus white square handles. The clip's
|
|
13
|
+
// box is PreviewPlayer.tsx (~:526-559) and it was already there; this file
|
|
14
|
+
// pins the overlay onto the same language, and pins the thing that treatment
|
|
15
|
+
// exists to expose — EIGHT handles, four of which resize a SINGLE axis.
|
|
16
|
+
//
|
|
17
|
+
// Two of these describes drive real gestures rather than inspecting markup,
|
|
18
|
+
// because the wiring is what can silently break: `useDragOverlay` has known
|
|
19
|
+
// per-axis math (useDragOverlay.perAxis.test.ts proves it in isolation), so
|
|
20
|
+
// the only thing left to get wrong is which drag type each handle sends. The
|
|
21
|
+
// gestures dispatch native MouseEvents with explicit coordinates — the same
|
|
22
|
+
// approach useDragOverlay.perAxis.test.ts takes, and for the same reason: the
|
|
23
|
+
// hook listens on `document`, and fireEvent's synthetic path is not a reliable
|
|
24
|
+
// carrier for clientX/clientY in this setup. A gesture test that asserts
|
|
25
|
+
// nothing is worse than no gesture test at all.
|
|
26
|
+
|
|
27
|
+
const _emptySnap = { x: false, y: false, left: false, right: false, top: false, bottom: false }
|
|
28
|
+
|
|
29
|
+
// 100×100 makes one pixel of mouse travel exactly 1%, so every delta below
|
|
30
|
+
// reads directly as a percentage of scale.
|
|
31
|
+
const RECT = { width: 100, height: 100, left: 0, top: 0, right: 100, bottom: 100, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
|
|
32
|
+
|
|
33
|
+
function makeProject(): EditorProject {
|
|
34
|
+
return {
|
|
35
|
+
id: 'p',
|
|
36
|
+
status: 'draft',
|
|
37
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
38
|
+
tracks: [[]],
|
|
39
|
+
} as unknown as EditorProject
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The layer wired to the REAL drag hook, so a mousedown on a handle travels the
|
|
44
|
+
* whole path a user's does: handle → setDragState → document listeners →
|
|
45
|
+
* onOverlayChange. Passing a stubbed hook here would let a mis-wired handle
|
|
46
|
+
* pass.
|
|
47
|
+
*/
|
|
48
|
+
function Harness({ item, selected = true, onTrack0 = false, onOverlayChange }: {
|
|
49
|
+
item: VisualItem
|
|
50
|
+
selected?: boolean
|
|
51
|
+
/** Route the item through the tracks[0] background-image branch instead. */
|
|
52
|
+
onTrack0?: boolean
|
|
53
|
+
onOverlayChange?: (id: string, changes: OverlayChanges) => void
|
|
54
|
+
}) {
|
|
55
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
56
|
+
const drag = useDragOverlay(containerRef, onOverlayChange)
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
ref={(el) => {
|
|
60
|
+
// jsdom lays nothing out, so the hook's percentage math would divide by
|
|
61
|
+
// a zero-width rect without this.
|
|
62
|
+
if (el) el.getBoundingClientRect = () => RECT
|
|
63
|
+
containerRef.current = el
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<OverlayItemsLayer
|
|
67
|
+
project={makeProject()}
|
|
68
|
+
currentTime={1}
|
|
69
|
+
isPlaying={false}
|
|
70
|
+
isCanvasProject={false}
|
|
71
|
+
overlayTracks={onTrack0 ? [] : [[item]]}
|
|
72
|
+
tracks0NonVideo={onTrack0 ? [item] : []}
|
|
73
|
+
renderScale={0.2}
|
|
74
|
+
selectedOverlayId={selected ? item.id : undefined}
|
|
75
|
+
containerRef={containerRef}
|
|
76
|
+
dragState={drag.dragState}
|
|
77
|
+
setDragState={drag.setDragState}
|
|
78
|
+
liveOffset={drag.liveOffset}
|
|
79
|
+
liveScale={drag.liveScale}
|
|
80
|
+
liveRotation={drag.liveRotation}
|
|
81
|
+
snapGuides={drag.snapGuides}
|
|
82
|
+
snapRotation={drag.snapRotation}
|
|
83
|
+
onOverlayChange={onOverlayChange}
|
|
84
|
+
compileOverlay={vi.fn(async (): Promise<OverlayFactory> => () => null)}
|
|
85
|
+
fileUrl={(pth: string) => pth}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const overlay = (over: Partial<VisualItem> = {}): VisualItem => ({
|
|
92
|
+
id: 'sel', type: 'overlay', src: 'o.jsx', start: 0, end: 10, props: {}, ...over,
|
|
93
|
+
} as VisualItem)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The item's wrapper. `container` is RTL's own div, whose only child is the
|
|
97
|
+
* harness's container div; the wrapper is that div's first child.
|
|
98
|
+
*/
|
|
99
|
+
function wrapperOf(container: HTMLElement): HTMLElement {
|
|
100
|
+
const harness = container.firstElementChild as HTMLElement
|
|
101
|
+
return harness.firstElementChild as HTMLElement
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
106
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
107
|
+
})
|
|
108
|
+
afterEach(() => vi.restoreAllMocks())
|
|
109
|
+
|
|
110
|
+
describe('OverlayItemsLayer — selection box', () => {
|
|
111
|
+
it('draws a 2px --editor-selection outline on the selected item', () => {
|
|
112
|
+
const { container } = render(<Harness item={overlay()} />)
|
|
113
|
+
expect(wrapperOf(container).style.outline).toBe('2px solid var(--editor-selection)')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('draws NO outline when the item is not selected', () => {
|
|
117
|
+
const { container } = render(<Harness item={overlay()} selected={false} />)
|
|
118
|
+
expect(wrapperOf(container).style.outline).toBe('')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('renders EIGHT resize handles — four corners and four edge midpoints', () => {
|
|
122
|
+
const { container } = render(<Harness item={overlay()} />)
|
|
123
|
+
const handles = Array.from(container.querySelectorAll('[data-handle]'))
|
|
124
|
+
expect(handles).toHaveLength(8)
|
|
125
|
+
expect(handles.map(h => h.getAttribute('data-handle')).sort())
|
|
126
|
+
.toEqual(['e', 'n', 'ne', 'nw', 's', 'se', 'sw', 'w'])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('gives each handle the cursor its axis implies', () => {
|
|
130
|
+
const { container } = render(<Harness item={overlay()} />)
|
|
131
|
+
const cursorOf = (h: string) =>
|
|
132
|
+
(container.querySelector(`[data-handle="${h}"]`) as HTMLElement).className
|
|
133
|
+
expect(cursorOf('n')).toContain('cursor-ns-resize')
|
|
134
|
+
expect(cursorOf('s')).toContain('cursor-ns-resize')
|
|
135
|
+
expect(cursorOf('e')).toContain('cursor-ew-resize')
|
|
136
|
+
expect(cursorOf('w')).toContain('cursor-ew-resize')
|
|
137
|
+
// Corners keep their diagonal cursors.
|
|
138
|
+
expect(cursorOf('nw')).toContain('cursor-nw-resize')
|
|
139
|
+
expect(cursorOf('se')).toContain('cursor-se-resize')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('handles are white squares bordered in the selection token, not amber', () => {
|
|
143
|
+
const { container } = render(<Harness item={overlay()} />)
|
|
144
|
+
for (const h of Array.from(container.querySelectorAll('[data-handle]'))) {
|
|
145
|
+
const el = h as HTMLElement
|
|
146
|
+
expect(el.style.backgroundColor).toBe('rgb(255, 255, 255)')
|
|
147
|
+
expect(el.style.border).toBe('1.5px solid var(--editor-selection)')
|
|
148
|
+
expect(el.style.width).toBe('12px')
|
|
149
|
+
expect(el.style.height).toBe('12px')
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('leaves NO amber anywhere in the selected-overlay subtree', () => {
|
|
154
|
+
// Covers the box, all eight handles, the rotate handle and the fit control
|
|
155
|
+
// in one sweep — an image item so the fit control is on screen too.
|
|
156
|
+
const item = { id: 'sel-img', type: 'image', src: 'i.png', start: 0, end: 10 } as VisualItem
|
|
157
|
+
const { container } = render(<Harness item={item} onOverlayChange={vi.fn()} />)
|
|
158
|
+
|
|
159
|
+
expect(container.querySelectorAll('[class*="amber"]')).toHaveLength(0)
|
|
160
|
+
expect(container.innerHTML).not.toContain('amber')
|
|
161
|
+
// …and the amber literal the rotation guide used to hardcode.
|
|
162
|
+
expect(container.innerHTML).not.toContain('251 191 36')
|
|
163
|
+
// Positive control: the treatment really is drawn, so the assertions above
|
|
164
|
+
// cannot pass simply because nothing rendered.
|
|
165
|
+
expect(container.querySelectorAll('[data-handle]')).toHaveLength(8)
|
|
166
|
+
expect(container.innerHTML).toContain('var(--editor-selection)')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('gives a tracks[0] background image the SAME treatment', () => {
|
|
170
|
+
// That branch draws its own selection box and handles, separately from the
|
|
171
|
+
// interactive-tracks branch every other test here exercises — so it can
|
|
172
|
+
// drift back to amber on its own.
|
|
173
|
+
const item = { id: 'bg', type: 'image', src: 'bg.png', start: 0, end: 10 } as VisualItem
|
|
174
|
+
const { container } = render(<Harness item={item} onTrack0 onOverlayChange={vi.fn()} />)
|
|
175
|
+
|
|
176
|
+
expect(wrapperOf(container).style.outline).toBe('2px solid var(--editor-selection)')
|
|
177
|
+
expect(container.querySelectorAll('[data-handle]')).toHaveLength(8)
|
|
178
|
+
expect(container.querySelectorAll('[class*="amber"]')).toHaveLength(0)
|
|
179
|
+
expect(container.innerHTML).not.toContain('amber')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('a tracks[0] edge handle also drives a single axis', () => {
|
|
183
|
+
const onOverlayChange = vi.fn()
|
|
184
|
+
const item = { id: 'bg', type: 'image', src: 'bg.png', start: 0, end: 10, scaleX: 2, scaleY: 0.5 } as VisualItem
|
|
185
|
+
const { container } = render(<Harness item={item} onTrack0 onOverlayChange={onOverlayChange} />)
|
|
186
|
+
|
|
187
|
+
dragHandle(container, 'e', { x: 10, y: 40 })
|
|
188
|
+
|
|
189
|
+
const changes = onOverlayChange.mock.calls[0][1] as OverlayChanges
|
|
190
|
+
expect(changes.scaleX).toBeCloseTo(2.2)
|
|
191
|
+
expect(changes.scaleY).toBe(0.5)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('counter-scales every handle per axis so it stays SQUARE under scaleX ≠ scaleY', () => {
|
|
195
|
+
const { container } = render(<Harness item={overlay({ scaleX: 4, scaleY: 0.5 } as Partial<VisualItem>)} />)
|
|
196
|
+
for (const h of Array.from(container.querySelectorAll('[data-handle]'))) {
|
|
197
|
+
// 1/4 on X and 1/0.5 on Y. Counter-scaling one axis only — the bug this
|
|
198
|
+
// guards — would read `scale(0.25, 0.25)` and render a 12×96 sliver.
|
|
199
|
+
expect((h as HTMLElement).style.transform).toBe('scale(0.25, 2) translate(-50%, -50%)')
|
|
200
|
+
}
|
|
201
|
+
const rotate = container.querySelector('.cursor-grab.flex-col') as HTMLElement
|
|
202
|
+
expect(rotate.style.transform).toBe('translateX(-50%) translateY(-100%) scale(0.25, 2)')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Gestures. `initX`/`initY` come from the mousedown, so every drag below starts
|
|
208
|
+
// at (0,0) and the mousemove coordinate IS the delta in percent.
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/** Full gesture on one handle: mousedown → mousemove → mouseup. */
|
|
212
|
+
function dragHandle(container: HTMLElement, handle: string, to: { x: number; y: number }) {
|
|
213
|
+
const el = container.querySelector(`[data-handle="${handle}"]`) as HTMLElement
|
|
214
|
+
expect(el).toBeTruthy()
|
|
215
|
+
act(() => {
|
|
216
|
+
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 0, clientY: 0 }))
|
|
217
|
+
})
|
|
218
|
+
act(() => {
|
|
219
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: to.x, clientY: to.y }))
|
|
220
|
+
})
|
|
221
|
+
const midDrag = wrapperOf(container).style.transform
|
|
222
|
+
act(() => { document.dispatchEvent(new MouseEvent('mouseup')) })
|
|
223
|
+
return { midDrag }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
describe('OverlayItemsLayer — edge handles drive a SINGLE axis', () => {
|
|
227
|
+
// Per-axis from the start, so the commit writes both fields and each one's
|
|
228
|
+
// value is directly checkable (a legacy uniform item would commit `scale`
|
|
229
|
+
// alone on a corner drag — covered separately below).
|
|
230
|
+
const perAxis = () => overlay({ scaleX: 2, scaleY: 0.5 } as Partial<VisualItem>)
|
|
231
|
+
|
|
232
|
+
it('`e` writes scaleX and leaves scaleY at its starting value', () => {
|
|
233
|
+
const onOverlayChange = vi.fn()
|
|
234
|
+
const { container } = render(<Harness item={perAxis()} onOverlayChange={onOverlayChange} />)
|
|
235
|
+
|
|
236
|
+
// 10% right AND 40% down: the vertical travel must be ignored entirely.
|
|
237
|
+
const { midDrag } = dragHandle(container, 'e', { x: 10, y: 40 })
|
|
238
|
+
|
|
239
|
+
const changes = onOverlayChange.mock.calls[0][1] as OverlayChanges
|
|
240
|
+
expect(changes.scaleX).toBeCloseTo(2.2) // 2 × (1 + 0.10)
|
|
241
|
+
expect(changes.scaleY).toBe(0.5) // untouched
|
|
242
|
+
expect(changes.scale).toBe(1) // the uniform knob is corner-only
|
|
243
|
+
// The box the user is watching moved on X only.
|
|
244
|
+
expect(midDrag).toContain('scale(2.2, 0.5)')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('`w` writes scaleX in the opposite direction, still leaving scaleY alone', () => {
|
|
248
|
+
const onOverlayChange = vi.fn()
|
|
249
|
+
const { container } = render(<Harness item={perAxis()} onOverlayChange={onOverlayChange} />)
|
|
250
|
+
|
|
251
|
+
dragHandle(container, 'w', { x: 10, y: 0 })
|
|
252
|
+
|
|
253
|
+
const changes = onOverlayChange.mock.calls[0][1] as OverlayChanges
|
|
254
|
+
expect(changes.scaleX).toBeCloseTo(1.8) // 2 × (1 − 0.10)
|
|
255
|
+
expect(changes.scaleY).toBe(0.5)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('`s` writes scaleY and leaves scaleX at its starting value', () => {
|
|
259
|
+
const onOverlayChange = vi.fn()
|
|
260
|
+
const { container } = render(<Harness item={perAxis()} onOverlayChange={onOverlayChange} />)
|
|
261
|
+
|
|
262
|
+
// 40% right AND 10% down: the horizontal travel must be ignored entirely.
|
|
263
|
+
const { midDrag } = dragHandle(container, 's', { x: 40, y: 10 })
|
|
264
|
+
|
|
265
|
+
const changes = onOverlayChange.mock.calls[0][1] as OverlayChanges
|
|
266
|
+
expect(changes.scaleY).toBeCloseTo(0.55) // 0.5 × (1 + 0.10)
|
|
267
|
+
expect(changes.scaleX).toBe(2) // untouched
|
|
268
|
+
expect(midDrag).toContain('scale(2, 0.55)')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('`n` writes scaleY in the opposite direction, still leaving scaleX alone', () => {
|
|
272
|
+
const onOverlayChange = vi.fn()
|
|
273
|
+
const { container } = render(<Harness item={perAxis()} onOverlayChange={onOverlayChange} />)
|
|
274
|
+
|
|
275
|
+
dragHandle(container, 'n', { x: 0, y: 10 })
|
|
276
|
+
|
|
277
|
+
const changes = onOverlayChange.mock.calls[0][1] as OverlayChanges
|
|
278
|
+
expect(changes.scaleY).toBeCloseTo(0.45) // 0.5 × (1 − 0.10)
|
|
279
|
+
expect(changes.scaleX).toBe(2)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('OverlayItemsLayer — corner handles scale BOTH axes', () => {
|
|
284
|
+
it('`se` moves scaleX and scaleY together, preserving aspect ratio', () => {
|
|
285
|
+
const onOverlayChange = vi.fn()
|
|
286
|
+
const { container } = render(
|
|
287
|
+
<Harness item={overlay({ scaleX: 2, scaleY: 0.5 } as Partial<VisualItem>)} onOverlayChange={onOverlayChange} />,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
const { midDrag } = dragHandle(container, 'se', { x: 10, y: 10 })
|
|
291
|
+
|
|
292
|
+
const changes = onOverlayChange.mock.calls[0][1] as OverlayChanges
|
|
293
|
+
// One shared delta of (10 + 10)/100 = 0.2 applied to both axes.
|
|
294
|
+
expect(changes.scaleX).toBeCloseTo(2.4)
|
|
295
|
+
expect(changes.scaleY).toBeCloseTo(0.6)
|
|
296
|
+
expect(changes.scaleX! / changes.scaleY!).toBeCloseTo(4)
|
|
297
|
+
expect(midDrag).toContain('scale(2.4, 0.6)')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('a corner drag of a LEGACY uniform item still commits `scale` alone', () => {
|
|
301
|
+
// The asymmetry is deliberate (see useDragOverlay `onUp`): the new edge
|
|
302
|
+
// handles must not drag legacy items into per-axis storage behind a corner
|
|
303
|
+
// gesture that never asked for it.
|
|
304
|
+
const onOverlayChange = vi.fn()
|
|
305
|
+
const { container } = render(
|
|
306
|
+
<Harness item={overlay({ scale: 1 } as Partial<VisualItem>)} onOverlayChange={onOverlayChange} />,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
dragHandle(container, 'nw', { x: -10, y: -10 })
|
|
310
|
+
|
|
311
|
+
const changes = onOverlayChange.mock.calls[0][1] as OverlayChanges
|
|
312
|
+
expect(Object.keys(changes).sort()).toEqual(['scale'])
|
|
313
|
+
expect(changes.scale).toBeCloseTo(1.2)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('an EDGE drag of a legacy uniform item commits both axes', () => {
|
|
317
|
+
const onOverlayChange = vi.fn()
|
|
318
|
+
const { container } = render(
|
|
319
|
+
<Harness item={overlay({ scale: 1 } as Partial<VisualItem>)} onOverlayChange={onOverlayChange} />,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
dragHandle(container, 'e', { x: 10, y: 0 })
|
|
323
|
+
|
|
324
|
+
const changes = onOverlayChange.mock.calls[0][1] as OverlayChanges
|
|
325
|
+
expect(Object.keys(changes).sort()).toEqual(['scale', 'scaleX', 'scaleY'])
|
|
326
|
+
expect(changes.scaleX).toBeCloseTo(1.1)
|
|
327
|
+
expect(changes.scaleY).toBe(1)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SP4 T6 — `PreviewPlayer`'s flag/eligibility branch.
|
|
3
|
+
*
|
|
4
|
+
* Three properties, in the order the plan states them:
|
|
5
|
+
* 1. flag OFF is not merely "eligible-but-unused" — the probe never runs and
|
|
6
|
+
* the two `<video>` slots render exactly as they always have;
|
|
7
|
+
* 2. flag ON + ineligible → the legacy path plus ONE console line naming the
|
|
8
|
+
* reason (never a black frame, never a half-engine);
|
|
9
|
+
* 3. flag ON + eligible → the canvas replaces the slots INSIDE the unchanged
|
|
10
|
+
* transform container, with the root's `isolation: isolate` and the
|
|
11
|
+
* handles/drag layer untouched.
|
|
12
|
+
*
|
|
13
|
+
* The engine module is mocked for the same reason as in
|
|
14
|
+
* `useEnginePlayback.test.tsx`: jsdom has no WebCodecs, and what is under test
|
|
15
|
+
* here is which surface mounts, not what it paints.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
18
|
+
import { render, waitFor } from '@testing-library/react'
|
|
19
|
+
import PreviewPlayer from '../PreviewPlayer'
|
|
20
|
+
import { createPlaybackClock } from '../../playback-clock'
|
|
21
|
+
import { __setEngineCapabilityForTests } from '../../../engine/eligibility'
|
|
22
|
+
import type { EditorProject as Project } from '../../../schema'
|
|
23
|
+
|
|
24
|
+
vi.mock('../../../engine', async (importOriginal) => {
|
|
25
|
+
const actual = await importOriginal<typeof import('../../../engine')>()
|
|
26
|
+
return {
|
|
27
|
+
...actual,
|
|
28
|
+
createEngine: () => ({
|
|
29
|
+
attach() {},
|
|
30
|
+
play() {},
|
|
31
|
+
pause() {},
|
|
32
|
+
seek() {},
|
|
33
|
+
updateProject() {},
|
|
34
|
+
status: () => ({ transport: 'paused', picture: 'black', clipId: null, seeking: false, clock: 'fallback' }),
|
|
35
|
+
clock: { now: () => 0, playing: false, kind: 'fallback' },
|
|
36
|
+
stats: () => ({ fps: 0, dropped: 0, buffered: 0, clock: 'fallback' }),
|
|
37
|
+
dispose() {},
|
|
38
|
+
}),
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
function makeProject(clipOverrides: Record<string, unknown> = {}): Project {
|
|
43
|
+
return {
|
|
44
|
+
id: 'p-engine',
|
|
45
|
+
status: 'draft',
|
|
46
|
+
settings: { resolution: [1080, 1920], fps: 30 },
|
|
47
|
+
tracks: [[{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 4, ...clipOverrides }]],
|
|
48
|
+
} as unknown as Project
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderPreview(project: Project, engine?: { enabled: boolean }) {
|
|
52
|
+
return render(
|
|
53
|
+
<PreviewPlayer
|
|
54
|
+
project={project}
|
|
55
|
+
clock={createPlaybackClock(0)}
|
|
56
|
+
compileOverlay={async () => (() => null) as never}
|
|
57
|
+
fileUrl={(p) => p}
|
|
58
|
+
engine={engine}
|
|
59
|
+
/>,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
65
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
|
66
|
+
observe() {}
|
|
67
|
+
unobserve() {}
|
|
68
|
+
disconnect() {}
|
|
69
|
+
}
|
|
70
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.play = vi.fn(async () => {}) as never
|
|
71
|
+
;(globalThis as unknown as { HTMLMediaElement: { prototype: HTMLMediaElement } }).HTMLMediaElement.prototype.pause = vi.fn(() => {}) as never
|
|
72
|
+
;(globalThis as unknown as { AudioContext: unknown }).AudioContext = class {
|
|
73
|
+
state = 'running'
|
|
74
|
+
createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} } }
|
|
75
|
+
createMediaElementSource() { return { connect() {}, disconnect() {} } }
|
|
76
|
+
get destination() { return {} }
|
|
77
|
+
close() {}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.restoreAllMocks()
|
|
83
|
+
__setEngineCapabilityForTests(null)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('PreviewPlayer engine branch', () => {
|
|
87
|
+
it('flag absent → legacy slots, no probe, no console line', () => {
|
|
88
|
+
const { container } = renderPreview(makeProject({ proxySrc: 'a_proxy.mp4' }))
|
|
89
|
+
expect(container.querySelectorAll('video')).toHaveLength(2)
|
|
90
|
+
expect(container.querySelector('canvas')).toBeNull()
|
|
91
|
+
expect(console.info).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('flag on, project ineligible → legacy slots and exactly one console line with the reason', async () => {
|
|
95
|
+
__setEngineCapabilityForTests(true)
|
|
96
|
+
// No proxySrc on the only track-0 video item.
|
|
97
|
+
const { container } = renderPreview(makeProject(), { enabled: true })
|
|
98
|
+
|
|
99
|
+
await waitFor(() => expect(container.querySelectorAll('video')).toHaveLength(2))
|
|
100
|
+
expect(container.querySelector('canvas')).toBeNull()
|
|
101
|
+
expect(console.info).toHaveBeenCalledTimes(1)
|
|
102
|
+
expect(vi.mocked(console.info).mock.calls[0][0]).toContain('no proxySrc yet')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('flag on, browser incapable → legacy slots and the capability reason', async () => {
|
|
106
|
+
__setEngineCapabilityForTests(false)
|
|
107
|
+
const { container } = renderPreview(makeProject({ proxySrc: 'a_proxy.mp4' }), { enabled: true })
|
|
108
|
+
|
|
109
|
+
await waitFor(() => expect(container.querySelectorAll('video')).toHaveLength(2))
|
|
110
|
+
expect(console.info).toHaveBeenCalledTimes(1)
|
|
111
|
+
expect(vi.mocked(console.info).mock.calls[0][0]).toContain('WebCodecs')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('flag on, project eligible → canvas inside the untouched transform container', async () => {
|
|
115
|
+
__setEngineCapabilityForTests(true)
|
|
116
|
+
const { container } = renderPreview(makeProject({ proxySrc: 'a_proxy.mp4' }), { enabled: true })
|
|
117
|
+
|
|
118
|
+
const canvas = await waitFor(() => {
|
|
119
|
+
const c = container.querySelector('canvas')
|
|
120
|
+
expect(c).not.toBeNull()
|
|
121
|
+
return c!
|
|
122
|
+
})
|
|
123
|
+
expect(container.querySelectorAll('video')).toHaveLength(0)
|
|
124
|
+
expect(console.info).not.toHaveBeenCalled()
|
|
125
|
+
|
|
126
|
+
// The canvas sits where the active slot sat: z-index 1, inside the
|
|
127
|
+
// transform container, which is itself inside the isolate root.
|
|
128
|
+
expect(canvas.style.zIndex).toBe('1')
|
|
129
|
+
const transformContainer = canvas.parentElement!
|
|
130
|
+
expect(transformContainer.className).toBe('absolute inset-0')
|
|
131
|
+
const root = transformContainer.parentElement!
|
|
132
|
+
expect(root.style.isolation).toBe('isolate')
|
|
133
|
+
|
|
134
|
+
// The play-toggle (z 10) and the paused play-button (z 100) still render
|
|
135
|
+
// over it, unchanged.
|
|
136
|
+
expect(root.querySelectorAll('div.absolute.inset-0.cursor-pointer')).toHaveLength(1)
|
|
137
|
+
expect(root.querySelectorAll('svg')).toHaveLength(1)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* SocialPreviewMenu — the "Preview for social media" platform picker. These
|
|
4
|
+
* tests cover the picker's own contract: it lists all four entries (TikTok /
|
|
5
|
+
* YouTube Shorts / Instagram Reels / None), calls `onChange` with the right
|
|
6
|
+
* platform (or `null` for None) and closes itself, and marks the active
|
|
7
|
+
* entry with a checkmark via `aria-checked` — the same `role="menuitemradio"`
|
|
8
|
+
* shape `ImageToneMenu.tsx` uses for its own list of exclusive options.
|
|
9
|
+
*
|
|
10
|
+
* The popover portals to `document.body` (see the component's doc comment),
|
|
11
|
+
* so these tests query `document.body` directly rather than a render
|
|
12
|
+
* container. jsdom does no layout, so `getBoundingClientRect()` on the
|
|
13
|
+
* anchor/popover both resolve to all-zero rects — the component still
|
|
14
|
+
* measures and sets a position from those, it's just not a meaningful one;
|
|
15
|
+
* none of these tests assert on the numeric position.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
18
|
+
import { render, cleanup, fireEvent, screen } from '@testing-library/react'
|
|
19
|
+
import { createRef } from 'react'
|
|
20
|
+
import SocialPreviewMenu from '../SocialPreviewMenu'
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
cleanup()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function Harness({ value, onChange, onClose }: {
|
|
27
|
+
value: 'tiktok' | 'youtube' | 'instagram' | null
|
|
28
|
+
onChange: (p: 'tiktok' | 'youtube' | 'instagram' | null) => void
|
|
29
|
+
onClose: () => void
|
|
30
|
+
}) {
|
|
31
|
+
const anchorRef = createRef<HTMLButtonElement>()
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<button ref={anchorRef}>trigger</button>
|
|
35
|
+
<SocialPreviewMenu anchorRef={anchorRef} value={value} onChange={onChange} onClose={onClose} />
|
|
36
|
+
</>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('SocialPreviewMenu — lists every entry', () => {
|
|
41
|
+
it('shows TikTok, YouTube Shorts, Instagram Reels and None', () => {
|
|
42
|
+
render(<Harness value={null} onChange={vi.fn()} onClose={vi.fn()} />)
|
|
43
|
+
expect(screen.getByLabelText('TikTok')).toBeTruthy()
|
|
44
|
+
expect(screen.getByLabelText('YouTube Shorts')).toBeTruthy()
|
|
45
|
+
expect(screen.getByLabelText('Instagram Reels')).toBeTruthy()
|
|
46
|
+
expect(screen.getByLabelText('None')).toBeTruthy()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('shows the "Preview for social media" title and its caption', () => {
|
|
50
|
+
render(<Harness value={null} onChange={vi.fn()} onClose={vi.fn()} />)
|
|
51
|
+
expect(screen.getByText('Preview for social media')).toBeTruthy()
|
|
52
|
+
expect(screen.getByText('What you see may vary depending on your device.')).toBeTruthy()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('gives every row (including None) a decorative, aria-hidden platform glyph', () => {
|
|
56
|
+
render(<Harness value={null} onChange={vi.fn()} onClose={vi.fn()} />)
|
|
57
|
+
for (const label of ['TikTok', 'YouTube Shorts', 'Instagram Reels', 'None']) {
|
|
58
|
+
const row = screen.getByLabelText(label)
|
|
59
|
+
// The glyph is decorative — the row's accessible name stays the plain
|
|
60
|
+
// platform label (asserted above), never the icon.
|
|
61
|
+
expect(row.querySelector('[aria-hidden="true"]')).toBeTruthy()
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('SocialPreviewMenu — selection', () => {
|
|
67
|
+
it('clicking a platform calls onChange with that platform and closes the menu', () => {
|
|
68
|
+
const onChange = vi.fn()
|
|
69
|
+
const onClose = vi.fn()
|
|
70
|
+
render(<Harness value={null} onChange={onChange} onClose={onClose} />)
|
|
71
|
+
|
|
72
|
+
fireEvent.click(screen.getByLabelText('YouTube Shorts'))
|
|
73
|
+
expect(onChange).toHaveBeenCalledWith('youtube')
|
|
74
|
+
expect(onClose).toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('clicking None calls onChange with null', () => {
|
|
78
|
+
const onChange = vi.fn()
|
|
79
|
+
render(<Harness value="tiktok" onChange={onChange} onClose={vi.fn()} />)
|
|
80
|
+
|
|
81
|
+
fireEvent.click(screen.getByLabelText('None'))
|
|
82
|
+
expect(onChange).toHaveBeenCalledWith(null)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('marks the active entry aria-checked, and only that one', () => {
|
|
86
|
+
render(<Harness value="instagram" onChange={vi.fn()} onClose={vi.fn()} />)
|
|
87
|
+
|
|
88
|
+
expect(screen.getByLabelText('Instagram Reels').getAttribute('aria-checked')).toBe('true')
|
|
89
|
+
expect(screen.getByLabelText('TikTok').getAttribute('aria-checked')).toBe('false')
|
|
90
|
+
expect(screen.getByLabelText('YouTube Shorts').getAttribute('aria-checked')).toBe('false')
|
|
91
|
+
expect(screen.getByLabelText('None').getAttribute('aria-checked')).toBe('false')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('marks None active when value is null', () => {
|
|
95
|
+
render(<Harness value={null} onChange={vi.fn()} onClose={vi.fn()} />)
|
|
96
|
+
expect(screen.getByLabelText('None').getAttribute('aria-checked')).toBe('true')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('SocialPreviewMenu — dismissal', () => {
|
|
101
|
+
it('closes on Escape', () => {
|
|
102
|
+
const onClose = vi.fn()
|
|
103
|
+
render(<Harness value={null} onChange={vi.fn()} onClose={onClose} />)
|
|
104
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
105
|
+
expect(onClose).toHaveBeenCalled()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('closes on an outside click', () => {
|
|
109
|
+
const onClose = vi.fn()
|
|
110
|
+
render(<Harness value={null} onChange={vi.fn()} onClose={onClose} />)
|
|
111
|
+
fireEvent.mouseDown(document.body)
|
|
112
|
+
expect(onClose).toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('does not close on a click on the trigger itself (the trigger owns its own toggle)', () => {
|
|
116
|
+
const onClose = vi.fn()
|
|
117
|
+
render(<Harness value={null} onChange={vi.fn()} onClose={onClose} />)
|
|
118
|
+
fireEvent.mouseDown(screen.getByText('trigger'))
|
|
119
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
})
|