@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,414 @@
1
+ /**
2
+ * Canvas-timeline test helpers — select a clip, drag a clip, focus the surface.
3
+ *
4
+ * ── Why this exists ──────────────────────────────────────────────────────
5
+ * The DOM timeline gave every clip an element, so a test could say
6
+ * `fireEvent.click(await screen.findByText('▪ video'))` and be done. The canvas
7
+ * has ONE surface and no per-clip nodes: the address of a clip is a
8
+ * (clientX, clientY) pair, and computing it means composing the same three
9
+ * things the production code composes — `computeTimelineLayout` for the row's
10
+ * `y`, `timeToX` through a `Viewport` for the item's `x`, and the surface's own
11
+ * offset from the page.
12
+ *
13
+ * Four test files already do that by hand (Timeline.backgroundClick /
14
+ * .fadeCurveMenu / .crossfade / .keyframeMenu). This is that work factored out
15
+ * so the DOM-era tests can migrate mechanically rather than each re-deriving
16
+ * the math.
17
+ *
18
+ * ── The two pinned constants, and nothing else ───────────────────────────
19
+ * jsdom lays every element out at 0×0, so a `clientX → item` hit-test would
20
+ * resolve nothing at all. `installCanvasHarness` pins the canvas surface to a
21
+ * real rect — `SURFACE_LEFT` from the left of the page, `SURFACE_WIDTH` wide —
22
+ * and that rect is the ONLY hardcoded geometry here. Everything else (px per
23
+ * second, row `y`, item `x`) is read out of the production math, so a change to
24
+ * a row height or the fit rule moves these helpers with it instead of
25
+ * silently mis-aiming.
26
+ *
27
+ * The scale therefore follows from the project: the surface fits the whole
28
+ * timeline on mount (`withSurfaceWidth` → `fitViewport`), and "the whole
29
+ * timeline" is `computeDerivedTiming(project).totalDuration` — content duration
30
+ * PLUS the drag headroom (`+ max(5, content * 0.2)`), not the content duration.
31
+ * A 4s project therefore fits 9s across 1000px, not 4s. Never assume a round
32
+ * px/second; call `canvasViewport`.
33
+ *
34
+ * ── Not a test file ──────────────────────────────────────────────────────
35
+ * Vitest collects `src/**\/*.{test,spec}.{ts,tsx}` (vitest.config.ts), so the
36
+ * leading underscore is cosmetic — what keeps this out of the suite is that it
37
+ * is neither `.test.` nor `.spec.`. Its own coverage lives in
38
+ * `_canvasSelect.test.tsx`.
39
+ */
40
+
41
+ import { act, fireEvent } from '@testing-library/react'
42
+ import type { Project } from '../../../types'
43
+ import { computeTimelineLayout } from '../canvas/draw'
44
+ import { AUDIO_EDGE_TOLERANCE_PX, PLAYHEAD_GRAB_PX, VISUAL_EDGE_TOLERANCE_PX } from '../canvas/hit-test'
45
+ import { fitPxPerSecond, timeToX, type Viewport } from '../canvas/viewport'
46
+ import { computeDerivedTiming } from '../timeline-model'
47
+ import { DRAG_THRESHOLD_PX } from '../useItemDragDrop'
48
+
49
+ /** How far the canvas surface sits from the left of the page. Non-zero on
50
+ * purpose: the real surface is inset by the track rail, and a helper that
51
+ * pinned it at 0 would let an off-by-the-rail bug pass. */
52
+ export const SURFACE_LEFT = 100
53
+
54
+ /** The surface's width, and therefore the denominator of the fit zoom. */
55
+ export const SURFACE_WIDTH = 1000
56
+
57
+ /** The surface's height. Only `observeSurface` reads it (for the backing
58
+ * store); no hit-test consults it, so it just has to be non-degenerate. */
59
+ export const SURFACE_HEIGHT = 200
60
+
61
+ // ── Harness ──────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Stub `getContext` and `getBoundingClientRect` for a canvas-mode test, and
65
+ * hand back the teardown.
66
+ *
67
+ * Typical use:
68
+ *
69
+ * let uninstall: () => void
70
+ * beforeEach(() => { uninstall = installCanvasHarness() })
71
+ * afterEach(() => { cleanup(); uninstall() })
72
+ *
73
+ * `getContext` returns a Proxy that no-ops every 2D call — the painter runs for
74
+ * real, it just draws nowhere. `createLinearGradient` is special-cased because
75
+ * the painter calls `.addColorStop` on what it returns.
76
+ */
77
+ export function installCanvasHarness(): () => void {
78
+ const realGetContext = HTMLCanvasElement.prototype.getContext
79
+ const realGetRect = Element.prototype.getBoundingClientRect
80
+
81
+ HTMLCanvasElement.prototype.getContext = (() =>
82
+ new Proxy({}, {
83
+ get(_t, prop: string) {
84
+ if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
85
+ return () => {}
86
+ },
87
+ set() { return true },
88
+ })) as unknown as typeof HTMLCanvasElement.prototype.getContext
89
+
90
+ // The canvas surface sits inset from the left of the column — that gap is the
91
+ // track rail, which is why a click at clientX < SURFACE_LEFT has no time.
92
+ // Everything else gets a rect that spans the whole column, so a test that
93
+ // measures some other element doesn't get a 0×0 back.
94
+ Element.prototype.getBoundingClientRect = function (this: Element) {
95
+ const isSurface = (this as HTMLElement).hasAttribute?.('data-timeline-canvas')
96
+ const left = isSurface ? SURFACE_LEFT : 0
97
+ const width = isSurface ? SURFACE_WIDTH : SURFACE_LEFT + SURFACE_WIDTH
98
+ return {
99
+ x: left, y: 0, top: 0, left, right: left + width, bottom: SURFACE_HEIGHT,
100
+ width, height: SURFACE_HEIGHT, toJSON: () => ({}),
101
+ } as DOMRect
102
+ }
103
+
104
+ return () => {
105
+ HTMLCanvasElement.prototype.getContext = realGetContext
106
+ Element.prototype.getBoundingClientRect = realGetRect
107
+ }
108
+ }
109
+
110
+ /** The canvas surface node — the element every pointer gesture is dispatched
111
+ * on, and the one `installCanvasHarness` gives a real rect to. */
112
+ export function canvasSurface(container: HTMLElement): HTMLElement {
113
+ const surface = container.querySelector('[data-timeline-canvas]')
114
+ if (!surface) {
115
+ throw new Error(
116
+ '[_canvasSelect] no [data-timeline-canvas] in the container — is the timeline mounted?',
117
+ )
118
+ }
119
+ return surface as HTMLElement
120
+ }
121
+
122
+ /**
123
+ * Focus the element Timeline's own Delete/Enter bindings are scoped to.
124
+ *
125
+ * Those bindings only fire with focus inside Timeline's `tabIndex={0}` root
126
+ * (see Timeline.tsx), and the canvas surface itself is `tabIndex={-1}`, so
127
+ * this walks up to the root rather than focusing the surface. A real press
128
+ * does the same thing from inside `TimelineCanvas`'s own `down` handler; this
129
+ * is for tests that need the focus WITHOUT a press (e.g. a selection made
130
+ * through the preview).
131
+ */
132
+ export function focusCanvasRoot(container: HTMLElement): void {
133
+ const surface = canvasSurface(container)
134
+ const root = (surface.closest('[tabindex="0"]') as HTMLElement | null) ?? surface
135
+ root.focus()
136
+ }
137
+
138
+ // ── Coordinates ──────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * The viewport a freshly mounted surface settles to: fitted to the whole
142
+ * timeline, scrolled to 0.
143
+ *
144
+ * This mirrors what `TimelineCanvas` actually does on mount — `observeSurface`
145
+ * fires once synchronously with the stubbed rect, and `withSurfaceWidth` on a
146
+ * zero-scale viewport falls through to `fitViewport`. It is only valid for a
147
+ * surface nobody has zoomed or scrolled since; a test that clicks "Zoom in"
148
+ * first must compute its own.
149
+ */
150
+ export function canvasViewport(project: Project): Viewport {
151
+ const { totalDuration } = computeDerivedTiming(project)
152
+ return {
153
+ pxPerSecond: fitPxPerSecond(SURFACE_WIDTH, totalDuration),
154
+ scrollSeconds: 0,
155
+ widthPx: SURFACE_WIDTH,
156
+ }
157
+ }
158
+
159
+ /** Page x for a timeline time, on a freshly mounted surface. */
160
+ export function timeToClientX(project: Project, t: number): number {
161
+ return SURFACE_LEFT + timeToX(t, canvasViewport(project))
162
+ }
163
+
164
+ /** How a caller names the thing to press. `{ type }` picks the FIRST visual
165
+ * item of that kind in draw order (topmost row first), which is what
166
+ * `findByText('▪ video')` used to resolve to; `{ id }` addresses one item
167
+ * exactly, and reaches audio bars and caption blocks too. */
168
+ export type CanvasItemSelector = { id: string } | { type: string }
169
+
170
+ /** A resolved target's span in time and its band on the surface. */
171
+ export interface CanvasTarget {
172
+ id: string
173
+ start: number
174
+ end: number
175
+ /** Top of the row / lane / caption band the target lives in. */
176
+ y: number
177
+ height: number
178
+ /** The trim-handle width to stay clear of when picking a press point. */
179
+ edgeTolerancePx: number
180
+ }
181
+
182
+ /** Where `selector` sits on a freshly mounted surface. Throws (loudly, with the
183
+ * ids that ARE there) rather than returning null — a mis-typed id in a test
184
+ * should read as a broken test, not as a gesture that quietly did nothing. */
185
+ export function resolveCanvasTarget(project: Project, selector: CanvasItemSelector): CanvasTarget {
186
+ const layout = computeTimelineLayout(project)
187
+ const known: string[] = []
188
+
189
+ for (const row of layout.rows) {
190
+ for (const item of row.items) {
191
+ known.push(item.id)
192
+ const match = 'id' in selector ? item.id === selector.id : item.type === selector.type
193
+ if (match) {
194
+ return {
195
+ id: item.id, start: item.start, end: item.end,
196
+ y: row.y, height: row.height, edgeTolerancePx: VISUAL_EDGE_TOLERANCE_PX,
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ // Audio bars and caption blocks are addressable by id only — neither carries
203
+ // a `type` field, and the DOM helpers this replaces never selected one.
204
+ if ('id' in selector) {
205
+ for (const lane of layout.lanes) {
206
+ for (const track of lane.tracks) {
207
+ known.push(track.id)
208
+ if (track.id === selector.id) {
209
+ return {
210
+ id: track.id, start: track.start, end: track.end,
211
+ y: lane.y, height: lane.height, edgeTolerancePx: AUDIO_EDGE_TOLERANCE_PX,
212
+ }
213
+ }
214
+ }
215
+ }
216
+ for (const band of layout.captions ?? []) {
217
+ for (const seg of band.segments) {
218
+ if (typeof seg.id !== 'string') continue
219
+ known.push(seg.id)
220
+ if (seg.id === selector.id) {
221
+ return {
222
+ id: seg.id, start: seg.start, end: seg.end,
223
+ y: band.y, height: band.height, edgeTolerancePx: AUDIO_EDGE_TOLERANCE_PX,
224
+ }
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ const asked = 'id' in selector ? `id ${JSON.stringify(selector.id)}` : `type ${JSON.stringify(selector.type)}`
231
+ throw new Error(`[_canvasSelect] no timeline item matching ${asked}. Ids in this project: ${known.join(', ') || '(none)'}`)
232
+ }
233
+
234
+ export interface CanvasPointOptions {
235
+ /** Press at this absolute timeline time instead of the target's middle. */
236
+ at?: number
237
+ /** Where the playhead is, so the press point can stay clear of its grab
238
+ * band. Defaults to 0, which is where every mounted test starts. */
239
+ playheadTime?: number
240
+ }
241
+
242
+ /**
243
+ * A page point that lands on the target's BODY — clear of both trim handles,
244
+ * and clear of the playhead's grab band.
245
+ *
246
+ * The playhead dodge is not fussiness. `grabsPlayhead` gives the playhead
247
+ * priority over a clip body, so a press within `PLAYHEAD_GRAB_PX` of the red
248
+ * line starts a SCRUB and never selects anything. A clip that starts at t=0
249
+ * with the playhead parked at 0 is the common case for these fixtures, so the
250
+ * dodge earns its keep whenever the item is short enough for its middle to
251
+ * fall inside the band.
252
+ */
253
+ export function canvasItemPoint(
254
+ project: Project,
255
+ selector: CanvasItemSelector,
256
+ opts: CanvasPointOptions = {},
257
+ ): { clientX: number; clientY: number } {
258
+ const target = resolveCanvasTarget(project, selector)
259
+ const viewport = canvasViewport(project)
260
+
261
+ const clientY = target.y + target.height / 2
262
+
263
+ if (opts.at !== undefined) {
264
+ return { clientX: SURFACE_LEFT + timeToX(opts.at, viewport), clientY }
265
+ }
266
+
267
+ const x0 = timeToX(target.start, viewport)
268
+ const x1 = timeToX(target.end, viewport)
269
+ // Inside both handles by a pixel. A degenerate span (a clip narrower than
270
+ // two handles) leaves no body at all, in which case the middle is the least
271
+ // wrong answer and the caller gets an edge hit — visible immediately as a
272
+ // trim rather than a selection.
273
+ const lo = x0 + target.edgeTolerancePx + 1
274
+ const hi = x1 - target.edgeTolerancePx - 1
275
+ const middle = (x0 + x1) / 2
276
+ let x = lo <= hi ? Math.min(Math.max(middle, lo), hi) : middle
277
+
278
+ const playheadX = timeToX(opts.playheadTime ?? 0, viewport)
279
+ if (lo <= hi && Math.abs(x - playheadX) <= PLAYHEAD_GRAB_PX) {
280
+ const right = playheadX + PLAYHEAD_GRAB_PX + 1
281
+ const left = playheadX - PLAYHEAD_GRAB_PX - 1
282
+ if (right <= hi) x = right
283
+ else if (left >= lo) x = left
284
+ }
285
+
286
+ return { clientX: SURFACE_LEFT + x, clientY }
287
+ }
288
+
289
+ // ── Gestures ─────────────────────────────────────────────────────────────
290
+
291
+ /** Named exactly as the DOM event fields are, so a migrated call site keeps
292
+ * the modifier flags it already had (`{ metaKey: true }`). */
293
+ export interface CanvasModifiers {
294
+ metaKey?: boolean
295
+ ctrlKey?: boolean
296
+ shiftKey?: boolean
297
+ altKey?: boolean
298
+ }
299
+
300
+ function mouseInit(point: { clientX: number; clientY: number }, mods: CanvasModifiers) {
301
+ return {
302
+ clientX: point.clientX,
303
+ clientY: point.clientY,
304
+ metaKey: mods.metaKey ?? false,
305
+ ctrlKey: mods.ctrlKey ?? false,
306
+ shiftKey: mods.shiftKey ?? false,
307
+ altKey: mods.altKey ?? false,
308
+ button: 0,
309
+ bubbles: true,
310
+ }
311
+ }
312
+
313
+ /** `TimelineCanvas` attaches move/up to `document` for the life of a gesture
314
+ * (the DOM drag hooks' pattern), so those two go straight to `document`
315
+ * rather than through the surface. `act` is explicit here because
316
+ * `document.dispatchEvent` is not one of RTL's own wrapped fire helpers. */
317
+ function dispatchOnDocument(type: 'mousemove' | 'mouseup', init: ReturnType<typeof mouseInit>): void {
318
+ act(() => { document.dispatchEvent(new MouseEvent(type, init)) })
319
+ }
320
+
321
+ export interface CanvasSelectOptions extends CanvasPointOptions, CanvasModifiers {}
322
+
323
+ /**
324
+ * Click a timeline item on the canvas — the canvas-native replacement for
325
+ * `fireEvent.click(await screen.findByText('▪ video'))`.
326
+ *
327
+ * Press and release with no movement in between, which is exactly what the
328
+ * pointer machine reads as a click: selection happens on RELEASE from the
329
+ * `pressed` state, and only for a press that never crossed
330
+ * `DRAG_THRESHOLD_PX`. The modifier semantics are the machine's own
331
+ * `isAdditive` — shift OR meta OR ctrl extends the selection, a bare click
332
+ * replaces it — so `{ metaKey: true }` here means what it meant on the DOM
333
+ * block.
334
+ */
335
+ export function selectCanvasItem(
336
+ container: HTMLElement,
337
+ project: Project,
338
+ selector: CanvasItemSelector,
339
+ opts: CanvasSelectOptions = {},
340
+ ): void {
341
+ const surface = canvasSurface(container)
342
+ const point = canvasItemPoint(project, selector, opts)
343
+ const init = mouseInit(point, opts)
344
+ fireEvent.mouseDown(surface, init)
345
+ dispatchOnDocument('mouseup', init)
346
+ }
347
+
348
+ export interface CanvasDragOptions extends CanvasModifiers {
349
+ /** Absolute timeline time to press at. Defaults to the target's body point. */
350
+ fromTime?: number
351
+ /** Absolute timeline time to release at. Mutually exclusive with `dxPx`. */
352
+ toTime?: number
353
+ /** Horizontal travel in page pixels, instead of `toTime`. */
354
+ dxPx?: number
355
+ /** Vertical travel in page pixels — a cross-track move. Defaults to 0. */
356
+ dyPx?: number
357
+ /** How many intermediate `mousemove`s to send. Defaults to 5. */
358
+ steps?: number
359
+ /** Where the playhead is, so the press point stays clear of its grab band. */
360
+ playheadTime?: number
361
+ }
362
+
363
+ /**
364
+ * Drag a timeline item across the canvas: press, travel in `steps` moves,
365
+ * release.
366
+ *
367
+ * The intermediate moves are not decoration. Each one drives a fresh
368
+ * `projectChange` (a live, uncommitted edit) and only the RELEASE emits the
369
+ * single `commit` — which is exactly the property "a whole drag undoes in one
370
+ * step, not one step per mousemove" tests, so collapsing this to one move
371
+ * would stop testing the thing.
372
+ *
373
+ * Throws when the requested travel wouldn't cross `DRAG_THRESHOLD_PX`: below
374
+ * it the machine keeps the gesture a click, and a test that meant to drag
375
+ * would silently assert against a selection instead.
376
+ */
377
+ export function dragCanvasItem(
378
+ container: HTMLElement,
379
+ project: Project,
380
+ selector: CanvasItemSelector,
381
+ opts: CanvasDragOptions,
382
+ ): void {
383
+ const surface = canvasSurface(container)
384
+ const from = canvasItemPoint(project, selector, {
385
+ at: opts.fromTime,
386
+ playheadTime: opts.playheadTime,
387
+ })
388
+
389
+ if (opts.toTime === undefined && opts.dxPx === undefined) {
390
+ throw new Error('[_canvasSelect] dragCanvasItem needs either `toTime` or `dxPx`')
391
+ }
392
+ const toX = opts.toTime !== undefined
393
+ ? timeToClientX(project, opts.toTime)
394
+ : from.clientX + (opts.dxPx as number)
395
+ const toY = from.clientY + (opts.dyPx ?? 0)
396
+
397
+ const dx = toX - from.clientX
398
+ const dy = toY - from.clientY
399
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) {
400
+ throw new Error(
401
+ `[_canvasSelect] a ${Math.hypot(dx, dy).toFixed(1)}px drag never crosses DRAG_THRESHOLD_PX `
402
+ + `(${DRAG_THRESHOLD_PX}) — the machine would keep this a click. Ask for more travel, `
403
+ + 'or use selectCanvasItem if a click is what you meant.',
404
+ )
405
+ }
406
+
407
+ const steps = Math.max(1, opts.steps ?? 5)
408
+ fireEvent.mouseDown(surface, mouseInit(from, opts))
409
+ for (let i = 1; i <= steps; i++) {
410
+ const at = { clientX: from.clientX + (dx * i) / steps, clientY: from.clientY + (dy * i) / steps }
411
+ dispatchOnDocument('mousemove', mouseInit(at, opts))
412
+ }
413
+ dispatchOnDocument('mouseup', mouseInit({ clientX: toX, clientY: toY }, opts))
414
+ }
@@ -0,0 +1,135 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * SP5 T5 — the drag/resize arithmetic extracted out of `useItemDragDrop` so the
4
+ * canvas pointer machine can reuse it instead of reimplementing trims.
5
+ *
6
+ * The hook's own suite (`useItemDragDrop.test.ts`), which drove the DOM shell,
7
+ * is retired along with that shell — this file is now the only coverage for
8
+ * this arithmetic, including the cases the shell couldn't easily reach
9
+ * (source-media clamps, tie-breaks).
10
+ */
11
+ import { describe, it, expect } from 'vitest'
12
+ import {
13
+ DRAG_THRESHOLD_PX,
14
+ computeResizedItem,
15
+ snapMovedSpan,
16
+ snapToBoundaries,
17
+ type Draggable,
18
+ } from '../useItemDragDrop'
19
+
20
+ describe('DRAG_THRESHOLD_PX', () => {
21
+ it('is the value both surfaces use to tell a click from a drag', () => {
22
+ expect(DRAG_THRESHOLD_PX).toBe(4)
23
+ })
24
+ })
25
+
26
+ describe('snapToBoundaries', () => {
27
+ it('takes the nearest boundary inside the threshold', () => {
28
+ expect(snapToBoundaries(5.1, [3, 5, 8], 0.5)).toBe(5)
29
+ })
30
+
31
+ it('leaves a value with nothing in range alone', () => {
32
+ expect(snapToBoundaries(5.1, [3, 8], 0.5)).toBe(5.1)
33
+ })
34
+
35
+ it('prefers the nearer of two candidates', () => {
36
+ expect(snapToBoundaries(5, [4.6, 5.2], 1)).toBe(5.2)
37
+ })
38
+
39
+ it('keeps the earlier entry on an exact tie', () => {
40
+ expect(snapToBoundaries(5, [4.5, 5.5], 1)).toBe(4.5)
41
+ })
42
+
43
+ it('does not snap at exactly the threshold', () => {
44
+ expect(snapToBoundaries(5.5, [5], 0.5)).toBe(5.5)
45
+ })
46
+
47
+ it('is a no-op with no boundaries or a zero threshold', () => {
48
+ expect(snapToBoundaries(5.1, [], 0.5)).toBe(5.1)
49
+ expect(snapToBoundaries(5.1, [5], 0)).toBe(5.1)
50
+ })
51
+ })
52
+
53
+ describe('snapMovedSpan', () => {
54
+ it('snaps the leading edge to a boundary', () => {
55
+ expect(snapMovedSpan(4.9, 2, [5], 0.5)).toEqual({ start: 5, end: 7 })
56
+ })
57
+
58
+ it('snaps the trailing edge to a boundary', () => {
59
+ expect(snapMovedSpan(2.9, 2, [5], 0.5)).toEqual({ start: 3, end: 5 })
60
+ })
61
+
62
+ it('picks whichever edge is closer', () => {
63
+ // start 4.8 is 0.2 from 5; end 6.8 is 0.8 from 6 — the start edge wins.
64
+ expect(snapMovedSpan(4.8, 2, [5, 6], 1)).toEqual({ start: 5, end: 7 })
65
+ })
66
+
67
+ it('resolves an exact tie in favour of the start edge', () => {
68
+ // Both edges are 0.5 away from their nearest boundary.
69
+ expect(snapMovedSpan(4.5, 2, [4, 7], 1)).toEqual({ start: 4, end: 6 })
70
+ })
71
+
72
+ it('returns the raw span when nothing is in range', () => {
73
+ expect(snapMovedSpan(4.9, 2, [1, 9], 0.05)).toEqual({ start: 4.9, end: 6.9 })
74
+ })
75
+ })
76
+
77
+ describe('computeResizedItem — non-video items', () => {
78
+ const overlay: Draggable = { id: 'o', type: 'overlay', start: 2, end: 6 }
79
+
80
+ it('moves the start edge only', () => {
81
+ expect(computeResizedItem(overlay, 'start', 3)).toEqual({ ...overlay, start: 3 })
82
+ })
83
+
84
+ it('moves the end edge only', () => {
85
+ expect(computeResizedItem(overlay, 'end', 5)).toEqual({ ...overlay, end: 5 })
86
+ })
87
+
88
+ it('keeps at least 0.1s from either edge', () => {
89
+ expect(computeResizedItem(overlay, 'start', 99).start).toBeCloseTo(5.9)
90
+ expect(computeResizedItem(overlay, 'end', -99).end).toBeCloseTo(2.1)
91
+ })
92
+ })
93
+
94
+ describe('computeResizedItem — video clips', () => {
95
+ const clip: Draggable = { id: 'c', type: 'video', start: 10, end: 20, inPoint: 5, outPoint: 15, sourceDuration: 30 }
96
+
97
+ it('walks the inPoint with the start edge', () => {
98
+ const trimmed = computeResizedItem(clip, 'start', 12)
99
+ expect(trimmed.start).toBe(12)
100
+ expect(trimmed.inPoint).toBeCloseTo(7)
101
+ expect(trimmed.outPoint).toBe(15)
102
+ })
103
+
104
+ it('walks the outPoint with the end edge', () => {
105
+ const trimmed = computeResizedItem(clip, 'end', 18)
106
+ expect(trimmed.end).toBe(18)
107
+ expect(trimmed.outPoint).toBeCloseTo(13)
108
+ expect(trimmed.inPoint).toBe(5)
109
+ })
110
+
111
+ it('never pulls the inPoint before the start of the source', () => {
112
+ expect(computeResizedItem(clip, 'start', -99).inPoint).toBe(0)
113
+ })
114
+
115
+ it('never pushes the outPoint past the end of the source', () => {
116
+ expect(computeResizedItem(clip, 'end', 999).outPoint).toBe(30)
117
+ })
118
+
119
+ it('keeps the source window at least 0.1s wide', () => {
120
+ expect(computeResizedItem(clip, 'start', 19.99).inPoint).toBeCloseTo(14.9)
121
+ expect(computeResizedItem(clip, 'end', 10.01).outPoint).toBeCloseTo(5.1)
122
+ })
123
+
124
+ it('treats a missing source window as starting at zero', () => {
125
+ const bare: Draggable = { id: 'b', type: 'video', start: 0, end: 4 }
126
+ const trimmed = computeResizedItem(bare, 'end', 3)
127
+ expect(trimmed.end).toBe(3)
128
+ expect(trimmed.outPoint).toBeCloseTo(3)
129
+ })
130
+
131
+ it('lets an unbounded source extend freely', () => {
132
+ const unbounded: Draggable = { id: 'u', type: 'video', start: 0, end: 4, inPoint: 0, outPoint: 4 }
133
+ expect(computeResizedItem(unbounded, 'end', 100).outPoint).toBe(100)
134
+ })
135
+ })
@@ -0,0 +1,50 @@
1
+ /// <reference types="vitest/globals" />
2
+ /**
3
+ * The track x item audio fold: a track's volume/mute settings combine with
4
+ * an item's own to produce what actually plays. Volume multiplies (never
5
+ * replaces, so a clip an editor already turned down stays proportionally
6
+ * quieter under a track pulled down too); mute is either/or (either one
7
+ * silences it). Mirrored by `effectiveItemAudio` in
8
+ * montaj_assets/render/project-tracks.js for the render path — the two must
9
+ * agree.
10
+ */
11
+ import { describe, it, expect } from 'vitest'
12
+ import { effectiveItemAudio } from '../timeline-model'
13
+
14
+ describe('effectiveItemAudio', () => {
15
+ it('multiplies track and item volume', () => {
16
+ expect(effectiveItemAudio({ volume: 0.5 }, { volume: 0.4 }).volume).toBeCloseTo(0.2)
17
+ })
18
+
19
+ it('does not let track volume replace a clip volume that was already turned down', () => {
20
+ const track = { volume: 0.5 }
21
+ const loud = effectiveItemAudio(track, { volume: 1.0 })
22
+ const quiet = effectiveItemAudio(track, { volume: 0.4 })
23
+ expect(loud.volume / quiet.volume).toBeCloseTo(1.0 / 0.4)
24
+ })
25
+
26
+ it('defaults to unity volume when both track and item omit it', () => {
27
+ expect(effectiveItemAudio({}, {}).volume).toBe(1)
28
+ })
29
+
30
+ it('respects an explicit item volume of 0 (nullish coalescing, not falsy)', () => {
31
+ expect(effectiveItemAudio({ volume: 1 }, { volume: 0 }).volume).toBe(0)
32
+ })
33
+
34
+ it('mutes when the track is muted, regardless of the item', () => {
35
+ expect(effectiveItemAudio({ muted: true }, { muted: false }).muted).toBe(true)
36
+ })
37
+
38
+ it('mutes when the item is muted, regardless of the track', () => {
39
+ expect(effectiveItemAudio({ muted: false }, { muted: true }).muted).toBe(true)
40
+ })
41
+
42
+ it('is unmuted only when neither track nor item is muted', () => {
43
+ expect(effectiveItemAudio({}, {}).muted).toBe(false)
44
+ })
45
+
46
+ it('tolerates undefined/null track and item', () => {
47
+ expect(effectiveItemAudio(undefined, undefined)).toEqual({ volume: 1, muted: false })
48
+ expect(effectiveItemAudio(null, null)).toEqual({ volume: 1, muted: false })
49
+ })
50
+ })