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