1
2
3
4
5import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
7
8vi.mock("@/lib/api", () => ({
9 blockApi: {
10 batch: vi.fn(async () => ({ applied: 0 })),
11 update: vi.fn(async (id: string, patch: object) => ({ id, ...patch })),
12 insert: vi.fn(async () => ({ id: "blk_new" })),
13 move: vi.fn(async () => ({ id: "blk_moved" })),
14 remove: vi.fn(async () => undefined),
15 duplicate: vi.fn(async () => ({ id: "blk_dup" })),
16 },
17}));
18
19import { blockApi } from "@/lib/api";
20import { BlockBatchQueue, getBlockBatchQueue, resetBlockBatchQueues } from "@/lib/batch-queue";
21import type { BatchBlockOp } from "@/lib/api";
22
23const batchMock = vi.mocked(blockApi.batch);
24
25const op = (id: string, extra?: Partial<BatchBlockOp>): BatchBlockOp => ({
26 op: "update",
27 blockId: id,
28 patch: { content: `content-${id}` },
29 ...extra,
30});
31
32describe("BlockBatchQueue (debounced batch save)", () => {
33 beforeEach(() => {
34 vi.useFakeTimers();
35 batchMock.mockClear();
36 batchMock.mockResolvedValue({ applied: 0 });
37 resetBlockBatchQueues();
38 });
39
40 afterEach(() => {
41 vi.useRealTimers();
42 });
43
44 it("does not call the API before the debounce window elapses", () => {
45 const q = new BlockBatchQueue("pg1", 500);
46 q.queue(op("blk1"));
47 vi.advanceTimersByTime(499);
48 expect(batchMock).not.toHaveBeenCalled();
49 q.dispose();
50 });
51
52 it("flushes queued ops in FIFO order as ONE batch after 500 ms", async () => {
53 const q = new BlockBatchQueue("pg1", 500);
54 q.queue(op("blk1"));
55 vi.advanceTimersByTime(200);
56 q.queue([op("blk2"), op("blk3")]);
57 vi.advanceTimersByTime(499);
58 expect(batchMock).not.toHaveBeenCalled();
59 vi.advanceTimersByTime(1);
60 await vi.runOnlyPendingTimersAsync();
61
62 expect(batchMock).toHaveBeenCalledTimes(1);
63 const [pageId, ops] = batchMock.mock.calls[0];
64 expect(pageId).toBe("pg1");
65 expect(ops.map((o) => o.blockId)).toEqual(["blk1", "blk2", "blk3"]);
66 expect(ops[0]?.patch).toEqual({ content: "content-blk1" });
67 });
68
69 it("re-arms the debounce after a flush", async () => {
70 const q = new BlockBatchQueue("pg1", 500);
71 q.queue(op("blk1"));
72 vi.advanceTimersByTime(500);
73 await vi.runOnlyPendingTimersAsync();
74 expect(batchMock).toHaveBeenCalledTimes(1);
75
76 q.queue(op("blk2"));
77 vi.advanceTimersByTime(499);
78 expect(batchMock).toHaveBeenCalledTimes(1);
79 vi.advanceTimersByTime(1);
80 await vi.runOnlyPendingTimersAsync();
81 expect(batchMock).toHaveBeenCalledTimes(2);
82 expect(batchMock.mock.calls[1]?.[1].map((o) => o.blockId)).toEqual(["blk2"]);
83 });
84
85 it("flush() persists immediately and cancels the pending timer", async () => {
86 const q = new BlockBatchQueue("pg1", 500);
87 q.queue(op("blk1"));
88 const done = q.flush();
89 expect(batchMock).toHaveBeenCalledTimes(1);
90 expect(batchMock.mock.calls[0]?.[1].map((o) => o.blockId)).toEqual(["blk1"]);
91 await done;
92
93 vi.advanceTimersByTime(10_000);
94 await vi.runOnlyPendingTimersAsync();
95 expect(batchMock).toHaveBeenCalledTimes(1);
96 });
97
98 it("re-queues ops ahead of newer ones when a flush fails", async () => {
99 const q = new BlockBatchQueue("pg1", 500);
100 batchMock.mockRejectedValueOnce(new Error("network down"));
101
102 q.queue(op("blk1"));
103 vi.advanceTimersByTime(500);
104 await vi.runOnlyPendingTimersAsync();
105 expect(batchMock).toHaveBeenCalledTimes(1);
106 expect(q.pendingCount).toBe(1);
107
108 batchMock.mockResolvedValue({ applied: 2 });
109 q.queue(op("blk2"));
110 vi.advanceTimersByTime(500);
111 await vi.runOnlyPendingTimersAsync();
112 expect(batchMock).toHaveBeenCalledTimes(2);
113
114 expect(batchMock.mock.calls[1]?.[1].map((o) => o.blockId)).toEqual(["blk1", "blk2"]);
115 });
116
117 it("resolves flush() with a no-op when the queue is empty", async () => {
118 const q = new BlockBatchQueue("pg1", 500);
119 await q.flush();
120 expect(batchMock).not.toHaveBeenCalled();
121 });
122});
123
124describe("getBlockBatchQueue (per-page singleton)", () => {
125 it("returns the same instance for the same page (shared queue, no double flush)", () => {
126 resetBlockBatchQueues();
127 const a = getBlockBatchQueue("pg1");
128 const b = getBlockBatchQueue("pg1");
129 const c = getBlockBatchQueue("pg2");
130 expect(a).toBe(b);
131 expect(a).not.toBe(c);
132 });
133});
134