@createiq/htmldiff 1.2.0-beta.0 → 1.2.0-beta.10

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.
@@ -2,67 +2,78 @@ import { describe, expect, it } from 'vitest'
2
2
 
3
3
  import HtmlDiff from '../src/HtmlDiff'
4
4
 
5
- describe('HtmlDiff.executeThreeWay (tables, positional case)', () => {
6
- describe('cell-level attribution propagates through table scaffolding', () => {
7
- it('CP-only edit inside a cell', () => {
5
+ /**
6
+ * Table tests under the genesis-spine model.
7
+ *
8
+ * `executeThreeWay(genesis, cpLatest, meCurrent)` walks the genesis
9
+ * table structure as the spine and attributes cp/me changes
10
+ * independently. Tables/rows/cells that both authors changed identically
11
+ * settle and emit plain; one-sided changes carry author attribution;
12
+ * disagreements show both authors' contributions.
13
+ */
14
+ describe('HtmlDiff.executeThreeWay (tables, genesis-spine)', () => {
15
+ describe('cell-level attribution through table scaffolding', () => {
16
+ it('CP only changed, Me kept genesis → cp attribution', () => {
8
17
  expect(
9
18
  HtmlDiff.executeThreeWay(
10
- '<table><tr><td>Rate</td><td>five</td></tr></table>',
11
- '<table><tr><td>Interest Rate</td><td>five</td></tr></table>',
12
- '<table><tr><td>Interest Rate</td><td>five</td></tr></table>'
19
+ '<table><tr><td>Rate</td></tr></table>',
20
+ '<table><tr><td>Interest Rate</td></tr></table>',
21
+ '<table><tr><td>Rate</td></tr></table>'
13
22
  )
14
- ).toBe(
15
- "<table><tr><td><ins class='diffins cp' data-author='cp'>Interest </ins>Rate</td><td>five</td></tr></table>"
16
- )
23
+ ).toBe("<table><tr><td><ins class='diffins cp' data-author='cp'>Interest </ins>Rate</td></tr></table>")
17
24
  })
18
25
 
19
- it('Me-only edit inside a cell', () => {
26
+ it('Me only changed, CP kept genesis → me attribution', () => {
20
27
  expect(
21
28
  HtmlDiff.executeThreeWay(
22
- '<table><tr><td>Rate</td><td>five</td></tr></table>',
23
- '<table><tr><td>Rate</td><td>five</td></tr></table>',
24
- '<table><tr><td>Rate</td><td>five percent</td></tr></table>'
29
+ '<table><tr><td>Rate</td></tr></table>',
30
+ '<table><tr><td>Rate</td></tr></table>',
31
+ '<table><tr><td>Rate per annum</td></tr></table>'
25
32
  )
26
- ).toBe(
27
- "<table><tr><td>Rate</td><td>five<ins class='diffins me' data-author='me'>&nbsp;percent</ins></td></tr></table>"
28
- )
33
+ ).toBe("<table><tr><td>Rate<ins class='diffins me' data-author='me'>&nbsp;per annum</ins></td></tr></table>")
29
34
  })
30
35
 
31
- it('Me keeps a CP cell insertion AND adds their own to a different cell', () => {
36
+ it('Settled both made the same change no markup', () => {
32
37
  expect(
33
38
  HtmlDiff.executeThreeWay(
34
- '<table><tr><td>Rate</td><td>five</td></tr></table>',
35
- '<table><tr><td>Interest Rate</td><td>five</td></tr></table>',
36
- '<table><tr><td>Interest Rate</td><td>five percent</td></tr></table>'
39
+ '<table><tr><td>Rate</td></tr></table>',
40
+ '<table><tr><td>Interest Rate</td></tr></table>',
41
+ '<table><tr><td>Interest Rate</td></tr></table>'
37
42
  )
38
- ).toBe(
39
- "<table><tr><td><ins class='diffins cp' data-author='cp'>Interest </ins>Rate</td><td>five<ins class='diffins me' data-author='me'>&nbsp;percent</ins></td></tr></table>"
40
- )
41
- })
42
-
43
- it('Me rejects a CP cell insertion (reject markup inside the cell)', () => {
43
+ ).toBe('<table><tr><td>Interest Rate</td></tr></table>')
44
+ })
45
+
46
+ it('Disagreement — different changes at the same place', () => {
47
+ // Genesis cell "five"; CP extended to "five and a half"; Me
48
+ // replaced with "seven". Intent reading from a reviewer's
49
+ // perspective:
50
+ // - del-me "five": Me already removed the genesis word from
51
+ // their cell (genesis tracking).
52
+ // - ins-cp " and a half": CP wants this appended.
53
+ // - del-cp "seven": CP wants Me's "seven" removed (Me has it,
54
+ // CP doesn't).
44
55
  expect(
45
56
  HtmlDiff.executeThreeWay(
46
57
  '<table><tr><td>five</td></tr></table>',
47
58
  '<table><tr><td>five and a half</td></tr></table>',
48
- '<table><tr><td>five</td></tr></table>'
59
+ '<table><tr><td>seven</td></tr></table>'
49
60
  )
50
61
  ).toBe(
51
- "<table><tr><td>five<del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'>&nbsp;and a half</del></td></tr></table>"
62
+ "<table><tr><td><del class='diffdel me' data-author='me'>five</del><ins class='diffins cp' data-author='cp'>&nbsp;and a half</ins><del class='diffdel cp' data-author='cp'>seven</del></td></tr></table>"
52
63
  )
53
64
  })
54
65
 
55
- it('CP-deletion of V1 cell content', () => {
66
+ it('CP deletes content, Me kept', () => {
56
67
  expect(
57
68
  HtmlDiff.executeThreeWay(
58
69
  '<table><tr><td>five and a half</td></tr></table>',
59
70
  '<table><tr><td>five</td></tr></table>',
60
- '<table><tr><td>five</td></tr></table>'
71
+ '<table><tr><td>five and a half</td></tr></table>'
61
72
  )
62
73
  ).toBe("<table><tr><td>five<del class='diffdel cp' data-author='cp'>&nbsp;and a half</del></td></tr></table>")
63
74
  })
64
75
 
65
- it('Me-deletion of V1 cell content (CP did nothing)', () => {
76
+ it('Me deletes content, CP kept', () => {
66
77
  expect(
67
78
  HtmlDiff.executeThreeWay(
68
79
  '<table><tr><td>five and a half</td></tr></table>',
@@ -74,103 +85,74 @@ describe('HtmlDiff.executeThreeWay (tables, positional case)', () => {
74
85
  })
75
86
 
76
87
  describe('scaffolding scanner edge cases', () => {
77
- it('preserves <thead>/<tbody> wrappers', () => {
88
+ it('<thead>/<tbody> wrappers', () => {
78
89
  expect(
79
90
  HtmlDiff.executeThreeWay(
80
91
  '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>Old</td></tr></tbody></table>',
81
92
  '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>New</td></tr></tbody></table>',
82
- '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>New</td></tr></tbody></table>'
93
+ '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>Old</td></tr></tbody></table>'
83
94
  )
84
95
  ).toBe(
85
96
  "<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td><del class='diffdel cp' data-author='cp'>Old</del><ins class='diffins cp' data-author='cp'>New</ins></td></tr></tbody></table>"
86
97
  )
87
98
  })
88
99
 
89
- it('handles <th> header cells correctly', () => {
90
- // Branches on td vs th in the scanner; the contentEnd computation
91
- // uses the right tagName length.
100
+ it('<th> header cells', () => {
92
101
  expect(
93
102
  HtmlDiff.executeThreeWay(
94
103
  '<table><tr><th>Label</th><th>Value</th></tr></table>',
95
104
  '<table><tr><th>Description</th><th>Value</th></tr></table>',
96
- '<table><tr><th>Description</th><th>Value</th></tr></table>'
105
+ '<table><tr><th>Label</th><th>Value</th></tr></table>'
97
106
  )
98
107
  ).toBe(
99
108
  "<table><tr><th><del class='diffdel cp' data-author='cp'>Label</del><ins class='diffins cp' data-author='cp'>Description</ins></th><th>Value</th></tr></table>"
100
109
  )
101
110
  })
102
111
 
103
- it('handles an empty cell (contentStart == contentEnd)', () => {
104
- // Empty cell on both sides — no diff, just scaffolding pass-through.
112
+ it('rowspan attribute preserved with author attribution on cell content', () => {
105
113
  expect(
106
114
  HtmlDiff.executeThreeWay(
107
- '<table><tr><td>a</td><td></td></tr></table>',
108
- '<table><tr><td>a</td><td></td></tr></table>',
109
- '<table><tr><td>a</td><td></td></tr></table>'
110
- )
111
- ).toBe('<table><tr><td>a</td><td></td></tr></table>')
112
- })
113
-
114
- it('handles cells with inline HTML (formatting tags)', () => {
115
- // The formatting-tag special case kicks in inside the cell content.
116
- const out = HtmlDiff.executeThreeWay(
117
- '<table><tr><td>The fee is due.</td></tr></table>',
118
- '<table><tr><td>The <strong>fee</strong> is due.</td></tr></table>',
119
- '<table><tr><td>The <strong>fee</strong> is due.</td></tr></table>'
120
- )
121
- expect(out).toContain("data-author='cp'")
122
- expect(out).toMatch(/<ins class='mod[^']*cp'/)
123
- expect(out).toContain('<table>')
124
- expect(out).toContain('</table>')
125
- })
126
-
127
- it('preserves attributes on the <table> element', () => {
128
- expect(
129
- HtmlDiff.executeThreeWay(
130
- '<table class="rates"><tr><td>Old</td></tr></table>',
131
- '<table class="rates"><tr><td>New</td></tr></table>',
132
- '<table class="rates"><tr><td>New</td></tr></table>'
115
+ '<table><tr><td rowspan="2">Foo</td></tr></table>',
116
+ '<table><tr><td rowspan="2">Bar</td></tr></table>',
117
+ '<table><tr><td rowspan="2">Foo</td></tr></table>'
133
118
  )
134
119
  ).toBe(
135
- "<table class=\"rates\"><tr><td><del class='diffdel cp' data-author='cp'>Old</del><ins class='diffins cp' data-author='cp'>New</ins></td></tr></table>"
120
+ "<table><tr><td rowspan=\"2\"><del class='diffdel cp' data-author='cp'>Foo</del><ins class='diffins cp' data-author='cp'>Bar</ins></td></tr></table>"
136
121
  )
137
122
  })
138
123
 
139
- it('multi-row table with edit in only one row', () => {
140
- // Cursor handoff between rows must work — row 0 unchanged, row 1 edited by CP, row 2 unchanged.
124
+ it('multi-row table with edit in one row only', () => {
141
125
  expect(
142
126
  HtmlDiff.executeThreeWay(
143
127
  '<table><tr><td>a</td></tr><tr><td>b</td></tr><tr><td>c</td></tr></table>',
144
128
  '<table><tr><td>a</td></tr><tr><td>B</td></tr><tr><td>c</td></tr></table>',
145
- '<table><tr><td>a</td></tr><tr><td>B</td></tr><tr><td>c</td></tr></table>'
129
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr><tr><td>c</td></tr></table>'
146
130
  )
147
131
  ).toBe(
148
132
  "<table><tr><td>a</td></tr><tr><td><del class='diffdel cp' data-author='cp'>b</del><ins class='diffins cp' data-author='cp'>B</ins></td></tr><tr><td>c</td></tr></table>"
149
133
  )
150
134
  })
151
135
 
152
- it('preserves whitespace between row/cell tags', () => {
153
- // Real Word-sourced HTML has \n and indentation between tags;
154
- // confirm the cursor-based emission passes it through unchanged.
155
- const text = '<table>\n <tr>\n <td>a</td>\n </tr>\n</table>'
136
+ it('identity input passes through unchanged', () => {
137
+ const text = '<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>'
156
138
  expect(HtmlDiff.executeThreeWay(text, text, text)).toBe(text)
157
139
  })
158
140
  })
159
141
 
160
- describe('structural changes (rows added or removed)', () => {
161
- it('CP-inserted row (V1 V2 adds a row, Me kept it)', () => {
142
+ describe('structural row changes (rows added or removed)', () => {
143
+ it('CP inserted a row, Me kept the absence cp-attributed row insertion', () => {
162
144
  expect(
163
145
  HtmlDiff.executeThreeWay(
164
146
  '<table><tr><td>a</td></tr></table>',
165
147
  '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
166
- '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>'
148
+ '<table><tr><td>a</td></tr></table>'
167
149
  )
168
150
  ).toBe(
169
151
  "<table><tr><td>a</td></tr><tr class='diffins cp' data-author='cp'><td class='diffins cp' data-author='cp'><ins class='diffins cp' data-author='cp'>b</ins></td></tr></table>"
170
152
  )
171
153
  })
172
154
 
173
- it('Me-inserted row (V2 V3 adds a row, CP did nothing)', () => {
155
+ it('Me inserted a row, CP kept the absence me-attributed row insertion', () => {
174
156
  expect(
175
157
  HtmlDiff.executeThreeWay(
176
158
  '<table><tr><td>a</td></tr></table>',
@@ -182,19 +164,19 @@ describe('HtmlDiff.executeThreeWay (tables, positional case)', () => {
182
164
  )
183
165
  })
184
166
 
185
- it('CP-deleted V1 row (V1 V2 removes a row, Me kept the removal)', () => {
167
+ it('CP deleted a row, Me kept it cp-attributed row deletion (content from Me)', () => {
186
168
  expect(
187
169
  HtmlDiff.executeThreeWay(
188
170
  '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
189
171
  '<table><tr><td>a</td></tr></table>',
190
- '<table><tr><td>a</td></tr></table>'
172
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>'
191
173
  )
192
174
  ).toBe(
193
175
  "<table><tr><td>a</td></tr><tr class='diffdel cp' data-author='cp'><td class='diffdel cp' data-author='cp'><del class='diffdel cp' data-author='cp'>b</del></td></tr></table>"
194
176
  )
195
177
  })
196
178
 
197
- it('Me-deleted V1 row (V2 == V1, Me removed it in V3)', () => {
179
+ it('Me deleted a row, CP kept it me-attributed row deletion', () => {
198
180
  expect(
199
181
  HtmlDiff.executeThreeWay(
200
182
  '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
@@ -206,57 +188,31 @@ describe('HtmlDiff.executeThreeWay (tables, positional case)', () => {
206
188
  )
207
189
  })
208
190
 
209
- it('Me rejects CP-inserted row (CP added it, Me removed it)', () => {
191
+ it('Both inserted same row settled, no markup', () => {
210
192
  expect(
211
193
  HtmlDiff.executeThreeWay(
212
194
  '<table><tr><td>a</td></tr></table>',
213
195
  '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
214
- '<table><tr><td>a</td></tr></table>'
215
- )
216
- ).toBe(
217
- "<table><tr><td>a</td></tr><tr class='diffdel me rejects-cp' data-author='me' data-rejects='cp'><td class='diffdel me rejects-cp' data-author='me' data-rejects='cp'><del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'>b</del></td></tr></table>"
218
- )
219
- })
220
-
221
- it('both CP and Me add their own rows in different places', () => {
222
- expect(
223
- HtmlDiff.executeThreeWay(
224
- '<table><tr><td>row1</td></tr></table>',
225
- '<table><tr><td>row1</td></tr><tr><td>cp-added</td></tr></table>',
226
- '<table><tr><td>row1</td></tr><tr><td>cp-added</td></tr><tr><td>me-added</td></tr></table>'
196
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>'
227
197
  )
228
- ).toBe(
229
- "<table><tr><td>row1</td></tr><tr class='diffins cp' data-author='cp'><td class='diffins cp' data-author='cp'><ins class='diffins cp' data-author='cp'>cp-added</ins></td></tr><tr class='diffins me' data-author='me'><td class='diffins me' data-author='me'><ins class='diffins me' data-author='me'>me-added</ins></td></tr></table>"
230
- )
198
+ ).toBe('<table><tr><td>a</td></tr><tr><td>b</td></tr></table>')
231
199
  })
232
200
  })
233
201
 
234
- describe('multi-table mismatch (D3)', () => {
235
- it('CP added a whole new table, Me kept it', () => {
202
+ describe('multi-table (table count diverges)', () => {
203
+ it('CP added a whole new table, Me kept absence → cp insertion of whole table', () => {
236
204
  expect(
237
205
  HtmlDiff.executeThreeWay(
238
206
  '<table><tr><td>a</td></tr></table>',
239
207
  '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
240
- '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>'
241
- )
242
- ).toBe(
243
- "<table><tr><td>a</td></tr></table><ins class='diffins cp' data-author='cp'><table><tr><td>b</td></tr></table></ins>"
244
- )
245
- })
246
-
247
- it('CP removed a table that was in V1, Me kept it removed', () => {
248
- expect(
249
- HtmlDiff.executeThreeWay(
250
- '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
251
- '<table><tr><td>a</td></tr></table>',
252
208
  '<table><tr><td>a</td></tr></table>'
253
209
  )
254
210
  ).toBe(
255
- "<table><tr><td>a</td></tr></table><del class='diffdel cp' data-author='cp'><table><tr><td>b</td></tr></table></del>"
211
+ "<table><tr><td>a</td></tr></table><ins class='diffins cp' data-author='cp'><table><tr><td>b</td></tr></table></ins>"
256
212
  )
257
213
  })
258
214
 
259
- it('Me added a brand-new table in V3 (CP did not touch it)', () => {
215
+ it('Me added a whole new table, CP kept absence me insertion', () => {
260
216
  expect(
261
217
  HtmlDiff.executeThreeWay(
262
218
  '<table><tr><td>a</td></tr></table>',
@@ -268,140 +224,188 @@ describe('HtmlDiff.executeThreeWay (tables, positional case)', () => {
268
224
  )
269
225
  })
270
226
 
271
- it('Me removed a table that V1 had and CP kept (Me-deletion of preserved table)', () => {
227
+ it('CP deleted a table from genesis, Me kept it cp deletion', () => {
272
228
  expect(
273
229
  HtmlDiff.executeThreeWay(
274
230
  '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
275
- '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
276
- '<table><tr><td>a</td></tr></table>'
231
+ '<table><tr><td>a</td></tr></table>',
232
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>'
277
233
  )
278
234
  ).toBe(
279
- "<table><tr><td>a</td></tr></table><del class='diffdel me' data-author='me'><table><tr><td>b</td></tr></table></del>"
235
+ "<table><tr><td>a</td></tr></table><del class='diffdel cp' data-author='cp'><table><tr><td>b</td></tr></table></del>"
280
236
  )
281
237
  })
282
238
 
283
- it('Me rejected a CP-added table (CP added, Me removed)', () => {
239
+ it('Me deleted a table from genesis, CP kept it → me deletion', () => {
284
240
  expect(
285
241
  HtmlDiff.executeThreeWay(
286
- '<table><tr><td>a</td></tr></table>',
242
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
287
243
  '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
288
244
  '<table><tr><td>a</td></tr></table>'
289
245
  )
290
246
  ).toBe(
291
- "<table><tr><td>a</td></tr></table><del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'><table><tr><td>b</td></tr></table></del>"
247
+ "<table><tr><td>a</td></tr></table><del class='diffdel me' data-author='me'><table><tr><td>b</td></tr></table></del>"
292
248
  )
293
249
  })
294
250
 
295
- it('falls through to content-LCS when tables are reordered (counts match but positions diverge)', () => {
296
- // V1: [Schedule of Fees, Penalty Notices]. V2 swaps them. V3 keeps the swap.
297
- // Counts match but the positional pairing would compare unrelated
298
- // tables — the similarity gate routes to content-LCS, which pairs
299
- // by content regardless of position.
300
- const fees = '<table><tr><td>Schedule of Fees</td><td>five percent annually</td></tr></table>'
301
- const penalties = '<table><tr><td>Penalty Notices</td><td>seven days written notice</td></tr></table>'
302
- const v1 = fees + penalties
303
- const v2 = penalties + fees
304
- const v3 = penalties + fees
305
- const out = HtmlDiff.executeThreeWay(v1, v2, v3)
306
- // Me did nothing — should produce zero Me attribution.
307
- expect(out).not.toContain("data-author='me'")
308
- // Both tables preserved verbatim (content-paired across positions).
309
- // Positional pairing would have produced wild ins/del replacing
310
- // "Schedule of Fees" with "Penalty Notices" and vice versa.
311
- expect(out).toContain('Schedule of Fees')
312
- expect(out).toContain('Penalty Notices')
313
- // Output should NOT contain replace-style ins/del wrapping the
314
- // distinctive table names (they should appear unchanged).
315
- expect(out).not.toMatch(/<del[^>]*>Schedule/)
316
- expect(out).not.toMatch(/<ins[^>]*>Penalty/)
317
- })
318
-
319
- it('both CP and Me each add their own new table', () => {
251
+ it('Both added the same new table settled, plain output', () => {
320
252
  expect(
321
253
  HtmlDiff.executeThreeWay(
322
254
  '<table><tr><td>orig</td></tr></table>',
323
- '<table><tr><td>orig</td></tr></table><table><tr><td>cp-added</td></tr></table>',
324
- '<table><tr><td>orig</td></tr></table><table><tr><td>cp-added</td></tr></table><table><tr><td>me-added</td></tr></table>'
255
+ '<table><tr><td>orig</td></tr></table><table><tr><td>added</td></tr></table>',
256
+ '<table><tr><td>orig</td></tr></table><table><tr><td>added</td></tr></table>'
325
257
  )
326
- ).toBe(
327
- "<table><tr><td>orig</td></tr></table><ins class='diffins cp' data-author='cp'><table><tr><td>cp-added</td></tr></table></ins><ins class='diffins me' data-author='me'><table><tr><td>me-added</td></tr></table></ins>"
328
- )
258
+ ).toBe('<table><tr><td>orig</td></tr></table><table><tr><td>added</td></tr></table>')
329
259
  })
330
260
  })
331
261
 
332
262
  describe('cell-level edge cases', () => {
333
- it('rowspan attribute preserved and content within attributed correctly', () => {
334
- // V1 has a rowspan="2" cell with "Foo"; CP edits to "Bar"; Me kept.
335
- // The rowspan attribute survives; the content is attributed.
336
- expect(
337
- HtmlDiff.executeThreeWay(
338
- '<table><tr><td rowspan="2">Foo</td></tr></table>',
339
- '<table><tr><td rowspan="2">Bar</td></tr></table>',
340
- '<table><tr><td rowspan="2">Bar</td></tr></table>'
341
- )
342
- ).toBe(
343
- "<table><tr><td rowspan=\"2\"><del class='diffdel cp' data-author='cp'>Foo</del><ins class='diffins cp' data-author='cp'>Bar</ins></td></tr></table>"
344
- )
345
- })
346
-
347
263
  it('exceedsSizeLimit bail-out: oversized table falls through to word-level path', () => {
348
- // Build a table with > 1500 rows. preprocessTablesThreeWay returns
349
- // null on size cap, so executeThreeWay treats the table as raw HTML
350
- // — output is V2 (since V1==V2==V3 in this construction) verbatim.
264
+ // 1501-row table: preprocessTablesThreeWay returns null on size cap,
265
+ // so executeThreeWay treats the table as raw HTML.
351
266
  const rows = Array.from({ length: 1501 }, (_, i) => `<tr><td>row ${i}</td></tr>`).join('')
352
267
  const html = `<table>${rows}</table>`
353
- // Identical inputs — output is verbatim regardless of size-cap path.
354
268
  expect(HtmlDiff.executeThreeWay(html, html, html)).toBe(html)
355
269
  })
270
+
271
+ it('cell-count mismatch: CP added a column — CP row content is visible (not silently dropped)', () => {
272
+ // Regression: the previous fallback in emitPreservedRow emitted
273
+ // only `del me` + `ins me` for any cell-count mismatch, which
274
+ // silently destroyed CP's row content whenever CP changed the
275
+ // cell count. A reader in cp-only mode would see no trace of
276
+ // CP's added column — a content-loss bug that violates the
277
+ // "CP's changes always visible" invariant.
278
+ const out = HtmlDiff.executeThreeWay(
279
+ '<table><tr><td>a</td><td>b</td></tr></table>',
280
+ '<table><tr><td>a</td><td>X</td><td>b</td></tr></table>',
281
+ '<table><tr><td>a</td><td>b</td></tr></table>'
282
+ )
283
+ expect(out).toBe(
284
+ "<table><tr class='diffdel cp' data-author='cp'><td class='diffdel cp' data-author='cp'><del class='diffdel cp' data-author='cp'>a</del></td><td class='diffdel cp' data-author='cp'><del class='diffdel cp' data-author='cp'>b</del></td></tr><tr class='diffins cp' data-author='cp'><td class='diffins cp' data-author='cp'><ins class='diffins cp' data-author='cp'>a</ins></td><td class='diffins cp' data-author='cp'><ins class='diffins cp' data-author='cp'>X</ins></td><td class='diffins cp' data-author='cp'><ins class='diffins cp' data-author='cp'>b</ins></td></tr></table>"
285
+ )
286
+ })
287
+
288
+ it('cell-count mismatch: Me removed a column — symmetric to the CP case', () => {
289
+ const out = HtmlDiff.executeThreeWay(
290
+ '<table><tr><td>a</td><td>b</td></tr></table>',
291
+ '<table><tr><td>a</td><td>b</td></tr></table>',
292
+ '<table><tr><td>a</td></tr></table>'
293
+ )
294
+ expect(out).toBe(
295
+ "<table><tr class='diffdel me' data-author='me'><td class='diffdel me' data-author='me'><del class='diffdel me' data-author='me'>a</del></td><td class='diffdel me' data-author='me'><del class='diffdel me' data-author='me'>b</del></td></tr><tr class='diffins me' data-author='me'><td class='diffins me' data-author='me'><ins class='diffins me' data-author='me'>a</ins></td></tr></table>"
296
+ )
297
+ })
298
+
299
+ it('CP edited one cell in a row (same shape) — fuzzy-pairs and emits a cell-level diff, not whole-row del+ins', () => {
300
+ // Regression: the 3-way row aligner only did exact lcsAlign over
301
+ // rowKey, so a row where CP edited a single cell's text produced
302
+ // no key match and the algorithm split the row into a whole-row
303
+ // delete + whole-row insert. The 2-way path has always run a
304
+ // fuzzy-pairing pass after lcsAlign; bringing the 3-way path in
305
+ // step removes the asymmetry where cp-only / all-changes views
306
+ // looked materially worse than 2-way for ordinary cell edits.
307
+ //
308
+ // Same-shape genesis/cp/me; CP edited the middle cell's text.
309
+ // Me === genesis. Expect a paired row with cell-level cp-ins
310
+ // markup, NOT two distinct whole-row entries.
311
+ const out = HtmlDiff.executeThreeWay(
312
+ '<table><tr><td>Party A</td><td>old details</td><td>kept</td></tr></table>',
313
+ '<table><tr><td>Party A</td><td>new details</td><td>kept</td></tr></table>',
314
+ '<table><tr><td>Party A</td><td>old details</td><td>kept</td></tr></table>'
315
+ )
316
+ // CP's edit lives inside the row, not as a parallel whole-row
317
+ // delete-then-insert. Whole-row markers would carry `class='diffdel ...'`
318
+ // or `class='diffins ...'` on the `<tr>` itself.
319
+ expect(out).not.toMatch(/<tr [^>]*class=['"]diffdel/)
320
+ expect(out).not.toMatch(/<tr [^>]*class=['"]diffins/)
321
+ expect(out).toContain('Party A')
322
+ expect(out).toContain("data-author='cp'")
323
+ // Me === genesis so any me attribution would indicate a swap.
324
+ expect(out).not.toContain("data-author='me'")
325
+ })
326
+
327
+ it('cell-count mismatch: both sides restructured differently — both ins rows attributed', () => {
328
+ // Genesis 2 cells, CP 3 cells, Me 4 cells. Neither side keeps
329
+ // the genesis shape, so both restructures must be visible.
330
+ const out = HtmlDiff.executeThreeWay(
331
+ '<table><tr><td>a</td><td>b</td></tr></table>',
332
+ '<table><tr><td>a</td><td>X</td><td>b</td></tr></table>',
333
+ '<table><tr><td>a</td><td>b</td><td>Y</td><td>Z</td></tr></table>'
334
+ )
335
+ expect(out).toBe(
336
+ "<table><tr class='diffins cp' data-author='cp'><td class='diffins cp' data-author='cp'><ins class='diffins cp' data-author='cp'>a</ins></td><td class='diffins cp' data-author='cp'><ins class='diffins cp' data-author='cp'>X</ins></td><td class='diffins cp' data-author='cp'><ins class='diffins cp' data-author='cp'>b</ins></td></tr><tr class='diffins me' data-author='me'><td class='diffins me' data-author='me'><ins class='diffins me' data-author='me'>a</ins></td><td class='diffins me' data-author='me'><ins class='diffins me' data-author='me'>b</ins></td><td class='diffins me' data-author='me'><ins class='diffins me' data-author='me'>Y</ins></td><td class='diffins me' data-author='me'><ins class='diffins me' data-author='me'>Z</ins></td></tr></table>"
337
+ )
338
+ })
356
339
  })
357
340
 
358
341
  describe('nested tables', () => {
359
342
  it('handles a table nested inside a cell — both attributed correctly', () => {
360
- // Outer table with a single cell containing an inner table. CP edits
361
- // the inner table's content; Me doesn't touch it. The recursion
362
- // through cellDiff should reach the inner table and attribute it.
363
- const v1 = '<table><tr><td><table><tr><td>inner</td></tr></table></td></tr></table>'
364
- const v2 = '<table><tr><td><table><tr><td>INNER</td></tr></table></td></tr></table>'
365
- const v3 = '<table><tr><td><table><tr><td>INNER</td></tr></table></td></tr></table>'
366
- const out = HtmlDiff.executeThreeWay(v1, v2, v3)
367
- // Inner cell content attributed to CP via the recursive cellDiff.
343
+ const out = HtmlDiff.executeThreeWay(
344
+ '<table><tr><td><table><tr><td>inner</td></tr></table></td></tr></table>',
345
+ '<table><tr><td><table><tr><td>INNER</td></tr></table></td></tr></table>',
346
+ '<table><tr><td><table><tr><td>inner</td></tr></table></td></tr></table>'
347
+ )
368
348
  expect(out).toMatch(/<del[^>]*data-author='cp'[^>]*>inner<\/del>/)
369
349
  expect(out).toMatch(/<ins[^>]*data-author='cp'[^>]*>INNER<\/ins>/)
370
- // Outer table scaffolding intact.
350
+ // me == genesis here, so any me attribution would indicate a
351
+ // cp↔me swap inside the table-cell merge.
352
+ expect(out).not.toContain("data-author='me'")
371
353
  expect(out.startsWith('<table><tr><td><table>')).toBe(true)
372
354
  expect(out.endsWith('</table></td></tr></table>')).toBe(true)
373
355
  })
374
356
 
375
- it('safely handles deeply-nested tables without infinite recursion', () => {
376
- // Build a 10-deep nested table. The MaxThreeWayDepth cap kicks in
377
- // at level 8, falling back to word-level treatment of deeper tables.
378
- // The test just verifies no crash and reasonable output.
357
+ it('deeply-nested identity tables pass through unchanged (depth cap exercised)', () => {
379
358
  let v = 'leaf'
380
359
  for (let i = 0; i < 10; i++) v = `<table><tr><td>${v}</td></tr></table>`
381
- // Identical inputs — should produce input verbatim regardless of depth.
382
360
  expect(HtmlDiff.executeThreeWay(v, v, v)).toBe(v)
383
361
  })
384
362
  })
385
363
 
386
364
  describe('integration with surrounding prose', () => {
387
- it('attributes both the cell content AND the surrounding prose', () => {
388
- const out = HtmlDiff.executeThreeWay(
389
- '<p>Rates as follows:</p><table><tr><td>five</td></tr></table>',
390
- '<p>Interest rates as follows:</p><table><tr><td>five and a half</td></tr></table>',
391
- '<p>Interest rates as follows:</p><table><tr><td>five</td></tr></table>'
365
+ it('prose and table both attributed correctly', () => {
366
+ expect(
367
+ HtmlDiff.executeThreeWay(
368
+ '<p>Rates as follows:</p><table><tr><td>five</td></tr></table>',
369
+ '<p>Interest rates as follows:</p><table><tr><td>five and a half</td></tr></table>',
370
+ '<p>Rates as follows:</p><table><tr><td>five</td></tr></table>'
371
+ )
372
+ ).toBe(
373
+ "<p><del class='diffdel cp' data-author='cp'>Rates</del><ins class='diffins cp' data-author='cp'>Interest rates</ins> as follows:</p><table><tr><td>five<ins class='diffins cp' data-author='cp'>&nbsp;and a half</ins></td></tr></table>"
392
374
  )
393
- // CP edited the prose (added "Interest "), Me rejected CP's table edit.
394
- expect(out).toContain("<ins class='diffins cp' data-author='cp'>Interest")
395
- expect(out).toContain("data-rejects='cp'")
396
- // Table scaffolding intact.
397
- expect(out).toContain('<table>')
398
- expect(out).toContain('</table>')
399
375
  })
400
376
 
401
- it('passes inputs with no tables through to the word-level path unchanged', () => {
402
- // No tables → preprocessTablesThreeWay returns null → executeThreeWay
403
- // skips straight to word-level. Sanity check the no-op.
377
+ it('no-tables path is unaffected', () => {
404
378
  expect(HtmlDiff.executeThreeWay('<p>a</p>', '<p>a</p>', '<p>a</p>')).toBe('<p>a</p>')
405
379
  })
406
380
  })
381
+
382
+ describe('positional pairing under moderate dissimilarity', () => {
383
+ it('column rename + value rewrite still routes through cell-level diff (not whole-table del+ins)', () => {
384
+ // Real-world regression: cp renamed a column ("Form/Document/Certificate"
385
+ // → "Extra column") and replaced the values in that column with short
386
+ // tokens. Word-level Jaccard between the genesis table and cp's edited
387
+ // table drops to ~0.38 — under the 0.5 threshold the three-way path
388
+ // used to take, which kicked the diff into multi-table content-LCS
389
+ // and produced whole-table del+ins (the cp's CP-bubble showed the
390
+ // entire old table struck through and the entire new table inserted).
391
+ // 2-way had no such guard and produced a cell-level diff for the same
392
+ // inputs; lowering the 3-way threshold brings the two paths in step.
393
+ const genesis =
394
+ '<table><tr><td>A</td><td>Form/Document/Certificate</td><td>Date</td></tr><tr><td>Party A</td><td>IRS W-8</td><td>On execution</td></tr></table>'
395
+ const cp =
396
+ '<table><tr><td>A</td><td>Extra column</td><td>Date</td></tr><tr><td>Party A</td><td>Yes</td><td>On execution</td></tr></table>'
397
+ const me = genesis
398
+ const out = HtmlDiff.executeThreeWay(genesis, cp, me)
399
+ // Expect cell-level cp attribution INSIDE the table cells, NOT a
400
+ // whole-table del+ins wrapping the entire <table>.
401
+ expect(out).not.toMatch(/<del[^>]*><table/)
402
+ expect(out).toMatch(/data-author='cp'/)
403
+ // me === genesis, so any me-attribution markers would mean the
404
+ // diff swapped CP's edits onto Me. Negative assertion locks the
405
+ // attribution direction.
406
+ expect(out).not.toContain("data-author='me'")
407
+ expect(out).toContain('Extra column')
408
+ expect(out).toContain('Form/Document/Certificate')
409
+ })
410
+ })
407
411
  })
@@ -138,10 +138,10 @@ describe('Utils', () => {
138
138
  it('combines extraClasses and dataAttrs in one call', () => {
139
139
  expect(
140
140
  Utils.wrapText('hello', 'del', 'diffdel', {
141
- extraClasses: 'me rejects-cp',
142
- dataAttrs: { author: 'me', rejects: 'cp' },
141
+ extraClasses: 'me',
142
+ dataAttrs: { author: 'me', source: 'edit' },
143
143
  })
144
- ).toBe("<del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'>hello</del>")
144
+ ).toBe("<del class='diffdel me' data-author='me' data-source='edit'>hello</del>")
145
145
  })
146
146
 
147
147
  it('skips the metadata path entirely when neither extraClasses nor dataAttrs is set', () => {