import { describe, expect, it, vi } from "vitest"; import { resolveToolcraftModuleDependencies } from "./resolution/resolve-module-dependencies"; import { createPaletteHandler, decodePalette, paletteModule } from "./testing/extensibility/palette-source"; import { createToolcraftSourceAssetRuntime } from "../source-assets/source-asset-coordinator"; import { createToolcraftSourceAssetRegistry } from "../source-assets/source-asset-registry"; import { createMemoryToolcraftBinaryAssetRepository } from "../source-assets/repository/memory-binary-asset-repository"; import { createStateHarness, deferred, fileControl } from "../source-assets/source-asset-coordinator-test-support"; import { createToolcraftSourceAssetCoordinator } from "../composition/source-asset-coordinator"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "../composition/public-state"; import { resolveToolcraftModuleBindings } from "./contract/module-bindings"; function fixture(afterStage?: () => Promise, enabled = true) { const harness = createStateHarness(false); const repository = createMemoryToolcraftBinaryAssetRepository(); const disposed = vi.fn(); const plan = resolveToolcraftModuleDependencies(enabled ? [paletteModule()] : [], []); const bindings = resolveToolcraftModuleBindings(plan.modules, { "palette-source": createPaletteHandler(afterStage), }); const runtime = createToolcraftSourceAssetRuntime({ ...harness, repository, binding: { registry: createToolcraftSourceAssetRegistry(bindings.map(({ binding }) => binding)), isDefaultReplacementCurrent: () => false, attach: () => ({ services: { restore: async (ref: string) => { const entry = await repository.get(ref); return entry ? decodePalette(entry.bytes) : undefined; }, }, cancel: () => { }, settle: async () => { }, dispose: disposed }), }, }); const file = new File([JSON.stringify({ version: 1, colors: ["#ff0000", "#112233"] })], "colors.palette"); return { ...harness, ...runtime, file, repository, disposed, plan }; } describe("source extension lifecycle", () => { it("imports a typed payload and restores it through the shared binary repository without a Layers panel", async () => { const f = fixture(); expect(f.plan.modules.map(module => module.id)).toEqual(["palette-source"]); const outcome = await f.coordinator.importBatch({ files: [f.file], origin: "panel", target: "source.files" }, fileControl()); expect(outcome.kind).toBe("committed"); const asset = f.getState().mediaAssets[0]!; expect(asset.assetKind).toBe("file"); if (asset.assetKind === "model") throw new Error("Expected file envelope"); expect(await f.services.restore(asset.resourceRef)).toEqual({ version: 1, colors: ["#ff0000", "#112233"] }); expect(f.getState().layers).toHaveLength(1); expect(f.dispatch).toHaveBeenCalledTimes(1); await f.coordinator.dispose(); expect(f.disposed).toHaveBeenCalledTimes(1); }); it("does not admit palette assets without its resolved module binding", async () => { const f = fixture(undefined, false); expect(f.plan.modules).toEqual([]); expect(f.coordinator.supportsKind("file")).toBe(false); expect(await f.coordinator.importBatch({ files: [f.file], origin: "panel", target: "source.files" }, fileControl())) .toMatchObject({ kind: "rejected", feedback: { code: "unsupported-batch" } }); expect(f.dispatch).not.toHaveBeenCalled(); await f.coordinator.dispose(); }); it("waits for an in-flight extension after cancellation and never publishes its staged result", async () => { const gate = deferred(); const staged = vi.fn(() => gate.promise); const f = fixture(staged); const pending = f.coordinator.importBatch({ files: [f.file], origin: "panel", target: "source.files" }, fileControl()); await vi.waitFor(() => expect(staged).toHaveBeenCalledOnce()); const disposal = f.coordinator.dispose(); expect(f.coordinator.dispose()).toBe(disposal); expect(f.disposed).not.toHaveBeenCalled(); gate.resolve(); expect(await pending).toEqual({ kind: "cancelled" }); await disposal; expect(f.dispatch).not.toHaveBeenCalled(); expect(f.disposed).toHaveBeenCalledOnce(); }); it("does not construct model workers or handlers when their module is absent", async () => { const state = createToolcraftState(defineToolcraft({ base: { identity: { id: "absent", title: "Absent" }, canvas: { enabled: true }, panels: {} }, modules: [] })); const workerFactory = vi.fn(); const coordinator = createToolcraftSourceAssetCoordinator({ getState: () => state, dispatch: vi.fn(), repository: createMemoryToolcraftBinaryAssetRepository(), createModelWorkerClient: workerFactory }); expect(coordinator.supportsKind("model")).toBe(false); expect(coordinator.supportsKind("file")).toBe(false); expect(workerFactory).not.toHaveBeenCalled(); expect(await coordinator.repairModel("missing")).toMatchObject({ kind: "rejected", feedback: { code: "model-module-absent" } }); await coordinator.dispose(); }); it("finishes shared cleanup when extension cancellation, settlement and disposal fail", async () => { const repository = createMemoryToolcraftBinaryAssetRepository(); const disposeRepository = vi.spyOn(repository, "dispose"); const failures = [new Error("cancel"), new Error("settle"), new Error("dispose")]; const lifecycle: string[] = []; const runtime = createToolcraftSourceAssetRuntime({ ...createStateHarness(false), repository, binding: { registry: createToolcraftSourceAssetRegistry([createPaletteHandler()]), isDefaultReplacementCurrent: () => false, attach: () => ({ services: {}, cancel: () => { lifecycle.push("cancel"); throw failures[0]; }, settle: async () => { lifecycle.push("settle"); throw failures[1]; }, dispose: () => { lifecycle.push("dispose"); throw failures[2]; }, }), }, }); const disposal = runtime.coordinator.dispose(); expect(runtime.coordinator.dispose()).toBe(disposal); await expect(disposal).rejects.toMatchObject({ errors: failures }); expect(lifecycle).toEqual(["cancel", "settle", "dispose"]); expect(disposeRepository).toHaveBeenCalledOnce(); await expect(repository.get("binary:gone")).rejects.toThrow("disposed"); }); });