返回 DeepSeek-Reasonix
message-selection-copy.test.ts
根目录 / desktop / frontend / src / __tests__ / message-selection-copy.test.ts
1 // Run: tsx src/__tests__/message-selection-copy.test.ts
2
3 import { createElement } from "react";
4 import { renderToStaticMarkup } from "react-dom/server";
5 import ReactMarkdown from "react-markdown";
6 import { JSDOM } from "jsdom";
7
8 import { reasonixRehypePlugins, reasonixRemarkPlugins } from "../components/markdownRemarkPlugins";
9 import { normalizeMath } from "../components/mathNormalize";
10 import {
11 messageSelectionContextText,
12 messageSelectionCopyText,
13 } from "../lib/messageSelectionCopy";
14
15 let passed = 0;
16 let failed = 0;
17
18 function eq(a: unknown, b: unknown, label: string) {
19 if (a === b) {
20 process.stdout.write(` PASS ${label}\n`);
21 passed += 1;
22 } else {
23 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
24 failed += 1;
25 }
26 }
27
28 console.log("\nmessage selection copy");
29
30 eq(
31 messageSelectionCopyText({
32 text: "selected assistant reply",
33 isCollapsed: false,
34 targetIsEditable: false,
35 intersectsMessage: true,
36 canWriteClipboard: true,
37 }),
38 "selected assistant reply",
39 "copies selected message text through the fallback handler",
40 );
41
42 eq(
43 messageSelectionCopyText({
44 text: "draft text",
45 isCollapsed: false,
46 targetIsEditable: true,
47 intersectsMessage: true,
48 canWriteClipboard: true,
49 }),
50 null,
51 "does not override native copy inside editable fields",
52 );
53
54 eq(
55 messageSelectionCopyText({
56 text: " \n\t",
57 isCollapsed: false,
58 targetIsEditable: false,
59 intersectsMessage: true,
60 canWriteClipboard: true,
61 }),
62 null,
63 "ignores whitespace-only selections",
64 );
65
66 eq(
67 messageSelectionCopyText({
68 text: "settings panel text",
69 isCollapsed: false,
70 targetIsEditable: false,
71 intersectsMessage: false,
72 canWriteClipboard: true,
73 }),
74 null,
75 "leaves non-message selections to the browser",
76 );
77
78 eq(
79 messageSelectionCopyText({
80 text: "selected assistant reply",
81 isCollapsed: true,
82 targetIsEditable: false,
83 intersectsMessage: true,
84 canWriteClipboard: true,
85 }),
86 null,
87 "ignores collapsed selections",
88 );
89
90 eq(
91 messageSelectionCopyText({
92 text: "selected assistant reply",
93 isCollapsed: false,
94 targetIsEditable: false,
95 intersectsMessage: true,
96 canWriteClipboard: false,
97 }),
98 null,
99 "does not claim copy events without writable clipboard data",
100 );
101
102 function installDOMGlobals(dom: JSDOM) {
103 Object.assign(globalThis, {
104 Node: dom.window.Node,
105 Element: dom.window.Element,
106 HTMLElement: dom.window.HTMLElement,
107 });
108 }
109
110 function inlineKatex(source: string, rendered: string): string {
111 return `<span class="katex"><span class="katex-mathml"><math><semantics>` +
112 `<annotation encoding="application/x-tex">${source}</annotation>` +
113 `</semantics></math></span><span class="katex-html">${rendered}</span></span>`;
114 }
115
116 {
117 const dom = new JSDOM(
118 `<div class="msg__body" id="message">before ${inlineKatex("\\alpha", "α")} after</div>`,
119 );
120 installDOMGlobals(dom);
121 const doc = dom.window.document;
122 const message = doc.getElementById("message")!;
123 const range = doc.createRange();
124 range.selectNodeContents(message);
125 const selection = dom.window.getSelection()!;
126 selection.addRange(range);
127 eq(
128 messageSelectionContextText(doc, message),
129 "before $\\alpha$ after",
130 "restores one inline formula without dropping surrounding prose",
131 );
132 }
133
134 {
135 const dom = new JSDOM(
136 `<div class="msg__body" id="message">${inlineKatex("\\frac{1}{2}", "½")}</div>`,
137 );
138 installDOMGlobals(dom);
139 const doc = dom.window.document;
140 const message = doc.getElementById("message")!;
141 const rendered = message.querySelector(".katex-html")!.firstChild!;
142 const range = doc.createRange();
143 range.setStart(rendered, 0);
144 range.setEnd(rendered, 1);
145 const selection = dom.window.getSelection()!;
146 selection.addRange(range);
147 eq(
148 messageSelectionContextText(doc, message),
149 "$\\frac{1}{2}$",
150 "selecting part of a formula copies the complete source",
151 );
152 }
153
154 {
155 const dom = new JSDOM(
156 `<div class="msg__body" id="message">x and ${inlineKatex("x", "x")} plus x</div>`,
157 );
158 installDOMGlobals(dom);
159 const doc = dom.window.document;
160 const message = doc.getElementById("message")!;
161 const range = doc.createRange();
162 range.selectNodeContents(message);
163 const selection = dom.window.getSelection()!;
164 selection.addRange(range);
165 eq(
166 messageSelectionContextText(doc, message),
167 "x and $x$ plus x",
168 "does not replace identical prose around a formula",
169 );
170 }
171
172 {
173 const dom = new JSDOM(
174 `<div class="msg__body" id="message">before <span class="katex-display">` +
175 `${inlineKatex("x^2", "x²")}</span> after</div>`,
176 );
177 installDOMGlobals(dom);
178 const doc = dom.window.document;
179 const message = doc.getElementById("message")!;
180 const range = doc.createRange();
181 range.selectNodeContents(message);
182 const selection = dom.window.getSelection()!;
183 selection.addRange(range);
184 eq(
185 messageSelectionContextText(doc, message),
186 "before $$\nx^2\n$$ after",
187 "restores display math delimiters",
188 );
189 }
190
191 {
192 const rendered = renderToStaticMarkup(
193 createElement(ReactMarkdown, {
194 remarkPlugins: reasonixRemarkPlugins,
195 rehypePlugins: reasonixRehypePlugins,
196 children: normalizeMath("before $|x|$ then $x = 50%$ after"),
197 }),
198 );
199 const dom = new JSDOM(`<div class="msg__body" id="message">${rendered}</div>`);
200 installDOMGlobals(dom);
201 const doc = dom.window.document;
202 const message = doc.getElementById("message")!;
203 const range = doc.createRange();
204 range.selectNodeContents(message);
205 const selection = dom.window.getSelection()!;
206 selection.addRange(range);
207 eq(
208 messageSelectionContextText(doc, message),
209 "before $|x|$ then $x = 50%$ after",
210 "real Markdown rendering copies original TeX instead of normalized KaTeX input",
211 );
212 }
213
214 {
215 const rendered = renderToStaticMarkup(
216 createElement(ReactMarkdown, {
217 remarkPlugins: reasonixRemarkPlugins,
218 rehypePlugins: reasonixRehypePlugins,
219 children: normalizeMath("before $\\alpha $ then $ x $ after"),
220 }),
221 );
222 const dom = new JSDOM(`<div class="msg__body" id="message">${rendered}</div>`);
223 installDOMGlobals(dom);
224 const doc = dom.window.document;
225 const message = doc.getElementById("message")!;
226 const range = doc.createRange();
227 range.selectNodeContents(message);
228 const selection = dom.window.getSelection()!;
229 selection.addRange(range);
230 eq(
231 messageSelectionContextText(doc, message),
232 "before $\\alpha $ then $ x $ after",
233 "preserves authored delimiter padding through real Markdown rendering",
234 );
235 }
236
237 {
238 const rendered = renderToStaticMarkup(
239 createElement(ReactMarkdown, {
240 remarkPlugins: reasonixRemarkPlugins,
241 rehypePlugins: reasonixRehypePlugins,
242 children: normalizeMath("before $V=\\yng(2,1) | x$ then $$\\young(ab,c)$$ after"),
243 }),
244 );
245 const dom = new JSDOM(`<div class="msg__body" id="message">${rendered}</div>`);
246 installDOMGlobals(dom);
247 const doc = dom.window.document;
248 const message = doc.getElementById("message")!;
249 const range = doc.createRange();
250 range.selectNodeContents(message);
251 const selection = dom.window.getSelection()!;
252 selection.addRange(range);
253 eq(
254 messageSelectionContextText(doc, message),
255 "before $V=\\yng(2,1) | x$ then\n$$\n\\young(ab,c)\n$$\nafter",
256 "copies authored Young macros through nested pipe protection",
257 );
258 }
259
260 console.log(`\n${passed} passed, ${failed} failed`);
261 if (failed > 0) process.exit(1);
262
262 lines TYPESCRIPT