// tests/parsers/anchor-parser.test.mjs // Unit tests for lib/parsers/anchor-parser.mjs (v4.2) import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseAnchors, addAnchors, stripAnchors, validateAnchorPlacement, } from '../../lib/parsers/anchor-parser.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const EXAMPLE_PATH = resolve(__dirname, '..', 'fixtures', 'annotation', 'annotation-example.md'); const PLAIN = `# Title A normal paragraph. ## Section More text. `; test('parseAnchors — empty array on plain markdown without anchors', () => { const r = parseAnchors(PLAIN); assert.equal(r.valid, true); assert.deepEqual(r.parsed, []); }); test('parseAnchors — extracts id/target/line/intent from valid anchor', () => { const md = readFileSync(EXAMPLE_PATH, 'utf-8'); const r = parseAnchors(md); assert.equal(r.valid, true, JSON.stringify(r.errors)); assert.equal(r.parsed.length, 1); assert.equal(r.parsed[0].id, 'ANN-0001'); assert.equal(r.parsed[0].target, 'section-b'); assert.equal(r.parsed[0].line, 20); assert.equal(r.parsed[0].intent, 'change'); }); test('parseAnchors — rejects ID not matching ANN-NNNN', () => { const md = `# X\n\n\n`; const r = parseAnchors(md); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'ANCHOR_BAD_ID')); }); test('parseAnchors — rejects malformed (missing id)', () => { const md = `# X\n\n\n`; const r = parseAnchors(md); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'ANCHOR_MALFORMED')); }); test('parseAnchors — rejects duplicate IDs', () => { const md = `# X\n\n\n\nFoo.\n\n\n`; const r = parseAnchors(md); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'ANCHOR_DUPLICATE_ID')); }); test('parseAnchors — ignores anchors inside fenced code blocks', () => { const md = `# X\n\n\`\`\`yaml\n\n\`\`\`\n`; const r = parseAnchors(md); assert.equal(r.valid, true); assert.deepEqual(r.parsed, []); }); test('addAnchors — empty list returns input byte-identical', () => { const r = addAnchors(PLAIN, []); assert.equal(r, PLAIN); }); test('addAnchors — inserts anchor on its own line with blank-line separation', () => { const md = `# Title\n\nLine 3.\n`; const result = addAnchors(md, [{ id: 'ANN-0001', target: 'title', line: 3, intent: 'change' }]); assert.match(result, //); // Anchor inserted above target line const lines = result.split('\n'); const anchorIdx = lines.findIndex(l => l.startsWith('\n- next\n`; const r = validateAnchorPlacement(md, []); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'ANCHOR_IN_LIST_ITEM')); }); test('validateAnchorPlacement — rejects anchor inside fenced yaml block', () => { const md = `# X\n\n\`\`\`yaml\nfoo: bar\n\n\`\`\`\n`; const r = validateAnchorPlacement(md, []); assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'ANCHOR_IN_FENCED_BLOCK')); }); test('validateAnchorPlacement — accepts anchor in body paragraph', () => { const md = readFileSync(EXAMPLE_PATH, 'utf-8'); const r = validateAnchorPlacement(md, []); assert.equal(r.valid, true, JSON.stringify(r.errors)); }); test('parseAnchors — anchor with intent block sets intent field', () => { const md = `# X\n\n\n`; const r = parseAnchors(md); assert.equal(r.valid, true); assert.equal(r.parsed[0].intent, 'block'); });