返回 DeepSeek-Reasonix
raf-batch.test.ts
根目录 / desktop / frontend / src / __tests__ / raf-batch.test.ts
1 // Run: tsx src/__tests__/raf-batch.test.ts
2 //
3 // rafBatch must flush coalesced deltas once per animation frame in the visible
4 // path and request a best-effort timer flush when rAF stops while the JS task
5 // queue still runs. The fake scheduler verifies the requested 200ms ordering;
6 // browser throttling or a blocked main thread may deliver either callback later.
7
8 import { createRafBatch } from "../lib/rafBatch";
9
10 let passed = 0;
11 let failed = 0;
12
13 function eq(a: unknown, b: unknown, label: string) {
14 if (a === b) {
15 process.stdout.write(` PASS ${label}
16 `);
17 passed += 1;
18 } else {
19 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}
20 `);
21 failed += 1;
22 }
23 }
24
25 type RafCb = () => void;
26
27 // Fake scheduler: rAF callbacks fire only on frame(), timers only on
28 // advance(), so a stalled-rAF scenario is fully controllable.
29 class Scheduler {
30 rafQueue = new Map<number, RafCb>();
31 timers = new Map<number, { at: number; cb: RafCb }>();
32 now = 0;
33 nextId = 1;
34
35 install() {
36 const s = this;
37 (globalThis as Record<string, unknown>).requestAnimationFrame = (cb: RafCb) => {
38 const id = s.nextId++;
39 s.rafQueue.set(id, cb);
40 return id;
41 };
42 (globalThis as Record<string, unknown>).cancelAnimationFrame = (id: number) => {
43 s.rafQueue.delete(id);
44 };
45 (globalThis as Record<string, unknown>).setTimeout = (cb: RafCb, ms?: number) => {
46 const id = s.nextId++;
47 s.timers.set(id, { at: s.now + (ms ?? 0), cb });
48 return id;
49 };
50 (globalThis as Record<string, unknown>).clearTimeout = (id: number) => {
51 s.timers.delete(id);
52 };
53 }
54
55 uninstall() {
56 const g = globalThis as Record<string, unknown>;
57 delete g.requestAnimationFrame;
58 delete g.cancelAnimationFrame;
59 delete g.setTimeout;
60 delete g.clearTimeout;
61 }
62
63 uninstallRafOnly() {
64 const g = globalThis as Record<string, unknown>;
65 delete g.requestAnimationFrame;
66 delete g.cancelAnimationFrame;
67 }
68
69 frame() {
70 const pending = [...this.rafQueue.keys()];
71 for (const id of pending) {
72 const cb = this.rafQueue.get(id);
73 this.rafQueue.delete(id);
74 cb?.();
75 }
76 }
77
78 advance(ms: number) {
79 const target = this.now + ms;
80 for (;;) {
81 const due = [...this.timers.entries()]
82 .filter(([, t]) => t.at <= target)
83 .sort((a, b) => a[1].at - b[1].at);
84 if (due.length === 0) break;
85 const [id, t] = due[0];
86 this.timers.delete(id);
87 this.now = t.at;
88 t.cb();
89 }
90 this.now = target;
91 }
92 }
93
94 const sched = new Scheduler();
95 sched.install();
96
97 // --- one flush per animation frame in the visible path ---
98 {
99 const flushed: string[][] = [];
100 const batch = createRafBatch<string>((out) => flushed.push(out));
101 batch.push("a");
102 batch.push("b");
103 eq(flushed.length, 0, "deltas wait for a frame (rAF still scheduled)");
104 sched.frame();
105 eq(flushed.length, 1, "one frame produces exactly one flush");
106 eq(JSON.stringify(flushed[0]), JSON.stringify(["a", "b"]), "same-frame deltas coalesce into one batch");
107 batch.push("c");
108 sched.frame();
109 eq(flushed.length, 2, "next frame flushes the next batch");
110 eq(JSON.stringify(flushed[1]), JSON.stringify(["c"]), "subsequent push lands in its own batch");
111 // The stall timer must have been cancelled by the rAF flushes.
112 sched.advance(1000);
113 eq(flushed.length, 2, "advancing time does not double-flush after rAF flushes");
114 }
115
116 // --- rAF stalls: the stall timer flushes after STALL_TIMEOUT_MS ---
117 {
118 const flushed: string[][] = [];
119 const batch = createRafBatch<string>((out) => flushed.push(out));
120 batch.push("thinking");
121 batch.push("chunk");
122 eq(flushed.length, 0, "nothing flushes while rAF is stalled");
123 sched.advance(199);
124 eq(flushed.length, 0, "still nothing before the stall timeout");
125 sched.advance(1);
126 eq(flushed.length, 1, "stall timer flushes the accumulated deltas");
127 eq(JSON.stringify(flushed[0]), JSON.stringify(["thinking", "chunk"]), "stall flush delivers everything buffered");
128 sched.advance(1000);
129 eq(flushed.length, 1, "stall flush is a one-shot; no repeated flushes");
130 }
131
132 // --- drain() flushes immediately and cancels the pending stall timer ---
133 {
134 const flushed: string[][] = [];
135 const batch = createRafBatch<string>((out) => flushed.push(out));
136 batch.push("x");
137 batch.drain();
138 eq(flushed.length, 1, "drain flushes immediately");
139 eq(JSON.stringify(flushed[0]), JSON.stringify(["x"]), "drain delivers the buffered delta");
140 eq(batch.size(), 0, "buffer is empty after drain");
141 sched.advance(1000);
142 eq(flushed.length, 1, "drain cancels rAF and the stall timer");
143 }
144
145 // --- re-entrant push() during flush lands in the next batch ---
146 {
147 const flushed: string[][] = [];
148 let nextRef: { push: (v: string) => void } | undefined;
149 const batch = createRafBatch<string>((out) => {
150 flushed.push(out);
151 if (out[0] === "first" && nextRef) nextRef.push("reentrant");
152 });
153 nextRef = batch;
154 batch.push("first");
155 sched.frame();
156 eq(flushed.length, 1, "first flush ran");
157 sched.frame();
158 eq(flushed.length, 2, "re-entrant push was flushed on the next frame");
159 eq(JSON.stringify(flushed[1]), JSON.stringify(["reentrant"]), "re-entrant delta coalesces into its own batch");
160 }
161
162 // --- microtask fallback still works without rAF (SSR / JSDOM) ---
163 {
164 sched.uninstallRafOnly(); // keep fake timers; only rAF is absent
165 const flushed: string[][] = [];
166 const batch = createRafBatch<string>((out) => flushed.push(out));
167 batch.push("y");
168 await Promise.resolve();
169 await Promise.resolve();
170 eq(flushed.length, 1, "microtask fallback flushes without rAF");
171 sched.install();
172 }
173
174 process.stdout.write(`
175 ${passed} passed, ${failed} failed
176 `);
177 if (failed > 0) process.exit(1);
178
178 lines TYPESCRIPT