Step 17 of v4.3 playground plan. Pure function relocateAnchorsToBlockBoundaries (text, anchors) detects atomic markdown blocks (fenced code, tables, deeply nested lists) and relocates anchor-comment insertion to the line BEFORE block opening rather than inside the block. Pure markdown-text -> markdown-text transform (no DOM, no markdown-it dependency). Companion test tests/integration/annotation-block-boundary.test.mjs extracts the function via balanced-brace scan and exercises it through Function() — 7 unit tests covering empty anchors, outside-block stays, fenced-code relocation, table relocation, deeply-nested list relocation, mixed inside/outside, and shape contract. Trace: SC6, research/04 Dim 3 (Notion block-level fallback), plan-critic major #6 (DOM-vs-no-DOM contradiction resolved via pure-function design).
168 lines
7 KiB
JavaScript
168 lines
7 KiB
JavaScript
// tests/integration/annotation-block-boundary.test.mjs
|
|
// Step 17 — verify relocateAnchorsToBlockBoundaries pure-function transforms
|
|
// markdown anchors away from atomic-block interiors (fenced code, tables,
|
|
// deeply-nested lists) toward the block-boundary line.
|
|
//
|
|
// Function lives in playground/voyage-playground.html as inline-script (file://
|
|
// compat). We extract it via balanced-brace scan and exercise via Function().
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, resolve, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = resolve(__dirname, '..', '..');
|
|
const HTML = join(ROOT, 'playground', 'voyage-playground.html');
|
|
|
|
function extractFunctionSource(text, fnName) {
|
|
const needle = `function ${fnName}`;
|
|
const start = text.indexOf(needle);
|
|
if (start === -1) return null;
|
|
const braceStart = text.indexOf('{', start);
|
|
if (braceStart === -1) return null;
|
|
let depth = 0;
|
|
for (let i = braceStart; i < text.length; i++) {
|
|
if (text[i] === '{') depth++;
|
|
else if (text[i] === '}') {
|
|
depth--;
|
|
if (depth === 0) return text.slice(start, i + 1);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function loadRelocate() {
|
|
const html = readFileSync(HTML, 'utf-8');
|
|
const src = extractFunctionSource(html, 'relocateAnchorsToBlockBoundaries');
|
|
if (!src) throw new Error('relocateAnchorsToBlockBoundaries not found in HTML');
|
|
// Function() factory creates an isolated scope; safe for pure function.
|
|
// eslint-disable-next-line no-new-func
|
|
const factory = new Function(`${src}; return relocateAnchorsToBlockBoundaries;`);
|
|
return factory();
|
|
}
|
|
|
|
const relocate = loadRelocate();
|
|
|
|
test('relocateAnchorsToBlockBoundaries returns input unchanged when anchors empty', () => {
|
|
const md = 'Line 1\nLine 2\nLine 3\n';
|
|
assert.equal(relocate(md, []), md);
|
|
});
|
|
|
|
test('relocateAnchorsToBlockBoundaries leaves anchor outside atomic block at original line', () => {
|
|
const lines = [];
|
|
for (let i = 1; i <= 20; i++) lines.push(`Line ${i}`);
|
|
const md = lines.join('\n');
|
|
const out = relocate(md, [{ id: 'ANN-0001', target: 'sec-a', line: 5 }]);
|
|
const outLines = out.split('\n');
|
|
// Anchor injected at output line 5 (1-indexed = index 4); blank line at index 5
|
|
assert.match(outLines[4], /<!-- voyage:anchor id="ANN-0001"/);
|
|
assert.equal(outLines[5], '');
|
|
// Original line 5 ("Line 5") shifted to output line 7 (index 6)
|
|
assert.equal(outLines[6], 'Line 5');
|
|
});
|
|
|
|
test('relocateAnchorsToBlockBoundaries moves anchor inside fenced code-block to block-boundary', () => {
|
|
const md = [
|
|
'Line 1', // 1
|
|
'Line 2', // 2
|
|
'Line 3', // 3
|
|
'Line 4', // 4
|
|
'Line 5', // 5
|
|
'Line 6', // 6
|
|
'Line 7', // 7
|
|
'Line 8', // 8
|
|
'Line 9', // 9
|
|
'```js', // 10 - fence opens
|
|
'const x = 1;', // 11
|
|
'const y = 2;', // 12
|
|
'const z = 3;', // 13
|
|
'const a = 4;', // 14
|
|
'const b = 5;', // 15 <- anchor target
|
|
'const c = 6;', // 16
|
|
'const d = 7;', // 17
|
|
'const e = 8;', // 18
|
|
'const f = 9;', // 19
|
|
'```', // 20 - fence closes
|
|
'Line 21', // 21
|
|
].join('\n');
|
|
const out = relocate(md, [{ id: 'ANN-0002', target: 'code-block', line: 15 }]);
|
|
const outLines = out.split('\n');
|
|
// Anchor was at line 15 inside fence (10-20); block-boundary insertion at fence.start - 1 = 9
|
|
assert.match(outLines[8], /<!-- voyage:anchor id="ANN-0002"/, `expected anchor at output line 9, got: ${JSON.stringify(outLines.slice(7, 12))}`);
|
|
// Fence-opening still intact further down (shifted by 2 inserted lines)
|
|
assert.equal(outLines.find((l) => l === '```js'), '```js');
|
|
});
|
|
|
|
test('relocateAnchorsToBlockBoundaries moves anchor inside table to block-boundary', () => {
|
|
const md = [
|
|
'Intro paragraph 1', // 1
|
|
'Intro paragraph 2', // 2
|
|
'Intro paragraph 3', // 3
|
|
'Intro paragraph 4', // 4
|
|
'', // 5
|
|
'| Col A | Col B |', // 6 - table header
|
|
'|-------|-------|', // 7 - separator
|
|
'| a1 | b1 |', // 8 <- anchor target inside table
|
|
'| a2 | b2 |', // 9
|
|
'| a3 | b3 |', // 10
|
|
'', // 11
|
|
'After table', // 12
|
|
].join('\n');
|
|
const out = relocate(md, [{ id: 'ANN-0003', target: 'table-row', line: 8 }]);
|
|
const outLines = out.split('\n');
|
|
// Table starts at line 6; anchor relocated to line 5 (start-1)
|
|
assert.match(outLines[4], /<!-- voyage:anchor id="ANN-0003"/, `expected anchor at output line 5, got: ${JSON.stringify(outLines.slice(3, 8))}`);
|
|
});
|
|
|
|
test('relocateAnchorsToBlockBoundaries moves anchor inside deeply-nested list to block-boundary', () => {
|
|
const md = [
|
|
'Heading paragraph', // 1
|
|
'', // 2
|
|
'- Top-level item A', // 3
|
|
' - Second-level A.1', // 4
|
|
' - Third-level A.1.a', // 5 <- nested-list start (4-space indent = depth >= 2)
|
|
' - Third-level A.1.b', // 6 <- anchor target inside nest
|
|
' - Third-level A.1.c', // 7
|
|
' - Second-level A.2', // 8
|
|
'- Top-level item B', // 9
|
|
].join('\n');
|
|
const out = relocate(md, [{ id: 'ANN-0004', target: 'list-item', line: 6 }]);
|
|
const outLines = out.split('\n');
|
|
// Deeply-nested list starts at line 5; anchor relocated to line 4
|
|
assert.match(outLines[3], /<!-- voyage:anchor id="ANN-0004"/, `expected anchor at output line 4, got: ${JSON.stringify(outLines.slice(2, 7))}`);
|
|
});
|
|
|
|
test('relocateAnchorsToBlockBoundaries handles multiple anchors mixed inside/outside blocks', () => {
|
|
const md = [
|
|
'Para A', // 1
|
|
'Para B', // 2 <- anchor 1 (outside, stays)
|
|
'Para C', // 3
|
|
'Para D', // 4
|
|
'Para E', // 5
|
|
'```py', // 6 - fence open
|
|
'x = 1', // 7
|
|
'y = 2', // 8 <- anchor 2 (inside fence, moves to 5)
|
|
'z = 3', // 9
|
|
'```', // 10 - fence close
|
|
'Para K', // 11
|
|
].join('\n');
|
|
const out = relocate(md, [
|
|
{ id: 'ANN-0010', target: 'p', line: 2 },
|
|
{ id: 'ANN-0011', target: 'code', line: 8 },
|
|
]);
|
|
// Both anchors must appear; ANN-0011 must precede the fence-opening in output
|
|
assert.match(out, /<!-- voyage:anchor id="ANN-0010"/);
|
|
assert.match(out, /<!-- voyage:anchor id="ANN-0011"/);
|
|
const outLines = out.split('\n');
|
|
const ann11Idx = outLines.findIndex((l) => /ANN-0011/.test(l));
|
|
const fenceIdx = outLines.findIndex((l) => l === '```py');
|
|
assert.ok(ann11Idx < fenceIdx, `ANN-0011 (${ann11Idx}) must precede fence-open (${fenceIdx})`);
|
|
});
|
|
|
|
test('relocateAnchorsToBlockBoundaries returns string (basic shape)', () => {
|
|
const out = relocate('a\nb\nc\n', [{ id: 'ANN-0099', target: 't', line: 2 }]);
|
|
assert.equal(typeof out, 'string');
|
|
assert.ok(out.length > 0);
|
|
});
|