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