@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,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,173 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import HtmlDiff from '../src/HtmlDiff'
4
+
5
+ /**
6
+ * Three-way diff tests under the genesis-spine model.
7
+ *
8
+ * `executeThreeWay(genesis, cpLatest, meCurrent)` compares both cp and
9
+ * me against the shared common ancestor (genesis). Each side's
10
+ * accumulated changes are attributed independently:
11
+ *
12
+ * - Both authors made the same change → emit plain (settled)
13
+ * - One author changed, the other kept the genesis content → emit
14
+ * that author's change with attribution; the kept content shows
15
+ * "pending" via the del/ins wrapping
16
+ * - Both made different changes at the same place → each shown with
17
+ * its author's attribution
18
+ */
19
+ describe('HtmlDiff.executeThreeWay (genesis-spine)', () => {
20
+ describe('attribution matrix', () => {
21
+ it('settled — both authors made the same change', () => {
22
+ // Genesis: "Hello world". Both cp and me changed it to "Hello cruel world".
23
+ // The change is settled — emit plain.
24
+ expect(HtmlDiff.executeThreeWay('Hello world', 'Hello cruel world', 'Hello cruel world')).toBe(
25
+ 'Hello cruel world'
26
+ )
27
+ })
28
+
29
+ it('CP changes a word, Me kept the genesis word', () => {
30
+ // Genesis: "Hello world". CP changed to "Hello cruel world". Me kept "Hello world".
31
+ // From Me's view: CP's insertion is pending. Render with cp attribution; Me's
32
+ // text ("world") is preserved verbatim.
33
+ expect(HtmlDiff.executeThreeWay('Hello world', 'Hello cruel world', 'Hello world')).toBe(
34
+ "Hello <ins class='diffins cp' data-author='cp'>cruel </ins>world"
35
+ )
36
+ })
37
+
38
+ it('Me changes a word, CP kept genesis', () => {
39
+ expect(HtmlDiff.executeThreeWay('Hello world', 'Hello world', 'Hello cruel world')).toBe(
40
+ "Hello <ins class='diffins me' data-author='me'>cruel </ins>world"
41
+ )
42
+ })
43
+
44
+ it('CP and Me each change the same word differently', () => {
45
+ // Genesis: "Hello world". CP made "Hello cruel world", Me made "Hello brave world".
46
+ // Disagreement — show both authors' insertions.
47
+ expect(HtmlDiff.executeThreeWay('Hello world', 'Hello cruel world', 'Hello brave world')).toBe(
48
+ "Hello <ins class='diffins cp' data-author='cp'>cruel </ins><ins class='diffins me' data-author='me'>brave </ins>world"
49
+ )
50
+ })
51
+
52
+ it('CP deletes a word, Me kept it', () => {
53
+ // Genesis: "Some really fine print". CP removed "really". Me kept it.
54
+ // Render genesis token "really" with del-cp markup — Me's text still has it.
55
+ expect(HtmlDiff.executeThreeWay('Some really fine print', 'Some fine print', 'Some really fine print')).toBe(
56
+ "Some<del class='diffdel cp' data-author='cp'>&nbsp;really</del> fine print"
57
+ )
58
+ })
59
+
60
+ it('Me deletes a word, CP kept it', () => {
61
+ expect(HtmlDiff.executeThreeWay('Some really fine print', 'Some really fine print', 'Some fine print')).toBe(
62
+ "Some<del class='diffdel me' data-author='me'>&nbsp;really</del> fine print"
63
+ )
64
+ })
65
+
66
+ it('Both authors delete the same content → settled, silenced', () => {
67
+ // Genesis: "Some really fine print". Both removed "really".
68
+ // Settled — neither the deletion nor any del markup appears.
69
+ expect(HtmlDiff.executeThreeWay('Some really fine print', 'Some fine print', 'Some fine print')).toBe(
70
+ 'Some fine print'
71
+ )
72
+ })
73
+
74
+ it('Stable across no-change rounds — V5 produces same output as V3 when V5==V3', () => {
75
+ // The user's V3/V5 invariant: when neither party changes their position
76
+ // in a subsequent turn, the diff should look identical to the previous
77
+ // turn's diff. With the genesis spine, this falls out automatically.
78
+ const genesis = 'The quick brown fox jumps over the lazy dog'
79
+ const cp = 'The fast brown fox leaps'
80
+ const me = 'The quick brown antelope leaps over the lazy pig'
81
+ const v3Output = HtmlDiff.executeThreeWay(genesis, cp, me)
82
+ const v5Output = HtmlDiff.executeThreeWay(genesis, cp, me)
83
+ expect(v5Output).toBe(v3Output)
84
+ // Sanity check the V3 output contains all four author-attributed changes
85
+ // from the user's expected output (quick→fast cp, fox→antelope me, etc.)
86
+ expect(v3Output).toMatch(/<del class='diffdel cp' data-author='cp'>quick<\/del>/)
87
+ expect(v3Output).toMatch(/<ins class='diffins cp' data-author='cp'>fast<\/ins>/)
88
+ expect(v3Output).toMatch(/<del class='diffdel me' data-author='me'>fox<\/del>/)
89
+ expect(v3Output).toMatch(/<ins class='diffins me' data-author='me'>antelope<\/ins>/)
90
+ })
91
+
92
+ it('Inverted view — switching cp and me args produces inverted attribution', () => {
93
+ // From the user's V4 example: same genesis, but from Party B's view
94
+ // cp and me swap. The output should have all attributions inverted.
95
+ const genesis = 'The quick brown fox jumps over the lazy dog'
96
+ const partyACurrent = 'The quick brown antelope leaps over the lazy pig'
97
+ const partyBCurrent = 'The fast brown fox leaps'
98
+
99
+ const aView = HtmlDiff.executeThreeWay(genesis, partyBCurrent, partyACurrent) // A is me, B is cp
100
+ const bView = HtmlDiff.executeThreeWay(genesis, partyACurrent, partyBCurrent) // B is me, A is cp
101
+
102
+ // A's view: B made fast/leaps changes (CP-attributed), A made antelope/pig (Me).
103
+ expect(aView).toMatch(/<ins class='diffins cp' data-author='cp'>fast<\/ins>/)
104
+ expect(aView).toMatch(/<ins class='diffins me' data-author='me'>antelope<\/ins>/)
105
+
106
+ // B's view: A made antelope/pig (now CP), B made fast/leaps (now Me).
107
+ expect(bView).toMatch(/<ins class='diffins me' data-author='me'>fast<\/ins>/)
108
+ expect(bView).toMatch(/<ins class='diffins cp' data-author='cp'>antelope<\/ins>/)
109
+ })
110
+ })
111
+
112
+ describe('identity inputs', () => {
113
+ it('all three identical → input verbatim', () => {
114
+ const text = '<p>Nothing changed at all.</p>'
115
+ expect(HtmlDiff.executeThreeWay(text, text, text)).toBe(text)
116
+ })
117
+
118
+ it('empty inputs produce empty output', () => {
119
+ expect(HtmlDiff.executeThreeWay('', '', '')).toBe('')
120
+ })
121
+
122
+ it('cp matches genesis (only Me changed)', () => {
123
+ expect(HtmlDiff.executeThreeWay('Hello world', 'Hello world', 'Hello brave world')).toContain("data-author='me'")
124
+ })
125
+
126
+ it('me matches genesis (only CP changed)', () => {
127
+ expect(HtmlDiff.executeThreeWay('Hello world', 'Hello cruel world', 'Hello world')).toContain("data-author='cp'")
128
+ })
129
+ })
130
+
131
+ describe('HTML structure handling', () => {
132
+ it('preserves wrapping <p> tags', () => {
133
+ expect(HtmlDiff.executeThreeWay('<p>Hello world.</p>', '<p>Hello cruel world.</p>', '<p>Hello world.</p>')).toBe(
134
+ "<p>Hello<ins class='diffins cp' data-author='cp'>&nbsp;cruel</ins> world.</p>"
135
+ )
136
+ })
137
+
138
+ it('multi-paragraph with edits in different paragraphs by each author', () => {
139
+ const genesis = '<p>First paragraph.</p><p>Second paragraph.</p>'
140
+ const cp = '<p>First paragraph edited by CP.</p><p>Second paragraph.</p>'
141
+ const me = '<p>First paragraph.</p><p>Second paragraph edited by Me.</p>'
142
+ const out = HtmlDiff.executeThreeWay(genesis, cp, me)
143
+ // CP's edit appears in the first paragraph, Me's in the second.
144
+ expect(out).toMatch(/<p>First paragraph.*data-author='cp'.*<\/p>/)
145
+ expect(out).toMatch(/<p>Second paragraph.*data-author='me'.*<\/p>/)
146
+ })
147
+ })
148
+
149
+ describe('options pass-through', () => {
150
+ it('honours ignoreWhitespaceDifferences', () => {
151
+ // Genesis: "a b" (double space). CP keeps it, Me uses "a b" (single space).
152
+ const without = HtmlDiff.executeThreeWay('a b', 'a b', 'a b')
153
+ const withFlag = HtmlDiff.executeThreeWay('a b', 'a b', 'a b', { ignoreWhitespaceDifferences: true })
154
+ expect(without).toContain("data-author='me'")
155
+ expect(withFlag).not.toContain('data-author=')
156
+ })
157
+ })
158
+
159
+ describe('first-turn fallback', () => {
160
+ it('cp == genesis means CP made no changes — Me-only attribution', () => {
161
+ // Common case: this is the first turn where the counterparty hasn't
162
+ // responded yet, so the cp version equals the genesis. Only Me's
163
+ // changes appear.
164
+ const out = HtmlDiff.executeThreeWay(
165
+ '<p>Draft contract.</p>',
166
+ '<p>Draft contract.</p>',
167
+ '<p>Draft contract with amendments.</p>'
168
+ )
169
+ expect(out).toContain("data-author='me'")
170
+ expect(out).not.toContain("data-author='cp'")
171
+ })
172
+ })
173
+ })