import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import * as React from "react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../../schema/define-toolcraft"; import type { ToolcraftExternalStore } from "../../state/toolcraft-external-store"; import { ToolcraftRoot } from "../app-shell/toolcraft-root"; import { useToolcraftStore } from "../app-shell/toolcraft-store-context"; import { TimelinePanel } from "./timeline-panel"; const persistenceKey = "toolcraft:scrub-lifecycle:state:v1"; const scrubLifecycleSchema = defineToolcraft({ canvas: { enabled: true }, panels: { timeline: { mode: "playback" } }, persistence: { include: ["panels", "timeline"], key: persistenceKey, storage: "localStorage", version: 1, }, }); function CaptureStore({ capture, }: { capture: (store: ToolcraftExternalStore) => void; }): null { capture(useToolcraftStore()); return null; } function ScrubLifecycleHarness({ capture, }: { capture: (store: ToolcraftExternalStore) => void; }): React.JSX.Element { const [visible, setVisible] = React.useState(true); return ( <> {visible ? : null} ); } function configureScrubber(scrubber: HTMLElement): void { vi.spyOn(scrubber, "getBoundingClientRect").mockReturnValue({ bottom: 20, height: 20, left: 0, right: 100, toJSON: () => ({}), top: 0, width: 100, x: 0, y: 0, }); Object.assign(scrubber, { hasPointerCapture: () => true, releasePointerCapture: vi.fn(), setPointerCapture: vi.fn(), }); } function persistedTime(): number | undefined { const persisted = JSON.parse(window.localStorage.getItem(persistenceKey) ?? "{}"); return persisted.state?.timeline?.currentTimeSeconds; } beforeAll(() => { Object.defineProperty(window, "matchMedia", { configurable: true, value: () => ({ addEventListener: () => undefined, addListener: () => undefined, matches: false, removeEventListener: () => undefined, removeListener: () => undefined, }), }); }); afterEach(() => { vi.useRealTimers(); cleanup(); window.localStorage.clear(); vi.restoreAllMocks(); }); function renderLifecycleHarness(): { getStore: () => ToolcraftExternalStore; } { let store: ToolcraftExternalStore | undefined; render( { store = value; }} /> , ); act(() => { vi.advanceTimersByTime(120); }); return { getStore: () => store! }; } describe("timeline scrub lifecycle boundaries", () => { it("commits and persists an active scrub when the timeline unmounts", () => { vi.useFakeTimers(); const { getStore } = renderLifecycleHarness(); const scrubber = screen.getByRole("slider", { name: "Playback position" }); configureScrubber(scrubber); fireEvent.pointerDown(scrubber, { clientX: 25, pointerId: 4 }); fireEvent.pointerMove(scrubber, { clientX: 50, pointerId: 4 }); const commitTransient = vi.spyOn(getStore(), "commitTransient"); fireEvent.click(screen.getByRole("button", { name: "Unmount timeline" })); expect(commitTransient).toHaveBeenCalledTimes(1); expect(commitTransient).toHaveBeenCalledWith("playback"); expect(getStore().getCommittedState().timeline.currentTimeSeconds).toBe(4); act(() => { vi.advanceTimersByTime(120); }); expect(persistedTime()).toBe(4); }); it("finishes lost pointer capture once even when pointerUp follows", () => { vi.useFakeTimers(); const { getStore } = renderLifecycleHarness(); const scrubber = screen.getByRole("slider", { name: "Playback position" }); configureScrubber(scrubber); fireEvent.pointerDown(scrubber, { clientX: 25, pointerId: 5 }); fireEvent.pointerMove(scrubber, { clientX: 75, pointerId: 5 }); const commitTransient = vi.spyOn(getStore(), "commitTransient"); fireEvent.lostPointerCapture(scrubber, { pointerId: 5 }); expect(getStore().getCommittedState().timeline.currentTimeSeconds).toBe(6); fireEvent.pointerUp(scrubber, { clientX: 75, pointerId: 5 }); expect(commitTransient).toHaveBeenCalledTimes(1); expect(commitTransient).toHaveBeenCalledWith("playback"); act(() => { vi.advanceTimersByTime(120); }); expect(persistedTime()).toBe(6); }); });