@adeu/core 1.6.7 → 1.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,481 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestDocument, addParagraph } from "./test-utils.js";
3
+ import { extractTextFromBuffer } from "./ingest.js";
4
+ import { RedlineEngine } from "./engine.js";
5
+ import { parseXml, serializeXml } from "./docx/dom.js";
6
+ import { create_unified_diff } from "./diff.js";
7
+ import { extract_outline } from "./outline.js";
8
+ import { paginate } from "./pagination.js";
9
+
10
+ describe("Resolved Bugs Core Engine Verification", () => {
11
+ it("BUG-3 & BUG-4: Links parts to package and yields headers for extraction", async () => {
12
+ const doc = await createTestDocument();
13
+
14
+ // Inject a raw header part
15
+ const xml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
16
+ <w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
17
+ <w:p><w:r><w:t>My Secret Header</w:t></w:r></w:p>
18
+ </w:hdr>`;
19
+
20
+ const headerPart = doc.pkg.addPart(
21
+ "/word/header1.xml",
22
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
23
+ xml,
24
+ );
25
+ doc.relateTo(
26
+ headerPart,
27
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",
28
+ );
29
+
30
+ // BUG-3a Fix: Ensure part.package is assigned so style cache traversal works
31
+ expect(headerPart.package).toBe(doc.pkg);
32
+
33
+ // BUG-3b/4 Fix: Ensure headers are yielded by iter_document_parts and extracted
34
+ const buf = await doc.save();
35
+ const text = await extractTextFromBuffer(buf);
36
+ expect(text).toContain("My Secret Header");
37
+ });
38
+
39
+ it("BUG-6: Provides context snippets for ambiguous matches", async () => {
40
+ const doc = await createTestDocument();
41
+ addParagraph(doc, "the apple is on the table, the dog is in the yard.");
42
+
43
+ const engine = new RedlineEngine(doc);
44
+ let caught: any = null;
45
+
46
+ try {
47
+ engine.process_batch([
48
+ { type: "modify", target_text: "the", new_text: "THE" },
49
+ ]);
50
+ } catch (e) {
51
+ caught = e;
52
+ }
53
+
54
+ expect(caught).toBeDefined();
55
+ expect(caught.name).toBe("BatchValidationError");
56
+ expect(caught.message).toContain(
57
+ "Ambiguous match. Target text appears 4 times",
58
+ );
59
+ expect(caught.message).toContain("[the]"); // Ensure the matched text is bracketed
60
+ expect(caught.message).toContain("Please provide more surrounding context");
61
+ });
62
+
63
+ it("BUG-7: Unifies review-action and text-edit validation errors in a single pass", async () => {
64
+ const doc = await createTestDocument();
65
+ addParagraph(doc, "Base text");
66
+ const engine = new RedlineEngine(doc);
67
+
68
+ let caught: any = null;
69
+ try {
70
+ engine.process_batch([
71
+ { type: "accept", target_id: "Chg:999" },
72
+ { type: "modify", target_text: "MISSING_TEXT", new_text: "found" },
73
+ ]);
74
+ } catch (e) {
75
+ caught = e;
76
+ }
77
+
78
+ expect(caught).toBeDefined();
79
+ expect(caught.name).toBe("BatchValidationError");
80
+ // Both errors should be accumulated and thrown together
81
+ expect(caught.message).toContain("Target ID Chg:999 not found");
82
+ expect(caught.message).toContain("Target text not found");
83
+ expect(caught.message).toContain("MISSING_TEXT");
84
+ });
85
+
86
+ it("BUG-8: Emits full commentRange wrappers for comment replies (1:1 Python Parity)", async () => {
87
+ const doc = await createTestDocument();
88
+ addParagraph(doc, "Hello world.");
89
+ const engine = new RedlineEngine(doc);
90
+
91
+ // Create parent comment
92
+ engine.process_batch([
93
+ {
94
+ type: "modify",
95
+ target_text: "world",
96
+ new_text: "world",
97
+ comment: "Parent",
98
+ },
99
+ ]);
100
+
101
+ const xml1 = doc.element.toString();
102
+ const starts1 = (xml1.match(/<w:commentRangeStart/g) || []).length;
103
+ expect(starts1).toBe(1); // 1 parent comment
104
+
105
+ // Find the dynamic comment ID (usually 1 in a fresh document)
106
+ const parentIdMatch = xml1.match(/<w:commentRangeStart w:id="(\d+)"\/>/);
107
+ expect(parentIdMatch).not.toBeNull();
108
+ const parentId = parentIdMatch![1];
109
+
110
+ // Issue reply
111
+ engine.process_batch([
112
+ { type: "reply", target_id: `Com:${parentId}`, text: "Reply" },
113
+ ]);
114
+
115
+ const xml2 = doc.element.toString();
116
+ const starts2 = (xml2.match(/<w:commentRangeStart/g) || []).length;
117
+ const ends2 = (xml2.match(/<w:commentRangeEnd/g) || []).length;
118
+ const refs2 = (xml2.match(/<w:commentReference/g) || []).length;
119
+
120
+ // Both starts, ends, and refs should have incremented by exactly 1
121
+ expect(starts2).toBe(starts1 + 1);
122
+ expect(ends2).toBe(starts1 + 1);
123
+ expect(refs2).toBe(starts1 + 1);
124
+ });
125
+
126
+ it("BUG-11: Deterministically sorts root XML attributes strictly by ASCII", () => {
127
+ // We intentionally place standard attributes before namespaces, and w10 after w.
128
+ const rawXml = `<w:document b="2" xmlns:w10="urn:w10" a="1" xmlns:w="urn:w" mc:Ignorable="w14" xmlns:mc="urn:mc"></w:document>`;
129
+ const docXml = parseXml(rawXml);
130
+
131
+ const serialized = serializeXml(docXml.documentElement);
132
+
133
+ const expected = `<w:document xmlns:mc="urn:mc" xmlns:w="urn:w" xmlns:w10="urn:w10" a="1" b="2" mc:Ignorable="w14"/>`;
134
+ // Direct string equality so Vitest prints the exact diff if they mismatch!
135
+ expect(serialized).toBe(expected);
136
+ });
137
+ it("BUG-11b: Sweeps orphaned comment anchors when accepting tracked changes", async () => {
138
+ const doc = await createTestDocument();
139
+ addParagraph(doc, "Confidential Information");
140
+ const engine = new RedlineEngine(doc, "Reviewer");
141
+
142
+ // Add a tracked change with a comment attached
143
+ engine.process_batch([
144
+ {
145
+ type: "modify",
146
+ target_text: "Confidential Information",
147
+ new_text: "Confidential Data",
148
+ comment: "Changed term",
149
+ },
150
+ ]);
151
+
152
+ let xml = doc.element.toString();
153
+ expect(xml).toContain("w:commentRangeStart");
154
+ expect(xml).toContain("w:commentReference");
155
+
156
+ // Accept it
157
+ engine.accept_all_revisions();
158
+
159
+ xml = doc.element.toString();
160
+ // Assert clean up
161
+ expect(xml).not.toContain("w:commentRangeStart");
162
+ expect(xml).not.toContain("w:commentReference");
163
+ });
164
+
165
+ it("BUG-2: Collapses multiple newlines to prevent empty paragraphs", async () => {
166
+ const doc = await createTestDocument();
167
+ addParagraph(doc, "Section 1");
168
+ const engine = new RedlineEngine(doc, "Reviewer");
169
+
170
+ engine.process_batch([
171
+ {
172
+ type: "modify",
173
+ target_text: "Section 1",
174
+ new_text: "Section 1\n\n# Section 2\n\nSection 3",
175
+ },
176
+ ]);
177
+
178
+ const buf = await doc.save();
179
+ const cleanText = await extractTextFromBuffer(buf, true);
180
+
181
+ // We shouldn't see four newlines in a row
182
+ expect(cleanText).toContain("Section 1\n\n# Section 2\n\nSection 3");
183
+ expect(cleanText).not.toContain("\n\n\n\n");
184
+ });
185
+
186
+ it("BUG-3: Outline reader gracefully falls back to style_id for headings missing in cache", async () => {
187
+ const doc = await createTestDocument();
188
+ const p = addParagraph(doc, "Dynamically Assigned Heading");
189
+
190
+ // Force a heading style without explicitly putting it in a styles.xml cache
191
+ const docEl = p.ownerDocument!;
192
+ const pPr = docEl.createElement("w:pPr");
193
+ const pStyle = docEl.createElement("w:pStyle");
194
+ pStyle.setAttribute("w:val", "Heading2");
195
+ pPr.appendChild(pStyle);
196
+ p.insertBefore(pPr, p.firstChild);
197
+
198
+ const buf = await doc.save();
199
+ const body = await extractTextFromBuffer(buf, false);
200
+ const pages = paginate(body, "");
201
+
202
+ const outlineNodes = extract_outline(
203
+ doc,
204
+ body,
205
+ pages.body_pages,
206
+ pages.body_page_offsets,
207
+ );
208
+
209
+ expect(outlineNodes.length).toBe(1);
210
+ expect(outlineNodes[0].text).toBe("Dynamically Assigned Heading");
211
+ expect(outlineNodes[0].level).toBe(2);
212
+ });
213
+
214
+ it("BUG-9b: Enforces strict timeout on pathologically complex diffs to prevent hanging", () => {
215
+ // Create highly complex, repetitive, slightly altered text that induces O(N^2) explosion
216
+ const base = "The quick brown fox jumps over the lazy dog. ".repeat(200);
217
+ const mod = base.replace(/e/g, "E").replace(/a/g, "A").replace(/o/g, "O");
218
+
219
+ const start = Date.now();
220
+ const diff = create_unified_diff(base, mod);
221
+ const elapsed = Date.now() - start;
222
+
223
+ // Should finish well under 5 seconds (target is ~2.0s due to timeout, + setup overhead)
224
+ expect(elapsed).toBeLessThan(5000);
225
+ expect(diff.length).toBeGreaterThan(0);
226
+ });
227
+
228
+ it("BUG-2.1: _track_insert_inline with empty string returns null instead of empty <w:ins>", async () => {
229
+ const doc = await createTestDocument();
230
+ addParagraph(doc, "Target word.");
231
+ const engine = new RedlineEngine(doc);
232
+
233
+ // Call internal method directly via any to match Python parity test
234
+ const ins = (engine as any)._build_tracked_ins_for_line(
235
+ "",
236
+ null,
237
+ "123",
238
+ doc.element.ownerDocument!
239
+ );
240
+ expect(ins).toBeNull();
241
+ });
242
+
243
+ it("BUG-3.1: Outline reader detects inherited outlineLvl from style cache", async () => {
244
+ const doc = await createTestDocument();
245
+ const p = addParagraph(doc, "Short heading");
246
+
247
+ const fakeCache = {
248
+ "CustomHeading": { name: "Custom Heading", outline_level: 2, bold: true }
249
+ };
250
+ (doc.pkg as any)._adeu_style_cache = [fakeCache, "Normal"];
251
+
252
+ const docEl = p.ownerDocument!;
253
+ const pPr = docEl.createElement("w:pPr");
254
+ const pStyle = docEl.createElement("w:pStyle");
255
+ pStyle.setAttribute("w:val", "CustomHeading");
256
+ pPr.appendChild(pStyle);
257
+ p.insertBefore(pPr, p.firstChild);
258
+
259
+ const buf = await doc.save();
260
+ const body = await extractTextFromBuffer(buf, false);
261
+ const pages = paginate(body, "");
262
+
263
+ const outlineNodes = extract_outline(
264
+ doc,
265
+ body,
266
+ pages.body_pages,
267
+ pages.body_page_offsets,
268
+ );
269
+
270
+ expect(outlineNodes.length).toBe(1);
271
+ expect(outlineNodes[0].text).toBe("Short heading");
272
+ expect(outlineNodes[0].level).toBe(3);
273
+ });
274
+
275
+ it("VAL-OBS-NEW-5: Orphaned comment anchors spanning redlines are swept on accept", async () => {
276
+ const doc = await createTestDocument();
277
+ const p = addParagraph(doc, "");
278
+ const engine = new RedlineEngine(doc);
279
+
280
+ const c_id = engine.comments_manager.addComment("Test", "Spanning comment");
281
+ const xmlDoc = doc.element.ownerDocument!;
282
+
283
+ const start = xmlDoc.createElement("w:commentRangeStart");
284
+ start.setAttribute("w:id", c_id);
285
+ p.appendChild(start);
286
+
287
+ const del_tag = xmlDoc.createElement("w:del");
288
+ del_tag.setAttribute("w:id", "1");
289
+ p.appendChild(del_tag);
290
+
291
+ const ins_tag = xmlDoc.createElement("w:ins");
292
+ ins_tag.setAttribute("w:id", "1");
293
+ p.appendChild(ins_tag);
294
+
295
+ const end = xmlDoc.createElement("w:commentRangeEnd");
296
+ end.setAttribute("w:id", c_id);
297
+ p.appendChild(end);
298
+
299
+ const ref_run = xmlDoc.createElement("w:r");
300
+ const ref = xmlDoc.createElement("w:commentReference");
301
+ ref.setAttribute("w:id", c_id);
302
+ ref_run.appendChild(ref);
303
+ p.appendChild(ref_run);
304
+
305
+ engine.accept_all_revisions();
306
+
307
+ const xml = doc.element.toString();
308
+ expect(xml).not.toContain("w:commentRangeStart");
309
+ expect(xml).not.toContain("w:commentRangeEnd");
310
+ expect(xml).not.toContain("w:commentReference");
311
+ });
312
+
313
+ it("BUG-DOM-1: Safely handles multi-paragraph replace with heading and comment without throwing DOM errors", async () => {
314
+ const doc = await createTestDocument();
315
+ addParagraph(doc, "This is the old text that will be replaced.");
316
+ const engine = new RedlineEngine(doc, "Reviewer");
317
+
318
+ // This specific combination caused a "child not in parent" DOM error in Node:
319
+ // 1. Modifying text
320
+ // 2. new_text has multiple paragraphs (\n\n)
321
+ // 3. new_text includes a markdown heading (##) which triggers block mode
322
+ // 4. A comment is attached
323
+ expect(() => {
324
+ engine.process_batch([
325
+ {
326
+ type: "modify",
327
+ target_text: "old text that will be replaced.",
328
+ new_text: "new introduction\n\n## Section 1\n\nNew paragraph content",
329
+ comment: "Restructuring this section",
330
+ },
331
+ ]);
332
+ }).not.toThrow();
333
+
334
+ const xml = doc.element.toString();
335
+ expect(xml).toContain("w:commentRangeStart");
336
+ expect(xml).toContain("w:commentRangeEnd");
337
+ expect(xml).toContain("Section 1");
338
+ });
339
+
340
+ it("BUG-OUTLINE-1: Normalizes lowercase 'heading N' style names to 'Heading N' for parity", async () => {
341
+ const doc = await createTestDocument();
342
+ const p = addParagraph(doc, "My lowercase heading");
343
+
344
+ // Force a heading style with lowercase name in styles.xml
345
+ const docEl = p.ownerDocument!;
346
+ const pPr = docEl.createElement("w:pPr");
347
+ const pStyle = docEl.createElement("w:pStyle");
348
+ pStyle.setAttribute("w:val", "heading1");
349
+ pPr.appendChild(pStyle);
350
+ p.insertBefore(pPr, p.firstChild);
351
+
352
+ // Mock the styles.xml
353
+ const stylesXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
354
+ <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
355
+ <w:style w:type="paragraph" w:styleId="heading1">
356
+ <w:name w:val="heading 1"/>
357
+ <w:pPr><w:outlineLvl w:val="0"/></w:pPr>
358
+ </w:style>
359
+ </w:styles>`;
360
+
361
+ const existingStyles = doc.pkg.getPartByPath("word/styles.xml");
362
+ if (existingStyles) {
363
+ existingStyles._element = parseXml(stylesXml).documentElement;
364
+ } else {
365
+ const stylesPart = doc.pkg.addPart(
366
+ "/word/styles.xml",
367
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml",
368
+ stylesXml,
369
+ );
370
+ doc.relateTo(
371
+ stylesPart,
372
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
373
+ );
374
+ }
375
+
376
+ const buf = await doc.save();
377
+ const body = await extractTextFromBuffer(buf, false);
378
+ const pages = paginate(body, "");
379
+
380
+ const outlineNodes = extract_outline(
381
+ doc,
382
+ body,
383
+ pages.body_pages,
384
+ pages.body_page_offsets,
385
+ );
386
+
387
+ expect(outlineNodes.length).toBe(1);
388
+ expect(outlineNodes[0].style).toBe("Heading 1"); // Instead of (outline_level)
389
+ expect(outlineNodes[0].text).toBe("My lowercase heading");
390
+ });
391
+
392
+ it("BUG-EXPLORE-1: Full-paragraph deletion safely removes the paragraph mark", async () => {
393
+ const doc = await createTestDocument();
394
+ addParagraph(doc, "Paragraph 1");
395
+ const pTarget = addParagraph(doc, "Target paragraph to delete.");
396
+ addParagraph(doc, "Paragraph 3");
397
+
398
+ const engine = new RedlineEngine(doc, "Reviewer");
399
+ engine.process_batch([
400
+ {
401
+ type: "modify",
402
+ target_text: "Target paragraph to delete.",
403
+ new_text: "",
404
+ },
405
+ ]);
406
+
407
+ engine.accept_all_revisions();
408
+
409
+ // If the paragraph mark <w:del> was successfully injected into pPr/rPr,
410
+ // accept_all_revisions will completely remove the <w:p> element.
411
+ // If the bug is present, the <w:p> will survive as an empty orphaned node.
412
+ expect(pTarget.parentNode).toBeNull();
413
+ });
414
+
415
+ it("BUG-EXPLORE-2: Nested redline validation error includes actionable hint", async () => {
416
+ const doc = await createTestDocument();
417
+ addParagraph(doc, "Original baseline.");
418
+
419
+ // Author A makes an insertion
420
+ const engineA = new RedlineEngine(doc, "Author A");
421
+ engineA.process_batch([
422
+ {
423
+ type: "modify",
424
+ target_text: "Original baseline.",
425
+ new_text: "Original baseline. Inserted by A.",
426
+ },
427
+ ]);
428
+
429
+ // Author B tries to modify Author A's pending insertion
430
+ const engineB = new RedlineEngine(doc, "Author B");
431
+
432
+ expect(() => {
433
+ engineB.process_batch([{ type: "modify", target_text: "Inserted by A.", new_text: "Modified by B." }]);
434
+ }).toThrowError(/Accept that change first or scope your edit outside of it/);
435
+ });
436
+
437
+ it("BUG-CROSS-PARA-1: Cross-paragraph modify coalesces paragraphs and tracks para-mark deletion", async () => {
438
+ const doc = await createTestDocument();
439
+ addParagraph(doc, "Clause 1 ends here.");
440
+ addParagraph(doc, "Clause 2 begins here.");
441
+ const engine = new RedlineEngine(doc, "Reviewer");
442
+
443
+ engine.process_batch([
444
+ {
445
+ type: "modify",
446
+ target_text: "ends here.\n\nClause 2 begins",
447
+ new_text: "ends here. MERGED",
448
+ },
449
+ ]);
450
+
451
+ engine.accept_all_revisions();
452
+
453
+ const buf = await doc.save();
454
+ const cleanText = await extractTextFromBuffer(buf, true);
455
+
456
+ expect(cleanText).not.toContain("ends here.\n\n");
457
+ expect(cleanText).toContain("Clause 1 ends here. MERGED here.");
458
+ });
459
+
460
+ it("BUG-CROSS-PARA-3: 3-paragraph modify cleanly merges bottom-up without leaving orphans", async () => {
461
+ const doc = await createTestDocument();
462
+ addParagraph(doc, "Paragraph 1 ends here.");
463
+ addParagraph(doc, "Paragraph 2 is in the middle.");
464
+ addParagraph(doc, "Paragraph 3 begins here.");
465
+ const engine = new RedlineEngine(doc, "Reviewer");
466
+
467
+ engine.process_batch([
468
+ {
469
+ type: "modify",
470
+ target_text: "ends here.\n\nParagraph 2 is in the middle.\n\nParagraph 3 begins",
471
+ new_text: "ends here. MERGED",
472
+ },
473
+ ]);
474
+
475
+ engine.accept_all_revisions();
476
+ const cleanText = await extractTextFromBuffer(await doc.save(), true);
477
+
478
+ expect(cleanText).not.toContain("Paragraph 2");
479
+ expect(cleanText).toContain("Paragraph 1 ends here. MERGED here.");
480
+ });
481
+ });