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);
});
});