import { act, render, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { encodeToolcraftModelDocument } from "../../model-import/canonical/model-document-codec"; import { createValidModelDocument } from "../../model-import/canonical/model-document-test-support"; import { model3dModule } from "../../modules/built-ins/model-3d/model-3d-module"; import { spatialViewModule } from "../../modules/built-ins/spatial-view/spatial-view-module"; import { defineToolcraft } from "../../schema/define-toolcraft"; import type { ToolcraftModelPresentationConsumerDeclaration, ToolcraftModelPresentationLease, ToolcraftModelResourceResolver, } from "./model-render-binding"; import { useToolcraftModelPresentationConsumer, type ToolcraftModelPresentationConsumer, } from "./model-presentation-consumer"; import { resolveToolcraftModelPresentationMode } from "./model-presentation-mode"; import { ToolcraftModelRenderProvider } from "./model-render-provider"; const declaration = Object.freeze({ id: "product-model", sourceTarget: "source.model", }); function ConsumerProbe({ capture, requestedDeclaration = declaration, }: { capture: (consumer: ToolcraftModelPresentationConsumer) => void; requestedDeclaration?: ToolcraftModelPresentationConsumerDeclaration; }): null { capture(useToolcraftModelPresentationConsumer(requestedDeclaration)); return null; } function createResolver(): ToolcraftModelResourceResolver { const bytes = encodeToolcraftModelDocument(createValidModelDocument()); return vi.fn(async () => bytes); } function freezeRecursively(value: Value): Value { if (value === null || typeof value !== "object" || Object.isFrozen(value)) { return value; } for (const child of Object.values(value)) freezeRecursively(child); return Object.freeze(value); } describe("Toolcraft model presentation mode", () => { const schema = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: { controls: { sections: [ { id: "test-section-1", controls: { model: { applicability: { mode: "always" as const }, assetKind: "model", target: "source.model", type: "fileDrop", }, orientation: { applicability: { mode: "always" as const }, defaultValue: { position: [0, 0, 5], up: [0, 1, 0] }, target: "view.orientation", type: "orientationGizmo", }, }, title: "Model", }, ], title: "Controls", }, }, }, modules: [model3dModule(), spatialViewModule()], }); it("defaults to runtime presentation and validates custom declarations", () => { expect(resolveToolcraftModelPresentationMode(schema)).toEqual({ mode: "runtime", }); expect( resolveToolcraftModelPresentationMode(schema, { consumers: [ { ...declaration, orientationTarget: "view.orientation", }, ], mode: "custom", }), ).toEqual({ consumers: [ { ...declaration, orientationTarget: "view.orientation", }, ], mode: "custom", }); }); it("preserves canonical identity only for the schema that validated it", () => { const frozenSchema = freezeRecursively(structuredClone(schema)); const resolved = resolveToolcraftModelPresentationMode(frozenSchema, { consumers: [declaration], mode: "custom", }); const frozenInspection = vi.spyOn(Object, "isFrozen"); expect(resolveToolcraftModelPresentationMode(frozenSchema, resolved)).toBe( resolved, ); expect(frozenInspection).not.toHaveBeenCalled(); frozenInspection.mockRestore(); const differentSchema = defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: { controls: { sections: [ { id: "test-section-2", controls: { model: { applicability: { mode: "always" as const }, assetKind: "model", target: "source.other", type: "fileDrop", }, }, title: "Model", }, ], title: "Controls", }, }, }, modules: [model3dModule()], }); expect(() => resolveToolcraftModelPresentationMode(differentSchema, resolved), ).toThrow(/exactly one model fileDrop/iu); }); it("does not reuse canonical identity after a mutable schema changes", () => { const mutableSchema = structuredClone(schema); const resolved = resolveToolcraftModelPresentationMode(mutableSchema, { consumers: [declaration], mode: "custom", }); const modelControl = mutableSchema.panels.controls?.sections .flatMap(({ controls }) => Object.values(controls)) .find( (control) => control.type === "fileDrop" && control.target === "source.model", ); if (modelControl?.type !== "fileDrop") { throw new Error("Expected the mutable model control fixture."); } (modelControl as { target: string }).target = "source.changed"; expect(() => resolveToolcraftModelPresentationMode(mutableSchema, resolved), ).toThrow(/exactly one model fileDrop/iu); }); it("rejects duplicate, unknown, untrimmed, and mismatched declarations", () => { expect(() => resolveToolcraftModelPresentationMode(schema, { consumers: [ declaration, { ...declaration, sourceTarget: "source.other" }, ], mode: "custom", }), ).toThrow(/consumer id.*unique/iu); expect(() => resolveToolcraftModelPresentationMode(schema, { consumers: [declaration, { ...declaration, id: "other" }], mode: "custom", }), ).toThrow(/source target.*unique/iu); expect(() => resolveToolcraftModelPresentationMode(schema, { consumers: [{ ...declaration, id: " product-model" }], mode: "custom", }), ).toThrow(/trimmed/iu); expect(() => resolveToolcraftModelPresentationMode(schema, { consumers: [{ ...declaration, sourceTarget: "source.unknown" }], mode: "custom", }), ).toThrow(/exactly one model fileDrop/iu); expect(() => resolveToolcraftModelPresentationMode(schema, { consumers: [{ ...declaration, orientationTarget: "view.other" }], mode: "custom", }), ).toThrow(/orientationGizmo/iu); }); }); describe("custom model presentation consumer", () => { it("registers an exact declaration and clears feedback after acquisition", async () => { const clearPresentationFeedback = vi.fn(); const reportPresentationFeedback = vi.fn(); let consumer: ToolcraftModelPresentationConsumer | undefined; const view = render( { consumer = value; }} /> , ); await waitFor(() => expect(consumer).toBeDefined()); await waitFor(() => { expect(clearPresentationFeedback).toHaveBeenCalledWith("source.model"); }); let lease: ToolcraftModelPresentationLease | undefined; await act(async () => { lease = await consumer!.acquirePresentation("document:model", { purpose: "preview", signal: new AbortController().signal, }); }); expect(lease?.root.getObjectByName("primitive-1")).toBeDefined(); expect(reportPresentationFeedback).not.toHaveBeenCalled(); lease?.release(); view.unmount(); }); it("publishes typed feedback for a missing consumer and acquisition failure", async () => { const reportMissing = vi.fn(); const missing = render( , ); await waitFor(() => { expect(reportMissing).toHaveBeenCalledWith( "source.model", expect.objectContaining({ category: "resource-unavailable", code: "model-presentation-consumer-missing", }), ); }); missing.unmount(); let consumer: ToolcraftModelPresentationConsumer | undefined; const reportUnavailable = vi.fn(); const unavailable = render( null)} > { consumer = value; }} /> , ); await waitFor(() => expect(consumer).toBeDefined()); await expect( consumer!.acquirePresentation("document:missing", { purpose: "preview", signal: new AbortController().signal, }), ).rejects.toThrow(/unavailable/iu); expect(reportUnavailable).toHaveBeenCalledWith( "source.model", expect.objectContaining({ category: "resource-unavailable", code: "model-presentation-unavailable", }), ); unavailable.unmount(); }); it("rejects a consumer hook declaration that differs from composition", () => { expect(() => render( undefined} requestedDeclaration={{ ...declaration, sourceTarget: "source.other", }} /> , ), ).toThrow(/does not match/iu); }); it("requires an oriented consumer to match the visible orientation gizmo", () => { const oriented = { ...declaration, orientationTarget: "view.orientation", }; expect(() => render( undefined} requestedDeclaration={oriented} /> , ), ).toThrow(/visible orientationGizmo/iu); }); });