575 lines
14 KiB
TypeScript
575 lines
14 KiB
TypeScript
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
|
|
"
|
|
`)
|
|
})
|
|
})
|
|
})
|