@bycrux/editor 0.10.0 → 0.11.0

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.
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import { render, waitFor } from '@testing-library/react'
2
+ import { render, waitFor, act, fireEvent } from '@testing-library/react'
3
3
  import type {
4
+ CaptionEvent,
4
5
  EditorAdapter,
5
6
  ImageElement,
6
7
  Project,
@@ -47,14 +48,30 @@ function makeVideoProject(overrides: Partial<Project> = {}): Project {
47
48
 
48
49
  interface FakeAdapter extends EditorAdapter<Project> {
49
50
  saveCalls: Array<{ id: string; project: Project }>
51
+ /** Push a server-authored SSE frame to every active subscriber. */
52
+ emit: (project: Project) => void
53
+ /** When true, saveProject() blocks until flushSaves() so a save stays pending. */
54
+ setHoldSaves: (hold: boolean) => void
55
+ /** Resolve every held saveProject() promise. */
56
+ flushSaves: () => void
50
57
  }
51
58
 
52
59
  function makeFakeAdapter(): FakeAdapter {
53
60
  const saveCalls: Array<{ id: string; project: Project }> = []
61
+ let subscribers: Array<(project: Project) => void> = []
62
+ let holdSaves = false
63
+ let saveResolvers: Array<() => void> = []
54
64
  return {
55
65
  loadProject: vi.fn(async () => makeVideoProject()),
56
- saveProject: vi.fn(async (id: string, project: Project) => { saveCalls.push({ id, project }) }),
57
- subscribe: () => () => {},
66
+ saveProject: vi.fn(async (id: string, project: Project) => {
67
+ saveCalls.push({ id, project })
68
+ if (holdSaves) await new Promise<void>((resolve) => saveResolvers.push(resolve))
69
+ }),
70
+ // Capture the sync core's frame callback so a test can drive SSE frames.
71
+ subscribe: (_id: string, onFrame: (project: Project) => void) => {
72
+ subscribers.push(onFrame)
73
+ return () => { subscribers = subscribers.filter((s) => s !== onFrame) }
74
+ },
58
75
  render: async function* (): AsyncIterable<RenderEvent> {
59
76
  yield { type: 'done', outputPath: '/out.mp4' }
60
77
  },
@@ -70,6 +87,9 @@ function makeFakeAdapter(): FakeAdapter {
70
87
  resolveCaptionTemplate: (style: string) => `/caption/${style}`,
71
88
  getInfo: vi.fn(async () => ({ root_skill_path: undefined })),
72
89
  saveCalls,
90
+ emit: (project: Project) => { for (const s of [...subscribers]) s(project) },
91
+ setHoldSaves: (hold: boolean) => { holdSaves = hold },
92
+ flushSaves: () => { const r = saveResolvers; saveResolvers = []; r.forEach((res) => res()) },
73
93
  }
74
94
  }
75
95
 
@@ -244,4 +264,226 @@ describe('VideoEditor — editor-package integration', () => {
244
264
  expect(merged.offsetX).toBe(5)
245
265
  expect(merged.offsetY).toBe(10)
246
266
  })
267
+
268
+ // ── Sync core adoption: undo/redo + SSE echo protection ──────────────────────
269
+ // These drive the one reliable DOM-triggerable mutation (the Render button,
270
+ // which flips status draft→final through sync.mutate) and then exercise the
271
+ // shared save/undo core the editor now routes through.
272
+
273
+ it('undo restores the pre-mutation project and re-persists it', async () => {
274
+ const adapter = makeFakeAdapter()
275
+ const onProjectChange = vi.fn()
276
+ const { findByText } = render(
277
+ <VideoEditor
278
+ project={makeVideoProject({ status: 'draft' })}
279
+ adapter={adapter}
280
+ onProjectChange={onProjectChange}
281
+ slots={{ exportActions: <div /> }}
282
+ />,
283
+ )
284
+
285
+ // Mutation: Render flips status draft → final and persists via the queue.
286
+ const renderBtn = await findByText('Render →')
287
+ await act(async () => { renderBtn.click() })
288
+ await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('final'))
289
+
290
+ // Undo (Cmd+Z): restores 'draft' AND enqueues a save of the restored state.
291
+ await act(async () => {
292
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
293
+ })
294
+ await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('draft'))
295
+ // Host is notified of the restored (draft) authoritative state.
296
+ expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'draft' }))
297
+ })
298
+
299
+ it('redo re-applies an undone mutation and re-persists it', async () => {
300
+ const adapter = makeFakeAdapter()
301
+ const { findByText } = render(
302
+ <VideoEditor
303
+ project={makeVideoProject({ status: 'draft' })}
304
+ adapter={adapter}
305
+ onProjectChange={vi.fn()}
306
+ slots={{ exportActions: <div /> }}
307
+ />,
308
+ )
309
+
310
+ const renderBtn = await findByText('Render →')
311
+ await act(async () => { renderBtn.click() })
312
+ await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('final'))
313
+
314
+ // Undo back to draft…
315
+ await act(async () => {
316
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true }))
317
+ })
318
+ await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('draft'))
319
+
320
+ // …then Redo (Cmd+Shift+Z) re-applies 'final'.
321
+ await act(async () => {
322
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', metaKey: true, shiftKey: true }))
323
+ })
324
+ await waitFor(() => expect(adapter.saveCalls[adapter.saveCalls.length - 1]?.project.status).toBe('final'))
325
+ })
326
+
327
+ it('does not clobber an optimistic edit with an SSE frame that arrives mid-save', async () => {
328
+ const adapter = makeFakeAdapter()
329
+ adapter.setHoldSaves(true) // saves hang so the mutation queue stays pending
330
+ const onProjectChange = vi.fn()
331
+ const { findByText } = render(
332
+ <VideoEditor
333
+ project={makeVideoProject({ status: 'draft', name: 'Original' })}
334
+ adapter={adapter}
335
+ onProjectChange={onProjectChange}
336
+ slots={{ exportActions: <div /> }}
337
+ />,
338
+ )
339
+
340
+ // Optimistic mutation: status → final. Its save is now in-flight (held).
341
+ const renderBtn = await findByText('Render →')
342
+ await act(async () => { renderBtn.click() })
343
+ await waitFor(() =>
344
+ expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'final' })),
345
+ )
346
+
347
+ // A stale server frame arrives WHILE the save is pending. It must be deferred,
348
+ // not applied — otherwise it would regress the optimistic 'final' edit.
349
+ await act(async () => {
350
+ adapter.emit(makeVideoProject({ status: 'draft', name: 'StaleServerFrame' }))
351
+ })
352
+ // Optimistic edit intact: still 'final', and the stale frame never reached the host.
353
+ expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'final' }))
354
+ expect(onProjectChange).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'StaleServerFrame' }))
355
+
356
+ // Once the save drains, the deferred frame is applied (last-write-wins).
357
+ await act(async () => { adapter.flushSaves() })
358
+ await waitFor(() =>
359
+ expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'StaleServerFrame' })),
360
+ )
361
+ })
362
+
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 () => {
373
+ const adapter = makeFakeAdapter()
374
+ const onProjectChange = vi.fn()
375
+ const initial = makeVideoProject({
376
+ name: 'Original',
377
+ 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' } }],
380
+ ],
381
+ })
382
+ const { findByText, findByTitle, findByLabelText, getByText } = render(
383
+ <VideoEditor
384
+ project={initial}
385
+ adapter={adapter}
386
+ onProjectChange={onProjectChange}
387
+ slots={{ exportActions: <div /> }}
388
+ />,
389
+ )
390
+
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)
398
+
399
+ // Preview an edit — mutateTransient baselines against the pre-gesture state.
400
+ const textField = await findByLabelText('text')
401
+ fireEvent.change(textField, { target: { value: 'Live preview' } })
402
+ await waitFor(() => expect(onProjectChange).toHaveBeenLastCalledWith(
403
+ expect.objectContaining({
404
+ tracks: expect.arrayContaining([
405
+ expect.arrayContaining([expect.objectContaining({ id: 'overlay-1', props: { text: 'Live preview' } })]),
406
+ ]),
407
+ }),
408
+ ))
409
+
410
+ // Cancel — routes through applyExternal, reverting to the pre-edit snapshot.
411
+ fireEvent.click(getByText('Cancel'))
412
+ await waitFor(() => expect(onProjectChange).toHaveBeenLastCalledWith(
413
+ expect.objectContaining({
414
+ name: 'Original',
415
+ tracks: expect.arrayContaining([
416
+ expect.arrayContaining([expect.objectContaining({ id: 'overlay-1', props: { text: 'Old text' } })]),
417
+ ]),
418
+ }),
419
+ ))
420
+
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 }))
439
+ })
440
+ await waitFor(() => expect(onProjectChange).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'FromServer' })))
441
+ })
442
+
443
+ // Regression: the caption-repair effect (VideoEditor.tsx, near backfillCaptionIds)
444
+ // used to be keyed on project.id ONLY, so it ran once per project load and never
445
+ // again. CaptionRegenModal's onDone replaces project.captions via applyExternal
446
+ // WITHOUT changing project.id, so a mid-session regeneration used to skip word
447
+ // repair entirely until a remount. The effect is now also keyed on
448
+ // project.captions.
449
+ it('regenerating captions mid-session (no project.id change) re-runs word repair, and the effect settles instead of looping', async () => {
450
+ const adapter = makeFakeAdapter()
451
+ // Text has a double space between words and no words[] at all — this
452
+ // exercises both "needs a repair pass" AND (via captionRepair.ts's
453
+ // whitespace-normalized comparison) "the repair reaches a fixed point on
454
+ // the very next pass", which is what stops the widened effect from
455
+ // applyExternal-ing forever.
456
+ adapter.generateCaptions = async function* (): AsyncIterable<CaptionEvent> {
457
+ yield {
458
+ type: 'done',
459
+ captions: { style: 'clean', segments: [{ id: 's1', text: 'brand new text', start: 0, end: 3 }] },
460
+ }
461
+ }
462
+ const onProjectChange = vi.fn()
463
+ const { findByText } = render(
464
+ <VideoEditor
465
+ project={makeVideoProject({ status: 'draft' })}
466
+ adapter={adapter}
467
+ onProjectChange={onProjectChange}
468
+ slots={{ exportActions: <div /> }}
469
+ />,
470
+ )
471
+
472
+ const regenBtn = await findByText('Regenerate')
473
+ await act(async () => { regenBtn.click() })
474
+
475
+ // Repair fired for a captions-only replacement — words[] derived from the
476
+ // (whitespace-collapsed) text, not left stale/absent.
477
+ await waitFor(() => {
478
+ const last = onProjectChange.mock.calls[onProjectChange.mock.calls.length - 1][0] as Project
479
+ expect(last.captions?.segments[0]?.words?.map((w) => w.word)).toEqual(['brand', 'new', 'text'])
480
+ })
481
+
482
+ // Settles: once repaired, flushing further ticks must produce no additional
483
+ // onProjectChange calls — proves the effect reached its fixed point instead
484
+ // of looping.
485
+ const settledCallCount = onProjectChange.mock.calls.length
486
+ await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
487
+ expect(onProjectChange.mock.calls.length).toBe(settledCallCount)
488
+ })
247
489
  })
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { Project } from '../../types'
3
+ import type { CaptionSegment } from '../../schema'
4
+ import { backfillCaptionIds } from '../VideoEditor'
5
+
6
+ function project(segments: CaptionSegment[] | null): Project {
7
+ return (segments
8
+ ? { id: 'p1', captions: { style: 'word-by-word', segments } }
9
+ : { id: 'p1' }) as unknown as Project
10
+ }
11
+
12
+ function seg(over: Partial<CaptionSegment> = {}): CaptionSegment {
13
+ return { text: 'hello', start: 0, end: 1, ...over }
14
+ }
15
+
16
+ const ids = (p: Project) => p.captions!.segments.map((s) => s.id)
17
+
18
+ describe('backfillCaptionIds', () => {
19
+ it('returns the SAME reference when there is nothing to do', () => {
20
+ // This is the property the backfill effect's loop-safety rests on: after our
21
+ // own applyExternal re-fires the effect, the second pass must be a no-op.
22
+ const noCaptions = project(null)
23
+ expect(backfillCaptionIds(noCaptions)).toBe(noCaptions)
24
+
25
+ const empty = project([])
26
+ expect(backfillCaptionIds(empty)).toBe(empty)
27
+
28
+ const done = project([seg({ id: 'cap-0' }), seg({ id: 'cap-1' })])
29
+ expect(backfillCaptionIds(done)).toBe(done)
30
+ })
31
+
32
+ it('is idempotent — backfilling the result changes nothing further', () => {
33
+ const once = backfillCaptionIds(project([seg(), seg(), seg()]))
34
+ expect(backfillCaptionIds(once)).toBe(once)
35
+ })
36
+
37
+ it('mints cap-<index> for an all-id-less track (regenerated captions)', () => {
38
+ const out = backfillCaptionIds(project([seg(), seg(), seg()]))
39
+ expect(ids(out)).toEqual(['cap-0', 'cap-1', 'cap-2'])
40
+ })
41
+
42
+ it('never overwrites an existing id', () => {
43
+ const out = backfillCaptionIds(project([seg({ id: 'kept' }), seg()]))
44
+ expect(ids(out)[0]).toBe('kept')
45
+ })
46
+
47
+ it('does not mint a duplicate of an id already in use', () => {
48
+ // `cap-1` is already taken at index 2; index 1 must not be handed the same id.
49
+ const out = backfillCaptionIds(project([seg({ id: 'cap-0' }), seg(), seg({ id: 'cap-1' })]))
50
+ const got = ids(out)
51
+ expect(got).toEqual(['cap-0', 'cap-2', 'cap-1'])
52
+ expect(new Set(got).size).toBe(got.length)
53
+ })
54
+
55
+ it('stays collision-free across a mixed track (backfilled + freshly regenerated)', () => {
56
+ const out = backfillCaptionIds(project([
57
+ seg(), seg({ id: 'cap-0' }), seg(), seg({ id: 'cap-3' }), seg(), seg({ id: 'cap-1' }), seg(),
58
+ ]))
59
+ const got = ids(out)
60
+ expect(new Set(got).size).toBe(got.length)
61
+ expect(got.every((id) => !!id)).toBe(true)
62
+ // Deterministic: the counter only moves forward, skipping ids already taken.
63
+ expect(got).toEqual(['cap-2', 'cap-0', 'cap-4', 'cap-3', 'cap-5', 'cap-1', 'cap-6'])
64
+ })
65
+
66
+ it('preserves every other field on the segments it touches', () => {
67
+ const out = backfillCaptionIds(project([seg({ text: 'x', start: 2, end: 4, offsetX: 10, scale: 1.5 })]))
68
+ expect(out.captions!.segments[0]).toMatchObject({ text: 'x', start: 2, end: 4, offsetX: 10, scale: 1.5 })
69
+ })
70
+ })