返回 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
11 let passed = 0;
12 let failed = 0;
13
14 function ok(value: boolean, label: string) {
15 if (value) {
16 process.stdout.write(` PASS ${label}\n`);
17 passed += 1;
18 } else {
19 process.stdout.write(` FAIL ${label}\n`);
20 failed += 1;
21 }
22 }
23
24 function eq(actual: unknown, expected: unknown, label: string) {
25 if (actual === expected) {
26 ok(true, label);
27 } else {
28 ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
29 }
30 }
31
32 function rect({ left, top, right, bottom, width, height }: RectParts): DOMRect {
33 return {
34 left,
35 top,
36 right,
37 bottom,
38 width,
39 height,
40 x: left,
41 y: top,
42 toJSON: () => ({}),
43 } as DOMRect;
44 }
45
46 async function nextFrame() {
47 await act(async () => {
48 await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
49 });
50 }
51
52 function Harness({ showAnchor = true }: { showAnchor?: boolean }) {
53 const anchorRef = useRef<HTMLButtonElement>(null);
54 return (
55 <>
56 {showAnchor && <button ref={anchorRef} data-testid="anchor">Anchor</button>}
57 <AnchoredPopover
58 open
59 anchorRef={anchorRef}
60 onClose={() => {}}
61 className="test-popover"
62 placement="bottom"
63 >
64 <div>Menu</div>
65 </AnchoredPopover>
66 </>
67 );
68 }
69
70 console.log("\nanchored popover scroll positioning");
71
72 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
73 pretendToBeVisual: true,
74 });
75 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
76 globalThis.window = dom.window as unknown as Window & typeof globalThis;
77 globalThis.document = dom.window.document;
78 globalThis.Node = dom.window.Node;
79 globalThis.HTMLElement = dom.window.HTMLElement;
80 globalThis.Event = dom.window.Event;
81 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
82 globalThis.MouseEvent = dom.window.MouseEvent;
83 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
84 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
85
86 Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 });
87 Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
88
89 let anchorTop = 100;
90 const originalGetBoundingClientRect = dom.window.HTMLElement.prototype.getBoundingClientRect;
91 dom.window.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
92 if (this instanceof dom.window.HTMLElement && this.dataset.testid === "anchor") {
93 return rect({ left: 60, top: anchorTop, right: 260, bottom: anchorTop + 30, width: 200, height: 30 });
94 }
95 if (this instanceof dom.window.HTMLElement && this.dataset.anchoredPopover === "active") {
96 return rect({ left: 0, top: 0, right: 240, bottom: 120, width: 240, height: 120 });
97 }
98 return originalGetBoundingClientRect.call(this);
99 };
100
101 const rootEl = document.getElementById("root");
102 if (!rootEl) throw new Error("missing root");
103 const root = createRoot(rootEl);
104
105 await act(async () => {
106 root.render(<Harness showAnchor={false} />);
107 });
108
109 const popover = document.querySelector<HTMLElement>("[data-anchored-popover='active']");
110 if (!popover) throw new Error("popover did not render");
111
112 eq(popover.style.left, "8px", "unmeasured popover uses the visible horizontal fallback");
113 eq(popover.style.top, "8px", "unmeasured popover uses the visible vertical fallback");
114 eq(popover.style.visibility, "visible", "unmeasured popover remains visible");
115 eq(popover.dataset.ready, "false", "fallback position does not pretend layout is ready");
116
117 // Exhaust the first scheduled measurement while the anchor is unavailable.
118 await nextFrame();
119
120 await act(async () => {
121 root.render(<Harness />);
122 });
123 await nextFrame();
124
125 eq(popover.style.top, "138px", "popover starts below the anchor");
126 eq(popover.dataset.ready, "true", "popover becomes ready after measurement recovers");
127
128 await act(async () => {
129 anchorTop = 40;
130 window.dispatchEvent(new Event("scroll", { bubbles: true }));
131 await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
132 });
133
134 eq(popover.style.top, "78px", "popover follows the anchor after a scroll event");
135
136 await act(async () => {
137 root.render(<Harness showAnchor={false} />);
138 window.dispatchEvent(new Event("scroll", { bubbles: true }));
139 await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
140 });
141
142 eq(popover.style.top, "78px", "popover keeps its last valid position while the anchor is unavailable");
143 eq(popover.dataset.ready, "true", "temporary anchor loss keeps the last measured position ready");
144
145 await act(async () => {
146 root.unmount();
147 });
148 dom.window.close();
149
150 console.log(`\n${passed} passed, ${failed} failed`);
151 if (failed > 0) process.exit(1);
152
152 lines Plain Text