返回 DeepSeek-Reasonix
anchored-popover-scroll.test.tsx
根目录 / desktop / frontend / src / __tests__ / anchored-popover-scroll.test.tsx
1 // Run: tsx src/__tests__/anchored-popover-scroll.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { useRef } from "react";
5 import { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import { AnchoredPopover } from "../components/AnchoredPopover";
8
9 type RectParts = Pick<DOMRect, "left" | "top" | "right" | "bottom" | "width" | "height">;
10 let passed = 0;
11 let failed = 0;
12 function ok(value: boolean, label: string) {
13 process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
14 if (value) passed += 1;
15 else failed += 1;
16 }
17 function eq(actual: unknown, expected: unknown, label: string) {
18 ok(actual === expected, actual === expected ? label : `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
19 }
20 function rect(parts: RectParts): DOMRect {
21 return { ...parts, x: parts.left, y: parts.top, toJSON: () => ({}) } as DOMRect;
22 }
23 async function nextFrame() {
24 await act(async () => { await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); });
25 }
26
27 let closeCount = 0;
28 function Harness({ showAnchor = true, open = true, hidden = false, visibilityHidden = false, inert = false, zero = false }: {
29 showAnchor?: boolean;
30 open?: boolean;
31 hidden?: boolean;
32 visibilityHidden?: boolean;
33 inert?: boolean;
34 zero?: boolean;
35 }) {
36 const anchorRef = useRef<HTMLButtonElement>(null);
37 return (
38 <div hidden={hidden || undefined} inert={inert || undefined} style={visibilityHidden ? { visibility: "hidden" } : undefined}>
39 {showAnchor && <button ref={anchorRef} data-testid="anchor" data-zero={zero || undefined}>Anchor</button>}
40 <AnchoredPopover open={open} anchorRef={anchorRef} onClose={() => { closeCount += 1; }}
41 className="test-popover" placement="bottom"><div>Menu</div></AnchoredPopover>
42 </div>
43 );
44 }
45
46 console.log("\nanchored popover lifecycle and positioning");
47 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true });
48 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
49 Object.assign(globalThis, {
50 window: dom.window, document: dom.window.document, Node: dom.window.Node, HTMLElement: dom.window.HTMLElement,
51 Event: dom.window.Event, KeyboardEvent: dom.window.KeyboardEvent, MouseEvent: dom.window.MouseEvent,
52 requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window),
53 cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window),
54 });
55 Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 });
56 Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
57
58 let anchorTop = 100;
59 let anchorLeft = 60;
60 let menuHeight = 120;
61 const originalGetBoundingClientRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
62 dom.window.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
63 if (this instanceof dom.window.HTMLElement && this.dataset.testid === "anchor") {
64 const width = this.dataset.zero ? 0 : 200;
65 const height = this.dataset.zero ? 0 : 30;
66 return rect({ left: anchorLeft, top: anchorTop, right: anchorLeft + width, bottom: anchorTop + height, width, height });
67 }
68 if (this instanceof dom.window.HTMLElement && this.dataset.anchoredPopover === "active") {
69 return rect({ left: 0, top: 0, right: 240, bottom: menuHeight, width: 240, height: menuHeight });
70 }
71 return originalGetBoundingClientRect.call(this);
72 };
73
74 const rootEl = document.getElementById("root");
75 if (!rootEl) throw new Error("missing root");
76 const root = createRoot(rootEl);
77
78 await act(async () => { root.render(<Harness showAnchor={false} />); });
79 let popover = document.querySelector<HTMLElement>("[data-anchored-popover='active']");
80 if (!popover) throw new Error("popover did not render during initial measurement");
81 eq(popover.style.left, "0px", "unmeasured popover does not use a visible corner fallback");
82 eq(popover.style.top, "0px", "unmeasured popover stays at an inert origin");
83 eq(popover.style.visibility, "hidden", "unmeasured popover stays hidden");
84 eq(popover.style.pointerEvents, "none", "unmeasured popover cannot intercept input");
85 eq(popover.dataset.ready, "false", "unmeasured position does not pretend layout is ready");
86
87 await nextFrame();
88 await act(async () => { root.render(<Harness />); });
89 await nextFrame();
90 popover = document.querySelector<HTMLElement>("[data-anchored-popover='active']");
91 if (!popover) throw new Error("popover did not recover when its anchor mounted");
92 eq(popover.style.top, "138px", "popover starts below a late-mounted anchor");
93 eq(popover.dataset.ready, "true", "popover becomes ready after measurement recovers");
94
95 anchorTop = 40;
96 anchorLeft = 90;
97 await nextFrame();
98 eq(popover.style.top, "78px", "popover follows anchor movement without an event");
99 eq(popover.style.left, "90px", "popover follows horizontal layout movement");
100
101 anchorTop = 300;
102 menuHeight = 180;
103 await nextFrame();
104 eq(popover.style.top, "338px", "bottom placement follows menu layout-size changes");
105
106 await act(async () => { root.render(<Harness showAnchor={false} />); });
107 await nextFrame();
108 await nextFrame();
109 ok(document.querySelector("[data-anchored-popover='active']") === null, "popover is removed when its measured anchor disappears");
110 eq(closeCount, 1, "anchor invalidation notifies its owner once");
111 await nextFrame();
112 eq(closeCount, 1, "stale frames cannot notify the owner again");
113
114 await act(async () => { root.render(<Harness open={false} />); });
115 await act(async () => { root.render(<Harness hidden />); });
116 ok(document.querySelector("[data-anchored-popover='active']") === null, "hidden ancestor closes before a corner flash");
117 eq(closeCount, 2, "hidden ancestor clears owner state");
118
119 await act(async () => { root.render(<Harness open={false} />); });
120 await act(async () => { root.render(<Harness inert />); });
121 ok(document.querySelector("[data-anchored-popover='active']") === null, "inert ancestor closes its portaled popover");
122 eq(closeCount, 3, "inert ancestor clears owner state");
123
124 await act(async () => { root.render(<Harness open={false} />); });
125 await act(async () => { root.render(<Harness visibilityHidden />); });
126 ok(document.querySelector("[data-anchored-popover='active']") === null, "visibility-hidden ancestor closes its portaled popover");
127 eq(closeCount, 4, "visibility-hidden ancestor clears owner state");
128
129 await act(async () => { root.render(<Harness open={false} />); });
130 await act(async () => { root.render(<Harness zero />); });
131 const zeroPopover = document.querySelector<HTMLElement>("[data-anchored-popover='active']");
132 eq(zeroPopover?.style.visibility, "hidden", "zero-size anchor stays hidden during bounded retries");
133 for (let i = 0; i < 9; i += 1) await nextFrame();
134 ok(document.querySelector("[data-anchored-popover='active']") === null, "zero-size anchor closes after bounded retries");
135 eq(closeCount, 5, "zero-size anchor clears owner state once");
136
137 await act(async () => { root.unmount(); });
138 dom.window.close();
139 console.log(`\n${passed} passed, ${failed} failed`);
140 if (failed > 0) process.exit(1);
141
141 lines Plain Text