返回 DeepSeek-Reasonix
native-motion.test.tsx
根目录 / desktop / frontend / src / __tests__ / native-motion.test.tsx
1 // Run: tsx src/__tests__/native-motion.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { useRef } from "react";
5 import { act } from "react";
6 import { createRoot, type Root } from "react-dom/client";
7 import { CSS_EASE_OUT } from "../lib/motion";
8 import { useCollapseAnimation } from "../lib/useCollapseAnimation";
9 import { transcriptEntranceResetKey, useEntranceAnimation } from "../lib/useEntranceAnimation";
10 import { useMountTransition } from "../lib/useMountTransition";
11
12 let passed = 0;
13 let failed = 0;
14
15 type ControllableAnimation = {
16 onfinish: (() => void) | null;
17 oncancel: (() => void) | null;
18 cancelCalls: number;
19 cancel: () => void;
20 };
21
22 function ok(value: boolean, label: string) {
23 if (value) {
24 process.stdout.write(` PASS ${label}\n`);
25 passed += 1;
26 } else {
27 process.stdout.write(` FAIL ${label}\n`);
28 failed += 1;
29 }
30 }
31
32 function eq(actual: unknown, expected: unknown, label: string) {
33 if (actual === expected) ok(true, label);
34 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
35 }
36
37 function flushTimers(ms = 0): Promise<void> {
38 return new Promise((resolve) => setTimeout(resolve, ms));
39 }
40
41 function controllableAnimation(): ControllableAnimation {
42 const animation: ControllableAnimation = {
43 onfinish: null,
44 oncancel: null,
45 cancelCalls: 0,
46 cancel() {
47 animation.cancelCalls += 1;
48 animation.oncancel?.();
49 },
50 };
51 return animation;
52 }
53
54 function installDom(reducedMotion = false) {
55 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
56 pretendToBeVisual: true,
57 url: "http://localhost/",
58 });
59 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
60 globalThis.window = dom.window as unknown as Window & typeof globalThis;
61 globalThis.document = dom.window.document;
62 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
63 globalThis.Node = dom.window.Node;
64 globalThis.Element = dom.window.Element;
65 globalThis.HTMLElement = dom.window.HTMLElement;
66 globalThis.Event = dom.window.Event;
67 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
68 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
69 dom.window.matchMedia = () => ({
70 matches: reducedMotion,
71 media: "(prefers-reduced-motion: reduce)",
72 onchange: null,
73 addListener: () => undefined,
74 removeListener: () => undefined,
75 addEventListener: () => undefined,
76 removeEventListener: () => undefined,
77 dispatchEvent: () => false,
78 });
79 return dom;
80 }
81
82 function mockNativeAnimate(
83 dom: JSDOM,
84 implementation: (
85 this: Element,
86 frames: Keyframe[] | PropertyIndexedKeyframes | null,
87 options: KeyframeAnimationOptions,
88 ) => ControllableAnimation,
89 ) {
90 Object.defineProperty(dom.window.Element.prototype, "animate", {
91 configurable: true,
92 value: function (frames: Keyframe[] | PropertyIndexedKeyframes | null, options: number | KeyframeAnimationOptions) {
93 if (typeof options === "number") throw new TypeError("expected keyframe animation options");
94 return implementation.call(this, frames, options) as unknown as Animation;
95 },
96 });
97 }
98
99 async function cleanup(root: Root, dom: JSDOM) {
100 await act(async () => root.unmount());
101 dom.window.close();
102 }
103
104 function CollapseHarness({
105 open,
106 onOpen,
107 onClose,
108 }: {
109 open: boolean;
110 onOpen?: () => void;
111 onClose?: () => void;
112 }) {
113 const ref = useRef<HTMLDivElement>(null);
114 useCollapseAnimation(ref, open, { onOpenComplete: onOpen, onCloseComplete: onClose });
115 return <div id="collapse" ref={ref}>content</div>;
116 }
117
118 async function renderCollapse(root: Root, open: boolean, onOpen?: () => void, onClose?: () => void) {
119 await act(async () => {
120 root.render(<CollapseHarness open={open} onOpen={onOpen} onClose={onClose} />);
121 });
122 }
123
124 function EntranceHarness({ resetKey, ids }: { resetKey: string; ids: string[] }) {
125 const ref = useEntranceAnimation<HTMLDivElement>(resetKey, ids.length, "[data-entrance]", ids);
126 return (
127 <div ref={ref}>
128 {ids.map((id) => <div key={id} id={`entry-${id}`} data-entrance={id} />)}
129 </div>
130 );
131 }
132
133 function DeferredEntranceHarness({
134 resetKey,
135 seedIds,
136 visibleIds,
137 }: {
138 resetKey: string;
139 seedIds: string[];
140 visibleIds: string[];
141 }) {
142 const ref = useEntranceAnimation<HTMLDivElement>(resetKey, visibleIds.length, "[data-entrance]", seedIds);
143 return (
144 <div ref={ref}>
145 {visibleIds.map((id) => <div key={id} id={`entry-${id}`} data-entrance={id} />)}
146 </div>
147 );
148 }
149
150 function MountHarness({ open, duration }: { open: boolean; duration: number }) {
151 const { mounted } = useMountTransition(open, duration);
152 return mounted ? <div id="mounted" /> : null;
153 }
154
155 console.log("\nnative motion fallbacks");
156
157 // Transcript tail appends keep one entrance generation; real surface changes
158 // and history prepends reset it before virtual rows mount.
159 {
160 const base = [{ id: "u1" }, { id: "a1" }];
161 const appended = [...base, { id: "u2" }];
162 const prepended = [{ id: "old-u" }, ...base];
163 const initialKey = transcriptEntranceResetKey("tab-a", 0, base);
164 eq(transcriptEntranceResetKey("tab-a", 0, appended), initialKey, "tail append preserves transcript entrance reset key");
165 ok(transcriptEntranceResetKey("tab-a", 0, prepended) !== initialKey, "history prepend changes transcript entrance reset key");
166 ok(transcriptEntranceResetKey("tab-b", 0, base) !== initialKey, "tab switch changes transcript entrance reset key");
167 ok(transcriptEntranceResetKey("tab-a", 1, base) !== initialKey, "reveal signal changes transcript entrance reset key");
168 }
169
170 // Valid native motion waits for completion and settles exactly once.
171 {
172 const dom = installDom();
173 const animations: ControllableAnimation[] = [];
174 let easing: string | undefined;
175 mockNativeAnimate(dom, (_frames, options) => {
176 easing = options.easing;
177 const animation = controllableAnimation();
178 animations.push(animation);
179 return animation;
180 });
181 const root = createRoot(document.getElementById("root")!);
182 let opened = 0;
183 await renderCollapse(root, false, () => { opened += 1; });
184 await renderCollapse(root, true, () => { opened += 1; });
185
186 eq(easing, CSS_EASE_OUT, "collapse passes a CSS easing to Element.animate");
187 eq(opened, 0, "collapse waits for native completion");
188 await act(async () => {
189 animations[0].onfinish?.();
190 animations[0].oncancel?.();
191 });
192 eq(opened, 1, "finish and late cancel settle collapse only once");
193 eq((document.getElementById("collapse") as HTMLElement).style.height, "auto", "finished open state uses auto height");
194 await cleanup(root, dom);
195 }
196
197 // A synchronously rejecting WebView must expose the requested final state.
198 {
199 const dom = installDom();
200 mockNativeAnimate(dom, () => {
201 throw new TypeError("WebView rejected height animation");
202 });
203 const root = createRoot(document.getElementById("root")!);
204 let opened = 0;
205 await renderCollapse(root, false, () => { opened += 1; });
206 await renderCollapse(root, true, () => { opened += 1; });
207 eq(opened, 1, "rejected collapse animation runs its completion fallback");
208 eq((document.getElementById("collapse") as HTMLElement).style.height, "auto", "rejected collapse animation exposes content");
209 await cleanup(root, dom);
210 }
211
212 // Direction reversal cancels the old animation without firing its stale callback.
213 {
214 const dom = installDom();
215 const animations: ControllableAnimation[] = [];
216 mockNativeAnimate(dom, () => {
217 const animation = controllableAnimation();
218 animations.push(animation);
219 return animation;
220 });
221 const root = createRoot(document.getElementById("root")!);
222 let opened = 0;
223 let closed = 0;
224 await renderCollapse(root, false, () => { opened += 1; }, () => { closed += 1; });
225 await renderCollapse(root, true, () => { opened += 1; }, () => { closed += 1; });
226 await renderCollapse(root, false, () => { opened += 1; }, () => { closed += 1; });
227 eq(animations[0].cancelCalls, 1, "direction reversal cancels the previous animation");
228 eq(opened, 0, "superseded open callback is suppressed");
229 await act(async () => animations[1].oncancel?.());
230 eq(closed, 1, "unexpected cancellation settles the active direction");
231 eq((document.getElementById("collapse") as HTMLElement).style.height, "0px", "cancelled close reaches zero height");
232 await cleanup(root, dom);
233 }
234
235 // A stable reset key animates appends; a real reset pre-seeds restored rows.
236 {
237 const dom = installDom();
238 const calls: { options: KeyframeAnimationOptions; animation: ControllableAnimation }[] = [];
239 mockNativeAnimate(dom, (_frames, options) => {
240 const animation = controllableAnimation();
241 calls.push({ options, animation });
242 return animation;
243 });
244 const root = createRoot(document.getElementById("root")!);
245 await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a"]} />));
246 await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a", "b"]} />));
247 await act(async () => { await flushTimers(25); });
248 eq(calls.length, 1, "stable entrance reset key animates an appended row");
249 eq(calls[0].options.easing, CSS_EASE_OUT, "entrance animation uses the shared CSS easing");
250 await act(async () => calls[0].animation.onfinish?.());
251 eq((document.getElementById("entry-b") as HTMLElement).style.opacity, "1", "finished entrance exposes the row");
252
253 await act(async () => root.render(<EntranceHarness resetKey="tab-b" ids={["a", "b", "c"]} />));
254 await act(async () => { await flushTimers(25); });
255 eq(calls.length, 1, "changed reset key pre-seeds restored rows without animation");
256 await act(async () => root.render(<EntranceHarness resetKey="tab-b" ids={["a", "b", "c", "d"]} />));
257 await act(async () => { await flushTimers(25); });
258 eq(calls.length, 2, "append after a reset animates normally");
259 await cleanup(root, dom);
260 }
261
262 // Virtual rows can mount after the initial scan. Model-backed seed IDs keep
263 // restored rows inert while still allowing a genuinely appended row to enter.
264 {
265 const dom = installDom();
266 const calls: string[] = [];
267 mockNativeAnimate(dom, function () {
268 calls.push((this as unknown as Element).getAttribute?.("data-entrance") ?? "");
269 return controllableAnimation();
270 });
271 const root = createRoot(document.getElementById("root")!);
272 await act(async () => root.render(
273 <DeferredEntranceHarness resetKey="tab-a" seedIds={["history"]} visibleIds={[]} />,
274 ));
275 await act(async () => root.render(
276 <DeferredEntranceHarness resetKey="tab-a" seedIds={["history", "new"]} visibleIds={["history", "new"]} />,
277 ));
278 await act(async () => { await flushTimers(25); });
279 eq(calls.length, 1, "deferred virtual history does not animate with a new append");
280 eq(calls[0], "new", "only the genuinely appended virtual row animates");
281 await cleanup(root, dom);
282 }
283
284 // One rejected entrance must fail open without aborting the timer callback.
285 {
286 const dom = installDom();
287 let attempts = 0;
288 mockNativeAnimate(dom, () => {
289 attempts += 1;
290 throw new TypeError("WebView rejected entrance animation");
291 });
292 const root = createRoot(document.getElementById("root")!);
293 await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a"]} />));
294 await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a", "b", "c"]} />));
295 await act(async () => { await flushTimers(25); });
296 eq(attempts, 2, "a rejected entrance does not prevent later entries from attempting motion");
297 eq((document.getElementById("entry-b") as HTMLElement).style.opacity, "1", "rejected entrance exposes the first row");
298 eq((document.getElementById("entry-c") as HTMLElement).style.opacity, "1", "rejected entrance exposes later rows");
299 await cleanup(root, dom);
300 }
301
302 // Terminal-style conditional content unmounts on a bounded timer even if no
303 // transitionend event is delivered.
304 {
305 const dom = installDom();
306 const root = createRoot(document.getElementById("root")!);
307 await act(async () => root.render(<MountHarness open={true} duration={20} />));
308 ok(Boolean(document.getElementById("mounted")), "transition content mounts while open");
309 await act(async () => root.render(<MountHarness open={false} duration={20} />));
310 ok(Boolean(document.getElementById("mounted")), "transition content remains mounted during close delay");
311 await act(async () => { await flushTimers(30); });
312 ok(!document.getElementById("mounted"), "transition content unmounts without transitionend");
313 await cleanup(root, dom);
314 }
315
316 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
317 if (failed > 0) process.exit(1);
318
318 lines Plain Text