lib/parsers/anchor-parser.mjs (~190 LoC): - parseAnchors(md) -> Anchor[] (id, target, line, snippet?, intent?) - addAnchors(md, anchors) -> md_with_anchors - stripAnchors(md_with_anchors) -> md (byte-identical) - validateAnchorPlacement(md, anchors) -> errors for list-item / fenced-block / indent Format: <!-- voyage:anchor id="ANN-NNNN" target="<slug>" line="<N>" --> Block-level only, on its own line (col 0), blank-line separation. Test fixture annotation-example.md with single ANN-0001 anchor — referenced by SC12 quickstart. 14 tests pass (parseAnchors, addAnchors, stripAnchors, validateAnchorPlacement).
130 lines
4.8 KiB
JavaScript
130 lines
4.8 KiB
JavaScript
// 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<!-- voyage:anchor id="X-001" target="foo" line="3" -->\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<!-- voyage:anchor target="foo" line="3" -->\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<!-- voyage:anchor id="ANN-0001" target="a" line="3" -->\n\nFoo.\n\n<!-- voyage:anchor id="ANN-0001" target="b" line="9" -->\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<!-- voyage:anchor id="ANN-0001" target="a" line="4" -->\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, /<!-- voyage:anchor id="ANN-0001" target="title" line="3" intent="change" -->/);
|
|
// Anchor inserted above target line
|
|
const lines = result.split('\n');
|
|
const anchorIdx = lines.findIndex(l => l.startsWith('<!-- voyage:anchor'));
|
|
assert.ok(anchorIdx >= 0);
|
|
});
|
|
|
|
test('addAnchors -> stripAnchors round-trips byte-identical', () => {
|
|
const md = `# Title\n\nLine 3.\n\nLine 5.\n`;
|
|
const withAnchors = addAnchors(md, [
|
|
{ id: 'ANN-0001', target: 'title', line: 3 },
|
|
{ id: 'ANN-0002', target: 'title', line: 5 },
|
|
]);
|
|
const stripped = stripAnchors(withAnchors);
|
|
assert.equal(stripped, md, 'addAnchors then stripAnchors must round-trip byte-identical');
|
|
});
|
|
|
|
test('parseAnchors(stripAnchors(addAnchors(md, []))) returns []', () => {
|
|
const md = `# Title\n\nBody.\n`;
|
|
const result = parseAnchors(stripAnchors(addAnchors(md, [])));
|
|
assert.equal(result.valid, true);
|
|
assert.deepEqual(result.parsed, []);
|
|
});
|
|
|
|
test('validateAnchorPlacement — rejects anchor in list-item', () => {
|
|
const md = `# X\n\n- item\n <!-- voyage:anchor id="ANN-0001" target="x" line="4" -->\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<!-- voyage:anchor id="ANN-0001" target="x" line="5" -->\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<!-- voyage:anchor id="ANN-0001" target="x" line="3" intent="block" -->\n`;
|
|
const r = parseAnchors(md);
|
|
assert.equal(r.valid, true);
|
|
assert.equal(r.parsed[0].intent, 'block');
|
|
});
|