// 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/) })