@adeu/core 1.26.0 → 1.28.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.
- package/dist/index.cjs +622 -71
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +622 -71
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +236 -3
- package/src/engine.atomic.test.ts +5 -1
- package/src/engine.batch.test.ts +16 -6
- package/src/engine.ts +416 -58
- package/src/ingest.test.ts +3 -1
- package/src/ingest.ts +16 -3
- package/src/mapper.ts +83 -10
- package/src/repro_qa_mcp_issues_2026_07_20.test.ts +224 -0
- package/src/repro_qa_report_v8.test.ts +265 -0
- package/src/repro_qa_report_v9.test.ts +392 -0
- package/src/sanitize/core.ts +11 -0
- package/src/sanitize/report.ts +9 -7
- package/src/sanitize/transforms.ts +39 -0
- package/src/utils/docx.ts +87 -0
package/src/ingest.test.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
@@ -258,7 +259,8 @@ export function extract_table(
|
|
|
258
259
|
| undefined;
|
|
259
260
|
const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
|
|
260
261
|
if (paraId) {
|
|
261
|
-
const
|
|
262
|
+
const space_pad = cell_content ? " " : "";
|
|
263
|
+
const anchor = `${space_pad}{#cell:${paraId}}`;
|
|
262
264
|
cell_content = cell_content + anchor;
|
|
263
265
|
}
|
|
264
266
|
}
|
|
@@ -603,13 +605,22 @@ function _build_merged_meta_block(
|
|
|
603
605
|
const comment_lines: string[] = [];
|
|
604
606
|
const seen_sigs = new Set<string>();
|
|
605
607
|
|
|
608
|
+
// Ids of one resolution group (a replacement's contiguous same-author
|
|
609
|
+
// del+ins pair) must not read as independently resolvable — either side
|
|
610
|
+
// resolves the whole group (QA 2026-07-19 ADEU-QA-004).
|
|
611
|
+
const pair_map = compute_change_pair_map(states_list);
|
|
612
|
+
const pairSuffix = (uid: string): string =>
|
|
613
|
+
pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
|
|
614
|
+
|
|
606
615
|
for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
|
|
607
616
|
for (const [uid, meta] of Object.entries(
|
|
608
617
|
ins_map as Record<string, DocxEvent>,
|
|
609
618
|
)) {
|
|
610
619
|
const sig = `Chg:${uid}`;
|
|
611
620
|
if (!seen_sigs.has(sig)) {
|
|
612
|
-
change_lines.push(
|
|
621
|
+
change_lines.push(
|
|
622
|
+
`[${sig} insert] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
|
|
623
|
+
);
|
|
613
624
|
seen_sigs.add(sig);
|
|
614
625
|
}
|
|
615
626
|
}
|
|
@@ -618,7 +629,9 @@ function _build_merged_meta_block(
|
|
|
618
629
|
)) {
|
|
619
630
|
const sig = `Chg:${uid}`;
|
|
620
631
|
if (!seen_sigs.has(sig)) {
|
|
621
|
-
change_lines.push(
|
|
632
|
+
change_lines.push(
|
|
633
|
+
`[${sig} delete] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
|
|
634
|
+
);
|
|
622
635
|
seen_sigs.add(sig);
|
|
623
636
|
}
|
|
624
637
|
}
|
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,
|
|
@@ -357,6 +358,11 @@ export class DocumentMapper {
|
|
|
357
358
|
// token offset so resolution targets THIS cell, not a neighbour.
|
|
358
359
|
const cellPara = new Paragraph(firstP, cell);
|
|
359
360
|
this._add_virtual_text("", current, cellPara);
|
|
361
|
+
const has_content = current > cell_start;
|
|
362
|
+
if (has_content) {
|
|
363
|
+
this._add_virtual_text(" ", current, cellPara);
|
|
364
|
+
current += 1;
|
|
365
|
+
}
|
|
360
366
|
const anchor = `{#cell:${paraId}}`;
|
|
361
367
|
this._add_virtual_text(anchor, current, cellPara);
|
|
362
368
|
current += anchor.length;
|
|
@@ -810,6 +816,13 @@ export class DocumentMapper {
|
|
|
810
816
|
const comment_lines: string[] = [];
|
|
811
817
|
const seen_sigs = new Set<string>();
|
|
812
818
|
|
|
819
|
+
// Must render EXACTLY as ingest's _build_merged_meta_block (Virtual Text
|
|
820
|
+
// contract), including the resolution-group annotation
|
|
821
|
+
// (QA 2026-07-19 ADEU-QA-004).
|
|
822
|
+
const pair_map = compute_change_pair_map(states_list);
|
|
823
|
+
const pairSuffix = (uid: string): string =>
|
|
824
|
+
pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
|
|
825
|
+
|
|
813
826
|
for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
|
|
814
827
|
for (const [uid, meta] of Object.entries(
|
|
815
828
|
ins_map as Record<string, DocxEvent>,
|
|
@@ -817,7 +830,7 @@ export class DocumentMapper {
|
|
|
817
830
|
const sig = `Chg:${uid}`;
|
|
818
831
|
if (!seen_sigs.has(sig)) {
|
|
819
832
|
const auth = meta.author || "Unknown";
|
|
820
|
-
change_lines.push(`[${sig} insert] ${auth}`);
|
|
833
|
+
change_lines.push(`[${sig} insert] ${auth}${pairSuffix(uid)}`);
|
|
821
834
|
seen_sigs.add(sig);
|
|
822
835
|
}
|
|
823
836
|
}
|
|
@@ -827,7 +840,7 @@ export class DocumentMapper {
|
|
|
827
840
|
const sig = `Chg:${uid}`;
|
|
828
841
|
if (!seen_sigs.has(sig)) {
|
|
829
842
|
const auth = meta.author || "Unknown";
|
|
830
|
-
change_lines.push(`[${sig} delete] ${auth}`);
|
|
843
|
+
change_lines.push(`[${sig} delete] ${auth}${pairSuffix(uid)}`);
|
|
831
844
|
seen_sigs.add(sig);
|
|
832
845
|
}
|
|
833
846
|
}
|
|
@@ -1000,6 +1013,40 @@ export class DocumentMapper {
|
|
|
1000
1013
|
return results;
|
|
1001
1014
|
}
|
|
1002
1015
|
|
|
1016
|
+
/**
|
|
1017
|
+
* True when no run-backed span overlaps [start, start+length): the range
|
|
1018
|
+
* covers only virtual projection text — meta bubbles (change/comment
|
|
1019
|
+
* headers, timestamps), style markers, list prefixes. Such text does not
|
|
1020
|
+
* exist in the document, so it can neither satisfy a match nor count
|
|
1021
|
+
* toward ambiguity (QA 2026-07-19 ADEU-QA-002 C): an edit targeting "4"
|
|
1022
|
+
* used to be rejected as "appears 8 times" because a comment bubble's
|
|
1023
|
+
* timestamp matched.
|
|
1024
|
+
*
|
|
1025
|
+
* Anchor tokens ({#Bookmark}, {#cell:paraId}) are the exception: they are
|
|
1026
|
+
* deliberate virtual TARGETING surfaces (empty-cell writes, bookmark
|
|
1027
|
+
* anchors) and must stay matchable.
|
|
1028
|
+
*/
|
|
1029
|
+
public range_is_virtual_only(start: number, length: number): boolean {
|
|
1030
|
+
const end = start + length;
|
|
1031
|
+
const overlapping = this.spans.filter(
|
|
1032
|
+
(s) => s.end > start && s.start < end,
|
|
1033
|
+
);
|
|
1034
|
+
if (overlapping.some((s) => s.run !== null)) return false;
|
|
1035
|
+
return !overlapping.some(
|
|
1036
|
+
(s) => s.run === null && s.text.startsWith("{#"),
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** Filters find_all_match_indices output down to matches that touch at
|
|
1041
|
+
* least one run-backed span. See range_is_virtual_only. */
|
|
1042
|
+
public drop_virtual_only_matches(
|
|
1043
|
+
matches: [number, number][],
|
|
1044
|
+
): [number, number][] {
|
|
1045
|
+
return matches.filter(
|
|
1046
|
+
([start, length]) => !this.range_is_virtual_only(start, length),
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1003
1050
|
public find_match_index(
|
|
1004
1051
|
target_text: string,
|
|
1005
1052
|
is_regex: boolean = false,
|
|
@@ -1011,19 +1058,33 @@ export class DocumentMapper {
|
|
|
1011
1058
|
// invalid-pattern errors mean "no match" here.
|
|
1012
1059
|
try {
|
|
1013
1060
|
const match = userSearch(target_text, this.full_text);
|
|
1014
|
-
if (
|
|
1061
|
+
if (
|
|
1062
|
+
match &&
|
|
1063
|
+
!this.range_is_virtual_only(match.start, match.end - match.start)
|
|
1064
|
+
) {
|
|
1065
|
+
return [match.start, match.end - match.start];
|
|
1066
|
+
}
|
|
1015
1067
|
} catch (e) {
|
|
1016
1068
|
if (e instanceof RegexTimeoutError) throw e;
|
|
1017
1069
|
}
|
|
1018
1070
|
return [-1, 0];
|
|
1019
1071
|
}
|
|
1020
1072
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1073
|
+
// Exact tier: skip occurrences that cover only virtual projection text
|
|
1074
|
+
// (meta bubbles, markers) — they are not document text (ADEU-QA-002 C).
|
|
1075
|
+
let from = 0;
|
|
1076
|
+
while (true) {
|
|
1077
|
+
const idx = this.full_text.indexOf(target_text, from);
|
|
1078
|
+
if (idx === -1) break;
|
|
1079
|
+
if (!this.range_is_virtual_only(idx, target_text.length)) {
|
|
1080
|
+
return [idx, target_text.length];
|
|
1081
|
+
}
|
|
1082
|
+
from = idx + 1;
|
|
1083
|
+
}
|
|
1023
1084
|
|
|
1024
1085
|
const norm_full = this._replace_smart_quotes(this.full_text);
|
|
1025
1086
|
const norm_target = this._replace_smart_quotes(target_text);
|
|
1026
|
-
start_idx = norm_full.indexOf(norm_target);
|
|
1087
|
+
let start_idx = norm_full.indexOf(norm_target);
|
|
1027
1088
|
if (start_idx !== -1) return [start_idx, target_text.length];
|
|
1028
1089
|
|
|
1029
1090
|
const stripped_target = this._strip_markdown_formatting(target_text);
|
|
@@ -1039,9 +1100,13 @@ export class DocumentMapper {
|
|
|
1039
1100
|
if (plain_first.length > 0) return plain_first[0];
|
|
1040
1101
|
|
|
1041
1102
|
try {
|
|
1042
|
-
const pattern = new RegExp(this._make_fuzzy_regex(target_text));
|
|
1043
|
-
const match
|
|
1044
|
-
|
|
1103
|
+
const pattern = new RegExp(this._make_fuzzy_regex(target_text), "g");
|
|
1104
|
+
for (const match of this.full_text.matchAll(pattern)) {
|
|
1105
|
+
// Virtual-only ranges are projection chrome, not document text.
|
|
1106
|
+
if (!this.range_is_virtual_only(match.index!, match[0].length)) {
|
|
1107
|
+
return [match.index!, match[0].length];
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1045
1110
|
} catch (e) {}
|
|
1046
1111
|
|
|
1047
1112
|
return [-1, 0];
|
|
@@ -1172,8 +1237,16 @@ export class DocumentMapper {
|
|
|
1172
1237
|
const first_real_span = real_spans[0];
|
|
1173
1238
|
let start_split_adjustment = 0;
|
|
1174
1239
|
|
|
1240
|
+
// A range may START on a virtual span (word-diff hunks absorb a style
|
|
1241
|
+
// marker adjacent to real changes, e.g. the `**` closing a bold run).
|
|
1242
|
+
// Virtual characters have no physical width: clamp to the first real
|
|
1243
|
+
// span's start or the subtraction goes negative and the split point
|
|
1244
|
+
// lands INSIDE the preceding run's kept text — the "**The Suppli**"
|
|
1245
|
+
// partial-word artifact (QA 2026-07-19 v8 F-04).
|
|
1175
1246
|
const local_start =
|
|
1176
|
-
start_idx
|
|
1247
|
+
Math.max(start_idx, first_real_span.start) -
|
|
1248
|
+
first_real_span.start +
|
|
1249
|
+
(first_real_span.run_offset || 0);
|
|
1177
1250
|
if (local_start > 0) {
|
|
1178
1251
|
const split_source = working_runs[0];
|
|
1179
1252
|
const [, right_run] = this._split_run_at_index(
|
|
@@ -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
|
+
});
|