diff --git a/src/lib/state/workspace-export.ts b/src/lib/state/workspace-export.ts index 5e67d44..3d497a9 100644 --- a/src/lib/state/workspace-export.ts +++ b/src/lib/state/workspace-export.ts @@ -50,14 +50,16 @@ export function createWorkspacePreviewRenderer({ let previewRenderTimer: ReturnType | undefined; function clearScheduledPreviewRender() { - if (previewRenderTimer) { + // Invalidate running exports as soon as the document changes or cleanup runs. + renderTurn += 1; + if (previewRenderTimer !== undefined) { clearTimeout(previewRenderTimer); previewRenderTimer = undefined; } + setIsRendering(false); } - async function renderPreview(document: JSONContent, options: DcExportOptions) { - const turn = ++renderTurn; + async function renderForTurn(document: JSONContent, options: DcExportOptions, turn: number) { setIsRendering(true); try { @@ -73,11 +75,18 @@ export function createWorkspacePreviewRenderer({ } } + async function renderPreview(document: JSONContent, options: DcExportOptions) { + clearScheduledPreviewRender(); + await renderForTurn(document, options, renderTurn); + } + function schedulePreviewRender(document: JSONContent, options: DcExportOptions) { clearScheduledPreviewRender(); + const turn = renderTurn; + setIsRendering(true); previewRenderTimer = setTimeout(() => { previewRenderTimer = undefined; - void renderPreview(document, options); + void renderForTurn(document, options, turn); }, debounceMs); } diff --git a/tests/unit/workspace-export.test.ts b/tests/unit/workspace-export.test.ts new file mode 100644 index 0000000..fb38b35 --- /dev/null +++ b/tests/unit/workspace-export.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { JSONContent } from "@tiptap/core"; +import { exportDocumentToDcHtml, type DcExportOptions } from "$lib/dc/export-document"; +import { createWorkspacePreviewRenderer } from "$lib/state/workspace-export"; + +vi.mock("$lib/dc/export-document", () => ({ + exportDocumentToDcHtml: vi.fn(), +})); +vi.mock("$lib/dc/clipboard", () => ({ + copyDcHtml: vi.fn<(html: string, plainText: string) => Promise>(), + copyPlainText: vi.fn<(text: string) => Promise>(), +})); + +const options: DcExportOptions = { + theme: "github-dark", + bodyFontFamily: "Pretendard", + bodyFontSize: "17px", + codeFontSize: "15px", + showLineNumbers: false, +}; + +function document(text: string): JSONContent { + return { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }; +} + +function deferred() { + let resolve!: (value: string) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createRenderer() { + const setHtml = vi.fn<(html: string) => void>(); + const setIsRendering = vi.fn<(isRendering: boolean) => void>(); + const renderer = createWorkspacePreviewRenderer({ + debounceMs: 90, + setHtml, + setIsRendering, + }); + return { renderer, setHtml, setIsRendering }; +} + +describe("workspace preview render lifecycle", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(exportDocumentToDcHtml).mockReset(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("invalidates old results while a newer document is still debouncing", async () => { + const old = deferred(); + vi.mocked(exportDocumentToDcHtml) + .mockReturnValueOnce(old.promise) + .mockResolvedValueOnce("new-html"); + const { renderer, setHtml, setIsRendering } = createRenderer(); + const oldRender = renderer.renderPreview(document("old"), options); + + renderer.schedulePreviewRender(document("new"), options); + old.resolve("old-html"); + await oldRender; + + expect(setHtml).not.toHaveBeenCalled(); + expect(setIsRendering).toHaveBeenLastCalledWith(true); + await vi.advanceTimersByTimeAsync(90); + expect(setHtml).toHaveBeenCalledExactlyOnceWith("new-html"); + expect(setIsRendering).toHaveBeenLastCalledWith(false); + }); + + it("prevents a running export from publishing after cleanup", async () => { + const old = deferred(); + vi.mocked(exportDocumentToDcHtml).mockReturnValueOnce(old.promise); + const { renderer, setHtml, setIsRendering } = createRenderer(); + const oldRender = renderer.renderPreview(document("old"), options); + + renderer.clearScheduledPreviewRender(); + old.resolve("old-html"); + await oldRender; + + expect(setHtml).not.toHaveBeenCalled(); + expect(setIsRendering).toHaveBeenLastCalledWith(false); + expect(renderer.previewRenderTimer).toBeUndefined(); + }); + + it("cancels delayed work when an immediate render starts", async () => { + vi.mocked(exportDocumentToDcHtml).mockResolvedValue("now-html"); + const { renderer, setHtml } = createRenderer(); + const immediateDocument = document("now"); + + renderer.schedulePreviewRender(document("scheduled"), options); + await renderer.renderPreview(immediateDocument, options); + await vi.advanceTimersByTimeAsync(90); + + expect(exportDocumentToDcHtml).toHaveBeenCalledExactlyOnceWith(immediateDocument, options); + expect(setHtml).toHaveBeenCalledExactlyOnceWith("now-html"); + }); + + it("reports queued work as busy and coalesces rapid edits", async () => { + vi.mocked(exportDocumentToDcHtml).mockResolvedValue("b-html"); + const { renderer, setHtml, setIsRendering } = createRenderer(); + const latestDocument = document("b"); + + renderer.schedulePreviewRender(document("a"), options); + expect(setIsRendering).toHaveBeenLastCalledWith(true); + renderer.schedulePreviewRender(latestDocument, options); + await vi.advanceTimersByTimeAsync(90); + + expect(exportDocumentToDcHtml).toHaveBeenCalledExactlyOnceWith(latestDocument, options); + expect(setHtml).toHaveBeenCalledExactlyOnceWith("b-html"); + }); + + it("keeps the latest result when the older export finishes last", async () => { + const old = deferred(); + const next = deferred(); + vi.mocked(exportDocumentToDcHtml) + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(next.promise); + const { renderer, setHtml } = createRenderer(); + + const oldRender = renderer.renderPreview(document("old"), options); + const nextRender = renderer.renderPreview(document("new"), options); + next.resolve("new-html"); + await nextRender; + old.resolve("old-html"); + await oldRender; + + expect(setHtml).toHaveBeenCalledExactlyOnceWith("new-html"); + }); + + it("does not let an old rejection reset a newer queued busy state", async () => { + const old = deferred(); + vi.mocked(exportDocumentToDcHtml) + .mockReturnValueOnce(old.promise) + .mockResolvedValueOnce("new-html"); + const { renderer, setHtml, setIsRendering } = createRenderer(); + const oldRender = renderer.renderPreview(document("old"), options); + const oldOutcome = oldRender.catch((error: unknown) => error); + + renderer.schedulePreviewRender(document("new"), options); + old.reject(new Error("old failure")); + expect(await oldOutcome).toEqual(new Error("old failure")); + + expect(setHtml).not.toHaveBeenCalled(); + expect(setIsRendering).toHaveBeenLastCalledWith(true); + await vi.advanceTimersByTimeAsync(90); + expect(setHtml).toHaveBeenCalledExactlyOnceWith("new-html"); + }); +});