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

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,175 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import HtmlDiff from '../src/HtmlDiff'
4
+
5
+ describe('HtmlDiff.executeThreeWay', () => {
6
+ describe('attribution matrix', () => {
7
+ it('CP-only changes — Me made no edits (V3 == V2)', () => {
8
+ expect(HtmlDiff.executeThreeWay('Hello world.', 'Hello cruel world.', 'Hello cruel world.')).toBe(
9
+ "Hello<ins class='diffins cp' data-author='cp'>&nbsp;cruel</ins> world."
10
+ )
11
+ })
12
+
13
+ it('Me-only changes — CP made no edits (V2 == V1)', () => {
14
+ expect(HtmlDiff.executeThreeWay('Hello world.', 'Hello world.', 'Hello world today.')).toBe(
15
+ "Hello world<ins class='diffins me' data-author='me'>&nbsp;today</ins>."
16
+ )
17
+ })
18
+
19
+ it('Me keeps CP insertion (both authors visible)', () => {
20
+ expect(HtmlDiff.executeThreeWay('Hello world.', 'Hello cruel world.', 'Hello cruel world today.')).toBe(
21
+ "Hello<ins class='diffins cp' data-author='cp'>&nbsp;cruel</ins> world<ins class='diffins me' data-author='me'>&nbsp;today</ins>."
22
+ )
23
+ })
24
+
25
+ it('Me rejects CP insertion (data-rejects markup)', () => {
26
+ expect(HtmlDiff.executeThreeWay('Hello world.', 'Hello cruel world.', 'Hello world.')).toBe(
27
+ "Hello<del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'>&nbsp;cruel</del> world."
28
+ )
29
+ })
30
+
31
+ it('CP-deletion of V1 text surfaces as a CP-attributed <del>', () => {
32
+ expect(
33
+ HtmlDiff.executeThreeWay('Some really fine print here.', 'Some fine print here.', 'Some fine print here.')
34
+ ).toBe("Some<del class='diffdel cp' data-author='cp'>&nbsp;really</del> fine print here.")
35
+ })
36
+
37
+ it('Me-deletion of original V1 text (CP did nothing, Me deleted)', () => {
38
+ expect(
39
+ HtmlDiff.executeThreeWay(
40
+ 'Some really fine print here.',
41
+ 'Some really fine print here.',
42
+ 'Some fine print here.'
43
+ )
44
+ ).toBe("Some<del class='diffdel me' data-author='me'>&nbsp;really</del> fine print here.")
45
+ })
46
+
47
+ it('CP and Me Replace ops in different places (del-then-ins for both)', () => {
48
+ // CP did will→shall; Me did thirty→sixty business. Both Replaces
49
+ // must emit del-then-ins in source order, matching the 2-way
50
+ // convention so the diff reads naturally.
51
+ expect(
52
+ HtmlDiff.executeThreeWay(
53
+ 'The party will pay the fee within thirty days.',
54
+ 'The party shall pay the fee within thirty days.',
55
+ 'The party shall pay the fee within sixty business days.'
56
+ )
57
+ ).toBe(
58
+ "The party <del class='diffdel cp' data-author='cp'>will</del><ins class='diffins cp' data-author='cp'>shall</ins> pay the fee within <del class='diffdel me' data-author='me'>thirty</del><ins class='diffins me' data-author='me'>sixty business</ins> days."
59
+ )
60
+ })
61
+ })
62
+
63
+ describe('Replace-collision and tail-end edge cases', () => {
64
+ it('Replace collision — CP and Me each replace the same V2 token', () => {
65
+ // V1: "foo" → V2: "bar" (CP Replace). V2: "bar" → V3: "baz" (Me Replace).
66
+ // V2's "bar" is replaced-into-by-cp AND replaced-out-by-me → reject.
67
+ // Off-spine: V1's "foo" (cpDel) emitted before; V3's "baz" (meIns)
68
+ // emitted after the reject (mirrors the del-then-ins ordering).
69
+ expect(HtmlDiff.executeThreeWay('foo', 'bar', 'baz')).toBe(
70
+ "<del class='diffdel cp' data-author='cp'>foo</del><del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'>bar</del><ins class='diffins me' data-author='me'>baz</ins>"
71
+ )
72
+ })
73
+
74
+ it('tail-end interleavings — CP deletion + Me insertion both at the end of V2', () => {
75
+ // V1 = "Hello world" (CP deletes " world" → V2 = "Hello").
76
+ // V2 = "Hello" (Me adds " cruel" → V3 = "Hello cruel").
77
+ // Both off-spine ops land at the tail boundary; output order is
78
+ // cpDel then meIns (no V2 token to anchor around).
79
+ expect(HtmlDiff.executeThreeWay('Hello world', 'Hello', 'Hello cruel')).toBe(
80
+ "Hello<del class='diffdel cp' data-author='cp'>&nbsp;world</del><ins class='diffins me' data-author='me'>&nbsp;cruel</ins>"
81
+ )
82
+ })
83
+ })
84
+
85
+ describe('identity inputs', () => {
86
+ it('V1 == V2 == V3 returns the input verbatim', () => {
87
+ const text = '<p>Nothing changed at all.</p>'
88
+ expect(HtmlDiff.executeThreeWay(text, text, text)).toBe(text)
89
+ })
90
+
91
+ it('empty inputs produce empty output', () => {
92
+ expect(HtmlDiff.executeThreeWay('', '', '')).toBe('')
93
+ })
94
+
95
+ it('V1 == V2 collapses to a single-author diff (CP did nothing)', () => {
96
+ const out = HtmlDiff.executeThreeWay('Hello world.', 'Hello world.', 'Hello brave world.')
97
+ expect(out).toContain("data-author='me'")
98
+ expect(out).not.toContain("data-author='cp'")
99
+ })
100
+
101
+ it('V2 == V3 collapses to a single-author diff (Me did nothing)', () => {
102
+ const out = HtmlDiff.executeThreeWay('Hello world.', 'Hello cruel world.', 'Hello cruel world.')
103
+ expect(out).toContain("data-author='cp'")
104
+ expect(out).not.toContain("data-author='me'")
105
+ })
106
+ })
107
+
108
+ describe('HTML structure handling', () => {
109
+ it('preserves wrapping <p> tags around an attributed run', () => {
110
+ expect(
111
+ HtmlDiff.executeThreeWay('<p>Hello world.</p>', '<p>Hello cruel world.</p>', '<p>Hello cruel world.</p>')
112
+ ).toBe("<p>Hello<ins class='diffins cp' data-author='cp'>&nbsp;cruel</ins> world.</p>")
113
+ })
114
+
115
+ it('attributes formatting-tag edits via the special-case path', () => {
116
+ // V1 plain; CP wraps "fee" in <strong>; Me leaves it. The
117
+ // formatting-tag special case inside insertTag now carries the
118
+ // author class through to the `mod` ins wrapper.
119
+ const out = HtmlDiff.executeThreeWay(
120
+ '<p>The fee is due.</p>',
121
+ '<p>The <strong>fee</strong> is due.</p>',
122
+ '<p>The <strong>fee</strong> is due.</p>'
123
+ )
124
+ expect(out).toContain("data-author='cp'")
125
+ expect(out).toMatch(/<ins class='mod[^']*cp'/)
126
+ })
127
+
128
+ it('multi-paragraph: each author scoped to their own paragraph', () => {
129
+ expect(
130
+ HtmlDiff.executeThreeWay(
131
+ '<p>First paragraph.</p><p>Second paragraph.</p>',
132
+ '<p>First paragraph edited by CP.</p><p>Second paragraph.</p>',
133
+ '<p>First paragraph edited by CP.</p><p>Second paragraph also edited by Me.</p>'
134
+ )
135
+ ).toBe(
136
+ "<p>First paragraph<ins class='diffins cp' data-author='cp'>&nbsp;edited by CP</ins>.</p><p>Second paragraph<ins class='diffins me' data-author='me'>&nbsp;also edited by Me</ins>.</p>"
137
+ )
138
+ })
139
+ })
140
+
141
+ describe('options pass-through', () => {
142
+ it('honours ignoreWhitespaceDifferences', () => {
143
+ const without = HtmlDiff.executeThreeWay('a b', 'a b', 'a b')
144
+ const withFlag = HtmlDiff.executeThreeWay('a b', 'a b', 'a b', { ignoreWhitespaceDifferences: true })
145
+ // Without the flag, the whitespace difference triggers a Me-attributed Replace.
146
+ expect(without).toContain("data-author='me'")
147
+ // With the flag, no diff at all.
148
+ expect(withFlag).not.toContain('data-author=')
149
+ })
150
+
151
+ it('useProjections=true forces structural normalisation even when heuristic would skip', () => {
152
+ // V1==V2 has no structural diff (heuristic would skip projection),
153
+ // V2↔V3 has no structural diff either, so the symmetric default is
154
+ // also skip. Forcing useProjections=true here is a no-op functionally
155
+ // but exercises the forced-on code path.
156
+ const out = HtmlDiff.executeThreeWay('<p>a b c</p>', '<p>a b c</p>', '<p>a x c</p>', { useProjections: true })
157
+ expect(out).toContain("data-author='me'")
158
+ })
159
+ })
160
+
161
+ describe('first-turn fallback (real-world scenario)', () => {
162
+ it('falls back cleanly when only Party A has edited (V2 == V1)', () => {
163
+ // The case the user described: when Party B is drafting V2 against
164
+ // V1, the 3-way view from Party A's perspective shows only Me's
165
+ // changes — no spurious CP authorship.
166
+ const out = HtmlDiff.executeThreeWay(
167
+ '<p>Draft contract.</p>',
168
+ '<p>Draft contract.</p>',
169
+ '<p>Draft contract with amendments.</p>'
170
+ )
171
+ expect(out).toContain("data-author='me'")
172
+ expect(out).not.toContain("data-author='cp'")
173
+ })
174
+ })
175
+ })
@@ -0,0 +1,407 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import HtmlDiff from '../src/HtmlDiff'
4
+
5
+ describe('HtmlDiff.executeThreeWay (tables, positional case)', () => {
6
+ describe('cell-level attribution propagates through table scaffolding', () => {
7
+ it('CP-only edit inside a cell', () => {
8
+ expect(
9
+ HtmlDiff.executeThreeWay(
10
+ '<table><tr><td>Rate</td><td>five</td></tr></table>',
11
+ '<table><tr><td>Interest Rate</td><td>five</td></tr></table>',
12
+ '<table><tr><td>Interest Rate</td><td>five</td></tr></table>'
13
+ )
14
+ ).toBe(
15
+ "<table><tr><td><ins class='diffins cp' data-author='cp'>Interest </ins>Rate</td><td>five</td></tr></table>"
16
+ )
17
+ })
18
+
19
+ it('Me-only edit inside a cell', () => {
20
+ expect(
21
+ HtmlDiff.executeThreeWay(
22
+ '<table><tr><td>Rate</td><td>five</td></tr></table>',
23
+ '<table><tr><td>Rate</td><td>five</td></tr></table>',
24
+ '<table><tr><td>Rate</td><td>five percent</td></tr></table>'
25
+ )
26
+ ).toBe(
27
+ "<table><tr><td>Rate</td><td>five<ins class='diffins me' data-author='me'>&nbsp;percent</ins></td></tr></table>"
28
+ )
29
+ })
30
+
31
+ it('Me keeps a CP cell insertion AND adds their own to a different cell', () => {
32
+ expect(
33
+ HtmlDiff.executeThreeWay(
34
+ '<table><tr><td>Rate</td><td>five</td></tr></table>',
35
+ '<table><tr><td>Interest Rate</td><td>five</td></tr></table>',
36
+ '<table><tr><td>Interest Rate</td><td>five percent</td></tr></table>'
37
+ )
38
+ ).toBe(
39
+ "<table><tr><td><ins class='diffins cp' data-author='cp'>Interest </ins>Rate</td><td>five<ins class='diffins me' data-author='me'>&nbsp;percent</ins></td></tr></table>"
40
+ )
41
+ })
42
+
43
+ it('Me rejects a CP cell insertion (reject markup inside the cell)', () => {
44
+ expect(
45
+ HtmlDiff.executeThreeWay(
46
+ '<table><tr><td>five</td></tr></table>',
47
+ '<table><tr><td>five and a half</td></tr></table>',
48
+ '<table><tr><td>five</td></tr></table>'
49
+ )
50
+ ).toBe(
51
+ "<table><tr><td>five<del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'>&nbsp;and a half</del></td></tr></table>"
52
+ )
53
+ })
54
+
55
+ it('CP-deletion of V1 cell content', () => {
56
+ expect(
57
+ HtmlDiff.executeThreeWay(
58
+ '<table><tr><td>five and a half</td></tr></table>',
59
+ '<table><tr><td>five</td></tr></table>',
60
+ '<table><tr><td>five</td></tr></table>'
61
+ )
62
+ ).toBe("<table><tr><td>five<del class='diffdel cp' data-author='cp'>&nbsp;and a half</del></td></tr></table>")
63
+ })
64
+
65
+ it('Me-deletion of V1 cell content (CP did nothing)', () => {
66
+ expect(
67
+ HtmlDiff.executeThreeWay(
68
+ '<table><tr><td>five and a half</td></tr></table>',
69
+ '<table><tr><td>five and a half</td></tr></table>',
70
+ '<table><tr><td>five</td></tr></table>'
71
+ )
72
+ ).toBe("<table><tr><td>five<del class='diffdel me' data-author='me'>&nbsp;and a half</del></td></tr></table>")
73
+ })
74
+ })
75
+
76
+ describe('scaffolding scanner edge cases', () => {
77
+ it('preserves <thead>/<tbody> wrappers', () => {
78
+ expect(
79
+ HtmlDiff.executeThreeWay(
80
+ '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>Old</td></tr></tbody></table>',
81
+ '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>New</td></tr></tbody></table>',
82
+ '<table><thead><tr><th>Term</th></tr></thead><tbody><tr><td>New</td></tr></tbody></table>'
83
+ )
84
+ ).toBe(
85
+ "<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
+ )
87
+ })
88
+
89
+ it('handles <th> header cells correctly', () => {
90
+ // Branches on td vs th in the scanner; the contentEnd computation
91
+ // uses the right tagName length.
92
+ expect(
93
+ HtmlDiff.executeThreeWay(
94
+ '<table><tr><th>Label</th><th>Value</th></tr></table>',
95
+ '<table><tr><th>Description</th><th>Value</th></tr></table>',
96
+ '<table><tr><th>Description</th><th>Value</th></tr></table>'
97
+ )
98
+ ).toBe(
99
+ "<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
+ )
101
+ })
102
+
103
+ it('handles an empty cell (contentStart == contentEnd)', () => {
104
+ // Empty cell on both sides — no diff, just scaffolding pass-through.
105
+ expect(
106
+ HtmlDiff.executeThreeWay(
107
+ '<table><tr><td>a</td><td></td></tr></table>',
108
+ '<table><tr><td>a</td><td></td></tr></table>',
109
+ '<table><tr><td>a</td><td></td></tr></table>'
110
+ )
111
+ ).toBe('<table><tr><td>a</td><td></td></tr></table>')
112
+ })
113
+
114
+ it('handles cells with inline HTML (formatting tags)', () => {
115
+ // The formatting-tag special case kicks in inside the cell content.
116
+ const out = HtmlDiff.executeThreeWay(
117
+ '<table><tr><td>The fee is due.</td></tr></table>',
118
+ '<table><tr><td>The <strong>fee</strong> is due.</td></tr></table>',
119
+ '<table><tr><td>The <strong>fee</strong> is due.</td></tr></table>'
120
+ )
121
+ expect(out).toContain("data-author='cp'")
122
+ expect(out).toMatch(/<ins class='mod[^']*cp'/)
123
+ expect(out).toContain('<table>')
124
+ expect(out).toContain('</table>')
125
+ })
126
+
127
+ it('preserves attributes on the <table> element', () => {
128
+ expect(
129
+ HtmlDiff.executeThreeWay(
130
+ '<table class="rates"><tr><td>Old</td></tr></table>',
131
+ '<table class="rates"><tr><td>New</td></tr></table>',
132
+ '<table class="rates"><tr><td>New</td></tr></table>'
133
+ )
134
+ ).toBe(
135
+ "<table class=\"rates\"><tr><td><del class='diffdel cp' data-author='cp'>Old</del><ins class='diffins cp' data-author='cp'>New</ins></td></tr></table>"
136
+ )
137
+ })
138
+
139
+ it('multi-row table with edit in only one row', () => {
140
+ // Cursor handoff between rows must work — row 0 unchanged, row 1 edited by CP, row 2 unchanged.
141
+ expect(
142
+ HtmlDiff.executeThreeWay(
143
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr><tr><td>c</td></tr></table>',
144
+ '<table><tr><td>a</td></tr><tr><td>B</td></tr><tr><td>c</td></tr></table>',
145
+ '<table><tr><td>a</td></tr><tr><td>B</td></tr><tr><td>c</td></tr></table>'
146
+ )
147
+ ).toBe(
148
+ "<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
+ )
150
+ })
151
+
152
+ it('preserves whitespace between row/cell tags', () => {
153
+ // Real Word-sourced HTML has \n and indentation between tags;
154
+ // confirm the cursor-based emission passes it through unchanged.
155
+ const text = '<table>\n <tr>\n <td>a</td>\n </tr>\n</table>'
156
+ expect(HtmlDiff.executeThreeWay(text, text, text)).toBe(text)
157
+ })
158
+ })
159
+
160
+ describe('structural changes (rows added or removed)', () => {
161
+ it('CP-inserted row (V1 → V2 adds a row, Me kept it)', () => {
162
+ expect(
163
+ HtmlDiff.executeThreeWay(
164
+ '<table><tr><td>a</td></tr></table>',
165
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
166
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>'
167
+ )
168
+ ).toBe(
169
+ "<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
+ )
171
+ })
172
+
173
+ it('Me-inserted row (V2 → V3 adds a row, CP did nothing)', () => {
174
+ expect(
175
+ HtmlDiff.executeThreeWay(
176
+ '<table><tr><td>a</td></tr></table>',
177
+ '<table><tr><td>a</td></tr></table>',
178
+ '<table><tr><td>a</td></tr><tr><td>new</td></tr></table>'
179
+ )
180
+ ).toBe(
181
+ "<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>"
182
+ )
183
+ })
184
+
185
+ it('CP-deleted V1 row (V1 → V2 removes a row, Me kept the removal)', () => {
186
+ expect(
187
+ HtmlDiff.executeThreeWay(
188
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
189
+ '<table><tr><td>a</td></tr></table>',
190
+ '<table><tr><td>a</td></tr></table>'
191
+ )
192
+ ).toBe(
193
+ "<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
+ )
195
+ })
196
+
197
+ it('Me-deleted V1 row (V2 == V1, Me removed it in V3)', () => {
198
+ expect(
199
+ HtmlDiff.executeThreeWay(
200
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
201
+ '<table><tr><td>a</td></tr><tr><td>b</td></tr></table>',
202
+ '<table><tr><td>a</td></tr></table>'
203
+ )
204
+ ).toBe(
205
+ "<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>"
206
+ )
207
+ })
208
+
209
+ it('Me rejects CP-inserted row (CP added it, Me removed it)', () => {
210
+ expect(
211
+ HtmlDiff.executeThreeWay(
212
+ '<table><tr><td>a</td></tr></table>',
213
+ '<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>'
227
+ )
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
+ )
231
+ })
232
+ })
233
+
234
+ describe('multi-table mismatch (D3)', () => {
235
+ it('CP added a whole new table, Me kept it', () => {
236
+ expect(
237
+ HtmlDiff.executeThreeWay(
238
+ '<table><tr><td>a</td></tr></table>',
239
+ '<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
+ '<table><tr><td>a</td></tr></table>'
253
+ )
254
+ ).toBe(
255
+ "<table><tr><td>a</td></tr></table><del class='diffdel cp' data-author='cp'><table><tr><td>b</td></tr></table></del>"
256
+ )
257
+ })
258
+
259
+ it('Me added a brand-new table in V3 (CP did not touch it)', () => {
260
+ expect(
261
+ HtmlDiff.executeThreeWay(
262
+ '<table><tr><td>a</td></tr></table>',
263
+ '<table><tr><td>a</td></tr></table>',
264
+ '<table><tr><td>a</td></tr></table><table><tr><td>new</td></tr></table>'
265
+ )
266
+ ).toBe(
267
+ "<table><tr><td>a</td></tr></table><ins class='diffins me' data-author='me'><table><tr><td>new</td></tr></table></ins>"
268
+ )
269
+ })
270
+
271
+ it('Me removed a table that V1 had and CP kept (Me-deletion of preserved table)', () => {
272
+ expect(
273
+ HtmlDiff.executeThreeWay(
274
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
275
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
276
+ '<table><tr><td>a</td></tr></table>'
277
+ )
278
+ ).toBe(
279
+ "<table><tr><td>a</td></tr></table><del class='diffdel me' data-author='me'><table><tr><td>b</td></tr></table></del>"
280
+ )
281
+ })
282
+
283
+ it('Me rejected a CP-added table (CP added, Me removed)', () => {
284
+ expect(
285
+ HtmlDiff.executeThreeWay(
286
+ '<table><tr><td>a</td></tr></table>',
287
+ '<table><tr><td>a</td></tr></table><table><tr><td>b</td></tr></table>',
288
+ '<table><tr><td>a</td></tr></table>'
289
+ )
290
+ ).toBe(
291
+ "<table><tr><td>a</td></tr></table><del class='diffdel me rejects-cp' data-author='me' data-rejects='cp'><table><tr><td>b</td></tr></table></del>"
292
+ )
293
+ })
294
+
295
+ it('falls through to content-LCS when tables are reordered (counts match but positions diverge)', () => {
296
+ // V1: [Schedule of Fees, Penalty Notices]. V2 swaps them. V3 keeps the swap.
297
+ // Counts match but the positional pairing would compare unrelated
298
+ // tables — the similarity gate routes to content-LCS, which pairs
299
+ // by content regardless of position.
300
+ const fees = '<table><tr><td>Schedule of Fees</td><td>five percent annually</td></tr></table>'
301
+ const penalties = '<table><tr><td>Penalty Notices</td><td>seven days written notice</td></tr></table>'
302
+ const v1 = fees + penalties
303
+ const v2 = penalties + fees
304
+ const v3 = penalties + fees
305
+ const out = HtmlDiff.executeThreeWay(v1, v2, v3)
306
+ // Me did nothing — should produce zero Me attribution.
307
+ expect(out).not.toContain("data-author='me'")
308
+ // Both tables preserved verbatim (content-paired across positions).
309
+ // Positional pairing would have produced wild ins/del replacing
310
+ // "Schedule of Fees" with "Penalty Notices" and vice versa.
311
+ expect(out).toContain('Schedule of Fees')
312
+ expect(out).toContain('Penalty Notices')
313
+ // Output should NOT contain replace-style ins/del wrapping the
314
+ // distinctive table names (they should appear unchanged).
315
+ expect(out).not.toMatch(/<del[^>]*>Schedule/)
316
+ expect(out).not.toMatch(/<ins[^>]*>Penalty/)
317
+ })
318
+
319
+ it('both CP and Me each add their own new table', () => {
320
+ expect(
321
+ HtmlDiff.executeThreeWay(
322
+ '<table><tr><td>orig</td></tr></table>',
323
+ '<table><tr><td>orig</td></tr></table><table><tr><td>cp-added</td></tr></table>',
324
+ '<table><tr><td>orig</td></tr></table><table><tr><td>cp-added</td></tr></table><table><tr><td>me-added</td></tr></table>'
325
+ )
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
+ )
329
+ })
330
+ })
331
+
332
+ 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
+ it('exceedsSizeLimit bail-out: oversized table falls through to word-level path', () => {
348
+ // Build a table with > 1500 rows. preprocessTablesThreeWay returns
349
+ // null on size cap, so executeThreeWay treats the table as raw HTML
350
+ // — output is V2 (since V1==V2==V3 in this construction) verbatim.
351
+ const rows = Array.from({ length: 1501 }, (_, i) => `<tr><td>row ${i}</td></tr>`).join('')
352
+ const html = `<table>${rows}</table>`
353
+ // Identical inputs — output is verbatim regardless of size-cap path.
354
+ expect(HtmlDiff.executeThreeWay(html, html, html)).toBe(html)
355
+ })
356
+ })
357
+
358
+ describe('nested tables', () => {
359
+ it('handles a table nested inside a cell — both attributed correctly', () => {
360
+ // Outer table with a single cell containing an inner table. CP edits
361
+ // the inner table's content; Me doesn't touch it. The recursion
362
+ // through cellDiff should reach the inner table and attribute it.
363
+ const v1 = '<table><tr><td><table><tr><td>inner</td></tr></table></td></tr></table>'
364
+ const v2 = '<table><tr><td><table><tr><td>INNER</td></tr></table></td></tr></table>'
365
+ const v3 = '<table><tr><td><table><tr><td>INNER</td></tr></table></td></tr></table>'
366
+ const out = HtmlDiff.executeThreeWay(v1, v2, v3)
367
+ // Inner cell content attributed to CP via the recursive cellDiff.
368
+ expect(out).toMatch(/<del[^>]*data-author='cp'[^>]*>inner<\/del>/)
369
+ expect(out).toMatch(/<ins[^>]*data-author='cp'[^>]*>INNER<\/ins>/)
370
+ // Outer table scaffolding intact.
371
+ expect(out.startsWith('<table><tr><td><table>')).toBe(true)
372
+ expect(out.endsWith('</table></td></tr></table>')).toBe(true)
373
+ })
374
+
375
+ it('safely handles deeply-nested tables without infinite recursion', () => {
376
+ // Build a 10-deep nested table. The MaxThreeWayDepth cap kicks in
377
+ // at level 8, falling back to word-level treatment of deeper tables.
378
+ // The test just verifies no crash and reasonable output.
379
+ let v = 'leaf'
380
+ 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
+ expect(HtmlDiff.executeThreeWay(v, v, v)).toBe(v)
383
+ })
384
+ })
385
+
386
+ describe('integration with surrounding prose', () => {
387
+ it('attributes both the cell content AND the surrounding prose', () => {
388
+ const out = HtmlDiff.executeThreeWay(
389
+ '<p>Rates as follows:</p><table><tr><td>five</td></tr></table>',
390
+ '<p>Interest rates as follows:</p><table><tr><td>five and a half</td></tr></table>',
391
+ '<p>Interest rates as follows:</p><table><tr><td>five</td></tr></table>'
392
+ )
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
+ })
400
+
401
+ it('passes inputs with no tables through to the word-level path unchanged', () => {
402
+ // No tables → preprocessTablesThreeWay returns null → executeThreeWay
403
+ // skips straight to word-level. Sanity check the no-op.
404
+ expect(HtmlDiff.executeThreeWay('<p>a</p>', '<p>a</p>', '<p>a</p>')).toBe('<p>a</p>')
405
+ })
406
+ })
407
+ })
@@ -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>