import { describe, expect, it, vi } from "vitest"; import { createToolcraftModelRenderHost } from "./model-render-host"; import { createLifetimeFixture, deferred, exportContext, exportRequest, } from "./model-render-lifetime.test-support"; import { createToolcraftModelRenderRegistry } from "./model-render-registry"; function createFixture(phase: "create" | "render" = "render") { const fixture = createLifetimeFixture(phase); const feedback = { clear: vi.fn(), report: vi.fn() }; const host = createToolcraftModelRenderHost( createToolcraftModelRenderRegistry({ standardBinding: fixture.binding }), fixture.resolveResource, feedback, ); return { ...fixture, feedback, host }; } describe("model render host export lifetime", () => { it.each(["create", "render"] as const)( "defers terminal disposal until paused %s actually settles", async (phase) => { const fixture = createFixture(phase); const run = fixture.host.renderExport(exportRequest, exportContext()); const outcome = run.catch((error: unknown) => error); await fixture.entered.promise; fixture.host.dispose(); fixture.host.dispose(); expect(fixture.signals[0]!.aborted).toBe(true); expect(fixture.binding.dispose).not.toHaveBeenCalled(); expect(fixture.binding.disposePreparedPreview).not.toHaveBeenCalled(); expect(fixture.leases[0]!.root.children.length).toBeGreaterThan(0); await expect(fixture.host.renderExport(exportRequest, exportContext())) .rejects.toBe(fixture.signals[0]!.reason); expect(() => fixture.host.retain()).toThrow(); fixture.pending.resolve(); expect(await outcome).toBe(fixture.signals[0]!.reason); expect(fixture.events).toEqual(["resource", "prepared"]); expect(fixture.leases[0]!.root.children).toHaveLength(0); expect(fixture.binding.dispose).toHaveBeenCalledTimes(1); fixture.host.dispose(); expect(fixture.binding.disposePreparedPreview).toHaveBeenCalledTimes(1); }, ); it.each(["create", "render"] as const)( "preserves incoming cancellation during %s without disposing the preview host", async (phase) => { const fixture = createFixture(phase); const controller = new AbortController(); const reason = new Error("cancel this model export"); const run = fixture.host.renderExport(exportRequest, exportContext(controller.signal)); const outcome = run.catch((error: unknown) => error); await fixture.entered.promise; controller.abort(reason); expect(fixture.signals[0]!.reason).toBe(reason); expect(fixture.binding.dispose).not.toHaveBeenCalled(); expect(fixture.leases[0]!.root.children.length).toBeGreaterThan(0); fixture.pending.resolve(); expect(await outcome).toBe(reason); expect(fixture.binding.dispose).toHaveBeenCalledTimes(1); expect(fixture.binding.disposePreparedPreview).not.toHaveBeenCalled(); const release = fixture.host.retain(); release(); await fixture.host.renderExport(exportRequest, exportContext()); fixture.host.dispose(); }, ); it("keeps presentation manager and prepared resources alive until the last host lease releases", async () => { const fixture = createFixture(); const presentation = await fixture.host.acquirePresentation("document:a", { purpose: "preview", signal: new AbortController().signal, }); const first = fixture.host.retain(); const second = fixture.host.retain(); fixture.host.dispose(); first(); first(); expect(presentation.root.children.length).toBeGreaterThan(0); expect(fixture.binding.disposePreparedPreview).not.toHaveBeenCalled(); await expect(fixture.host.acquirePresentation("document:b", { purpose: "export", signal: new AbortController().signal, })).rejects.toThrow(); second(); second(); expect(presentation.root.children).toHaveLength(0); expect(fixture.binding.disposePreparedPreview).toHaveBeenCalledTimes(1); presentation.release(); }); it("requires both export settlement and host lease release before final disposal", async () => { const fixture = createFixture(); const release = fixture.host.retain(); const outcome = fixture.host.renderExport(exportRequest, exportContext()) .catch((error: unknown) => error); await fixture.entered.promise; fixture.host.dispose(); fixture.pending.resolve(); await outcome; expect(fixture.events).toEqual(["resource"]); release(); expect(fixture.events).toEqual(["resource", "prepared"]); }); it("does not let a late renderer callback draw after cancellation", async () => { const fixture = createFixture(); const controller = new AbortController(); const onRendered = vi.fn(); const outcome = fixture.host.renderExport(exportRequest, { ...exportContext(controller.signal), onRendered, }).catch((error: unknown) => error); await fixture.entered.promise; controller.abort(new Error("draw cancelled")); fixture.pending.resolve(); expect(await outcome).toBe(controller.signal.reason); expect(onRendered).not.toHaveBeenCalled(); fixture.host.dispose(); }); it("checks cancellation after an awaited rendered callback settles", async () => { const fixture = createFixture(); fixture.pending.resolve(); const controller = new AbortController(); const entered = deferred(); const callback = deferred(); const outcome = fixture.host.renderExport(exportRequest, { ...exportContext(controller.signal), onRendered: async () => { entered.resolve(); await callback.promise; }, }).catch((error: unknown) => error); await entered.promise; controller.abort(new Error("callback cancelled")); expect(fixture.binding.dispose).not.toHaveBeenCalled(); callback.resolve(); expect(await outcome).toBe(controller.signal.reason); expect(fixture.feedback.clear).not.toHaveBeenCalled(); fixture.host.dispose(); }); it("preserves a real render failure over cancellation and cleanup failure", async () => { const fixture = createFixture(); const failure = new Error("render failed"); vi.mocked(fixture.binding.dispose).mockImplementation((lease) => { lease.release(); throw new Error("cleanup failed"); }); const outcome = fixture.host.renderExport(exportRequest, exportContext()) .catch((error: unknown) => error); await fixture.entered.promise; fixture.host.dispose(); fixture.pending.reject(failure); expect(await outcome).toBe(failure); expect(fixture.binding.disposePreparedPreview).toHaveBeenCalledTimes(1); }); it("rejects a cancelled request before binding acquisition", async () => { const fixture = createFixture(); const signal = AbortSignal.abort(new Error("already cancelled")); await expect(fixture.host.renderExport(exportRequest, exportContext(signal))) .rejects.toBe(signal.reason); expect(fixture.binding.create).not.toHaveBeenCalled(); fixture.host.dispose(); }); it("surfaces a cleanup-only failure instead of claiming export success", async () => { const fixture = createFixture(); const failure = new Error("release failed"); vi.mocked(fixture.binding.dispose).mockImplementation((lease) => { lease.release(); throw failure; }); fixture.pending.resolve(); await expect(fixture.host.renderExport(exportRequest, exportContext())).rejects.toBe(failure); fixture.host.dispose(); }); it("does not replace the incoming abort reason when the host is later disposed", async () => { const fixture = createFixture(); const controller = new AbortController(); const outcome = fixture.host.renderExport(exportRequest, exportContext(controller.signal)) .catch((error: unknown) => error); await fixture.entered.promise; controller.abort(new Error("first cancellation")); fixture.host.dispose(); fixture.pending.resolve(); expect(await outcome).toBe(controller.signal.reason); }); it.each(["create", "update", "prepare"] as const)( "preserves terminal preview safety while %s is paused", async (phase) => { const fixture = createFixture(phase === "create" ? "create" : "render"); const entered = deferred(); const pending = deferred(); const preview = { ...exportContext(), host: document.createElement("div"), selected: false }; if (phase === "prepare") fixture.binding.preparePreview = async () => { entered.resolve(); await pending.promise; }; if (phase === "update") { await fixture.host.renderPreview("layer", exportRequest, preview); fixture.binding.update = async () => { entered.resolve(); await pending.promise; }; } const work = fixture.host.renderPreview("layer", exportRequest, preview); await (phase === "create" ? fixture.entered.promise : entered.promise); fixture.host.dispose(); expect(fixture.binding.dispose).not.toHaveBeenCalled(); expect(fixture.binding.disposePreparedPreview).not.toHaveBeenCalled(); fixture.pending.resolve(); pending.resolve(); await work; expect(fixture.binding.dispose).toHaveBeenCalledTimes(phase === "prepare" ? 0 : 1); expect(fixture.binding.disposePreparedPreview).toHaveBeenCalledTimes(1); }, ); });