@adeu/core 1.17.1 → 1.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.17.1",
3
+ "version": "1.18.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -418,7 +418,7 @@ describe("Resolved Bugs Core Engine Verification", () => {
418
418
  expect(pTarget.parentNode).toBeNull();
419
419
  });
420
420
 
421
- it("BUG-EXPLORE-2: Nested redline validation error includes actionable hint", async () => {
421
+ it("BUG-EXPLORE-2: a strict edit inside a foreign author's insertion applies (nested change)", async () => {
422
422
  const doc = await createTestDocument();
423
423
  addParagraph(doc, "Original baseline.");
424
424
 
@@ -432,20 +432,18 @@ describe("Resolved Bugs Core Engine Verification", () => {
432
432
  },
433
433
  ]);
434
434
 
435
- // Author B tries to modify Author A's pending insertion
435
+ // Author B modifies Author A's pending insertion — this now applies: the
436
+ // enclosing <w:ins> is split and Author B's change nested inside it.
436
437
  const engineB = new RedlineEngine(doc, "Author B");
437
-
438
- expect(() => {
439
- engineB.process_batch([
440
- {
441
- type: "modify",
442
- target_text: "Inserted by A.",
443
- new_text: "Modified by B.",
444
- },
445
- ]);
446
- }).toThrowError(
447
- /Accept that change first or scope your edit outside of it/,
448
- );
438
+ const result = engineB.process_batch([
439
+ {
440
+ type: "modify",
441
+ target_text: "Inserted by A.",
442
+ new_text: "Modified by B.",
443
+ },
444
+ ]);
445
+ expect(result.edits_applied).toBe(1);
446
+ expect(result.edits_skipped).toBe(0);
449
447
  });
450
448
 
451
449
  it("BUG-CROSS-PARA-1: Cross-paragraph modify coalesces paragraphs and tracks para-mark deletion", async () => {
@@ -422,31 +422,36 @@ describe("BUG-23-4: multi-paragraph target_text must produce actionable feedback
422
422
  }
423
423
  });
424
424
 
425
- it("BUG-23-4-NN: rejects plain-paragraph N->N modifications spanning a paragraph boundary to prevent silent corruption", async () => {
425
+ it("BUG-23-4-NN: applies balanced plain-paragraph N->N modifications per paragraph, preserving the boundary", async () => {
426
426
  const doc = await createTestDocument();
427
427
  addParagraph(doc, "Clause 1 ends here.");
428
428
  addParagraph(doc, "Clause 2 begins here.");
429
429
 
430
430
  const engine = new RedlineEngine(doc, "Test Author");
431
431
 
432
- let raised: any = null;
433
- try {
434
- engine.process_batch([
435
- {
436
- type: "modify",
437
- target_text: "ends here.\n\nClause 2 begins",
438
- new_text: "ends here. MERGED\n\nClause 2 begins CHANGED",
439
- },
440
- ]);
441
- } catch (e: any) {
442
- raised = e;
443
- }
432
+ // Both target and replacement carry the same number of \n\n breaks, so the
433
+ // paragraph structure is preserved. The engine splits this into one sub-edit
434
+ // per paragraph and applies it as a single logical edit. (Unbalanced
435
+ // merges/splits are still rejected — see the regex case in engine.qa.test.)
436
+ const result = engine.process_batch([
437
+ {
438
+ type: "modify",
439
+ target_text: "ends here.\n\nClause 2 begins",
440
+ new_text: "ends here. MERGED\n\nClause 2 begins CHANGED",
441
+ },
442
+ ]);
444
443
 
445
- expect(raised).not.toBeNull();
446
- if (raised) {
447
- expect(raised.name).toBe("BatchValidationError");
448
- expect(raised.message.toLowerCase()).toContain("paragraph boundary");
449
- }
444
+ expect(result.edits_applied).toBe(1);
445
+ expect(result.edits_skipped).toBe(0);
446
+
447
+ const buf = await doc.save();
448
+ const text = await extractTextFromBuffer(buf);
449
+ // The paragraph boundary must survive — the two clauses are NOT merged.
450
+ expect(text).not.toContain("ends here.Clause 2 begins");
451
+ expect(text).not.toContain("ends here. Clause 2 begins");
452
+ // Both per-paragraph insertions land.
453
+ expect(text).toContain("MERGED");
454
+ expect(text).toContain("CHANGED");
450
455
  });
451
456
  });
452
457
 
@@ -83,7 +83,7 @@ describe("QA Report V2: Engine Logic", () => {
83
83
  target_text: "Occurrence",
84
84
  new_text: "Match",
85
85
  match_mode: "all"
86
- };
86
+ } as any;
87
87
  const stats = engine.process_batch([edit], false);
88
88
 
89
89
  const report = stats.edits[0];
@@ -116,7 +116,7 @@ describe("QA Report V2: Engine Logic", () => {
116
116
  target_text: "Target word",
117
117
  new_text: "Replaced",
118
118
  match_mode: "all"
119
- };
119
+ } as any;
120
120
 
121
121
  expect(() => engine.process_batch([edit], false)).toThrow(/targets an active insertion from another author/);
122
122
  });
@@ -132,7 +132,7 @@ describe("QA Report V2: Engine Logic", () => {
132
132
  target_text: "part.\\n\\nSecond",
133
133
  new_text: "merged",
134
134
  regex: true
135
- };
135
+ } as any;
136
136
 
137
137
  expect(() => engine.process_batch([edit], false)).toThrow(/spans a paragraph boundary with body text on both sides/);
138
138
  });
@@ -155,7 +155,7 @@ describe("QA Report V2: Engine Logic", () => {
155
155
  target_text: "the Board of Directors",
156
156
  new_text: "the Supervisory Board",
157
157
  match_mode: "all"
158
- };
158
+ } as any;
159
159
 
160
160
  expect(() => engineBob.process_batch([editBob], false)).toThrow(/another author/i);
161
161
  });
@@ -1,12 +1,24 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { createTestDocument, addParagraph, addTable, setCellText, mergeCells, addNestedTable } from './test-utils.js';
3
- import { DocumentObject } from './docx/bridge.js';
4
- import { extractTextFromBuffer } from './ingest.js';
5
- import { RedlineEngine } from './engine.js';
6
- import { ModifyText, InsertTableRow, DeleteTableRow, RejectChange } from './models.js';
7
-
8
- describe('Table Interop & Engine (Node.js Port)', () => {
9
- it('interleaved tables and text remain ordered', async () => {
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ createTestDocument,
4
+ addParagraph,
5
+ addTable,
6
+ setCellText,
7
+ mergeCells,
8
+ addNestedTable,
9
+ } from "./test-utils.js";
10
+ import { DocumentObject } from "./docx/bridge.js";
11
+ import { extractTextFromBuffer } from "./ingest.js";
12
+ import { RedlineEngine } from "./engine.js";
13
+ import {
14
+ ModifyText,
15
+ InsertTableRow,
16
+ DeleteTableRow,
17
+ RejectChange,
18
+ } from "./models.js";
19
+
20
+ describe("Table Interop & Engine (Node.js Port)", () => {
21
+ it("interleaved tables and text remain ordered", async () => {
10
22
  const doc = await createTestDocument();
11
23
  addParagraph(doc, "Section 1");
12
24
  const tbl = addTable(doc, 1, 1);
@@ -28,13 +40,17 @@ describe('Table Interop & Engine (Node.js Port)', () => {
28
40
  expect(tIdx).toBeLessThan(p2);
29
41
  });
30
42
 
31
- it('extracts and edits nested tables correctly', async () => {
43
+ it("extracts and edits nested tables correctly", async () => {
32
44
  const doc = await createTestDocument();
33
45
  const outerTbl = addTable(doc, 1, 1);
34
-
35
- const rows = Array.from(outerTbl.childNodes).filter(n => (n as Element).tagName === 'w:tr') as Element[];
36
- const cells = Array.from(rows[0].childNodes).filter(n => (n as Element).tagName === 'w:tc') as Element[];
37
-
46
+
47
+ const rows = Array.from(outerTbl.childNodes).filter(
48
+ (n) => (n as Element).tagName === "w:tr",
49
+ ) as Element[];
50
+ const cells = Array.from(rows[0].childNodes).filter(
51
+ (n) => (n as Element).tagName === "w:tc",
52
+ ) as Element[];
53
+
38
54
  const nestedTbl = addNestedTable(cells[0], 1, 1);
39
55
  setCellText(nestedTbl, 0, 0, "InnerSecret");
40
56
 
@@ -44,7 +60,13 @@ describe('Table Interop & Engine (Node.js Port)', () => {
44
60
 
45
61
  const midDoc = await DocumentObject.load(buf);
46
62
  const engine = new RedlineEngine(midDoc);
47
- const [applied] = engine.apply_edits([{ type: 'modify', target_text: "InnerSecret", new_text: "OuterSecret" } as ModifyText]);
63
+ const [applied] = engine.apply_edits([
64
+ {
65
+ type: "modify",
66
+ target_text: "InnerSecret",
67
+ new_text: "OuterSecret",
68
+ } as ModifyText,
69
+ ]);
48
70
  expect(applied).toBe(1);
49
71
 
50
72
  const finalBuf = await midDoc.save();
@@ -52,11 +74,11 @@ describe('Table Interop & Engine (Node.js Port)', () => {
52
74
  expect(final_text).toContain("{--InnerSecret--}{++OuterSecret++}");
53
75
  });
54
76
 
55
- it('merged cells do not duplicate content extraction', async () => {
77
+ it("merged cells do not duplicate content extraction", async () => {
56
78
  const doc = await createTestDocument();
57
79
  const tbl = addTable(doc, 1, 2);
58
80
  setCellText(tbl, 0, 0, "MergedUnique");
59
-
81
+
60
82
  // Simulate python-docx's cell.merge(cell)
61
83
  mergeCells(tbl, 0, 0, 1);
62
84
 
@@ -68,11 +90,17 @@ describe('Table Interop & Engine (Node.js Port)', () => {
68
90
 
69
91
  const midDoc = await DocumentObject.load(buf);
70
92
  const engine = new RedlineEngine(midDoc);
71
- const [applied] = engine.apply_edits([{ type: 'modify', target_text: "MergedUnique", new_text: "ChangedUnique" } as ModifyText]);
93
+ const [applied] = engine.apply_edits([
94
+ {
95
+ type: "modify",
96
+ target_text: "MergedUnique",
97
+ new_text: "ChangedUnique",
98
+ } as ModifyText,
99
+ ]);
72
100
  expect(applied).toBe(1);
73
101
  });
74
102
 
75
- it('empty row mapping alignment stays synchronized', async () => {
103
+ it("empty row mapping alignment stays synchronized", async () => {
76
104
  const doc = await createTestDocument();
77
105
  const tbl = addTable(doc, 3, 1);
78
106
  setCellText(tbl, 0, 0, "RowA");
@@ -81,10 +109,12 @@ describe('Table Interop & Engine (Node.js Port)', () => {
81
109
 
82
110
  const buf = await doc.save();
83
111
  const midDoc = await DocumentObject.load(buf);
84
-
112
+
85
113
  const engine = new RedlineEngine(midDoc);
86
- const [applied] = engine.apply_edits([{ type: 'modify', target_text: "RowB", new_text: "RowC" } as ModifyText]);
87
-
114
+ const [applied] = engine.apply_edits([
115
+ { type: "modify", target_text: "RowB", new_text: "RowC" } as ModifyText,
116
+ ]);
117
+
88
118
  expect(applied).toBe(1);
89
119
 
90
120
  const resBuf = await midDoc.save();
@@ -94,18 +124,25 @@ describe('Table Interop & Engine (Node.js Port)', () => {
94
124
  expect(resText).toContain("{--RowB--}{++RowC++}");
95
125
  });
96
126
 
97
- it('inserts table row below', async () => {
127
+ it("inserts table row below", async () => {
98
128
  const doc = await createTestDocument();
99
129
  const tbl = addTable(doc, 2, 2);
100
- setCellText(tbl, 0, 0, "A1"); setCellText(tbl, 0, 1, "A2");
101
- setCellText(tbl, 1, 0, "B1"); setCellText(tbl, 1, 1, "B2");
130
+ setCellText(tbl, 0, 0, "A1");
131
+ setCellText(tbl, 0, 1, "A2");
132
+ setCellText(tbl, 1, 0, "B1");
133
+ setCellText(tbl, 1, 1, "B2");
102
134
 
103
135
  const buf = await doc.save();
104
136
  const midDoc = await DocumentObject.load(buf);
105
137
 
106
138
  const engine = new RedlineEngine(midDoc);
107
139
  const stats = engine.process_batch([
108
- { type: 'insert_row', target_text: "A1 | A2", position: "below", cells: ["New B1", "New B2"] } as InsertTableRow
140
+ {
141
+ type: "insert_row",
142
+ target_text: "A1 | A2",
143
+ position: "below",
144
+ cells: ["New B1", "New B2"],
145
+ } as InsertTableRow,
109
146
  ]);
110
147
 
111
148
  expect(stats.edits_applied).toBe(1);
@@ -120,18 +157,23 @@ describe('Table Interop & Engine (Node.js Port)', () => {
120
157
  expect(clean_text).toContain("B1 | B2");
121
158
  });
122
159
 
123
- it('deletes table row', async () => {
160
+ it("deletes table row", async () => {
124
161
  const doc = await createTestDocument();
125
162
  const tbl = addTable(doc, 3, 2);
126
- setCellText(tbl, 0, 0, "A1"); setCellText(tbl, 0, 1, "A2");
127
- setCellText(tbl, 1, 0, "B1"); setCellText(tbl, 1, 1, "B2");
128
- setCellText(tbl, 2, 0, "C1"); setCellText(tbl, 2, 1, "C2");
163
+ setCellText(tbl, 0, 0, "A1");
164
+ setCellText(tbl, 0, 1, "A2");
165
+ setCellText(tbl, 1, 0, "B1");
166
+ setCellText(tbl, 1, 1, "B2");
167
+ setCellText(tbl, 2, 0, "C1");
168
+ setCellText(tbl, 2, 1, "C2");
129
169
 
130
170
  const buf = await doc.save();
131
171
  const midDoc = await DocumentObject.load(buf);
132
172
 
133
173
  const engine = new RedlineEngine(midDoc);
134
- const stats = engine.process_batch([{ type: 'delete_row', target_text: "B1" } as DeleteTableRow]);
174
+ const stats = engine.process_batch([
175
+ { type: "delete_row", target_text: "B1" } as DeleteTableRow,
176
+ ]);
135
177
 
136
178
  expect(stats.edits_applied).toBe(1);
137
179
 
@@ -144,17 +186,21 @@ describe('Table Interop & Engine (Node.js Port)', () => {
144
186
  expect(clean_text).toContain("C1 | C2");
145
187
  });
146
188
 
147
- it('clean view naturally omits deleted row', async () => {
189
+ it("clean view naturally omits deleted row", async () => {
148
190
  const doc = await createTestDocument();
149
191
  const tbl = addTable(doc, 2, 2);
150
- setCellText(tbl, 0, 0, "A1"); setCellText(tbl, 0, 1, "A2");
151
- setCellText(tbl, 1, 0, "B1"); setCellText(tbl, 1, 1, "B2");
192
+ setCellText(tbl, 0, 0, "A1");
193
+ setCellText(tbl, 0, 1, "A2");
194
+ setCellText(tbl, 1, 0, "B1");
195
+ setCellText(tbl, 1, 1, "B2");
152
196
 
153
197
  const buf = await doc.save();
154
198
  const midDoc = await DocumentObject.load(buf);
155
199
 
156
200
  const engine = new RedlineEngine(midDoc);
157
- engine.process_batch([{ type: 'delete_row', target_text: "B1" } as DeleteTableRow]);
201
+ engine.process_batch([
202
+ { type: "delete_row", target_text: "B1" } as DeleteTableRow,
203
+ ]);
158
204
 
159
205
  // Do NOT accept revisions, extract as Clean View directly
160
206
  const finalBuf = await midDoc.save();
@@ -163,4 +209,60 @@ describe('Table Interop & Engine (Node.js Port)', () => {
163
209
  expect(clean_text).toContain("A1 | A2");
164
210
  expect(clean_text).not.toContain("B1 | B2");
165
211
  });
166
- });
212
+ it("P0 Case 1: writes into an empty value cell via its {#cell:paraId} anchor", async () => {
213
+ const doc = await createTestDocument();
214
+ const tbl = addTable(doc, 1, 2);
215
+ setCellText(tbl, 0, 0, "Date");
216
+ // Leave cell (0,1) empty — mirrors the cloud-service-agreement form row.
217
+
218
+ // The empty cell's paragraph needs a w14:paraId for the anchor scheme.
219
+ const rows = Array.from(tbl.childNodes).filter(
220
+ (n) => (n as Element).tagName === "w:tr",
221
+ ) as Element[];
222
+ const cells = Array.from(rows[0].childNodes).filter(
223
+ (n) => (n as Element).tagName === "w:tc",
224
+ ) as Element[];
225
+ const emptyP = cells[1].getElementsByTagName("w:p")[0];
226
+ emptyP.setAttribute("w14:paraId", "DEADBEEF");
227
+
228
+ const buf = await doc.save();
229
+ const projected = await extractTextFromBuffer(buf, false);
230
+ // The anchor must be present and addressable.
231
+ expect(projected).toContain("{#cell:DEADBEEF}");
232
+
233
+ const midDoc = await DocumentObject.load(buf);
234
+ const engine = new RedlineEngine(midDoc);
235
+ // Target the anchor, insert text before it.
236
+ const stats = engine.process_batch([
237
+ {
238
+ type: "modify",
239
+ target_text: "{#cell:DEADBEEF}",
240
+ new_text: "June 22, 2026",
241
+ } as any,
242
+ ]);
243
+ expect(stats.edits_applied).toBe(1);
244
+
245
+ (engine as any).accept_all_revisions();
246
+ const cleanText = await extractTextFromBuffer(await midDoc.save(), true);
247
+ expect(cleanText).toContain("June 22, 2026");
248
+ });
249
+
250
+ it('P0 Case 2: anchored-regex miss on "( x )" yields a literal nearest-match hint', async () => {
251
+ const doc = await createTestDocument();
252
+ addParagraph(doc, "Some intro text. ( x ) Date of last signature.");
253
+ const engine = new RedlineEngine(doc);
254
+
255
+ const errors = engine.validate_edits([
256
+ {
257
+ type: "modify",
258
+ regex: true,
259
+ target_text: "^\\( x \\)$",
260
+ new_text: "",
261
+ } as any,
262
+ ]);
263
+ expect(errors.length).toBe(1);
264
+ expect(errors[0]).toContain("Target text not found");
265
+ expect(errors[0]).toContain('Did you mean the literal "( x )"');
266
+ expect(errors[0]).toContain("drop the ^/$ anchors");
267
+ });
268
+ });