import { describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import type { ToolcraftControlSchema } from "../schema/types"; import { createToolcraftState } from "../state/create-template-state"; import { toolcraftReducer } from "../state/reducer"; import type { ToolcraftCommand, ToolcraftMediaAssetDraft, ToolcraftState, } from "../state/types"; import { createMemoryToolcraftBinaryAssetRepository, } from "./repository/memory-binary-asset-repository"; import type { ToolcraftBinaryAssetLease, ToolcraftBinaryAssetRepository, } from "./repository/binary-asset-repository"; import { createToolcraftSourceAssetRegistry, } from "./source-asset-registry"; import { createToolcraftSourceAssetCoordinator, } from "./source-asset-coordinator"; import type { ToolcraftPreparedSourceAssetRecord, ToolcraftSourceAssetBatch, ToolcraftSourceAssetHandler, ToolcraftSourceAssetPrepareContext, } from "./source-asset-types"; type Deferred = { promise: Promise; resolve: (value: Value) => void; }; type TestLease = ToolcraftBinaryAssetLease & { commit: ReturnType; rollback: ReturnType; }; function deferred(): Deferred { let resolvePromise: ((value: Value) => void) | undefined; const promise = new Promise((resolve) => { resolvePromise = resolve; }); return { promise, resolve: (value) => resolvePromise?.(value), }; } function file(name: string, type = "application/octet-stream"): File { return new File([name], name, { type }); } function fileControl(target = "source.files") { return { assetKind: "file" as const, multiple: true, target, type: "fileDrop" as const, }; } function createSchema() { return defineToolcraft({ canvas: { enabled: true, upload: true }, panels: { controls: { sections: [ { controls: { files: fileControl() }, title: "Sources", }, ], title: "Controls", }, layers: true, }, }); } function fileDraft( fileName: string, sourceTarget = "source.files", ): ToolcraftPreparedSourceAssetRecord<"file"> { return { assetKind: "file", fileName, lifecycle: "ready", mimeType: "text/plain", position: { x: 0, y: 0 }, resourceRef: `media:file:${fileName}`, sourceTarget, }; } function prepared(...assets: ToolcraftPreparedSourceAssetRecord<"file">[]) { return { assets, stagedResourceRefs: [] as const, }; } function createFileHandler( prepare: ( context: ToolcraftSourceAssetPrepareContext, ) => Promise>, ): ToolcraftSourceAssetHandler< "file", ToolcraftPreparedSourceAssetRecord<"file"> > { return { kind: "file", match: vi.fn(( batch: ToolcraftSourceAssetBatch, control: ToolcraftControlSchema, ) => control.assetKind === "file" && batch.files.length > 0 ? { handlerKind: "file" as const, rootFileNames: batch.files.map((item) => item.name), specificity: 1, } : null, ), plan: vi.fn((batch, control, claim) => ({ claim, logicalAssetCount: batch.files.length, replaceExisting: control.multiple !== true, target: control.target, })), prepare: vi.fn(prepare), present: vi.fn(() => { throw new Error("not used by coordinator tests"); }), }; } function createLease(overrides: Partial = {}): TestLease { const stagedRefs = new Set(); return { commit: vi.fn(async () => undefined), get: vi.fn(async () => null), put: vi.fn(async (ref: string) => { stagedRefs.add(ref); }), refs: vi.fn(() => [...stagedRefs]), rollback: vi.fn(async () => undefined), ...overrides, } as TestLease; } function createRepository(leases: readonly TestLease[]): ToolcraftBinaryAssetRepository & { beginLease: ReturnType; collect: ReturnType; dispose: ReturnType; } { let leaseIndex = 0; return { beginLease: vi.fn(async () => { const lease = leases[leaseIndex]; leaseIndex += 1; if (!lease) { throw new Error("Missing test lease"); } return lease; }), collect: vi.fn(async () => []), dispose: vi.fn(async () => undefined), get: vi.fn(async () => null), }; } function createStateHarness() { let state = createToolcraftState(createSchema()); const dispatch = vi.fn((command: ToolcraftCommand) => { state = toolcraftReducer(state, command); }); return { dispatch, getState: () => state, setState: (nextState: ToolcraftState) => { state = nextState; }, }; } describe("source asset coordinator", () => { it("prepares one generic file batch and dispatches one immutable importBatch", 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 = [file("one.csv", "text/csv"), file("two.json", "application/json")]; 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({ assets: [ { assetKind: "file", fileName: "one.csv" }, { assetKind: "file", fileName: "two.json" }, ], replaceExisting: false, type: "media.importBatch", }); if (!command || command.type !== "media.importBatch") { throw new Error("Expected one media.importBatch command"); } expect(Object.isFrozen(command.assets)).toBe(true); expect(command.assets.every((asset) => Object.isFrozen(asset))).toBe(true); expect(command.assets.every((asset) => !("id" in asset) && !("layerId" in asset))).toBe( true, ); expect(harness.getState().history.undo).toHaveLength(1); 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: [file("one.txt"), file("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: [file("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: [file("old.txt")], origin: "panel", target: "source.files", }, fileControl(), ); await vi.waitFor(() => expect(handler.prepare).toHaveBeenCalledTimes(1)); const newImport = coordinator.importBatch( { files: [file("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: [file("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: [file("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: [file("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", ]), ); }); });