返回 DeepSeek-Reasonix
diff-rendering.test.ts
根目录 / desktop / frontend / src / __tests__ / diff-rendering.test.ts
1 // Run: tsx src/__tests__/diff-rendering.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import { diffLines, diffRowsFromUnifiedDiff } from "../lib/diff";
7 import { summarize } from "../lib/tools";
8 import { initialState, reducer } from "../lib/useController";
9 import type { Item } from "../lib/useController";
10
11 const testDir = dirname(fileURLToPath(import.meta.url));
12 const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
13
14 let passed = 0;
15 let failed = 0;
16
17 function ok(value: unknown, label: string) {
18 if (value) {
19 process.stdout.write(` PASS ${label}\n`);
20 passed += 1;
21 } else {
22 process.stdout.write(` FAIL ${label}\n`);
23 failed += 1;
24 }
25 }
26
27 function eq(a: unknown, b: unknown, label: string) {
28 if (a === b) {
29 process.stdout.write(` PASS ${label}\n`);
30 passed += 1;
31 } else {
32 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
33 failed += 1;
34 }
35 }
36
37 function matchingBlocks(selector: string): string[] {
38 const blocks: string[] = [];
39 const rule = /([^{}]+)\{([^{}]*)\}/g;
40 let match: RegExpExecArray | null;
41 while ((match = rule.exec(styles)) !== null) {
42 const selectors = match[1].split(",").map((part) => part.trim());
43 if (selectors.includes(selector)) blocks.push(match[2]);
44 }
45 return blocks;
46 }
47
48 function finalDeclaration(selector: string, property: string): string | undefined {
49 let value: string | undefined;
50 for (const block of matchingBlocks(selector)) {
51 const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
52 let match: RegExpExecArray | null;
53 while ((match = declaration.exec(block)) !== null) {
54 value = match[1].trim();
55 }
56 }
57 return value;
58 }
59
60 type ToolItem = Extract<Item, { kind: "tool" }>;
61
62 function toolItems(s: typeof initialState): ToolItem[] {
63 return s.items.filter((it): it is ToolItem => it.kind === "tool");
64 }
65
66 console.log("\ndiff rendering contract");
67
68 {
69 const rows = diffLines("one\ntwo\nthree", "one\nTWO\nthree\nfour");
70 eq(JSON.stringify(rows.map((r) => [r.type, r.oldLine ?? "", r.newLine ?? "", r.text])), JSON.stringify([
71 ["ctx", 1, 1, "one"],
72 ["del", 2, "", "two"],
73 ["add", "", 2, "TWO"],
74 ["ctx", 3, 3, "three"],
75 ["add", "", 4, "four"],
76 ]), "diff rows carry old/new line numbers");
77 }
78
79 {
80 const rows = diffRowsFromUnifiedDiff([
81 "--- a/settings/settings_IO.gd",
82 "+++ b/settings/settings_IO.gd",
83 "@@ -27,2 +27,3 @@",
84 " func keep():",
85 "-func save():",
86 "+func save_file():",
87 "+func save_backup():",
88 "",
89 ].join("\n"));
90 eq(JSON.stringify(rows.map((r) => [r.type, r.oldLine ?? "", r.newLine ?? "", r.text])), JSON.stringify([
91 ["ctx", 27, 27, "func keep():"],
92 ["del", 28, "", "func save():"],
93 ["add", "", 28, "func save_file():"],
94 ["add", "", 29, "func save_backup():"],
95 ]), "unified diff rows preserve hunk line numbers");
96 }
97
98 {
99 eq(summarize("write_file", ""), "", "archived write_file without args does not synthesize 0 lines");
100 eq(summarize("write_file", JSON.stringify({ content: "" })), "0 lines", "explicit empty write_file content still summarizes as 0 lines");
101 }
102
103 for (const prefix of ["diff", "inline-diff"]) {
104 eq(finalDeclaration(`.${prefix}__table`, "min-width"), "max-content", `${prefix} rows share the longest scroll width`);
105 eq(finalDeclaration(`.${prefix}__table`, "width"), "100%", `${prefix} table fills the visible viewport`);
106 eq(finalDeclaration(`.${prefix}__row`, "width"), "100%", `${prefix} row background fills table width`);
107 eq(finalDeclaration(`.${prefix}__gutter`, "position"), "sticky", `${prefix} gutter remains visible while horizontally scrolled`);
108 eq(finalDeclaration(`.${prefix}__gutter`, "left"), "0", `${prefix} sticky gutter anchors at left`);
109 }
110
111 {
112 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
113 const fileDiff = [
114 "--- a/settings/pages/video_settings.tscn",
115 "+++ b/settings/pages/video_settings.tscn",
116 "@@ -42,2 +42,2 @@",
117 "-layout_mode = 3",
118 "+layout_mode = 1",
119 ].join("\n");
120 s = reducer(s, {
121 type: "event",
122 e: {
123 kind: "tool_dispatch",
124 tool: {
125 id: "write-existing",
126 name: "write_file",
127 args: JSON.stringify({ path: "settings/pages/video_settings.tscn", content: "full\nreplacement\nfile\n" }),
128 readOnly: false,
129 diff: fileDiff,
130 added: 1,
131 removed: 1,
132 } as any,
133 },
134 });
135 let [tool] = toolItems(s);
136 eq(tool?.summary, "+1 -1", "writer dispatch uses preview file diff summary instead of content line count");
137 eq((tool as any)?.fileDiff?.diff, fileDiff, "writer dispatch keeps preview file diff for rendering");
138 s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "write-existing", name: "write_file", readOnly: false, output: "wrote 22 bytes", durationMs: 12 } } });
139 [tool] = toolItems(s);
140 eq(tool?.summary, "+1 -1", "completed writer archives with preview file diff summary");
141 ok(tool?.dataArchived === true, "completed writer with preview diff is still archived");
142 }
143
144 {
145 const fileDiff = [
146 "--- a/settings/settings_IO.gd",
147 "+++ b/settings/settings_IO.gd",
148 "@@ -27 +27 @@",
149 "-func save():",
150 "+func save_file():",
151 ].join("\n");
152 const s = reducer(initialState, {
153 type: "history",
154 messages: [
155 {
156 role: "assistant",
157 content: "",
158 toolCalls: [{
159 id: "hist-edit",
160 name: "edit_file",
161 arguments: "",
162 argumentsArchived: true,
163 subject: "settings/settings_IO.gd",
164 diff: fileDiff,
165 added: 1,
166 removed: 1,
167 }],
168 },
169 {
170 role: "tool",
171 content: "",
172 toolCallId: "hist-edit",
173 toolName: "edit_file",
174 toolResultArchived: true,
175 },
176 ] as any,
177 });
178 const [tool] = toolItems(s);
179 eq(tool?.summary, "+1 -1", "history writer restores preview file diff summary");
180 eq((tool as any)?.fileDiff?.diff, fileDiff, "history writer restores preview file diff body");
181 }
182
183 {
184 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
185 s = reducer(s, {
186 type: "event",
187 e: {
188 kind: "tool_dispatch",
189 tool: {
190 id: "edit-1",
191 name: "edit_file",
192 args: JSON.stringify({ path: "settings/settings.gd", old_string: "old\nsame", new_string: "new\nsame\nextra" }),
193 readOnly: false,
194 },
195 },
196 });
197 s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "edit-1", name: "edit_file", readOnly: false, output: "edited settings/settings.gd", durationMs: 12 } } });
198 const [tool] = toolItems(s);
199 eq(tool?.summary, "+2 -1", "completed writer keeps +N -M summary after archiving");
200 ok(tool?.dataArchived === true, "completed writer data is still archived");
201 eq(tool?.output, undefined, "summary does not require keeping tool output");
202 }
203
204 {
205 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
206 s = reducer(s, {
207 type: "event",
208 e: {
209 kind: "tool_dispatch",
210 tool: {
211 id: "edit-error",
212 name: "edit_file",
213 args: JSON.stringify({ path: "settings/settings.gd", old_string: "old", new_string: "new" }),
214 readOnly: false,
215 },
216 },
217 });
218 s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "edit-error", name: "edit_file", readOnly: false, err: "old_string not found", durationMs: 12 } } });
219 const [tool] = toolItems(s);
220 eq(tool?.status, "error", "failed writer is marked as error");
221 eq(tool?.summary, undefined, "failed writer clears cached +N -M summary");
222 }
223
224 {
225 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
226 const args = JSON.stringify({
227 path: "settings/settings.gd",
228 edits: [
229 { old_string: "old", new_string: "new", replace_all: true },
230 { old_string: "same", new_string: "same2" },
231 ],
232 });
233 s = reducer(s, {
234 type: "event",
235 e: {
236 kind: "tool_dispatch",
237 tool: {
238 id: "multi-replace-all",
239 name: "multi_edit",
240 args,
241 readOnly: false,
242 },
243 },
244 });
245 let [tool] = toolItems(s);
246 eq(tool?.summary, "", "replace_all multi_edit defers summary until result");
247 s = reducer(s, {
248 type: "event",
249 e: {
250 kind: "tool_result",
251 tool: {
252 id: "multi-replace-all",
253 name: "multi_edit",
254 readOnly: false,
255 output: "multi_edit settings/settings.gd: 2 edits applied (5 total replacements)",
256 durationMs: 12,
257 },
258 },
259 });
260 [tool] = toolItems(s);
261 eq(tool?.summary, "2 edits · 5 replacements", "replace_all multi_edit uses applied replacement count");
262 }
263
264 {
265 let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
266 s = reducer(s, {
267 type: "event",
268 e: {
269 kind: "tool_dispatch",
270 tool: {
271 id: "tool-1",
272 name: "edit_file",
273 args: JSON.stringify({ path: "settings/settings.gd", old_string: "old", new_string: "new" }),
274 readOnly: false,
275 },
276 },
277 });
278 s = reducer(s, {
279 type: "event",
280 e: {
281 kind: "tool_dispatch",
282 tool: {
283 id: "tool-1",
284 name: "read_file",
285 args: JSON.stringify({ path: "settings/settings.gd" }),
286 readOnly: true,
287 },
288 },
289 });
290 const [tool] = toolItems(s);
291 eq(tool?.summary, undefined, "dispatch updates clear stale writer summary");
292 }
293
294 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
295 if (failed > 0) process.exit(1);
296
296 lines TYPESCRIPT