import { test, expect, describe } from 'vitest' import { Micropatch } from '../src/micropatch' describe('Micropatch', () => { describe('EOL detection', () => { test('detects LF', () => { expect(Micropatch.detectEOL('line1\nline2\n')).toBe('lf') }) test('detects CRLF', () => { expect(Micropatch.detectEOL('line1\r\nline2\r\n')).toBe('crlf') }) test('defaults to LF when no line breaks', () => { expect(Micropatch.detectEOL('single line')).toBe('lf') }) }) describe('Delete operations', () => { test('deletes single line', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎-2' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 line3 " `) }) test('deletes range of lines', () => { const source = 'line1\nline2\nline3\nline4\nline5\n' const ops = '◼︎-2-4' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 line5 " `) }) test('deletes multiple non-contiguous lines', () => { const source = 'line1\nline2\nline3\nline4\n' const ops = '◼︎-2\n◼︎-4' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 line3 " `) }) test('deletes first line', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎-1' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line2 line3 " `) }) test('deletes last line', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎-3' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 line2 " `) }) }) describe('Replace operations', () => { test('replaces single line with single line', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎=2|REPLACED' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 REPLACED line3 " `) }) test('replaces single line with multiple lines', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎=2|REPLACED1\nREPLACED2\nREPLACED3' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 REPLACED1 REPLACED2 REPLACED3 line3 " `) }) test('replaces range with single line', () => { const source = 'line1\nline2\nline3\nline4\n' const ops = '◼︎=2-3|REPLACED' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 REPLACED line4 " `) }) test('replaces range with multiple lines', () => { const source = 'line1\nline2\nline3\nline4\n' const ops = '◼︎=2-3|NEW1\nNEW2' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 NEW1 NEW2 line4 " `) }) test('replaces empty payload', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎=2|' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 line3 " `) }) }) describe('Insert operations', () => { test('inserts before line', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎<2|INSERTED' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 INSERTED line2 line3 " `) }) test('inserts after line', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎>2|INSERTED' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 line2 INSERTED line3 " `) }) test('inserts before first line', () => { const source = 'line1\nline2\n' const ops = '◼︎<1|FIRST' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "FIRST line1 line2 " `) }) test('inserts after last line', () => { const source = 'line1\nline2\n' const ops = '◼︎>2|LAST' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 line2 LAST " `) }) test('multiple inserts at same position', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎>1|INSERT1\n◼︎>1|INSERT2' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 INSERT2 INSERT1 line2 line3 " `) }) }) describe('Complex multi-operation patches', () => { test('combines delete, replace, and insert', () => { const source = 'line1\nline2\nline3\nline4\nline5\n' const ops = `◼︎-3 ◼︎=2|REPLACED ◼︎>4|INSERTED` const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 REPLACED line4 INSERTED line5 " `) }) test('operations maintain original line references', () => { const source = 'A\nB\nC\nD\nE\n' // Delete B (line 2), then insert after original C (line 3) // After delete: A, C, D, E // Insert after original C should still work const ops = '◼︎-2\n◼︎>3|X' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "A C X D E " `) }) }) describe('Multiline payloads', () => { test('handles multiline replace with embedded marker escape', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎=2|This has \\◼︎ marker\nSecond line\nThird line' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 This has ◼︎ marker Second line Third line line3 " `) }) test('multiline payload ends at next op marker', () => { const source = 'line1\nline2\nline3\nline4\n' const ops = `◼︎=2|MULTI1 MULTI2 ◼︎=4|REPLACED` const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 MULTI1 MULTI2 line3 REPLACED " `) }) }) describe('EOL handling', () => { test('preserves CRLF line endings', () => { const source = 'line1\r\nline2\r\nline3\r\n' const ops = '◼︎=2|REPLACED' const result = Micropatch.applyText(source, ops) expect(result).toBe('line1\r\nREPLACED\r\nline3\r\n') }) test('uses specified EOL style', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎=2|REPLACED' const result = Micropatch.applyText(source, ops, 'crlf') expect(result).toBe('line1\r\nREPLACED\r\nline3\r\n') }) test('can switch EOL style', () => { const source = 'line1\r\nline2\r\nline3\r\n' const ops = '◼︎=2|REPLACED' const result = Micropatch.applyText(source, ops, 'lf') expect(result).toBe('line1\nREPLACED\nline3\n') }) }) describe('Micropatch instance methods', () => { test('getText returns current text', () => { const mp = new Micropatch('line1\nline2\n') expect(mp.getText()).toBe('line1\nline2\n') }) test('setText updates text', () => { const mp = new Micropatch('line1\nline2\n') mp.setText('new1\nnew2\n') expect(mp.getText()).toBe('new1\nnew2\n') }) test('apply updates internal text', () => { const mp = new Micropatch('line1\nline2\nline3\n') mp.apply('◼︎=2|REPLACED') expect(mp.getText()).toBe('line1\nREPLACED\nline3\n') }) test('can apply multiple patches sequentially', () => { const mp = new Micropatch('line1\nline2\nline3\n') mp.apply('◼︎=2|REPLACED') mp.apply('◼︎-3') expect(mp.getText()).toBe('line1\nREPLACED\n') }) test('renderNumberedView shows line numbers', () => { const mp = new Micropatch('line1\nline2\nline3\n') const view = mp.renderNumberedView() expect(view).toMatchInlineSnapshot(` "001|line1 002|line2 003|line3 004|" `) }) }) describe('Validation', () => { test('validate returns ok and count', () => { const ops = '◼︎-1\n◼︎=2|X\n◼︎>3|Y' const result = Micropatch.validate(ops) expect(result.ok).toBe(true) expect(result.count).toBe(3) }) test('validate ignores non-op lines', () => { const ops = '# comment\n◼︎-1\n\n◼︎=2|X' const result = Micropatch.validate(ops) expect(result.ok).toBe(true) expect(result.count).toBe(2) }) }) describe('Error handling', () => { test('throws on invalid op syntax', () => { const source = 'line1\nline2\n' const ops = '◼︎INVALID' expect(() => Micropatch.applyText(source, ops)).toThrow('Invalid op syntax') }) test('throws on range for insert', () => { const source = 'line1\nline2\n' const ops = '◼︎<1-2|X' expect(() => Micropatch.applyText(source, ops)).toThrow('Insert cannot target a range') }) test('throws on delete with payload', () => { const source = 'line1\nline2\n' const ops = '◼︎-1|INVALID' expect(() => Micropatch.applyText(source, ops)).toThrow('Delete must not have a payload') }) test('throws on invalid line number', () => { const source = 'line1\nline2\n' const ops = '◼︎-0' expect(() => Micropatch.applyText(source, ops)).toThrow('Invalid line/range') }) test('throws on invalid range', () => { const source = 'line1\nline2\n' const ops = '◼︎-3-2' expect(() => Micropatch.applyText(source, ops)).toThrow('Invalid line/range') }) }) describe('Edge cases', () => { test('handles empty source', () => { const source = '' const ops = '◼︎<1|NEW' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "NEW " `) }) test('handles single line source', () => { const source = 'line1' const ops = '◼︎=1|REPLACED' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(`"REPLACED"`) }) test('skips ops targeting non-existent lines', () => { const source = 'line1\nline2\n' const ops = '◼︎-1\n◼︎-1' // Try to delete line 1 twice const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line2 " `) }) test('skips inserts targeting non-existent lines', () => { const source = 'line1\nline2\n' const ops = '◼︎<5|BEFORE\n◼︎>9|AFTER' // Stale references beyond EOF const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 line2 " `) }) test('insert before a line deleted at the top of the file lands where it was', () => { const source = 'line1\nline2\n' const ops = '◼︎-1\n◼︎<1|NEW' // Delete + insert as a replace of line 1 const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "NEW line2 " `) }) test('insert after a range deleted at the top of the file lands where it was', () => { const source = 'line1\nline2\nline3\n' const ops = '◼︎-1-2\n◼︎>1|NEW' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "NEW line3 " `) }) test('handles empty op text', () => { const source = 'line1\nline2\n' const ops = '' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 line2 " `) }) test('handles blank lines in ops', () => { const source = 'line1\nline2\nline3\n' const ops = '\n\n◼︎=2|REPLACED\n\n' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 REPLACED line3 " `) }) test('two subsequent replace ops', () => { const source = 'line1\nline2\nline3\nline4\nline5\n' const ops = '◼︎=2|REPLACED\n\n◼︎=5|REPLACED2' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 REPLACED line3 line4 REPLACED2 " `) }) }) describe('Operation order', () => { test('applies deletes before replaces', () => { const source = 'A\nB\nC\nD\n' // Delete C (line 3), replace B (line 2) // Should delete first, then replace const ops = '◼︎=2|X\n◼︎-3' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "A X D " `) }) test('applies replaces before inserts', () => { const source = 'A\nB\nC\n' // Insert after B (line 2), replace B (line 2) // Should replace first, then insert const ops = '◼︎>2|Y\n◼︎=2|X' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "A X Y C " `) }) test('applies < inserts before > inserts', () => { const source = 'A\nB\n' // <2 is processed before >1 per operation order const ops = '◼︎>1|AFTER\n◼︎<2|BEFORE' const result = Micropatch.applyText(source, ops) // < ops go first (BEFORE at line 2), then > ops (AFTER after line 1) expect(result).toMatchInlineSnapshot(` "A AFTER BEFORE B " `) }) }) describe('Escaping', () => { test('unescapes marker in insert', () => { const source = 'line1\nline2\n' const ops = '◼︎>1|Text with \\◼︎ marker' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 Text with ◼︎ marker line2 " `) }) test('unescapes marker in replace', () => { const source = 'line1\nline2\n' const ops = '◼︎=2|Escaped \\◼︎ here' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "line1 Escaped ◼︎ here " `) }) test('multiple escaped markers', () => { const source = 'line1\n' const ops = '◼︎=1|\\◼︎ start \\◼︎ middle \\◼︎ end' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "◼︎ start ◼︎ middle ◼︎ end " `) }) test('no other escape sequences', () => { const source = 'line1\n' const ops = '◼︎=1|\\n \\t \\r stay literal' const result = Micropatch.applyText(source, ops) expect(result).toMatchInlineSnapshot(` "\\n \\t \\r stay literal " `) }) }) })