import { act, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ToolcraftExternalStore } from "../../state/toolcraft-external-store";
import type { ToolcraftInitialState } from "../../state/types";
import { ToolcraftRoot } from "../app-shell/toolcraft-root";
import { useToolcraftStore } from "../app-shell/toolcraft-store-context";
import { TimelinePanel } from "./timeline-panel";
import {
createTimelineSchema,
installTimelinePanelTestEnvironment,
renderTimelinePanel,
} from "./timeline-panel-test-utils";
installTimelinePanelTestEnvironment();
function CaptureStore({
capture,
}: {
capture: (store: ToolcraftExternalStore) => void;
}): null {
capture(useToolcraftStore());
return null;
}
function renderTimelineWithStore(initialState?: ToolcraftInitialState) {
let store: ToolcraftExternalStore | undefined;
const view = render(
{ store = value; }} />
,
);
return { ...view, getStore: () => store! };
}
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(),
});
}
describe("TimelinePanel transport", () => {
it("stays collapsed by default in the runtime contract", () => {
renderTimelinePanel();
expect(screen.getByTestId("timeline-expanded").textContent).toBe("false");
expect(screen.getByText("Dur:")).toBeTruthy();
expect(screen.queryByText("Duration:")).toBeNull();
expect(screen.queryByText("Add your first keyframe from the properties panel.")).toBeNull();
});
it("renders compact presentation as playback-only transport", () => {
renderTimelinePanel({ variant: "compact" });
expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Disable loop" })).toBeNull();
expect(screen.queryByText("Dur:")).toBeNull();
expect(screen.queryByText("Duration:")).toBeNull();
expect(screen.queryByRole("slider", { name: "Playback position" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Pause playback" }));
expect(screen.getByTestId("timeline-playing").textContent).toBe("false");
});
it("renders timeline state and dispatches playback commands", () => {
renderTimelinePanel();
expect(screen.getByText("8s")).toBeTruthy();
expect(screen.getByText("Dur:")).toBeTruthy();
expect(screen.queryByText("Duration:")).toBeNull();
expect(screen.getByTestId("timeline-playing").textContent).toBe("true");
expect(screen.getByTestId("timeline-looping").textContent).toBe("true");
expect(screen.getByTestId("timeline-expanded").textContent).toBe("false");
fireEvent.click(screen.getByRole("button", { name: "Pause playback" }));
expect(screen.getByTestId("timeline-playing").textContent).toBe("false");
fireEvent.click(screen.getByRole("button", { name: "Disable loop" }));
expect(screen.getByTestId("timeline-looping").textContent).toBe("false");
});
it("keeps timeline playhead handles easy to grab without changing visual size", () => {
const { container } = renderTimelinePanel();
const compactHandle = container.querySelector(
'[data-slot="timeline-playback-handle"]',
);
expect(compactHandle?.className).toContain("size-2");
expect(compactHandle?.className).toContain("before:inset-[-4px]");
fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
const expandedHitArea = container.querySelector(
'[data-slot="timeline-expanded-playhead-hit-area"]',
);
const expandedHandle = container.querySelector(
'[data-slot="timeline-expanded-playhead-handle"]',
);
expect(expandedHandle?.className).toContain("size-[9px]");
expect(expandedHitArea?.getAttribute("style")).toContain("width: 15px;");
});
it("edits timeline duration from the highlighted duration value in collapsed and expanded states", () => {
const { container } = renderTimelinePanel();
const durationDisplay = container.querySelector(
'[data-slot="timeline-duration-display"]',
);
expect(durationDisplay?.className).toContain("!cursor-text");
expect(durationDisplay?.className).toContain("group-hover/timeline-panel-header:bg");
expect(durationDisplay?.className).toContain("var(--foreground)_8%");
expect(durationDisplay?.className).toContain("px-1");
expect(container.querySelector('[data-slot="timeline-duration-edit-trigger"]')).toBeNull();
expect(screen.queryByRole("textbox", { name: "timeline duration" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Edit timeline duration" }));
const editor = screen.getByRole("textbox", { name: "timeline duration" });
expect(editor.className).toContain("var(--foreground)_8%");
expect(editor.className).toContain("px-1");
expect(editor.className).toContain("!cursor-text");
expect(screen.queryByRole("button", { name: "Edit timeline duration" })).toBeNull();
editor.textContent = "12s";
fireEvent.keyDown(editor, { key: "Enter" });
expect(screen.getByText("12s")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
const expandedDurationDisplay = container.querySelector(
'[data-slot="timeline-duration-display"]',
);
expect(expandedDurationDisplay?.className).toContain("!cursor-text");
expect(expandedDurationDisplay?.className).toContain("group-hover/timeline-panel-header:bg");
expect(expandedDurationDisplay?.className).toContain("var(--foreground)_8%");
fireEvent.click(screen.getByRole("button", { name: "Edit timeline duration" }));
const expandedEditor = screen.getByRole("textbox", { name: "timeline duration" });
expect(expandedEditor.className).toContain("var(--foreground)_8%");
expect(expandedEditor.className).toContain("!cursor-text");
});
it("replays from the beginning when play is pressed at the non-looping end", () => {
renderTimelinePanel(
{},
{
timeline: {
currentTimeSeconds: 8,
isLooping: false,
isPlaying: false,
},
},
{ mode: "playback" },
);
expect(screen.getByTestId("timeline-time").textContent).toBe("8");
fireEvent.click(screen.getByRole("button", { name: "Play playback" }));
expect(screen.getByTestId("timeline-time").textContent).toBe("0");
expect(screen.getByTestId("timeline-playing").textContent).toBe("true");
});
it("keeps rAF playback transient until pause commits the latest time", () => {
let frameCallback: FrameRequestCallback | undefined;
vi.spyOn(window.performance, "now").mockReturnValue(0);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
frameCallback = callback;
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
const { getStore } = renderTimelineWithStore({
timeline: { isLooping: false, isPlaying: true },
});
act(() => {
frameCallback?.(1000);
});
expect(getStore().getState().timeline.currentTimeSeconds).toBe(1);
expect(getStore().getCommittedState().timeline.currentTimeSeconds).toBe(0);
fireEvent.click(screen.getByRole("button", { name: "Pause playback" }));
expect(getStore().getCommittedState().timeline).toMatchObject({
currentTimeSeconds: 1,
isPlaying: false,
});
});
it("commits pointer scrubbing only when the pointer interaction ends", () => {
const { getStore } = renderTimelineWithStore({
timeline: { isPlaying: false },
});
const scrubber = screen.getByRole("slider", { name: "Playback position" });
configureScrubber(scrubber);
fireEvent.pointerDown(scrubber, { button: 0, clientX: 25, pointerId: 7 });
fireEvent.pointerMove(scrubber, { clientX: 50, pointerId: 7 });
expect(getStore().getState().timeline.currentTimeSeconds).toBe(4);
expect(getStore().getCommittedState().timeline.currentTimeSeconds).toBe(0);
const commitTransient = vi.spyOn(getStore(), "commitTransient");
fireEvent.pointerUp(scrubber, { clientX: 50, pointerId: 7 });
expect(getStore().getCommittedState().timeline.currentTimeSeconds).toBe(4);
expect(commitTransient).toHaveBeenCalledTimes(1);
expect(commitTransient).toHaveBeenCalledWith("playback");
});
it("commits pointer scrubbing when pointerCancel ends the interaction", () => {
const { getStore } = renderTimelineWithStore({
timeline: { isPlaying: false },
});
const scrubber = screen.getByRole("slider", { name: "Playback position" });
configureScrubber(scrubber);
fireEvent.pointerDown(scrubber, { button: 0, clientX: 25, pointerId: 8 });
fireEvent.pointerMove(scrubber, { clientX: 75, pointerId: 8 });
expect(getStore().getState().timeline.currentTimeSeconds).toBe(6);
expect(getStore().getCommittedState().timeline.currentTimeSeconds).toBe(0);
fireEvent.pointerCancel(scrubber, { clientX: 75, pointerId: 8 });
expect(getStore().getCommittedState().timeline.currentTimeSeconds).toBe(6);
});
it("commits keyboard seeks at the keyboard interaction boundary", () => {
const { getStore } = renderTimelineWithStore({
timeline: { isPlaying: false },
});
const scrubber = screen.getByRole("slider", { name: "Playback position" });
fireEvent.keyDown(scrubber, { key: "ArrowRight" });
expect(getStore().getCommittedState().timeline.currentTimeSeconds).toBe(
getStore().getState().timeline.currentTimeSeconds,
);
expect(getStore().getCommittedState().timeline.currentTimeSeconds).toBeGreaterThan(0);
});
it("writes and commits the final time before stopping natural playback", () => {
let frameCallback: FrameRequestCallback | undefined;
vi.spyOn(window.performance, "now").mockReturnValue(0);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
frameCallback = callback;
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
const { getStore } = renderTimelineWithStore({
timeline: {
durationSeconds: 1,
isLooping: false,
isPlaying: true,
keyframeGroups: [
{
controlId: "shape.opacity",
keyframes: [
{
controlId: "shape.opacity",
controlLabel: "Opacity",
id: "shape.opacity::0",
timeSeconds: 0,
value: 0,
valueLabel: "0",
},
],
label: "Opacity",
},
],
},
});
act(() => {
frameCallback?.(1000);
});
expect(getStore().getCommittedState().timeline).toMatchObject({
currentTimeSeconds: 1,
durationSeconds: 1,
isLooping: false,
isPlaying: false,
keyframeGroups: [
expect.objectContaining({ controlId: "shape.opacity" }),
],
});
});
it("persists expanded state in the shared runtime timeline", () => {
renderTimelinePanel();
fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
expect(screen.getByTestId("timeline-expanded").textContent).toBe("true");
expect(screen.getByText("Duration:")).toBeTruthy();
expect(screen.getByText("Add your first keyframe from the properties panel.")).toBeTruthy();
expect(screen.queryByText("Dur:")).toBeNull();
});
it("renders playback-only timelines without an expanded keyframe editor", () => {
const { container } = renderTimelinePanel(
{},
{
timeline: {
expanded: true,
keyframeGroups: [
{
controlId: "selectedLayer.opacity",
keyframes: [
{
controlId: "selectedLayer.opacity",
controlLabel: "Opacity",
id: "selectedLayer.opacity::4",
timeSeconds: 4,
valueLabel: "75%",
},
],
label: "Opacity",
},
],
},
},
{ mode: "playback" },
);
expect(screen.getByText("Dur:")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Expand timeline panel" })).toBeNull();
expect(screen.queryByText("Duration:")).toBeNull();
expect(screen.queryByText("Opacity")).toBeNull();
expect(screen.queryByText("Add your first keyframe from the properties panel.")).toBeNull();
expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
expect(
container.querySelector('[data-slot="timeline-panel"] > [data-panel-id="timeline"]')
?.className,
).toContain("pr-3");
});
it("points the expand toggle down when collapsed and up when expanded", () => {
const { container } = renderTimelinePanel();
const getArrowIcon = () => container.querySelector('[data-slot="primitive-arrow-icon"]');
expect(getArrowIcon()?.classList.contains("rotate-180")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
expect(getArrowIcon()?.classList.contains("rotate-180")).toBe(true);
});
});