145 lines
6.7 KiB
JavaScript
145 lines
6.7 KiB
JavaScript
// node --test test/
|
|
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { parseHashline, applySection } from '../lib/hashline.js'
|
|
import { resolveBlock } from '../lib/block.js'
|
|
import { SnapshotStore, rebaseOps, lineMap } from '../lib/store.js'
|
|
import { applyReplace } from '../lib/replace.js'
|
|
import { parseSloppy, applySloppy } from '../lib/sloppy.js'
|
|
import { parseApplyPatch, applyUpdate } from '../lib/apply_patch.js'
|
|
import { fileTag, splitLines, joinLines } from '../lib/text.js'
|
|
|
|
const run = (path, text, patch, registers = new Map()) => {
|
|
const { sections, warnings } = parseHashline(patch)
|
|
const { lines, eol } = splitLines(text)
|
|
const r = applySection(sections[0], { path, lines, registers, anon: { value: null }, warnings })
|
|
return { text: joinLines(r.lines, eol), r, warnings }
|
|
}
|
|
|
|
const py = '@cache\ndef greet(name):\n print("Hello, " + name)\n\ngreet("world")\n'
|
|
|
|
test('PUT range replaces one line', () => {
|
|
const out = run('greet.py', py, `[greet.py#${fileTag(py)}]\nPUT 5.=5:\n+greet("team")`)
|
|
assert.equal(out.text, '@cache\ndef greet(name):\n print("Hello, " + name)\n\ngreet("team")\n')
|
|
})
|
|
|
|
test('PUT N* replaces a decorated python block', () => {
|
|
const out = run('greet.py', py, `[greet.py#ABCD]\nPUT 1*:\n+@cache\n+def greet(name):\n+ print(f"Hi, {name}")`)
|
|
assert.equal(out.text, '@cache\ndef greet(name):\n print(f"Hi, {name}")\n\ngreet("world")\n')
|
|
})
|
|
|
|
test('gap inserts and tail append', () => {
|
|
const t = 'a\nb\nc\n'
|
|
assert.equal(run('x.txt', t, '[x.txt#0000]\nPUT <1:\n+head').text, 'head\na\nb\nc\n')
|
|
assert.equal(run('x.txt', t, '[x.txt#0000]\nPUT >2:\n+mid').text, 'a\nb\nmid\nc\n')
|
|
assert.equal(run('x.txt', t, '[x.txt#0000]\nPUT >$:\n+tail').text, 'a\nb\nc\ntail\n')
|
|
})
|
|
|
|
test('multiple hunks use original numbering', () => {
|
|
const t = '1\n2\n3\n4\n5\n'
|
|
const out = run('n.txt', t, '[n.txt#0000]\nPUT 1.=1:\n+one\n+uno\nCUT 3.=3\nPUT 5.=5:\n+five')
|
|
assert.equal(out.text, 'one\nuno\n2\n4\nfive\n')
|
|
})
|
|
|
|
test('overlap is rejected', () => {
|
|
assert.throws(() => run('n.txt', '1\n2\n3\n', '[n.txt#0000]\nPUT 1.=2:\n+x\nPUT 2.=3:\n+y'), /overlap/)
|
|
})
|
|
|
|
test('CUT + paste via anon register in one section', () => {
|
|
const t = 'a\nb\nc\nd\n'
|
|
const out = run('m.txt', t, '[m.txt#0000]\nCUT 1.=2\nPUT >4')
|
|
assert.equal(out.text, 'c\nd\na\nb\n')
|
|
})
|
|
|
|
test('JS block resolution and >N* insert', () => {
|
|
const js = 'function a() {\n if (x) {\n y()\n } else {\n z()\n }\n}\nconst b = 1\n'
|
|
assert.deepEqual(resolveBlock('a.js', splitLines(js).lines, 1), { start: 1, end: 7 })
|
|
assert.deepEqual(resolveBlock('a.js', splitLines(js).lines, 2), { start: 2, end: 6 })
|
|
const out = run('a.js', js, '[a.js#0000]\nPUT >1*:\n+\n+function c() {}')
|
|
assert.equal(out.text, 'function a() {\n if (x) {\n y()\n } else {\n z()\n }\n}\n\nfunction c() {}\nconst b = 1\n')
|
|
})
|
|
|
|
test('markdown heading block', () => {
|
|
const md = '# A\ntext\n## A1\nmore\n# B\nb\n'
|
|
assert.deepEqual(resolveBlock('r.md', splitLines(md).lines, 1), { start: 1, end: 4 })
|
|
assert.deepEqual(resolveBlock('r.md', splitLines(md).lines, 3), { start: 3, end: 4 })
|
|
})
|
|
|
|
test('block on single line is rejected', () => {
|
|
assert.throws(() => resolveBlock('a.js', ['const x = 1', 'y'], 1), /does not open a multi-line block/)
|
|
})
|
|
|
|
test('body rows must be +', () => {
|
|
assert.throws(() => parseHashline('[a#0000]\nPUT 1.=1:\n-old\n+new'), /must start with '\+'/)
|
|
})
|
|
|
|
test('loose range spelling is accepted with a warning', () => {
|
|
const out = run('n.txt', '1\n2\n3\n', '[n.txt#0000]\nPUT 2-3:\n+x')
|
|
assert.equal(out.text, '1\nx\n')
|
|
assert.match(out.warnings.join(), /canonical range/)
|
|
})
|
|
|
|
test('stale tag recovery remaps lines shifted by an external insert', () => {
|
|
const v1 = 'alpha\nbeta\ngamma\ndelta\n'
|
|
const v2 = 'NEW\nalpha\nbeta\ngamma\ndelta\n'
|
|
const { sections } = parseHashline('[f#0000]\nPUT 3.=3:\n+GAMMA')
|
|
const rb = rebaseOps(v1, v2, sections[0].ops)
|
|
assert.ok(rb)
|
|
assert.equal(rb.ops[0].start, 4)
|
|
})
|
|
|
|
test('stale tag recovery refuses when the anchored line changed', () => {
|
|
const v1 = 'alpha\nbeta\ngamma\ndelta\n'
|
|
const v2 = 'alpha\nbeta\nGAMMA!\ndelta\n'
|
|
const { sections } = parseHashline('[f#0000]\nPUT 3.=3:\n+x')
|
|
assert.equal(rebaseOps(v1, v2, sections[0].ops), null)
|
|
})
|
|
|
|
test('lineMap via Myers', () => {
|
|
const m = lineMap(['a', 'b', 'c', 'd'], ['a', 'x', 'c', 'd', 'e'])
|
|
assert.equal(m.get(1), 1); assert.equal(m.get(2), undefined); assert.equal(m.get(3), 3); assert.equal(m.get(4), 4)
|
|
})
|
|
|
|
test('snapshot store records and finds tags', () => {
|
|
const s = new SnapshotStore()
|
|
const tag = s.record('k', 'hello\n')
|
|
assert.match(tag, /^[0-9A-F]{4}$/)
|
|
assert.equal(s.byTag('k', tag).text, 'hello\n')
|
|
})
|
|
|
|
test('replace: exact, ambiguous, fuzzy indentation', () => {
|
|
assert.equal(applyReplace('a = 1\nb = 2\n', 'b = 2', 'b = 3').text, 'a = 1\nb = 3\n')
|
|
assert.throws(() => applyReplace('x\nx\n', 'x', 'y'), /matched 2 times/)
|
|
assert.equal(applyReplace('x\nx\n', 'x', 'y', { all: true }).text, 'y\ny\n')
|
|
const src = 'def f():\n if a:\n return 1\n'
|
|
const r = applyReplace(src, 'if a:\n return 1', 'if a:\n return 2')
|
|
assert.equal(r.text, 'def f():\n if a:\n return 2\n')
|
|
assert.equal(r.kind, 'trimmed')
|
|
const q = applyReplace('say("hi")\n', 'say(\u201chi\u201d)', 'say("yo")')
|
|
assert.equal(q.text, 'say("yo")\n')
|
|
})
|
|
|
|
test('replace: not-found gives closest hint', () => {
|
|
assert.throws(() => applyReplace('function alpha(beta) {}\n', 'function alpha(betaa) {}', 'x', { fuzzy: false }), /not found/)
|
|
})
|
|
|
|
test('sloppy: replace, insert before/after, captures', () => {
|
|
const t = 'const helper = () => 1;\nfunction run(target) {\n run(target);\n}\n'
|
|
const [f] = parseSloppy('*** Edit File: a.js\n*** Find\nconst helper = () => 1;\n*** Replace\nconst helper = () => 2;\n*** Find\n run(target);\n*** Insert Before\n log(target);')
|
|
assert.equal(applySloppy(t, f), 'const helper = () => 2;\nfunction run(target) {\n log(target);\n run(target);\n}\n')
|
|
const [g] = parseSloppy('*** Edit File: a.js\n*** Find\nfunction run(…) {\n*** Replace\nfunction run(…, opts) {')
|
|
assert.equal(applySloppy(t, g), 'const helper = () => 1;\nfunction run(target, opts) {\n run(target);\n}\n')
|
|
})
|
|
|
|
test('apply_patch: update with context + add + move', () => {
|
|
const ops = parseApplyPatch('*** Begin Patch\n*** Update File: a.py\n@@ def f\n def f():\n- return 1\n+ return 2\n*** Add File: b.py\n+x = 1\n*** End Patch')
|
|
assert.equal(ops.length, 2)
|
|
const { lines } = applyUpdate(['import os', 'def f():', ' return 1', ''], ops[0])
|
|
assert.deepEqual(lines, ['import os', 'def f():', ' return 2', ''])
|
|
assert.deepEqual(ops[1].lines, ['x = 1'])
|
|
})
|
|
|
|
test('header without tag gives actionable error', () => {
|
|
assert.throws(() => parseHashline('[a.js]\nPUT 1.=1:\n+x'), /missing the 4-hex snapshot tag/)
|
|
})
|