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