import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} 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 { ToolcraftCustomControlRendererProps } from "../controls-panel/control-renderers";
import {
createToolcraftAppTestSchema,
installToolcraftAppTestWindow,
} from "./toolcraft-app-test-utils";
import { ToolcraftApp } from "./toolcraft-app";
import { useToolcraft } from "./use-toolcraft";
beforeAll(() => {
installToolcraftAppTestWindow();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
document.documentElement.classList.remove("dark");
document.documentElement.style.colorScheme = "";
window.localStorage.clear();
});
function createCustomControlSchema() {
return defineToolcraft({
canvas: {
enabled: true,
size: { height: 180, unit: "px", width: 320 },
},
panels: {
controls: {
sections: [
{
controls: {
power: {
defaultValue: 10,
label: "Power",
target: "generation.power",
type: "customPower",
},
},
title: "Custom",
},
],
title: "Controls",
},
},
});
}
function CanvasSlotProbe(): React.JSX.Element {
const { state } = useToolcraft();
return (
{state.canvas.size.width}:{state.mediaAssets.length}
);
}
function CustomValueProbe(): React.JSX.Element {
const { state } = useToolcraft();
return (
{String(state.values["generation.power"])}
);
}
function TimelineTimeProbe(): React.JSX.Element {
const { state } = useToolcraft();
return (
{state.timeline.currentTimeSeconds.toFixed(3)}
);
}
describe("ToolcraftApp", () => {
it("renders enabled runtime surfaces from the app schema", () => {
const { container } = render();
expect(
screen.getByRole("application", { name: "Canvas viewport" }),
).toBeTruthy();
expect(screen.getByText("Controls")).toBeTruthy();
expect(screen.getByText("Layers")).toBeTruthy();
expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
expect(
container.querySelector('[data-toolcraft-timeline-panel-variant="compact"]'),
).toBeTruthy();
expect(screen.queryByText("Dur:")).toBeNull();
expect(screen.queryByText("Duration:")).toBeNull();
expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Redo" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Center canvas" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Light theme" })).toBeTruthy();
expect(
container.querySelector('[data-slot="toolcraft-runtime-app"]'),
).toBeTruthy();
expect(
container.querySelector('[data-slot="toolcraft-runtime-app"]')
?.style.minWidth,
).toBe("1024px");
expect(
container.querySelector('[data-panel-type="controls"]'),
).toBeTruthy();
expect(container.querySelector('[data-panel-type="layers"]')).toBeTruthy();
expect(
container.querySelector('[data-panel-type="timeline"]'),
).toBeTruthy();
expect(container.querySelector('[data-panel-type="toolbar"]')).toBeTruthy();
});
it("switches the timeline panel between compact and extended through the runtime setup toggle", () => {
const { container } = render();
expect(container.querySelector('[data-panel-type="timeline"]')).toBeTruthy();
expect(
container.querySelector('[data-toolcraft-timeline-panel-variant="compact"]'),
).toBeTruthy();
expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
expect(screen.queryByText("Dur:")).toBeNull();
const timelineField = screen.getByText("Timeline").closest('[role="group"]');
const timelineSwitch = timelineField?.querySelector('[role="switch"]');
expect(timelineSwitch?.getAttribute("aria-checked")).toBe("false");
fireEvent.click(timelineSwitch as HTMLElement);
expect(container.querySelector('[data-panel-type="timeline"]')).toBeTruthy();
expect(
container.querySelector('[data-toolcraft-timeline-panel-variant="extended"]'),
).toBeTruthy();
expect(container.querySelector('[data-toolcraft-timeline-panel-hidden="true"]')).toBeNull();
expect(screen.getByText("Dur:")).toBeTruthy();
expect(screen.getByText(/\d+(?:\.\d+)? \/ 8s/)).toBeTruthy();
expect(timelineSwitch?.getAttribute("aria-checked")).toBe("true");
});
it("keeps timeline playback running while the panel is hidden", () => {
vi.useFakeTimers();
const schema = defineToolcraft({
canvas: {
enabled: true,
size: { height: 180, unit: "px", width: 320 },
},
panels: {
controls: {
sections: [],
title: "Controls",
},
timeline: { mode: "playback" },
},
persistence: {
include: ["panels", "timeline"],
key: "toolcraft:timeline-hidden-test:state:v1",
storage: "localStorage",
version: 1,
},
});
window.localStorage.setItem(
"toolcraft:timeline-hidden-test:state:v1",
JSON.stringify({
state: {
panels: {
timeline: { hidden: true },
},
timeline: {
currentTimeSeconds: 0,
durationSeconds: 8,
expanded: false,
isLooping: true,
isPlaying: true,
},
},
version: 1,
}),
);
const { container } = render(
} schema={schema} />,
);
expect(
container.querySelector('[data-toolcraft-timeline-panel-hidden="true"]'),
).toBeTruthy();
expect(screen.getByTestId("timeline-time").textContent).toBe("0.000");
act(() => {
vi.advanceTimersByTime(200);
});
expect(Number(screen.getByTestId("timeline-time").textContent)).toBeGreaterThan(0);
vi.useRealTimers();
});
it("does not render disabled optional panels or toolbar features", () => {
const schema = defineToolcraft({
canvas: { enabled: true },
panels: {},
toolbar: {
history: false,
radar: false,
theme: false,
zoom: false,
},
});
const { container } = render();
expect(
screen.getByRole("application", { name: "Canvas viewport" }),
).toBeTruthy();
expect(screen.queryByText("Controls")).toBeNull();
expect(screen.queryByText("Layers")).toBeNull();
expect(screen.queryByText("Duration:")).toBeNull();
expect(screen.queryByText("Dur:")).toBeNull();
expect(screen.queryByRole("button", { name: "Undo" })).toBeNull();
expect(screen.queryByRole("button", { name: "Center canvas" })).toBeNull();
expect(container.querySelector('[data-panel-type="toolbar"]')).toBeNull();
});
it("renders app-specific canvas content inside the shared runtime canvas slot", () => {
const { container } = render(
}
renderDefaultCanvasMedia={false}
schema={createToolcraftAppTestSchema()}
/>,
);
const editableCanvas = container.querySelector(
"[data-toolcraft-editable-canvas]",
);
const canvasSlot = container.querySelector(
"[data-toolcraft-canvas-slot]",
);
expect(editableCanvas).toBeTruthy();
expect(canvasSlot?.parentElement).toBe(editableCanvas);
expect(screen.getByTestId("canvas-slot-probe").textContent).toBe("320:0");
});
it("lets app-specific canvas renderers replace default media rendering while keeping upload state", async () => {
render(
}
renderDefaultCanvasMedia={false}
schema={createToolcraftAppTestSchema()}
/>,
);
const canvas = screen.getByRole("application", { name: "Canvas viewport" });
const file = new File(["image"], "shader-input.png", {
type: "image/png",
});
fireEvent.drop(canvas, {
clientX: 0,
clientY: 0,
dataTransfer: {
files: [file],
},
});
await waitFor(() => {
expect(screen.getByTestId("canvas-slot-probe").textContent).toBe("320:1");
});
expect(
screen.queryByRole("button", { name: "Select shader-input.png" }),
).toBeNull();
});
it("preserves reset, upload, and toolbar history without manual shell wiring", async () => {
render();
fireEvent.change(screen.getByDisplayValue("Initial prompt"), {
target: { value: "Updated prompt" },
});
expect(screen.getByDisplayValue("Updated prompt")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
expect(screen.getByDisplayValue("Initial prompt")).toBeTruthy();
const canvas = screen.getByRole("application", { name: "Canvas viewport" });
const file = new File(["image"], "material.png", { type: "image/png" });
fireEvent.drop(canvas, {
clientX: 0,
clientY: 0,
dataTransfer: {
files: [file],
},
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Select material.png" }),
).toBeTruthy();
}, { timeout: 3000 });
fireEvent.click(screen.getByRole("button", { name: "Undo" }));
expect(
screen.queryByRole("button", { name: "Select material.png" }),
).toBeNull();
expect(
(screen.getByRole("button", { name: "Redo" }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
it("routes app-specific sticky footer actions from the assembled app shell", () => {
const handledActions: string[] = [];
const schema = defineToolcraft({
canvas: { enabled: true },
panels: {
controls: {
sections: [
{
controls: {
footer: {
actions: [
{
icon: "upload-simple",
label: "Export image",
value: "export",
variant: "default",
},
],
target: "panel.actions",
type: "panelActions",
},
},
title: "Export",
},
],
title: "Controls",
},
},
});
render(
{
handledActions.push(action.value);
}}
schema={schema}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Export image" }));
expect(handledActions).toEqual(["export"]);
});
it("renders registered custom controls through the shared runtime setter", async () => {
function CustomPowerControl({
name,
setValue,
value,
}: ToolcraftCustomControlRendererProps): React.JSX.Element {
return (
);
}
render(
}
controlRenderers={{
customPower: CustomPowerControl,
}}
schema={createCustomControlSchema()}
/>,
);
expect(screen.getByRole("button", { name: "Power: 10" })).toBeTruthy();
expect(screen.getByTestId("custom-value-probe").textContent).toBe("10");
fireEvent.click(screen.getByRole("button", { name: "Power: 10" }));
await waitFor(() => {
expect(screen.getByTestId("custom-value-probe").textContent).toBe("42");
});
});
});