import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { diffTextChanges } from '../text-diff' function apply(source: string, changes: readonly { from: number; to: number; insert: string }[]): string { let output = source for (const change of [...changes].sort((left, right) => right.from - left.from)) output = output.slice(0, change.from) + change.insert + output.slice(change.to) return output } describe('diffTextChanges', () => { it.each([ ['left\nright\n', 'left\nmiddle\nright\n', 5, 'middle\n'], ['left\n', 'left\nlast\n', 5, 'last\n'], ['right\n', 'first\nright\n', 0, 'first\n'], ])('anchors insertion-only gaps at their actual base offset', (before, after, from, insert) => { const changes = diffTextChanges(before, after) expect(changes).toEqual([{ from, to: from, insert }]) expect(apply(before, changes)).toBe(after) }) it('reconstructs bounded generated text changes deterministically', () => { const text = fc.array(fc.constantFrom('a', 'b', 'same', '\n', '\r\n', '\r', '\u{1f600}'), { maxLength: 30 }).map(parts => parts.join('')) fc.assert(fc.property(text, text, (before, after) => { const changes = diffTextChanges(before, after) expect(changes).toEqual(diffTextChanges(before, after)) expect(apply(before, changes)).toBe(after) for (let index = 1; index < changes.length; index += 1) expect(changes[index]!.from).toBeGreaterThanOrEqual(changes[index - 1]!.to) }), { seed: 516, numRuns: 250 }) }) it('keeps distant line edits as separate hunks', () => { const oldText = 'zero\nkeep middle\none\nkeep tail\nlast' const newText = 'ZERO\nkeep middle\none\nkeep tail\nLAST' const changes = diffTextChanges(oldText, newText) expect(changes).toHaveLength(2) expect(apply(oldText, changes)).toBe(newText) }) it('does not treat repeated lines as movable identity', () => { const oldText = 'same\nold\nsame\nend' const newText = 'same\nnew\nsame\nend' const changes = diffTextChanges(oldText, newText) expect(changes).toEqual([{ from: 5, to: 8, insert: 'new' }]) expect(apply(oldText, changes)).toBe(newText) }) it('uses UTF-16 offsets without splitting astral characters', () => { const oldText = 'a😀b\nkeep\nc😀d' const newText = 'a😃b\nkeep\nc😄d' const changes = diffTextChanges(oldText, newText) expect(changes.every(change => ![...changes].some(other => other !== change && other.from < change.to && change.from < other.to))).toBe(true) expect(changes.every(change => oldText.charCodeAt(change.from - 1) < 0xdc00 || oldText.charCodeAt(change.from - 1) > 0xdfff)).toBe(true) expect(apply(oldText, changes)).toBe(newText) }) it.each([ ['', 'replacement'], ['replacement', ''], ['a\r\nb\rc\n', 'A\r\nb\rc\n'], ])('reconstructs exact source (%j -> %j)', (oldText, newText) => { expect(apply(oldText, diffTextChanges(oldText, newText))).toBe(newText) }) it('falls back exactly when the character budget is exhausted', () => { const oldText = 'before\n' + 'x'.repeat(500) + '\nafter' const newText = 'before\n' + 'y'.repeat(500) + '\nafter' const changes = diffTextChanges(oldText, newText, { maxCharWork: 1 }) expect(changes).toHaveLength(1) expect(apply(oldText, changes)).toBe(newText) }) it('keeps large-line fallback narrow and Unicode-safe', () => { const prefix = 'a'.repeat(100_000) const before = prefix + '\u{1f600} end' const after = prefix + '\u{1f601} end' expect(diffTextChanges(before, after, { maxCharWork: 0 })).toEqual([{ from: prefix.length, to: prefix.length + 2, insert: '\u{1f601}' }]) }) it.each([NaN, Infinity, -1, 1.5])('rejects an invalid work budget %s', maxCharWork => { expect(() => diffTextChanges('a', 'b', { maxCharWork })).toThrow(RangeError) }) })