Driver brief §B. gospel-internet's GiTree had NO props and NO emits: it read a board composable and three module singletons, which is what made it gospel's rather than anybody's. Everything it reached for is now a prop, and the parts worth testing are no longer inside an SFC a gate cannot run. WHAT IS ACTUALLY IN THE COMPONENT: wiring, 160 lines. The drawing is the whiteboard tiered-points layer, the surface is whiteboard-map.js (moved out of gospel's plugin -- 67 lines whose every import already came from library, so it was never theirs), the hierarchy is language-tree-model.js and the camera is frame-target.js. Four modules, four headless gates. An SFC cannot be executed by a check, so it should hold as little as possible. THREE BEHAVIOURS PINNED BECAUSE THEY ARE EASY TO STATE WRONGLY: FRAME ON SELECT, NOT ON CHANGE. Clearing does not re-frame; the camera stays where the reader left it. "It frames when the selection changes" is the obvious phrasing and it is wrong in exactly the case that annoys people -- being yanked somewhere after deselecting. A SELECTION THAT ARRIVES BEFORE THE MAP IS REPLAYED. GiTree survived this by ordering: its host set the node through a composable that waited for data, so the value always landed after the map. As a PROP the ordering belongs to the host, and a prop whose effect depends on WHEN you set it is a trap -- a shared deep link would work or not depending on load timing, which nobody could report. ready replays whatever `selected` is. HOVER CANNOT MOVE THE CAMERA, structurally rather than by convention: hover sets a filter on its own layer and there is no camera call on that path to remove later. AND THE CONSTANTS. gospel's maintainer inherited GiTree and had to read intent out of structure -- there is no "this took measuring" note anywhere in it. Asked which line they would flag, they named frame(). So padding 60 (the outermost nodes carry labels that extend past the point), maxZoom 8 (a two-language family must not fill the screen with its own neighbourhood gone), duration 900, and the floor zoom that stops framing one node from zooming the reader OUT are now stated with their reasons and asserted. Two branch rules with them, both of which a tidy-up would erase: reduced motion is a DIFFERENT CALL (jumpTo, not easeTo with duration 0 -- the engines do not treat those as one), and a single point CENTRES rather than fitting a degenerate box, because a one-language family is ordinary and fitBounds on no extent zooms to nothing. THE ROLLUP IS OVER THE READER'S SCOPE. "A family shows the worst colour below it" is only true relative to what is currently on the board: a family that is red overall can be green inside a filter. Handing the rollup a whole subtree makes the colours quietly disagree with the board -- no error, no blank, just a wrong fact about a real people group. Asserted in both directions, and colorFor stays injectable because nothing in the drawing depends on the rollup being worst-of. THE CLICK TOGGLE MOVED TO THE HOST, and that is a change rather than a codification: GiTree cleared the selection when you re-clicked the selected node, and that toggle lived in the component's own handler. A host that wants it must now write it. In exchange the chart holds no selection state. gospel's UI is not wired and their boardEngine.js is untouched on main; they adopt on their own schedule.
90 lines
5.8 KiB
JavaScript
90 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-language-tree-model — the hierarchical half of the language tree, tested without a browser.
|
|
*
|
|
* The chart's drawing is a whiteboard of tiered points and is proven by check:tiered-points against a
|
|
* NON-tree fixture. This covers what is left: the parent/child index, the edges, the descendants a
|
|
* selection frames, and the colour rollup.
|
|
*
|
|
* ★ THE ROLLUP ASSERTION IS THE ONE THAT MATTERS. "A family shows the worst colour of the languages
|
|
* below it" is only true relative to what the reader is CURRENTLY looking at: it is computed against
|
|
* the filtered roster, not the whole subtree. A family that is red overall can be green inside the
|
|
* reader's selection, and handing the rollup an unfiltered subtree makes the colours quietly disagree
|
|
* with the board — no error, no blank, just a wrong fact about a real people group. So scope is
|
|
* asserted in both directions: with a scope, and without one.
|
|
*
|
|
* Exit 0 pass · 1 fail.
|
|
*/
|
|
import { resolve, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const M = await import(resolve(ROOT, 'library/charts/whiteboard/language-tree-model.js'))
|
|
|
|
const fails = []
|
|
const ok = (what, cond, detail = '') => { if (!cond) fails.push(`${what}${detail ? `\n ${detail}` : ''}`) }
|
|
|
|
// fam (root)
|
|
// ├── br1 ── lgA (g, iso aaa)
|
|
// │ └── lgB (r, iso bbb)
|
|
// └── lgC (y, iso ccc)
|
|
const NODES = [
|
|
{ id: 'fam', n: 'Family', lv: 'f', p: '', c: [0, 0], R: 5 },
|
|
{ id: 'br1', n: 'Branch', lv: 'b', p: 'fam', c: [1, 0] },
|
|
{ id: 'lgA', n: 'A', lv: 'l', p: 'br1', c: [2, 1], iso: 'aaa', st: 'g' },
|
|
{ id: 'lgB', n: 'B', lv: 'l', p: 'br1', c: [2, -1], iso: 'bbb', st: 'r' },
|
|
{ id: 'lgC', n: 'C', lv: 'l', p: 'fam', c: [-2, 0], iso: 'ccc', st: 'y' },
|
|
]
|
|
const ix = M.indexTree(NODES)
|
|
|
|
ok('every node is indexed', ix.byId.size === 5)
|
|
ok('the root is found by having no parent', JSON.stringify(ix.roots) === JSON.stringify(['fam']))
|
|
ok('children are collected under their parent', JSON.stringify(ix.kids.get('br1')) === JSON.stringify(['lgA', 'lgB']))
|
|
ok('a language is reachable from its ISO', ix.nodeOfIso.get('bbb') === 'lgB')
|
|
|
|
// ── framing a selection fits the SUBTREE, not the node ───────────────────────────────────────────
|
|
ok('a family frames all of its descendants', M.subtreeCoords(ix, 'fam').length === 5)
|
|
ok('a branch frames only its own', M.subtreeCoords(ix, 'br1').length === 3)
|
|
ok('a leaf frames itself — which is the single-point branch of frameTarget', M.subtreeCoords(ix, 'lgC').length === 1)
|
|
ok('an unknown id frames nothing rather than throwing', M.subtreeCoords(ix, 'nope').length === 0)
|
|
// A malformed index must not hang the board.
|
|
const cyclic = M.indexTree([{ id: 'a', p: 'b', c: [0, 0] }, { id: 'b', p: 'a', c: [1, 1] }])
|
|
ok('a cycle terminates instead of hanging', M.subtreeCoords(cyclic, 'a').length <= 2)
|
|
|
|
// ── the ONE hierarchical layer ───────────────────────────────────────────────────────────────────
|
|
const links = M.linkFC(ix)
|
|
ok('one edge per non-root node', links.features.length === 4)
|
|
ok('each edge runs parent -> child',
|
|
links.features.every((f) => f.geometry.type === 'LineString' && f.geometry.coordinates.length === 2))
|
|
ok('the root has no edge', !links.features.some((f) => f.properties.id === 'fam'))
|
|
|
|
// ── ★ THE ROLLUP, IN SCOPE AND OUT ───────────────────────────────────────────────────────────────
|
|
ok('unscoped, a family is the WORST of everything below it', M.worstOfDescendants(ix, 'fam') === 'r')
|
|
ok('unscoped, a branch is the worst of ITS own', M.worstOfDescendants(ix, 'br1') === 'r')
|
|
// The reader filters to A and C: B (the red one) is no longer on the board, so the family is no longer red.
|
|
const scope = new Set(['aaa', 'ccc'])
|
|
ok('IN SCOPE, the family reflects only what the reader can see', M.worstOfDescendants(ix, 'fam', { inScope: scope }) === 'y',
|
|
`got ${M.worstOfDescendants(ix, 'fam', { inScope: scope })}, expected y — the red language is filtered out`)
|
|
ok('...and a branch whose only in-scope child is green reads green',
|
|
M.worstOfDescendants(ix, 'br1', { inScope: scope }) === 'g')
|
|
ok('a node with nothing in scope has no status rather than a wrong one',
|
|
M.worstOfDescendants(ix, 'br1', { inScope: new Set(['ccc']) }) === undefined)
|
|
ok('a leaf is its own status', M.worstOfDescendants(ix, 'lgA') === 'g')
|
|
|
|
// ── the point collection carries what the whiteboard layer needs ─────────────────────────────────
|
|
const fc = M.nodeFC(ix, { colorFor: (n) => (n.st === 'r' ? '#b42318' : '#1f8f5f'), inScope: scope })
|
|
ok('every node becomes a point', fc.features.length === 5)
|
|
ok('tier comes from the node level', fc.features.find((f) => f.properties.id === 'lgA').properties.tier === 'l')
|
|
ok('the root is marked, so a dish can filter on it', fc.features.find((f) => f.properties.id === 'fam').properties.root === 1)
|
|
ok('out-of-scope nodes are MARKED, not dropped — they fade, never vanish',
|
|
fc.features.find((f) => f.properties.id === 'lgB').properties.m === 0 && fc.features.length === 5)
|
|
ok('the caller\'s colour is applied per node',
|
|
fc.features.find((f) => f.properties.id === 'lgB').properties.colour === '#b42318')
|
|
|
|
if (fails.length) {
|
|
console.error('LANGUAGE-TREE-MODEL: FAIL')
|
|
for (const f of fails) console.error(` ✗ ${f}`)
|
|
process.exit(1)
|
|
}
|
|
console.log('LANGUAGE-TREE-MODEL: PASS — the index, the one edge layer, subtree framing, and a colour rollup that '
|
|
+ 'reflects the READER\'s scope rather than the whole subtree.')
|