import { describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "../state/create-template-state"; import { toolcraftReducer } from "../state/reducer"; import type { ToolcraftCommand, ToolcraftState } from "../state/types"; import type { ToolcraftPreparedSourceAssetRecord, ToolcraftSourceAssetHandler, } from "./source-asset-types"; import { createLease, createRepository, deferred, } from "./source-asset-coordinator-test-support"; import { createToolcraftSourceAssetCoordinator } from "./source-asset-coordinator"; import { createToolcraftSourceAssetRegistry } from "./source-asset-registry"; function createApp() { return defineToolcraft({ canvas: { enabled: true, upload: true }, media: { defaultAssets: [ { assetKind: "model", fileName: "default.glb", id: "default-model", layerId: "default-model-layer", sourceFiles: [{ dataUrl: "data:model/gltf-binary;base64,AAAA", path: "default.glb", }], sourceTarget: "source.model", }, ], }, panels: { controls: { sections: [{ controls: { model: { assetKind: "model", defaultValue: null, target: "source.model", type: "fileDrop", }, }, title: "Model", }], title: "Controls", }, }, }); } function createHarness() { let state = createToolcraftState(createApp()); const dispatch = vi.fn((command: ToolcraftCommand) => { state = toolcraftReducer(state, command); }); return { dispatch, getState: () => state, }; } function modelDraft(): ToolcraftPreparedSourceAssetRecord<"model"> { const analysis = { boundaryEdges: 0, diagnostics: [] as const, disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean" as const, triangles: 12, vertices: 8, }; return { activeDocumentRef: "document:ready", analysis, assetKind: "model", fileName: "default.glb", lifecycle: "clean", mimeType: "model/gltf-binary", originalAnalysis: analysis, originalDocumentRef: "document:ready", position: { x: 0, y: 0 }, size: { height: 512, unit: "px", width: 512 }, sourceBundleDigest: "sha256:ready", sourceBundleRef: "bundle:ready", topologyProfile: "realtime-mesh", }; } function createModelHandler( prepare: ToolcraftSourceAssetHandler< "model", ToolcraftPreparedSourceAssetRecord<"model"> >["prepare"], ): ToolcraftSourceAssetHandler< "model", ToolcraftPreparedSourceAssetRecord<"model"> > { return { kind: "model", match: vi.fn((batch, control) => control.assetKind === "model" && batch.files.some((file: File) => file.name === "default.glb") ? { handlerKind: "model" as const, rootFileNames: ["default.glb"], specificity: 100, } : null ), plan: vi.fn((_batch, control, claim) => ({ claim, logicalAssetCount: 1, replaceExisting: true, target: control.target, })), prepare: vi.fn(prepare), present: vi.fn(() => { throw new Error("Presentation is not used by this test."); }), }; } describe("default model hydration", () => { it("uses the standard coordinator path and preserves schema identity across reset", async () => { const harness = createHarness(); const handler = createModelHandler(async () => ({ assets: [modelDraft()], stagedResourceRefs: [], })); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([createLease(), createLease()]), }); await Promise.all([ coordinator.hydrateModels(), coordinator.hydrateModels(), ]); expect(handler.prepare).toHaveBeenCalledTimes(1); expect(harness.getState().mediaAssets[0]).toMatchObject({ id: "default-model", layerId: "default-model-layer", lifecycle: "clean", sourceBundleDigest: "sha256:ready", }); harness.dispatch({ mediaId: "default-model", type: "media.delete" }); harness.dispatch({ type: "controls.reset" }); await coordinator.hydrateModels(); expect(handler.prepare).toHaveBeenCalledTimes(2); expect(harness.getState().mediaAssets[0]).toMatchObject({ id: "default-model", layerId: "default-model-layer", lifecycle: "clean", }); }); it("cannot commit a pending default after the attachment is deleted", async () => { const harness = createHarness(); const started = deferred(); const handler = createModelHandler(({ signal }) => new Promise((resolve, reject) => { started.resolve(undefined); signal.addEventListener("abort", () => { const error = new Error("Default import cancelled"); error.name = "AbortError"; reject(error); }, { once: true }); }) ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([createLease()]), }); const hydration = coordinator.hydrateModels(); await started.promise; harness.dispatch({ mediaId: "default-model", type: "media.delete" }); const reconciliation = coordinator.hydrateModels(); await expect(hydration).resolves.toBeUndefined(); await expect(reconciliation).resolves.toBeUndefined(); expect(harness.getState().mediaAssets).toEqual([]); expect( harness.dispatch.mock.calls.some( ([command]) => command.type === "media.hydrateDefaultModel", ), ).toBe(false); }); });