import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import * as React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../../schema/define-toolcraft"; import type { ToolcraftProductSceneBoundsProvider } from "../../scene"; import type { ToolcraftCanvasFrame } from "../../state/canvas-frame"; import { ToolcraftRoot } from "../app-shell/toolcraft-root"; import { useToolcraftDispatch } from "../app-shell/use-toolcraft"; import { useToolcraftCanvasFrame } from "./use-toolcraft-canvas-frame"; import { ToolcraftProductSceneBoundsBoundary, ToolcraftProductSceneSurface, useToolcraftProductSceneFrame, } from "./product-scene-surface"; const schema = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true, size: { height: 200, unit: "px", width: 300 }, sizing: { mode: "editable-output" }, }, panels: {}, persistence: { storage: "none" }, }, modules: [], }); afterEach(() => { cleanup(); }); function FrameProbe(): React.JSX.Element { const frame = useToolcraftProductSceneFrame(); return {JSON.stringify(frame)}; } function renderSurface( frame: ToolcraftCanvasFrame, boundsProvider?: ToolcraftProductSceneBoundsProvider, ) { return { boundsProvider, ...render( , ), }; } describe("ToolcraftProductSceneSurface", () => { it("uses the centered finite artboard when no provider is declared", () => { const { container } = renderSurface({ kind: "finite", size: { height: 200, unit: "px", width: 300 }, }); expect(screen.getByTestId("frame").textContent).toBe( JSON.stringify({ kind: "ready", rect: { height: 200, width: 300, x: -150, y: -100 }, }), ); const productScene = container.querySelector( "[data-toolcraft-product-scene]", ); expect(productScene?.dataset.toolcraftProductSceneStatus).toBe("ready"); expect(productScene?.style.cssText).toBe( "height: 200px; left: -150px; top: -100px; width: 300px;", ); }); it("uses one provider frame and host across finite, infinite, and finite", () => { const probeMount = vi.fn(); const boundsProvider = vi.fn(() => [ { height: 128, width: 192, x: -96, y: -64 }, ]); function StatefulProbe(): React.JSX.Element { const productFrame = useToolcraftProductSceneFrame(); const [mountNumber] = React.useState(() => { probeMount(); return probeMount.mock.calls.length; }); return ( {mountNumber}:{JSON.stringify(productFrame)} ); } function ModeControls(): React.JSX.Element { const dispatch = useToolcraftDispatch(); const setMode = (infinite: boolean) => dispatch({ target: "canvas.infinity", type: "controls.setValue", value: infinite, }); return ( <> ); } function SurfaceHarness(): React.JSX.Element { const frame = useToolcraftCanvasFrame(); return ( ); } const view = render( , ); const finiteHost = view.container.querySelector( "[data-toolcraft-product-scene]", ); const finiteProbe = screen.getByTestId("stateful-product-probe"); const exactStyle = "height: 128px; left: -96px; top: -64px; width: 192px;"; expect(finiteHost?.style.cssText).toBe(exactStyle); expect(finiteProbe.textContent).toBe( `1:${JSON.stringify({ kind: "ready", rect: { height: 128, width: 192, x: -96, y: -64 }, })}`, ); expect(boundsProvider).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: "Enable Infinity" })); expect( view.container.querySelector("[data-toolcraft-product-scene]"), ).toBe(finiteHost); expect(screen.getByTestId("stateful-product-probe")).toBe(finiteProbe); expect(finiteHost?.style.cssText).toBe(exactStyle); expect(finiteProbe.textContent).toContain('1:{"kind":"ready"'); fireEvent.click(screen.getByRole("button", { name: "Disable Infinity" })); expect( view.container.querySelector("[data-toolcraft-product-scene]"), ).toBe(finiteHost); expect(screen.getByTestId("stateful-product-probe")).toBe(finiteProbe); expect(finiteHost?.style.cssText).toBe(exactStyle); expect(probeMount).toHaveBeenCalledTimes(1); expect( boundsProvider.mock.calls.map( ([context]) => context.state.canvas.mode, ), ).toEqual(["finite", "infinite", "finite"]); }); it("positions one infinite product surface from the exact provider union", () => { const boundsProvider = vi.fn(() => [ { height: 50, width: 80, x: -40, y: -25 }, { height: 20, width: 30, x: 30, y: 15 }, ]); const { container } = renderSurface({ kind: "infinite" }, boundsProvider); const productScene = container.querySelector( "[data-toolcraft-product-scene]", ); expect(boundsProvider).toHaveBeenCalledTimes(1); expect(productScene?.dataset.toolcraftProductSceneStatus).toBe("ready"); expect(productScene?.style.cssText).toContain("height: 60px"); expect(productScene?.style.cssText).toContain("left: -40px"); expect(productScene?.style.cssText).toContain("top: -25px"); expect(productScene?.style.cssText).toContain("width: 100px"); expect(screen.getByTestId("frame").textContent).toBe( JSON.stringify({ kind: "ready", rect: { height: 60, width: 100, x: -40, y: -25 }, }), ); }); it("keeps hook consumers stable when state changes without changing bounds", () => { const renderProbe = vi.fn(); function StableFrameProbe(): React.JSX.Element { renderProbe(); const dispatch = useToolcraftDispatch(); useToolcraftProductSceneFrame(); return ( ); } const boundsProvider = vi.fn(() => [ { height: 60, width: 100, x: -40, y: -25 }, ]); render( , ); expect(renderProbe).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: "Pan" })); expect(boundsProvider).toHaveBeenCalledTimes(2); expect(renderProbe).toHaveBeenCalledTimes(1); }); it("keeps the resolved product scene stable across local parent rerenders", () => { const boundsProvider = vi.fn(() => [ { height: 60, width: 100, x: -40, y: -25 }, ]); const probeMount = vi.fn(); function StatefulProbe(): React.JSX.Element { const [mountNumber] = React.useState(() => { probeMount(); return probeMount.mock.calls.length; }); return {mountNumber}; } function LocalRerenderParent(): React.JSX.Element { const frame = useToolcraftCanvasFrame(); const [, rerender] = React.useReducer((count: number) => count + 1, 0); return ( <> ); } const { container } = render( , ); const productHost = container.querySelector( "[data-toolcraft-product-scene]", ); const probe = screen.getByTestId("local-rerender-probe"); expect(boundsProvider).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: "Rerender parent" })); expect(boundsProvider).toHaveBeenCalledTimes(1); expect( container.querySelector("[data-toolcraft-product-scene]"), ).toBe(productHost); expect(screen.getByTestId("local-rerender-probe")).toBe(probe); expect(screen.getByTestId("local-rerender-probe").textContent).toBe("1"); expect(probeMount).toHaveBeenCalledTimes(1); }); it.each([ { expectedKind: "empty", provider: () => [], }, { expectedKind: "unavailable", provider: () => [{ height: 20, width: Number.NaN, x: 0, y: 0 }], }, { expectedKind: "unavailable", provider: () => { throw new Error("broken provider"); }, }, ])( "clears a ready host when declared bounds become $expectedKind", ({ expectedKind, provider }) => { const readyProvider = vi.fn(() => [ { height: 128, width: 192, x: -96, y: -64 }, ]); const renderTree = ( boundsProvider: ToolcraftProductSceneBoundsProvider, ) => ( ); const view = render(renderTree(readyProvider)); const productScene = view.container.querySelector( "[data-toolcraft-product-scene]", ); expect(productScene?.style.width).toBe("192px"); expect(productScene?.style.height).toBe("128px"); view.rerender(renderTree(vi.fn(provider))); expect(productScene?.dataset.toolcraftProductSceneStatus).toBe( expectedKind, ); expect(productScene?.style.width).toBe(""); expect(productScene?.style.height).toBe(""); expect(screen.getByTestId("frame").textContent).toBe( JSON.stringify({ kind: expectedKind, rect: null }), ); expect( view.container.querySelector("[data-toolcraft-product-scene]"), ).toBe(productScene); }, ); it("fails directly when the frame hook escapes the product scene", () => { expect(() => render()).toThrow( "useToolcraftProductSceneFrame must be used inside Toolcraft product scene content", ); }); });