@adeu/core 1.27.0 → 1.29.0

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.
@@ -23,7 +23,9 @@ describe('Ingestion Engine (Node.js Port)', () => {
23
23
  expect(markdown.length).toBeGreaterThan(0);
24
24
 
25
25
  // Assert exact parity with the Python engine's CriticMarkup generation
26
- expect(markdown).toBe('This is the {--initial --}{++golden ++}{>>[Chg:3 delete] Mikko Korpela\n[Chg:4 insert] Mikko Korpela\n[Com:0] Mikko Korpela @ 2026-01-23T07:25:00Z: Start of comment thread\n[Com:1] Mikko Korpela @ 2026-01-23T07:25:00Z: Second comment\n[Com:2] Mikko Korpela @ 2026-01-23T07:26:00Z: Third comment in the thread<<}document');
26
+ // The del+ins pair of one modification is annotated as a resolution
27
+ // group (QA 2026-07-19 ADEU-QA-004).
28
+ expect(markdown).toBe('This is the {--initial --}{++golden ++}{>>[Chg:3 delete] Mikko Korpela (pairs with Chg:4)\n[Chg:4 insert] Mikko Korpela (pairs with Chg:3)\n[Com:0] Mikko Korpela @ 2026-01-23T07:25:00Z: Start of comment thread\n[Com:1] Mikko Korpela @ 2026-01-23T07:25:00Z: Second comment\n[Com:2] Mikko Korpela @ 2026-01-23T07:26:00Z: Third comment in the thread<<}document');
27
29
  });
28
30
 
29
31
  it('should execute in cleanView mode without failing', async () => {
package/src/ingest.ts CHANGED
@@ -2,6 +2,7 @@ import { DocumentObject } from "./docx/bridge.js";
2
2
  import { Paragraph, Table, Run, DocxEvent } from "./docx/primitives.js";
3
3
  import {
4
4
  _get_style_cache,
5
+ compute_change_pair_map,
5
6
  get_paragraph_prefix,
6
7
  is_heading_paragraph,
7
8
  is_native_heading,
@@ -253,12 +254,30 @@ export function extract_table(
253
254
  // via the mapper). We key on the cell's first paragraph w14:paraId, which
254
255
  // Word assigns and keeps stable across reads.
255
256
  if (!cleanView) {
256
- const firstP = cell._element.getElementsByTagName("w:p")[0] as
257
+ let firstP = cell._element.getElementsByTagName("w:p")[0] as
257
258
  | Element
258
259
  | undefined;
259
- const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
260
+ let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
261
+ if (!paraId && (!cell_content || cell_content.trim() === "")) {
262
+ if (!firstP) {
263
+ const xmlDoc = cell._element.ownerDocument!;
264
+ firstP = xmlDoc.createElement("w:p");
265
+ cell._element.appendChild(firstP);
266
+ }
267
+ const allPs = Array.from(cell._element.ownerDocument!.getElementsByTagName("w:p"));
268
+ const index = allPs.indexOf(firstP);
269
+ let hash = 2166136261;
270
+ const str = `fallback-paraId-${index}`;
271
+ for (let i = 0; i < str.length; i++) {
272
+ hash ^= str.charCodeAt(i);
273
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
274
+ }
275
+ paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
276
+ firstP.setAttribute("w14:paraId", paraId);
277
+ }
260
278
  if (paraId) {
261
- const anchor = `{#cell:${paraId}}`;
279
+ const space_pad = cell_content ? " " : "";
280
+ const anchor = `${space_pad}{#cell:${paraId}}`;
262
281
  cell_content = cell_content + anchor;
263
282
  }
264
283
  }
@@ -603,13 +622,22 @@ function _build_merged_meta_block(
603
622
  const comment_lines: string[] = [];
604
623
  const seen_sigs = new Set<string>();
605
624
 
625
+ // Ids of one resolution group (a replacement's contiguous same-author
626
+ // del+ins pair) must not read as independently resolvable — either side
627
+ // resolves the whole group (QA 2026-07-19 ADEU-QA-004).
628
+ const pair_map = compute_change_pair_map(states_list);
629
+ const pairSuffix = (uid: string): string =>
630
+ pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
631
+
606
632
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
607
633
  for (const [uid, meta] of Object.entries(
608
634
  ins_map as Record<string, DocxEvent>,
609
635
  )) {
610
636
  const sig = `Chg:${uid}`;
611
637
  if (!seen_sigs.has(sig)) {
612
- change_lines.push(`[${sig} insert] ${meta.author || "Unknown"}`);
638
+ change_lines.push(
639
+ `[${sig} insert] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
640
+ );
613
641
  seen_sigs.add(sig);
614
642
  }
615
643
  }
@@ -618,7 +646,9 @@ function _build_merged_meta_block(
618
646
  )) {
619
647
  const sig = `Chg:${uid}`;
620
648
  if (!seen_sigs.has(sig)) {
621
- change_lines.push(`[${sig} delete] ${meta.author || "Unknown"}`);
649
+ change_lines.push(
650
+ `[${sig} delete] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
651
+ );
622
652
  seen_sigs.add(sig);
623
653
  }
624
654
  }
package/src/mapper.ts CHANGED
@@ -5,6 +5,7 @@ import { extract_comments_data } from "./comments.js";
5
5
  import { RegexTimeoutError, userFindAllMatches, userSearch } from "./utils/safe-regex.js";
6
6
  import {
7
7
  _get_style_cache,
8
+ compute_change_pair_map,
8
9
  get_paragraph_prefix,
9
10
  get_run_style_markers,
10
11
  get_run_text,
@@ -347,16 +348,39 @@ export class DocumentMapper {
347
348
  // can resolve "write into this cell" even when the cell is empty
348
349
  // (pPr-only paragraph with no run).
349
350
  if (!this.clean_view && !this.original_view) {
350
- const firstP = cell._element.getElementsByTagName("w:p")[0] as
351
+ let firstP = cell._element.getElementsByTagName("w:p")[0] as
351
352
  | Element
352
353
  | undefined;
353
- const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
354
+ let paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
355
+ const is_empty = current === cell_start;
356
+ if (!paraId && is_empty) {
357
+ if (!firstP) {
358
+ const xmlDoc = cell._element.ownerDocument!;
359
+ firstP = xmlDoc.createElement("w:p");
360
+ cell._element.appendChild(firstP);
361
+ }
362
+ const allPs = Array.from(cell._element.ownerDocument!.getElementsByTagName("w:p"));
363
+ const index = allPs.indexOf(firstP);
364
+ let hash = 2166136261;
365
+ const str = `fallback-paraId-${index}`;
366
+ for (let i = 0; i < str.length; i++) {
367
+ hash ^= str.charCodeAt(i);
368
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
369
+ }
370
+ paraId = (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
371
+ firstP.setAttribute("w14:paraId", paraId);
372
+ }
354
373
  if (paraId && firstP) {
355
374
  // Zero-width span bound to the empty cell paragraph: gives
356
375
  // get_insertion_anchor a paragraph to land on. Placed at the anchor
357
376
  // token offset so resolution targets THIS cell, not a neighbour.
358
377
  const cellPara = new Paragraph(firstP, cell);
359
378
  this._add_virtual_text("", current, cellPara);
379
+ const has_content = current > cell_start;
380
+ if (has_content) {
381
+ this._add_virtual_text(" ", current, cellPara);
382
+ current += 1;
383
+ }
360
384
  const anchor = `{#cell:${paraId}}`;
361
385
  this._add_virtual_text(anchor, current, cellPara);
362
386
  current += anchor.length;
@@ -810,6 +834,13 @@ export class DocumentMapper {
810
834
  const comment_lines: string[] = [];
811
835
  const seen_sigs = new Set<string>();
812
836
 
837
+ // Must render EXACTLY as ingest's _build_merged_meta_block (Virtual Text
838
+ // contract), including the resolution-group annotation
839
+ // (QA 2026-07-19 ADEU-QA-004).
840
+ const pair_map = compute_change_pair_map(states_list);
841
+ const pairSuffix = (uid: string): string =>
842
+ pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
843
+
813
844
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
814
845
  for (const [uid, meta] of Object.entries(
815
846
  ins_map as Record<string, DocxEvent>,
@@ -817,7 +848,7 @@ export class DocumentMapper {
817
848
  const sig = `Chg:${uid}`;
818
849
  if (!seen_sigs.has(sig)) {
819
850
  const auth = meta.author || "Unknown";
820
- change_lines.push(`[${sig} insert] ${auth}`);
851
+ change_lines.push(`[${sig} insert] ${auth}${pairSuffix(uid)}`);
821
852
  seen_sigs.add(sig);
822
853
  }
823
854
  }
@@ -827,7 +858,7 @@ export class DocumentMapper {
827
858
  const sig = `Chg:${uid}`;
828
859
  if (!seen_sigs.has(sig)) {
829
860
  const auth = meta.author || "Unknown";
830
- change_lines.push(`[${sig} delete] ${auth}`);
861
+ change_lines.push(`[${sig} delete] ${auth}${pairSuffix(uid)}`);
831
862
  seen_sigs.add(sig);
832
863
  }
833
864
  }
@@ -1000,6 +1031,40 @@ export class DocumentMapper {
1000
1031
  return results;
1001
1032
  }
1002
1033
 
1034
+ /**
1035
+ * True when no run-backed span overlaps [start, start+length): the range
1036
+ * covers only virtual projection text — meta bubbles (change/comment
1037
+ * headers, timestamps), style markers, list prefixes. Such text does not
1038
+ * exist in the document, so it can neither satisfy a match nor count
1039
+ * toward ambiguity (QA 2026-07-19 ADEU-QA-002 C): an edit targeting "4"
1040
+ * used to be rejected as "appears 8 times" because a comment bubble's
1041
+ * timestamp matched.
1042
+ *
1043
+ * Anchor tokens ({#Bookmark}, {#cell:paraId}) are the exception: they are
1044
+ * deliberate virtual TARGETING surfaces (empty-cell writes, bookmark
1045
+ * anchors) and must stay matchable.
1046
+ */
1047
+ public range_is_virtual_only(start: number, length: number): boolean {
1048
+ const end = start + length;
1049
+ const overlapping = this.spans.filter(
1050
+ (s) => s.end > start && s.start < end,
1051
+ );
1052
+ if (overlapping.some((s) => s.run !== null)) return false;
1053
+ return !overlapping.some(
1054
+ (s) => s.run === null && s.text.startsWith("{#"),
1055
+ );
1056
+ }
1057
+
1058
+ /** Filters find_all_match_indices output down to matches that touch at
1059
+ * least one run-backed span. See range_is_virtual_only. */
1060
+ public drop_virtual_only_matches(
1061
+ matches: [number, number][],
1062
+ ): [number, number][] {
1063
+ return matches.filter(
1064
+ ([start, length]) => !this.range_is_virtual_only(start, length),
1065
+ );
1066
+ }
1067
+
1003
1068
  public find_match_index(
1004
1069
  target_text: string,
1005
1070
  is_regex: boolean = false,
@@ -1011,19 +1076,33 @@ export class DocumentMapper {
1011
1076
  // invalid-pattern errors mean "no match" here.
1012
1077
  try {
1013
1078
  const match = userSearch(target_text, this.full_text);
1014
- if (match) return [match.start, match.end - match.start];
1079
+ if (
1080
+ match &&
1081
+ !this.range_is_virtual_only(match.start, match.end - match.start)
1082
+ ) {
1083
+ return [match.start, match.end - match.start];
1084
+ }
1015
1085
  } catch (e) {
1016
1086
  if (e instanceof RegexTimeoutError) throw e;
1017
1087
  }
1018
1088
  return [-1, 0];
1019
1089
  }
1020
1090
 
1021
- let start_idx = this.full_text.indexOf(target_text);
1022
- if (start_idx !== -1) return [start_idx, target_text.length];
1091
+ // Exact tier: skip occurrences that cover only virtual projection text
1092
+ // (meta bubbles, markers) they are not document text (ADEU-QA-002 C).
1093
+ let from = 0;
1094
+ while (true) {
1095
+ const idx = this.full_text.indexOf(target_text, from);
1096
+ if (idx === -1) break;
1097
+ if (!this.range_is_virtual_only(idx, target_text.length)) {
1098
+ return [idx, target_text.length];
1099
+ }
1100
+ from = idx + 1;
1101
+ }
1023
1102
 
1024
1103
  const norm_full = this._replace_smart_quotes(this.full_text);
1025
1104
  const norm_target = this._replace_smart_quotes(target_text);
1026
- start_idx = norm_full.indexOf(norm_target);
1105
+ let start_idx = norm_full.indexOf(norm_target);
1027
1106
  if (start_idx !== -1) return [start_idx, target_text.length];
1028
1107
 
1029
1108
  const stripped_target = this._strip_markdown_formatting(target_text);
@@ -1039,9 +1118,13 @@ export class DocumentMapper {
1039
1118
  if (plain_first.length > 0) return plain_first[0];
1040
1119
 
1041
1120
  try {
1042
- const pattern = new RegExp(this._make_fuzzy_regex(target_text));
1043
- const match = pattern.exec(this.full_text);
1044
- if (match) return [match.index, match[0].length];
1121
+ const pattern = new RegExp(this._make_fuzzy_regex(target_text), "g");
1122
+ for (const match of this.full_text.matchAll(pattern)) {
1123
+ // Virtual-only ranges are projection chrome, not document text.
1124
+ if (!this.range_is_virtual_only(match.index!, match[0].length)) {
1125
+ return [match.index!, match[0].length];
1126
+ }
1127
+ }
1045
1128
  } catch (e) {}
1046
1129
 
1047
1130
  return [-1, 0];
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestDocument, addTable, setCellText } from "./test-utils.js";
3
+ import { extractTextFromBuffer } from "./ingest.js";
4
+ import { RedlineEngine } from "./engine.js";
5
+
6
+ describe("QA Regression Test - Finding F1: Missing cell anchors for empty table cells", () => {
7
+ it("should generate and render stable cell anchors for empty table cells", async () => {
8
+ // 1. Build a document containing a table with empty/blank cells.
9
+ // In this document, the cells are constructed programmatically without any pre-existing w14:paraId.
10
+ const doc = await createTestDocument();
11
+ const tbl = addTable(doc, 1, 2);
12
+ setCellText(tbl, 0, 0, "Hello");
13
+ // Cell (0,1) is left completely empty, with no pre-existing w14:paraId attribute on its w:p element.
14
+
15
+ const buf = await doc.save();
16
+
17
+ // 2. Ingest/extract text from the document.
18
+ const text = await extractTextFromBuffer(buf, false);
19
+
20
+ // 3. Assert correct behavior.
21
+ // The empty cell must still render a trailing {#cell:<id>} anchor so that it can be targeted for edits.
22
+ // We expect the output to be formatted like: Hello | {#cell:<id>}
23
+ const cellAnchorRegex = /Hello \| \{#cell:[0-9a-fA-F]{8}\}/;
24
+ expect(text).toSatisfy((val: string) => {
25
+ return cellAnchorRegex.test(val);
26
+ }, `Expected extracted text to contain a cell anchor for the empty cell, but got:\n"${text}"`);
27
+ });
28
+ });
29
+
30
+ describe("QA Regression Test - Top Bug: match_mode='all' fails to affect multiple matches for table row edits", () => {
31
+ it("should delete all rows containing target_text when match_mode is all", async () => {
32
+ const doc = await createTestDocument();
33
+ const tbl = addTable(doc, 3, 3);
34
+ setCellText(tbl, 0, 0, "ID");
35
+ setCellText(tbl, 0, 1, "Name");
36
+ setCellText(tbl, 0, 2, "Notes");
37
+
38
+ setCellText(tbl, 1, 0, "1");
39
+ setCellText(tbl, 1, 1, "Alice");
40
+ setCellText(tbl, 1, 2, "First record");
41
+
42
+ setCellText(tbl, 2, 0, "2");
43
+ setCellText(tbl, 2, 1, "");
44
+ setCellText(tbl, 2, 2, "Second record with empty name");
45
+
46
+ const buf = await doc.save();
47
+ const midDoc = await (doc.constructor as any).load(buf);
48
+
49
+ const engine = new RedlineEngine(midDoc);
50
+ const stats = engine.process_batch([
51
+ {
52
+ type: "delete_row",
53
+ target_text: "record",
54
+ match_mode: "all",
55
+ } as any,
56
+ ]);
57
+
58
+ expect(stats.edits_applied).toBe(1);
59
+ expect(stats.occurrences_modified).toBe(2);
60
+
61
+ engine.accept_all_revisions();
62
+ const finalBuf = await midDoc.save();
63
+ const clean_text = await extractTextFromBuffer(finalBuf, true);
64
+
65
+ expect(clean_text).not.toContain("First record");
66
+ expect(clean_text).not.toContain("Second record with empty name");
67
+ });
68
+
69
+ it("should insert rows adjacent to all rows containing target_text when match_mode is all", async () => {
70
+ const doc = await createTestDocument();
71
+ const tbl = addTable(doc, 3, 3);
72
+ setCellText(tbl, 0, 0, "ID");
73
+ setCellText(tbl, 0, 1, "Name");
74
+ setCellText(tbl, 0, 2, "Notes");
75
+
76
+ setCellText(tbl, 1, 0, "1");
77
+ setCellText(tbl, 1, 1, "Alice");
78
+ setCellText(tbl, 1, 2, "First record");
79
+
80
+ setCellText(tbl, 2, 0, "2");
81
+ setCellText(tbl, 2, 1, "");
82
+ setCellText(tbl, 2, 2, "Second record with empty name");
83
+
84
+ const buf = await doc.save();
85
+ const midDoc = await (doc.constructor as any).load(buf);
86
+
87
+ const engine = new RedlineEngine(midDoc);
88
+ const stats = engine.process_batch([
89
+ {
90
+ type: "insert_row",
91
+ target_text: "record",
92
+ match_mode: "all",
93
+ position: "below",
94
+ cells: ["NEW_ID", "NEW_NAME", "NEW_NOTES"],
95
+ } as any,
96
+ ]);
97
+
98
+ expect(stats.edits_applied).toBe(1);
99
+ expect(stats.occurrences_modified).toBe(2);
100
+
101
+ engine.accept_all_revisions();
102
+ const finalBuf = await midDoc.save();
103
+ const clean_text = await extractTextFromBuffer(finalBuf, true);
104
+
105
+ const occurrences = (clean_text.match(/NEW_ID \| NEW_NAME \| NEW_NOTES/g) || []).length;
106
+ expect(occurrences).toBe(2);
107
+ });
108
+ });
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestDocument, addParagraph, addTable, setCellText } from "./test-utils.js";
3
+ import { _extractTextFromDoc, extractTextFromBuffer } from "./ingest.js";
4
+ import { RedlineEngine } from "./engine.js";
5
+ import { extract_outline } from "./outline.js";
6
+ import { paginate } from "./pagination.js";
7
+ import { finalize_document } from "./sanitize/core.js";
8
+ import { findChild, findAllDescendants, QN_W_R, QN_W_RPR, QN_W_B, QN_W_VAL, QN_W_T } from "./utils/docx.js";
9
+
10
+ describe("Adeu MCP QA Report - Issue 1: Markdown headings in new_text", () => {
11
+ it("TC 1.1: single modify to ### heading changes style and is visible in outline", async () => {
12
+ const doc = await createTestDocument();
13
+ addParagraph(doc, "This is a normal paragraph.");
14
+
15
+ const engine = new RedlineEngine(doc);
16
+ const res = engine.process_batch([
17
+ {
18
+ type: "modify",
19
+ target_text: "This is a normal paragraph.",
20
+ new_text: "### Heading Only Test",
21
+ },
22
+ ]);
23
+
24
+ expect(res.edits_applied).toBe(1);
25
+
26
+ // Re-extract outline
27
+ const extract_res = _extractTextFromDoc(doc, false, false, true) as {
28
+ text: string;
29
+ paragraph_offsets: Map<any, [number, number]>;
30
+ };
31
+ const pages = paginate(extract_res.text, "");
32
+ const nodes = extract_outline(
33
+ doc,
34
+ extract_res.text,
35
+ pages.body_pages,
36
+ pages.body_page_offsets,
37
+ extract_res.paragraph_offsets as any,
38
+ );
39
+
40
+ // The heading must be present in the outline
41
+ const testNode = nodes.find((n) => n.text.includes("Heading Only Test"));
42
+ expect(testNode).toBeDefined();
43
+ expect(testNode!.level).toBe(3);
44
+ });
45
+
46
+ it("TC 1.2: multi-paragraph insert with heading does not leak ### prefix to other paragraphs", async () => {
47
+ const doc = await createTestDocument();
48
+ addParagraph(doc, "Replace me.");
49
+
50
+ const engine = new RedlineEngine(doc);
51
+ const res = engine.process_batch([
52
+ {
53
+ type: "modify",
54
+ target_text: "Replace me.",
55
+ new_text: "### Title\n\nBody with **bold** and _italic_.\n\nSecond paragraph.",
56
+ },
57
+ ]);
58
+
59
+ expect(res.edits_applied).toBe(1);
60
+
61
+ const buf = await doc.save();
62
+ const cleanText = await extractTextFromBuffer(buf, true);
63
+
64
+ // Subsequent paragraphs must not have literal "###" prepended
65
+ expect(cleanText).toContain("Body with **bold** and _italic_.");
66
+ expect(cleanText).toContain("Second paragraph.");
67
+ expect(cleanText).not.toContain("### Body with");
68
+ expect(cleanText).not.toContain("### Second");
69
+ });
70
+ });
71
+
72
+ describe("Adeu MCP QA Report - Issue 2: Bold/large-font table cells misclassified as headings", () => {
73
+ it("TC 2.1: bold table cell with uppercase/numeric text is NOT classified as a heading", async () => {
74
+ const doc = await createTestDocument();
75
+ const tbl = addTable(doc, 1, 1);
76
+ setCellText(tbl, 0, 0, "$ 88,136 $ 72,361 $ 72,738");
77
+
78
+ // Make the run inside the cell bold
79
+ const p = tbl.getElementsByTagName("w:p")[0];
80
+ const r = p.getElementsByTagName("w:r")[0];
81
+ const xmlDoc = doc.element.ownerDocument!;
82
+ const rPr = xmlDoc.createElement("w:rPr");
83
+ const b = xmlDoc.createElement("w:b");
84
+ rPr.appendChild(b);
85
+ r.insertBefore(rPr, r.firstChild);
86
+
87
+ // Re-extract outline
88
+ const extract_res = _extractTextFromDoc(doc, false, false, true) as {
89
+ text: string;
90
+ paragraph_offsets: Map<any, [number, number]>;
91
+ };
92
+ const pages = paginate(extract_res.text, "");
93
+ const nodes = extract_outline(
94
+ doc,
95
+ extract_res.text,
96
+ pages.body_pages,
97
+ pages.body_page_offsets,
98
+ extract_res.paragraph_offsets as any,
99
+ );
100
+
101
+ // The outline should be empty or should NOT contain the table cell text as a heading
102
+ const fakeHeading = nodes.find((n) => n.text.includes("$ 88,136"));
103
+ expect(fakeHeading).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe("Adeu MCP QA Report - Issue 3: insert_row/delete_row with anchor-only target", () => {
108
+ it("TC 3.1: insert_row succeeds when targeted with a {#cell:paraId} anchor alone", async () => {
109
+ const doc = await createTestDocument();
110
+ const tbl = addTable(doc, 1, 2);
111
+ setCellText(tbl, 0, 0, "A1");
112
+ setCellText(tbl, 0, 1, "A2");
113
+
114
+ // Add w14:paraId to the paragraph of cell 0 to act as our stable anchor
115
+ const rows = Array.from(tbl.childNodes).filter((n) => (n as Element).tagName === "w:tr") as Element[];
116
+ const cells = Array.from(rows[0].childNodes).filter((n) => (n as Element).tagName === "w:tc") as Element[];
117
+ const p = cells[0].getElementsByTagName("w:p")[0];
118
+ p.setAttribute("w14:paraId", "DEADBEEF");
119
+
120
+ const engine = new RedlineEngine(doc);
121
+ const res = engine.process_batch([
122
+ {
123
+ type: "insert_row",
124
+ target_text: "{#cell:DEADBEEF}",
125
+ cells: ["B1", "B2"],
126
+ position: "below",
127
+ } as any,
128
+ ]);
129
+
130
+ expect(res.edits_applied).toBe(1);
131
+ });
132
+
133
+ it("TC 3.2: delete_row succeeds when targeted with a {#cell:paraId} anchor alone", async () => {
134
+ const doc = await createTestDocument();
135
+ const tbl = addTable(doc, 2, 2);
136
+ setCellText(tbl, 0, 0, "A1");
137
+ setCellText(tbl, 0, 1, "A2");
138
+ setCellText(tbl, 1, 0, "B1");
139
+ setCellText(tbl, 1, 1, "B2");
140
+
141
+ // Add w14:paraId to the paragraph of cell (0,0) to act as our stable anchor
142
+ const rows = Array.from(tbl.childNodes).filter((n) => (n as Element).tagName === "w:tr") as Element[];
143
+ const cells0 = Array.from(rows[0].childNodes).filter((n) => (n as Element).tagName === "w:tc") as Element[];
144
+ const p = cells0[0].getElementsByTagName("w:p")[0];
145
+ p.setAttribute("w14:paraId", "DEADBEEF");
146
+
147
+ const engine = new RedlineEngine(doc);
148
+ const res = engine.process_batch([
149
+ {
150
+ type: "delete_row",
151
+ target_text: "{#cell:DEADBEEF}",
152
+ } as any,
153
+ ]);
154
+
155
+ expect(res.edits_applied).toBe(1);
156
+ });
157
+ });
158
+
159
+ describe("Adeu MCP QA Report - Issue 5: Empty-cell fill spacing", () => {
160
+ it("TC 5.1: modifying cell anchor next to a label preserves or inserts a separator space", async () => {
161
+ const doc = await createTestDocument();
162
+ const tbl = addTable(doc, 1, 1);
163
+ const rows = Array.from(tbl.childNodes).filter((n) => (n as Element).tagName === "w:tr") as Element[];
164
+ const cells = Array.from(rows[0].childNodes).filter((n) => (n as Element).tagName === "w:tc") as Element[];
165
+ const tc = cells[0];
166
+
167
+ // Clear and construct a paragraph with text "Nimi" and paraId "DEADBEEF"
168
+ while (tc.firstChild) tc.removeChild(tc.firstChild);
169
+ const xmlDoc = doc.element.ownerDocument!;
170
+ const p = xmlDoc.createElement("w:p");
171
+ p.setAttribute("w14:paraId", "DEADBEEF");
172
+ const r = xmlDoc.createElement("w:r");
173
+ const t = xmlDoc.createElement("w:t");
174
+ t.textContent = "Nimi";
175
+ r.appendChild(t);
176
+ p.appendChild(r);
177
+ tc.appendChild(p);
178
+
179
+ const engine = new RedlineEngine(doc);
180
+ const res = engine.process_batch([
181
+ {
182
+ type: "modify",
183
+ target_text: "{#cell:DEADBEEF}",
184
+ new_text: "Testi Testinen",
185
+ } as any,
186
+ ]);
187
+
188
+ expect(res.edits_applied).toBe(1);
189
+
190
+ (engine as any).accept_all_revisions();
191
+ const cleanText = await extractTextFromBuffer(await doc.save(), true);
192
+ // It should have some separator, e.g. "Nimi Testi Testinen" instead of "NimiTesti Testinen"
193
+ expect(cleanText).not.toContain("NimiTesti Testinen");
194
+ });
195
+ });
196
+
197
+ describe("Adeu MCP QA Report - Issue 7: finalize_document keep-markup comments section label", () => {
198
+ it("TC 7.1: keep-markup with open comments does NOT label them as COMMENTS (stripped)", async () => {
199
+ const doc = await createTestDocument();
200
+ addParagraph(doc, "This is a normal paragraph.");
201
+
202
+ const engine = new RedlineEngine(doc);
203
+ const res = engine.process_batch([
204
+ {
205
+ type: "modify",
206
+ target_text: "This is a normal paragraph.",
207
+ new_text: "This is a normal paragraph.",
208
+ comment: "This is a comment",
209
+ } as any,
210
+ ]);
211
+ expect(res.edits_applied).toBe(1);
212
+
213
+ const finalizeRes = await finalize_document(doc, {
214
+ filename: "test.docx",
215
+ sanitize_mode: "keep-markup",
216
+ });
217
+
218
+ const report = finalizeRes.reportText;
219
+
220
+ // The report must NOT contain the contradictory "COMMENTS (stripped)" header
221
+ // when comments are actually kept (0 comments removed, 1 comment kept).
222
+ expect(report).not.toContain("COMMENTS (stripped)");
223
+ });
224
+ });