@createiq/htmldiff 1.0.5-beta.0 → 1.0.5-beta.1

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.
@@ -0,0 +1,1419 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import HtmlDiff from '../src/HtmlDiff'
4
+
5
+ // Tests defining the expected blackline behaviour for HTML tables.
6
+ // The reference behaviour is Microsoft Word's track-changes representation
7
+ // of the same edit. Where Word's output is unclear, the test is marked as
8
+ // `it.todo` until we can capture it from a real document.
9
+ describe('HtmlDiff — tables', () => {
10
+ describe('cell content edits', () => {
11
+ it('marks an inline text change inside a single cell', () => {
12
+ const oldHtml = '<table><tr><td>Foo</td><td>Bar</td></tr></table>'
13
+ const newHtml = '<table><tr><td>Foo</td><td>Baz</td></tr></table>'
14
+
15
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
16
+ "<table><tr><td>Foo</td><td><del class='diffmod'>Bar</del><ins class='diffmod'>Baz</ins></td></tr></table>"
17
+ )
18
+ })
19
+
20
+ it('marks inline text changes across multiple cells in the same row', () => {
21
+ const oldHtml = '<table><tr><td>Apple</td><td>Banana</td><td>Cherry</td></tr></table>'
22
+ const newHtml = '<table><tr><td>Apricot</td><td>Banana</td><td>Coconut</td></tr></table>'
23
+
24
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
25
+ '<table><tr>' +
26
+ "<td><del class='diffmod'>Apple</del><ins class='diffmod'>Apricot</ins></td>" +
27
+ '<td>Banana</td>' +
28
+ "<td><del class='diffmod'>Cherry</del><ins class='diffmod'>Coconut</ins></td>" +
29
+ '</tr></table>'
30
+ )
31
+ })
32
+
33
+ it('marks inline text changes across multiple cells on different rows', () => {
34
+ const oldHtml = '<table><tr><td>Apple</td><td>Banana</td></tr><tr><td>Cherry</td><td>Date</td></tr></table>'
35
+ const newHtml = '<table><tr><td>Apricot</td><td>Banana</td></tr><tr><td>Cherry</td><td>Durian</td></tr></table>'
36
+
37
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
38
+ '<table>' +
39
+ "<tr><td><del class='diffmod'>Apple</del><ins class='diffmod'>Apricot</ins></td><td>Banana</td></tr>" +
40
+ "<tr><td>Cherry</td><td><del class='diffmod'>Date</del><ins class='diffmod'>Durian</ins></td></tr>" +
41
+ '</table>'
42
+ )
43
+ })
44
+
45
+ it('appends inserted text inside a cell when content is added', () => {
46
+ const oldHtml = '<table><tr><td>Foo</td><td>Bar</td></tr></table>'
47
+ const newHtml = '<table><tr><td>Foo</td><td>Bar baz</td></tr></table>'
48
+
49
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
50
+ "<table><tr><td>Foo</td><td>Bar<ins class='diffins'>&nbsp;baz</ins></td></tr></table>"
51
+ )
52
+ })
53
+
54
+ it('strikes through removed text inside a cell when content is deleted', () => {
55
+ const oldHtml = '<table><tr><td>Foo</td><td>Bar baz</td></tr></table>'
56
+ const newHtml = '<table><tr><td>Foo</td><td>Bar</td></tr></table>'
57
+
58
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
59
+ "<table><tr><td>Foo</td><td>Bar<del class='diffdel'>&nbsp;baz</del></td></tr></table>"
60
+ )
61
+ })
62
+
63
+ it('marks formatting changes inside a cell', () => {
64
+ const oldHtml = '<table><tr><td>plain text</td></tr></table>'
65
+ const newHtml = '<table><tr><td><strong>plain text</strong></td></tr></table>'
66
+
67
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
68
+ "<table><tr><td><strong><ins class='mod strong'>plain text</ins></strong></td></tr></table>"
69
+ )
70
+ })
71
+ })
72
+
73
+ describe('content moved between cells', () => {
74
+ // Word does not track cross-cell moves; it shows each cell as an
75
+ // independent edit. A cell that loses its old content and gains new
76
+ // content renders as <del>old</del><ins>new</ins>, and a cell that just
77
+ // loses content renders as <del>old</del>. There is no "moved" marker.
78
+ // Cell-aware preprocessing in TableDiff is what aligns cells positionally
79
+ // when row × cell dimensions match, so these per-cell edits emit cleanly.
80
+ it('marks each cell with independent del/ins when content shifts right by one cell', () => {
81
+ const oldHtml = '<table><tr><td>A</td><td>B</td><td>C</td></tr></table>'
82
+ const newHtml = '<table><tr><td></td><td>A</td><td>B</td></tr></table>'
83
+
84
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
85
+ '<table><tr>' +
86
+ "<td><del class='diffdel'>A</del></td>" +
87
+ "<td><del class='diffmod'>B</del><ins class='diffmod'>A</ins></td>" +
88
+ "<td><del class='diffmod'>C</del><ins class='diffmod'>B</ins></td>" +
89
+ '</tr></table>'
90
+ )
91
+ })
92
+
93
+ it('marks each cell with independent del/ins when content shifts left by one cell', () => {
94
+ const oldHtml = '<table><tr><td>A</td><td>B</td><td>C</td></tr></table>'
95
+ const newHtml = '<table><tr><td>B</td><td>C</td><td></td></tr></table>'
96
+
97
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
98
+ '<table><tr>' +
99
+ "<td><del class='diffmod'>A</del><ins class='diffmod'>B</ins></td>" +
100
+ "<td><del class='diffmod'>B</del><ins class='diffmod'>C</ins></td>" +
101
+ "<td><del class='diffdel'>C</del></td>" +
102
+ '</tr></table>'
103
+ )
104
+ })
105
+
106
+ it('marks each cell with independent del/ins when content is swapped between two cells', () => {
107
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
108
+ const newHtml = '<table><tr><td>B</td><td>A</td></tr></table>'
109
+
110
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
111
+ '<table><tr>' +
112
+ "<td><del class='diffmod'>A</del><ins class='diffmod'>B</ins></td>" +
113
+ "<td><del class='diffmod'>B</del><ins class='diffmod'>A</ins></td>" +
114
+ '</tr></table>'
115
+ )
116
+ })
117
+
118
+ it('marks each cell with independent del/ins when content is moved across rows', () => {
119
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
120
+ const newHtml = '<table><tr><td>C</td><td>D</td></tr><tr><td>A</td><td>B</td></tr></table>'
121
+
122
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
123
+ '<table>' +
124
+ '<tr>' +
125
+ "<td><del class='diffmod'>A</del><ins class='diffmod'>C</ins></td>" +
126
+ "<td><del class='diffmod'>B</del><ins class='diffmod'>D</ins></td>" +
127
+ '</tr>' +
128
+ '<tr>' +
129
+ "<td><del class='diffmod'>C</del><ins class='diffmod'>A</ins></td>" +
130
+ "<td><del class='diffmod'>D</del><ins class='diffmod'>B</ins></td>" +
131
+ '</tr>' +
132
+ '</table>'
133
+ )
134
+ })
135
+ })
136
+
137
+ // Inserted rows get `class='diffins'` on the <tr> AND on every <td> in
138
+ // the row, with each cell's content wrapped in <ins>. The triple class
139
+ // (tr/td/ins) lets stylesheets pick whichever level they want — Word's
140
+ // tinted-row + underlined-text pair maps naturally onto this.
141
+ describe('add rows', () => {
142
+ it('marks a row added at the end of a table', () => {
143
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
144
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
145
+
146
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
147
+ '<table>' +
148
+ '<tr><td>A</td><td>B</td></tr>' +
149
+ "<tr class='diffins'>" +
150
+ "<td class='diffins'><ins class='diffins'>C</ins></td>" +
151
+ "<td class='diffins'><ins class='diffins'>D</ins></td>" +
152
+ '</tr>' +
153
+ '</table>'
154
+ )
155
+ })
156
+
157
+ it('marks a row added at the start of a table', () => {
158
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
159
+ const newHtml = '<table><tr><td>X</td><td>Y</td></tr><tr><td>A</td><td>B</td></tr></table>'
160
+
161
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
162
+ '<table>' +
163
+ "<tr class='diffins'>" +
164
+ "<td class='diffins'><ins class='diffins'>X</ins></td>" +
165
+ "<td class='diffins'><ins class='diffins'>Y</ins></td>" +
166
+ '</tr>' +
167
+ '<tr><td>A</td><td>B</td></tr>' +
168
+ '</table>'
169
+ )
170
+ })
171
+
172
+ it('marks a row added in the middle of a table', () => {
173
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
174
+ const newHtml =
175
+ '<table><tr><td>A</td><td>B</td></tr><tr><td>X</td><td>Y</td></tr><tr><td>C</td><td>D</td></tr></table>'
176
+
177
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
178
+ '<table>' +
179
+ '<tr><td>A</td><td>B</td></tr>' +
180
+ "<tr class='diffins'>" +
181
+ "<td class='diffins'><ins class='diffins'>X</ins></td>" +
182
+ "<td class='diffins'><ins class='diffins'>Y</ins></td>" +
183
+ '</tr>' +
184
+ '<tr><td>C</td><td>D</td></tr>' +
185
+ '</table>'
186
+ )
187
+ })
188
+
189
+ it('marks multiple consecutive rows added at the end', () => {
190
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
191
+ const newHtml =
192
+ '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr><tr><td>E</td><td>F</td></tr></table>'
193
+
194
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
195
+ '<table>' +
196
+ '<tr><td>A</td><td>B</td></tr>' +
197
+ "<tr class='diffins'>" +
198
+ "<td class='diffins'><ins class='diffins'>C</ins></td>" +
199
+ "<td class='diffins'><ins class='diffins'>D</ins></td>" +
200
+ '</tr>' +
201
+ "<tr class='diffins'>" +
202
+ "<td class='diffins'><ins class='diffins'>E</ins></td>" +
203
+ "<td class='diffins'><ins class='diffins'>F</ins></td>" +
204
+ '</tr>' +
205
+ '</table>'
206
+ )
207
+ })
208
+
209
+ it('marks non-consecutive rows added in different parts of the table', () => {
210
+ const oldHtml = '<table><tr><td>A</td></tr><tr><td>C</td></tr></table>'
211
+ const newHtml = '<table><tr><td>X</td></tr><tr><td>A</td></tr><tr><td>C</td></tr><tr><td>Y</td></tr></table>'
212
+
213
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
214
+ '<table>' +
215
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>X</ins></td></tr>" +
216
+ '<tr><td>A</td></tr>' +
217
+ '<tr><td>C</td></tr>' +
218
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>Y</ins></td></tr>" +
219
+ '</table>'
220
+ )
221
+ })
222
+
223
+ it('marks a row containing formatted content as added', () => {
224
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
225
+ const newHtml = '<table><tr><td>A</td></tr><tr><td><strong>B</strong></td></tr></table>'
226
+
227
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
228
+ '<table>' +
229
+ '<tr><td>A</td></tr>' +
230
+ "<tr class='diffins'><td class='diffins'><strong><ins class='diffins'>B</ins></strong></td></tr>" +
231
+ '</table>'
232
+ )
233
+ })
234
+
235
+ // Fuzzy row matching: a row with a minor content edit alongside an
236
+ // unrelated row addition should be matched as an edit (cell-level diff)
237
+ // rather than treated as a whole-row delete + reinsert. The
238
+ // character-level prefix+suffix similarity above ROW_FUZZY_THRESHOLD
239
+ // is what makes this happen.
240
+ it('matches a row with a minor edit alongside a newly added row', () => {
241
+ const oldHtml = '<table><tr><td>Same row</td></tr><tr><td>Edited row</td></tr></table>'
242
+ const newHtml = '<table><tr><td>Same row</td></tr><tr><td>Edited rowX</td></tr><tr><td>New row</td></tr></table>'
243
+
244
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
245
+ '<table>' +
246
+ '<tr><td>Same row</td></tr>' +
247
+ "<tr><td>Edited <del class='diffmod'>row</del><ins class='diffmod'>rowX</ins></td></tr>" +
248
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>New row</ins></td></tr>" +
249
+ '</table>'
250
+ )
251
+ })
252
+
253
+ it('still treats wholly-different rows as deleted+inserted when fuzzy similarity is too low', () => {
254
+ // Different row counts, so we go through the row-LCS path. The
255
+ // unmatched old row "Hello world" and new row "Goodbye there" share
256
+ // no prefix/suffix, so fuzzy matching declines to pair them and
257
+ // they remain as a separate delete + insert (with the unrelated
258
+ // "New row" also inserted).
259
+ const oldHtml = '<table><tr><td>Same</td></tr><tr><td>Hello world</td></tr></table>'
260
+ const newHtml = '<table><tr><td>Same</td></tr><tr><td>Goodbye there</td></tr><tr><td>New row</td></tr></table>'
261
+
262
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
263
+ '<table>' +
264
+ '<tr><td>Same</td></tr>' +
265
+ "<tr class='diffdel'><td class='diffdel'><del class='diffdel'>Hello world</del></td></tr>" +
266
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>Goodbye there</ins></td></tr>" +
267
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>New row</ins></td></tr>" +
268
+ '</table>'
269
+ )
270
+ })
271
+ })
272
+
273
+ // Inserted columns get `class='diffins'` on each new <td> (since the
274
+ // <tr> still contains preserved cells, the row itself isn't tagged).
275
+ describe('add columns', () => {
276
+ it('marks a column added at the end of a table', () => {
277
+ const oldHtml = '<table><tr><td>A</td></tr><tr><td>C</td></tr></table>'
278
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
279
+
280
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
281
+ '<table>' +
282
+ "<tr><td>A</td><td class='diffins'><ins class='diffins'>B</ins></td></tr>" +
283
+ "<tr><td>C</td><td class='diffins'><ins class='diffins'>D</ins></td></tr>" +
284
+ '</table>'
285
+ )
286
+ })
287
+
288
+ it('marks a column added at the start of a table', () => {
289
+ const oldHtml = '<table><tr><td>A</td></tr><tr><td>C</td></tr></table>'
290
+ const newHtml = '<table><tr><td>X</td><td>A</td></tr><tr><td>Y</td><td>C</td></tr></table>'
291
+
292
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
293
+ '<table>' +
294
+ "<tr><td class='diffins'><ins class='diffins'>X</ins></td><td>A</td></tr>" +
295
+ "<tr><td class='diffins'><ins class='diffins'>Y</ins></td><td>C</td></tr>" +
296
+ '</table>'
297
+ )
298
+ })
299
+
300
+ it('marks a column added in the middle of a table', () => {
301
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
302
+ const newHtml = '<table><tr><td>A</td><td>X</td><td>B</td></tr><tr><td>C</td><td>Y</td><td>D</td></tr></table>'
303
+
304
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
305
+ '<table>' +
306
+ "<tr><td>A</td><td class='diffins'><ins class='diffins'>X</ins></td><td>B</td></tr>" +
307
+ "<tr><td>C</td><td class='diffins'><ins class='diffins'>Y</ins></td><td>D</td></tr>" +
308
+ '</table>'
309
+ )
310
+ })
311
+
312
+ it('marks multiple consecutive columns added at the end', () => {
313
+ const oldHtml = '<table><tr><td>A</td></tr><tr><td>C</td></tr></table>'
314
+ const newHtml =
315
+ '<table><tr><td>A</td><td>B1</td><td>B2</td></tr><tr><td>C</td><td>D1</td><td>D2</td></tr></table>'
316
+
317
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
318
+ '<table>' +
319
+ '<tr>' +
320
+ '<td>A</td>' +
321
+ "<td class='diffins'><ins class='diffins'>B1</ins></td>" +
322
+ "<td class='diffins'><ins class='diffins'>B2</ins></td>" +
323
+ '</tr>' +
324
+ '<tr>' +
325
+ '<td>C</td>' +
326
+ "<td class='diffins'><ins class='diffins'>D1</ins></td>" +
327
+ "<td class='diffins'><ins class='diffins'>D2</ins></td>" +
328
+ '</tr>' +
329
+ '</table>'
330
+ )
331
+ })
332
+ })
333
+
334
+ // Deleted rows mirror inserted rows: `class='diffdel'` on <tr> and on
335
+ // every <td>, with each cell content wrapped in <del>.
336
+ describe('delete rows', () => {
337
+ it('marks a row deleted from the end of a table', () => {
338
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
339
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
340
+
341
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
342
+ '<table>' +
343
+ '<tr><td>A</td><td>B</td></tr>' +
344
+ "<tr class='diffdel'>" +
345
+ "<td class='diffdel'><del class='diffdel'>C</del></td>" +
346
+ "<td class='diffdel'><del class='diffdel'>D</del></td>" +
347
+ '</tr>' +
348
+ '</table>'
349
+ )
350
+ })
351
+
352
+ it('marks a row deleted from the start of a table', () => {
353
+ const oldHtml = '<table><tr><td>X</td><td>Y</td></tr><tr><td>A</td><td>B</td></tr></table>'
354
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
355
+
356
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
357
+ '<table>' +
358
+ "<tr class='diffdel'>" +
359
+ "<td class='diffdel'><del class='diffdel'>X</del></td>" +
360
+ "<td class='diffdel'><del class='diffdel'>Y</del></td>" +
361
+ '</tr>' +
362
+ '<tr><td>A</td><td>B</td></tr>' +
363
+ '</table>'
364
+ )
365
+ })
366
+
367
+ it('marks a row deleted from the middle of a table', () => {
368
+ const oldHtml =
369
+ '<table><tr><td>A</td><td>B</td></tr><tr><td>X</td><td>Y</td></tr><tr><td>C</td><td>D</td></tr></table>'
370
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
371
+
372
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
373
+ '<table>' +
374
+ '<tr><td>A</td><td>B</td></tr>' +
375
+ "<tr class='diffdel'>" +
376
+ "<td class='diffdel'><del class='diffdel'>X</del></td>" +
377
+ "<td class='diffdel'><del class='diffdel'>Y</del></td>" +
378
+ '</tr>' +
379
+ '<tr><td>C</td><td>D</td></tr>' +
380
+ '</table>'
381
+ )
382
+ })
383
+
384
+ it('marks multiple consecutive rows deleted', () => {
385
+ const oldHtml = '<table><tr><td>A</td></tr><tr><td>X</td></tr><tr><td>Y</td></tr><tr><td>B</td></tr></table>'
386
+ const newHtml = '<table><tr><td>A</td></tr><tr><td>B</td></tr></table>'
387
+
388
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
389
+ '<table>' +
390
+ '<tr><td>A</td></tr>' +
391
+ "<tr class='diffdel'><td class='diffdel'><del class='diffdel'>X</del></td></tr>" +
392
+ "<tr class='diffdel'><td class='diffdel'><del class='diffdel'>Y</del></td></tr>" +
393
+ '<tr><td>B</td></tr>' +
394
+ '</table>'
395
+ )
396
+ })
397
+
398
+ it('marks every row deleted when the table is emptied of body rows', () => {
399
+ const oldHtml = '<table><tr><td>A</td></tr><tr><td>B</td></tr></table>'
400
+ const newHtml = '<table></table>'
401
+
402
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
403
+ '<table>' +
404
+ "<tr class='diffdel'><td class='diffdel'><del class='diffdel'>A</del></td></tr>" +
405
+ "<tr class='diffdel'><td class='diffdel'><del class='diffdel'>B</del></td></tr>" +
406
+ '</table>'
407
+ )
408
+ })
409
+ })
410
+
411
+ // Word does not track column deletion (it warns "this action won't be
412
+ // marked as a change"), so these expectations are our own design: each
413
+ // deleted <td> gets `class='diffdel'` and its content is wrapped in
414
+ // <del>, mirroring the per-cell behaviour of delete-row.
415
+ describe('delete columns', () => {
416
+ it('marks a column deleted from the end of a table', () => {
417
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
418
+ const newHtml = '<table><tr><td>A</td></tr><tr><td>C</td></tr></table>'
419
+
420
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
421
+ '<table>' +
422
+ "<tr><td>A</td><td class='diffdel'><del class='diffdel'>B</del></td></tr>" +
423
+ "<tr><td>C</td><td class='diffdel'><del class='diffdel'>D</del></td></tr>" +
424
+ '</table>'
425
+ )
426
+ })
427
+
428
+ it('marks a column deleted from the start of a table', () => {
429
+ const oldHtml = '<table><tr><td>X</td><td>A</td></tr><tr><td>Y</td><td>C</td></tr></table>'
430
+ const newHtml = '<table><tr><td>A</td></tr><tr><td>C</td></tr></table>'
431
+
432
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
433
+ '<table>' +
434
+ "<tr><td class='diffdel'><del class='diffdel'>X</del></td><td>A</td></tr>" +
435
+ "<tr><td class='diffdel'><del class='diffdel'>Y</del></td><td>C</td></tr>" +
436
+ '</table>'
437
+ )
438
+ })
439
+
440
+ it('marks a column deleted from the middle of a table', () => {
441
+ const oldHtml = '<table><tr><td>A</td><td>X</td><td>B</td></tr><tr><td>C</td><td>Y</td><td>D</td></tr></table>'
442
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
443
+
444
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
445
+ '<table>' +
446
+ "<tr><td>A</td><td class='diffdel'><del class='diffdel'>X</del></td><td>B</td></tr>" +
447
+ "<tr><td>C</td><td class='diffdel'><del class='diffdel'>Y</del></td><td>D</td></tr>" +
448
+ '</table>'
449
+ )
450
+ })
451
+
452
+ it('marks multiple consecutive columns deleted from the end', () => {
453
+ const oldHtml =
454
+ '<table><tr><td>A</td><td>B1</td><td>B2</td></tr><tr><td>C</td><td>D1</td><td>D2</td></tr></table>'
455
+ const newHtml = '<table><tr><td>A</td></tr><tr><td>C</td></tr></table>'
456
+
457
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
458
+ '<table>' +
459
+ '<tr>' +
460
+ '<td>A</td>' +
461
+ "<td class='diffdel'><del class='diffdel'>B1</del></td>" +
462
+ "<td class='diffdel'><del class='diffdel'>B2</del></td>" +
463
+ '</tr>' +
464
+ '<tr>' +
465
+ '<td>C</td>' +
466
+ "<td class='diffdel'><del class='diffdel'>D1</del></td>" +
467
+ "<td class='diffdel'><del class='diffdel'>D2</del></td>" +
468
+ '</tr>' +
469
+ '</table>'
470
+ )
471
+ })
472
+ })
473
+
474
+ // Word does not track merge/split (it warns "this action won't be marked
475
+ // as a change"), so the design below is our own. Convention: render the
476
+ // new structure as-is, but tag merged/split cells with `class='mod
477
+ // colspan'` or `class='mod rowspan'` so stylesheets can show the
478
+ // structural change without the misleading visual noise of synthetic
479
+ // del/ins around content that didn't actually change. The `mod` prefix
480
+ // matches htmldiff's existing convention for "modified" markers (e.g.
481
+ // `mod strong` for bold-formatting changes).
482
+ describe('merge cells horizontally (colspan)', () => {
483
+ it('marks two adjacent cells merged into one colspan=2 cell', () => {
484
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
485
+ const newHtml = '<table><tr><td colspan="2">A B</td></tr></table>'
486
+
487
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
488
+ '<table><tr><td colspan="2" class=\'mod colspan\'>A B</td></tr></table>'
489
+ )
490
+ })
491
+
492
+ it('marks three adjacent cells merged into one colspan=3 cell', () => {
493
+ const oldHtml = '<table><tr><td>A</td><td>B</td><td>C</td></tr></table>'
494
+ const newHtml = '<table><tr><td colspan="3">A B C</td></tr></table>'
495
+
496
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
497
+ '<table><tr><td colspan="3" class=\'mod colspan\'>A B C</td></tr></table>'
498
+ )
499
+ })
500
+ })
501
+
502
+ describe('merge cells vertically (rowspan)', () => {
503
+ it('marks two stacked cells merged into one rowspan=2 cell', () => {
504
+ const oldHtml = '<table><tr><td>A</td></tr><tr><td>B</td></tr></table>'
505
+ const newHtml = '<table><tr><td rowspan="2">A B</td></tr><tr></tr></table>'
506
+
507
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
508
+ '<table><tr><td rowspan="2" class=\'mod rowspan\'>A B</td></tr><tr></tr></table>'
509
+ )
510
+ })
511
+
512
+ it('marks three stacked cells merged into one rowspan=3 cell', () => {
513
+ const oldHtml = '<table><tr><td>A</td></tr><tr><td>B</td></tr><tr><td>C</td></tr></table>'
514
+ const newHtml = '<table><tr><td rowspan="3">A B C</td></tr><tr></tr><tr></tr></table>'
515
+
516
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
517
+ '<table><tr><td rowspan="3" class=\'mod rowspan\'>A B C</td></tr><tr></tr><tr></tr></table>'
518
+ )
519
+ })
520
+ })
521
+
522
+ describe('split cells horizontally', () => {
523
+ it('marks a colspan=2 cell split into two cells', () => {
524
+ const oldHtml = '<table><tr><td colspan="2">A B</td></tr></table>'
525
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
526
+
527
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
528
+ "<table><tr><td class='mod colspan'>A</td><td class='mod colspan'>B</td></tr></table>"
529
+ )
530
+ })
531
+
532
+ it('marks a colspan=3 cell split into three cells', () => {
533
+ const oldHtml = '<table><tr><td colspan="3">A B C</td></tr></table>'
534
+ const newHtml = '<table><tr><td>A</td><td>B</td><td>C</td></tr></table>'
535
+
536
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
537
+ "<table><tr><td class='mod colspan'>A</td><td class='mod colspan'>B</td><td class='mod colspan'>C</td></tr></table>"
538
+ )
539
+ })
540
+ })
541
+
542
+ describe('split cells vertically', () => {
543
+ it('marks a rowspan=2 cell split into two cells across two rows', () => {
544
+ const oldHtml = '<table><tr><td rowspan="2">A B</td></tr><tr></tr></table>'
545
+ const newHtml = '<table><tr><td>A</td></tr><tr><td>B</td></tr></table>'
546
+
547
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
548
+ "<table><tr><td class='mod rowspan'>A</td></tr><tr><td class='mod rowspan'>B</td></tr></table>"
549
+ )
550
+ })
551
+
552
+ it('marks a rowspan=3 cell split into three cells across three rows', () => {
553
+ const oldHtml = '<table><tr><td rowspan="3">A B C</td></tr><tr></tr><tr></tr></table>'
554
+ const newHtml = '<table><tr><td>A</td></tr><tr><td>B</td></tr><tr><td>C</td></tr></table>'
555
+
556
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
557
+ "<table><tr><td class='mod rowspan'>A</td></tr><tr><td class='mod rowspan'>B</td></tr><tr><td class='mod rowspan'>C</td></tr></table>"
558
+ )
559
+ })
560
+ })
561
+
562
+ // Combined colspan + rowspan (rectangular merge): a cell with both
563
+ // colspan and rowspan absorbs a 2D region. The vertical-merge detector
564
+ // now also handles the case where the absorbed rows aren't 1-cell —
565
+ // any cell layout in old whose logical column width sums to the new
566
+ // cell's colspan and whose rowspans are all 1 is a valid match.
567
+ describe('combined colspan + rowspan merges', () => {
568
+ it('marks a 2x2 region merged into one colspan=2 rowspan=2 cell', () => {
569
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
570
+ const newHtml = '<table><tr><td colspan="2" rowspan="2">Merged</td></tr><tr></tr></table>'
571
+
572
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
573
+ '<table><tr><td colspan="2" rowspan="2" class=\'mod rowspan\'>Merged</td></tr><tr></tr></table>'
574
+ )
575
+ })
576
+
577
+ it('marks the inverse split (colspan=2 rowspan=2 cell split into 2x2)', () => {
578
+ const oldHtml = '<table><tr><td colspan="2" rowspan="2">Merged</td></tr><tr></tr></table>'
579
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
580
+
581
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
582
+ '<table>' +
583
+ "<tr><td class='mod rowspan'>A</td><td class='mod rowspan'>B</td></tr>" +
584
+ "<tr><td class='mod rowspan'>C</td><td class='mod rowspan'>D</td></tr>" +
585
+ '</table>'
586
+ )
587
+ })
588
+ })
589
+
590
+ // Coverage for the structural cases the test reviewer flagged. These
591
+ // sit at the boundary between `preprocessTables` and the word-level
592
+ // diff, or test that we don't lose attributes/wrappers in the new
593
+ // emission paths.
594
+ describe('table identity and pairing', () => {
595
+ it('returns input verbatim when both sides are identical (early-exit)', () => {
596
+ const html = '<table><tr><td>Same</td></tr></table>'
597
+ expect(HtmlDiff.execute(html, html)).toEqual(html)
598
+ })
599
+
600
+ it('only diffs the changed table when there are multiple unchanged tables alongside it', () => {
601
+ const oldHtml =
602
+ '<table><tr><td>A</td></tr></table>' +
603
+ '<table><tr><td>B</td></tr></table>' +
604
+ '<table><tr><td>C</td></tr></table>'
605
+ const newHtml =
606
+ '<table><tr><td>A</td></tr></table>' +
607
+ '<table><tr><td>X</td></tr></table>' +
608
+ '<table><tr><td>C</td></tr></table>'
609
+
610
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
611
+ '<table><tr><td>A</td></tr></table>' +
612
+ "<table><tr><td><del class='diffmod'>B</del><ins class='diffmod'>X</ins></td></tr></table>" +
613
+ '<table><tr><td>C</td></tr></table>'
614
+ )
615
+ })
616
+
617
+ it('diffs a table embedded in surrounding prose without disturbing the prose', () => {
618
+ const oldHtml = '<p>Intro</p><table><tr><td>A</td></tr></table><p>Outro</p>'
619
+ const newHtml = '<p>Intro</p><table><tr><td>B</td></tr></table><p>Outro</p>'
620
+
621
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
622
+ '<p>Intro</p>' +
623
+ "<table><tr><td><del class='diffmod'>A</del><ins class='diffmod'>B</ins></td></tr></table>" +
624
+ '<p>Outro</p>'
625
+ )
626
+ })
627
+
628
+ it('still diffs cells when the user input contains a comment that LOOKS like our placeholder', () => {
629
+ // The per-call random nonce makes collisions astronomically unlikely;
630
+ // the hard-coded `aaaa1234_0` here is harmless because the actual
631
+ // placeholder will use a different random suffix.
632
+ const oldHtml = '<table><tr><td>A</td></tr></table><!--HTMLDIFF_TABLE_aaaa1234_0-->'
633
+ const newHtml = '<table><tr><td>B</td></tr></table><!--HTMLDIFF_TABLE_aaaa1234_0-->'
634
+
635
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
636
+ "<table><tr><td><del class='diffmod'>A</del><ins class='diffmod'>B</ins></td></tr></table><!--HTMLDIFF_TABLE_aaaa1234_0-->"
637
+ )
638
+ })
639
+ })
640
+
641
+ describe('table count mismatches', () => {
642
+ // When old and new have different numbers of tables, preprocessTables
643
+ // returns null and the word-level diff handles it. The cell content
644
+ // gets ins/del markers, but the surrounding `<table>`/`<tr>`
645
+ // structural tags pass through bare. Documenting current behaviour.
646
+ it('handles a table newly injected after surrounding prose (table count old=0, new=1)', () => {
647
+ const oldHtml = '<p>Intro</p>'
648
+ const newHtml = '<p>Intro</p><table><tr><td>Clause</td><td>Term</td></tr></table>'
649
+
650
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
651
+ "<p>Intro</p><table><tr><td><ins class='diffins'>Clause</ins></td><td><ins class='diffins'>Term</ins></td></tr></table>"
652
+ )
653
+ })
654
+
655
+ it('handles a table deleted from surrounding prose (table count old=1, new=0)', () => {
656
+ const oldHtml = '<p>Hello</p><table><tr><td>A</td></tr></table>'
657
+ const newHtml = '<p>Hello</p>'
658
+
659
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
660
+ "<p>Hello</p><table><tr><td><del class='diffdel'>A</del></td></tr></table>"
661
+ )
662
+ })
663
+
664
+ it('handles old=1 vs new=2 tables', () => {
665
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
666
+ const newHtml = '<table><tr><td>A</td></tr></table><table><tr><td>B</td></tr></table>'
667
+
668
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
669
+ "<table><tr><td>A</td></tr></table><table><tr><td><ins class='diffins'>B</ins></td></tr></table>"
670
+ )
671
+ })
672
+ })
673
+
674
+ describe('table reordering (positional pairing)', () => {
675
+ // When two tables swap order, preprocessTables pairs them positionally
676
+ // (table[0] in old with table[0] in new), which produces a flat
677
+ // word-level diff over both tables — both look completely modified
678
+ // even though they were just reordered. This is a known limitation
679
+ // (Word doesn't track move/reorder either); pinned here so a future
680
+ // improvement is visible.
681
+ it('pairs swapped tables positionally and renders both as fully modified', () => {
682
+ const oldHtml = '<table><tr><td>Table1</td></tr></table><table><tr><td>Table2</td></tr></table>'
683
+ const newHtml = '<table><tr><td>Table2</td></tr></table><table><tr><td>Table1</td></tr></table>'
684
+
685
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
686
+ "<table><tr><td><del class='diffmod'>Table1</del><ins class='diffmod'>Table2</ins></td></tr></table>" +
687
+ "<table><tr><td><del class='diffmod'>Table2</del><ins class='diffmod'>Table1</ins></td></tr></table>"
688
+ )
689
+ })
690
+ })
691
+
692
+ describe('thead/tbody/tfoot wrappers', () => {
693
+ it('preserves <thead>/<tbody> when adding a row inside <tbody>', () => {
694
+ const oldHtml = '<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>A</td></tr></tbody></table>'
695
+ const newHtml =
696
+ '<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>A</td></tr><tr><td>B</td></tr></tbody></table>'
697
+
698
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
699
+ '<table><thead><tr><th>H</th></tr></thead><tbody>' +
700
+ '<tr><td>A</td></tr>' +
701
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>B</ins></td></tr>" +
702
+ '</tbody></table>'
703
+ )
704
+ })
705
+
706
+ it('preserves <thead>/<tbody> when deleting a row from <tbody>', () => {
707
+ const oldHtml =
708
+ '<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>A</td></tr><tr><td>B</td></tr></tbody></table>'
709
+ const newHtml = '<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>A</td></tr></tbody></table>'
710
+
711
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
712
+ '<table><thead><tr><th>H</th></tr></thead><tbody>' +
713
+ '<tr><td>A</td></tr>' +
714
+ "<tr class='diffdel'><td class='diffdel'><del class='diffdel'>B</del></td></tr>" +
715
+ '</tbody></table>'
716
+ )
717
+ })
718
+ })
719
+
720
+ describe('<th> cells', () => {
721
+ it('marks an added column with <th> header cells', () => {
722
+ const oldHtml = '<table><tr><th>H1</th><th>H2</th></tr><tr><td>A</td><td>B</td></tr></table>'
723
+ const newHtml = '<table><tr><th>H1</th><th>H2</th><th>H3</th></tr><tr><td>A</td><td>B</td><td>C</td></tr></table>'
724
+
725
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
726
+ '<table>' +
727
+ "<tr><th>H1</th><th>H2</th><th class='diffins'><ins class='diffins'>H3</ins></th></tr>" +
728
+ "<tr><td>A</td><td>B</td><td class='diffins'><ins class='diffins'>C</ins></td></tr>" +
729
+ '</table>'
730
+ )
731
+ })
732
+
733
+ it('marks a deleted row whose cells are <th>', () => {
734
+ const oldHtml = '<table><tr><th>H1</th><th>H2</th></tr><tr><td>A</td><td>B</td></tr></table>'
735
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
736
+
737
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
738
+ '<table>' +
739
+ "<tr class='diffdel'>" +
740
+ "<th class='diffdel'><del class='diffdel'>H1</del></th>" +
741
+ "<th class='diffdel'><del class='diffdel'>H2</del></th>" +
742
+ '</tr>' +
743
+ '<tr><td>A</td><td>B</td></tr>' +
744
+ '</table>'
745
+ )
746
+ })
747
+ })
748
+
749
+ // The user explicitly called these out: when we inject diffins/diffdel
750
+ // class markers, we must preserve any existing attributes (especially
751
+ // `class`) and the `class` injection must merge into the existing
752
+ // attribute rather than overwrite it.
753
+ describe('attribute preservation', () => {
754
+ it("preserves existing class attribute on <tr> when adding 'diffins'", () => {
755
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
756
+ const newHtml = '<table><tr><td>A</td></tr><tr class="section-header"><td>B</td></tr></table>'
757
+
758
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
759
+ '<table>' +
760
+ '<tr><td>A</td></tr>' +
761
+ "<tr class=\"section-header diffins\"><td class='diffins'><ins class='diffins'>B</ins></td></tr>" +
762
+ '</table>'
763
+ )
764
+ })
765
+
766
+ it("preserves multi-class attribute on <td> when adding 'diffdel'", () => {
767
+ const oldHtml = '<table><tr><td>A</td></tr><tr><td class="highlight important">B</td></tr></table>'
768
+ const newHtml = '<table><tr><td>A</td></tr></table>'
769
+
770
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
771
+ '<table>' +
772
+ '<tr><td>A</td></tr>' +
773
+ "<tr class='diffdel'>" +
774
+ '<td class="highlight important diffdel"><del class=\'diffdel\'>B</del></td>' +
775
+ '</tr>' +
776
+ '</table>'
777
+ )
778
+ })
779
+
780
+ it('preserves single-quoted class on <td> in inserted column', () => {
781
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
782
+ const newHtml = "<table><tr><td>A</td><td class='added-col'>B</td></tr></table>"
783
+
784
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
785
+ '<table>' + "<tr><td>A</td><td class='added-col diffins'><ins class='diffins'>B</ins></td></tr>" + '</table>'
786
+ )
787
+ })
788
+
789
+ it('preserves arbitrary attributes (id, style, data-*) on a <td> in an inserted row', () => {
790
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
791
+ const newHtml = '<table><tr><td>A</td></tr><tr><td id="row2" data-key="b" style="color:red">B</td></tr></table>'
792
+
793
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
794
+ '<table>' +
795
+ '<tr><td>A</td></tr>' +
796
+ "<tr class='diffins'>" +
797
+ '<td id="row2" data-key="b" style="color:red" class=\'diffins\'>' +
798
+ "<ins class='diffins'>B</ins>" +
799
+ '</td>' +
800
+ '</tr>' +
801
+ '</table>'
802
+ )
803
+ })
804
+
805
+ it('preserves <table> attributes verbatim from new (no diff marker on attribute changes)', () => {
806
+ const oldHtml = '<table border="1"><tr><td>A</td></tr></table>'
807
+ const newHtml = '<table border="2" style="width:100%"><tr><td>A</td></tr></table>'
808
+
809
+ // Same content → table attributes flow through from new without
810
+ // a diff marker. This is intentional (Word doesn't track table
811
+ // attribute changes either) but documented as a regression anchor.
812
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
813
+ '<table border="2" style="width:100%"><tr><td>A</td></tr></table>'
814
+ )
815
+ })
816
+
817
+ it('preserves <colgroup>/<col> within a same-dimension table', () => {
818
+ const oldHtml = '<table><colgroup><col span="2"/></colgroup><tr><td>Foo</td><td>Bar</td></tr></table>'
819
+ const newHtml = '<table><colgroup><col span="2"/></colgroup><tr><td>Foo</td><td>Baz</td></tr></table>'
820
+
821
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
822
+ '<table><colgroup><col span="2"/></colgroup>' +
823
+ "<tr><td>Foo</td><td><del class='diffmod'>Bar</del><ins class='diffmod'>Baz</ins></td></tr>" +
824
+ '</table>'
825
+ )
826
+ })
827
+
828
+ it('does not duplicate a class when the same class would be injected twice', () => {
829
+ // Edge case: a `<td>` that already carries `diffins` (from a prior
830
+ // run, or a hand-authored input) should not pick up a second
831
+ // `diffins` token in the class attribute.
832
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
833
+ const newHtml = '<table><tr><td>A</td></tr><tr><td class="diffins">B</td></tr></table>'
834
+
835
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
836
+ '<table>' +
837
+ '<tr><td>A</td></tr>' +
838
+ "<tr class='diffins'><td class=\"diffins\"><ins class='diffins'>B</ins></td></tr>" +
839
+ '</table>'
840
+ )
841
+ })
842
+ })
843
+
844
+ describe('nested tables', () => {
845
+ it('diffs a nested cell change without disturbing the outer table', () => {
846
+ const oldHtml = '<table><tr><td><table><tr><td>inner A</td></tr></table></td></tr></table>'
847
+ const newHtml = '<table><tr><td><table><tr><td>inner B</td></tr></table></td></tr></table>'
848
+
849
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
850
+ '<table><tr><td>' +
851
+ "<table><tr><td>inner <del class='diffmod'>A</del><ins class='diffmod'>B</ins></td></tr></table>" +
852
+ '</td></tr></table>'
853
+ )
854
+ })
855
+ })
856
+
857
+ describe('whitespace handling', () => {
858
+ it('treats whitespace-only differences in row HTML as equal (no spurious diff)', () => {
859
+ const oldHtml = '<table>\n <tr>\n <td>A</td>\n </tr>\n</table>'
860
+ const newHtml = '<table><tr><td>A</td></tr></table>'
861
+
862
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual('<table><tr><td>A</td></tr></table>')
863
+ })
864
+ })
865
+
866
+ describe('row deletion with empty cells', () => {
867
+ it('marks empty cells in a deleted row with diffdel even though they have no content', () => {
868
+ const oldHtml = '<table><tr><td>A</td><td></td><td>C</td></tr><tr><td>D</td><td></td><td>F</td></tr></table>'
869
+ const newHtml = '<table><tr><td>A</td><td></td><td>C</td></tr></table>'
870
+
871
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
872
+ '<table>' +
873
+ '<tr><td>A</td><td></td><td>C</td></tr>' +
874
+ "<tr class='diffdel'>" +
875
+ "<td class='diffdel'><del class='diffdel'>D</del></td>" +
876
+ "<td class='diffdel'></td>" +
877
+ "<td class='diffdel'><del class='diffdel'>F</del></td>" +
878
+ '</tr>' +
879
+ '</table>'
880
+ )
881
+ })
882
+ })
883
+
884
+ // Block-level cell content is the most common shape in legal-doc tables —
885
+ // cells routinely contain `<p>`-wrapped paragraphs, `<ol>`/`<ul>` lists,
886
+ // and `<div>`-wrapped sections. The wrapInlineTextRuns helper walks
887
+ // through tags transparently and only wraps non-whitespace text runs, so
888
+ // `<ins>`/`<del>` ends up *inside* every block-level container — keeping
889
+ // the output as valid HTML (the spec disallows `<ins>` directly wrapping
890
+ // a `<p>`, but `<p><ins>...</ins></p>` is fine).
891
+ describe('block-level cell content', () => {
892
+ it('wraps each paragraph independently in an inserted row with multi-paragraph cells', () => {
893
+ const oldHtml = '<table><tr><td>Header</td></tr></table>'
894
+ const newHtml =
895
+ '<table><tr><td>Header</td></tr><tr><td><p>Paragraph one</p><p>Paragraph two</p></td></tr></table>'
896
+
897
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
898
+ '<table>' +
899
+ '<tr><td>Header</td></tr>' +
900
+ "<tr class='diffins'>" +
901
+ "<td class='diffins'>" +
902
+ "<p><ins class='diffins'>Paragraph one</ins></p>" +
903
+ "<p><ins class='diffins'>Paragraph two</ins></p>" +
904
+ '</td>' +
905
+ '</tr>' +
906
+ '</table>'
907
+ )
908
+ })
909
+
910
+ it('wraps each paragraph independently in a deleted row with multi-paragraph cells', () => {
911
+ const oldHtml = '<table><tr><td><p>Paragraph one</p><p>Paragraph two</p></td></tr><tr><td>Keep</td></tr></table>'
912
+ const newHtml = '<table><tr><td>Keep</td></tr></table>'
913
+
914
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
915
+ '<table>' +
916
+ "<tr class='diffdel'>" +
917
+ "<td class='diffdel'>" +
918
+ "<p><del class='diffdel'>Paragraph one</del></p>" +
919
+ "<p><del class='diffdel'>Paragraph two</del></p>" +
920
+ '</td>' +
921
+ '</tr>' +
922
+ '<tr><td>Keep</td></tr>' +
923
+ '</table>'
924
+ )
925
+ })
926
+
927
+ it('wraps text inside paragraphs containing inline formatting in an inserted row', () => {
928
+ // Legal-doc shape: a paragraph with bold + italic in the middle.
929
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
930
+ const newHtml =
931
+ '<table><tr><td>A</td></tr><tr><td><p>The <strong>Cross-Default</strong> provisions of <em>Section 5(a)</em> apply.</p></td></tr></table>'
932
+
933
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
934
+ '<table>' +
935
+ '<tr><td>A</td></tr>' +
936
+ "<tr class='diffins'><td class='diffins'>" +
937
+ '<p>' +
938
+ "<ins class='diffins'>The </ins>" +
939
+ "<strong><ins class='diffins'>Cross-Default</ins></strong>" +
940
+ "<ins class='diffins'> provisions of </ins>" +
941
+ "<em><ins class='diffins'>Section 5(a)</ins></em>" +
942
+ "<ins class='diffins'> apply.</ins>" +
943
+ '</p>' +
944
+ '</td></tr>' +
945
+ '</table>'
946
+ )
947
+ })
948
+
949
+ it('wraps each list item independently in an inserted row with an <ol>', () => {
950
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
951
+ const newHtml =
952
+ '<table><tr><td>A</td></tr><tr><td><ol data-type="a"><li><p>First</p></li><li><p>Second</p></li></ol></td></tr></table>'
953
+
954
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
955
+ '<table>' +
956
+ '<tr><td>A</td></tr>' +
957
+ "<tr class='diffins'><td class='diffins'>" +
958
+ '<ol data-type="a">' +
959
+ "<li><p><ins class='diffins'>First</ins></p></li>" +
960
+ "<li><p><ins class='diffins'>Second</ins></p></li>" +
961
+ '</ol>' +
962
+ '</td></tr>' +
963
+ '</table>'
964
+ )
965
+ })
966
+
967
+ it('wraps each list item independently in a deleted row with a <ul>', () => {
968
+ const oldHtml = '<table><tr><td><ul><li>Item 1</li><li>Item 2</li></ul></td></tr><tr><td>Keep</td></tr></table>'
969
+ const newHtml = '<table><tr><td>Keep</td></tr></table>'
970
+
971
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
972
+ '<table>' +
973
+ "<tr class='diffdel'><td class='diffdel'>" +
974
+ '<ul>' +
975
+ "<li><del class='diffdel'>Item 1</del></li>" +
976
+ "<li><del class='diffdel'>Item 2</del></li>" +
977
+ '</ul>' +
978
+ '</td></tr>' +
979
+ '<tr><td>Keep</td></tr>' +
980
+ '</table>'
981
+ )
982
+ })
983
+
984
+ it('wraps text inside a div-wrapped paragraph in an inserted row', () => {
985
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
986
+ const newHtml =
987
+ '<table><tr><td>A</td></tr><tr><td><div class="justify"><p>Inside a div</p></div></td></tr></table>'
988
+
989
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
990
+ '<table>' +
991
+ '<tr><td>A</td></tr>' +
992
+ "<tr class='diffins'><td class='diffins'>" +
993
+ '<div class="justify"><p><ins class=\'diffins\'>Inside a div</ins></p></div>' +
994
+ '</td></tr>' +
995
+ '</table>'
996
+ )
997
+ })
998
+
999
+ it('preserves an empty <p> as-is in an inserted row (no spurious <ins> on whitespace)', () => {
1000
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1001
+ const newHtml = '<table><tr><td>A</td></tr><tr><td><p>Real content</p><p></p></td></tr></table>'
1002
+
1003
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1004
+ '<table>' +
1005
+ '<tr><td>A</td></tr>' +
1006
+ "<tr class='diffins'><td class='diffins'>" +
1007
+ "<p><ins class='diffins'>Real content</ins></p>" +
1008
+ '<p></p>' +
1009
+ '</td></tr>' +
1010
+ '</table>'
1011
+ )
1012
+ })
1013
+
1014
+ it('handles a paragraph with a hyperlink in an inserted row (anchor passes through, text wraps)', () => {
1015
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1016
+ const newHtml =
1017
+ '<table><tr><td>A</td></tr><tr><td><p>See <a href="http://example.com">the docs</a> for details.</p></td></tr></table>'
1018
+
1019
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1020
+ '<table>' +
1021
+ '<tr><td>A</td></tr>' +
1022
+ "<tr class='diffins'><td class='diffins'>" +
1023
+ '<p>' +
1024
+ "<ins class='diffins'>See </ins>" +
1025
+ '<a href="http://example.com">' +
1026
+ "<ins class='diffins'>the docs</ins>" +
1027
+ '</a>' +
1028
+ "<ins class='diffins'> for details.</ins>" +
1029
+ '</p>' +
1030
+ '</td></tr>' +
1031
+ '</table>'
1032
+ )
1033
+ })
1034
+
1035
+ it('handles a heading inside a cell in an inserted row', () => {
1036
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1037
+ const newHtml = '<table><tr><td>A</td></tr><tr><td><h3>Section heading</h3><p>Body text</p></td></tr></table>'
1038
+
1039
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1040
+ '<table>' +
1041
+ '<tr><td>A</td></tr>' +
1042
+ "<tr class='diffins'><td class='diffins'>" +
1043
+ "<h3><ins class='diffins'>Section heading</ins></h3>" +
1044
+ "<p><ins class='diffins'>Body text</ins></p>" +
1045
+ '</td></tr>' +
1046
+ '</table>'
1047
+ )
1048
+ })
1049
+
1050
+ it('preserves a self-closing <br/> inside a paragraph in an inserted row', () => {
1051
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1052
+ const newHtml = '<table><tr><td>A</td></tr><tr><td><p>line one<br/>line two</p></td></tr></table>'
1053
+
1054
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1055
+ '<table>' +
1056
+ '<tr><td>A</td></tr>' +
1057
+ "<tr class='diffins'><td class='diffins'>" +
1058
+ "<p><ins class='diffins'>line one</ins><br/><ins class='diffins'>line two</ins></p>" +
1059
+ '</td></tr>' +
1060
+ '</table>'
1061
+ )
1062
+ })
1063
+ })
1064
+
1065
+ // Adversarial / hardening cases surfaced by the second-pass review.
1066
+ // These pin behaviour that was previously broken or untested for inputs
1067
+ // containing comments, CDATA, mixed-case tags, foreign attribute values
1068
+ // that look like class= patterns, etc.
1069
+ describe('hostile / adversarial inputs', () => {
1070
+ it('passes an HTML comment with embedded > through cell content unmolested', () => {
1071
+ // Word-exported HTML routinely has comments with `>` inside (e.g.
1072
+ // conditional comments). Before the parser fix, the scanner cut
1073
+ // the comment at the inner `>` and wrapped half of it in <ins>.
1074
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1075
+ const newHtml = '<table><tr><td>A</td></tr><tr><td><!-- note: >5% threshold -->text</td></tr></table>'
1076
+
1077
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1078
+ '<table>' +
1079
+ '<tr><td>A</td></tr>' +
1080
+ "<tr class='diffins'><td class='diffins'>" +
1081
+ "<!-- note: >5% threshold --><ins class='diffins'>text</ins>" +
1082
+ '</td></tr>' +
1083
+ '</table>'
1084
+ )
1085
+ })
1086
+
1087
+ it('handles a CDATA section inside cell content', () => {
1088
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1089
+ const newHtml = '<table><tr><td>A</td></tr><tr><td><![CDATA[ x > y ]]>text</td></tr></table>'
1090
+
1091
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1092
+ '<table>' +
1093
+ '<tr><td>A</td></tr>' +
1094
+ "<tr class='diffins'><td class='diffins'>" +
1095
+ "<![CDATA[ x > y ]]><ins class='diffins'>text</ins>" +
1096
+ '</td></tr>' +
1097
+ '</table>'
1098
+ )
1099
+ })
1100
+
1101
+ it('finds the real class attribute even when a foreign attribute contains `class=`-like text', () => {
1102
+ // injectClass previously used a flat regex that could match inside
1103
+ // any quoted attribute value. A `<td title="see class='x'">` would
1104
+ // get its `title` mangled and never receive the diff class. The
1105
+ // attribute-aware walker handles this correctly.
1106
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1107
+ const newHtml = '<table><tr><td>A</td></tr><tr><td title="see class=\'important\' note">B</td></tr></table>'
1108
+
1109
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1110
+ '<table>' +
1111
+ '<tr><td>A</td></tr>' +
1112
+ "<tr class='diffins'>" +
1113
+ "<td title=\"see class='important' note\" class='diffins'>" +
1114
+ "<ins class='diffins'>B</ins>" +
1115
+ '</td></tr>' +
1116
+ '</table>'
1117
+ )
1118
+ })
1119
+
1120
+ it('does not duplicate "mod" when the cell already has a partial overlap with the multi-word class', () => {
1121
+ // injectClass: existing class "mod" + injecting "mod colspan" →
1122
+ // result must be "mod colspan", not "mod mod colspan".
1123
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
1124
+ const newHtml = '<table><tr><td colspan="2" class="mod">AB</td></tr><tr><td>C</td><td>D</td></tr></table>'
1125
+
1126
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1127
+ '<table>' +
1128
+ '<tr><td colspan="2" class="mod colspan">AB</td></tr>' +
1129
+ '<tr><td>C</td><td>D</td></tr>' +
1130
+ '</table>'
1131
+ )
1132
+ })
1133
+
1134
+ it('handles mixed-case tag names', () => {
1135
+ const oldHtml = '<TABLE><TR><Td>A</Td><Td>B</Td></TR></TABLE>'
1136
+ const newHtml = '<TABLE><TR><Td>A</Td><Td>C</Td></TR></TABLE>'
1137
+
1138
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1139
+ "<TABLE><TR><Td>A</Td><Td><del class='diffmod'>B</del><ins class='diffmod'>C</ins></Td></TR></TABLE>"
1140
+ )
1141
+ })
1142
+
1143
+ it('handles whitespace inside opening tags', () => {
1144
+ const oldHtml = '<table><tr><td class = "highlight" >A</td></tr></table>'
1145
+ const newHtml = '<table><tr><td class = "highlight" >B</td></tr></table>'
1146
+
1147
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1148
+ "<table><tr><td class = \"highlight\" ><del class='diffmod'>A</del><ins class='diffmod'>B</ins></td></tr></table>"
1149
+ )
1150
+ })
1151
+
1152
+ it('handles attribute values containing > inside quotes', () => {
1153
+ const oldHtml = '<table><tr><td data-cond="x > 0">Old</td></tr></table>'
1154
+ const newHtml = '<table><tr><td data-cond="x > 0">New</td></tr></table>'
1155
+
1156
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1157
+ "<table><tr><td data-cond=\"x > 0\"><del class='diffmod'>Old</del><ins class='diffmod'>New</ins></td></tr></table>"
1158
+ )
1159
+ })
1160
+
1161
+ it('falls back to the word-level diff when a table exceeds the row-count safety cap', () => {
1162
+ // Construct a table with 1600 rows (above MAX_TABLE_ROWS=1500).
1163
+ // preprocessTables should bail and let the word-level diff handle
1164
+ // it. The output is whatever the word-level diff produces; we
1165
+ // assert it contains the cell content (so the diff didn't crash)
1166
+ // and that it does NOT contain the structural-aware class markers.
1167
+ const buildBigTable = (cellContent: (r: number) => string) => {
1168
+ const out: string[] = ['<table>']
1169
+ for (let r = 0; r < 1600; r++) out.push(`<tr><td>${cellContent(r)}</td></tr>`)
1170
+ out.push('</table>')
1171
+ return out.join('')
1172
+ }
1173
+ const oldHtml = buildBigTable(r => `Row ${r}`)
1174
+ const newHtml = buildBigTable(r => (r === 100 ? `Row ${r} edited` : `Row ${r}`))
1175
+
1176
+ const result = HtmlDiff.execute(oldHtml, newHtml)
1177
+ // The word-level diff should produce *some* del/ins for the change.
1178
+ expect(result).toContain('edited')
1179
+ // But not the structural-aware class markers, because the cap kicked in.
1180
+ expect(result).not.toContain("class='mod colspan'")
1181
+ })
1182
+
1183
+ it('does not infinite-loop on an unclosed table tag', () => {
1184
+ // Malformed HTML — `<table>` with no `</table>`. findTopLevelTables
1185
+ // should return -1 from findMatchingClosingTag and skip the table,
1186
+ // letting preprocessTables fall back to the word-level diff.
1187
+ const oldHtml = '<table><tr><td>A</td></tr>'
1188
+ const newHtml = '<table><tr><td>B</td></tr>'
1189
+
1190
+ const result = HtmlDiff.execute(oldHtml, newHtml)
1191
+ expect(result).toContain('A')
1192
+ expect(result).toContain('B')
1193
+ })
1194
+
1195
+ it('handles cell content that is whitespace-only without emitting a spurious <ins>', () => {
1196
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1197
+ const newHtml = '<table><tr><td>A</td></tr><tr><td>\n \t</td></tr></table>'
1198
+
1199
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1200
+ '<table>' + '<tr><td>A</td></tr>' + "<tr class='diffins'><td class='diffins'>\n \t</td></tr>" + '</table>'
1201
+ )
1202
+ })
1203
+ })
1204
+
1205
+ describe('combined edits', () => {
1206
+ it('handles a column added together with a new row inserted in the middle', () => {
1207
+ // Both row count AND cell count change — exercises the
1208
+ // diffStructurallyAlignedTable path with fuzzy row matching
1209
+ // followed by per-row cell-level diff.
1210
+ const oldHtml = '<table><tr><td>A</td><td>B</td></tr><tr><td>C</td><td>D</td></tr></table>'
1211
+ const newHtml =
1212
+ '<table><tr><td>A</td><td>B</td><td>E</td></tr><tr><td>New</td><td>Row</td><td>F</td></tr><tr><td>C</td><td>D</td><td>G</td></tr></table>'
1213
+
1214
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1215
+ '<table>' +
1216
+ "<tr><td>A</td><td>B</td><td class='diffins'><ins class='diffins'>E</ins></td></tr>" +
1217
+ "<tr class='diffins'>" +
1218
+ "<td class='diffins'><ins class='diffins'>New</ins></td>" +
1219
+ "<td class='diffins'><ins class='diffins'>Row</ins></td>" +
1220
+ "<td class='diffins'><ins class='diffins'>F</ins></td>" +
1221
+ '</tr>' +
1222
+ "<tr><td>C</td><td>D</td><td class='diffins'><ins class='diffins'>G</ins></td></tr>" +
1223
+ '</table>'
1224
+ )
1225
+ })
1226
+
1227
+ it('places a new row inserted at the start above an edited row (fuzzy matching)', () => {
1228
+ // Order-preservation regression: previously the (paired old,
1229
+ // paired new) entry was emitted at the del's alignment position
1230
+ // which could put it BEFORE the unpaired ins, violating cursor
1231
+ // monotonicity. With the fix, the paired entry is emitted at the
1232
+ // ins position so output order matches new-side order.
1233
+ const oldHtml = '<table><tr><td>Edited row</td></tr><tr><td>Same row</td></tr></table>'
1234
+ const newHtml = '<table><tr><td>New row</td></tr><tr><td>Edited rowX</td></tr><tr><td>Same row</td></tr></table>'
1235
+
1236
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1237
+ '<table>' +
1238
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>New row</ins></td></tr>" +
1239
+ "<tr><td>Edited <del class='diffmod'>row</del><ins class='diffmod'>rowX</ins></td></tr>" +
1240
+ '<tr><td>Same row</td></tr>' +
1241
+ '</table>'
1242
+ )
1243
+ })
1244
+
1245
+ it('handles an edit to a colspan-spanning section row alongside a normal-row content edit', () => {
1246
+ // Real-world legal-doc shape: header row, a colspan section
1247
+ // header, and content rows below. Editing one cell of one of the
1248
+ // content rows shouldn't disturb the colspan row.
1249
+ const oldHtml =
1250
+ '<table>' +
1251
+ '<tr><th>Label</th><th>A</th><th>B</th></tr>' +
1252
+ '<tr><td colspan="3">Section header</td></tr>' +
1253
+ '<tr><td>Row 1</td><td>Old A</td><td>Same B</td></tr>' +
1254
+ '</table>'
1255
+ const newHtml =
1256
+ '<table>' +
1257
+ '<tr><th>Label</th><th>A</th><th>B</th></tr>' +
1258
+ '<tr><td colspan="3">Section header</td></tr>' +
1259
+ '<tr><td>Row 1</td><td>New A</td><td>Same B</td></tr>' +
1260
+ '</table>'
1261
+
1262
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1263
+ '<table>' +
1264
+ '<tr><th>Label</th><th>A</th><th>B</th></tr>' +
1265
+ '<tr><td colspan="3">Section header</td></tr>' +
1266
+ "<tr><td>Row 1</td><td><del class='diffmod'>Old</del><ins class='diffmod'>New</ins> A</td><td>Same B</td></tr>" +
1267
+ '</table>'
1268
+ )
1269
+ })
1270
+
1271
+ it('handles a rowspan cell sharing a row with normal cells (column-add adjacency)', () => {
1272
+ // The rowspan'd cell occupies row 0 col 0 and row 1's col 0 slot
1273
+ // (absorbed). Old has rowspan=2 in col 0 + col 1 in row 0 + col
1274
+ // 0-of-row-2 in row 1. New adds a column on the right: same
1275
+ // rowspan structure, but row 1 has 2 cells (col 0 in row 1 is the
1276
+ // absorbed col, col 1 is the existing C, col 2 is new D).
1277
+ // detectVerticalMerge bails (multi-cell row), so this falls
1278
+ // through to per-row diff with cell-level LCS.
1279
+ const oldHtml = '<table>' + '<tr><td rowspan="2">A</td><td>B</td></tr>' + '<tr><td>C</td></tr>' + '</table>'
1280
+ const newHtml =
1281
+ '<table>' +
1282
+ '<tr><td rowspan="2">A prime</td><td>B prime</td></tr>' +
1283
+ '<tr><td>C prime</td><td>D</td></tr>' +
1284
+ '</table>'
1285
+
1286
+ // The exact emission shape is messy because the algorithm doesn't
1287
+ // model column-position-with-rowspan. Pin current behaviour as a
1288
+ // regression anchor; the cell-level changes are visible.
1289
+ const result = HtmlDiff.execute(oldHtml, newHtml)
1290
+ expect(result).toContain('rowspan="2"')
1291
+ expect(result).toContain('A')
1292
+ expect(result).toContain('prime')
1293
+ expect(result).toContain('D')
1294
+ })
1295
+ })
1296
+
1297
+ describe('block-level html5 wrappers', () => {
1298
+ it('preserves <tfoot> and diffs cell content inside it', () => {
1299
+ const oldHtml = '<table><tbody><tr><td>A</td></tr></tbody><tfoot><tr><td>Total: 1</td></tr></tfoot></table>'
1300
+ const newHtml = '<table><tbody><tr><td>A</td></tr></tbody><tfoot><tr><td>Total: 2</td></tr></tfoot></table>'
1301
+
1302
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1303
+ '<table><tbody><tr><td>A</td></tr></tbody><tfoot>' +
1304
+ "<tr><td>Total: <del class='diffmod'>1</del><ins class='diffmod'>2</ins></td></tr>" +
1305
+ '</tfoot></table>'
1306
+ )
1307
+ })
1308
+
1309
+ it('handles multiple <tbody> blocks within one table', () => {
1310
+ const oldHtml =
1311
+ '<table><tbody><tr><th>H1</th></tr></tbody><tbody></tbody><tbody><tr><td>A</td></tr></tbody></table>'
1312
+ const newHtml =
1313
+ '<table><tbody><tr><th>H1</th></tr></tbody><tbody></tbody><tbody><tr><td>A</td></tr><tr><td>B</td></tr></tbody></table>'
1314
+
1315
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1316
+ '<table><tbody><tr><th>H1</th></tr></tbody><tbody></tbody><tbody>' +
1317
+ '<tr><td>A</td></tr>' +
1318
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>B</ins></td></tr>" +
1319
+ '</tbody></table>'
1320
+ )
1321
+ })
1322
+ })
1323
+
1324
+ describe('inline elements in inserted/deleted rows', () => {
1325
+ it('passes <sup> through and wraps inner text in <ins>', () => {
1326
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1327
+ const newHtml = '<table><tr><td>A</td></tr><tr><td>ISDA SIMM<sup>TM</sup></td></tr></table>'
1328
+
1329
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1330
+ '<table>' +
1331
+ '<tr><td>A</td></tr>' +
1332
+ "<tr class='diffins'><td class='diffins'>" +
1333
+ "<ins class='diffins'>ISDA SIMM</ins>" +
1334
+ "<sup><ins class='diffins'>TM</ins></sup>" +
1335
+ '</td></tr>' +
1336
+ '</table>'
1337
+ )
1338
+ })
1339
+
1340
+ it('wraps text inside a nested <table> inside a deleted row', () => {
1341
+ const oldHtml =
1342
+ '<table>' + '<tr><td><table><tr><td>Inner A</td></tr></table></td></tr>' + '<tr><td>Keep</td></tr>' + '</table>'
1343
+ const newHtml = '<table><tr><td>Keep</td></tr></table>'
1344
+
1345
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1346
+ '<table>' +
1347
+ "<tr class='diffdel'><td class='diffdel'>" +
1348
+ '<table><tr><td>' +
1349
+ "<del class='diffdel'>Inner A</del>" +
1350
+ '</td></tr></table>' +
1351
+ '</td></tr>' +
1352
+ '<tr><td>Keep</td></tr>' +
1353
+ '</table>'
1354
+ )
1355
+ })
1356
+ })
1357
+
1358
+ describe('fuzzy threshold boundary', () => {
1359
+ it('does NOT pair rows when similarity is exactly at the threshold (strict >)', () => {
1360
+ // rowText = "abcdef" (6 chars) vs "abcxyz" (6 chars).
1361
+ // Prefix = 3 ("abc"), suffix = 0 → similarity = 3/6 = 0.5 exactly.
1362
+ // Threshold is `> 0.5` (strict), so this pair should be rejected.
1363
+ const oldHtml = '<table><tr><td>Same</td></tr><tr><td>abcdef</td></tr></table>'
1364
+ const newHtml = '<table><tr><td>Same</td></tr><tr><td>abcxyz</td></tr><tr><td>Brand new</td></tr></table>'
1365
+
1366
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1367
+ '<table>' +
1368
+ '<tr><td>Same</td></tr>' +
1369
+ "<tr class='diffdel'><td class='diffdel'><del class='diffdel'>abcdef</del></td></tr>" +
1370
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>abcxyz</ins></td></tr>" +
1371
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>Brand new</ins></td></tr>" +
1372
+ '</table>'
1373
+ )
1374
+ })
1375
+
1376
+ it('DOES pair rows when similarity is just above the threshold', () => {
1377
+ // rowText "abcdefg" (7) vs "abcdxxx" (7). Prefix = 4, suffix = 0
1378
+ // → similarity = 4/7 ≈ 0.571, above 0.5.
1379
+ const oldHtml = '<table><tr><td>Same</td></tr><tr><td>abcdefg</td></tr></table>'
1380
+ const newHtml = '<table><tr><td>Same</td></tr><tr><td>abcdxxx</td></tr><tr><td>Brand new</td></tr></table>'
1381
+
1382
+ // The cell-level diff is word-based, so "abcdefg" and "abcdxxx"
1383
+ // (no whitespace inside) are seen as one word each — the resulting
1384
+ // cell-level output is a whole-word replacement. The row-level
1385
+ // fuzzy match is what put them on the same row instead of
1386
+ // emitting two unrelated full-row entries; that's the win here.
1387
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1388
+ '<table>' +
1389
+ '<tr><td>Same</td></tr>' +
1390
+ "<tr><td><del class='diffmod'>abcdefg</del><ins class='diffmod'>abcdxxx</ins></td></tr>" +
1391
+ "<tr class='diffins'><td class='diffins'><ins class='diffins'>Brand new</ins></td></tr>" +
1392
+ '</table>'
1393
+ )
1394
+ })
1395
+ })
1396
+
1397
+ describe('attribute edge cases', () => {
1398
+ it('does not introduce a leading space when the existing class attribute is empty', () => {
1399
+ const oldHtml = '<table><tr><td>A</td></tr></table>'
1400
+ const newHtml = '<table><tr><td>A</td></tr><tr><td class="">B</td></tr></table>'
1401
+
1402
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1403
+ '<table>' +
1404
+ '<tr><td>A</td></tr>' +
1405
+ "<tr class='diffins'><td class=\"diffins\"><ins class='diffins'>B</ins></td></tr>" +
1406
+ '</table>'
1407
+ )
1408
+ })
1409
+
1410
+ it('parses unquoted span attribute values (e.g. colspan=2)', () => {
1411
+ const oldHtml = '<table><tr><td colspan=2>AB</td></tr></table>'
1412
+ const newHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
1413
+
1414
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1415
+ "<table><tr><td class='mod colspan'>A</td><td class='mod colspan'>B</td></tr></table>"
1416
+ )
1417
+ })
1418
+ })
1419
+ })