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