@adeu/core 1.26.0 → 1.27.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 +59 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +59 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.ts +93 -6
- package/src/mapper.ts +9 -1
- package/src/repro_qa_report_v8.test.ts +265 -0
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -1423,6 +1423,12 @@ export class RedlineEngine {
|
|
|
1423
1423
|
// authoritative: strip inherited bold/italic from the anchor style
|
|
1424
1424
|
// (QA 2026-07-19 F-02).
|
|
1425
1425
|
suppress_emphasis: boolean = false,
|
|
1426
|
+
// True when the caller will attach the insertion BEFORE the anchor
|
|
1427
|
+
// (paragraph-start insertions): the anchor itself then belongs to the
|
|
1428
|
+
// relocating suffix (hunt-profile counterexample, 2026-07-19 —
|
|
1429
|
+
// "00." + insert "0.\n\n0 " must read "0.\n\n0 00.", never
|
|
1430
|
+
// "0.00.\n\n0 "). Mirrors the Python engine.
|
|
1431
|
+
insert_before: boolean = false,
|
|
1426
1432
|
): {
|
|
1427
1433
|
first_node: Element | null;
|
|
1428
1434
|
last_p: Element | null;
|
|
@@ -1459,12 +1465,21 @@ export class RedlineEngine {
|
|
|
1459
1465
|
// DOM, and the insertion lands immediately after it, so its following
|
|
1460
1466
|
// child-of-paragraph siblings are exactly the suffix.
|
|
1461
1467
|
const suffix_nodes: Element[] = [];
|
|
1462
|
-
const
|
|
1468
|
+
const relocatable = new Set(["w:r", "w:ins", "w:del"]);
|
|
1469
|
+
// insert_before with a RUN anchor: the insertion precedes the anchor, so
|
|
1470
|
+
// the anchor run itself is part of the suffix. (The explicit
|
|
1471
|
+
// positional_anchor_el is only passed by flows that insert AFTER it.)
|
|
1472
|
+
const pos_from_positional =
|
|
1463
1473
|
positional_anchor_el && positional_anchor_el.parentNode
|
|
1464
1474
|
? positional_anchor_el
|
|
1465
|
-
:
|
|
1466
|
-
|
|
1467
|
-
|
|
1475
|
+
: null;
|
|
1476
|
+
const pos_from_anchor_run =
|
|
1477
|
+
anchor_run !== null && anchor_run._element.parentNode
|
|
1478
|
+
? anchor_run._element
|
|
1479
|
+
: null;
|
|
1480
|
+
const pos_source = pos_from_positional ?? pos_from_anchor_run;
|
|
1481
|
+
const suffix_includes_anchor =
|
|
1482
|
+
insert_before && pos_from_positional === null && pos_from_anchor_run !== null;
|
|
1468
1483
|
if (current_p !== null && pos_source !== null) {
|
|
1469
1484
|
let pos_anchor: Element | null = pos_source;
|
|
1470
1485
|
while (pos_anchor && pos_anchor.parentNode !== current_p) {
|
|
@@ -1475,8 +1490,9 @@ export class RedlineEngine {
|
|
|
1475
1490
|
}
|
|
1476
1491
|
}
|
|
1477
1492
|
if (pos_anchor) {
|
|
1478
|
-
|
|
1479
|
-
|
|
1493
|
+
let nxt: Node | null = suffix_includes_anchor
|
|
1494
|
+
? pos_anchor
|
|
1495
|
+
: pos_anchor.nextSibling;
|
|
1480
1496
|
while (nxt) {
|
|
1481
1497
|
if (nxt.nodeType === 1 && relocatable.has((nxt as Element).tagName)) {
|
|
1482
1498
|
suffix_nodes.push(nxt as Element);
|
|
@@ -1484,6 +1500,20 @@ export class RedlineEngine {
|
|
|
1484
1500
|
nxt = nxt.nextSibling;
|
|
1485
1501
|
}
|
|
1486
1502
|
}
|
|
1503
|
+
} else if (current_p !== null && insert_before) {
|
|
1504
|
+
// No attached anchor run at all (paragraph-anchored insertion at
|
|
1505
|
+
// paragraph START): everything in the host paragraph follows the
|
|
1506
|
+
// insertion point, so it all relocates (mirrors the Python engine).
|
|
1507
|
+
let child = current_p.firstChild;
|
|
1508
|
+
while (child) {
|
|
1509
|
+
if (
|
|
1510
|
+
child.nodeType === 1 &&
|
|
1511
|
+
relocatable.has((child as Element).tagName)
|
|
1512
|
+
) {
|
|
1513
|
+
suffix_nodes.push(child as Element);
|
|
1514
|
+
}
|
|
1515
|
+
child = child.nextSibling;
|
|
1516
|
+
}
|
|
1487
1517
|
}
|
|
1488
1518
|
|
|
1489
1519
|
// Drop the trailing empty line ONLY when there is no suffix to relocate.
|
|
@@ -2523,6 +2553,60 @@ export class RedlineEngine {
|
|
|
2523
2553
|
|
|
2524
2554
|
public validate_review_actions(actions: any[]): string[] {
|
|
2525
2555
|
const errors: string[] = [];
|
|
2556
|
+
|
|
2557
|
+
// Document-context-free shape checks (QA 2026-07-19 v8 F-07), mirroring
|
|
2558
|
+
// Python's validate_review_action_batch: blank replies render as empty
|
|
2559
|
+
// Word comment bubbles; a duplicated or conflicting accept/reject on one
|
|
2560
|
+
// target_id either double-counts as "applied" or contradicts itself.
|
|
2561
|
+
// Distinct IDs one action resolves as a group (a modification's del+ins
|
|
2562
|
+
// pair) stay legitimate, as do DIFFERENT replies to the same comment.
|
|
2563
|
+
const seen_resolutions = new Map<string, [number, string]>();
|
|
2564
|
+
const seen_replies = new Set<string>();
|
|
2565
|
+
for (let i = 0; i < actions.length; i++) {
|
|
2566
|
+
const action = actions[i];
|
|
2567
|
+
const type = action.type;
|
|
2568
|
+
const target_id = action.target_id ?? "";
|
|
2569
|
+
if (type === "reply") {
|
|
2570
|
+
if (!String(action.text ?? "").trim()) {
|
|
2571
|
+
errors.push(
|
|
2572
|
+
`- Action ${i + 1} Failed: reply text for ${target_id} is empty or ` +
|
|
2573
|
+
`whitespace-only. Word would show a blank comment bubble — provide the ` +
|
|
2574
|
+
`reply content in 'text'.`,
|
|
2575
|
+
);
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
const reply_key = `${target_id}${String(action.text).trim()}`;
|
|
2579
|
+
if (seen_replies.has(reply_key)) {
|
|
2580
|
+
errors.push(
|
|
2581
|
+
`- Action ${i + 1} Failed: duplicate reply — this batch already replies to ` +
|
|
2582
|
+
`${target_id} with the same text. Remove the duplicate action.`,
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
seen_replies.add(reply_key);
|
|
2586
|
+
} else if (type === "accept" || type === "reject") {
|
|
2587
|
+
const prior = seen_resolutions.get(target_id);
|
|
2588
|
+
if (prior !== undefined) {
|
|
2589
|
+
const [first_idx, first_type] = prior;
|
|
2590
|
+
if (first_type === type) {
|
|
2591
|
+
errors.push(
|
|
2592
|
+
`- Action ${i + 1} Failed: duplicate action — Action ${first_idx + 1} in this ` +
|
|
2593
|
+
`batch already applies '${type}' to ${target_id}. A change can only be ` +
|
|
2594
|
+
`resolved once; remove the duplicate action.`,
|
|
2595
|
+
);
|
|
2596
|
+
} else {
|
|
2597
|
+
errors.push(
|
|
2598
|
+
`- Action ${i + 1} Failed: conflicting actions — Action ${first_idx + 1} in ` +
|
|
2599
|
+
`this batch applies '${first_type}' to ${target_id}, but this action applies ` +
|
|
2600
|
+
`'${type}'. Decide the outcome and keep exactly one of them.`,
|
|
2601
|
+
);
|
|
2602
|
+
}
|
|
2603
|
+
} else {
|
|
2604
|
+
seen_resolutions.set(target_id, [i, type]);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
if (errors.length > 0) return errors;
|
|
2609
|
+
|
|
2526
2610
|
for (let i = 0; i < actions.length; i++) {
|
|
2527
2611
|
const action = actions[i];
|
|
2528
2612
|
const type = action.type;
|
|
@@ -4161,6 +4245,9 @@ export class RedlineEngine {
|
|
|
4161
4245
|
ins_id!,
|
|
4162
4246
|
null,
|
|
4163
4247
|
suppress_emphasis,
|
|
4248
|
+
// Paragraph-start insertions attach BEFORE the anchor (see
|
|
4249
|
+
// before_anchor below): the suffix relocation must know.
|
|
4250
|
+
start_idx === 0,
|
|
4164
4251
|
);
|
|
4165
4252
|
|
|
4166
4253
|
if (!result.first_node) return false;
|
package/src/mapper.ts
CHANGED
|
@@ -1172,8 +1172,16 @@ export class DocumentMapper {
|
|
|
1172
1172
|
const first_real_span = real_spans[0];
|
|
1173
1173
|
let start_split_adjustment = 0;
|
|
1174
1174
|
|
|
1175
|
+
// A range may START on a virtual span (word-diff hunks absorb a style
|
|
1176
|
+
// marker adjacent to real changes, e.g. the `**` closing a bold run).
|
|
1177
|
+
// Virtual characters have no physical width: clamp to the first real
|
|
1178
|
+
// span's start or the subtraction goes negative and the split point
|
|
1179
|
+
// lands INSIDE the preceding run's kept text — the "**The Suppli**"
|
|
1180
|
+
// partial-word artifact (QA 2026-07-19 v8 F-04).
|
|
1175
1181
|
const local_start =
|
|
1176
|
-
start_idx
|
|
1182
|
+
Math.max(start_idx, first_real_span.start) -
|
|
1183
|
+
first_real_span.start +
|
|
1184
|
+
(first_real_span.run_offset || 0);
|
|
1177
1185
|
if (local_start > 0) {
|
|
1178
1186
|
const split_source = working_runs[0];
|
|
1179
1187
|
const [, right_run] = this._split_run_at_index(
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// FILE: node/packages/core/src/repro_qa_report_v8.test.ts
|
|
2
|
+
/**
|
|
3
|
+
* Node-engine repro tests for the 2026-07-19 black-box QA and UX report on
|
|
4
|
+
* 1.26.0 (adeu 1.26.0+0741eaf). Mirrors python/tests/test_repro_qa_report_v8.py
|
|
5
|
+
* for the findings that live in the shared core engine:
|
|
6
|
+
*
|
|
7
|
+
* F-04 replacing a full sentence that crosses bold/italic formatting runs
|
|
8
|
+
* leaves a partial word bold (`**The Suppli** must perform ...`):
|
|
9
|
+
* the word-diff hunk absorbs the closing `**` marker, the resolved
|
|
10
|
+
* range starts on a virtual span, and the run-local split offset
|
|
11
|
+
* underflows into the preceding run's kept text
|
|
12
|
+
* F-07 review-action validation permits blank replies and duplicate /
|
|
13
|
+
* conflicting accept-reject pairs on one target_id
|
|
14
|
+
*
|
|
15
|
+
* Every test fails against the commit preceding its fix.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from "vitest";
|
|
19
|
+
import { createTestDocument, addParagraph } from "./test-utils.js";
|
|
20
|
+
import { DocumentObject } from "./docx/bridge.js";
|
|
21
|
+
import { RedlineEngine, BatchValidationError } from "./engine.js";
|
|
22
|
+
import { _extractTextFromDoc, extractTextFromBuffer } from "./ingest.js";
|
|
23
|
+
|
|
24
|
+
function addStyledRun(
|
|
25
|
+
p: Element,
|
|
26
|
+
text: string,
|
|
27
|
+
style: { bold?: boolean; italic?: boolean; underline?: boolean } = {},
|
|
28
|
+
): Element {
|
|
29
|
+
const xmlDoc = p.ownerDocument!;
|
|
30
|
+
const r = xmlDoc.createElement("w:r");
|
|
31
|
+
if (style.bold || style.italic || style.underline) {
|
|
32
|
+
const rPr = xmlDoc.createElement("w:rPr");
|
|
33
|
+
if (style.bold) rPr.appendChild(xmlDoc.createElement("w:b"));
|
|
34
|
+
if (style.italic) rPr.appendChild(xmlDoc.createElement("w:i"));
|
|
35
|
+
if (style.underline) {
|
|
36
|
+
const u = xmlDoc.createElement("w:u");
|
|
37
|
+
u.setAttribute("w:val", "single");
|
|
38
|
+
rPr.appendChild(u);
|
|
39
|
+
}
|
|
40
|
+
r.appendChild(rPr);
|
|
41
|
+
}
|
|
42
|
+
const t = xmlDoc.createElement("w:t");
|
|
43
|
+
t.textContent = text;
|
|
44
|
+
if (text !== text.trim()) t.setAttribute("xml:space", "preserve");
|
|
45
|
+
r.appendChild(t);
|
|
46
|
+
p.appendChild(r);
|
|
47
|
+
return r;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function addEmptyParagraph(doc: DocumentObject): Element {
|
|
51
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
52
|
+
const p = xmlDoc.createElement("w:p");
|
|
53
|
+
doc.element.appendChild(p);
|
|
54
|
+
return p;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The QA report's F-04 fixture: bold "The Supplier ", italic
|
|
58
|
+
* "shall provide ", underlined remainder — one sentence, three runs. */
|
|
59
|
+
async function buildCrossRunDoc(): Promise<DocumentObject> {
|
|
60
|
+
const doc = await createTestDocument();
|
|
61
|
+
const p = addEmptyParagraph(doc);
|
|
62
|
+
addStyledRun(p, "The Supplier ", { bold: true });
|
|
63
|
+
addStyledRun(p, "shall provide ", { italic: true });
|
|
64
|
+
addStyledRun(p, "the Services with reasonable skill and care.", {
|
|
65
|
+
underline: true,
|
|
66
|
+
});
|
|
67
|
+
return doc;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cleanText(doc: DocumentObject): string {
|
|
71
|
+
return _extractTextFromDoc(doc, {
|
|
72
|
+
cleanView: true,
|
|
73
|
+
includeAppendix: false,
|
|
74
|
+
}) as string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A document carrying exactly one tracked modification (Chg:1 + Chg:2). */
|
|
78
|
+
async function buildTrackedChangeDoc(): Promise<DocumentObject> {
|
|
79
|
+
const doc = await createTestDocument();
|
|
80
|
+
addParagraph(doc, "Payment is due in 30 days.");
|
|
81
|
+
addParagraph(doc, "Second paragraph here.");
|
|
82
|
+
const engine = new RedlineEngine(doc);
|
|
83
|
+
engine.apply_edits([
|
|
84
|
+
{ type: "modify", target_text: "30 days", new_text: "60 days" },
|
|
85
|
+
]);
|
|
86
|
+
return DocumentObject.load(await doc.save());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A document carrying exactly one comment; returns [doc, "Com:<id>"]. */
|
|
90
|
+
async function buildCommentDoc(): Promise<[DocumentObject, string]> {
|
|
91
|
+
const doc = await createTestDocument();
|
|
92
|
+
addParagraph(doc, "Alpha beta gamma.");
|
|
93
|
+
addParagraph(doc, "Delta epsilon.");
|
|
94
|
+
const engine = new RedlineEngine(doc);
|
|
95
|
+
engine.process_batch([
|
|
96
|
+
{
|
|
97
|
+
type: "modify",
|
|
98
|
+
target_text: "Alpha beta gamma.",
|
|
99
|
+
new_text: "Alpha beta gamma.",
|
|
100
|
+
comment: "Please review.",
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
const buf = await doc.save();
|
|
104
|
+
const reloaded = await DocumentObject.load(buf);
|
|
105
|
+
const projected = await extractTextFromBuffer(buf);
|
|
106
|
+
const m = projected.match(/\[Com:(\d+)/);
|
|
107
|
+
expect(m, `expected a comment id in ${projected}`).toBeTruthy();
|
|
108
|
+
return [reloaded, `Com:${m![1]}`];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// F-04: full-span replacements across formatting runs keep words whole
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe("QA v8 F-04: cross-run full-span replacement fidelity", () => {
|
|
116
|
+
const TARGET =
|
|
117
|
+
"The Supplier shall provide the Services with reasonable skill and care.";
|
|
118
|
+
const NEW = "The Supplier must perform the Services professionally.";
|
|
119
|
+
|
|
120
|
+
it("leaves no partial-word bold after the replacement", async () => {
|
|
121
|
+
const doc = await buildCrossRunDoc();
|
|
122
|
+
const engine = new RedlineEngine(doc);
|
|
123
|
+
const stats = engine.process_batch([
|
|
124
|
+
{ type: "modify", target_text: TARGET, new_text: NEW },
|
|
125
|
+
]);
|
|
126
|
+
expect(stats.edits_applied).toBe(1);
|
|
127
|
+
|
|
128
|
+
const clean = cleanText(doc);
|
|
129
|
+
expect(clean).not.toContain("**The Suppli**");
|
|
130
|
+
expect(clean).not.toContain("Suppli**");
|
|
131
|
+
expect(clean.trim()).toBe(
|
|
132
|
+
"**The Supplier** must perform the Services professionally.",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("accepted document matches the clean view", async () => {
|
|
137
|
+
const doc = await buildCrossRunDoc();
|
|
138
|
+
const engine = new RedlineEngine(doc);
|
|
139
|
+
engine.process_batch([
|
|
140
|
+
{ type: "modify", target_text: TARGET, new_text: NEW },
|
|
141
|
+
]);
|
|
142
|
+
engine.accept_all_revisions();
|
|
143
|
+
const accepted = _extractTextFromDoc(doc, {
|
|
144
|
+
cleanView: false,
|
|
145
|
+
includeAppendix: false,
|
|
146
|
+
}) as string;
|
|
147
|
+
expect(accepted.trim()).toBe(
|
|
148
|
+
"**The Supplier** must perform the Services professionally.",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Hunt-profile counterexample (python property suite, 2026-07-19): a
|
|
155
|
+
// paragraph-splitting insertion at paragraph START must relocate the host
|
|
156
|
+
// paragraph's content into the LAST new paragraph. Deterministic pin,
|
|
157
|
+
// mirrored here because both engines shared the suffix-relocation logic.
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
describe("QA v8: paragraph-start splitting insertion", () => {
|
|
161
|
+
it("relocates the host paragraph content into the last new paragraph", async () => {
|
|
162
|
+
const doc = await createTestDocument();
|
|
163
|
+
addParagraph(doc, "00.");
|
|
164
|
+
const engine = new RedlineEngine(doc);
|
|
165
|
+
const edit: any = {
|
|
166
|
+
type: "modify",
|
|
167
|
+
target_text: "",
|
|
168
|
+
new_text: "0.\n\n0 ",
|
|
169
|
+
_match_start_index: 0,
|
|
170
|
+
};
|
|
171
|
+
engine.apply_edits([edit]);
|
|
172
|
+
engine.accept_all_revisions();
|
|
173
|
+
const final = _extractTextFromDoc(doc, {
|
|
174
|
+
cleanView: true,
|
|
175
|
+
includeAppendix: false,
|
|
176
|
+
}) as string;
|
|
177
|
+
expect(final.trim()).toBe("0.\n\n0 00.");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// F-07: blank replies and duplicate review actions are rejected
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
describe("QA v8 F-07: review-action validation", () => {
|
|
186
|
+
it("rejects a blank reply", async () => {
|
|
187
|
+
const [doc, comId] = await buildCommentDoc();
|
|
188
|
+
const engine = new RedlineEngine(doc);
|
|
189
|
+
expect(() =>
|
|
190
|
+
engine.process_batch([{ type: "reply", target_id: comId, text: " " }]),
|
|
191
|
+
).toThrowError(BatchValidationError);
|
|
192
|
+
try {
|
|
193
|
+
engine.process_batch([{ type: "reply", target_id: comId, text: " " }]);
|
|
194
|
+
} catch (e: any) {
|
|
195
|
+
expect(e.errors.join("\n").toLowerCase()).toContain("empty");
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects a duplicate accept of the same target_id", async () => {
|
|
200
|
+
const doc = await buildTrackedChangeDoc();
|
|
201
|
+
const engine = new RedlineEngine(doc);
|
|
202
|
+
try {
|
|
203
|
+
engine.process_batch([
|
|
204
|
+
{ type: "accept", target_id: "Chg:1" },
|
|
205
|
+
{ type: "accept", target_id: "Chg:1" },
|
|
206
|
+
]);
|
|
207
|
+
expect.unreachable("duplicate accept must be rejected");
|
|
208
|
+
} catch (e: any) {
|
|
209
|
+
expect(e).toBeInstanceOf(BatchValidationError);
|
|
210
|
+
expect(e.errors.join("\n").toLowerCase()).toContain("duplicate");
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("rejects conflicting accept + reject on one target_id", async () => {
|
|
215
|
+
const doc = await buildTrackedChangeDoc();
|
|
216
|
+
const engine = new RedlineEngine(doc);
|
|
217
|
+
try {
|
|
218
|
+
engine.process_batch([
|
|
219
|
+
{ type: "accept", target_id: "Chg:1" },
|
|
220
|
+
{ type: "reject", target_id: "Chg:1" },
|
|
221
|
+
]);
|
|
222
|
+
expect.unreachable("conflicting actions must be rejected");
|
|
223
|
+
} catch (e: any) {
|
|
224
|
+
expect(e).toBeInstanceOf(BatchValidationError);
|
|
225
|
+
expect(e.errors.join("\n").toLowerCase()).toContain("conflict");
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("rejects a duplicated identical reply", async () => {
|
|
230
|
+
const [doc, comId] = await buildCommentDoc();
|
|
231
|
+
const engine = new RedlineEngine(doc);
|
|
232
|
+
try {
|
|
233
|
+
engine.process_batch([
|
|
234
|
+
{ type: "reply", target_id: comId, text: "Same reply." },
|
|
235
|
+
{ type: "reply", target_id: comId, text: "Same reply." },
|
|
236
|
+
]);
|
|
237
|
+
expect.unreachable("duplicate identical reply must be rejected");
|
|
238
|
+
} catch (e: any) {
|
|
239
|
+
expect(e).toBeInstanceOf(BatchValidationError);
|
|
240
|
+
expect(e.errors.join("\n").toLowerCase()).toContain("duplicate");
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("still allows distinct replies to the same comment", async () => {
|
|
245
|
+
const [doc, comId] = await buildCommentDoc();
|
|
246
|
+
const engine = new RedlineEngine(doc);
|
|
247
|
+
const stats = engine.process_batch([
|
|
248
|
+
{ type: "reply", target_id: comId, text: "First reply." },
|
|
249
|
+
{ type: "reply", target_id: comId, text: "Second reply." },
|
|
250
|
+
]);
|
|
251
|
+
expect(stats.actions_applied).toBe(2);
|
|
252
|
+
expect(stats.actions_skipped).toBe(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("still allows accepting the del+ins pair of one modification", async () => {
|
|
256
|
+
const doc = await buildTrackedChangeDoc();
|
|
257
|
+
const engine = new RedlineEngine(doc);
|
|
258
|
+
const stats = engine.process_batch([
|
|
259
|
+
{ type: "accept", target_id: "Chg:1" },
|
|
260
|
+
{ type: "accept", target_id: "Chg:2" },
|
|
261
|
+
]);
|
|
262
|
+
expect(stats.actions_skipped).toBe(0);
|
|
263
|
+
expect(cleanText(doc)).toContain("60 days");
|
|
264
|
+
});
|
|
265
|
+
});
|