import { describe, expect, it, vi } from "vitest"; import type { ToolcraftCommand } from "../state/types"; import { createMemoryToolcraftBinaryAssetRepository } from "./repository/memory-binary-asset-repository"; import { createToolcraftSourceAssetRegistry } from "./source-asset-registry"; import { createToolcraftSourceAssetCoordinator } from "../composition/source-asset-coordinator"; import { createFileHandler, createLease, createRepository, createStateHarness, deferred, fileControl, fileDraft, prepared, testFile, } from "./source-asset-coordinator-test-support"; describe("source asset coordinator", () => { it("prepares one generic file batch and dispatches one immutable canonical allocation", async () => { const harness = createStateHarness(); const repository = createMemoryToolcraftBinaryAssetRepository(); const beginLease = vi.spyOn(repository, "beginLease"); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, repository, }); const files = [ testFile("one.csv", "text/csv"), testFile("two.json", "application/json"), ]; Object.defineProperty(files[0], "webkitRelativePath", { value: "dataset/one.csv" }); const outcome = await coordinator.importBatch( { files, origin: "panel", target: "source.files", }, fileControl(), ); expect(outcome).toMatchObject({ kind: "committed" }); expect(beginLease).toHaveBeenCalledTimes(1); expect(harness.dispatch).toHaveBeenCalledTimes(1); const command = harness.dispatch.mock.calls[0]?.[0]; expect(command).toMatchObject({ allocation: { items: [ { draft: { assetKind: "file", fileName: "one.csv" } }, { draft: { assetKind: "file", fileName: "two.json" } }, ], }, type: "media.commitCanonicalImportAllocation", }); if (!command || command.type !== "media.commitCanonicalImportAllocation") { throw new Error("Expected one canonical media allocation command"); } expect(Object.isFrozen(command.allocation)).toBe(true); expect(Object.isFrozen(command.allocation.items)).toBe(true); expect( command.allocation.items.every( (item) => Object.isFrozen(item) && Object.isFrozen(item.draft), ), ).toBe(true); expect( command.allocation.items.every( (item) => !("id" in item.draft) && !("layerId" in item.draft), ), ).toBe(true); expect(harness.getState().history.undo).toHaveLength(1); expect(harness.getState().mediaAssets.map(({ sourcePaths }) => sourcePaths)) .toEqual([["dataset/one.csv"], ["two.json"]]); await coordinator.dispose(); }); it("selects, plans, prepares, leases, commits, and dispatches exactly once", async () => { const harness = createStateHarness(); const lease = createLease(); const repository = createRepository([lease]); const handler = createFileHandler(async ({ batch }) => prepared(...batch.files.map((item) => fileDraft(item.name))), ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository, }); await coordinator.importBatch( { files: [testFile("one.txt"), testFile("two.txt")], origin: "panel", target: "source.files", }, fileControl(), ); expect(handler.match).toHaveBeenCalledTimes(1); expect(handler.plan).toHaveBeenCalledTimes(1); expect(handler.prepare).toHaveBeenCalledTimes(1); expect(repository.beginLease).toHaveBeenCalledTimes(1); expect(lease.commit).toHaveBeenCalledTimes(1); expect(harness.dispatch).toHaveBeenCalledTimes(1); }); it("publishes stable frozen operation snapshots and rolls back cancellation", async () => { const harness = createStateHarness(); const lease = createLease(); const repository = createRepository([lease]); const preparation = deferred>(); const handler = createFileHandler(async (context) => { context.reportOperation({ phase: "staging", progress: 0.5 }); return preparation.promise; }); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository, }); const listener = vi.fn(); coordinator.subscribe(listener); const importPromise = coordinator.importBatch( { files: [testFile("one.txt")], origin: "panel", target: "source.files", }, fileControl(), ); await vi.waitFor(() => expect(handler.prepare).toHaveBeenCalledTimes(1)); const snapshot = coordinator.getOperation("source.files"); expect(snapshot).toMatchObject({ phase: "staging", progress: 0.5, target: "source.files", }); expect(Object.isFrozen(snapshot)).toBe(true); expect(coordinator.getOperation("source.files")).toBe(snapshot); expect(listener).toHaveBeenCalled(); coordinator.cancelTarget("source.files"); preparation.resolve(prepared(fileDraft("one.txt"))); await expect(importPromise).resolves.toEqual({ kind: "cancelled" }); expect(lease.rollback).toHaveBeenCalledTimes(1); expect(lease.commit).not.toHaveBeenCalled(); expect(harness.dispatch).not.toHaveBeenCalled(); }); it("suppresses stale completion and commits only the newest generation", async () => { const harness = createStateHarness(); const firstLease = createLease(); const secondLease = createLease(); const repository = createRepository([firstLease, secondLease]); const firstPreparation = deferred>(); const handler = createFileHandler(async ({ batch }) => { const firstFile = batch.files[0]; return firstFile?.name === "old.txt" ? firstPreparation.promise : prepared(fileDraft("new.txt")); }); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository, }); const oldImport = coordinator.importBatch( { files: [testFile("old.txt")], origin: "panel", target: "source.files", }, fileControl(), ); await vi.waitFor(() => expect(handler.prepare).toHaveBeenCalledTimes(1)); const newImport = coordinator.importBatch( { files: [testFile("new.txt")], origin: "panel", target: "source.files", }, fileControl(), ); await expect(newImport).resolves.toMatchObject({ kind: "committed" }); firstPreparation.resolve(prepared(fileDraft("old.txt"))); await expect(oldImport).resolves.toEqual({ kind: "cancelled" }); expect(firstLease.rollback).toHaveBeenCalledTimes(1); expect(firstLease.commit).not.toHaveBeenCalled(); expect(secondLease.commit).toHaveBeenCalledTimes(1); expect(harness.dispatch).toHaveBeenCalledTimes(1); expect( harness.getState().mediaAssets.map((asset) => asset.fileName), ).toEqual(["new.txt"]); }); it("rolls back preparation and pre-commit failures", async () => { const preparationLease = createLease(); const commitLease = createLease({ commit: vi.fn(async () => { throw new Error("commit failed"); }), }); const repository = createRepository([preparationLease, commitLease]); const harness = createStateHarness(); const failedPreparation = createFileHandler(async () => { throw new Error("prepare failed"); }); const failedCommit = createFileHandler(async () => prepared(fileDraft("two.txt")), ); const firstCoordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([failedPreparation]), repository, }); await expect( firstCoordinator.importBatch( { files: [testFile("one.txt")], origin: "panel", target: "source.files", }, fileControl(), ), ).resolves.toMatchObject({ kind: "rejected" }); const secondCoordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([failedCommit]), repository, }); await expect( secondCoordinator.importBatch( { files: [testFile("two.txt")], origin: "panel", target: "source.files", }, fileControl(), ), ).resolves.toMatchObject({ kind: "rejected" }); expect(preparationLease.rollback).toHaveBeenCalledTimes(1); expect(commitLease.rollback).toHaveBeenCalledTimes(1); expect(harness.dispatch).not.toHaveBeenCalled(); }); it("compensates a post-commit dispatch failure with complete reachability collection", async () => { const harness = createStateHarness(); const state = harness.getState(); const analysis = { boundaryEdges: 0, diagnostics: [], disconnectedComponents: 1, nonManifoldEdges: 0, outcome: "clean" as const, triangles: 1, vertices: 3, }; harness.setState({ ...state, mediaAssets: [ { activeDocumentRef: "document:active", analysis, assetKind: "model", fileName: "kept.glb", id: "model-1", layerId: "layer-1", lifecycle: "clean", mimeType: "model/gltf-binary", originalAnalysis: analysis, originalDocumentRef: "document:original", position: { x: 0, y: 0 }, size: { height: 512, unit: "px", width: 512 }, sourceBundleDigest: "digest", sourceBundleRef: "source:kept", topologyProfile: "realtime-mesh", }, ], }); const dispatch = vi.fn((_command: ToolcraftCommand) => { throw new Error("dispatch failed"); }); const lease = createLease(); const repository = createRepository([lease]); const handler = createFileHandler(async () => prepared(fileDraft("failed.txt")), ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository, }); await expect( coordinator.importBatch( { files: [testFile("failed.txt")], origin: "panel", target: "source.files", }, fileControl(), ), ).resolves.toMatchObject({ kind: "rejected" }); expect(lease.commit).toHaveBeenCalledTimes(1); expect(lease.rollback).not.toHaveBeenCalled(); expect(repository.collect).toHaveBeenCalledTimes(1); const reachableRefs = repository.collect.mock .calls[0]?.[0] as ReadonlySet; expect([...reachableRefs]).toEqual( expect.arrayContaining([ "document:active", "document:original", "source:kept", ]), ); }); });