import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { ToolcraftApp } from "../react/app-shell/toolcraft-app"; import { installToolcraftAppTestWindow } from "../react/app-shell/toolcraft-app-test-utils"; import { createToolcraftArtifactExportActions, getToolcraftArtifactExportActions, } from "../schema/artifact-export-actions"; import type { ToolcraftProductBase } from "../schema/product-base"; import { resolveToolcraftProductDefinition } from "../schema/resolve-toolcraft-product-definition"; import { imageExportModule } from "./built-ins/image-export/image-export-module"; import { svgExportModule } from "./built-ins/svg-export/svg-export-module"; import { videoExportModule } from "./built-ins/video-export/video-export-module"; beforeAll(() => { installToolcraftAppTestWindow(); }); afterEach(() => { cleanup(); window.localStorage.clear(); }); function deepFreeze(value: T): T { if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { for (const child of Object.values(value as Record)) { deepFreeze(child); } Object.freeze(value); } return value; } function createBaseSchema(): ToolcraftProductBase { return { canvas: { enabled: true, size: { height: 180, unit: "px", width: 320 }, sizing: { mode: "editable-output" }, }, identity: { id: "module-composition", title: "Module Composition" }, panels: { controls: { sections: [ { controls: { includeBackground: { applicability: { mode: "always" as const }, defaultValue: true, label: "Include", target: "export.includeBackground", type: "switch", }, background: { applicability: { mode: "always" as const }, defaultValue: "#0F0F0F", label: false, target: "appearance.background", type: "color", }, }, id: "background", layoutGroups: [ { columns: 2, controls: ["includeBackground", "background"], layout: "inline", }, ], title: "Background", }, { controls: { intensity: { applicability: { mode: "always" as const }, defaultValue: 50, label: "Intensity", max: 100, min: 0, target: "product.intensity", type: "slider", }, }, id: "product", title: "Product", }, ], title: "Module Composition", }, }, persistence: { storage: "localStorage", }, }; } type UnsafeMutableProductBaseFixture = { -readonly [Key in keyof ToolcraftProductBase]: ToolcraftProductBase[Key]; }; function asUnsafeMutableProductBaseFixture( base: ToolcraftProductBase, ): UnsafeMutableProductBaseFixture { return base as UnsafeMutableProductBaseFixture; } const exportModules = Object.freeze([ imageExportModule(), svgExportModule(), videoExportModule(), ]); function createResolvedFixture() { return resolveToolcraftProductDefinition({ base: deepFreeze(createBaseSchema()), modules: exportModules, }); } function expectRejectedWithoutMutation( base: ToolcraftProductBase, message: string, ): void { const frozenBase = deepFreeze(base); const before = JSON.stringify(frozenBase); expect(() => resolveToolcraftProductDefinition({ base: frozenBase, modules: exportModules, }), ).toThrowError(message); expect(JSON.stringify(frozenBase)).toBe(before); expect(Object.isFrozen(frozenBase)).toBe(true); expect(Object.isFrozen(frozenBase.panels)).toBe(true); } describe("resolved product module integration", () => { it("composes internal export modules through current defineToolcraft and ToolcraftApp", () => { const fixture = createResolvedFixture(); const schema = fixture; const { modulePlan } = fixture; expect(schema.panels.controls?.sections.map(({ title }) => title)).toEqual([ "Setup", "Product", "Image Export", "Video Export", "Export", ]); expect( getToolcraftArtifactExportActions(schema).map(({ role }) => role), ).toEqual(["export-image", "export-svg", "export-video"]); expect(schema.panels.timeline).toMatchObject({ enabled: true, mode: "playback", }); expect(schema.persistence).toEqual({ additionalValueTargets: [], include: ["canvas", "panels", "timeline", "values"], key: "toolcraft:module-composition:state:v2", storage: "localStorage", version: 2, }); expect( schema.persistence.storage === "localStorage" ? schema.persistence.include.filter((slice) => slice === "timeline") : [], ).toHaveLength(1); expect(modulePlan.modules).toContainEqual({ id: "timeline", origin: "default-provider", provides: ["timeline.playback"], requestedBy: ["video-export"], requires: [], }); expect(modulePlan.portRequirements).toEqual([ { applicability: "product-scene", consumers: ["image-export", "video-export"], id: "scene.rasterFrameRenderer", }, { applicability: "always", consumers: ["svg-export"], id: "scene.vectorFrameRenderer", }, ]); expect(schema).not.toHaveProperty("modules"); expect(Object.isFrozen(fixture)).toBe(true); expect(Object.isFrozen(modulePlan)).toBe(true); expect(Object.isFrozen(exportModules)).toBe(true); const { container } = render( } exportRenderer={{ baseFileName: "module-composition", renderFrame: () => undefined, }} schema={schema} svgExportRenderer={{ baseFileName: "module-composition", renderFrame: () => undefined, }} />, ); expect(screen.getAllByText("Image Export").length).toBeGreaterThan(0); expect(screen.getAllByText("Video Export").length).toBeGreaterThan(0); expect(screen.getByRole("button", { name: "Export PNG" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Export SVG" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Export MP4" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy(); expect( container.querySelector('[data-panel-type="timeline"]'), ).toBeTruthy(); expect( container.querySelector( '[data-toolcraft-timeline-panel-variant="compact"]', ), ).toBeTruthy(); }); it("reuses host rejection when image/video modules lack the shared raster renderer", () => { const schema = createResolvedFixture(); expect(() => render( } schema={schema} svgExportRenderer={{ baseFileName: "module-composition", renderFrame: () => undefined, }} />, ), ).toThrow( 'Toolcraft integration port "scene.rasterFrameRenderer" is missing', ); }); it("reuses host rejection when the SVG module lacks svgExportRenderer", () => { const schema = createResolvedFixture(); expect(() => render( } exportRenderer={{ baseFileName: "module-composition", renderFrame: () => undefined, }} schema={schema} />, ), ).toThrow( 'Toolcraft integration port "scene.vectorFrameRenderer" is missing', ); }); }); describe("resolveToolcraftProductDefinition preflight", () => { it("rejects an authored timeline owned by resolved modules", () => { const base = createBaseSchema(); (base.panels as Record).timeline = { enabled: true, mode: "playback", }; expectRejectedWithoutMutation( base, "Toolcraft product definition validation failed:\n- Toolcraft product base panels.timeline is module-owned and cannot be authored directly.", ); }); it("rejects a base control target owned by resolved modules", () => { const base = createBaseSchema(); base.panels.controls!.sections[1]!.controls.intensity!.target = "export.image.format"; expectRejectedWithoutMutation( base, 'Toolcraft product definition validation failed:\n- Product control target "export.image.format" conflicts with reserved standard artifact ownership.', ); }); it("rejects a base artifact action role owned by resolved modules", () => { const base = createBaseSchema(); base.panels.controls!.sections[1]!.controls.productActions = { applicability: { mode: "always" as const }, actions: createToolcraftArtifactExportActions(["export-svg"]), target: "product.actions", type: "panelActions", }; expectRejectedWithoutMutation( base, 'Toolcraft product definition validation failed:\n- Product artifact action role "export-svg" conflicts with reserved standard artifact ownership.', ); }); it("rejects explicit persistence opt-out when a module requires a slice", () => { const base = asUnsafeMutableProductBaseFixture(createBaseSchema()); base.persistence = { storage: "none" }; expectRejectedWithoutMutation( base, 'Toolcraft product persistence storage "none" conflicts with resolved persistence slices: timeline.', ); }); it("rejects module control contributions when the base has no controls panel", () => { const base = asUnsafeMutableProductBaseFixture(createBaseSchema()); base.panels = {}; expectRejectedWithoutMutation( base, "Toolcraft product definition validation failed:\n- Toolcraft product definition cannot compose module control contributions without a base controls panel.", ); }); it("rejects a base section identity owned by resolved modules", () => { const base = createBaseSchema(); base.panels.controls!.sections[1]!.id = "runtime.image-export"; expectRejectedWithoutMutation( base, 'Toolcraft product definition validation failed:\n- Product control section "runtime.image-export" conflicts with reserved standard artifact ownership.', ); }); });