// @vitest-environment node // No DOM needed — jsdom is not installed in this workspace, so the web // default environment (jsdom, vitest.config.ts) is unreachable; node-env // tests are bannered, matching realtime-frame-handler.test.ts. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock the API layer: only blockApi.batch matters to the queue. vi.mock("@/lib/api", () => ({ blockApi: { batch: vi.fn(async () => ({ applied: 0 })), update: vi.fn(async (id: string, patch: object) => ({ id, ...patch })), insert: vi.fn(async () => ({ id: "blk_new" })), move: vi.fn(async () => ({ id: "blk_moved" })), remove: vi.fn(async () => undefined), duplicate: vi.fn(async () => ({ id: "blk_dup" })), }, })); import { blockApi } from "@/lib/api"; import { BlockBatchQueue, getBlockBatchQueue, resetBlockBatchQueues } from "@/lib/batch-queue"; import type { BatchBlockOp } from "@/lib/api"; const batchMock = vi.mocked(blockApi.batch); const op = (id: string, extra?: Partial): BatchBlockOp => ({ op: "update", blockId: id, patch: { content: `content-${id}` }, ...extra, }); describe("BlockBatchQueue (debounced batch save)", () => { beforeEach(() => { vi.useFakeTimers(); batchMock.mockClear(); batchMock.mockResolvedValue({ applied: 0 }); resetBlockBatchQueues(); }); afterEach(() => { vi.useRealTimers(); }); it("does not call the API before the debounce window elapses", () => { const q = new BlockBatchQueue("pg1", 500); q.queue(op("blk1")); vi.advanceTimersByTime(499); expect(batchMock).not.toHaveBeenCalled(); q.dispose(); }); it("flushes queued ops in FIFO order as ONE batch after 500 ms", async () => { const q = new BlockBatchQueue("pg1", 500); q.queue(op("blk1")); vi.advanceTimersByTime(200); q.queue([op("blk2"), op("blk3")]); vi.advanceTimersByTime(499); expect(batchMock).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); await vi.runOnlyPendingTimersAsync(); expect(batchMock).toHaveBeenCalledTimes(1); const [pageId, ops] = batchMock.mock.calls[0]; expect(pageId).toBe("pg1"); expect(ops.map((o) => o.blockId)).toEqual(["blk1", "blk2", "blk3"]); expect(ops[0]?.patch).toEqual({ content: "content-blk1" }); }); it("re-arms the debounce after a flush", async () => { const q = new BlockBatchQueue("pg1", 500); q.queue(op("blk1")); vi.advanceTimersByTime(500); await vi.runOnlyPendingTimersAsync(); expect(batchMock).toHaveBeenCalledTimes(1); q.queue(op("blk2")); vi.advanceTimersByTime(499); expect(batchMock).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1); await vi.runOnlyPendingTimersAsync(); expect(batchMock).toHaveBeenCalledTimes(2); expect(batchMock.mock.calls[1]?.[1].map((o) => o.blockId)).toEqual(["blk2"]); }); it("flush() persists immediately and cancels the pending timer", async () => { const q = new BlockBatchQueue("pg1", 500); q.queue(op("blk1")); const done = q.flush(); expect(batchMock).toHaveBeenCalledTimes(1); expect(batchMock.mock.calls[0]?.[1].map((o) => o.blockId)).toEqual(["blk1"]); await done; // Nothing left pending: advancing past the debounce adds no call. vi.advanceTimersByTime(10_000); await vi.runOnlyPendingTimersAsync(); expect(batchMock).toHaveBeenCalledTimes(1); }); it("re-queues ops ahead of newer ones when a flush fails", async () => { const q = new BlockBatchQueue("pg1", 500); batchMock.mockRejectedValueOnce(new Error("network down")); q.queue(op("blk1")); vi.advanceTimersByTime(500); await vi.runOnlyPendingTimersAsync(); expect(batchMock).toHaveBeenCalledTimes(1); expect(q.pendingCount).toBe(1); // failed op was re-queued batchMock.mockResolvedValue({ applied: 2 }); q.queue(op("blk2")); vi.advanceTimersByTime(500); await vi.runOnlyPendingTimersAsync(); expect(batchMock).toHaveBeenCalledTimes(2); // Old failed op first, then the newer one — FIFO preserved. expect(batchMock.mock.calls[1]?.[1].map((o) => o.blockId)).toEqual(["blk1", "blk2"]); }); it("resolves flush() with a no-op when the queue is empty", async () => { const q = new BlockBatchQueue("pg1", 500); await q.flush(); expect(batchMock).not.toHaveBeenCalled(); }); }); describe("getBlockBatchQueue (per-page singleton)", () => { it("returns the same instance for the same page (shared queue, no double flush)", () => { resetBlockBatchQueues(); const a = getBlockBatchQueue("pg1"); const b = getBlockBatchQueue("pg1"); const c = getBlockBatchQueue("pg2"); expect(a).toBe(b); expect(a).not.toBe(c); }); });