返回 DeepSeek-Reasonix
composer-menu-viewport.test.ts
根目录 / desktop / frontend / src / __tests__ / composer-menu-viewport.test.ts
1 // Run: tsx src/__tests__/composer-menu-viewport.test.ts
2
3 import { JSDOM } from "jsdom";
4 import {
5 COMPOSER_MENU_AVAILABLE_HEIGHT,
6 composerMenuAvailableHeight,
7 observeComposerMenuViewport,
8 } from "../lib/composerMenuViewport";
9
10 let passed = 0;
11 let failed = 0;
12
13 function ok(value: boolean, label: string) {
14 if (value) {
15 process.stdout.write(` PASS ${label}\n`);
16 passed += 1;
17 } else {
18 process.stdout.write(` FAIL ${label}\n`);
19 failed += 1;
20 }
21 }
22
23 function eq(actual: unknown, expected: unknown, label: string) {
24 ok(actual === expected, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
25 }
26
27 function rect(top: number): DOMRect {
28 return {
29 left: 0,
30 top,
31 right: 600,
32 bottom: top + 120,
33 width: 600,
34 height: 120,
35 x: 0,
36 y: top,
37 toJSON: () => ({}),
38 } as DOMRect;
39 }
40
41 class TestResizeObserver {
42 static instance: TestResizeObserver | null = null;
43 readonly observed = new Set<Element>();
44 disconnected = false;
45
46 constructor(private readonly callback: ResizeObserverCallback) {
47 TestResizeObserver.instance = this;
48 }
49
50 observe(target: Element) {
51 this.observed.add(target);
52 }
53
54 unobserve(target: Element) {
55 this.observed.delete(target);
56 }
57
58 disconnect() {
59 this.disconnected = true;
60 this.observed.clear();
61 }
62
63 trigger() {
64 this.callback([], this as unknown as ResizeObserver);
65 }
66 }
67
68 console.log("\ncomposer menu viewport geometry");
69
70 const dom = new JSDOM(`<!doctype html><html><body>
71 <div id="layout">
72 <main id="chat"></main>
73 <footer id="footer"><div id="anchor"><div class="slashmenu"></div></div></footer>
74 <section id="terminal"></section>
75 </div>
76 </body></html>`, { pretendToBeVisual: true });
77
78 globalThis.window = dom.window as unknown as Window & typeof globalThis;
79 globalThis.document = dom.window.document;
80 globalThis.Element = dom.window.Element;
81 globalThis.HTMLElement = dom.window.HTMLElement;
82 globalThis.Event = dom.window.Event;
83 globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
84
85 let nextFrameId = 1;
86 const frames = new Map<number, FrameRequestCallback>();
87 const requestFrame = (callback: FrameRequestCallback) => {
88 const id = nextFrameId++;
89 frames.set(id, callback);
90 return id;
91 };
92 const cancelFrame = (id: number) => frames.delete(id);
93 dom.window.requestAnimationFrame = requestFrame;
94 dom.window.cancelAnimationFrame = cancelFrame;
95 globalThis.requestAnimationFrame = requestFrame;
96 globalThis.cancelAnimationFrame = cancelFrame;
97
98 function flushFrames() {
99 const queued = [...frames.entries()];
100 frames.clear();
101 for (const [, callback] of queued) callback(0);
102 }
103
104 const anchor = document.getElementById("anchor");
105 const terminal = document.getElementById("terminal");
106 if (!(anchor instanceof HTMLElement) || !(terminal instanceof HTMLElement)) {
107 throw new Error("missing geometry test elements");
108 }
109
110 let anchorTop = 567;
111 anchor.getBoundingClientRect = () => rect(anchorTop);
112
113 eq(composerMenuAvailableHeight(anchorTop), 553, "normal layout exposes the full 360px CSS cap");
114 eq(composerMenuAvailableHeight(10), 0, "tiny viewports never produce a negative max height");
115
116 const stop = observeComposerMenuViewport(anchor);
117 eq(anchor.style.getPropertyValue(COMPOSER_MENU_AVAILABLE_HEIGHT), "553px", "initial layout is measured synchronously");
118
119 const observer = TestResizeObserver.instance;
120 if (!observer) throw new Error("ResizeObserver was not installed");
121 ok(observer.observed.has(terminal), "terminal sibling is observed because it can move the composer anchor");
122
123 // Reproduce the constrained layout from the review: terminal + tall composer
124 // move the anchor to y=207. The menu has 207 - 6 (anchor gap) - 8 (safe edge)
125 // = 193px, so its first rows remain on-screen and the rest scroll internally.
126 anchorTop = 207;
127 observer.trigger();
128 flushFrames();
129 eq(anchor.style.getPropertyValue(COMPOSER_MENU_AVAILABLE_HEIGHT), "193px", "terminal expansion uses real space above the composer");
130
131 anchorTop = 307;
132 window.dispatchEvent(new Event("resize"));
133 flushFrames();
134 eq(anchor.style.getPropertyValue(COMPOSER_MENU_AVAILABLE_HEIGHT), "293px", "viewport resize recomputes the geometry");
135
136 observer.trigger();
137 stop();
138 eq(frames.size, 0, "cleanup cancels a queued geometry frame");
139 eq(anchor.style.getPropertyValue(COMPOSER_MENU_AVAILABLE_HEIGHT), "", "cleanup removes the temporary CSS variable");
140 ok(observer.disconnected, "cleanup disconnects layout observers");
141
142 dom.window.close();
143
144 console.log(`\n${passed} passed, ${failed} failed`);
145 if (failed > 0) process.exit(1);
146
146 lines TYPESCRIPT