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);
});
});