@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/package.json
CHANGED
package/src/diff.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { trim_common_context, generate_edits_from_text, create_word_patch_diff } from './diff.js';
|
|
2
|
+
import { trim_common_context, generate_edits_from_text, create_word_patch_diff, generate_edits_via_paragraph_alignment } from './diff.js';
|
|
3
3
|
|
|
4
4
|
describe('Diff Logic & Context Trimming', () => {
|
|
5
5
|
it('handles basic prefix and suffix', () => {
|
|
@@ -71,4 +71,11 @@ describe('Diff Logic & Context Trimming', () => {
|
|
|
71
71
|
expect(diff).toContain("+ Corporation");
|
|
72
72
|
expect(diff).toContain(" This agreement is made between the"); // Within 40-char context window so no truncation
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
it('handles trailing newline and whitespace safely in paragraph alignment', () => {
|
|
76
|
+
const original = "This is a single paragraph of the draft.";
|
|
77
|
+
const modified = "This is a single paragraph of the draft.\n";
|
|
78
|
+
const edits = generate_edits_via_paragraph_alignment(original, modified);
|
|
79
|
+
expect(edits.length).toBe(0);
|
|
80
|
+
});
|
|
74
81
|
});
|
package/src/diff.ts
CHANGED
|
@@ -436,6 +436,241 @@ function _sequence_opcodes(a: string[], b: string[]): Opcode[] {
|
|
|
436
436
|
return ops;
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
/**
|
|
440
|
+
* True when a "\n\n"-separated block reads as projected table rows: every
|
|
441
|
+
* line carries the " | " cell separator. Table rows are separated by single
|
|
442
|
+
* newlines, so a whole table is one block in the paragraph alignment.
|
|
443
|
+
*/
|
|
444
|
+
function _is_table_blob(block: string): boolean {
|
|
445
|
+
const lines = block.split("\n");
|
|
446
|
+
return lines.length > 0 && lines.every((line) => line.includes(" | "));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Pairwise row-level edits between two aligned table blobs, or null when the
|
|
451
|
+
* blobs cannot be row-aligned (different row counts — a structural change the
|
|
452
|
+
* text path hands to the engine's row guards). Row edits are unpinned: the
|
|
453
|
+
* full row text is the anchor, and the engine's per-cell splitter resolves
|
|
454
|
+
* them — word-level hunks across " | " separators land in the wrong cell.
|
|
455
|
+
*/
|
|
456
|
+
function _table_blob_row_edits(
|
|
457
|
+
orig_blob: string,
|
|
458
|
+
mod_blob: string,
|
|
459
|
+
): ModifyText[] | null {
|
|
460
|
+
const rows_o = orig_blob.split("\n");
|
|
461
|
+
const rows_m = mod_blob.split("\n");
|
|
462
|
+
if (rows_o.length !== rows_m.length) return null;
|
|
463
|
+
const row_edits: ModifyText[] = [];
|
|
464
|
+
for (let k = 0; k < rows_o.length; k++) {
|
|
465
|
+
if (rows_o[k] === rows_m[k]) continue;
|
|
466
|
+
row_edits.push({
|
|
467
|
+
type: "modify",
|
|
468
|
+
target_text: rows_o[k],
|
|
469
|
+
new_text: rows_m[k],
|
|
470
|
+
comment: "Diff: Table row modified",
|
|
471
|
+
_is_table_edit: true,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return row_edits;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Splits generated hunks the engine would reject: an UNBALANCED target
|
|
479
|
+
* spanning a paragraph break with body text on both sides ("tail of A\n\n
|
|
480
|
+
* head of B" — the shape dmp produces when adjacent paragraphs share a
|
|
481
|
+
* prefix). Each leading paragraph piece becomes its own separator-carrying
|
|
482
|
+
* deletion (an allowed merge shape) and the final piece carries the whole
|
|
483
|
+
* replacement text; sequential application produces the identical merged
|
|
484
|
+
* result. Without this, one such hunk poisons the entire batch — apply is
|
|
485
|
+
* all-or-nothing (QA 2026-07-19 ADEU-QA-002 A).
|
|
486
|
+
*/
|
|
487
|
+
function _split_cross_paragraph_hunks(edits: ModifyText[]): ModifyText[] {
|
|
488
|
+
const out: ModifyText[] = [];
|
|
489
|
+
for (const e of edits) {
|
|
490
|
+
const target = e.target_text || "";
|
|
491
|
+
const newText = e.new_text || "";
|
|
492
|
+
const idx = e._match_start_index;
|
|
493
|
+
if (
|
|
494
|
+
idx === undefined ||
|
|
495
|
+
idx === null ||
|
|
496
|
+
!target.includes("\n\n") ||
|
|
497
|
+
target.split("\n\n").length - 1 === newText.split("\n\n").length - 1
|
|
498
|
+
) {
|
|
499
|
+
out.push(e);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const parts = target.split("\n\n");
|
|
503
|
+
if (parts.length < 2 || !parts[0].trim() || !parts[parts.length - 1].trim()) {
|
|
504
|
+
// One-sided shapes (separator-carrying deletions/insertions) are the
|
|
505
|
+
// engine's supported merge protocol — leave them intact.
|
|
506
|
+
out.push(e);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
let offset = idx;
|
|
510
|
+
for (const piece of parts.slice(0, -1)) {
|
|
511
|
+
out.push({
|
|
512
|
+
type: "modify",
|
|
513
|
+
target_text: piece + "\n\n",
|
|
514
|
+
new_text: "",
|
|
515
|
+
comment: e.comment || "Diff: Text deleted",
|
|
516
|
+
_match_start_index: offset,
|
|
517
|
+
});
|
|
518
|
+
offset += piece.length + 2;
|
|
519
|
+
}
|
|
520
|
+
out.push({
|
|
521
|
+
type: "modify",
|
|
522
|
+
target_text: parts[parts.length - 1],
|
|
523
|
+
new_text: newText,
|
|
524
|
+
comment: e.comment,
|
|
525
|
+
_match_start_index: offset,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Aligns original and modified text by paragraph (using the difflib-style
|
|
533
|
+
* opcodes), then performs precise word-level diffing on replaced blocks.
|
|
534
|
+
* Whole-paragraph structural changes come out as engine-applicable shapes:
|
|
535
|
+
* a deleted paragraph is ONE deletion per paragraph carrying its "\n\n"
|
|
536
|
+
* separator, an inserted paragraph carries its separator — a raw word-level
|
|
537
|
+
* diff instead legally shifts hunk boundaries across paragraphs sharing a
|
|
538
|
+
* prefix ("...arrears.\n\nThe "), which the engine rightly rejects; every
|
|
539
|
+
* paragraph deletion/reordering in the QA corpus failed replay that way
|
|
540
|
+
* (QA 2026-07-19 ADEU-QA-002 A). Mirrors the Python engine's
|
|
541
|
+
* generate_edits_via_paragraph_alignment.
|
|
542
|
+
*/
|
|
543
|
+
export function generate_edits_via_paragraph_alignment(
|
|
544
|
+
original_text: string,
|
|
545
|
+
modified_text: string,
|
|
546
|
+
): ModifyText[] {
|
|
547
|
+
// Normalize trailing whitespace/newlines in modified_text to match original_text
|
|
548
|
+
const orig_stripped = original_text.trimEnd();
|
|
549
|
+
const orig_ws = original_text.slice(orig_stripped.length);
|
|
550
|
+
const mod_stripped = modified_text.trimEnd();
|
|
551
|
+
modified_text = mod_stripped + orig_ws;
|
|
552
|
+
|
|
553
|
+
const orig_paragraphs = original_text.split("\n\n");
|
|
554
|
+
const mod_paragraphs = modified_text.split("\n\n");
|
|
555
|
+
|
|
556
|
+
const orig_offsets: number[] = [];
|
|
557
|
+
let current_offset = 0;
|
|
558
|
+
for (const p of orig_paragraphs) {
|
|
559
|
+
orig_offsets.push(current_offset);
|
|
560
|
+
current_offset += p.length + 2;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const opcodes = _sequence_opcodes(orig_paragraphs, mod_paragraphs);
|
|
564
|
+
const edits: ModifyText[] = [];
|
|
565
|
+
|
|
566
|
+
for (const [tag, i1, i2, j1, j2] of opcodes) {
|
|
567
|
+
if (tag === "equal") continue;
|
|
568
|
+
|
|
569
|
+
const offset =
|
|
570
|
+
i1 < orig_offsets.length ? orig_offsets[i1] : original_text.length;
|
|
571
|
+
|
|
572
|
+
if (tag === "delete") {
|
|
573
|
+
// Multi-paragraph mid-document blocks are emitted as ONE deletion PER
|
|
574
|
+
// paragraph ("A\n\n", "B\n\n"), never "A\n\nB\n\n": the engine's merge
|
|
575
|
+
// protocol supports one deleted paragraph break per edit
|
|
576
|
+
// (QA 2026-07-19 ADEU-QA-002 A). The document's trailing block takes
|
|
577
|
+
// the LEADING separator instead (QA 2026-07-19 v8 F-12 fallout).
|
|
578
|
+
if (i2 < orig_paragraphs.length) {
|
|
579
|
+
let piece_offset = offset;
|
|
580
|
+
for (let k = i1; k < i2; k++) {
|
|
581
|
+
const piece = orig_paragraphs[k] + "\n\n";
|
|
582
|
+
edits.push({
|
|
583
|
+
type: "modify",
|
|
584
|
+
target_text: piece,
|
|
585
|
+
new_text: "",
|
|
586
|
+
comment: "Diff: Text deleted",
|
|
587
|
+
_match_start_index: piece_offset,
|
|
588
|
+
});
|
|
589
|
+
piece_offset += piece.length;
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
let deleted_text = orig_paragraphs.slice(i1, i2).join("\n\n");
|
|
593
|
+
let del_offset = offset;
|
|
594
|
+
if (i1 > 0) {
|
|
595
|
+
deleted_text = "\n\n" + deleted_text;
|
|
596
|
+
del_offset -= 2;
|
|
597
|
+
}
|
|
598
|
+
edits.push({
|
|
599
|
+
type: "modify",
|
|
600
|
+
target_text: deleted_text,
|
|
601
|
+
new_text: "",
|
|
602
|
+
comment: "Diff: Text deleted",
|
|
603
|
+
_match_start_index: del_offset,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
} else if (tag === "insert") {
|
|
607
|
+
// An inserted paragraph must CARRY its paragraph separator, or the
|
|
608
|
+
// engine (rightly) treats the text as an inline insertion and glues it
|
|
609
|
+
// to the neighboring paragraph (QA 2026-07-18 v6 H2).
|
|
610
|
+
let inserted_text = mod_paragraphs.slice(j1, j2).join("\n\n");
|
|
611
|
+
if (i1 < orig_paragraphs.length) {
|
|
612
|
+
inserted_text = inserted_text + "\n\n";
|
|
613
|
+
} else {
|
|
614
|
+
inserted_text = "\n\n" + inserted_text;
|
|
615
|
+
}
|
|
616
|
+
edits.push({
|
|
617
|
+
type: "modify",
|
|
618
|
+
target_text: "",
|
|
619
|
+
new_text: inserted_text,
|
|
620
|
+
comment: "Diff: Text inserted",
|
|
621
|
+
_match_start_index: offset,
|
|
622
|
+
});
|
|
623
|
+
} else if (tag === "replace") {
|
|
624
|
+
// Table blobs in equal-count replace blocks pair up positionally and
|
|
625
|
+
// diff as ROW-LEVEL edits; word-level hunks over a table blob start or
|
|
626
|
+
// end inside " | " separators and land in the wrong cell. Prose pairs
|
|
627
|
+
// (and any block whose counts differ) keep the word-level chunk diff.
|
|
628
|
+
if (
|
|
629
|
+
i2 - i1 === j2 - j1 &&
|
|
630
|
+
Array.from({ length: i2 - i1 }).some(
|
|
631
|
+
(_, k) =>
|
|
632
|
+
_is_table_blob(orig_paragraphs[i1 + k]) ||
|
|
633
|
+
_is_table_blob(mod_paragraphs[j1 + k]),
|
|
634
|
+
)
|
|
635
|
+
) {
|
|
636
|
+
for (let k = 0; k < i2 - i1; k++) {
|
|
637
|
+
const orig_p = orig_paragraphs[i1 + k];
|
|
638
|
+
const mod_p = mod_paragraphs[j1 + k];
|
|
639
|
+
if (orig_p === mod_p) continue;
|
|
640
|
+
const pair_offset = orig_offsets[i1 + k];
|
|
641
|
+
const row_edits =
|
|
642
|
+
_is_table_blob(orig_p) && _is_table_blob(mod_p)
|
|
643
|
+
? _table_blob_row_edits(orig_p, mod_p)
|
|
644
|
+
: null;
|
|
645
|
+
if (row_edits !== null) {
|
|
646
|
+
edits.push(...row_edits);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const pair_edits = generate_edits_from_text(orig_p, mod_p);
|
|
650
|
+
for (const ce of pair_edits) {
|
|
651
|
+
ce._match_start_index = (ce._match_start_index || 0) + pair_offset;
|
|
652
|
+
edits.push(ce);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const orig_chunk = orig_paragraphs.slice(i1, i2).join("\n\n");
|
|
659
|
+
const mod_chunk = mod_paragraphs.slice(j1, j2).join("\n\n");
|
|
660
|
+
const chunk_edits = generate_edits_from_text(orig_chunk, mod_chunk);
|
|
661
|
+
for (const ce of chunk_edits) {
|
|
662
|
+
ce._match_start_index = (ce._match_start_index || 0) + offset;
|
|
663
|
+
edits.push(ce);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Word-level diffs over unequal replace chunks can still emit hunks that
|
|
669
|
+
// straddle a paragraph break with body text on both sides; split them into
|
|
670
|
+
// engine-applicable pieces (ADEU-QA-002 A).
|
|
671
|
+
return _split_cross_paragraph_hunks(edits);
|
|
672
|
+
}
|
|
673
|
+
|
|
439
674
|
const _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
|
|
440
675
|
|
|
441
676
|
/**
|
|
@@ -756,7 +991,7 @@ export function generate_structured_edits(
|
|
|
756
991
|
);
|
|
757
992
|
const flat = _drop_marker_interior_hunks(
|
|
758
993
|
_drop_image_marker_hunks(
|
|
759
|
-
|
|
994
|
+
generate_edits_via_paragraph_alignment(text_orig, text_mod),
|
|
760
995
|
warnings,
|
|
761
996
|
),
|
|
762
997
|
text_orig,
|
|
@@ -796,7 +1031,7 @@ export function generate_structured_edits(
|
|
|
796
1031
|
}
|
|
797
1032
|
|
|
798
1033
|
if (!tables_alignable) {
|
|
799
|
-
const part_edits =
|
|
1034
|
+
const part_edits = generate_edits_via_paragraph_alignment(
|
|
800
1035
|
text_orig.substring(po_start, po_end),
|
|
801
1036
|
text_mod.substring(pm_start, pm_end),
|
|
802
1037
|
);
|
|
@@ -824,7 +1059,11 @@ export function generate_structured_edits(
|
|
|
824
1059
|
const seg_o_end = boundaries_o[seg_idx + 1][0];
|
|
825
1060
|
const seg_m_start = boundaries_m[seg_idx][1];
|
|
826
1061
|
const seg_m_end = boundaries_m[seg_idx + 1][0];
|
|
827
|
-
|
|
1062
|
+
// Paragraph alignment, never a raw word-level diff over the whole
|
|
1063
|
+
// segment: dmp legally shifts hunk boundaries across paragraphs
|
|
1064
|
+
// sharing a prefix, and the engine rightly rejects a deletion with
|
|
1065
|
+
// body text on both sides of a paragraph break (ADEU-QA-002 A).
|
|
1066
|
+
const seg_edits = generate_edits_via_paragraph_alignment(
|
|
828
1067
|
text_orig.substring(seg_o_start, seg_o_end),
|
|
829
1068
|
text_mod.substring(seg_m_start, seg_m_end),
|
|
830
1069
|
);
|
|
@@ -40,7 +40,11 @@ describe('Atomic Batch Pipeline (Node.js Port)', () => {
|
|
|
40
40
|
const stats = engine2.process_batch(changes);
|
|
41
41
|
|
|
42
42
|
// 4. Assertions on the Tool Execution
|
|
43
|
-
|
|
43
|
+
// The two ids form ONE replacement pair: the first accept resolves both,
|
|
44
|
+
// the second is an accurate no-op (QA 2026-07-19 ADEU-QA-004) — never a
|
|
45
|
+
// second "applied" state transition.
|
|
46
|
+
expect(stats.actions_applied).toBe(1);
|
|
47
|
+
expect(stats.actions_already_resolved).toBe(actions.length - 1);
|
|
44
48
|
expect(stats.edits_applied).toBe(1);
|
|
45
49
|
|
|
46
50
|
// 5. Assertions on the Final Document State
|
package/src/engine.batch.test.ts
CHANGED
|
@@ -32,10 +32,15 @@ describe('Batch Reliability (Node.js Port)', () => {
|
|
|
32
32
|
|
|
33
33
|
const actions: AcceptChange[] = [1, 2, 3, 4, 5, 6].map(id => ({ type: 'accept', target_id: `Chg:${id}` }));
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// Three replacements: each del+ins pair resolves as one unit on the
|
|
36
|
+
// first accept, so 3 actions transition state and 3 are accurate no-ops
|
|
37
|
+
// (QA 2026-07-19 ADEU-QA-004).
|
|
38
|
+
const [applied, skipped, already_resolved] = (
|
|
39
|
+
engine2 as any
|
|
40
|
+
).apply_review_actions(actions);
|
|
41
|
+
|
|
42
|
+
expect(applied).toBe(3);
|
|
43
|
+
expect(already_resolved).toBe(3);
|
|
39
44
|
expect(skipped).toBe(0);
|
|
40
45
|
|
|
41
46
|
const finalBuf = await midDoc.save();
|
|
@@ -78,8 +83,13 @@ describe('Batch Reliability (Node.js Port)', () => {
|
|
|
78
83
|
{ type: 'reject', target_id: "Chg:2" } as RejectChange,
|
|
79
84
|
];
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
// Two replacement pairs, each resolved by its first action; the paired
|
|
87
|
+
// follow-ups are accurate no-ops (QA 2026-07-19 ADEU-QA-004).
|
|
88
|
+
const [applied, , already_resolved] = (
|
|
89
|
+
engine2 as any
|
|
90
|
+
).apply_review_actions(actions);
|
|
91
|
+
expect(applied).toBe(2);
|
|
92
|
+
expect(already_resolved).toBe(2);
|
|
83
93
|
|
|
84
94
|
const finalBuf = await midDoc.save();
|
|
85
95
|
const text_final = await extractTextFromBuffer(finalBuf);
|
package/src/engine.bugs.test.ts
CHANGED
|
@@ -568,4 +568,53 @@ describe("Resolved Bugs Core Engine Verification", () => {
|
|
|
568
568
|
expect(caught.name).toBe("BatchValidationError");
|
|
569
569
|
expect(caught.message).toContain("Invalid change format");
|
|
570
570
|
});
|
|
571
|
+
|
|
572
|
+
it("BUG-REPRO: accept_all_revisions returns counts of accepted changes and removed comments", async () => {
|
|
573
|
+
const doc = await createTestDocument();
|
|
574
|
+
addParagraph(doc, "This is a test document.");
|
|
575
|
+
const engine = new RedlineEngine(doc);
|
|
576
|
+
|
|
577
|
+
engine.process_batch([
|
|
578
|
+
{
|
|
579
|
+
type: "modify",
|
|
580
|
+
target_text: "document",
|
|
581
|
+
new_text: "dossier",
|
|
582
|
+
comment: "Review note to be stripped",
|
|
583
|
+
},
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
const stats = engine.accept_all_revisions() as any;
|
|
587
|
+
|
|
588
|
+
expect(stats).toBeDefined();
|
|
589
|
+
expect(stats.accepted_insertions).toBe(1);
|
|
590
|
+
expect(stats.accepted_deletions).toBe(1);
|
|
591
|
+
expect(stats.accepted_formatting).toBe(0);
|
|
592
|
+
// Counted from comment bodies this call actually deleted, not from the
|
|
593
|
+
// document's comment total — the two coincide here, but see below.
|
|
594
|
+
expect(stats.removed_comments).toBe(1);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("BUG-REPRO: removed_comments does not count comments it deliberately keeps", async () => {
|
|
598
|
+
const doc = await createTestDocument();
|
|
599
|
+
addParagraph(doc, "This is a test document.");
|
|
600
|
+
const engine = new RedlineEngine(doc);
|
|
601
|
+
|
|
602
|
+
engine.process_batch([
|
|
603
|
+
{
|
|
604
|
+
type: "modify",
|
|
605
|
+
target_text: "document",
|
|
606
|
+
new_text: "dossier",
|
|
607
|
+
comment: "Review note",
|
|
608
|
+
},
|
|
609
|
+
]);
|
|
610
|
+
|
|
611
|
+
// A different reviewer now opens the redlined document. The existing
|
|
612
|
+
// comment is foreign to them, so it keeps its BODY by design (only the
|
|
613
|
+
// anchor is detached) and must not be reported as removed.
|
|
614
|
+
const other = new RedlineEngine(doc);
|
|
615
|
+
other.author = "Someone Else";
|
|
616
|
+
const stats = other.accept_all_revisions() as any;
|
|
617
|
+
|
|
618
|
+
expect(stats.removed_comments).toBe(0);
|
|
619
|
+
});
|
|
571
620
|
});
|
|
@@ -265,4 +265,35 @@ describe("Table Interop & Engine (Node.js Port)", () => {
|
|
|
265
265
|
expect(errors[0]).toContain('Did you mean the literal "( x )"');
|
|
266
266
|
expect(errors[0]).toContain("drop the ^/$ anchors");
|
|
267
267
|
});
|
|
268
|
+
|
|
269
|
+
it("compiled report includes type for structural table operations", async () => {
|
|
270
|
+
const doc = await createTestDocument();
|
|
271
|
+
const tbl = addTable(doc, 2, 2);
|
|
272
|
+
setCellText(tbl, 0, 0, "Row1 Col1");
|
|
273
|
+
setCellText(tbl, 0, 1, "Row1 Col2");
|
|
274
|
+
setCellText(tbl, 1, 0, "Row2 Col1");
|
|
275
|
+
setCellText(tbl, 1, 1, "Row2 Col2");
|
|
276
|
+
|
|
277
|
+
const buf = await doc.save();
|
|
278
|
+
const midDoc = await DocumentObject.load(buf);
|
|
279
|
+
const engine = new RedlineEngine(midDoc);
|
|
280
|
+
|
|
281
|
+
const stats = engine.process_batch([
|
|
282
|
+
{
|
|
283
|
+
type: "insert_row",
|
|
284
|
+
target_text: "Row1 Col1",
|
|
285
|
+
position: "below",
|
|
286
|
+
cells: ["NewRow Col1", "NewRow Col2"],
|
|
287
|
+
} as InsertTableRow,
|
|
288
|
+
{
|
|
289
|
+
type: "delete_row",
|
|
290
|
+
target_text: "Row2 Col1",
|
|
291
|
+
} as DeleteTableRow,
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
expect(stats.edits_applied).toBe(2);
|
|
295
|
+
expect(stats.edits).toHaveLength(2);
|
|
296
|
+
expect(stats.edits[0].type).toBe("insert_row");
|
|
297
|
+
expect(stats.edits[1].type).toBe("delete_row");
|
|
298
|
+
});
|
|
268
299
|
});
|