@createiq/htmldiff 1.1.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.
package/src/Utils.ts CHANGED
@@ -32,8 +32,39 @@ export function stripTagAttributes(word: string): string {
32
32
  return word
33
33
  }
34
34
 
35
- export function wrapText(text: string, tagName: string, cssClass: string): string {
36
- return `<${tagName} class='${cssClass}'>${text}</${tagName}>`
35
+ /**
36
+ * Optional metadata attached to a wrapped tag. Used by `executeThreeWay`
37
+ * to colour diff segments with their author (CP vs Me) via extra classes
38
+ * and `data-*` attributes; the two-way path passes nothing and gets the
39
+ * unchanged historical output.
40
+ */
41
+ export interface WrapMetadata {
42
+ /** Space-separated classes appended after `cssClass`. */
43
+ extraClasses?: string
44
+ /** `data-*` attribute map, keyed by the attribute name *without* the `data-` prefix. */
45
+ dataAttrs?: Readonly<Record<string, string>>
46
+ }
47
+
48
+ export function wrapText(text: string, tagName: string, cssClass: string, metadata?: WrapMetadata): string {
49
+ if (!metadata) return `<${tagName} class='${cssClass}'>${text}</${tagName}>`
50
+ return `<${tagName}${composeTagAttributes(cssClass, metadata)}>${text}</${tagName}>`
51
+ }
52
+
53
+ /**
54
+ * Build the attribute portion of an opening tag from a base class plus
55
+ * optional metadata. Exposed so emission paths that build opening-tag
56
+ * fragments by hand (e.g. the formatting-tag special-case in
57
+ * `HtmlDiff.insertTag`) can stay consistent with `wrapText`.
58
+ */
59
+ export function composeTagAttributes(cssClass: string, metadata: WrapMetadata): string {
60
+ const classes = metadata.extraClasses ? `${cssClass} ${metadata.extraClasses}` : cssClass
61
+ let out = ` class='${classes}'`
62
+ if (metadata.dataAttrs) {
63
+ for (const key of Object.keys(metadata.dataAttrs)) {
64
+ out += ` data-${key}='${metadata.dataAttrs[key]}'`
65
+ }
66
+ }
67
+ return out
37
68
  }
38
69
 
39
70
  export function isStartOfTag(val: string): boolean {
@@ -85,6 +116,7 @@ export default {
85
116
  isTag,
86
117
  stripTagAttributes,
87
118
  wrapText,
119
+ composeTagAttributes,
88
120
  isStartOfTag,
89
121
  isEndOfTag,
90
122
  isStartOfEntity,
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import Action from '../src/Action'
4
+ import HtmlDiff from '../src/HtmlDiff'
5
+
6
+ describe('HtmlDiff.analyze', () => {
7
+ describe('return shape', () => {
8
+ it('returns operations indexed into oldDiffWords / newDiffWords', () => {
9
+ const result = HtmlDiff.analyze('a b c', 'a x c')
10
+ expect(result.oldDiffWords).toBeInstanceOf(Array)
11
+ expect(result.newDiffWords).toBeInstanceOf(Array)
12
+ expect(result.operations).toBeInstanceOf(Array)
13
+ // Every op's endInOld must be ≤ oldDiffWords.length, etc.
14
+ for (const op of result.operations) {
15
+ expect(op.endInOld).toBeLessThanOrEqual(result.oldDiffWords.length)
16
+ expect(op.endInNew).toBeLessThanOrEqual(result.newDiffWords.length)
17
+ }
18
+ })
19
+
20
+ it('returns original word arrays alongside the diff arrays', () => {
21
+ const result = HtmlDiff.analyze('hello world', 'hello there world')
22
+ expect(result.oldOriginalWords).toBeInstanceOf(Array)
23
+ expect(result.newOriginalWords).toBeInstanceOf(Array)
24
+ })
25
+
26
+ it('returns null contentToOriginal maps when projections are inactive', () => {
27
+ // Plain text with no structural tags → projection inactive.
28
+ const result = HtmlDiff.analyze('a b c', 'a x c')
29
+ expect(result.oldContentToOriginal).toBeNull()
30
+ expect(result.newContentToOriginal).toBeNull()
31
+ })
32
+
33
+ it('returns non-null contentToOriginal maps when projections are active', () => {
34
+ // Different wrapper tags → projection kicks in.
35
+ const result = HtmlDiff.analyze('<p>hello world</p>', '<div>hello world</div>')
36
+ expect(result.oldContentToOriginal).not.toBeNull()
37
+ expect(result.newContentToOriginal).not.toBeNull()
38
+ })
39
+ })
40
+
41
+ describe('useProjections option', () => {
42
+ it('honours useProjections=false even when the heuristic would project', () => {
43
+ const result = HtmlDiff.analyze('<p>hello world</p>', '<div>hello world</div>', { useProjections: false })
44
+ expect(result.oldContentToOriginal).toBeNull()
45
+ expect(result.newContentToOriginal).toBeNull()
46
+ // Structural tags appear as diff tokens — observable consequence.
47
+ expect(result.oldDiffWords).toContain('<p>')
48
+ expect(result.newDiffWords).toContain('<div>')
49
+ })
50
+
51
+ it('honours useProjections=true even when the heuristic would skip', () => {
52
+ // Same structural tags on both sides → heuristic skips projection.
53
+ // Forcing it should still project (strip the <p> tags from diff space).
54
+ const result = HtmlDiff.analyze('<p>a b c</p>', '<p>a x c</p>', { useProjections: true })
55
+ expect(result.oldContentToOriginal).not.toBeNull()
56
+ expect(result.newContentToOriginal).not.toBeNull()
57
+ // Structural tags removed from diff arrays.
58
+ expect(result.oldDiffWords).not.toContain('<p>')
59
+ })
60
+
61
+ it('keeps projections off when useProjections=true but one side has no content', () => {
62
+ const result = HtmlDiff.analyze('<p></p>', '<p>added</p>', { useProjections: true })
63
+ // Empty-content side disables the forced projection.
64
+ expect(result.oldContentToOriginal).toBeNull()
65
+ expect(result.newContentToOriginal).toBeNull()
66
+ })
67
+ })
68
+
69
+ describe('symmetric V2 tokenisation', () => {
70
+ it('produces an identical V2 diff array across two calls when useProjections matches', () => {
71
+ const v1 = '<p>Hello world.</p>'
72
+ const v2 = '<p>Hello cruel world.</p>'
73
+ const v3 = '<p>Hello cruel world today.</p>'
74
+ const useProjections = false // Force off — both calls agree.
75
+ const d1 = HtmlDiff.analyze(v1, v2, { useProjections })
76
+ const d2 = HtmlDiff.analyze(v2, v3, { useProjections })
77
+ expect(d1.newDiffWords).toEqual(d2.oldDiffWords)
78
+ })
79
+
80
+ it('the V2 arrays diverge when one call projects and the other does not (motivates D1)', () => {
81
+ // Asymmetric structural patterns: V1 has <p>, V3 has <div>; V2 has <p>.
82
+ // V1↔V2 heuristic: no structural diff → no projection.
83
+ // V2↔V3 heuristic: structural diff → project.
84
+ // Result: d1.newDiffWords (raw V2) ≠ d2.oldDiffWords (projected V2).
85
+ const v1 = '<p>Hello world.</p>'
86
+ const v2 = '<p>Hello cruel world.</p>'
87
+ const v3 = '<div>Hello cruel world today.</div>'
88
+ const d1 = HtmlDiff.analyze(v1, v2)
89
+ const d2 = HtmlDiff.analyze(v2, v3)
90
+ // This is the bug that D1's symmetric-decision design exists to prevent.
91
+ expect(d1.newDiffWords).not.toEqual(d2.oldDiffWords)
92
+ })
93
+ })
94
+
95
+ describe('options pass-through', () => {
96
+ it('respects ignoreWhitespaceDifferences', () => {
97
+ // With the flag on, the matcher should consider two-space and
98
+ // single-space as equivalent; without it, they replace.
99
+ const withoutFlag = HtmlDiff.analyze('a b', 'a b')
100
+ const withFlag = HtmlDiff.analyze('a b', 'a b', { ignoreWhitespaceDifferences: true })
101
+ const replaceCount = (r: typeof withFlag) => r.operations.filter(op => op.action === Action.Replace).length
102
+ // Flag off: whitespace difference shows up as a Replace.
103
+ expect(replaceCount(withoutFlag)).toBeGreaterThan(0)
104
+ // Flag on: no Replace, only Equals.
105
+ expect(replaceCount(withFlag)).toBe(0)
106
+ })
107
+
108
+ it('respects blockExpressions', () => {
109
+ // Without the block expression, "01/01/2026" is split into multiple
110
+ // tokens; with it, the whole date is one token (per WordSplitter's
111
+ // BlockFinder contract — uses the `g` flag).
112
+ const dateExpr = /\d{2}\/\d{2}\/\d{4}/g
113
+ const without = HtmlDiff.analyze('on 01/01/2026 here', 'on 02/02/2027 here')
114
+ const withExpr = HtmlDiff.analyze('on 01/01/2026 here', 'on 02/02/2027 here', { blockExpressions: [dateExpr] })
115
+ expect(withExpr.oldDiffWords.length).toBeLessThan(without.oldDiffWords.length)
116
+ })
117
+ })
118
+ })
119
+
120
+ describe('HtmlDiff.evaluateProjectionApplicability', () => {
121
+ it('returns false when structures match', () => {
122
+ expect(HtmlDiff.evaluateProjectionApplicability('<p>a</p>', '<p>b</p>')).toBe(false)
123
+ })
124
+
125
+ it('returns true when structures differ (wrapper rename)', () => {
126
+ expect(HtmlDiff.evaluateProjectionApplicability('<p>a b c</p>', '<div>a b c</div>')).toBe(true)
127
+ })
128
+
129
+ it('returns false when one side has no structural tags at all', () => {
130
+ // Plain text vs wrapped HTML: shouldUseContentProjections bails.
131
+ expect(HtmlDiff.evaluateProjectionApplicability('plain text', '<p>plain text</p>')).toBe(false)
132
+ })
133
+
134
+ it('returns false when projection would empty one side', () => {
135
+ expect(HtmlDiff.evaluateProjectionApplicability('<p></p>', '<div>content</div>')).toBe(false)
136
+ })
137
+
138
+ it('lets a composer compute a symmetric decision across three inputs', () => {
139
+ const v1 = '<p>Hello world.</p>'
140
+ const v2 = '<p>Hello cruel world.</p>'
141
+ const v3 = '<div>Hello cruel world today.</div>'
142
+ const proj12 = HtmlDiff.evaluateProjectionApplicability(v1, v2)
143
+ const proj23 = HtmlDiff.evaluateProjectionApplicability(v2, v3)
144
+ // The symmetric decision is the conjunction — project iff both pairs would.
145
+ const symmetric = proj12 && proj23
146
+ expect(symmetric).toBe(false) // V1↔V2 has no structural diff.
147
+ // Both calls then use useProjections=false and V2 tokenises identically.
148
+ const d1 = HtmlDiff.analyze(v1, v2, { useProjections: symmetric })
149
+ const d2 = HtmlDiff.analyze(v2, v3, { useProjections: symmetric })
150
+ expect(d1.newDiffWords).toEqual(d2.oldDiffWords)
151
+ })
152
+ })
@@ -1586,12 +1586,13 @@ describe('HtmlDiff — tables', () => {
1586
1586
  })
1587
1587
  })
1588
1588
 
1589
- // Coverage gaps surfaced by the v8 report: the cell-LCS fallback path
1590
- // (diffStructurallyAlignedRow + cellKey + pairSimilarUnmatchedCells) is
1591
- // only entered when the per-row column delta exceeds MAX_COLUMN_DELTA
1592
- // (6) or the row's logical width exceeds MAX_COLUMN_SEARCH_WIDTH (40).
1593
- // None of the existing tests trigger that. These tests exercise the
1594
- // fallback and pin its behaviour.
1589
+ // The cell-LCS fallback path (diffStructurallyAlignedRow + cellKey +
1590
+ // pairSimilarUnmatchedCells) is now entered only when the per-row
1591
+ // column delta exceeds MAX_COLUMN_DELTA (6) — the semantic "this is a
1592
+ // row rewrite, not a column add" guard. The row-width guard
1593
+ // (MAX_COLUMN_SEARCH_WIDTH) is now defensive only since the DP is
1594
+ // O(M × N). These tests pin the fallback's behaviour for the
1595
+ // delta > 6 path.
1595
1596
  describe('cell-LCS fallback for very-wide column changes', () => {
1596
1597
  it('handles 8 columns inserted alongside existing cells (delta > MAX_COLUMN_DELTA)', () => {
1597
1598
  // Old: 3 cells. New: 11 cells (8 columns added). Exact-LCS finds
@@ -1716,11 +1717,11 @@ describe('HtmlDiff — tables', () => {
1716
1717
  )
1717
1718
  })
1718
1719
 
1719
- it('handles row wider than MAX_COLUMN_SEARCH_WIDTH (40 cells) fallback to cell-LCS', () => {
1720
+ it('handles a 50-cell row with a single column inserted at start', () => {
1720
1721
  // 50-cell row in old, 51-cell row in new (1 column added at
1721
- // start). MAX_COLUMN_SEARCH_WIDTH guard prevents the
1722
- // combinatorial search; fallback to cell-LCS which finds 50
1723
- // exact matches and the 1 new cell as an insertion.
1722
+ // start). Now stays on the DP path (MAX_COLUMN_SEARCH_WIDTH=200);
1723
+ // produces the same output as the prior cell-LCS fallback would
1724
+ // have: 1 inserted cell, 50 preserved.
1724
1725
  const oldCells = Array.from({ length: 50 }, (_, i) => `<td>c${i}</td>`).join('')
1725
1726
  const newCells = `<td>NEW</td>${oldCells}`
1726
1727
  const oldHtml = `<table><tr>${oldCells}</tr></table>`
@@ -1758,21 +1759,44 @@ describe('HtmlDiff — tables', () => {
1758
1759
  })
1759
1760
  })
1760
1761
 
1761
- // The combinatorial position search can encounter score ties when
1762
- // inserted cells have content that is similar both to each other and to
1762
+ // The column-position search can encounter score ties when inserted
1763
+ // cells have content that is similar both to each other and to
1763
1764
  // existing cells (e.g. boilerplate "N/A" in a legal schedule). The
1764
- // algorithm resolves ties by combination-iteration order, so the choice
1765
- // of which specific column gets the diffins marker is deterministic
1766
- // but not necessarily the "intuitive" one. These tests lock in the
1767
- // observed behaviour and guard against silent regressions in the
1768
- // structural shape: all original cells must survive unmarked, and the
1769
- // inserted-marker count must equal the column delta.
1770
- describe('combinatorial column search — score-tied inputs', () => {
1765
+ // algorithm's tie-breaking resolves to skipping LATER positions in
1766
+ // the longer side the lex-first-combo behaviour of the original
1767
+ // combinatorial path, now matched by "prefer pair on ties" in the DP
1768
+ // backtrack. These tests pin both the structural shape AND the exact
1769
+ // positions the diffins markers land on, so a silent shift of the
1770
+ // tie-breaking rule would fail loudly.
1771
+ describe('column-position search — score-tied inputs', () => {
1771
1772
  it('handles delta=2 with content-similar inserts (N/A boilerplate)', () => {
1772
1773
  const oldHtml = '<table><tr><td>N/A</td><td>Term</td><td>Amount</td><td>N/A</td></tr></table>'
1773
1774
  const newHtml =
1774
1775
  '<table><tr><td>N/A</td><td>N/A</td><td>Term</td><td>N/A</td><td>Amount</td><td>N/A</td></tr></table>'
1775
1776
 
1777
+ // Exact match locks in tie-breaking: the diffins markers MUST land
1778
+ // on the earliest positions that produce the optimal score (here:
1779
+ // positions 0 and 3).
1780
+ expect(HtmlDiff.execute(oldHtml, newHtml)).toEqual(
1781
+ '<table><tr>' +
1782
+ "<td class='diffins'><ins class='diffins'>N/A</ins></td>" +
1783
+ '<td>N/A</td>' +
1784
+ '<td>Term</td>' +
1785
+ "<td class='diffins'><ins class='diffins'>N/A</ins></td>" +
1786
+ '<td>Amount</td>' +
1787
+ '<td>N/A</td>' +
1788
+ '</tr></table>'
1789
+ )
1790
+ })
1791
+
1792
+ it('still passes the loose structural checks for the same inputs', () => {
1793
+ // Kept alongside the exact-match assertion above as a structural
1794
+ // safety net: if the exact form ever shifts (e.g. quote style),
1795
+ // these structural invariants still apply.
1796
+ const oldHtml = '<table><tr><td>N/A</td><td>Term</td><td>Amount</td><td>N/A</td></tr></table>'
1797
+ const newHtml =
1798
+ '<table><tr><td>N/A</td><td>N/A</td><td>Term</td><td>N/A</td><td>Amount</td><td>N/A</td></tr></table>'
1799
+
1776
1800
  const result = HtmlDiff.execute(oldHtml, newHtml)
1777
1801
  // Both inserted N/A cells must be marked diffins.
1778
1802
  const insMarkers = (result.match(/<td class='diffins'>/g) || []).length
@@ -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
+ })