| … | … | @@ -22,6 +22,120 @@ |
| 22 | 22 | * openTo the symbol NAME this card descends into, or null |
| 23 | 23 | */ |
| 24 | 24 | |
| 25 | /** | |
| 26 | * The system level: one card per module, grouped into CONTAINERS by category. | |
| 27 | * | |
| 28 | * The categories were drawn as frames after layout in the old view, which is how | |
| 29 | * they came to overlap. Here they are containers, so the layout places them as | |
| 30 | * whole rectangles and they cannot interleave. | |
| 31 | */ | |
| 32 | export function buildSystemLevel (tree) { | |
| 33 | const categories = tree.moduleCategories || {} | |
| 34 | const used = new Set() | |
| 35 | ||
| 36 | const cards = (tree.system.modules || []).map(name => { | |
| 37 | const mod = tree.modules[name] || {} | |
| 38 | const category = categories[name] || null | |
| 39 | if (category) used.add(category) | |
| 40 | const deps = Object.keys(mod.dependencies || {}) | |
| 41 | const api = mod.publicAPI || [] | |
| 42 | return { | |
| 43 | id: 'module:' + name, | |
| 44 | kind: 'module', | |
| 45 | title: name, | |
| 46 | badge: mod.version || '', | |
| 47 | sig: api.length > 0 ? api.slice(0, 4).join(', ') + (api.length > 4 ? ', ...' : '') : '', | |
| 48 | sub: Object.keys(mod.files || {}).length + ' files · ' + deps.length + ' deps · ' + | |
| 49 | api.length + ' exports', | |
| 50 | container: category ? 'cat:' + category : null, | |
| 51 | code: null, | |
| 52 | span: null, | |
| 53 | openTo: name | |
| 54 | } | |
| 55 | }) | |
| 56 | ||
| 57 | const containers = Array.from(used).sort().map(c => ({ | |
| 58 | id: 'cat:' + c, parent: null, label: c, shape: 'category', depth: 1 | |
| 59 | })) | |
| 60 | ||
| 61 | // Only edges between modules the scan actually has. An external dependency is | |
| 62 | // not a card here, so an edge to it would point at nothing. | |
| 63 | const present = new Set(cards.map(c => c.id)) | |
| 64 | const links = [] | |
| 65 | for (const [name, deps] of Object.entries(tree.system.dependencies || {})) { | |
| 66 | for (const dep of deps) { | |
| 67 | const from = 'module:' + dep | |
| 68 | const to = 'module:' + name | |
| 69 | if (present.has(from) && present.has(to) && from !== to) links.push({ from, to }) | |
| 70 | } | |
| 71 | } | |
| 72 | return { cards, links, containers } | |
| 73 | } | |
| 74 | ||
| 75 | /** | |
| 76 | * The module level: one card per file, grouped into CONTAINERS by directory. | |
| 77 | * | |
| 78 | * Directories nest, so the containers nest — lib/handlers sits inside lib — which | |
| 79 | * is the same machinery zones use one rung further down. | |
| 80 | */ | |
| 81 | export function buildModuleLevel (tree, moduleName) { | |
| 82 | const mod = (tree.modules || {})[moduleName] | |
| 83 | if (!mod) return { cards: [], links: [], containers: [] } | |
| 84 | ||
| 85 | const dirs = new Set() | |
| 86 | const cards = Object.entries(mod.files || {}).map(([path, file]) => { | |
| 87 | const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : null | |
| 88 | if (dir) { | |
| 89 | // Register every ancestor, so lib/handlers implies lib. | |
| 90 | const parts = dir.split('/') | |
| 91 | for (let i = 1; i <= parts.length; i++) dirs.add(parts.slice(0, i).join('/')) | |
| 92 | } | |
| 93 | const exports = file.exports || [] | |
| 94 | return { | |
| 95 | id: 'file:' + moduleName + ':' + path, | |
| 96 | kind: 'file', | |
| 97 | title: path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path, | |
| 98 | badge: file.role || 'lib', | |
| 99 | sig: exports.length > 0 | |
| 100 | ? exports.map(e => e.name).slice(0, 4).join(', ') + (exports.length > 4 ? ', ...' : '') | |
| 101 | : '', | |
| 102 | sub: (file.imports || []).length + ' imports · ' + exports.length + ' exports · ' + | |
| 103 | (file.functions || []).length + ' functions', | |
| 104 | container: dir ? 'dir:' + dir : null, | |
| 105 | code: null, | |
| 106 | span: null, | |
| 107 | openTo: mod.path && mod.path !== '.' ? mod.path + '/' + path : path | |
| 108 | } | |
| 109 | }) | |
| 110 | ||
| 111 | const containers = Array.from(dirs).sort().map(dir => { | |
| 112 | const parent = dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : null | |
| 113 | return { | |
| 114 | id: 'dir:' + dir, | |
| 115 | parent: parent ? 'dir:' + parent : null, | |
| 116 | label: dir + '/', | |
| 117 | shape: 'directory', | |
| 118 | depth: dir.split('/').length | |
| 119 | } | |
| 120 | }) | |
| 121 | ||
| 122 | // `from` imports `to`, so the supplier is on the left, matching every other | |
| 123 | // level's reading direction. | |
| 124 | const present = new Set(cards.map(c => c.id)) | |
| 125 | const links = [] | |
| 126 | const seen = new Set() | |
| 127 | for (const edge of (mod.importGraph && mod.importGraph.edges) || []) { | |
| 128 | const from = 'file:' + moduleName + ':' + edge.to | |
| 129 | const to = 'file:' + moduleName + ':' + edge.from | |
| 130 | if (!present.has(from) || !present.has(to) || from === to) continue | |
| 131 | const key = from + '->' + to | |
| 132 | if (seen.has(key)) continue | |
| 133 | seen.add(key) | |
| 134 | links.push({ from, to }) | |
| 135 | } | |
| 136 | return { cards, links, containers } | |
| 137 | } | |
| 138 | ||
| 25 | 139 | /** The file level: one card per symbol (L1), including the module body (L2). */ |
| 26 | 140 | export function buildFileLevel (model) { |
| 27 | 141 | const cards = (model.symbols || []).map(sym => ({ |
| … | … | @@ -14,7 +14,8 @@ |
| 14 | 14 | |
| 15 | 15 | import { layout } from '/floor/layout.js' |
| 16 | 16 | import { |
| 17 | buildFileLevel, buildSymbolLevel, resolveCode, lineDepths, spanOnLine, marksOwnSpan | |
| 17 | buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel, | |
| 18 | resolveCode, lineDepths, spanOnLine, marksOwnSpan | |
| 18 | 19 | } from '/floor/cards.js' |
| 19 | 20 | |
| 20 | 21 | const SVG_NS = 'http://www.w3.org/2000/svg' |
| … | … | @@ -26,6 +27,8 @@ const KIND_COLOR = { |
| 26 | 27 | class: '#6b4a86', |
| 27 | 28 | const: '#8a6a2a', |
| 28 | 29 | 'module-body': '#1f4a6b', |
| 30 | module: '#1f4a6b', | |
| 31 | file: '#2f5b86', | |
| 29 | 32 | op: '#2f5b86', |
| 30 | 33 | call: '#2f6b6b', |
| 31 | 34 | exit: '#8a3b36', |
| … | … | @@ -42,6 +45,8 @@ const CODE_LINE_H = 10 |
| 42 | 45 | const MAX_CODE_LINES = 120 |
| 43 | 46 | |
| 44 | 47 | const view = { |
| 48 | tree: null, // the scan, for the system and module levels | |
| 49 | module: null, // the module level, when there is no path | |
| 45 | 50 | path: null, |
| 46 | 51 | model: null, |
| 47 | 52 | source: null, |
| … | … | @@ -61,6 +66,22 @@ const view = { |
| 61 | 66 | sy: 0 |
| 62 | 67 | } |
| 63 | 68 | |
| 69 | // system -> module -> file -> symbol. Which rung we are on is decided by what | |
| 70 | // the URL names, so every level is deep-linkable (invariant I5). | |
| 71 | function levelKind () { | |
| 72 | if (view.path && view.symbolId) return 'symbol' | |
| 73 | if (view.path) return 'file' | |
| 74 | if (view.module) return 'module' | |
| 75 | return 'system' | |
| 76 | } | |
| 77 | ||
| 78 | async function loadTree () { | |
| 79 | if (view.tree) return view.tree | |
| 80 | const res = await fetch('/api/tree') | |
| 81 | view.tree = await res.json() | |
| 82 | return view.tree | |
| 83 | } | |
| 84 | ||
| 64 | 85 | async function load (path) { |
| 65 | 86 | const res = await fetch('/api/derive?path=' + encodeURIComponent(path)) |
| 66 | 87 | const data = await res.json() |
| … | … | @@ -114,9 +135,18 @@ function heightOf (card) { |
| 114 | 135 | return h |
| 115 | 136 | } |
| 116 | 137 | |
| 138 | function builtLevel () { | |
| 139 | const kind = levelKind() | |
| 140 | if (kind === 'system') return buildSystemLevel(view.tree) | |
| 141 | if (kind === 'module') return buildModuleLevel(view.tree, view.module) | |
| 142 | const sym = currentSymbol() | |
| 143 | return sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model) | |
| 144 | } | |
| 145 | ||
| 117 | 146 | function rebuild () { |
| 147 | const kind = levelKind() | |
| 118 | 148 | const sym = currentSymbol() |
| 119 | const built = sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model) | |
| 149 | const built = builtLevel() | |
| 120 | 150 | view.cards = built.cards |
| 121 | 151 | view.links = built.links |
| 122 | 152 | view.containers = built.containers |
| … | … | @@ -132,15 +162,28 @@ function rebuild () { |
| 132 | 162 | containers: built.containers |
| 133 | 163 | }) |
| 134 | 164 | |
| 165 | const NAMES = { | |
| 166 | system: '<b>system level</b> - one node per module, grouped by category', | |
| 167 | module: '<b>module level</b> - one node per file, grouped by directory', | |
| 168 | file: '<b>file level</b> - one node per symbol', | |
| 169 | symbol: '<b>symbol level</b> - operations inside their zones' | |
| 170 | } | |
| 171 | document.getElementById('levelname').innerHTML = NAMES[kind] | |
| 172 | ||
| 135 | 173 | const m = view.model |
| 136 | document.getElementById('levelname').innerHTML = sym | |
| 137 | ? '<b>symbol level</b> - operations inside their zones' | |
| 138 | : '<b>file level</b> - one node per symbol' | |
| 139 | document.getElementById('stats').textContent = sym | |
| 140 | ? built.cards.length + ' nodes · ' + built.containers.length + ' zones in ' + sym.name | |
| 141 | : m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' + m.zones.length + | |
| 174 | let stats = '' | |
| 175 | if (kind === 'system') { | |
| 176 | stats = built.cards.length + ' modules · ' + built.containers.length + ' categories' | |
| 177 | } else if (kind === 'module') { | |
| 178 | stats = built.cards.length + ' files · ' + built.containers.length + ' directories' | |
| 179 | } else if (kind === 'symbol') { | |
| 180 | stats = built.cards.length + ' nodes · ' + built.containers.length + ' zones in ' + sym.name | |
| 181 | } else { | |
| 182 | stats = m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' + m.zones.length + | |
| 142 | 183 | ' zones · ' + m.exits.length + ' exits' + (m.language ? ' · ' + m.language : '') |
| 143 | banner(m.partial ? 'PARTIAL - ' + m.partialReason : null) | |
| 184 | } | |
| 185 | document.getElementById('stats').textContent = stats | |
| 186 | banner(m && m.partial ? 'PARTIAL - ' + m.partialReason : null) | |
| 144 | 187 | breadcrumb() |
| 145 | 188 | render() |
| 146 | 189 | } |
| … | … | @@ -162,29 +205,32 @@ function banner (text) { |
| 162 | 205 | function breadcrumb () { |
| 163 | 206 | const bc = document.getElementById('crumb') |
| 164 | 207 | bc.textContent = '' |
| 208 | const kind = levelKind() | |
| 165 | 209 | const sym = currentSymbol() |
| 166 | 210 | |
| 167 | const file = document.createElement('a') | |
| 168 | file.textContent = view.path | |
| 169 | file.href = '#' | |
| 170 | file.className = sym ? '' : 'current' | |
| 171 | file.addEventListener('click', (e) => { | |
| 172 | e.preventDefault() | |
| 173 | enter(null) | |
| 211 | const rungs = [{ label: 'system', go: () => goSystem() }] | |
| 212 | if (view.module) rungs.push({ label: view.module, go: () => goModule(view.module) }) | |
| 213 | if (view.path) rungs.push({ label: view.path, go: () => goFile(view.path) }) | |
| 214 | if (sym) rungs.push({ label: sym.name, go: null }) | |
| 215 | ||
| 216 | rungs.forEach((rung, i) => { | |
| 217 | if (i > 0) { | |
| 218 | const sep = document.createElement('span') | |
| 219 | sep.className = 'sep' | |
| 220 | sep.textContent = ' > ' | |
| 221 | bc.appendChild(sep) | |
| 222 | } | |
| 223 | const a = document.createElement('a') | |
| 224 | a.textContent = rung.label | |
| 225 | a.href = '#' | |
| 226 | const last = i === rungs.length - 1 | |
| 227 | if (last) a.className = 'current' | |
| 228 | if (!last && rung.go) { | |
| 229 | a.addEventListener('click', (e) => { e.preventDefault(); rung.go() }) | |
| 230 | } | |
| 231 | bc.appendChild(a) | |
| 174 | 232 | }) |
| 175 | bc.appendChild(file) | |
| 176 | ||
| 177 | if (sym) { | |
| 178 | const sep = document.createElement('span') | |
| 179 | sep.className = 'sep' | |
| 180 | sep.textContent = ' > ' | |
| 181 | bc.appendChild(sep) | |
| 182 | const here = document.createElement('a') | |
| 183 | here.textContent = sym.name | |
| 184 | here.href = '#' | |
| 185 | here.className = 'current' | |
| 186 | bc.appendChild(here) | |
| 187 | } | |
| 233 | void kind | |
| 188 | 234 | } |
| 189 | 235 | |
| 190 | 236 | function render () { |
| … | … | @@ -310,7 +356,9 @@ function drawCard (world, card, pos) { |
| 310 | 356 | title.classList.add('n-open') |
| 311 | 357 | title.addEventListener('click', (e) => { |
| 312 | 358 | e.stopPropagation() |
| 313 | enter(card.openTo) | |
| 359 | if (card.kind === 'module') goModule(card.openTo) | |
| 360 | else if (card.kind === 'file') goFile(card.openTo) | |
| 361 | else enter(card.openTo) | |
| 314 | 362 | }) |
| 315 | 363 | } |
| 316 | 364 | |
| … | … | @@ -362,6 +410,41 @@ function clip (text, n) { |
| 362 | 410 | |
| 363 | 411 | // --- navigation ------------------------------------------------------------ |
| 364 | 412 | |
| 413 | async function goSystem () { | |
| 414 | await loadTree() | |
| 415 | view.module = null | |
| 416 | view.path = null | |
| 417 | view.model = null | |
| 418 | view.symbolId = null | |
| 419 | resetView() | |
| 420 | syncUrl() | |
| 421 | rebuild() | |
| 422 | } | |
| 423 | ||
| 424 | async function goModule (name) { | |
| 425 | await loadTree() | |
| 426 | view.module = name | |
| 427 | view.path = null | |
| 428 | view.model = null | |
| 429 | view.symbolId = null | |
| 430 | resetView() | |
| 431 | syncUrl() | |
| 432 | rebuild() | |
| 433 | } | |
| 434 | ||
| 435 | async function goFile (path) { | |
| 436 | view.symbolId = null | |
| 437 | resetView() | |
| 438 | await load(path) | |
| 439 | syncUrl() | |
| 440 | } | |
| 441 | ||
| 442 | function resetView () { | |
| 443 | view.expanded = new Set() | |
| 444 | view.panX = 60 | |
| 445 | view.panY = 110 | |
| 446 | } | |
| 447 | ||
| 365 | 448 | function enter (symbolName) { |
| 366 | 449 | const sym = symbolName ? view.model.symbols.find(s => s.name === symbolName) : null |
| 367 | 450 | view.symbolId = sym ? sym.id : null |
| … | … | @@ -396,9 +479,11 @@ function applyExpandParam () { |
| 396 | 479 | |
| 397 | 480 | function syncUrl () { |
| 398 | 481 | const sym = currentSymbol() |
| 399 | let url = '/floor?path=' + encodeURIComponent(view.path) | |
| 400 | if (sym) url += '&symbol=' + encodeURIComponent(sym.name) | |
| 401 | history.replaceState(null, '', url) | |
| 482 | const q = [] | |
| 483 | if (view.module) q.push('module=' + encodeURIComponent(view.module)) | |
| 484 | if (view.path) q.push('path=' + encodeURIComponent(view.path)) | |
| 485 | if (sym) q.push('symbol=' + encodeURIComponent(sym.name)) | |
| 486 | history.replaceState(null, '', '/floor' + (q.length > 0 ? '?' + q.join('&') : '')) | |
| 402 | 487 | } |
| 403 | 488 | |
| 404 | 489 | function applyTransform () { |
| … | … | @@ -448,7 +533,12 @@ function wire () { |
| 448 | 533 | view.expanded.clear() |
| 449 | 534 | rebuild() |
| 450 | 535 | } else if (e.key === 'Escape') { |
| 451 | enter(null) | |
| 536 | const kind = levelKind() | |
| 537 | if (kind === 'symbol') enter(null) | |
| 538 | else if (kind === 'file') { | |
| 539 | if (view.module) goModule(view.module) | |
| 540 | else goSystem() | |
| 541 | } else if (kind === 'module') goSystem() | |
| 452 | 542 | } |
| 453 | 543 | }) |
| 454 | 544 | |
| … | … | @@ -464,19 +554,18 @@ function wire () { |
| 464 | 554 | |
| 465 | 555 | async function start () { |
| 466 | 556 | wire() |
| 557 | await loadTree() | |
| 467 | 558 | const params = new URLSearchParams(location.search) |
| 468 | let path = params.get('path') | |
| 469 | if (!path) { | |
| 470 | const res = await fetch('/api/files') | |
| 471 | const data = await res.json() | |
| 472 | const first = (data.files || []).find(f => f.path.endsWith('.js')) || (data.files || [])[0] | |
| 473 | path = first ? first.path : null | |
| 474 | } | |
| 475 | if (!path) { | |
| 476 | banner('no files in the scan to open') | |
| 559 | view.module = params.get('module') | |
| 560 | const path = params.get('path') | |
| 561 | ||
| 562 | if (path) { | |
| 563 | await load(path) | |
| 564 | syncUrl() | |
| 477 | 565 | return |
| 478 | 566 | } |
| 479 | await load(path) | |
| 567 | // No path: the system level is the top of the ladder and the honest default. | |
| 568 | rebuild() | |
| 480 | 569 | } |
| 481 | 570 | |
| 482 | 571 | start() |
| … | … | @@ -0,0 +1,105 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { resolve, dirname } from 'path' | |
| 3 | import { fileURLToPath } from 'url' | |
| 4 | import { scan } from '../lib/scanner.js' | |
| 5 | import { buildSystemLevel, buildModuleLevel } from '../lib/floor/cards.js' | |
| 6 | ||
| 7 | const HERE = dirname(fileURLToPath(import.meta.url)) | |
| 8 | const WORKSPACE = resolve(HERE, 'fixtures/workspace') | |
| 9 | ||
| 10 | // The top two rungs, against the fixture workspace: four modules, one shared | |
| 11 | // dep, one real dependency edge, and a module whose files nest two directories | |
| 12 | // deep. Categories and directories are CONTAINERS here — the same machinery | |
| 13 | // zones use at the symbol level — which is why they cannot overlap. | |
| 14 | ||
| 15 | let tree = null | |
| 16 | test('upper levels: scan the fixture workspace', async (t) => { | |
| 17 | tree = await scan(WORKSPACE, { maxDepth: 1 }) | |
| 18 | t.is(tree.system.modules.length, 4, 'four modules') | |
| 19 | }) | |
| 20 | ||
| 21 | test('system level: one card per module, categories as containers', (t) => { | |
| 22 | const { cards, containers, links } = buildSystemLevel(tree) | |
| 23 | ||
| 24 | t.is(cards.length, 4, 'four cards') | |
| 25 | const byTitle = {} | |
| 26 | for (const c of cards) byTitle[c.title] = c | |
| 27 | t.ok(byTitle['widget-store'], 'widget-store is there') | |
| 28 | t.is(byTitle['widget-store'].kind, 'module', 'kind is module') | |
| 29 | t.is(byTitle['widget-store'].badge, '0.2.0', 'badged with its version') | |
| 30 | t.is(byTitle['widget-store'].openTo, 'widget-store', 'and opens into itself') | |
| 31 | t.ok(byTitle['widget-store'].sub.includes('files'), 'counts its files: ' + byTitle['widget-store'].sub) | |
| 32 | ||
| 33 | // Every card belongs to the container its category names. | |
| 34 | for (const c of cards) { | |
| 35 | const category = tree.moduleCategories[c.title] | |
| 36 | t.is(c.container, category ? 'cat:' + category : null, c.title + ' is in its category') | |
| 37 | } | |
| 38 | const ids = new Set(containers.map(c => c.id)) | |
| 39 | for (const c of cards) { | |
| 40 | if (c.container) t.ok(ids.has(c.container), 'the container it names exists') | |
| 41 | } | |
| 42 | t.ok(links.length >= 0, 'links computed') | |
| 43 | }) | |
| 44 | ||
| 45 | test('system level: only edges between modules the scan HAS', (t) => { | |
| 46 | const { cards, links } = buildSystemLevel(tree) | |
| 47 | const ids = new Set(cards.map(c => c.id)) | |
| 48 | ||
| 49 | const named = links.map(l => l.from.replace('module:', '') + '->' + l.to.replace('module:', '')) | |
| 50 | t.ok(named.includes('widget-rooms->widget-store'), 'the real dependency edge is drawn') | |
| 51 | // b4a is a shared dependency but is NOT a module in the scan, so it is not a | |
| 52 | // card and must not be an edge endpoint pointing at nothing. | |
| 53 | for (const l of links) { | |
| 54 | t.ok(ids.has(l.from), 'edge source is a card') | |
| 55 | t.ok(ids.has(l.to), 'edge target is a card') | |
| 56 | } | |
| 57 | t.absent(named.some(n => n.includes('b4a')), 'no edge to an external dependency') | |
| 58 | }) | |
| 59 | ||
| 60 | test('module level: one card per file, directories as NESTED containers', (t) => { | |
| 61 | const { cards, containers } = buildModuleLevel(tree, 'widget-store') | |
| 62 | ||
| 63 | const titles = cards.map(c => c.title).sort() | |
| 64 | t.alike(titles, ['index.js', 'schema.js', 'store.test.js', 'widget-store.js', 'widgets.js'], | |
| 65 | 'every file is a card') | |
| 66 | ||
| 67 | const byId = {} | |
| 68 | for (const c of containers) byId[c.id] = c | |
| 69 | t.ok(byId['dir:lib'], 'lib/ is a container') | |
| 70 | t.ok(byId['dir:lib/handlers'], 'lib/handlers/ is a container') | |
| 71 | t.ok(byId['dir:test'], 'test/ is a container') | |
| 72 | t.is(byId['dir:lib/handlers'].parent, 'dir:lib', 'and it nests inside lib') | |
| 73 | t.is(byId['dir:lib'].parent, null, 'while lib is top level') | |
| 74 | ||
| 75 | const handlers = cards.find(c => c.title === 'widgets.js') | |
| 76 | t.is(handlers.container, 'dir:lib/handlers', 'a nested file names the deepest directory') | |
| 77 | const entry = cards.find(c => c.title === 'index.js') | |
| 78 | t.is(entry.container, null, 'a root file has no directory container') | |
| 79 | t.is(entry.badge, 'entry', 'and keeps its role as its badge') | |
| 80 | }) | |
| 81 | ||
| 82 | test('module level: links read supplier-first, like every other level', (t) => { | |
| 83 | const { cards, links } = buildModuleLevel(tree, 'widget-store') | |
| 84 | const byId = {} | |
| 85 | for (const c of cards) byId[c.id] = c.title | |
| 86 | ||
| 87 | const named = links.map(l => byId[l.from] + '->' + byId[l.to]) | |
| 88 | t.ok(named.includes('widget-store.js->index.js'), | |
| 89 | 'the barrel is fed by what it re-exports: ' + named.join(', ')) | |
| 90 | t.ok(named.includes('schema.js->widget-store.js'), 'and schema feeds the class') | |
| 91 | t.is(new Set(named).size, named.length, 'deduped') | |
| 92 | }) | |
| 93 | ||
| 94 | test('module level: opening a file gives a path the file level can load', (t) => { | |
| 95 | const { cards } = buildModuleLevel(tree, 'widget-store') | |
| 96 | for (const c of cards) { | |
| 97 | t.ok(typeof c.openTo === 'string' && c.openTo.length > 0, c.title + ' opens to a path') | |
| 98 | t.ok(c.openTo.endsWith(c.title), 'which ends in the file name: ' + c.openTo) | |
| 99 | } | |
| 100 | }) | |
| 101 | ||
| 102 | test('upper levels: a module that does not exist yields an empty level, not a throw', (t) => { | |
| 103 | const out = buildModuleLevel(tree, 'nope') | |
| 104 | t.alike(out, { cards: [], links: [], containers: [] }, 'empty') | |
| 105 | }) |