@createiq/htmldiff 1.0.5-beta.0 → 1.0.5-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/HtmlDiff.cjs +861 -5
- package/dist/HtmlDiff.cjs.map +1 -1
- package/dist/HtmlDiff.d.cts +16 -2
- package/dist/HtmlDiff.d.mts +16 -2
- package/dist/HtmlDiff.mjs +861 -5
- package/dist/HtmlDiff.mjs.map +1 -1
- package/package.json +1 -1
- package/src/HtmlDiff.ts +51 -5
- package/src/TableDiff.ts +1269 -0
- package/test/HtmlDiff.spec.ts +1 -1
- package/test/HtmlDiff.tables.spec.ts +1458 -0
- package/test/TableDiff.bench.ts +244 -0
|
@@ -0,0 +1,1458 @@
|
|
|
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'> 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'> 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 column-add alongside content edit in the SAME row (cell-level fuzzy matching)', () => {
|
|
1272
|
+
// Real-world scenario: a column was inserted at position 1 AND
|
|
1273
|
+
// one of the existing cells got new content appended. Without
|
|
1274
|
+
// cell-level fuzzy matching, the cell-LCS exact-match misses the
|
|
1275
|
+
// "IRS Forms…" pairing, producing a 5-cell row (phantom delete +
|
|
1276
|
+
// two inserts) instead of 4 cells with one inline content edit.
|
|
1277
|
+
const oldHtml =
|
|
1278
|
+
'<table>' +
|
|
1279
|
+
'<tr><th>Party</th><th>Form</th><th>Date</th></tr>' +
|
|
1280
|
+
'<tr><td>Party A</td><td>IRS Forms W-8BEN-E and W-8ECI (or any successors thereto).</td><td>Upon execution.</td></tr>' +
|
|
1281
|
+
'<tr><td>Party B</td><td>IRS Form W-9, as applicable.</td><td>Upon execution.</td></tr>' +
|
|
1282
|
+
'</table>'
|
|
1283
|
+
const newHtml =
|
|
1284
|
+
'<table>' +
|
|
1285
|
+
'<tr><th>Party</th><th>Extra column</th><th>Form</th><th>Date</th></tr>' +
|
|
1286
|
+
"<tr><td>Party A</td><td>Yes</td><td>IRS Forms W-8BEN-E and W-8ECI (or any successors thereto). Here's some extra content</td><td>Upon execution.</td></tr>" +
|
|
1287
|
+
'<tr><td>Party B</td><td>A</td><td>IRS Form W-9, as applicable.</td><td>Upon execution.</td></tr>' +
|
|
1288
|
+
'</table>'
|
|
1289
|
+
|
|
1290
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1291
|
+
'<table>' +
|
|
1292
|
+
// Header row: extra column inserted at position 1
|
|
1293
|
+
'<tr><th>Party</th>' +
|
|
1294
|
+
"<th class='diffins'><ins class='diffins'>Extra column</ins></th>" +
|
|
1295
|
+
'<th>Form</th><th>Date</th></tr>' +
|
|
1296
|
+
// Party A row: extra column cell + content edit on the IRS Forms cell
|
|
1297
|
+
'<tr><td>Party A</td>' +
|
|
1298
|
+
"<td class='diffins'><ins class='diffins'>Yes</ins></td>" +
|
|
1299
|
+
"<td>IRS Forms W-8BEN-E and W-8ECI (or any successors thereto).<ins class='diffins'> Here's some extra content</ins></td>" +
|
|
1300
|
+
'<td>Upon execution.</td></tr>' +
|
|
1301
|
+
// Party B row: extra column cell, IRS Form W-9 cell unchanged
|
|
1302
|
+
'<tr><td>Party B</td>' +
|
|
1303
|
+
"<td class='diffins'><ins class='diffins'>A</ins></td>" +
|
|
1304
|
+
'<td>IRS Form W-9, as applicable.</td>' +
|
|
1305
|
+
'<td>Upon execution.</td></tr>' +
|
|
1306
|
+
'</table>'
|
|
1307
|
+
)
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
it('handles a rowspan cell sharing a row with normal cells (column-add adjacency)', () => {
|
|
1311
|
+
// The rowspan'd cell occupies row 0 col 0 and row 1's col 0 slot
|
|
1312
|
+
// (absorbed). Old has rowspan=2 in col 0 + col 1 in row 0 + col
|
|
1313
|
+
// 0-of-row-2 in row 1. New adds a column on the right: same
|
|
1314
|
+
// rowspan structure, but row 1 has 2 cells (col 0 in row 1 is the
|
|
1315
|
+
// absorbed col, col 1 is the existing C, col 2 is new D).
|
|
1316
|
+
// detectVerticalMerge bails (multi-cell row), so this falls
|
|
1317
|
+
// through to per-row diff with cell-level LCS.
|
|
1318
|
+
const oldHtml = '<table>' + '<tr><td rowspan="2">A</td><td>B</td></tr>' + '<tr><td>C</td></tr>' + '</table>'
|
|
1319
|
+
const newHtml =
|
|
1320
|
+
'<table>' +
|
|
1321
|
+
'<tr><td rowspan="2">A prime</td><td>B prime</td></tr>' +
|
|
1322
|
+
'<tr><td>C prime</td><td>D</td></tr>' +
|
|
1323
|
+
'</table>'
|
|
1324
|
+
|
|
1325
|
+
// The exact emission shape is messy because the algorithm doesn't
|
|
1326
|
+
// model column-position-with-rowspan. Pin current behaviour as a
|
|
1327
|
+
// regression anchor; the cell-level changes are visible.
|
|
1328
|
+
const result = HtmlDiff.execute(oldHtml, newHtml)
|
|
1329
|
+
expect(result).toContain('rowspan="2"')
|
|
1330
|
+
expect(result).toContain('A')
|
|
1331
|
+
expect(result).toContain('prime')
|
|
1332
|
+
expect(result).toContain('D')
|
|
1333
|
+
})
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
describe('block-level html5 wrappers', () => {
|
|
1337
|
+
it('preserves <tfoot> and diffs cell content inside it', () => {
|
|
1338
|
+
const oldHtml = '<table><tbody><tr><td>A</td></tr></tbody><tfoot><tr><td>Total: 1</td></tr></tfoot></table>'
|
|
1339
|
+
const newHtml = '<table><tbody><tr><td>A</td></tr></tbody><tfoot><tr><td>Total: 2</td></tr></tfoot></table>'
|
|
1340
|
+
|
|
1341
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1342
|
+
'<table><tbody><tr><td>A</td></tr></tbody><tfoot>' +
|
|
1343
|
+
"<tr><td>Total: <del class='diffmod'>1</del><ins class='diffmod'>2</ins></td></tr>" +
|
|
1344
|
+
'</tfoot></table>'
|
|
1345
|
+
)
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
it('handles multiple <tbody> blocks within one table', () => {
|
|
1349
|
+
const oldHtml =
|
|
1350
|
+
'<table><tbody><tr><th>H1</th></tr></tbody><tbody></tbody><tbody><tr><td>A</td></tr></tbody></table>'
|
|
1351
|
+
const newHtml =
|
|
1352
|
+
'<table><tbody><tr><th>H1</th></tr></tbody><tbody></tbody><tbody><tr><td>A</td></tr><tr><td>B</td></tr></tbody></table>'
|
|
1353
|
+
|
|
1354
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1355
|
+
'<table><tbody><tr><th>H1</th></tr></tbody><tbody></tbody><tbody>' +
|
|
1356
|
+
'<tr><td>A</td></tr>' +
|
|
1357
|
+
"<tr class='diffins'><td class='diffins'><ins class='diffins'>B</ins></td></tr>" +
|
|
1358
|
+
'</tbody></table>'
|
|
1359
|
+
)
|
|
1360
|
+
})
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
describe('inline elements in inserted/deleted rows', () => {
|
|
1364
|
+
it('passes <sup> through and wraps inner text in <ins>', () => {
|
|
1365
|
+
const oldHtml = '<table><tr><td>A</td></tr></table>'
|
|
1366
|
+
const newHtml = '<table><tr><td>A</td></tr><tr><td>ISDA SIMM<sup>TM</sup></td></tr></table>'
|
|
1367
|
+
|
|
1368
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1369
|
+
'<table>' +
|
|
1370
|
+
'<tr><td>A</td></tr>' +
|
|
1371
|
+
"<tr class='diffins'><td class='diffins'>" +
|
|
1372
|
+
"<ins class='diffins'>ISDA SIMM</ins>" +
|
|
1373
|
+
"<sup><ins class='diffins'>TM</ins></sup>" +
|
|
1374
|
+
'</td></tr>' +
|
|
1375
|
+
'</table>'
|
|
1376
|
+
)
|
|
1377
|
+
})
|
|
1378
|
+
|
|
1379
|
+
it('wraps text inside a nested <table> inside a deleted row', () => {
|
|
1380
|
+
const oldHtml =
|
|
1381
|
+
'<table>' + '<tr><td><table><tr><td>Inner A</td></tr></table></td></tr>' + '<tr><td>Keep</td></tr>' + '</table>'
|
|
1382
|
+
const newHtml = '<table><tr><td>Keep</td></tr></table>'
|
|
1383
|
+
|
|
1384
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1385
|
+
'<table>' +
|
|
1386
|
+
"<tr class='diffdel'><td class='diffdel'>" +
|
|
1387
|
+
'<table><tr><td>' +
|
|
1388
|
+
"<del class='diffdel'>Inner A</del>" +
|
|
1389
|
+
'</td></tr></table>' +
|
|
1390
|
+
'</td></tr>' +
|
|
1391
|
+
'<tr><td>Keep</td></tr>' +
|
|
1392
|
+
'</table>'
|
|
1393
|
+
)
|
|
1394
|
+
})
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
describe('fuzzy threshold boundary', () => {
|
|
1398
|
+
it('does NOT pair rows when similarity is exactly at the threshold (strict >)', () => {
|
|
1399
|
+
// rowText = "abcdef" (6 chars) vs "abcxyz" (6 chars).
|
|
1400
|
+
// Prefix = 3 ("abc"), suffix = 0 → similarity = 3/6 = 0.5 exactly.
|
|
1401
|
+
// Threshold is `> 0.5` (strict), so this pair should be rejected.
|
|
1402
|
+
const oldHtml = '<table><tr><td>Same</td></tr><tr><td>abcdef</td></tr></table>'
|
|
1403
|
+
const newHtml = '<table><tr><td>Same</td></tr><tr><td>abcxyz</td></tr><tr><td>Brand new</td></tr></table>'
|
|
1404
|
+
|
|
1405
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1406
|
+
'<table>' +
|
|
1407
|
+
'<tr><td>Same</td></tr>' +
|
|
1408
|
+
"<tr class='diffdel'><td class='diffdel'><del class='diffdel'>abcdef</del></td></tr>" +
|
|
1409
|
+
"<tr class='diffins'><td class='diffins'><ins class='diffins'>abcxyz</ins></td></tr>" +
|
|
1410
|
+
"<tr class='diffins'><td class='diffins'><ins class='diffins'>Brand new</ins></td></tr>" +
|
|
1411
|
+
'</table>'
|
|
1412
|
+
)
|
|
1413
|
+
})
|
|
1414
|
+
|
|
1415
|
+
it('DOES pair rows when similarity is just above the threshold', () => {
|
|
1416
|
+
// rowText "abcdefg" (7) vs "abcdxxx" (7). Prefix = 4, suffix = 0
|
|
1417
|
+
// → similarity = 4/7 ≈ 0.571, above 0.5.
|
|
1418
|
+
const oldHtml = '<table><tr><td>Same</td></tr><tr><td>abcdefg</td></tr></table>'
|
|
1419
|
+
const newHtml = '<table><tr><td>Same</td></tr><tr><td>abcdxxx</td></tr><tr><td>Brand new</td></tr></table>'
|
|
1420
|
+
|
|
1421
|
+
// The cell-level diff is word-based, so "abcdefg" and "abcdxxx"
|
|
1422
|
+
// (no whitespace inside) are seen as one word each — the resulting
|
|
1423
|
+
// cell-level output is a whole-word replacement. The row-level
|
|
1424
|
+
// fuzzy match is what put them on the same row instead of
|
|
1425
|
+
// emitting two unrelated full-row entries; that's the win here.
|
|
1426
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1427
|
+
'<table>' +
|
|
1428
|
+
'<tr><td>Same</td></tr>' +
|
|
1429
|
+
"<tr><td><del class='diffmod'>abcdefg</del><ins class='diffmod'>abcdxxx</ins></td></tr>" +
|
|
1430
|
+
"<tr class='diffins'><td class='diffins'><ins class='diffins'>Brand new</ins></td></tr>" +
|
|
1431
|
+
'</table>'
|
|
1432
|
+
)
|
|
1433
|
+
})
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
describe('attribute edge cases', () => {
|
|
1437
|
+
it('does not introduce a leading space when the existing class attribute is empty', () => {
|
|
1438
|
+
const oldHtml = '<table><tr><td>A</td></tr></table>'
|
|
1439
|
+
const newHtml = '<table><tr><td>A</td></tr><tr><td class="">B</td></tr></table>'
|
|
1440
|
+
|
|
1441
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1442
|
+
'<table>' +
|
|
1443
|
+
'<tr><td>A</td></tr>' +
|
|
1444
|
+
"<tr class='diffins'><td class=\"diffins\"><ins class='diffins'>B</ins></td></tr>" +
|
|
1445
|
+
'</table>'
|
|
1446
|
+
)
|
|
1447
|
+
})
|
|
1448
|
+
|
|
1449
|
+
it('parses unquoted span attribute values (e.g. colspan=2)', () => {
|
|
1450
|
+
const oldHtml = '<table><tr><td colspan=2>AB</td></tr></table>'
|
|
1451
|
+
const newHtml = '<table><tr><td>A</td><td>B</td></tr></table>'
|
|
1452
|
+
|
|
1453
|
+
expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
|
|
1454
|
+
"<table><tr><td class='mod colspan'>A</td><td class='mod colspan'>B</td></tr></table>"
|
|
1455
|
+
)
|
|
1456
|
+
})
|
|
1457
|
+
})
|
|
1458
|
+
})
|