@createiq/htmldiff 1.1.0 → 1.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import HtmlDiff from '../src/HtmlDiff'
4
+
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', () => {
17
+ expect(
18
+ HtmlDiff.executeThreeWay(
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>'
22
+ )
23
+ ).toBe("<table><tr><td><ins class='diffins cp' data-author='cp'>Interest </ins>Rate</td></tr></table>")
24
+ })
25
+
26
+ it('Me only changed, CP kept genesis → me attribution', () => {
27
+ expect(
28
+ HtmlDiff.executeThreeWay(
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>'
32
+ )
33
+ ).toBe("<table><tr><td>Rate<ins class='diffins me' data-author='me'>&nbsp;per annum</ins></td></tr></table>")
34
+ })
35
+
36
+ it('Settled — both made the same change → no markup', () => {
37
+ expect(
38
+ HtmlDiff.executeThreeWay(
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>'
42
+ )
43
+ ).toBe('<table><tr><td>Interest Rate</td></tr></table>')
44
+ })
45
+
46
+ it('Disagreement — different changes at the same place', () => {
47
+ expect(
48
+ HtmlDiff.executeThreeWay(
49
+ '<table><tr><td>five</td></tr></table>',
50
+ '<table><tr><td>five and a half</td></tr></table>',
51
+ '<table><tr><td>seven</td></tr></table>'
52
+ )
53
+ ).toBe(
54
+ "<table><tr><td><del class='diffdel me' data-author='me'>five</del><ins class='diffins cp' data-author='cp'>&nbsp;and a half</ins><ins class='diffins me' data-author='me'>seven</ins></td></tr></table>"
55
+ )
56
+ })
57
+
58
+ it('CP deletes content, Me kept', () => {
59
+ expect(
60
+ HtmlDiff.executeThreeWay(
61
+ '<table><tr><td>five and a half</td></tr></table>',
62
+ '<table><tr><td>five</td></tr></table>',
63
+ '<table><tr><td>five and a half</td></tr></table>'
64
+ )
65
+ ).toBe("<table><tr><td>five<del class='diffdel cp' data-author='cp'>&nbsp;and a half</del></td></tr></table>")
66
+ })
67
+
68
+ it('Me deletes content, CP kept', () => {
69
+ expect(
70
+ HtmlDiff.executeThreeWay(
71
+ '<table><tr><td>five and a half</td></tr></table>',
72
+ '<table><tr><td>five and a half</td></tr></table>',
73
+ '<table><tr><td>five</td></tr></table>'
74
+ )
75
+ ).toBe("<table><tr><td>five<del class='diffdel me' data-author='me'>&nbsp;and a half</del></td></tr></table>")
76
+ })
77
+ })
78
+
79
+ describe('scaffolding scanner edge cases', () => {
80
+ it('<thead>/<tbody> wrappers', () => {
81
+ expect(
82
+ HtmlDiff.executeThreeWay(
83
+ '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>Old</td></tr></tbody></table>',
84
+ '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>New</td></tr></tbody></table>',
85
+ '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>Old</td></tr></tbody></table>'
86
+ )
87
+ ).toBe(
88
+ "<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>"
89
+ )
90
+ })
91
+
92
+ it('<th> header cells', () => {
93
+ expect(
94
+ HtmlDiff.executeThreeWay(
95
+ '<table><tr><th>Label</th><th>Value</th></tr></table>',
96
+ '<table><tr><th>Description</th><th>Value</th></tr></table>',
97
+ '<table><tr><th>Label</th><th>Value</th></tr></table>'
98
+ )
99
+ ).toBe(
100
+ "<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>"
101
+ )
102
+ })
103
+
104
+ it('rowspan attribute preserved with author attribution on cell content', () => {
105
+ expect(
106
+ HtmlDiff.executeThreeWay(
107
+ '<table><tr><td rowspan="2">Foo</td></tr></table>',
108
+ '<table><tr><td rowspan="2">Bar</td></tr></table>',
109
+ '<table><tr><td rowspan="2">Foo</td></tr></table>'
110
+ )
111
+ ).toBe(
112
+ "<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>"
113
+ )
114
+ })
115
+
116
+ it('multi-row table with edit in one row only', () => {
117
+ expect(
118
+ HtmlDiff.executeThreeWay(
119
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr><tr><td>c</td></tr></table>',
120
+ '<table><tr><td>a</td></tr><tr><td>B</td></tr><tr><td>c</td></tr></table>',
121
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr><tr><td>c</td></tr></table>'
122
+ )
123
+ ).toBe(
124
+ "<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>"
125
+ )
126
+ })
127
+
128
+ it('identity input passes through unchanged', () => {
129
+ const text = '<table><tr><td>a</td><td>b</td></tr><tr><td>c</td><td>d</td></tr></table>'
130
+ expect(HtmlDiff.executeThreeWay(text, text, text)).toBe(text)
131
+ })
132
+ })
133
+
134
+ describe('structural row changes (rows added or removed)', () => {
135
+ it('CP inserted a row, Me kept the absence → cp-attributed row insertion', () => {
136
+ expect(
137
+ HtmlDiff.executeThreeWay(
138
+ '<table><tr><td>a</td></tr></table>',
139
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
140
+ '<table><tr><td>a</td></tr></table>'
141
+ )
142
+ ).toBe(
143
+ "<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>"
144
+ )
145
+ })
146
+
147
+ it('Me inserted a row, CP kept the absence → me-attributed row insertion', () => {
148
+ expect(
149
+ HtmlDiff.executeThreeWay(
150
+ '<table><tr><td>a</td></tr></table>',
151
+ '<table><tr><td>a</td></tr></table>',
152
+ '<table><tr><td>a</td></tr><tr><td>new</td></tr></table>'
153
+ )
154
+ ).toBe(
155
+ "<table><tr><td>a</td></tr><tr class='diffins me' data-author='me'><td class='diffins me' data-author='me'><ins class='diffins me' data-author='me'>new</ins></td></tr></table>"
156
+ )
157
+ })
158
+
159
+ it('CP deleted a row, Me kept it → cp-attributed row deletion (content from Me)', () => {
160
+ expect(
161
+ HtmlDiff.executeThreeWay(
162
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
163
+ '<table><tr><td>a</td></tr></table>',
164
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>'
165
+ )
166
+ ).toBe(
167
+ "<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>"
168
+ )
169
+ })
170
+
171
+ it('Me deleted a row, CP kept it → me-attributed row deletion', () => {
172
+ expect(
173
+ HtmlDiff.executeThreeWay(
174
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
175
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
176
+ '<table><tr><td>a</td></tr></table>'
177
+ )
178
+ ).toBe(
179
+ "<table><tr><td>a</td></tr><tr class='diffdel me' data-author='me'><td class='diffdel me' data-author='me'><del class='diffdel me' data-author='me'>b</del></td></tr></table>"
180
+ )
181
+ })
182
+
183
+ it('Both inserted same row → settled, no markup', () => {
184
+ expect(
185
+ HtmlDiff.executeThreeWay(
186
+ '<table><tr><td>a</td></tr></table>',
187
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
188
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>'
189
+ )
190
+ ).toBe('<table><tr><td>a</td></tr><tr><td>b</td></tr></table>')
191
+ })
192
+ })
193
+
194
+ describe('multi-table (table count diverges)', () => {
195
+ it('CP added a whole new table, Me kept absence → cp insertion of whole table', () => {
196
+ expect(
197
+ HtmlDiff.executeThreeWay(
198
+ '<table><tr><td>a</td></tr></table>',
199
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
200
+ '<table><tr><td>a</td></tr></table>'
201
+ )
202
+ ).toBe(
203
+ "<table><tr><td>a</td></tr></table><ins class='diffins cp' data-author='cp'><table><tr><td>b</td></tr></table></ins>"
204
+ )
205
+ })
206
+
207
+ it('Me added a whole new table, CP kept absence → me insertion', () => {
208
+ expect(
209
+ HtmlDiff.executeThreeWay(
210
+ '<table><tr><td>a</td></tr></table>',
211
+ '<table><tr><td>a</td></tr></table>',
212
+ '<table><tr><td>a</td></tr></table><table><tr><td>new</td></tr></table>'
213
+ )
214
+ ).toBe(
215
+ "<table><tr><td>a</td></tr></table><ins class='diffins me' data-author='me'><table><tr><td>new</td></tr></table></ins>"
216
+ )
217
+ })
218
+
219
+ it('CP deleted a table from genesis, Me kept it → cp deletion', () => {
220
+ expect(
221
+ HtmlDiff.executeThreeWay(
222
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
223
+ '<table><tr><td>a</td></tr></table>',
224
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>'
225
+ )
226
+ ).toBe(
227
+ "<table><tr><td>a</td></tr></table><del class='diffdel cp' data-author='cp'><table><tr><td>b</td></tr></table></del>"
228
+ )
229
+ })
230
+
231
+ it('Me deleted a table from genesis, CP kept it → me deletion', () => {
232
+ expect(
233
+ HtmlDiff.executeThreeWay(
234
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
235
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
236
+ '<table><tr><td>a</td></tr></table>'
237
+ )
238
+ ).toBe(
239
+ "<table><tr><td>a</td></tr></table><del class='diffdel me' data-author='me'><table><tr><td>b</td></tr></table></del>"
240
+ )
241
+ })
242
+
243
+ it('Both added the same new table → settled, plain output', () => {
244
+ expect(
245
+ HtmlDiff.executeThreeWay(
246
+ '<table><tr><td>orig</td></tr></table>',
247
+ '<table><tr><td>orig</td></tr></table><table><tr><td>added</td></tr></table>',
248
+ '<table><tr><td>orig</td></tr></table><table><tr><td>added</td></tr></table>'
249
+ )
250
+ ).toBe('<table><tr><td>orig</td></tr></table><table><tr><td>added</td></tr></table>')
251
+ })
252
+ })
253
+
254
+ describe('cell-level edge cases', () => {
255
+ it('exceedsSizeLimit bail-out: oversized table falls through to word-level path', () => {
256
+ // 1501-row table: preprocessTablesThreeWay returns null on size cap,
257
+ // so executeThreeWay treats the table as raw HTML.
258
+ const rows = Array.from({ length: 1501 }, (_, i) => `<tr><td>row ${i}</td></tr>`).join('')
259
+ const html = `<table>${rows}</table>`
260
+ expect(HtmlDiff.executeThreeWay(html, html, html)).toBe(html)
261
+ })
262
+ })
263
+
264
+ describe('nested tables', () => {
265
+ it('handles a table nested inside a cell — both attributed correctly', () => {
266
+ const out = HtmlDiff.executeThreeWay(
267
+ '<table><tr><td><table><tr><td>inner</td></tr></table></td></tr></table>',
268
+ '<table><tr><td><table><tr><td>INNER</td></tr></table></td></tr></table>',
269
+ '<table><tr><td><table><tr><td>inner</td></tr></table></td></tr></table>'
270
+ )
271
+ expect(out).toMatch(/<del[^>]*data-author='cp'[^>]*>inner<\/del>/)
272
+ expect(out).toMatch(/<ins[^>]*data-author='cp'[^>]*>INNER<\/ins>/)
273
+ expect(out.startsWith('<table><tr><td><table>')).toBe(true)
274
+ expect(out.endsWith('</table></td></tr></table>')).toBe(true)
275
+ })
276
+
277
+ it('deeply-nested identity tables pass through unchanged (depth cap exercised)', () => {
278
+ let v = 'leaf'
279
+ for (let i = 0; i < 10; i++) v = `<table><tr><td>${v}</td></tr></table>`
280
+ expect(HtmlDiff.executeThreeWay(v, v, v)).toBe(v)
281
+ })
282
+ })
283
+
284
+ describe('integration with surrounding prose', () => {
285
+ it('prose and table both attributed correctly', () => {
286
+ expect(
287
+ HtmlDiff.executeThreeWay(
288
+ '<p>Rates as follows:</p><table><tr><td>five</td></tr></table>',
289
+ '<p>Interest rates as follows:</p><table><tr><td>five and a half</td></tr></table>',
290
+ '<p>Rates as follows:</p><table><tr><td>five</td></tr></table>'
291
+ )
292
+ ).toBe(
293
+ "<p><del class='diffdel cp' data-author='cp'>Rates</del><ins class='diffins cp' data-author='cp'>Interest rates</ins> as follows:</p><table><tr><td>five<ins class='diffins cp' data-author='cp'>&nbsp;and a half</ins></td></tr></table>"
294
+ )
295
+ })
296
+
297
+ it('no-tables path is unaffected', () => {
298
+ expect(HtmlDiff.executeThreeWay('<p>a</p>', '<p>a</p>', '<p>a</p>')).toBe('<p>a</p>')
299
+ })
300
+ })
301
+ })
@@ -110,6 +110,45 @@ describe('TableDiff — column added', () => {
110
110
  )
111
111
  })
112
112
 
113
+ describe('TableDiff — column-position DP worst case', () => {
114
+ // The pre-DP combinatorial path was O(C(N, K)) per row. At N=40 and
115
+ // K=6 (the caps), that's 3.8M combos × ~34 string ops, multiplied by
116
+ // every preserved row. This case exercises that path: 50 preserved
117
+ // rows × 40-cell wide new (34-cell old + 6 inserted columns).
118
+ const oldHtml = buildTable(50, 34, defaultCell)
119
+ const newHtml = buildTable(50, 40, (r, c) => {
120
+ if (c < 3) return `NEW_PREFIX_r${r}_c${c}`
121
+ if (c >= 37) return `NEW_SUFFIX_r${r}_c${c}`
122
+ return defaultCell(r, c - 3)
123
+ })
124
+
125
+ bench(
126
+ '50×40 — 6 columns inserted (was C(40,6) ≈ 3.8M combos/row pre-DP)',
127
+ () => {
128
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toBeTruthy()
129
+ },
130
+ benchOptions
131
+ )
132
+ })
133
+
134
+ describe('TableDiff — column-position DP common case', () => {
135
+ // Companion to the worst-case bench above: the common case is a wide
136
+ // row with a single column added. This should stay sub-ms and not
137
+ // hide behind the worst-case headline if anything regresses.
138
+ const oldHtml = buildTable(50, 40, defaultCell)
139
+ const newHtml = buildTable(50, 41, (r, c) =>
140
+ c < 20 ? defaultCell(r, c) : c === 20 ? `Inserted col r${r}` : defaultCell(r, c - 1)
141
+ )
142
+
143
+ bench(
144
+ '50×40 — 1 column inserted (common case)',
145
+ () => {
146
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toBeTruthy()
147
+ },
148
+ benchOptions
149
+ )
150
+ })
151
+
113
152
  describe('TableDiff — horizontal merge (colspan)', () => {
114
153
  // Old: <tr><td>a</td><td>b</td><td>c</td></tr>
115
154
  // New: <tr><td colspan=2>a b</td><td>c</td></tr>
@@ -117,4 +117,52 @@ describe('Utils', () => {
117
117
  expect(Utils.isWord('b')).toBe(true)
118
118
  })
119
119
  })
120
+
121
+ describe('wrapText()', () => {
122
+ it('produces the historical shape when no metadata is passed', () => {
123
+ expect(Utils.wrapText('hello', 'ins', 'diffins')).toBe("<ins class='diffins'>hello</ins>")
124
+ })
125
+
126
+ it('appends extraClasses after the base class', () => {
127
+ expect(Utils.wrapText('hello', 'ins', 'diffins', { extraClasses: 'cp' })).toBe(
128
+ "<ins class='diffins cp'>hello</ins>"
129
+ )
130
+ })
131
+
132
+ it('emits data-* attributes for each entry in dataAttrs', () => {
133
+ expect(Utils.wrapText('hello', 'ins', 'diffins', { dataAttrs: { author: 'cp' } })).toBe(
134
+ "<ins class='diffins' data-author='cp'>hello</ins>"
135
+ )
136
+ })
137
+
138
+ it('combines extraClasses and dataAttrs in one call', () => {
139
+ expect(
140
+ Utils.wrapText('hello', 'del', 'diffdel', {
141
+ extraClasses: 'me rejects-cp',
142
+ dataAttrs: { author: 'me', rejects: 'cp' },
143
+ })
144
+ ).toBe("<del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'>hello</del>")
145
+ })
146
+
147
+ it('skips the metadata path entirely when neither extraClasses nor dataAttrs is set', () => {
148
+ // Empty metadata should still pass through the chosen branch deterministically.
149
+ expect(Utils.wrapText('hello', 'ins', 'diffins', {})).toBe("<ins class='diffins'>hello</ins>")
150
+ })
151
+ })
152
+
153
+ describe('composeTagAttributes()', () => {
154
+ it('returns just the class attribute when metadata is empty', () => {
155
+ expect(Utils.composeTagAttributes('diffins', {})).toBe(" class='diffins'")
156
+ })
157
+
158
+ it('appends extraClasses inside the class attribute', () => {
159
+ expect(Utils.composeTagAttributes('mod strong', { extraClasses: 'cp' })).toBe(" class='mod strong cp'")
160
+ })
161
+
162
+ it('includes all data-* attrs after the class attr', () => {
163
+ expect(Utils.composeTagAttributes('mod', { dataAttrs: { author: 'me', flag: 'on' } })).toBe(
164
+ " class='mod' data-author='me' data-flag='on'"
165
+ )
166
+ })
167
+ })
120
168
  })