import * as React from "react";
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { defineToolcraft } from "../../schema/define-toolcraft";
import { createHydrationModelAsset } from "../../model-import/model-persistence-hydration-test-support";
import { createToolcraftState } from "../../state/create-template-state";
import { createToolcraftExternalStore } from "../../state/toolcraft-external-store";
import type { ToolcraftSourceAssetCoordinator } from "../../source-assets/source-asset-coordinator";
import { useOptionalToolcraftModelRenderHost } from "../model-rendering/model-render-provider";
import { ToolcraftModelPresentationModeContext } from "../model-rendering/model-presentation-mode";
import { ToolcraftRoot } from "./toolcraft-root";
import { ToolcraftSourceAssetProvider } from "./toolcraft-source-asset-context";
import {
ToolcraftSourceAssetCoordinatorProbe as CoordinatorProbe,
createTestSourceAssetCoordinator as createTestCoordinator,
createTestSourceAssetStore as createTestStore,
} from "./toolcraft-source-asset-test-support";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function ModelRenderHostProbe(): React.JSX.Element {
const host = useOptionalToolcraftModelRenderHost();
return {host ? "ready" : "missing"};
}
describe("Toolcraft source asset provider", () => {
it("shares one app-owned coordinator and disposes it on unmount", async () => {
const schema = defineToolcraft({
canvas: { enabled: true, upload: true },
panels: {},
});
const coordinators: ToolcraftSourceAssetCoordinator[] = [];
const capture = (coordinator: ToolcraftSourceAssetCoordinator) => {
coordinators.push(coordinator);
};
const rendered = render(
,
);
expect(coordinators).toHaveLength(2);
expect(coordinators[0]).toBe(coordinators[1]);
const dispose = vi.spyOn(coordinators[0] as ToolcraftSourceAssetCoordinator, "dispose");
rendered.unmount();
await waitFor(() => expect(dispose).toHaveBeenCalledTimes(1));
});
it("creates one stable coordinator owner under React StrictMode", async () => {
const store = createTestStore();
const coordinator = createTestCoordinator();
const createCoordinator = vi.fn(() => coordinator);
const coordinators: ToolcraftSourceAssetCoordinator[] = [];
const rendered = render(
coordinators.push(value)} />
,
);
expect(createCoordinator).toHaveBeenCalledTimes(1);
expect(createCoordinator).toHaveBeenCalledWith(store);
expect(coordinators.length).toBeGreaterThanOrEqual(2);
expect(coordinators.every((value) => value === coordinator)).toBe(true);
expect(rendered.getByTestId("strict-source-asset-child")).toBeDefined();
rendered.unmount();
await waitFor(() => expect(coordinator.dispose).toHaveBeenCalledTimes(1));
});
it("connects the production resource resolver to the standard model render host", () => {
const coordinator = {
...createTestCoordinator(),
resolveResource: vi.fn(async () => null),
} satisfies ToolcraftSourceAssetCoordinator;
const rendered = render(
coordinator}
store={createTestStore()}
>
,
);
expect(rendered.getByTestId("model-render-host").textContent).toBe("ready");
});
it("scopes unavailable-resource browser proof to one real resolver resource and cleans up", async () => {
const resourceRef = `media:image:sha256:${"c".repeat(64)}`;
const store = createToolcraftExternalStore(
createToolcraftState(
defineToolcraft({ canvas: { enabled: true }, panels: {} }),
{
mediaAssets: [
{
assetKind: "image",
fileName: "fixture.png",
id: "fixture-image",
layerId: "fixture-layer",
lifecycle: "ready",
mimeType: "image/png",
position: { x: 0, y: 0 },
resourceRef,
size: { height: 20, unit: "px", width: 20 },
sourceTarget: "source.images",
},
],
},
),
);
const baseResolveResource = vi.fn(async () => new Uint8Array([1, 2, 3]));
const coordinator = {
...createTestCoordinator(),
resolveResource: baseResolveResource,
} satisfies ToolcraftSourceAssetCoordinator;
let proofCoordinator: ToolcraftSourceAssetCoordinator | null = null;
const rendered = render(
coordinator}
store={store}
>
{
proofCoordinator = value;
}} />
,
);
const request = {
fileName: "fixture.png",
requestId: "proof-request",
sourceTarget: "source.images",
};
document.dispatchEvent(
new CustomEvent(
"toolcraft.browser-proof.unavailable-resource-request",
{ detail: { ...request, action: "activate" } },
),
);
await waitFor(() => {
expect(store.getCommittedState().mediaAssets[0]).toMatchObject({
lifecycle: "unavailable",
});
expect(
rendered.container
.querySelector('[data-slot="toolcraft-runtime-app"]')
?.getAttribute("data-toolcraft-unavailable-resource-evidence"),
).toContain('"status":"active"');
});
await expect(
proofCoordinator!.resolveResource!(
resourceRef,
{ signal: new AbortController().signal },
),
).resolves.toBeNull();
document.dispatchEvent(
new CustomEvent(
"toolcraft.browser-proof.unavailable-resource-request",
{ detail: { ...request, action: "cleanup" } },
),
);
await waitFor(() => {
expect(store.getCommittedState().mediaAssets[0]).toMatchObject({
lifecycle: "ready",
});
expect(
rendered.container
.querySelector('[data-slot="toolcraft-runtime-app"]')
?.getAttribute("data-toolcraft-unavailable-resource-evidence"),
).toContain('"status":"cleaned"');
});
await expect(
proofCoordinator!.resolveResource!(
resourceRef,
{ signal: new AbortController().signal },
),
).resolves.toEqual(new Uint8Array([1, 2, 3]));
expect(baseResolveResource).toHaveBeenCalledTimes(3);
});
it("reports a committed custom model whose declared consumer is not mounted", async () => {
const schema = defineToolcraft({
canvas: { enabled: true },
panels: {
controls: {
sections: [{
controls: {
model: {
assetKind: "model",
target: "source.model",
type: "fileDrop",
},
},
title: "Model",
}],
title: "Controls",
},
},
});
const store = createToolcraftExternalStore(createToolcraftState(schema));
const { id: _id, layerId: _layerId, ...model } =
createHydrationModelAsset("document:model", {
lifecycle: "clean",
sourceTarget: "source.model",
});
store.dispatch({
assets: [model],
replaceExisting: true,
type: "media.importBatch",
});
const coordinator = {
...createTestCoordinator(),
resolveResource: vi.fn(async () => null),
} satisfies ToolcraftSourceAssetCoordinator;
const rendered = render(
coordinator}
store={store}
>
,
);
await waitFor(() => {
expect(coordinator.reportPresentationFeedback).toHaveBeenCalledWith(
"source.model",
expect.objectContaining({
code: "model-presentation-consumer-missing",
}),
);
});
rendered.unmount();
});
it("reconciles model hydration when default attachments are removed and reset", async () => {
const schema = defineToolcraft({
canvas: { enabled: true, upload: true },
media: {
defaultAssets: [{
assetKind: "model",
fileName: "default.glb",
sourceFiles: [{
dataUrl: "data:model/gltf-binary;base64,AAAA",
path: "default.glb",
}],
sourceTarget: "source.model",
}],
},
panels: {
controls: {
sections: [{
controls: {
source: {
assetKind: "model",
defaultValue: null,
target: "source.model",
type: "fileDrop",
},
},
title: "Model",
}],
title: "Controls",
},
},
});
const store = createToolcraftExternalStore(createToolcraftState(schema));
const coordinator = createTestCoordinator();
const rendered = render(
coordinator}
store={store}
>
undefined} />
,
);
await waitFor(() => expect(coordinator.hydrateModels).toHaveBeenCalledTimes(1));
expect(coordinator.clearPresentationFeedback).toHaveBeenLastCalledWith(
"source.model",
);
const defaultAssetId = store.getCommittedState().mediaAssets[0]?.id;
if (!defaultAssetId) throw new Error("Expected a default model attachment.");
store.dispatch({ mediaId: defaultAssetId, type: "media.delete" });
await waitFor(() => expect(coordinator.hydrateModels).toHaveBeenCalledTimes(2));
expect(coordinator.clearPresentationFeedback).toHaveBeenCalledTimes(2);
expect(coordinator.clearPresentationFeedback).toHaveBeenLastCalledWith(
"source.model",
);
store.dispatch({ type: "controls.reset" });
await waitFor(() => expect(coordinator.hydrateModels).toHaveBeenCalledTimes(3));
expect(coordinator.clearPresentationFeedback).toHaveBeenCalledTimes(3);
expect(coordinator.clearPresentationFeedback).toHaveBeenLastCalledWith(
"source.model",
);
rendered.unmount();
});
it("rehydrates a default image after reset restores its attachment", async () => {
const schema = defineToolcraft({
canvas: { enabled: true },
media: {
defaultAssets: [{
dataUrl: "data:image/png;base64,AQID",
fileName: "default.png",
id: "default-image",
layerId: "default-layer",
mimeType: "image/png",
sourceTarget: "source.image",
}],
},
panels: {
controls: {
sections: [{
controls: {
source: {
defaultValue: null,
target: "source.image",
type: "fileDrop",
},
},
title: "Source",
}],
title: "Controls",
},
},
});
const store = createToolcraftExternalStore(createToolcraftState(schema));
const rendered = render(
,
);
await waitFor(() => {
expect(store.getCommittedState().mediaAssets[0]).toMatchObject({
lifecycle: "ready",
});
});
store.dispatch({ mediaId: "default-image", type: "media.delete" });
store.dispatch({ type: "controls.reset" });
expect(store.getCommittedState().mediaAssets[0]).toMatchObject({
lifecycle: "restoring",
});
await waitFor(() => {
expect(store.getCommittedState().mediaAssets[0]).toMatchObject({
lifecycle: "ready",
});
});
rendered.unmount();
});
it("reports asynchronous disposal failure without an unhandled rejection", async () => {
const store = createTestStore();
const failure = new AggregateError(
[new Error("rollback failed")],
"cleanup failed",
);
const coordinator = createTestCoordinator({
dispose: vi.fn(async () => Promise.reject(failure)),
});
const onError = vi.fn();
const onUnhandledRejection = vi.fn();
window.addEventListener("unhandledrejection", onUnhandledRejection);
const rendered = render(
coordinator}
onError={onError}
store={store}
>
undefined} />
,
);
rendered.unmount();
await waitFor(() => expect(onError).toHaveBeenCalledWith(failure));
await Promise.resolve();
window.removeEventListener("unhandledrejection", onUnhandledRejection);
expect(onUnhandledRejection).not.toHaveBeenCalled();
});
it("uses the runtime reportError boundary by default", async () => {
const failure = new Error("repository owner disposal failed");
const reportError = vi.fn();
vi.stubGlobal("reportError", reportError);
const coordinator = createTestCoordinator({
dispose: vi.fn(async () => Promise.reject(failure)),
});
const rendered = render(
coordinator}
store={createTestStore()}
>
undefined} />
,
);
rendered.unmount();
await waitFor(() => expect(reportError).toHaveBeenCalledWith(failure));
});
});