import { describe, expect, it, vi } from "vitest"; import { toolcraftReducer } from "../state/reducer"; import type { ToolcraftCommand, ToolcraftMediaAssetDraft, } from "../state/types"; import { createToolcraftSourceAssetCoordinator, } from "./source-asset-coordinator"; import { createFileHandler, createLease, createRepository, createStateHarness, deferred, fileControl, fileDraft, prepared, testFile, type TestLease, } from "./source-asset-coordinator-test-support"; import { createToolcraftSourceAssetRegistry } from "./source-asset-registry"; import type { ToolcraftSourceAssetPrepareContext, } from "./source-asset-types"; describe("source asset coordinator admission", () => { it("enforces fileDrop hardMaxItems before preparation or repository allocation", async () => { const harness = createStateHarness(); const handler = createFileHandler(async ({ batch, plan }) => prepared( ...batch.files.map((file) => fileDraft(file.name, plan.sourceTarget)), ), ); const repository = createRepository([createLease()]); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository, }); const control = { ...fileControl(), hardMaxItems: 2 }; await expect( coordinator.importBatch( { files: [testFile("one.txt"), testFile("two.txt")], origin: "panel", }, control, ), ).resolves.toMatchObject({ kind: "committed" }); await expect( coordinator.importBatch( { files: [testFile("overflow.txt")], origin: "panel" }, control, ), ).resolves.toMatchObject({ feedback: { category: "resource-limit", code: "hard-max-items-exceeded", }, kind: "rejected", }); expect(handler.prepare).toHaveBeenCalledTimes(1); expect(repository.beginLease).toHaveBeenCalledTimes(1); expect(harness.getState().mediaAssets).toHaveLength(2); }); it("frees fileDrop capacity after deletion and ignores unrelated targets", async () => { const harness = createStateHarness(); const handler = createFileHandler(async ({ batch, plan }) => prepared( ...batch.files.map((file) => fileDraft(file.name, plan.sourceTarget)), ), ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([ createLease(), createLease(), createLease(), createLease(), ]), }); const primaryControl = { ...fileControl("source.primary"), hardMaxItems: 2 }; await coordinator.importBatch( { files: [testFile("one.txt"), testFile("two.txt")], origin: "panel", }, primaryControl, ); await coordinator.importBatch( { files: [testFile("other.txt")], origin: "panel" }, { ...fileControl("source.other"), hardMaxItems: 1 }, ); const deletedId = harness.getState().mediaAssets.find( (asset) => asset.sourceTarget === "source.primary", )?.id; if (!deletedId) throw new Error("Expected a primary source asset."); harness.dispatch({ mediaId: deletedId, type: "media.delete" }); await expect( coordinator.importBatch( { files: [testFile("replacement.txt")], origin: "panel" }, primaryControl, ), ).resolves.toMatchObject({ kind: "committed" }); expect( harness.getState().mediaAssets.filter( (asset) => asset.sourceTarget === "source.primary", ), ).toHaveLength(2); expect( harness.getState().mediaAssets.filter( (asset) => asset.sourceTarget === "source.other", ), ).toHaveLength(1); }); it("counts only the replacement batch when replaceExisting is true", async () => { const harness = createStateHarness(false); const handler = createFileHandler( async ({ batch, plan }) => prepared( ...batch.files.map((file) => fileDraft(file.name, plan.sourceTarget)), ), { plan: (batch, control, claim) => ({ claim, logicalAssetCount: batch.files.length, replaceExisting: true, target: control.target, }), }, ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([createLease(), createLease()]), }); const control = { ...fileControl(), hardMaxItems: 2 }; await coordinator.importBatch( { files: [testFile("old-one.txt"), testFile("old-two.txt")], origin: "panel", }, control, ); await expect( coordinator.importBatch( { files: [testFile("new-one.txt"), testFile("new-two.txt")], origin: "panel", }, control, ), ).resolves.toMatchObject({ kind: "committed" }); expect(harness.getState().mediaAssets.map((asset) => asset.fileName)).toEqual([ "new-one.txt", "new-two.txt", ]); }); it("cancels an admitted import before opening a repository lease", async () => { const harness = createStateHarness(); const repository = createRepository([]); const handler = createFileHandler(async () => prepared(fileDraft("should-not-prepare.txt")), ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository, }); const importPromise = coordinator.importBatch( { files: [testFile("cancel-me.txt")], origin: "panel" }, fileControl(), ); coordinator.cancelTarget("source.files"); await expect(importPromise).resolves.toEqual({ kind: "cancelled" }); expect(repository.beginLease).not.toHaveBeenCalled(); expect(handler.prepare).not.toHaveBeenCalled(); expect(harness.dispatch).not.toHaveBeenCalled(); }); it("synchronously snapshots and freezes caller-owned batch and control data", async () => { const harness = createStateHarness(); const leaseReady = deferred(); const beginLease = vi.fn(() => leaseReady.promise); const repository = createRepository([], { beginLease }); let preparedContext: ToolcraftSourceAssetPrepareContext | undefined; const handler = createFileHandler(async (context) => { preparedContext = context; const selectedFile = context.batch.files[0]; return prepared( fileDraft(selectedFile?.name ?? "missing", context.plan.sourceTarget), ); }); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository, }); const files = [testFile("original.txt")]; const position = { x: 12, y: 34 }; const batch = { files, origin: "panel" as const, position, target: "source.original", }; const control = fileControl("source.original"); const importPromise = coordinator.importBatch(batch, control); files.splice(0, files.length, testFile("mutated.txt"), testFile("extra.txt")); position.x = 999; batch.target = "source.mutated"; control.target = "source.mutated"; control.multiple = false; leaseReady.resolve(createLease()); await expect(importPromise).resolves.toMatchObject({ kind: "committed" }); expect(preparedContext?.batch.files.map((file) => file.name)).toEqual([ "original.txt", ]); expect(preparedContext?.batch.position).toEqual({ x: 12, y: 34 }); expect(preparedContext?.control.target).toBe("source.original"); expect(preparedContext?.plan.sourceTarget).toBe("source.original"); expect(Object.isFrozen(preparedContext?.batch)).toBe(true); expect(Object.isFrozen(preparedContext?.batch.files)).toBe(true); expect(Object.isFrozen(preparedContext?.batch.position)).toBe(true); expect(Object.isFrozen(preparedContext?.control)).toBe(true); expect(Object.isFrozen(preparedContext?.plan)).toBe(true); }); it("uses each explicitly selected control target as canonical asset provenance", async () => { const harness = createStateHarness(); const handler = createFileHandler(async ({ batch, plan }) => prepared( ...batch.files.map((file) => fileDraft(file.name, plan.sourceTarget)), ), ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([createLease(), createLease()]), }); await coordinator.importBatch( { files: [testFile("primary.txt")], origin: "panel" }, fileControl("source.primary"), ); await coordinator.importBatch( { files: [testFile("secondary.txt")], origin: "panel" }, fileControl("source.secondary"), ); expect( harness.getState().mediaAssets.map((asset) => asset.sourceTarget), ).toEqual(["source.primary", "source.secondary"]); }); it("returns preallocated ids even when dispatch listeners import reentrantly", async () => { const harness = createStateHarness(); const handler = createFileHandler(async ({ batch, plan }) => prepared( ...batch.files.map((file) => fileDraft(file.name, plan.sourceTarget)), ), ); let reentered = false; const dispatch = vi.fn((command: ToolcraftCommand) => { const reduced = toolcraftReducer(harness.getState(), command); harness.setState(reduced); if (command.type === "media.importBatch" && !reentered) { reentered = true; const listenerAsset: ToolcraftMediaAssetDraft = fileDraft( "listener.txt", "source.listener", ); harness.setState( toolcraftReducer(harness.getState(), { assets: [listenerAsset], replaceExisting: false, type: "media.importBatch", }), ); } }); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([createLease()]), }); const outcome = await coordinator.importBatch( { files: [testFile("one.txt"), testFile("two.txt")], origin: "panel", }, fileControl(), ); expect(outcome).toEqual({ assetIds: ["media-1", "media-2"], kind: "committed", }); expect( harness.getState().mediaAssets.map((asset) => [asset.id, asset.fileName]), ).toEqual([ ["media-1", "one.txt"], ["media-2", "two.txt"], ["media-3", "listener.txt"], ]); }); it("delegates presentation to the registered handler", () => { const harness = createStateHarness(); const presentation = { accept: ".txt", allowsFileBatch: true, allowsFolderSelection: false, emptyDescription: "Drop source files", emptyTitle: "Upload files", items: [], presenter: "file-list" as const, secondaryActions: [], }; const handler = createFileHandler( async () => prepared(fileDraft("unused.txt")), { present: () => presentation }, ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([]), }); const control = fileControl(); expect(coordinator.getPresentation(control, [])).toBe(presentation); expect(handler.present).toHaveBeenCalledTimes(1); expect(handler.present).toHaveBeenCalledWith({ control, mediaAssets: [], operation: { phase: "idle", target: "source.files" }, }); }); });