call_scripters 0.2.0

Call scripts as markdown, followed as a clickable conversation graph in one offline html file
const test = require('node:test');
const assert = require('node:assert/strict');
const { parse, visible } = require('./model.js');

const dedent = s => s.replace(/^\n/, '').replace(/^\t+/gm, '');

function render(graph) {
	return [
		...graph.labels.map(l => `label ${l.name} ${l.color} ${JSON.stringify(l.description)}`),
		...graph.nodes.map(n => `node ${n.id} "${n.title}"${n.root ? ' root' : ''}${n.labels.map(l => ` .${l}`).join('')} body=${JSON.stringify(n.body.trim())}`),
		...graph.edges.map(e => `edge ${e.from} -[${e.label}]-> ${e.to}`),
		...Object.entries(graph.dims).map(([k, vs]) => `dim ${k}: ${vs.join(' ')}`),
		...graph.errors.map(e => `error L${e.line}: ${e.msg}`),
	].join('\n');
}

function checkParse(md, expected) {
	assert.equal(render(parse(dedent(md))), dedent(expected).trim());
}

function checkVisible(md, variants, expected) {
	const graph = parse(dedent(md));
	assert.deepEqual(graph.errors, []);
	assert.deepEqual([...visible(graph, { variants })].sort(), expected.sort());
}

test('slots, tags and response edges', () => {
	checkParse(
		`
		# Hello, World!
		Say [hi](#opening) warmly.

		- [Has time](#opening)
		- not a [response](#opening) item

		# Opening {opener=warm}
		warm
		# Opening {opener=cold}
		cold
		`,
		`
		node hello-world "Hello, World!" root body="Say [hi](#opening) warmly.\\n\\n- not a [response](#opening) item"
		node opening{opener=warm} "Opening" body="warm"
		node opening{opener=cold} "Opening" body="cold"
		edge hello-world -[Has time]-> opening{opener=warm}
		edge hello-world -[Has time]-> opening{opener=cold}
		dim opener: cold warm
		`,
	);
});

test('canonical id sorts tag keys', () => {
	checkParse(
		`
		# A {z=1 a=2}
		`,
		`
		node a{a=2 z=1} "A" root body=""
		dim z: 1
		dim a: 2
		`,
	);
});

test('headings and responses inside code fences are body', () => {
	checkParse(
		'# A\n```md\n# Not a node\n- [x](#nowhere)\n```\n~~~\n```\n# still fenced\n~~~\n',
		'node a "A" root body="```md\\n# Not a node\\n- [x](#nowhere)\\n```\\n~~~\\n```\\n# still fenced\\n~~~"',
	);
});

test('parse errors', () => {
	checkParse(
		`
		intro text

		# A
		`,
		`
		node a "A" root body=""
		error L1: text before the first \`# \` heading
		`,
	);
	checkParse(
		`
		# A
		- [x](#b)
		`,
		`
		node a "A" root body=""
		error L2: response links to unknown slot #b
		`,
	);
	checkParse(
		`
		# A {k=v}
		# A {k=v}
		`,
		`
		node a{k=v} "A" root body=""
		node a{k=v} "A" root body=""
		dim k: v
		error L2: duplicate of line 1: same heading and tags
		`,
	);
	checkParse(
		`
		# A
		# B
		- [x](#c)
		# C
		- [y](#b)
		`,
		`
		node a "A" root body=""
		node b "B" body=""
		node c "C" body=""
		edge b -[x]-> c
		edge c -[y]-> b
		error L2: "B" is unreachable from any root
		error L4: "C" is unreachable from any root
		`,
	);
	checkParse('', 'error L1: no `# ` headings');
	checkParse('# A {k=1 k=2}\n', 'node a{k=2} "A" root body=""\ndim k: 2\nerror L1: tag key `k` given twice');
});

test('labels are defined before the first heading and attached with `.name`', () => {
	checkParse(
		`
		[hot]: <oklch(0.6 0.2 25)> "Wants it \\"now\\""
		[later]: <oklch(0.55 0.15 145)>

		# A {.hot k=v .later}
		- [x](#b)
		# B {.later}
		`,
		`
		label hot oklch(0.6 0.2 25) "Wants it \\"now\\""
		label later oklch(0.55 0.15 145) ""
		node a{k=v} "A" root .hot .later body=""
		node b "B" .later body=""
		edge a{k=v} -[x]-> b
		dim k: v
		`,
	);
	checkParse(
		`
		[hot]: <oklch(0.6 0.2 25)>
		[hot]: <oklch(0 0 0)>
		[cold]: #0000ff
		# A {.warm .hot .hot}
		`,
		`
		label hot oklch(0.6 0.2 25) ""
		node a "A" root .warm .hot body=""
		error L2: label \`hot\` defined twice
		error L3: text before the first \`# \` heading
		error L4: label \`.hot\` given twice
		error L4: unknown label \`.warm\`
		`,
	);
});

test('spans locate the text an edit replaces', () => {
	const md = '[l]: <oklch(0 0 0)>\n\n# A {k=v .l} ##\nbody\n- [x](#b)\n# B\n';
	const g = parse(md);
	assert.deepEqual(g.errors, []);
	const [a, b] = g.nodes;
	assert.equal(md.slice(a.attrs.start, a.attrs.end), ' {k=v .l}');
	assert.equal(b.attrs.start, b.attrs.end);
	assert.equal(md.slice(0, b.attrs.start), '[l]: <oklch(0 0 0)>\n\n# A {k=v .l} ##\nbody\n- [x](#b)\n# B');
	assert.equal(md.slice(g.edges[0].start, g.edges[0].end), '- [x](#b)\n');
	assert.equal(md.slice(g.labels[0].start, g.labels[0].end), '[l]: <oklch(0 0 0)>\n');
});

const VARIANTS = `
	# Intro
	- [go](#opening)
	# Opening {opener=warm}
	- [next](#warm-pitch)
	# Opening {opener=cold}
	- [next](#cold-pitch)
	# Warm pitch
	- [ok](#close)
	# Cold pitch
	- [ok](#close)
	- [no](#objection)
	# Objection
	- [handled](#close)
	# Close
	- [again](#intro)
	# Price objection
	- [handled](#close)
`;

test('unselected dims admit every variant; cycles and multiple roots', () => {
	checkVisible(VARIANTS, {}, [
		'intro',
		'opening{opener=warm}',
		'opening{opener=cold}',
		'warm-pitch',
		'cold-pitch',
		'objection',
		'close',
		'price-objection',
	]);
});

test('excluded variant hides its subtree; slot link resolves to the selected variant', () => {
	checkVisible(VARIANTS, { opener: 'warm' }, ['intro', 'opening{opener=warm}', 'warm-pitch', 'close', 'price-objection']);
});

test('rejoin stays visible through any visible parent', () => {
	checkVisible(VARIANTS, { opener: 'cold' }, ['intro', 'opening{opener=cold}', 'cold-pitch', 'objection', 'close', 'price-objection']);
});

test('variant root is filtered like any other node', () => {
	const md = `
		# Intro {lang=en}
		- [x](#end)
		# Intro {lang=de}
		- [y](#end)
		# End
	`;
	checkVisible(md, { lang: 'de' }, ['intro{lang=de}', 'end']);
});