@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.
Files changed (266) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +75 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +134 -2
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +586 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +6 -2
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2202 -251
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/keyframeOps.ts +384 -0
  147. package/src/video/keymap.ts +146 -0
  148. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  149. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  150. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  151. package/src/video/panels/TabNav.tsx +60 -0
  152. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  153. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  154. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  155. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  156. package/src/video/preview/CaptionPreview.tsx +57 -69
  157. package/src/video/preview/EngineSurface.tsx +103 -0
  158. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  159. package/src/video/preview/PreviewPlayer.tsx +337 -56
  160. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  161. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  162. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  163. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  164. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  165. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  167. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  168. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  169. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  170. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  171. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  172. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  173. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  174. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  175. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  176. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  177. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  178. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  179. package/src/video/preview/audio-context.ts +111 -0
  180. package/src/video/preview/captionDragState.ts +91 -1
  181. package/src/video/preview/proxySupport.ts +86 -0
  182. package/src/video/preview/transformStyle.ts +22 -12
  183. package/src/video/preview/useDragOverlay.ts +92 -18
  184. package/src/video/preview/useEnginePlayback.ts +625 -0
  185. package/src/video/preview/useVideoPlayback.ts +211 -167
  186. package/src/video/sdrCurves.ts +56 -0
  187. package/src/video/shuttle.ts +159 -0
  188. package/src/video/source-preview.ts +66 -0
  189. package/src/video/timecode.ts +59 -0
  190. package/src/video/timeline/EditableSegment.tsx +1 -1
  191. package/src/video/timeline/Scrubber.tsx +46 -174
  192. package/src/video/timeline/SpeedControl.tsx +95 -0
  193. package/src/video/timeline/Timeline.tsx +1061 -328
  194. package/src/video/timeline/TimelineContext.ts +17 -10
  195. package/src/video/timeline/TrackGutter.tsx +560 -0
  196. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  197. package/src/video/timeline/VolumeControl.tsx +113 -0
  198. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  199. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  200. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  201. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  202. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  204. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  205. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  206. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  208. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  209. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  210. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  211. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  212. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  213. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  214. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  215. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  216. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  217. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  218. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  219. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  220. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  225. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  226. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  227. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  228. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  229. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  230. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  231. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  232. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  233. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  234. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  235. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  236. package/src/video/timeline/canvas/draw.ts +2187 -0
  237. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  238. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  239. package/src/video/timeline/canvas/hit-test.ts +501 -0
  240. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  241. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  242. package/src/video/timeline/canvas/snap.ts +232 -0
  243. package/src/video/timeline/canvas/viewport.ts +457 -0
  244. package/src/video/timeline/canvas/waveforms.ts +664 -0
  245. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  246. package/src/video/timeline/multiSelectOps.ts +214 -55
  247. package/src/video/timeline/placement.ts +282 -0
  248. package/src/video/timeline/timeline-model.ts +919 -0
  249. package/src/video/timeline/useItemDragDrop.ts +151 -178
  250. package/src/video/timeline/useTimelineZoom.ts +25 -60
  251. package/src/video/timeline/utils.ts +0 -12
  252. package/src/video/use-report-context.ts +75 -0
  253. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  254. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  255. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  256. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  257. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  258. package/src/video/timeline/PlayheadLine.tsx +0 -18
  259. package/src/video/timeline/TranscriptModal.tsx +0 -70
  260. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  261. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  262. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  263. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  264. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  265. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  266. 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
+ })