import { describe, expect, it, vi } from "vitest"; import type { ToolcraftControlSchema } from "../schema/types"; import { createToolcraftBinaryAssetRepositoryEntry, type ToolcraftBinaryAssetLease, type ToolcraftBinaryAssetRepository, type ToolcraftBinaryAssetRepositoryEntry, } from "../source-assets/repository/binary-asset-repository"; import { createMemoryToolcraftBinaryAssetRepository } from "../source-assets/repository/memory-binary-asset-repository"; import { createToolcraftSourceAssetCoordinator } from "../source-assets/source-asset-coordinator"; import { createLease, createRepository, createStateHarness, deferred, } from "../source-assets/source-asset-coordinator-test-support"; import type { ToolcraftModelAsset } from "../state/types"; import { TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS } from "./model-import-limit-values"; import type { ToolcraftModelWorkerAnalysisSummary } from "./model-import-types"; import type { ToolcraftModelWorkerClient, ToolcraftModelWorkerClientObservers, } from "./worker/model-import-worker-client"; import type { ToolcraftModelDecodeAnalyzeWorkerResult, ToolcraftModelWorkerGeometryTerminalResponse, } from "./worker/model-import-worker-protocol"; const SHA_A = `sha256:${"a".repeat(64)}`; const SHA_B = `sha256:${"b".repeat(64)}`; function control(format: "glb" | "gltf" = "glb"): ToolcraftControlSchema { return { assetKind: "model", modelFormats: [format], modelLimits: TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS, multiple: false, target: "source.model", topologyProfile: "realtime-mesh", type: "fileDrop", }; } function gltfBundle(): readonly File[] { return [ new File([ JSON.stringify({ asset: { version: "2.0" }, buffers: [{ byteLength: 4, uri: "geometry.bin" }], }), ], "scene.gltf", { type: "model/gltf+json" }), new File([new Uint8Array([1, 2, 3, 4])], "geometry.bin", { type: "application/octet-stream", }), ]; } function glb(): File { return new File( [new Uint8Array([0x67, 0x6c, 0x54, 0x46])], "replacement.glb", { type: "model/gltf-binary" }, ); } function analysis( outcome: "clean" | "fatal", ): ToolcraftModelWorkerAnalysisSummary { return { analyzerVersion: "toolcraft-topology-v1", diagnostics: [{ affectedCount: 1, code: outcome === "fatal" ? "no-renderable-triangles" : "boundary-edges", explanation: `${outcome} result`, severity: outcome === "fatal" ? "fatal" : "info", }], limits: TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS, outcome, profile: "realtime-mesh", statistics: { boundaryEdgeCount: 0, componentCount: 1, decodedBytes: 64, edgeCount: 3, estimatedPeakWorkerBytes: 128, estimatedRepairBytes: 0, nodeCount: 1, nonManifoldEdgeCount: 0, nonManifoldVertexCount: 0, primitiveCount: 1, triangleCount: 1, unusedVertexCount: 0, vertexCount: 3, }, }; } function result(outcome: "clean" | "fatal"): ToolcraftModelDecodeAnalyzeWorkerResult { return { analysis: analysis(outcome), appearanceResources: [], canonicalDocument: new Uint8Array([4, 5, 6]).buffer, canonicalDocumentDigest: SHA_B, operation: "decode-and-analyze", receiptDigest: SHA_A, }; } function workerClient( outcome: "clean" | "fatal", terminalGate?: ReturnType>, ): ToolcraftModelWorkerClient & { dispose: ReturnType; importModel: ReturnType; } { const importModel = vi.fn(async ( request: { jobId: string }, observers: ToolcraftModelWorkerClientObservers = {}, ) => { observers.onDraft?.({ draft: { appearanceResources: [], canonicalDocument: new Uint8Array([1, 2, 3]).buffer, canonicalDocumentDigest: SHA_A, receiptDigest: SHA_B, }, generation: 1, jobId: request.jobId, kind: "draft", }); if (terminalGate) return terminalGate.promise; return { generation: 1, jobId: request.jobId, kind: "result" as const, result: result(outcome), }; }); return { cancel: vi.fn(), dispose: vi.fn(), extractModelPackage: vi.fn(), importModel, repair: vi.fn(), reset: vi.fn(), }; } function previousModel(): ToolcraftModelAsset { const compact = { boundaryEdges: 0, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean" as const, triangles: 1, vertices: 3, }; return { activeDocumentRef: "document:previous", analysis: compact, assetKind: "model", fileName: "previous.glb", id: "media-previous", layerId: "layer-previous", lifecycle: "clean", mimeType: "model/gltf-binary", originalAnalysis: compact, originalDocumentRef: "document:previous", position: { x: 0, y: 0 }, size: { height: 512, unit: "px", width: 512 }, sourceBundleDigest: "sha256:previous", sourceBundleRef: "bundle:previous", sourceTarget: "source.model", topologyProfile: "realtime-mesh", }; } function createCommitGatedRepository(commitGate: ReturnType>) { const commitStarted = deferred(); const committed = new Map(); const staged = new Map(); let terminal = false; const assertActive = () => { if (terminal) throw new Error("Test binary asset lease is terminal"); }; const lease: ToolcraftBinaryAssetLease = { commit: async () => { assertActive(); terminal = true; commitStarted.resolve(undefined); await commitGate.promise; for (const [ref, value] of staged) committed.set(ref, value); staged.clear(); }, get: async (ref) => { assertActive(); return staged.get(ref) ?? committed.get(ref) ?? null; }, put: async (ref, bytes, options) => { assertActive(); staged.set( ref, createToolcraftBinaryAssetRepositoryEntry(ref, bytes, options), ); }, refs: () => terminal ? [] : [...staged.keys()].sort(), rollback: async () => { assertActive(); terminal = true; staged.clear(); }, }; const repository: ToolcraftBinaryAssetRepository = { beginLease: async () => lease, collect: async () => [], dispose: async () => undefined, get: async (ref) => committed.get(ref) ?? null, }; return { commitStarted: commitStarted.promise, repository }; } describe("model source asset coordinator integration", () => { it("uses the production model handler and owns worker disposal", async () => { const lease = createLease(); const repository = createRepository([lease]); const harness = createStateHarness(); const worker = workerClient("clean"); const coordinator = createToolcraftSourceAssetCoordinator({ createModelWorkerClient: () => worker, dispatch: harness.dispatch, getState: harness.getState, repository, }); const outcome = await coordinator.importBatch( { files: [glb()], origin: "panel" }, control(), ); expect(outcome.kind).toBe("committed"); expect(harness.getState().mediaAssets).toHaveLength(1); expect(harness.getState().mediaAssets[0]).toMatchObject({ assetKind: "model", fileName: "replacement.glb", lifecycle: "clean", sourceTarget: "source.model", }); expect(lease.commit).toHaveBeenCalledTimes(1); await coordinator.dispose(); expect(worker.dispose).toHaveBeenCalledTimes(1); }); it("commits a multi-file glTF bundle as one root model row", async () => { const lease = createLease(); const harness = createStateHarness(); const worker = workerClient("clean"); const coordinator = createToolcraftSourceAssetCoordinator({ createModelWorkerClient: () => worker, dispatch: harness.dispatch, getState: harness.getState, repository: createRepository([lease]), }); const outcome = await coordinator.importBatch( { files: gltfBundle(), origin: "panel" }, control("gltf"), ); expect(outcome).toMatchObject({ kind: "committed" }); expect(worker.importModel).toHaveBeenCalledTimes(1); expect(harness.dispatch).toHaveBeenCalledTimes(1); expect(harness.getState().mediaAssets).toHaveLength(1); expect(harness.getState().mediaAssets[0]).toMatchObject({ assetKind: "model", fileName: "scene.gltf", sourceTarget: "source.model", }); await coordinator.dispose(); }); it("publishes a staged draft without replacing the committed model", async () => { const gate = deferred(); const repository = createMemoryToolcraftBinaryAssetRepository(); const harness = createStateHarness(); harness.setState({ ...harness.getState(), mediaAssets: [previousModel()], }); const coordinator = createToolcraftSourceAssetCoordinator({ createModelWorkerClient: () => workerClient("clean", gate), dispatch: harness.dispatch, getState: harness.getState, repository, }); const pending = coordinator.importBatch( { files: [glb()], origin: "panel" }, control(), ); await vi.waitFor(() => { expect(coordinator.getOperation("source.model")).toMatchObject({ phase: "analyzing", stagedDocumentRef: `toolcraft:model-document:${SHA_A}`, }); }); expect(harness.getState().mediaAssets).toEqual([previousModel()]); await expect(coordinator.resolveResource!( `toolcraft:model-document:${SHA_A}`, { signal: new AbortController().signal }, )).resolves.toEqual(new Uint8Array([1, 2, 3])); gate.resolve({ generation: 1, jobId: "source-asset-1", kind: "result", result: result("clean"), }); await expect(pending).resolves.toMatchObject({ kind: "committed" }); await coordinator.dispose(); }); it("waits for a terminal lease to publish before resolving staged bytes", async () => { const commitGate = deferred(); const { commitStarted, repository } = createCommitGatedRepository(commitGate); const harness = createStateHarness(); const coordinator = createToolcraftSourceAssetCoordinator({ createModelWorkerClient: () => workerClient("clean"), dispatch: harness.dispatch, getState: harness.getState, repository, }); const pending = coordinator.importBatch( { files: [glb()], origin: "panel" }, control(), ); await commitStarted; let didResolve = false; const resolution = coordinator.resolveResource!( `toolcraft:model-document:${SHA_B}`, { signal: new AbortController().signal }, ).then((bytes) => { didResolve = true; return bytes; }); await Promise.resolve(); expect(didResolve).toBe(false); commitGate.resolve(undefined); await expect(resolution).resolves.toEqual(new Uint8Array([4, 5, 6])); await expect(pending).resolves.toMatchObject({ kind: "committed" }); await coordinator.dispose(); }); it("aborts staged resource resolution while publication is pending", async () => { const commitGate = deferred(); const { commitStarted, repository } = createCommitGatedRepository(commitGate); const harness = createStateHarness(); const coordinator = createToolcraftSourceAssetCoordinator({ createModelWorkerClient: () => workerClient("clean"), dispatch: harness.dispatch, getState: harness.getState, repository, }); const pending = coordinator.importBatch( { files: [glb()], origin: "panel" }, control(), ); await commitStarted; const controller = new AbortController(); const resolution = coordinator.resolveResource!( `toolcraft:model-document:${SHA_B}`, { signal: controller.signal }, ); controller.abort(); await expect(resolution).rejects.toMatchObject({ name: "AbortError" }); commitGate.resolve(undefined); await expect(pending).resolves.toMatchObject({ kind: "committed" }); await coordinator.dispose(); }); it("rolls back fatal replacement and preserves the previous model", async () => { const lease = createLease(); const harness = createStateHarness(); const previous = previousModel(); harness.setState({ ...harness.getState(), mediaAssets: [previous] }); const coordinator = createToolcraftSourceAssetCoordinator({ createModelWorkerClient: () => workerClient("fatal"), dispatch: harness.dispatch, getState: harness.getState, repository: createRepository([lease]), }); await expect(coordinator.importBatch( { files: [glb()], origin: "panel" }, control(), )).resolves.toMatchObject({ feedback: { category: "topology", code: "model-topology-fatal" }, kind: "rejected", }); expect(harness.getState().mediaAssets).toEqual([previous]); expect(lease.rollback).toHaveBeenCalledTimes(1); expect(lease.commit).not.toHaveBeenCalled(); await coordinator.dispose(); }); });