import { describe, expect, it, vi } from "vitest"; import type { ToolcraftControlSchema } from "../schema/types"; import { createToolcraftSourceAssetRegistry, selectToolcraftSourceAssetHandler, } from "./source-asset-registry"; import type { ToolcraftSourceAssetBatch, ToolcraftSourceAssetHandler, ToolcraftSourceAssetKind, ToolcraftPreparedSourceAssetRecord, } from "./source-asset-types"; type TestPreparedAsset = ToolcraftPreparedSourceAssetRecord; type TestHandler = ToolcraftSourceAssetHandler< ToolcraftSourceAssetKind, TestPreparedAsset >; function file(name: string, type = "application/octet-stream"): File { return new File([new Uint8Array([1])], name, { type }); } function batch(...files: File[]): ToolcraftSourceAssetBatch { return { files, origin: "panel" }; } function control( target: string, assetKind: "file" | "image" | "model", ): ToolcraftControlSchema { return { assetKind, target, type: "fileDrop" }; } function handler( kind: ToolcraftSourceAssetKind, match: TestHandler["match"], ): TestHandler { return { kind, match, plan: vi.fn(() => { throw new Error("not used by registry selection tests"); }), prepare: vi.fn(async () => { throw new Error("not used by registry selection tests"); }), present: vi.fn(() => { throw new Error("not used by registry selection tests"); }), }; } function exactKindHandler( kind: ToolcraftSourceAssetKind, specificity: number, ): TestHandler { return handler(kind, (sourceBatch, sourceControl) => { if ( sourceControl.type !== "fileDrop" || sourceControl.assetKind !== kind ) { return null; } const firstFile = sourceBatch.files[0]; if (!firstFile) { return null; } return { handlerKind: kind, rootFileNames: [firstFile.name], specificity, }; }); } describe("source asset registry", () => { it("prefers a specialized model claim over the generic file fallback", () => { const registry = createToolcraftSourceAssetRegistry([ exactKindHandler("file", 1), exactKindHandler("model", 100), ]); const selection = selectToolcraftSourceAssetHandler( registry, batch(file("scene.glb", "model/gltf-binary")), [control("source.model", "model"), control("source.file", "file")], ); expect(selection).toMatchObject({ claim: { handlerKind: "model", rootFileNames: ["scene.glb"] }, control: { target: "source.model" }, kind: "selected", }); }); it("prefers an exact image claim over the generic file fallback", () => { const registry = createToolcraftSourceAssetRegistry([ exactKindHandler("file", 1), exactKindHandler("image", 50), ]); const selection = selectToolcraftSourceAssetHandler( registry, batch(file("cover.png", "image/png")), [control("source.image", "image"), control("source.file", "file")], ); expect(selection).toMatchObject({ claim: { handlerKind: "image" }, control: { target: "source.image" }, kind: "selected", }); }); it("uses one generic file fallback when no specialized handler claims", () => { const registry = createToolcraftSourceAssetRegistry([ exactKindHandler("image", 50), exactKindHandler("file", 1), ]); const selection = selectToolcraftSourceAssetHandler( registry, batch(file("data.csv", "text/csv")), [control("source.file", "file")], ); expect(selection).toMatchObject({ claim: { handlerKind: "file", rootFileNames: ["data.csv"] }, control: { target: "source.file" }, kind: "selected", }); }); it("keeps an explicit panel target from routing into a different control", () => { const registry = createToolcraftSourceAssetRegistry([ exactKindHandler("file", 1), exactKindHandler("model", 100), ]); const sourceBatch: ToolcraftSourceAssetBatch = { files: [file("scene.glb", "model/gltf-binary")], origin: "panel", target: "source.file", }; const selection = selectToolcraftSourceAssetHandler( registry, sourceBatch, [control("source.model", "model"), control("source.file", "file")], ); expect(selection).toMatchObject({ claim: { handlerKind: "file" }, control: { target: "source.file" }, kind: "selected", }); }); it("returns none when no handler claims the batch", () => { const registry = createToolcraftSourceAssetRegistry([ exactKindHandler("image", 50), ]); expect( selectToolcraftSourceAssetHandler( registry, batch(file("data.csv", "text/csv")), [control("source.file", "file")], ), ).toEqual({ kind: "none" }); }); it("returns none when an explicit target is not among the candidate controls", () => { const registry = createToolcraftSourceAssetRegistry([ exactKindHandler("file", 1), ]); expect( selectToolcraftSourceAssetHandler( registry, { files: [file("data.csv", "text/csv")], origin: "panel", target: "source.missing", }, [control("source.file", "file")], ), ).toEqual({ kind: "none" }); }); it("reports equal-specificity target ambiguity instead of choosing by order", () => { const registry = createToolcraftSourceAssetRegistry([ exactKindHandler("model", 100), exactKindHandler("file", 1), ]); const selection = selectToolcraftSourceAssetHandler( registry, batch(file("scene.glb", "model/gltf-binary")), [control("model.primary", "model"), control("model.secondary", "model")], ); expect(selection).toEqual({ feedback: { category: "bundle", code: "ambiguous-target", message: "The selected files match more than one equally specific upload target.", }, kind: "ambiguous", }); }); it("rejects duplicate handler kinds at registry construction", () => { expect(() => createToolcraftSourceAssetRegistry([ exactKindHandler("image", 50), exactKindHandler("image", 10), ]), ).toThrowError( '[source-asset-registry] Duplicate handler kind "image".', ); }); it("rejects registration without the complete handler contract", () => { const incompleteHandler = { kind: "file" as const, match: () => null, }; expect(() => createToolcraftSourceAssetRegistry([ // @ts-expect-error A registry handler owns all four source-asset responsibilities. incompleteHandler, ]), ).toThrowError( '[source-asset-registry] Handler "file" must implement match, plan, prepare, and present.', ); }); it("rejects a runtime handler with a missing match function", () => { const invalidHandler = { ...exactKindHandler("file", 1), match: undefined, }; expect(() => createToolcraftSourceAssetRegistry([ // @ts-expect-error Runtime callers cannot omit the matcher either. invalidHandler, ]), ).toThrowError( '[source-asset-registry] Handler "file" must implement match, plan, prepare, and present.', ); }); it("snapshots and freezes handlers instead of retaining mutable registration", () => { const mutableHandler = exactKindHandler("file", 1); const mutableHandlers = [mutableHandler]; const registry = createToolcraftSourceAssetRegistry(mutableHandlers); mutableHandlers.length = 0; mutableHandler.match = () => null; expect(Object.isFrozen(registry)).toBe(true); expect(Object.isFrozen(registry.handlers)).toBe(true); expect(Object.isFrozen(registry.handlers[0])).toBe(true); expect( selectToolcraftSourceAssetHandler( registry, batch(file("data.csv", "text/csv")), [control("source.file", "file")], ), ).toMatchObject({ claim: { handlerKind: "file" }, kind: "selected", }); }); });