diff --git a/src/components/ai-edition/VirtualPreview.playback.test.tsx b/src/components/ai-edition/VirtualPreview.playback.test.tsx new file mode 100644 index 000000000..5dce37785 --- /dev/null +++ b/src/components/ai-edition/VirtualPreview.playback.test.tsx @@ -0,0 +1,208 @@ +import "@testing-library/jest-dom"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AxcutClip, AxcutTrimRange } from "@/lib/ai-edition/schema"; +import { type VideoSource, VirtualPreview } from "./VirtualPreview"; + +// The rAF tick is the whole subject here, so it is driven by hand rather than by the +// browser: `tick()` runs exactly one frame, which is what makes "what did the loop decide +// at 9.96 s?" an assertion instead of a race. +let frameCallbacks: FrameRequestCallback[] = []; + +beforeEach(() => { + frameCallbacks = []; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + frameCallbacks.push(cb); + return frameCallbacks.length; + }); + vi.stubGlobal("cancelAnimationFrame", () => { + // Frames are drained by `tick()`, never scheduled, so there is nothing to cancel — + // the stub only exists so the effect's cleanup has something to call. + }); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function tick() { + const pending = frameCallbacks; + frameCallbacks = []; + act(() => { + for (const cb of pending) cb(0); + }); +} + +function clip( + id: string, + assetId: string, + sourceStartSec: number, + sourceEndSec: number, + timelineStartSec: number, +): AxcutClip { + return { + id, + assetId, + sourceStartSec, + sourceEndSec, + timelineStartSec, + timelineEndSec: timelineStartSec + (sourceEndSec - sourceStartSec), + wordRefs: [], + origin: "user", + reason: "", + }; +} + +/** A `