@createiq/htmldiff 1.2.0-beta.3 → 1.2.0-beta.5
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/dist/HtmlDiff.cjs +89 -22
- package/dist/HtmlDiff.cjs.map +1 -1
- package/dist/HtmlDiff.d.cts +11 -16
- package/dist/HtmlDiff.d.mts +11 -16
- package/dist/HtmlDiff.mjs +89 -22
- package/dist/HtmlDiff.mjs.map +1 -1
- package/package.json +1 -1
- package/src/HtmlDiff.ts +23 -25
- package/src/ThreeWayTable.ts +125 -8
- package/test/HtmlDiff.threeWay.spec.ts +47 -2
- package/test/HtmlDiff.threeWay.tables.spec.ts +76 -0
- package/test/Utils.spec.ts +3 -3
package/package.json
CHANGED
package/src/HtmlDiff.ts
CHANGED
|
@@ -335,22 +335,6 @@ export default class HtmlDiff {
|
|
|
335
335
|
return HtmlDiff.shouldUseContentProjections(oldWords, newWords, oldProj, newProj)
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
/**
|
|
339
|
-
* Three-way HTML diff. Given V1 (the version Me last sent), V2 (the
|
|
340
|
-
* version CP sent back), and V3 (Me's current draft), produces a
|
|
341
|
-
* single attributed HTML output where CP's and Me's changes are
|
|
342
|
-
* distinguished by `data-author` ('cp' or 'me') and matching
|
|
343
|
-
* `class='diffins cp'` / `class='diffdel me'` etc. The "Me rejected
|
|
344
|
-
* CP's proposal" case (Me deleted text CP had inserted) gets a
|
|
345
|
-
* dedicated marker: `data-rejects='cp'` plus `class='... rejects-cp'`.
|
|
346
|
-
*
|
|
347
|
-
* Coordinates the symmetric-projection decision (D1) across both
|
|
348
|
-
* internal `analyze` calls so V2 tokenises identically on each side
|
|
349
|
-
* of the spine. When `useProjections` is left undefined, the decision
|
|
350
|
-
* is the conjunction of both pair-wise heuristics — project iff both
|
|
351
|
-
* pairs would project on their own. Pass an explicit boolean to
|
|
352
|
-
* override.
|
|
353
|
-
*/
|
|
354
338
|
/**
|
|
355
339
|
* Three-way HTML diff against a shared genesis. Produces attributed
|
|
356
340
|
* HTML that distinguishes CP's accumulated changes (genesis → cpLatest)
|
|
@@ -439,6 +423,17 @@ export default class HtmlDiff {
|
|
|
439
423
|
* buffer. Reusing the instance keeps the formatting-tag stack
|
|
440
424
|
* (`specialTagDiffStack`) coherent across segments — a `<strong>`
|
|
441
425
|
* opened in one segment and closed in another stays balanced.
|
|
426
|
+
*
|
|
427
|
+
* Edge case: an ins/del segment can open a formatting wrap whose
|
|
428
|
+
* matching closer ends up in an equal segment (`<strong>` deleted
|
|
429
|
+
* by CP but `</strong>` kept by both — buildSegments emits the open
|
|
430
|
+
* as del-cp and the close as equal). Equal segments bypass
|
|
431
|
+
* `insertTag` and push raw, so the stack entry for the open is
|
|
432
|
+
* never popped. Rather than throw — which forces the caller's UI
|
|
433
|
+
* into an error boundary — close every leftover wrap with `</ins>`
|
|
434
|
+
* at the end of emission. The resulting HTML has an extra
|
|
435
|
+
* `</ins>` next to the formatting closer; DOMParser-normalisation
|
|
436
|
+
* downstream produces sensible nesting.
|
|
442
437
|
*/
|
|
443
438
|
private static emitSegments(segments: Segment[]): string {
|
|
444
439
|
const emitter = new HtmlDiff('', '')
|
|
@@ -451,18 +446,21 @@ export default class HtmlDiff {
|
|
|
451
446
|
// insertTag mutates its `words` array; pass a copy.
|
|
452
447
|
emitter.insertTag(tag, baseClass, [...seg.words], metadata)
|
|
453
448
|
}
|
|
454
|
-
// Stack-balance invariant: every special-case opening tag pushed onto
|
|
455
|
-
// `specialTagDiffStack` during emission must have been matched by a
|
|
456
|
-
// closing tag. An unbalanced stack means the input had unbalanced
|
|
457
|
-
// formatting tags AND a Replace at an inconvenient position — the
|
|
458
|
-
// output would be silently malformed (half-closed `<ins>`). Fail
|
|
459
|
-
// loudly so the caller can investigate rather than ship broken HTML.
|
|
460
449
|
if (emitter.specialTagDiffStack.length > 0) {
|
|
461
|
-
|
|
450
|
+
// Log once so we can spot bad inputs in dev tools, but don't
|
|
451
|
+
// throw — the caller's only fallback was to crash the React
|
|
452
|
+
// tree, which is worse than emitting slightly-imperfect HTML.
|
|
453
|
+
// eslint-disable-next-line no-console
|
|
454
|
+
console.warn(
|
|
462
455
|
`HtmlDiff.executeThreeWay: emission left ${emitter.specialTagDiffStack.length} ` +
|
|
463
|
-
'unclosed formatting
|
|
464
|
-
'
|
|
456
|
+
'unclosed formatting wrap(s) on the stack. Closing defensively. ' +
|
|
457
|
+
'This usually means a formatting tag opens in a del/ins segment ' +
|
|
458
|
+
'and its matching closer is in an equal segment.'
|
|
465
459
|
)
|
|
460
|
+
while (emitter.specialTagDiffStack.length > 0) {
|
|
461
|
+
emitter.content.push('</ins>')
|
|
462
|
+
emitter.specialTagDiffStack.pop()
|
|
463
|
+
}
|
|
466
464
|
}
|
|
467
465
|
return emitter.content.join('')
|
|
468
466
|
}
|
package/src/ThreeWayTable.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lcsAlign, textSimilarity } from './Alignment'
|
|
1
|
+
import { type Alignment, lcsAlign, pairSimilarUnmatched, textSimilarity } from './Alignment'
|
|
2
2
|
import { injectClass, parseOpeningTagAt } from './HtmlScanner'
|
|
3
3
|
import {
|
|
4
4
|
type CellRange,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PLACEHOLDER_SUFFIX,
|
|
9
9
|
type RowRange,
|
|
10
10
|
rowKey,
|
|
11
|
+
rowText,
|
|
11
12
|
sameDimensions,
|
|
12
13
|
spliceString,
|
|
13
14
|
type TableRange,
|
|
@@ -143,8 +144,14 @@ function preprocessByContent(
|
|
|
143
144
|
const cKeys = cTables.map(t => tableKey(cpLatest, t))
|
|
144
145
|
const mKeys = mTables.map(t => tableKey(meCurrent, t))
|
|
145
146
|
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
// Exact tableKey LCS, then fuzzy-pair unmatched runs by content
|
|
148
|
+
// similarity. Without this, a table whose cells were edited (but
|
|
149
|
+
// not its overall shape) fails the exact tableKey match and the
|
|
150
|
+
// table-level aligner pulls it apart into a whole-table del + a
|
|
151
|
+
// whole-table ins. Same fuzzy pass `TableDiff` uses for the 2-way
|
|
152
|
+
// path — `pairSimilarTablesThreeWay` is defined below.
|
|
153
|
+
const alignCp = pairSimilarTablesThreeWay(lcsAlign(gKeys, cKeys), genesis, cpLatest, gTables, cTables)
|
|
154
|
+
const alignMe = pairSimilarTablesThreeWay(lcsAlign(gKeys, mKeys), genesis, meCurrent, gTables, mTables)
|
|
148
155
|
|
|
149
156
|
// Maps: genesisIdx → matching cpIdx (-1 if none); cpIdx → matching genesisIdx; etc.
|
|
150
157
|
const gToCp = new Array<number>(gTables.length).fill(-1)
|
|
@@ -342,6 +349,76 @@ function tableKey(html: string, table: TableRange): string {
|
|
|
342
349
|
return html.slice(table.tableStart, table.tableEnd).replace(/\s+/g, ' ').trim()
|
|
343
350
|
}
|
|
344
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Character-level similarity above which the three-way aligner treats
|
|
354
|
+
* two rows / tables as "the same logical entry, edited" rather than
|
|
355
|
+
* an unrelated delete + insert. Matched to TableDiff's
|
|
356
|
+
* `ROW_FUZZY_THRESHOLD` / `CELL_FUZZY_THRESHOLD` so 2-way and 3-way
|
|
357
|
+
* agree on which pairings are reachable; if a row's content overlap
|
|
358
|
+
* is enough to fool the 2-way diff into pairing, it should also be
|
|
359
|
+
* enough for 3-way.
|
|
360
|
+
*/
|
|
361
|
+
const THREE_WAY_FUZZY_THRESHOLD = 0.5
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Run the same fuzzy-pairing pass `TableDiff.pairSimilarUnmatchedRows`
|
|
365
|
+
* applies after its exact-LCS, but against one side of the genesis
|
|
366
|
+
* spine (either cp or me). The genesis tables/rows are always the
|
|
367
|
+
* "old" side; `newTable` is the cp or me table being aligned. Returns
|
|
368
|
+
* the enriched alignment with additional paired entries.
|
|
369
|
+
*
|
|
370
|
+
* Cell-count guard: only fuzzy-pair when both rows have the same cell
|
|
371
|
+
* count. Without this guard an asymmetric restructure — e.g. CP and
|
|
372
|
+
* Me both added a different column — leads to ONE side fuzzy-pairing
|
|
373
|
+
* its row with genesis (content overlap above threshold) while the
|
|
374
|
+
* other side falls below threshold. That mismatch routes through
|
|
375
|
+
* `diffTableStructural`'s "Me dropped, CP kept" (or the mirror)
|
|
376
|
+
* branch, which emits CP's row as a Me-attributed deletion. In
|
|
377
|
+
* cp-only mode `stripMeAttributedMarkers` then removes the row
|
|
378
|
+
* entirely and CP's edit vanishes from the view — exactly the
|
|
379
|
+
* content-loss case we're meant to prevent. Restricting fuzzy
|
|
380
|
+
* pairing to same-shape rows preserves the common case (single cell
|
|
381
|
+
* edit, identical row shape) while pushing structural mismatches
|
|
382
|
+
* back to the boundary-insertion path that emits both sides
|
|
383
|
+
* explicitly.
|
|
384
|
+
*/
|
|
385
|
+
function pairSimilarRowsThreeWay(
|
|
386
|
+
alignment: Alignment[],
|
|
387
|
+
genesis: string,
|
|
388
|
+
newHtml: string,
|
|
389
|
+
oldTable: TableRange,
|
|
390
|
+
newTable: TableRange
|
|
391
|
+
): Alignment[] {
|
|
392
|
+
const oldTexts = oldTable.rows.map(r => rowText(genesis, r))
|
|
393
|
+
const newTexts = newTable.rows.map(r => rowText(newHtml, r))
|
|
394
|
+
return pairSimilarUnmatched(alignment, THREE_WAY_FUZZY_THRESHOLD, (oldIdx, newIdx) => {
|
|
395
|
+
if (oldTable.rows[oldIdx].cells.length !== newTable.rows[newIdx].cells.length) return 0
|
|
396
|
+
return textSimilarity(oldTexts[oldIdx], newTexts[newIdx])
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Table-level counterpart: after `lcsAlign(gKeys, otherKeys)` over
|
|
402
|
+
* full table HTML keys, fuzzy-pair unmatched table runs by their
|
|
403
|
+
* row-text-concatenated content. Without this, a table whose body
|
|
404
|
+
* was edited (but not its outer shape) fails the exact-key match
|
|
405
|
+
* and the preprocessing emits whole-table del + whole-table ins
|
|
406
|
+
* instead of recursing into per-cell three-way diffs.
|
|
407
|
+
*/
|
|
408
|
+
function pairSimilarTablesThreeWay(
|
|
409
|
+
alignment: Alignment[],
|
|
410
|
+
oldHtml: string,
|
|
411
|
+
newHtml: string,
|
|
412
|
+
oldTables: TableRange[],
|
|
413
|
+
newTables: TableRange[]
|
|
414
|
+
): Alignment[] {
|
|
415
|
+
const oldTexts = oldTables.map(t => t.rows.map(r => rowText(oldHtml, r)).join(' '))
|
|
416
|
+
const newTexts = newTables.map(t => t.rows.map(r => rowText(newHtml, r)).join(' '))
|
|
417
|
+
return pairSimilarUnmatched(alignment, THREE_WAY_FUZZY_THRESHOLD, (oldIdx, newIdx) =>
|
|
418
|
+
textSimilarity(oldTexts[oldIdx], newTexts[newIdx])
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
345
422
|
// ────────────────────────────────────────────────────────────────────────────
|
|
346
423
|
// Per-table diff: positional cells or row-level structural change.
|
|
347
424
|
|
|
@@ -426,8 +503,17 @@ function diffTableStructural(
|
|
|
426
503
|
const cKeys = tC.rows.map(r => rowKey(cpLatest, r))
|
|
427
504
|
const mKeys = tM.rows.map(r => rowKey(meCurrent, r))
|
|
428
505
|
|
|
429
|
-
|
|
430
|
-
|
|
506
|
+
// Exact LCS first, then fuzzy-pair remaining unmatched runs. Without
|
|
507
|
+
// the fuzzy pass, a row where CP edited just a single cell's text
|
|
508
|
+
// produces no key match — the row aligner emits the genesis row as
|
|
509
|
+
// CP-deleted AND CP's reshaped row as inserted, when a cell-level
|
|
510
|
+
// diff against the paired row would render the edit far more
|
|
511
|
+
// legibly. The 2-way path (`TableDiff.pairSimilarUnmatchedRows`)
|
|
512
|
+
// has done this since inception; bringing the three-way path in
|
|
513
|
+
// step removes the asymmetry where the cp-only / all-changes view
|
|
514
|
+
// looks markedly worse than plain 2-way for ordinary cell edits.
|
|
515
|
+
const alignCp = pairSimilarRowsThreeWay(lcsAlign(gKeys, cKeys), genesis, cpLatest, tG, tC)
|
|
516
|
+
const alignMe = pairSimilarRowsThreeWay(lcsAlign(gKeys, mKeys), genesis, meCurrent, tG, tM)
|
|
431
517
|
|
|
432
518
|
// genesisIdx → matching cpIdx (-1 if cp deleted this row)
|
|
433
519
|
const gToCp = new Array<number>(tG.rows.length).fill(-1)
|
|
@@ -535,9 +621,40 @@ function emitPreservedRow(
|
|
|
535
621
|
return out.join('')
|
|
536
622
|
}
|
|
537
623
|
// Cell-count mismatch within a preserved row — cell-level structural
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
624
|
+
// alignment is non-trivial (which Me cell maps to which CP cell when
|
|
625
|
+
// the counts diverge?). The previous fallback emitted only
|
|
626
|
+
// genesis-as-del + me-as-ins, which silently destroyed CP's row
|
|
627
|
+
// content whenever CP changed the cell count — a content-loss bug
|
|
628
|
+
// (a row where CP added a column would disappear from the rendered
|
|
629
|
+
// diff entirely). Emit each side's row as a distinct attributed
|
|
630
|
+
// block so neither party's restructure can vanish:
|
|
631
|
+
// - if both restructured (different shapes on both sides) the
|
|
632
|
+
// genesis row is settled-deleted (silent) and we emit cp + me
|
|
633
|
+
// rows side by side, each attributed to its author;
|
|
634
|
+
// - if only one restructured, the genesis row is del-attributed to
|
|
635
|
+
// the restructuring author so the reader sees what was there
|
|
636
|
+
// before, then the new shape ins-attributed to the same author.
|
|
637
|
+
//
|
|
638
|
+
// Content edits inside a side that DID keep the genesis cell count
|
|
639
|
+
// are not surfaced here (no positional path is available across
|
|
640
|
+
// mismatched shapes); the underlying data is still present in the
|
|
641
|
+
// source document but the visual diff doesn't decompose it. That is
|
|
642
|
+
// a degradation of detail, not content loss — symmetric for cp/me.
|
|
643
|
+
const cpRestructured = rC.cells.length !== rG.cells.length
|
|
644
|
+
const meRestructured = rM.cells.length !== rG.cells.length
|
|
645
|
+
const blocks: string[] = []
|
|
646
|
+
if (cpRestructured && meRestructured) {
|
|
647
|
+
// Both sides restructured; genesis shape retained by neither.
|
|
648
|
+
blocks.push(emitFullRowAttributed(cpLatest, rC, 'ins', 'cp'))
|
|
649
|
+
blocks.push(emitFullRowAttributed(meCurrent, rM, 'ins', 'me'))
|
|
650
|
+
} else if (cpRestructured) {
|
|
651
|
+
blocks.push(emitFullRowAttributed(genesis, rG, 'del', 'cp'))
|
|
652
|
+
blocks.push(emitFullRowAttributed(cpLatest, rC, 'ins', 'cp'))
|
|
653
|
+
} else {
|
|
654
|
+
blocks.push(emitFullRowAttributed(genesis, rG, 'del', 'me'))
|
|
655
|
+
blocks.push(emitFullRowAttributed(meCurrent, rM, 'ins', 'me'))
|
|
656
|
+
}
|
|
657
|
+
return blocks.join('')
|
|
541
658
|
}
|
|
542
659
|
|
|
543
660
|
/**
|
|
@@ -120,11 +120,19 @@ describe('HtmlDiff.executeThreeWay (genesis-spine)', () => {
|
|
|
120
120
|
})
|
|
121
121
|
|
|
122
122
|
it('cp matches genesis (only Me changed)', () => {
|
|
123
|
-
|
|
123
|
+
// Negative assertion is load-bearing: without `not.toContain`
|
|
124
|
+
// a cp↔me swap inside the genesis-spine merge would still
|
|
125
|
+
// emit `data-author='cp'` somewhere in the output and the
|
|
126
|
+
// positive assertion would silently pass.
|
|
127
|
+
const out = HtmlDiff.executeThreeWay('Hello world', 'Hello world', 'Hello brave world')
|
|
128
|
+
expect(out).toContain("data-author='me'")
|
|
129
|
+
expect(out).not.toContain("data-author='cp'")
|
|
124
130
|
})
|
|
125
131
|
|
|
126
132
|
it('me matches genesis (only CP changed)', () => {
|
|
127
|
-
|
|
133
|
+
const out = HtmlDiff.executeThreeWay('Hello world', 'Hello cruel world', 'Hello world')
|
|
134
|
+
expect(out).toContain("data-author='cp'")
|
|
135
|
+
expect(out).not.toContain("data-author='me'")
|
|
128
136
|
})
|
|
129
137
|
})
|
|
130
138
|
|
|
@@ -163,10 +171,47 @@ describe('HtmlDiff.executeThreeWay (genesis-spine)', () => {
|
|
|
163
171
|
const without = HtmlDiff.executeThreeWay('a b', 'a b', 'a b')
|
|
164
172
|
const withFlag = HtmlDiff.executeThreeWay('a b', 'a b', 'a b', { ignoreWhitespaceDifferences: true })
|
|
165
173
|
expect(without).toContain("data-author='me'")
|
|
174
|
+
// CP matches genesis — any cp attribution would be a mis-merge.
|
|
175
|
+
expect(without).not.toContain("data-author='cp'")
|
|
166
176
|
expect(withFlag).not.toContain('data-author=')
|
|
167
177
|
})
|
|
168
178
|
})
|
|
169
179
|
|
|
180
|
+
describe('stack-balance defence', () => {
|
|
181
|
+
// The emission walks segments built by `buildSegments`: ins/del
|
|
182
|
+
// segments go through `insertTag` (which manages the formatting-
|
|
183
|
+
// tag stack), but equal segments push raw words straight to the
|
|
184
|
+
// content buffer. When a formatting opener is in a del segment
|
|
185
|
+
// and its matching closer falls in an equal segment, the stack
|
|
186
|
+
// entry never gets popped — the emitter used to throw "emission
|
|
187
|
+
// left 1 unclosed formatting tag(s) on the stack" and crash the
|
|
188
|
+
// caller. Now it closes the leftover wraps defensively with
|
|
189
|
+
// `</ins>` so the output stays renderable.
|
|
190
|
+
|
|
191
|
+
it('CP inserted a <strong> opener whose closer is matched as equal — does not throw', () => {
|
|
192
|
+
// Genesis has an orphan closer (`X</strong>`); CP wrapped X in
|
|
193
|
+
// a fresh `<strong>`. The opener is ins-cp (no genesis match)
|
|
194
|
+
// but the closer is shared by all three and emits as equal.
|
|
195
|
+
// The mod-`<ins>` opened on the strong push needs to be closed
|
|
196
|
+
// somehow; the defensive path emits a trailing `</ins>`.
|
|
197
|
+
expect(() => HtmlDiff.executeThreeWay('X</strong>', '<strong>X</strong>', 'X</strong>')).not.toThrow()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('CP deleted only the <strong> opener — does not throw', () => {
|
|
201
|
+
// Symmetric: genesis had `<strong>X</strong>`, CP dropped the
|
|
202
|
+
// opener but kept the closer. The opener-delete pushes onto
|
|
203
|
+
// the stack and the closer arrives via an equal segment.
|
|
204
|
+
expect(() => HtmlDiff.executeThreeWay('<strong>X</strong>', 'X</strong>', '<strong>X</strong>')).not.toThrow()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('produces non-empty output even when the stack is left unbalanced at end', () => {
|
|
208
|
+
const out = HtmlDiff.executeThreeWay('X</strong>', '<strong>X</strong>', 'X</strong>')
|
|
209
|
+
// The content is still there, the formatting wraps just close
|
|
210
|
+
// defensively. Sanity-check the visible content survives.
|
|
211
|
+
expect(out).toContain('X')
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
170
215
|
describe('first-turn fallback', () => {
|
|
171
216
|
it('cp == genesis means CP made no changes — Me-only attribution', () => {
|
|
172
217
|
// Common case: this is the first turn where the counterparty hasn't
|
|
@@ -259,6 +259,75 @@ describe('HtmlDiff.executeThreeWay (tables, genesis-spine)', () => {
|
|
|
259
259
|
const html = `<table>${rows}</table>`
|
|
260
260
|
expect(HtmlDiff.executeThreeWay(html, html, html)).toBe(html)
|
|
261
261
|
})
|
|
262
|
+
|
|
263
|
+
it('cell-count mismatch: CP added a column — CP row content is visible (not silently dropped)', () => {
|
|
264
|
+
// Regression: the previous fallback in emitPreservedRow emitted
|
|
265
|
+
// only `del me` + `ins me` for any cell-count mismatch, which
|
|
266
|
+
// silently destroyed CP's row content whenever CP changed the
|
|
267
|
+
// cell count. A reader in cp-only mode would see no trace of
|
|
268
|
+
// CP's added column — a content-loss bug that violates the
|
|
269
|
+
// "CP's changes always visible" invariant.
|
|
270
|
+
const out = HtmlDiff.executeThreeWay(
|
|
271
|
+
'<table><tr><td>a</td><td>b</td></tr></table>',
|
|
272
|
+
'<table><tr><td>a</td><td>X</td><td>b</td></tr></table>',
|
|
273
|
+
'<table><tr><td>a</td><td>b</td></tr></table>'
|
|
274
|
+
)
|
|
275
|
+
expect(out).toBe(
|
|
276
|
+
"<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>"
|
|
277
|
+
)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('cell-count mismatch: Me removed a column — symmetric to the CP case', () => {
|
|
281
|
+
const out = HtmlDiff.executeThreeWay(
|
|
282
|
+
'<table><tr><td>a</td><td>b</td></tr></table>',
|
|
283
|
+
'<table><tr><td>a</td><td>b</td></tr></table>',
|
|
284
|
+
'<table><tr><td>a</td></tr></table>'
|
|
285
|
+
)
|
|
286
|
+
expect(out).toBe(
|
|
287
|
+
"<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>"
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('CP edited one cell in a row (same shape) — fuzzy-pairs and emits a cell-level diff, not whole-row del+ins', () => {
|
|
292
|
+
// Regression: the 3-way row aligner only did exact lcsAlign over
|
|
293
|
+
// rowKey, so a row where CP edited a single cell's text produced
|
|
294
|
+
// no key match and the algorithm split the row into a whole-row
|
|
295
|
+
// delete + whole-row insert. The 2-way path has always run a
|
|
296
|
+
// fuzzy-pairing pass after lcsAlign; bringing the 3-way path in
|
|
297
|
+
// step removes the asymmetry where cp-only / all-changes views
|
|
298
|
+
// looked materially worse than 2-way for ordinary cell edits.
|
|
299
|
+
//
|
|
300
|
+
// Same-shape genesis/cp/me; CP edited the middle cell's text.
|
|
301
|
+
// Me === genesis. Expect a paired row with cell-level cp-ins
|
|
302
|
+
// markup, NOT two distinct whole-row entries.
|
|
303
|
+
const out = HtmlDiff.executeThreeWay(
|
|
304
|
+
'<table><tr><td>Party A</td><td>old details</td><td>kept</td></tr></table>',
|
|
305
|
+
'<table><tr><td>Party A</td><td>new details</td><td>kept</td></tr></table>',
|
|
306
|
+
'<table><tr><td>Party A</td><td>old details</td><td>kept</td></tr></table>'
|
|
307
|
+
)
|
|
308
|
+
// CP's edit lives inside the row, not as a parallel whole-row
|
|
309
|
+
// delete-then-insert. Whole-row markers would carry `class='diffdel ...'`
|
|
310
|
+
// or `class='diffins ...'` on the `<tr>` itself.
|
|
311
|
+
expect(out).not.toMatch(/<tr [^>]*class=['"]diffdel/)
|
|
312
|
+
expect(out).not.toMatch(/<tr [^>]*class=['"]diffins/)
|
|
313
|
+
expect(out).toContain('Party A')
|
|
314
|
+
expect(out).toContain("data-author='cp'")
|
|
315
|
+
// Me === genesis so any me attribution would indicate a swap.
|
|
316
|
+
expect(out).not.toContain("data-author='me'")
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('cell-count mismatch: both sides restructured differently — both ins rows attributed', () => {
|
|
320
|
+
// Genesis 2 cells, CP 3 cells, Me 4 cells. Neither side keeps
|
|
321
|
+
// the genesis shape, so both restructures must be visible.
|
|
322
|
+
const out = HtmlDiff.executeThreeWay(
|
|
323
|
+
'<table><tr><td>a</td><td>b</td></tr></table>',
|
|
324
|
+
'<table><tr><td>a</td><td>X</td><td>b</td></tr></table>',
|
|
325
|
+
'<table><tr><td>a</td><td>b</td><td>Y</td><td>Z</td></tr></table>'
|
|
326
|
+
)
|
|
327
|
+
expect(out).toBe(
|
|
328
|
+
"<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>"
|
|
329
|
+
)
|
|
330
|
+
})
|
|
262
331
|
})
|
|
263
332
|
|
|
264
333
|
describe('nested tables', () => {
|
|
@@ -270,6 +339,9 @@ describe('HtmlDiff.executeThreeWay (tables, genesis-spine)', () => {
|
|
|
270
339
|
)
|
|
271
340
|
expect(out).toMatch(/<del[^>]*data-author='cp'[^>]*>inner<\/del>/)
|
|
272
341
|
expect(out).toMatch(/<ins[^>]*data-author='cp'[^>]*>INNER<\/ins>/)
|
|
342
|
+
// me == genesis here, so any me attribution would indicate a
|
|
343
|
+
// cp↔me swap inside the table-cell merge.
|
|
344
|
+
expect(out).not.toContain("data-author='me'")
|
|
273
345
|
expect(out.startsWith('<table><tr><td><table>')).toBe(true)
|
|
274
346
|
expect(out.endsWith('</table></td></tr></table>')).toBe(true)
|
|
275
347
|
})
|
|
@@ -320,6 +392,10 @@ describe('HtmlDiff.executeThreeWay (tables, genesis-spine)', () => {
|
|
|
320
392
|
// whole-table del+ins wrapping the entire <table>.
|
|
321
393
|
expect(out).not.toMatch(/<del[^>]*><table/)
|
|
322
394
|
expect(out).toMatch(/data-author='cp'/)
|
|
395
|
+
// me === genesis, so any me-attribution markers would mean the
|
|
396
|
+
// diff swapped CP's edits onto Me. Negative assertion locks the
|
|
397
|
+
// attribution direction.
|
|
398
|
+
expect(out).not.toContain("data-author='me'")
|
|
323
399
|
expect(out).toContain('Extra column')
|
|
324
400
|
expect(out).toContain('Form/Document/Certificate')
|
|
325
401
|
})
|
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', () => {
|