import { test } from 'brittle'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { scan } from '../lib/scanner.js'
import {
buildSystemLevel, buildModuleLevel, bundleLinks, dependencyClosure, depthColor,
nodesAtPath, categoryOf, categoriesOf, categoryColor, moduleStats
} from '../lib/floor/cards.js'
const HERE = dirname(fileURLToPath(import.meta.url))
const WORKSPACE = resolve(HERE, 'fixtures/workspace')
// The top two rungs, against the fixture workspace: four modules, one shared
// dep, one real dependency edge, and a module whose files nest two directories
// deep. Categories and directories are CONTAINERS here — the same machinery
// zones use at the symbol level — which is why they cannot overlap.
let tree = null
test('upper levels: scan the fixture workspace', async (t) => {
tree = await scan(WORKSPACE, { maxDepth: 1 })
t.is(tree.system.modules.length, 4, 'four modules')
})
test('system level: one card per module, categories as containers', (t) => {
const { cards, containers, links } = buildSystemLevel(tree)
t.is(cards.length, 4, 'four cards')
const byTitle = {}
for (const c of cards) byTitle[c.title] = c
t.ok(byTitle['widget-store'], 'widget-store is there')
t.is(byTitle['widget-store'].kind, 'module', 'kind is module')
t.is(byTitle['widget-store'].badge, '0.2.0', 'badged with its version')
t.is(byTitle['widget-store'].openTo, 'widget-store', 'and opens into itself')
t.ok(byTitle['widget-store'].sub.includes('files'), 'counts its files: ' + byTitle['widget-store'].sub)
// Every card belongs to the container its category names.
for (const c of cards) {
const category = tree.moduleCategories[c.title]
t.is(c.container, category ? 'cat:' + category : null, c.title + ' is in its category')
}
const ids = new Set(containers.map(c => c.id))
for (const c of cards) {
if (c.container) t.ok(ids.has(c.container), 'the container it names exists')
}
t.ok(links.length >= 0, 'links computed')
})
test('system level: only edges between modules the scan HAS', (t) => {
const { cards, links } = buildSystemLevel(tree)
const ids = new Set(cards.map(c => c.id))
const named = links.map(l => l.from.replace('module:', '') + '->' + l.to.replace('module:', ''))
t.ok(named.includes('widget-rooms->widget-store'), 'the real dependency edge is drawn')
// b4a is a shared dependency but is NOT a module in the scan, so it is not a
// card and must not be an edge endpoint pointing at nothing.
for (const l of links) {
t.ok(ids.has(l.from), 'edge source is a card')
t.ok(ids.has(l.to), 'edge target is a card')
}
t.absent(named.some(n => n.includes('b4a')), 'no edge to an external dependency')
})
test('module level: one card per file, directories as NESTED containers', (t) => {
const { cards, containers } = buildModuleLevel(tree, 'widget-store')
const titles = cards.map(c => c.title).sort()
t.alike(titles, ['index.js', 'schema.js', 'store.test.js', 'widget-store.js', 'widgets.js'],
'every file is a card')
const byId = {}
for (const c of containers) byId[c.id] = c
t.ok(byId['dir:lib'], 'lib/ is a container')
t.ok(byId['dir:lib/handlers'], 'lib/handlers/ is a container')
t.ok(byId['dir:test'], 'test/ is a container')
t.is(byId['dir:lib/handlers'].parent, 'dir:lib', 'and it nests inside lib')
t.is(byId['dir:lib'].parent, null, 'while lib is top level')
const handlers = cards.find(c => c.title === 'widgets.js')
t.is(handlers.container, 'dir:lib/handlers', 'a nested file names the deepest directory')
const entry = cards.find(c => c.title === 'index.js')
t.is(entry.container, null, 'a root file has no directory container')
t.is(entry.badge, 'entry', 'and keeps its role as its badge')
})
test('module level: links read supplier-first, like every other level', (t) => {
const { cards, links } = buildModuleLevel(tree, 'widget-store')
const byId = {}
for (const c of cards) byId[c.id] = c.title
const named = links.map(l => byId[l.from] + '->' + byId[l.to])
t.ok(named.includes('widget-store.js->index.js'),
'the barrel is fed by what it re-exports: ' + named.join(', '))
t.ok(named.includes('schema.js->widget-store.js'), 'and schema feeds the class')
t.is(new Set(named).size, named.length, 'deduped')
})
test('module level: opening a file gives a path the file level can load', (t) => {
const { cards } = buildModuleLevel(tree, 'widget-store')
for (const c of cards) {
t.ok(typeof c.openTo === 'string' && c.openTo.length > 0, c.title + ' opens to a path')
t.ok(c.openTo.endsWith(c.title), 'which ends in the file name: ' + c.openTo)
}
})
test('upper levels: a module that does not exist yields an empty level, not a throw', (t) => {
const out = buildModuleLevel(tree, 'nope')
t.alike(out, { cards: [], links: [], containers: [] }, 'empty')
})
test('bundling: crossing edges collapse to one per container pair, with a count', (t) => {
const cards = [
{ id: 'a1', container: 'A' }, { id: 'a2', container: 'A' },
{ id: 'b1', container: 'B' }, { id: 'b2', container: 'B' },
{ id: 'loose', container: null }
]
const links = [
{ from: 'a1', to: 'b1' },
{ from: 'a1', to: 'b2' },
{ from: 'a2', to: 'b1' },
{ from: 'a1', to: 'a2' }, // inside A — says nothing bundled
{ from: 'loose', to: 'b1' }
]
const bundled = bundleLinks(cards, links)
const ab = bundled.find(b => b.from === 'A' && b.to === 'B')
t.ok(ab, 'A -> B exists')
t.is(ab.count, 3, 'carrying all three crossing edges')
t.is(ab.members.length, 3, 'and remembering which they were')
t.absent(bundled.find(b => b.from === 'A' && b.to === 'A'), 'no self-edge for the internal one')
const lb = bundled.find(b => b.from === 'loose')
t.ok(lb, 'a card with no container stands for itself')
t.is(lb.to, 'B', 'and still reaches the group it points at')
})
test('bundling: direction is kept, and the order is stable', (t) => {
const cards = [{ id: 'x', container: 'A' }, { id: 'y', container: 'B' }]
const links = [{ from: 'x', to: 'y' }, { from: 'y', to: 'x' }]
const bundled = bundleLinks(cards, links)
t.is(bundled.length, 2, 'A->B and B->A are different edges')
t.alike(bundleLinks(cards, links), bundled, 'and the order does not wander')
})
test('bundling: on a real level it is a large reduction', (t) => {
const sys = buildSystemLevel(tree)
const bundled = bundleLinks(sys.cards, sys.links)
t.ok(bundled.length <= sys.links.length, 'never more edges than it started with')
t.ok(bundled.every(b => b.count >= 1), 'every bundle carries at least one')
const total = bundled.reduce((a, b) => a + b.count, 0)
const crossing = sys.links.filter(l => {
const g = (id) => (sys.cards.find(c => c.id === id) || {}).container
return g(l.from) !== g(l.to)
}).length
t.is(total, crossing, 'and every crossing edge is accounted for exactly once')
})
test('bundling: crossing edges collapse to one per container pair, with a count', (t) => {
const cards = [
{ id: 'a1', container: 'A' }, { id: 'a2', container: 'A' },
{ id: 'b1', container: 'B' }, { id: 'b2', container: 'B' },
{ id: 'loose', container: null }
]
const links = [
{ from: 'a1', to: 'b1' },
{ from: 'a1', to: 'b2' },
{ from: 'a2', to: 'b1' },
{ from: 'a1', to: 'a2' }, // inside A - says nothing once bundled
{ from: 'loose', to: 'b1' }
]
const bundled = bundleLinks(cards, links)
const ab = bundled.find(b => b.from === 'A' && b.to === 'B')
t.ok(ab, 'A -> B exists')
t.is(ab.count, 3, 'carrying all three crossing edges')
t.is(ab.members.length, 3, 'and remembering which they were')
t.absent(bundled.find(b => b.from === 'A' && b.to === 'A'), 'no self-edge for the internal one')
const lb = bundled.find(b => b.from === 'loose')
t.ok(lb, 'a card with no container stands for itself')
t.is(lb.to, 'B', 'and still reaches the group it points at')
})
test('bundling: direction is kept, and the order is stable', (t) => {
const cards = [{ id: 'x', container: 'A' }, { id: 'y', container: 'B' }]
const links = [{ from: 'x', to: 'y' }, { from: 'y', to: 'x' }]
const bundled = bundleLinks(cards, links)
t.is(bundled.length, 2, 'A->B and B->A are different edges')
t.alike(bundleLinks(cards, links), bundled, 'and the order does not wander')
})
test('bundling: on a real level every crossing edge is accounted for once', (t) => {
const sys = buildSystemLevel(tree)
const bundled = bundleLinks(sys.cards, sys.links)
t.ok(bundled.length <= sys.links.length, 'never more edges than it started with')
const groupOf = (id) => {
const card = sys.cards.find(c => c.id === id)
return card ? (card.container || card.id) : undefined
}
const crossing = sys.links.filter(l => groupOf(l.from) !== groupOf(l.to)).length
t.is(bundled.reduce((a, b) => a + b.count, 0), crossing, 'exactly once')
})
test('closure: dependencies are found by walking backward, with shortest depth', (t) => {
// a -> b -> c -> d (supplier -> consumer), so d depends on c, b and a.
const links = [
{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'd' }
]
const out = dependencyClosure(links, 'd')
t.is(out.nodes.get('d'), 0, 'the start is depth 0')
t.is(out.nodes.get('c'), 1, 'its direct dependency is 1')
t.is(out.nodes.get('b'), 2, 'then 2')
t.is(out.nodes.get('a'), 3, 'then 3')
t.is(out.maxDepth, 3, 'and it reports how deep it went')
// The other direction finds nothing: d supplies nobody.
t.is(dependencyClosure(links, 'd', 'dependents').nodes.size, 1, 'no dependents')
t.is(dependencyClosure(links, 'a', 'dependents').nodes.get('d'), 3, 'but a has d at 3')
})
test('closure: depth is the SHORTEST path, not the first one walked', (t) => {
// a -> b -> c -> e
// a -------------> e (a is both 3 hops and 1 hop away)
const links = [
{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'e' },
{ from: 'a', to: 'e' }
]
const out = dependencyClosure(links, 'e')
t.is(out.nodes.get('a'), 1, 'a is one hop away by the short edge')
})
test('closure: a cycle terminates', (t) => {
const links = [{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'a' }]
const out = dependencyClosure(links, 'a')
t.is(out.nodes.size, 3, 'all three reached')
t.ok(out.maxDepth <= 3, 'and it stopped')
})
test('closure: unrelated nodes are not in it', (t) => {
const links = [{ from: 'a', to: 'b' }, { from: 'x', to: 'y' }]
const out = dependencyClosure(links, 'b')
t.absent(out.nodes.has('x'), 'a different component is excluded')
t.absent(out.nodes.has('y'), 'entirely')
})
test('closure: on a real level it finds the real dependency', (t) => {
// This fixture is deliberately one hop deep — widget-rooms feeds widget-store
// and nothing else chains — so the claim here is that the closure finds the
// right module at the right distance, not that it goes deep. Depth is proven
// on the synthetic chains above, where the shape is controlled.
const sys = buildSystemLevel(tree)
const store = sys.cards.find(c => c.title === 'widget-store')
const out = dependencyClosure(sys.links, store.id)
t.is(out.nodes.get('module:widget-rooms'), 1, 'widget-rooms is one hop away')
t.is(out.maxDepth, 1, 'and this fixture is exactly one hop deep')
t.absent(out.nodes.has('module:legacy-sync'), 'an unrelated module is not pulled in')
})
test('gradient: colour moves with depth and is stable at the ends', (t) => {
t.is(depthColor(0, 5), '#ffa657', 'the selected node itself is the accent colour')
const near = depthColor(1, 5)
const far = depthColor(5, 5)
t.not(near, far, 'near and far differ')
t.ok(near.startsWith('hsl('), 'and they are hsl ramps: ' + near + ' -> ' + far)
t.is(depthColor(9, 1), depthColor(9, 1), 'deterministic')
})
test('shared: nodesAtPath walks the dirTree, one implementation for both views', (t) => {
const roots = nodesAtPath(tree, '')
t.ok(roots.length > 0, 'the workspace root has entries')
const names = roots.map(r => r.name).sort()
t.alike(names, ['legacy-sync', 'mapped-io', 'widget-rooms', 'widget-store'], 'the four modules')
for (const r of roots) {
t.is(r.type, 'module', r.name + ' is a module, not a bare directory')
t.ok(r.mod, 'and carries its module entry')
t.ok(r.dirEntry, 'and its dirTree entry')
}
t.alike(nodesAtPath(tree, 'does/not/exist'), [], 'a missing path is empty, not a throw')
})
test('shared: categoryOf uses the scanner`s single resolution', (t) => {
for (const name of tree.system.modules) {
t.is(categoryOf(tree, name), tree.moduleCategories[name],
name + ' agrees with the scanner')
}
t.is(categoryOf(tree, 'not-a-module'), 'Other', 'an unknown module lands in Other')
})
test('shared: every category in use gets a colour, derived ones included', (t) => {
const cats = categoriesOf(tree)
t.ok(cats.length > 0, 'categories: ' + cats.join(', '))
const derived = cats.filter(c => c.endsWith(' users'))
t.ok(derived.length > 0, 'this workspace has a DERIVED category: ' + derived.join(', '))
const colors = new Set()
for (const c of cats) {
const col = categoryColor(tree, c)
t.ok(/^#[0-9a-f]{6}$/i.test(col), c + ' has a colour: ' + col)
colors.add(col)
}
t.is(colors.size, cats.length, 'and distinct categories get distinct colours')
t.is(categoryColor(tree, cats[0]), categoryColor(tree, cats[0]), 'stable')
})
test('shared: moduleStats counts what both views show', (t) => {
const mod = tree.modules['widget-store']
const stats = moduleStats(mod)
t.is(stats.fileCount, Object.keys(mod.files).length, 'files')
t.is(stats.exportCount, mod.publicAPI.length, 'exports')
// Pin the definition: functions + class methods + getters, across every file.
let expected = 0
for (const f of Object.values(mod.files)) {
expected += (f.functions || []).length
for (const c of f.classes || []) expected += (c.methods || []).length + (c.getters || []).length
}
t.is(stats.memberCount, expected, 'members are functions + methods + getters: ' + expected)
t.ok(expected > 0, 'and this fixture has some')
t.alike(moduleStats(null), { fileCount: 0, memberCount: 0, exportCount: 0 }, 'no module is zeros')
})