@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.
- package/dist/index.cjs +696 -78
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -2
- package/dist/index.d.ts +63 -2
- package/dist/index.js +696 -78
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.test.ts +8 -1
- package/src/diff.ts +242 -3
- package/src/engine.atomic.test.ts +5 -1
- package/src/engine.batch.test.ts +16 -6
- package/src/engine.bugs.test.ts +49 -0
- package/src/engine.tables.test.ts +31 -0
- package/src/engine.ts +443 -66
- package/src/ingest.test.ts +3 -1
- package/src/ingest.ts +35 -5
- package/src/mapper.ts +94 -11
- package/src/repro_agy_bug.test.ts +108 -0
- package/src/repro_qa_mcp_issues_2026_07_20.test.ts +224 -0
- package/src/repro_qa_report_v9.test.ts +392 -0
- package/src/sanitize/core.ts +12 -1
- package/src/sanitize/report.ts +9 -7
- package/src/sanitize/sanitize.test.ts +1 -0
- 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,
|
|
@@ -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
|
-
|
|
257
|
+
let firstP = cell._element.getElementsByTagName("w:p")[0] as
|
|
257
258
|
| Element
|
|
258
259
|
| undefined;
|
|
259
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
351
|
+
let firstP = cell._element.getElementsByTagName("w:p")[0] as
|
|
351
352
|
| Element
|
|
352
353
|
| undefined;
|
|
353
|
-
|
|
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 (
|
|
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
|
-
|
|
1022
|
-
|
|
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
|
|
1044
|
-
|
|
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
|
+
});
|