<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>PHASE-02 Model + Routing Tests</title>
</head>
<body>
<h1>PHASE-02 Tests</h1>
<pre id="results"></pre>
<script type="module">
import { state, encodePart, normalizeGraph, neighbourhood, resolveFocus, findFocus, searchFilter, kinds, normalizeConceptMap, buildNodeLabelList, buildRelLabelList, cmNeighbourhood, compareEdgesBySource } from './src/model.ts'
import { ApiError } from './src/api.ts'
import * as router from './src/router.ts'
import * as dot from './src/dot.ts'
import * as cm from './src/concept-map.ts'
import { renderCmDiagnostics } from './src/app.ts'
// padId NOT exported by model.ts — define inline
function padId(n) { return String(n).padStart(3, '0') }
// Keep the assert/assertEqual helpers
// Set window.renderCmDiagnostics for the last assertion
window.renderCmDiagnostics = renderCmDiagnostics
var resultsEl = document.getElementById('results');
var pass = 0, fail = 0, messages = [];
function assert(cond, msg) {
if (cond) { pass++; messages.push('PASS: ' + msg); }
else { fail++; messages.push('FAIL: ' + msg); }
}
function assertEqual(actual, expected, msg) {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
pass++; messages.push('PASS: ' + msg);
} else {
fail++; messages.push('FAIL: ' + msg + ' — got ' + JSON.stringify(actual) + ', expected ' + JSON.stringify(expected));
}
}
/* --- encodePart --- */
(function() {
assert(encodePart('A-Z') === 'A-Z', 'encodePart: hyphen through unchanged');
assert(encodePart('A Z') === 'A_20Z', 'encodePart: space to _20');
assert(encodePart('foo/bar') === 'foo_2fbar', 'encodePart: slash to _2f');
assert(encodePart('hello_world') === 'hello_world', 'encodePart: underscore unchanged');
assert(encodePart('Café') === 'Caf_e9', 'encodePart: accented e to _e9');
assert(encodePart('') === '', 'encodePart: empty string returns empty');
assert(encodePart('abc') === 'abc', 'encodePart: all safe unchanged');
})();
/* --- padId (internal helper) --- */
(function() {
assert(padId(1) === '001', 'padId: 1 → 001');
assert(padId(10) === '010', 'padId: 10 → 010');
assert(padId(100) === '100', 'padId: 100 → 100');
assert(padId(999) === '999', 'padId: 999 → 999');
})();
/* --- ApiError --- */
(function() {
var e = new ApiError('test message', 404, 'Not Found', '/api/test');
assert(e.name === 'ApiError', 'ApiError: name is ApiError');
assert(e.message === 'test message', 'ApiError: message preserved');
assert(e.status === 404, 'ApiError: status preserved');
assert(e.body === 'Not Found', 'ApiError: body preserved');
assert(e.endpoint === '/api/test', 'ApiError: endpoint preserved');
assert(e instanceof Error, 'ApiError: instanceof Error');
})();
/* --- state globals exist --- */
(function() {
assert(typeof state !== 'undefined', 'state: global declared');
assert(state.depth === 1, 'state: default depth is 1');
assert(state.graph.nodes instanceof Map, 'state: graph.nodes is a Map');
assert(state.graph.edges instanceof Array, 'state: graph.edges is an Array');
assert(state.graphRenderSeq === 0, 'state: graphRenderSeq is 0');
})();
/* --- normalizeGraph --- */
(function() {
var raw = {
nodes: {
'SL-072': { title: 'Doctrine Map Server', status: 'done', kind_label: 'slice' },
'ADR-001': { title: 'Module layering', status: 'accepted', kind_label: 'adr' },
'SL-071': { title: 'Entity display normalization', status: 'done', kind_label: 'slice' }
},
edges: [
{
source: { prefix: 'SL', id: 72 },
label: 'governed_by',
target: { Resolved: { prefix: 'ADR', id: 1 } },
origin: { file: 'slice-072.toml', field: 'governed_by' }
},
{
source: { prefix: 'SL', id: 72 },
label: 'needs',
target: { Resolved: { prefix: 'SL', id: 71 } },
origin: { file: 'slice-072.toml', field: 'needs' }
},
{
source: { prefix: 'SL', id: 72 },
label: 'needs',
target: { Resolved: { prefix: 'SL', id: 71 } },
origin: { file: 'slice-072.toml', field: 'needs_dup' }
},
{
source: { prefix: 'SL', id: 71 },
label: 'broken_ref',
target: { Unresolved: 'NONEXIST' },
origin: { file: 'slice-071.toml', field: 'broken' }
}
]
};
normalizeGraph(raw);
assert(state.graph.nodes.size === 3, 'normalizeGraph: 3 nodes');
assert(state.graph.edges.length === 2, 'normalizeGraph: 2 edges (1 dedup, 1 unresolved skipped)');
assert(state.graph.edgeById.size === 2, 'normalizeGraph: edgeById count 2');
var n072 = state.graph.nodes.get('SL-072');
assert(n072.kindPrefix === 'SL', 'normalizeGraph: SL-072 kindPrefix is SL');
assert(n072.kindLabel === 'slice', 'normalizeGraph: SL-072 kindLabel is slice');
assert(n072.title === 'Doctrine Map Server', 'normalizeGraph: SL-072 title preserved');
assert(n072.status === 'done', 'normalizeGraph: SL-072 status preserved');
assert(n072.id === 'SL-072', 'normalizeGraph: SL-072 id preserved');
var nAdr1 = state.graph.nodes.get('ADR-001');
assert(nAdr1.kindPrefix === 'ADR', 'normalizeGraph: ADR-001 kindPrefix is ADR');
var nSl71 = state.graph.nodes.get('SL-071');
assert(nSl71.kindPrefix === 'SL', 'normalizeGraph: SL-071 kindPrefix is SL');
/* check edge ids */
var edgeIds = [];
state.graph.edges.forEach(function(e) { edgeIds.push(e.id); });
assert(edgeIds.indexOf('e_SL-072_governed_by_ADR-001') !== -1, 'normalizeGraph: governed_by edge id correct');
assert(edgeIds.indexOf('e_SL-072_needs_SL-071') !== -1, 'normalizeGraph: needs edge id correct');
/* check incoming */
var adrIncoming = state.graph.incoming.get('ADR-001');
assert(adrIncoming !== undefined, 'normalizeGraph: ADR-001 has incoming');
assert(adrIncoming.length === 1, 'normalizeGraph: ADR-001 has 1 incoming edge');
assert(adrIncoming[0].source === 'SL-072', 'normalizeGraph: ADR-001 incoming from SL-072');
var sl71Incoming = state.graph.incoming.get('SL-071');
assert(sl71Incoming !== undefined, 'normalizeGraph: SL-071 has incoming');
assert(sl71Incoming.length === 1, 'normalizeGraph: SL-071 has 1 incoming edge');
/* check outgoing */
var sl72Outgoing = state.graph.outgoing.get('SL-072');
assert(sl72Outgoing !== undefined, 'normalizeGraph: SL-072 has outgoing');
assert(sl72Outgoing.length === 2, 'normalizeGraph: SL-072 has 2 outgoing edges');
})();
/* --- normalizeGraph: unresolved edge filtering --- */
(function() {
var raw = {
nodes: { 'SL-001': { title: 'Test', status: 'done', kind_label: 'slice' } },
edges: [
{ source: { prefix: 'SL', id: 1 }, label: 'x', target: { Unresolved: 'MISSING' }, origin: {} }
]
};
normalizeGraph(raw);
assert(state.graph.edges.length === 0, 'normalizeGraph: unresolved edge filtered out');
assert(state.graph.edgeById.size === 0, 'normalizeGraph: unresolved edge not in edgeById');
})();
/* --- neighbourhood --- */
(function() {
var raw = {
nodes: {
'SL-001': { title: 'A', status: 'done', kind_label: 'slice' },
'SL-002': { title: 'B', status: 'done', kind_label: 'slice' },
'SL-003': { title: 'C', status: 'done', kind_label: 'slice' },
'SL-004': { title: 'D', status: 'done', kind_label: 'slice' }
},
edges: [
{ source: { prefix: 'SL', id: 1 }, label: 'needs', target: { Resolved: { prefix: 'SL', id: 2 } }, origin: {} },
{ source: { prefix: 'SL', id: 2 }, label: 'needs', target: { Resolved: { prefix: 'SL', id: 3 } }, origin: {} },
{ source: { prefix: 'SL', id: 3 }, label: 'needs', target: { Resolved: { prefix: 'SL', id: 4 } }, origin: {} }
]
};
normalizeGraph(raw);
var g = state.graph;
/* depth 0 */
var n0 = neighbourhood('SL-001', 0, g);
assert(n0.nodes.size === 1 && n0.nodes.has('SL-001'), 'neighbourhood: depth 0 returns only focus');
assert(n0.edges.length === 0, 'neighbourhood: depth 0 returns empty edges');
/* depth 1 */
var n1 = neighbourhood('SL-001', 1, g);
assert(n1.nodes.has('SL-001') && n1.nodes.has('SL-002'), 'neighbourhood: depth 1 includes focus + 1 neighbour');
assert(n1.nodes.size === 2, 'neighbourhood: depth 1 has 2 nodes');
assert(n1.edges.length === 1, 'neighbourhood: depth 1 has 1 edge');
/* depth 2 */
var n2 = neighbourhood('SL-001', 2, g);
assert(n2.nodes.has('SL-003'), 'neighbourhood: depth 2 reaches SL-003');
assert(n2.nodes.size === 3, 'neighbourhood: depth 2 has 3 nodes');
/* depth 3 */
var n3 = neighbourhood('SL-001', 3, g);
assert(n3.nodes.has('SL-004'), 'neighbourhood: depth 3 reaches SL-004');
assert(n3.nodes.size === 4, 'neighbourhood: depth 3 has 4 nodes');
/* depth clamp */
var nClampLo = neighbourhood('SL-001', -1, g);
assert(nClampLo.nodes.size === 1 && nClampLo.edges.length === 0, 'neighbourhood: depth -1 clamped to 0');
var nClampHi = neighbourhood('SL-001', 5, g);
assert(nClampHi.nodes.size === 4, 'neighbourhood: depth 5 clamped to 3');
/* disconnected node */
var raw2 = {
nodes: { 'SL-099': { title: 'Isolated', status: 'done', kind_label: 'slice' } },
edges: []
};
normalizeGraph(raw2);
var nDisc = neighbourhood('SL-099', 2, state.graph);
assert(nDisc.nodes.size === 1 && nDisc.nodes.has('SL-099'), 'neighbourhood: disconnected node at depth 2 returns only itself');
/* cyclic (A→B, B→A) does not infinite loop */
var raw3 = {
nodes: {
'SL-010': { title: 'A', status: 'done', kind_label: 'slice' },
'SL-011': { title: 'B', status: 'done', kind_label: 'slice' }
},
edges: [
{ source: { prefix: 'SL', id: 10 }, label: 'needs', target: { Resolved: { prefix: 'SL', id: 11 } }, origin: {} },
{ source: { prefix: 'SL', id: 11 }, label: 'after', target: { Resolved: { prefix: 'SL', id: 10 } }, origin: {} }
]
};
normalizeGraph(raw3);
var nCyc = neighbourhood('SL-010', 3, state.graph);
assert(nCyc.nodes.size === 2, 'neighbourhood: cyclic graph terminates with both nodes');
})();
/* --- resolveFocus --- */
(function() {
var raw = {
nodes: {
'SL-072': { title: 'Doctrine Map Server', status: 'done', kind_label: 'slice' },
'ADR-001': { title: 'Module layering', status: 'accepted', kind_label: 'adr' },
'SL-010': { title: 'Something Else', status: 'started', kind_label: 'slice' }
},
edges: []
};
normalizeGraph(raw);
var g = state.graph;
/* null → first sorted node (ADR-001 comes before SL-*) */
var rNull = resolveFocus(null, g);
assertEqual(rNull, 'ADR-001', 'resolveFocus: null → first sorted node (ADR-001)');
/* empty string */
var rEmpty = resolveFocus('', g);
assertEqual(rEmpty, 'ADR-001', 'resolveFocus: empty string → first sorted node');
/* exact match */
var rExact = resolveFocus('SL-072', g);
assertEqual(rExact, 'SL-072', 'resolveFocus: exact match SL-072');
/* case-insensitive */
var rCase = resolveFocus('sl-072', g);
assertEqual(rCase, 'SL-072', 'resolveFocus: case-insensitive sl-072');
/* loose canonical */
var rLoose1 = resolveFocus('SL72', g);
assertEqual(rLoose1, 'SL-072', 'resolveFocus: loose canonical SL72 → SL-072');
var rLoose2 = resolveFocus('SL 72', g);
assertEqual(rLoose2, 'SL-072', 'resolveFocus: loose canonical "SL 72" → SL-072');
var rLoose3 = resolveFocus('sl-72', g);
assertEqual(rLoose3, 'SL-072', 'resolveFocus: loose canonical sl-72 → SL-072');
var rLoose4 = resolveFocus('SL010', g);
assertEqual(rLoose4, 'SL-010', 'resolveFocus: loose canonical SL010 → SL-010');
/* title match */
var rTitle = resolveFocus('Module layering', g);
assertEqual(rTitle, 'ADR-001', 'resolveFocus: exact title match → ADR-001');
var rTitleCase = resolveFocus('module layering', g);
assertEqual(rTitleCase, 'ADR-001', 'resolveFocus: title case-insensitive');
/* substring match */
var rSub = resolveFocus('map', g);
assertEqual(rSub, 'SL-072', 'resolveFocus: substring "map" → SL-072');
/* fallback */
var rFb = resolveFocus('zzz_nonexistent', g);
assertEqual(rFb, 'ADR-001', 'resolveFocus: fallback to first sorted node');
})();
/* --- findFocus --- */
(function() {
var raw = {
nodes: {
'SL-072': { title: 'Doctrine Map Server', status: 'done', kind_label: 'slice' },
'ADR-001': { title: 'Module layering', status: 'accepted', kind_label: 'adr' }
},
edges: []
};
normalizeGraph(raw);
var g = state.graph;
/* null → first sorted */
assertEqual(findFocus(null, g), 'ADR-001', 'findFocus: null → first sorted');
/* exact match */
assertEqual(findFocus('SL-072', g), 'SL-072', 'findFocus: exact match');
/* loose canonical */
assertEqual(findFocus('SL72', g), 'SL-072', 'findFocus: loose canonical');
/* no match → null */
assertEqual(findFocus('nonexistent', g), null, 'findFocus: no match → null (no fallback!)');
})();
/* --- searchFilter --- */
(function() {
var raw = {
nodes: {
'SL-072': { title: 'Doctrine Map Server', status: 'done', kind_label: 'slice' },
'ADR-001': { title: 'Module layering', status: 'accepted', kind_label: 'adr' },
'SL-010': { title: 'Something Else', status: 'started', kind_label: 'slice' }
},
edges: []
};
normalizeGraph(raw);
var g = state.graph;
/* empty → all sorted */
var all = searchFilter('', g);
assert(all.length === 3, 'searchFilter: empty returns all 3 nodes');
assert(all[0].id === 'ADR-001', 'searchFilter: first in sort is ADR-001');
/* null → all sorted */
var allNull = searchFilter(null, g);
assert(allNull.length === 3, 'searchFilter: null returns all 3 nodes');
/* substring in id */
var idMatch = searchFilter('SL-072', g);
assert(idMatch.length === 1 && idMatch[0].id === 'SL-072', 'searchFilter: exact id match');
/* substring in title */
var titleMatch = searchFilter('layering', g);
assert(titleMatch.length === 1 && titleMatch[0].id === 'ADR-001', 'searchFilter: substring in title');
/* case-insensitive */
var caseMatch = searchFilter('map', g);
assert(caseMatch.length === 1 && caseMatch[0].id === 'SL-072', 'searchFilter: case-insensitive "map"');
/* multiple matches sorted */
var multi = searchFilter('SL', g);
assert(multi.length === 2, 'searchFilter: "SL" matches 2');
assert(multi[0].id === 'SL-010', 'searchFilter: sorted SL-010 before SL-072');
/* no match */
var none = searchFilter('zzzzzz', g);
assert(none.length === 0, 'searchFilter: no match returns empty');
})();
/* --- kinds --- */
(function() {
var raw = {
nodes: {
'SL-072': { title: 'A', status: 'done', kind_label: 'slice' },
'SL-071': { title: 'B', status: 'done', kind_label: 'slice' },
'ADR-001': { title: 'C', status: 'accepted', kind_label: 'adr' }
},
edges: []
};
normalizeGraph(raw);
var k = kinds(state.graph.nodes);
assert(k.get('ADR') === 1, 'kinds: ADR count = 1');
assert(k.get('SL') === 2, 'kinds: SL count = 2');
/* sorted alphabetically */
var keys = [];
k.forEach(function(_, key) { keys.push(key); });
assert(keys[0] === 'ADR', 'kinds: ADR before SL (alpha sort)');
assert(keys[1] === 'SL', 'kinds: SL after ADR');
})();
/* --- router.parseHash / buildHash --- */
(function() {
/* setup mock location */
var savedHash = window.location.hash;
var savedDepth = state.depth;
/* Test parseHash with no hash */
window.location.hash = '';
var p1 = router.parseHash();
assertEqual(p1, { view: 'focus', id: null, depth: state.depth }, 'parseHash: empty hash → focus default');
/* #/focus/SL-072 */
window.location.hash = '#/focus/SL-072';
var p2 = router.parseHash();
assertEqual(p2, { view: 'focus', id: 'SL-072', depth: state.depth }, 'parseHash: #/focus/SL-072');
/* #/focus/SL-072?depth=2 */
window.location.hash = '#/focus/SL-072?depth=2';
var p3 = router.parseHash();
assertEqual(p3, { view: 'focus', id: 'SL-072', depth: 2 }, 'parseHash: #/focus/SL-072?depth=2');
/* #/edge/e_SL-072_needs_SL-071 */
window.location.hash = '#/edge/e_SL-072_needs_SL-071';
var p4 = router.parseHash();
assertEqual(p4, { view: 'edge', id: 'e_SL-072_needs_SL-071', depth: state.depth }, 'parseHash: #/edge/e_SL-072_needs_SL-071');
/* #/edge/e_SL-072_needs_SL-071?depth=3 */
window.location.hash = '#/edge/e_SL-072_needs_SL-071?depth=3';
var p5 = router.parseHash();
assertEqual(p5, { view: 'edge', id: 'e_SL-072_needs_SL-071', depth: 3 }, 'parseHash: #/edge/...?depth=3');
/* buildHash omits ?depth when depth === state.depth */
window.location.hash = '';
var built = router.buildHash('focus', 'SL-072', state.depth);
assert(built === '#/focus/SL-072', 'buildHash: omits ?depth when depth === state.depth');
/* buildHash includes ?depth when different */
var builtDiff = router.buildHash('focus', 'SL-072', 2);
assert(builtDiff === '#/focus/SL-072?depth=2', 'buildHash: includes ?depth=2 when different');
/* round-trip: buildHash → parseHash when depth differs from default */
window.location.hash = router.buildHash('focus', 'SL-071', 2);
var rt = router.parseHash();
assertEqual(rt, { view: 'focus', id: 'SL-071', depth: 2 }, 'round-trip: focus SL-071 depth=2');
window.location.hash = router.buildHash('edge', 'e_SL-072_needs_SL-071', 3);
var rtEdge = router.parseHash();
assertEqual(rtEdge, { view: 'edge', id: 'e_SL-072_needs_SL-071', depth: 3 }, 'round-trip: edge depth=3');
/* garbage hash falls back */
window.location.hash = '#/garbage';
var p6 = router.parseHash();
assertEqual(p6, { view: 'focus', id: null, depth: state.depth }, 'parseHash: garbage hash fallback');
/* edge with hyphens in id */
window.location.hash = '#/edge/e_SL-072_needs_SL-071';
var p7 = router.parseHash();
assertEqual(p7, { view: 'edge', id: 'e_SL-072_needs_SL-071', depth: state.depth }, 'parseHash: edge id with hyphens');
/* restore */
window.location.hash = savedHash;
})();
/* --- dot.dotQuote --- */
(function() {
assert(dot.dotQuote('hello') === 'hello', 'dotQuote: no special chars unchanged');
assert(dot.dotQuote('say "hello"') === 'say \"hello\"', 'dotQuote: quotes escaped');
assert(dot.dotQuote('C:\\path\\file') === 'C:\\\\path\\\\file', 'dotQuote: backslashes doubled');
assert(dot.dotQuote('line1\nline2') === 'line1\\nline2', 'dotQuote: newlines escaped');
assert(dot.dotQuote('') === '', 'dotQuote: empty string');
})();
/* --- dot.graphToDot --- */
(function() {
// Set up a mock neighbourhood for testing
var mockNodes = new Map();
mockNodes.set('SL-072', {
id: 'SL-072', title: 'Test Slice', status: 'done',
kindPrefix: 'SL', kindLabel: 'slice'
});
mockNodes.set('ADR-001', {
id: 'ADR-001', title: 'Test ADR', status: 'accepted',
kindPrefix: 'ADR', kindLabel: 'adr'
});
var savedGraph = state.graph;
state.graph = { nodes: mockNodes };
var nb = {
nodes: new Set(['SL-072', 'ADR-001']),
edges: [
{ id: 'e_SL-072_governed_by_ADR-001', source: 'SL-072', target: 'ADR-001', label: 'governed_by' }
]
};
var output = dot.graphToDot(nb, 'SL-072', 1);
assert(output.indexOf('digraph G {') === 0, 'graphToDot: starts with digraph G {');
assert(output.indexOf('rankdir=LR;') !== -1, 'graphToDot: contains rankdir=LR');
assert(output.indexOf('bgcolor="transparent"') !== -1, 'graphToDot: contains bgcolor transparent');
assert(output.indexOf('nodesep=0.45') !== -1, 'graphToDot: contains nodesep');
assert(output.indexOf('ranksep=0.8') !== -1, 'graphToDot: contains ranksep');
assert(output.indexOf('penwidth=3') !== -1, 'graphToDot: focus node has penwidth 3');
assert(output.indexOf('penwidth=1') !== -1, 'graphToDot: non-focus node has penwidth 1');
assert(output.indexOf('SL-072') !== -1, 'graphToDot: contains focus node id');
assert(output.indexOf('ADR-001') !== -1, 'graphToDot: contains target node id');
assert(output.indexOf('governed_by') !== -1, 'graphToDot: contains edge label');
assert(output.lastIndexOf('}') === output.length - 1, 'graphToDot: ends with }');
state.graph = savedGraph;
})();
/* --- router.setFocus / setEdge --- */
(function() {
var hashBefore = window.location.hash;
router.setFocus('SL-072');
assert(window.location.hash === '#/focus/SL-072', 'setFocus: sets focus hash');
router.setFocus('SL-071', 2);
assert(window.location.hash === '#/focus/SL-071?depth=2', 'setFocus: sets focus with depth');
router.setEdge('e_SL-072_needs_SL-071');
assert(window.location.hash === '#/edge/e_SL-072_needs_SL-071', 'setEdge: sets edge hash');
router.setEdge('e_SL-072_needs_SL-071', 3);
assert(window.location.hash === '#/edge/e_SL-072_needs_SL-071?depth=3', 'setEdge: sets edge with depth');
/* restore */
window.location.hash = hashBefore;
})();
/* --- PHASE-04/05: Concept Map JS tests --- */
/* --- normalizeConceptMap --- */
(function() {
var raw = {
id: 'CM-001',
title: 'System Architecture',
status: 'draft',
description: 'High-level map',
dsl_hash: 'abc123',
nodes: [{ key: 'user-story', label: 'User Story' }],
edges: [{ from_key: 'user-story', from_label: 'User Story', rel: 'expresses', to_key: 'user-need', to_label: 'User Need', line: 5 }],
diagnostics: [{ MalformedLine: { line: 3, text: 'bad line' } }]
};
var cm = normalizeConceptMap(raw);
assert(cm.id === 'CM-001', 'normalizeConceptMap: id preserved');
assert(cm.title === 'System Architecture', 'normalizeConceptMap: title preserved');
assert(cm.status === 'draft', 'normalizeConceptMap: status preserved');
assert(cm.description === 'High-level map', 'normalizeConceptMap: description preserved');
assert(cm.dslHash === 'abc123', 'normalizeConceptMap: dslHash mapped correctly');
assert(cm.nodes.length === 1, 'normalizeConceptMap: nodes array preserved');
assert(cm.edges.length === 1, 'normalizeConceptMap: edges array preserved');
assert(cm.diagnostics.length === 1, 'normalizeConceptMap: diagnostics array preserved');
})();
/* --- normalizeConceptMap: missing fields --- */
(function() {
var raw = { id: 'CM-002', title: 'Empty Map', status: 'draft' };
var cm = normalizeConceptMap(raw);
assert(cm.description === '', 'normalizeConceptMap: missing description → empty string');
assert(cm.dslHash === '', 'normalizeConceptMap: missing dsl_hash → empty string');
assert(cm.nodes.length === 0, 'normalizeConceptMap: missing nodes → empty array');
assert(cm.edges.length === 0, 'normalizeConceptMap: missing edges → empty array');
assert(cm.diagnostics.length === 0, 'normalizeConceptMap: missing diagnostics → empty array');
})();
/* --- buildNodeLabelList (autocomplete) --- */
(function() {
var cm = { nodes: [] };
assert(buildNodeLabelList(cm).length === 0, 'buildNodeLabelList: empty nodes → empty array');
var cm1 = { nodes: [{ key: 'a', label: 'Alpha' }, { key: 'b', label: 'Beta' }] };
var labels1 = buildNodeLabelList(cm1);
assertEqual(labels1, ['Alpha', 'Beta'], 'buildNodeLabelList: two distinct labels');
var cmDup = { nodes: [{ key: 'a', label: 'Alpha' }, { key: 'a2', label: 'Alpha' }] };
var labelsDup = buildNodeLabelList(cmDup);
assert(labelsDup.length === 1, 'buildNodeLabelList: duplicate labels deduplicated');
assert(labelsDup[0] === 'Alpha', 'buildNodeLabelList: keeps first occurrence');
var cmNull = buildNodeLabelList(null);
assert(cmNull.length === 0, 'buildNodeLabelList: null cm → empty array');
var cmNoNodes = buildNodeLabelList({});
assert(cmNoNodes.length === 0, 'buildNodeLabelList: missing nodes → empty array');
})();
/* --- buildRelLabelList (autocomplete) --- */
(function() {
var cm = { edges: [] };
assert(buildRelLabelList(cm).length === 0, 'buildRelLabelList: empty edges → empty array');
var cm1 = { edges: [{ rel: 'expresses' }, { rel: 'depends on' }] };
assertEqual(buildRelLabelList(cm1), ['expresses', 'depends on'], 'buildRelLabelList: two distinct rels');
var cmDup = { edges: [{ rel: 'expresses' }, { rel: 'expresses' }] };
assert(buildRelLabelList(cmDup).length === 1, 'buildRelLabelList: duplicate rels deduplicated');
var cmNull = buildRelLabelList(null);
assert(cmNull.length === 0, 'buildRelLabelList: null cm → empty array');
})();
/* --- isConceptMap (emulated via kindPrefix check) --- */
(function() {
// Populate graph with a CM node and a non-CM node
var raw = {
nodes: {
'CM-001': { title: 'Test CM', status: 'draft', kind_label: 'concept map' },
'SL-001': { title: 'Test Slice', status: 'done', kind_label: 'slice' }
},
edges: []
};
normalizeGraph(raw);
var cmNode = state.graph.nodes.get('CM-001');
var slNode = state.graph.nodes.get('SL-001');
assert(cmNode.kindPrefix === 'CM', 'isConceptMap prep: CM-001 kindPrefix is CM');
assert(slNode.kindPrefix === 'SL', 'isConceptMap prep: SL-001 kindPrefix is SL');
// Simulate isConceptMap logic
var isCm = cmNode && cmNode.kindPrefix === 'CM';
var isNotCm = slNode && slNode.kindPrefix === 'CM';
assert(isCm === true, 'isConceptMap: CM-001 → true');
assert(isNotCm === false, 'isConceptMap: SL-001 → false');
var nonExistent = state.graph.nodes.get('NONEXIST');
assert(!(nonExistent && nonExistent.kindPrefix === 'CM'), 'isConceptMap: nonexistent → false');
})();
/* --- stale-write API error handling (ApiError) --- */
(function() {
// Verify ApiError carries status and body for stale-write detection
var err409 = new ApiError('stale', 409, '{"error":"stale_concept_map"}', '/api/concept-map/CM-001');
assert(err409.status === 409, 'ApiError stale: status 409');
assert(err409.body.indexOf('stale_concept_map') !== -1, 'ApiError stale: body contains stale_concept_map');
// Duplicate edge error
var errDup = new ApiError('duplicate', 409, '{"error":"duplicate_edge","line":5}', '/api/concept-map/CM-001');
assert(errDup.status === 409, 'ApiError duplicate: status 409');
assert(errDup.body.indexOf('duplicate_edge') !== -1, 'ApiError duplicate: body contains duplicate_edge');
assert(errDup.body.indexOf('"line":5') !== -1, 'ApiError duplicate: body contains line 5');
// Empty field error
var err400 = new ApiError('empty', 400, '{"error":"empty_field","message":"source must be non-empty"}', '/api/concept-map/CM-001');
assert(err400.status === 400, 'ApiError empty: status 400');
// Not found error
var err404 = new ApiError('not found', 404, '{"error":"edge_not_found"}', '/api/concept-map/CM-001');
assert(err404.status === 404, 'ApiError not found: status 404');
// Node collision error
var errColl = new ApiError('collision', 409, '{"error":"node_collision","existing_label":"Foo"}', '/api/concept-map/CM-001');
assert(errColl.body.indexOf('node_collision') !== -1, 'ApiError collision: body contains node_collision');
})();
/* --- PHASE-07: CM focal node, depth filtering, styling (VT-1) --- */
/* --- cmNeighbourhood: null focusKey → wide-open --- */
(function() {
var cm = {
nodes: [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
{ key: 'c', label: 'C' },
{ key: 'd', label: 'D' }
],
edges: [
{ from_key: 'a', to_key: 'b', rel: 'relates' },
{ from_key: 'b', to_key: 'c', rel: 'relates' },
{ from_key: 'b', to_key: 'd', rel: 'relates' }
]
};
var result = cmNeighbourhood(cm, null, 2);
assert(result.nodes.length === 4, 'cmNeighbourhood: null focusKey → all 4 nodes');
assert(result.edges.length === 3, 'cmNeighbourhood: null focusKey → all 3 edges');
})();
/* --- cmNeighbourhood: depth 0 → focal only --- */
(function() {
var cm = {
nodes: [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' }
],
edges: [
{ from_key: 'a', to_key: 'b', rel: 'relates' }
]
};
var result = cmNeighbourhood(cm, 'a', 0);
assert(result.nodes.length === 1, 'cmNeighbourhood: depth 0 → only focal node');
assert(result.nodes[0].key === 'a', 'cmNeighbourhood: depth 0 → focal is A');
assert(result.edges.length === 0, 'cmNeighbourhood: depth 0 → no edges');
})();
/* --- cmNeighbourhood: depth 1 → focal + 1-hop --- */
(function() {
var cm = {
nodes: [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
{ key: 'c', label: 'C' },
{ key: 'd', label: 'D' }
],
edges: [
{ from_key: 'a', to_key: 'b', rel: 'relates' },
{ from_key: 'b', to_key: 'c', rel: 'relates' },
{ from_key: 'b', to_key: 'd', rel: 'relates' }
]
};
var result = cmNeighbourhood(cm, 'a', 1);
assert(result.nodes.length === 2, 'cmNeighbourhood: depth 1 from A → 2 nodes (A + B)');
var keys = result.nodes.map(function(n) { return n.key; });
assert(keys.indexOf('a') !== -1, 'cmNeighbourhood: depth 1 includes A');
assert(keys.indexOf('b') !== -1, 'cmNeighbourhood: depth 1 includes B');
assert(result.edges.length === 1, 'cmNeighbourhood: depth 1 → 1 edge (A→B)');
})();
/* --- cmNeighbourhood: depth 2 → full 4-node tree --- */
(function() {
var cm = {
nodes: [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
{ key: 'c', label: 'C' },
{ key: 'd', label: 'D' }
],
edges: [
{ from_key: 'a', to_key: 'b', rel: 'relates' },
{ from_key: 'b', to_key: 'c', rel: 'relates' },
{ from_key: 'b', to_key: 'd', rel: 'relates' }
]
};
var result = cmNeighbourhood(cm, 'a', 2);
assert(result.nodes.length === 4, 'cmNeighbourhood: depth 2 from A → all 4 nodes');
assert(result.edges.length === 3, 'cmNeighbourhood: depth 2 → all 3 edges');
})();
/* --- cmNeighbourhood: disconnected node → only itself --- */
(function() {
var cm = {
nodes: [
{ key: 'e', label: 'E' },
{ key: 'f', label: 'F' }
],
edges: []
};
var result = cmNeighbourhood(cm, 'e', 2);
assert(result.nodes.length === 1, 'cmNeighbourhood: disconnected node E → only itself');
assert(result.edges.length === 0, 'cmNeighbourhood: disconnected → 0 edges');
})();
/* --- cmNeighbourhood: 1-node graph --- */
(function() {
var cm = {
nodes: [{ key: 'solo', label: 'Solo' }],
edges: []
};
var result = cmNeighbourhood(cm, 'solo', 2);
assert(result.nodes.length === 1, 'cmNeighbourhood: 1-node graph → only itself');
assert(result.edges.length === 0, 'cmNeighbourhood: 1-node graph → 0 edges');
})();
/* --- cmNeighbourhood: focusKey not in nodes → graceful fallback (all) --- */
(function() {
var cm = {
nodes: [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' }
],
edges: [{ from_key: 'a', to_key: 'b', rel: 'relates' }]
};
var result = cmNeighbourhood(cm, 'nonexistent', 2);
assert(result.nodes.length === 2, 'cmNeighbourhood: focusKey not in nodes → returns all nodes');
assert(result.edges.length === 1, 'cmNeighbourhood: focusKey not in nodes → returns all edges');
})();
/* --- cmNeighbourhood: undirected adjacency (B→D edge, focus A depth 2 includes D) --- */
(function() {
var cm = {
nodes: [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
{ key: 'd', label: 'D' }
],
edges: [
{ from_key: 'a', to_key: 'b', rel: 'relates' },
{ from_key: 'b', to_key: 'd', rel: 'relates' }
]
};
var result = cmNeighbourhood(cm, 'd', 1);
assert(result.nodes.length === 2, 'cmNeighbourhood: depth 1 from D → D + B (undirected)');
assert(result.edges.length === 1, 'cmNeighbourhood: depth 1 from D → 1 edge');
var result2 = cmNeighbourhood(cm, 'd', 2);
assert(result2.nodes.length === 3, 'cmNeighbourhood: depth 2 from D → D + B + A (all)');
})();
/* --- cmNeighbourhood: null/undefined cm → safe empty --- */
(function() {
var result = cmNeighbourhood(null, 'a', 1);
assert(result.nodes.length === 0, 'cmNeighbourhood: null cm → empty nodes');
assert(result.edges.length === 0, 'cmNeighbourhood: null cm → empty edges');
var result2 = cmNeighbourhood(undefined, 'a', 1);
assert(result2.nodes.length === 0, 'cmNeighbourhood: undefined cm → empty nodes');
})();
/* --- cmGraphToDot with focusKey --- */
(function() {
var cm = {
nodes: [
{ key: 'a', label: 'Node A' },
{ key: 'b', label: 'Node B' }
],
edges: [
{ from_key: 'a', to_key: 'b', rel: 'relates' }
]
};
var dotText = dot.cmGraphToDot(cm, 'a');
assert(dotText.indexOf('penwidth=3.0') !== -1, 'cmGraphToDot: focal node A has penwidth=3.0');
assert(dotText.indexOf('penwidth=1.5') !== -1, 'cmGraphToDot: default penwidth=1.5 in node attrs');
assert(dotText.indexOf('shape=record') !== -1, 'cmGraphToDot: shape=record present');
assert(dotText.indexOf('fillcolor="#f8f9fa"') !== -1, 'cmGraphToDot: off-white fill present');
assert(dotText.indexOf('color="#4A90D9"') !== -1, 'cmGraphToDot: blue border present in node attrs');
assert(dotText.indexOf('fontcolor="#222222"') !== -1, 'cmGraphToDot: dark text present');
assert(dotText.indexOf('edge [color="#4A90D9"') !== -1, 'cmGraphToDot: blue edge colour');
})();
/* --- cmGraphToDot without focusKey (no penwidth overrides) --- */
(function() {
var cm = {
nodes: [
{ key: 'a', label: 'Node A' },
{ key: 'b', label: 'Node B' }
],
edges: [
{ from_key: 'a', to_key: 'b', rel: 'relates' }
]
};
var dotText = dot.cmGraphToDot(cm, null);
/* No per-node penwidth=3.0 → all nodes use default penwidth=1.5 from node attrs */
assert(dotText.indexOf('penwidth=3.0') === -1, 'cmGraphToDot: no focusKey → no penwidth=3.0 override');
assert(dotText.indexOf('penwidth=1.5') !== -1, 'cmGraphToDot: default penwidth=1.5 in node attrs');
})();
/* --- cmGraphToDot with focusKey=undefined (backward compat) --- */
(function() {
var cm = {
nodes: [{ key: 'a', label: 'Node A' }],
edges: []
};
var dotText = dot.cmGraphToDot(cm);
assert(dotText.indexOf('penwidth=3.0') === -1, 'cmGraphToDot: no second arg → no penwidth=3.0');
assert(dotText.indexOf('digraph concept_map') !== -1, 'cmGraphToDot: valid DOT output');
})();
/* --- router.parseHash with cmFocus --- */
(function() {
var hashBefore = window.location.hash;
window.location.hash = '#/focus/CM-001?depth=2&cm_focus=user-story';
var route = router.parseHash();
assert(route.view === 'focus', 'parseHash cmFocus: view is focus');
assert(route.id === 'CM-001', 'parseHash cmFocus: id is CM-001');
assert(route.depth === 2, 'parseHash cmFocus: depth is 2');
assert(route.cmFocus === 'user-story', 'parseHash cmFocus: cmFocus is user-story');
window.location.hash = '#/focus/CM-001?depth=1';
var route2 = router.parseHash();
assert(route2.cmFocus === null, 'parseHash cmFocus: absent cm_focus → null');
window.location.hash = '#/focus/CM-001';
var route3 = router.parseHash();
assert(route3.cmFocus === null, 'parseHash cmFocus: no query string → null');
window.location.hash = '#/focus/SL-001?depth=0';
var route4 = router.parseHash();
assert(route4.cmFocus === null, 'parseHash cmFocus: non-CM focus → null');
assert(route4.id === 'SL-001', 'parseHash cmFocus: non-CM id preserved');
window.location.hash = '#/edge/e_CM-001_relates_CM-002';
var route5 = router.parseHash();
assert(route5.view === 'edge', 'parseHash cmFocus: edge view');
assert(route5.cmFocus === null, 'parseHash cmFocus: edge view cmFocus is null');
/* restore */
window.location.hash = hashBefore;
})();
/* --- PHASE-06: Diagnostics panel tests --- */
(function() { var p = document.querySelector('.cm-diagnostics-panel'); if (!p) { p = document.createElement('div'); p.className = 'cm-diagnostics-panel'; p.style.display = 'none'; document.body.appendChild(p); } })();
(function() { var p = document.querySelector('.cm-diagnostics-panel'); cm.renderDiagnostics({ container: p, diagnostics: [{ CanonicalNodeCollision: { key: 'foo', first_label: 'FooBar', first_line: 1, label: 'Foo', line: 3 } }] }); assert(p.style.display !== 'none', 'non-empty diagnostics → panel visible'); assert(p.innerHTML.indexOf('Foo') !== -1, 'contains conflicting label'); assert(p.innerHTML.indexOf('foo') !== -1, 'contains key'); assert(p.innerHTML.indexOf('FooBar') !== -1, 'contains first_label'); assert(p.innerHTML.indexOf('line 3') !== -1, 'contains line prefix'); assert(p.innerHTML.indexOf('Diagnostics') !== -1, 'has header'); })();
(function() { var p = document.querySelector('.cm-diagnostics-panel'); cm.renderDiagnostics({ container: p, diagnostics: [] }); assert(p.style.display === 'none', 'empty → hidden'); })();
(function() { var p = document.querySelector('.cm-diagnostics-panel'); cm.renderDiagnostics({ container: p, diagnostics: [] }); assert(p.style.display === 'none', 'empty array → hidden'); })();
(function() { var p = document.querySelector('.cm-diagnostics-panel'); cm.renderDiagnostics({ container: p, diagnostics: [{ SelfEdge: { line: 2, node_key: 'alpha' } }] }); assert(p.style.display !== 'none', 'SelfEdge → visible'); assert(p.innerHTML.indexOf('Self-referencing') !== -1, 'SelfEdge message'); assert(p.innerHTML.indexOf('alpha') !== -1, 'contains node_key'); })();
(function() { var p = document.querySelector('.cm-diagnostics-panel'); cm.renderDiagnostics({ container: p, diagnostics: [{ SelfEdge: { line: 1, node_key: 'a' } }, { EntityRefLike: { label: 'SL-001', line: 3 } }, { SimilarNodeLabel: { label_a: 'Foo', line_a: 2, label_b: 'Fooo', line_b: 4 } }] }); assert(p.querySelectorAll('.cm-diag-item').length === 3, '3 items'); assert(p.innerHTML.indexOf('SL-001') !== -1, 'EntityRefLike'); assert(p.innerHTML.indexOf('entity reference') !== -1, 'EntityRefLike msg'); assert(p.innerHTML.indexOf('Similar node labels') !== -1, 'SimilarNodeLabel msg'); assert(p.innerHTML.indexOf('Foo') !== -1, 'contains label_a'); })();
(function() { var p = document.querySelector('.cm-diagnostics-panel'); cm.renderDiagnostics({ container: p, diagnostics: [{ DuplicateEdge: { line: 5, existing_line: 2, from_key: 'a', rel: 'relates', to_key: 'b' } }] }); assert(p.innerHTML.indexOf('Duplicate edge') !== -1, 'DuplicateEdge msg'); assert(p.innerHTML.indexOf('line 2') !== -1, 'mentions existing_line'); })();
(function() { var p = document.querySelector('.cm-diagnostics-panel'); cm.renderDiagnostics({ container: p, diagnostics: [{ MalformedLine: { line: 7, text: 'bad > line > extra' } }] }); assert(p.innerHTML.indexOf('Malformed DSL') !== -1, 'MalformedLine msg'); assert(p.innerHTML.indexOf('bad > line > extra') !== -1, 'contains text'); })();
(function() { var p = document.querySelector('.cm-diagnostics-panel'); cm.renderDiagnostics({ container: p, diagnostics: [{ RelationDrift: { rel_a: 'depends', line_a: 2, rel_b: 'dependes', line_b: 5 } }] }); assert(p.innerHTML.indexOf('depends') !== -1, 'contains rel_a'); assert(p.innerHTML.indexOf('possible typo') !== -1, 'RelationDrift msg'); })();
(function() { assert(typeof window.renderCmDiagnostics === 'function', 'function exposed on window'); })();
/* --- report --- */
resultsEl.textContent = pass + '/' + (pass + fail) + ' assertions passed';
if (fail > 0) {
resultsEl.textContent += '\n\n' + messages.join('\n');
} else {
resultsEl.textContent += '\n\nAll tests passed!';
}
</script>
</body>
</html>