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 { 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); } describe("Toolcraft model presentation mode", () => { const schema = defineToolcraft({ canvas: { enabled: true }, panels: { controls: { sections: [{ controls: { model: { assetKind: "model", target: "source.model", type: "fileDrop", }, orientation: { defaultValue: { position: [0, 0, 5], up: [0, 1, 0] }, target: "view.orientation", type: "orientationGizmo", }, }, title: "Model", }], title: "Controls", }, }, }); 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("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); }); });