import { describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "../state/create-template-state"; import { toolcraftReducer } from "../composition/public-state"; import type { ToolcraftCommand, ToolcraftFileAsset } from "../state/types"; import { createToolcraftDataUrlResourceRef } from "./media-resource-ref"; import { createMemoryToolcraftBinaryAssetRepository } from "./repository/memory-binary-asset-repository"; import { createToolcraftSourceAssetBinaryMediaHydrator } from "./source-asset-binary-media-hydrator"; function deferred() { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } function fixture(count = 1) { const dataUrl = "data:application/octet-stream;base64,AQID"; const resourceRef = createToolcraftDataUrlResourceRef("file", dataUrl); const assets: ToolcraftFileAsset[] = Array.from({ length: count }, (_, index) => ({ assetKind: "file", fileName: `${index}.bin`, id: `asset-${index}`, layerId: `layer-${index}`, mimeType: "application/octet-stream", position: { x: 0, y: 0 }, lifecycle: "restoring", resourceRef, })); const state = createToolcraftState(defineToolcraft({ base: { identity: { id: "hydration-test", title: "Hydration test" }, canvas: { enabled: true }, panels: {}, }, modules: [], })); state.mediaAssets = assets; const repository = createMemoryToolcraftBinaryAssetRepository(); const originalBeginLease = repository.beginLease.bind(repository); const beginLease = vi.spyOn(repository, "beginLease"); const jobs = assets.map(asset => ({ assetId: asset.id, dataUrl, kind: "file" as const, mimeType: asset.mimeType, resourceRef, })); const cleanupManager = { collect: vi.fn(async () => {}), dispose: vi.fn(async () => {}), retryRollbacks: vi.fn(async () => {}), rollback: vi.fn(async (lease: { rollback: () => Promise } | null) => { await lease?.rollback(); }), }; const dispatch = vi.fn((command: ToolcraftCommand) => { if (command.type !== "media.setBinaryResourceState") return; Object.assign(state, toolcraftReducer(state, command)); }); const hydrate = createToolcraftSourceAssetBinaryMediaHydrator({ cleanupManager, dispatch, getState: () => state, repository, }); return { assets, state, repository, beginLease, originalBeginLease, jobs, cleanupManager, dispatch, hydrate }; } describe("binary media hydration concurrency", () => { it("shares pending work across overlapping callers and waits for it", async () => { const f = fixture(); const gate = deferred(); const beginLease = f.beginLease.mockImplementation(async id => { await gate.promise; return f.originalBeginLease(id); }); const first = f.hydrate(f.jobs); let secondFinished = false; const second = f.hydrate(f.jobs).then(() => { secondFinished = true; }); await vi.waitFor(() => expect(beginLease).toHaveBeenCalled()); expect(beginLease).toHaveBeenCalledTimes(1); expect(secondFinished).toBe(false); gate.resolve(); await Promise.all([first, second]); expect(f.dispatch).toHaveBeenCalledTimes(1); expect(f.state.mediaAssets[0]?.lifecycle).toBe("ready"); }); it("does not restart the remaining 60 defaults when each ready update re-enters", async () => { const f = fixture(60); const update = f.dispatch.getMockImplementation()!; const observers: Promise[] = []; f.dispatch.mockImplementation(command => { update(command); observers.push(f.hydrate(f.jobs)); }); await f.hydrate(f.jobs); await Promise.all(observers); expect(f.beginLease).toHaveBeenCalledTimes(60); expect(f.dispatch).toHaveBeenCalledTimes(60); expect(f.state.mediaAssets.every(asset => asset.lifecycle === "ready")).toBe(true); }); it("shares repository-only restoration as well as default-data jobs", async () => { const f = fixture(); await f.hydrate(f.jobs); f.state.mediaAssets = f.assets; f.dispatch.mockClear(); const get = vi.spyOn(f.repository, "get"); await Promise.all([f.hydrate([]), f.hydrate([])]); expect(get).toHaveBeenCalledTimes(1); expect(f.dispatch).toHaveBeenCalledTimes(1); }); it("releases failed work for retry and restores defaults again after reset", async () => { const f = fixture(); f.beginLease.mockRejectedValueOnce(new Error("temporary storage failure")); await Promise.all([f.hydrate(f.jobs), f.hydrate(f.jobs)]); expect(f.dispatch).toHaveBeenCalledTimes(1); expect(f.state.mediaAssets[0]?.lifecycle).toBe("unavailable"); f.state.mediaAssets = f.assets; await f.hydrate(f.jobs); expect(f.state.mediaAssets[0]?.lifecycle).toBe("ready"); f.state.mediaAssets = f.assets.map(asset => ({ ...asset })); await f.hydrate(f.jobs); expect(f.state.mediaAssets[0]?.lifecycle).toBe("ready"); expect(f.beginLease).toHaveBeenCalledTimes(3); }); it.each(["ready", "error"])("does not publish stale %s state after an asset is replaced", async outcome => { const f = fixture(); const gate = deferred(); f.beginLease.mockImplementationOnce(async () => { await gate.promise; if (outcome === "error") throw new Error("old operation failed"); return { put: async () => {}, commit: async () => {}, rollback: async () => {}, get: async () => null, refs: () => [], }; }); const pending = f.hydrate(f.jobs); await vi.waitFor(() => expect(f.beginLease).toHaveBeenCalledTimes(1)); const dataUrl = "data:application/octet-stream;base64,BAUG"; const resourceRef = createToolcraftDataUrlResourceRef("file", dataUrl); f.state.mediaAssets = f.assets.map(asset => ({ ...asset, resourceRef })); gate.resolve(); await pending; expect(f.dispatch).not.toHaveBeenCalled(); expect(f.state.mediaAssets[0]?.lifecycle).toBe("restoring"); await f.hydrate(f.jobs.map(job => ({ ...job, dataUrl, resourceRef }))); expect(f.state.mediaAssets[0]?.lifecycle).toBe("ready"); }); it("keeps shared hydration alive when metadata changes without changing bytes", async () => { const f = fixture(); const gate = deferred(); f.beginLease.mockImplementation(async id => { await gate.promise; return f.originalBeginLease(id); }); const first = f.hydrate(f.jobs); await vi.waitFor(() => expect(f.beginLease).toHaveBeenCalledTimes(1)); f.state.mediaAssets = f.assets.map(asset => ({ ...asset, fileName: "renamed.bin" })); const second = f.hydrate(f.jobs); gate.resolve(); await Promise.all([first, second]); expect(f.beginLease).toHaveBeenCalledTimes(1); expect(f.state.mediaAssets[0]).toMatchObject({ lifecycle: "ready", fileName: "renamed.bin" }); }); });