OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 0c24f3e

Extract the card builders into a testable module
lib/floor/cards.js is pure — model in, cards out. No DOM, no view state, no pixel sizes — so node imports it directly and the browser imports the same file over /floor/cards.js. It was inside level.js, which imports the served '/floor/layout.js' path and therefore cannot be loaded from node at all, so the logic that decides what each LEVEL SHOWS was guarded by a parse gate and nothing else. Moved out: buildFileLevel, buildSymbolLevel, resolveCode, ownLines, lineDepths, fileLinks, signatureOf, interiorCounts, zoneLabel. Left behind: sizing, drawing, navigation, input — level.js now declares 16 functions, all of them view work. A card now carries a semantic `kind` (function/class/const/module-body/op/call/ exit/nested) and the view maps kind to colour. cards.js has no opinion about pixels; level.js has no opinion about the model. ## 11 tests, 42 asserts, including the cases that were only ever screenshots - the file level is one card per symbol, with openTo set only where there is something to open - the module body slices SPARSELY — its range is the whole file, so a contiguous slice would drag in every other symbol's body - file links are the call graph: deduped, no self-edges - the symbol level shows only what the symbol CONTAINS: an operation inside a nested closure is inside this symbol's RANGE and must not appear here (L3) - zones become containers wired by parent, labelled with shape, condition and carried state - an exit with no operation of its own still gets a card - a declaration badges the operation that produces it (R3) ## A near-miss worth recording The scripted extraction removed `load` and `currentSymbol` along with the functions it was told to cut — the blank-line heuristic reached too far back. The file still PARSED, so the existing gate stayed green, and the page would have shipped broken. test/floor-view.test.js now walks the AST and asserts that every function called is declared or imported, which is the gate that catches a deletion rather than a syntax error. Restored by hand and re-verified by screenshot: identical output, 19 nodes and 4 zones on isRuntimeSupplied. Suite: 181 tests, 1067 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0c24f3e17847eaaf9f00e035c9add6e39ad8ebba
by plankatron <fleeky@gmail.com> · 2026-09-20T23:01:56.000Z

Changed files (5) +500 -220

lib/floor/cards.js +257 -0
……@@ -0,0 +1,257 @@
1/**
2 * Cards — what a level of the ladder shows, derived from the model.
3 *
4 * Pure: model in, cards out. No DOM, no view state, no pixel sizes, so node can
5 * import it directly and the browser can import the same file over /floor/cards.js.
6 * It was inside level.js and therefore untestable, guarded only by a parse gate.
7 *
8 * Both rungs produce the SAME shape — { cards, links, containers } — which is
9 * what lets one layout and one renderer serve both (spec section 3).
10 *
11 * A card is:
12 * id the model id it came from
13 * kind what it IS, semantically: function | class | const | module-body |
14 * op | call | exit | nested. The view maps kind to colour; this
15 * module has no opinion about pixels.
16 * title the big label
17 * badge the small right-hand label
18 * sig one line under the title
19 * sub counts and position
20 * container the zone that holds it, or null
21 * code how to find its source: { from, to } or { sparse: true, container }
22 * openTo the symbol NAME this card descends into, or null
23 */
24
25/** The file level: one card per symbol (L1), including the module body (L2). */
26export function buildFileLevel (model) {
27 const cards = (model.symbols || []).map(sym => ({
28 id: sym.id,
29 kind: sym.kind,
30 title: sym.name,
31 badge: sym.kind,
32 sig: signatureOf(model, sym),
33 sub: interiorCounts(model, sym) + ' L' + sym.range.startLine + '-' + sym.range.endLine,
34 container: null,
35 // The module body's RANGE is the whole file — it IS the program — so a
36 // contiguous slice would dump every other symbol's body with it. Its own
37 // code is the lines its operations and wires actually sit on.
38 code: sym.kind === 'module-body'
39 ? { sparse: true, container: sym.fn }
40 : { from: sym.range.startLine, to: sym.range.endLine },
41 openTo: sym.fn ? sym.name : null
42 }))
43 return { cards, links: fileLinks(model), containers: [] }
44}
45
46/**
47 * The symbol level: the expression floor for one symbol.
48 *
49 * Membership is by CONTAINER, never by range (L3/E4b) — an operation inside a
50 * nested closure is also inside this symbol's range, and claiming it here would
51 * put it in the wrong node.
52 */
53export function buildSymbolLevel (model, sym) {
54 const fnId = sym.fn
55 const cards = []
56
57 const zones = (model.zones || []).filter(z => z.container === fnId)
58 const zoneIds = new Set(zones.map(z => z.id))
59 const containers = zones.map(z => ({
60 id: z.id,
61 parent: zoneIds.has(z.parent) ? z.parent : null,
62 label: zoneLabel(z),
63 shape: z.shape,
64 depth: z.depth
65 }))
66
67 // A declaration names its producing operation, so the value is recognisable
68 // downstream by the name the code gave it (R3).
69 const nameOfNode = new Map()
70 for (const n of model.names || []) if (n.node) nameOfNode.set(n.node, n.name)
71
72 for (const op of model.nodes || []) {
73 if (op.container !== fnId) continue
74 const named = nameOfNode.get(op.id)
75 cards.push({
76 id: op.id,
77 kind: op.kind === 'CallExpression' ? 'call' : 'op',
78 title: op.label,
79 badge: named ? '-> ' + named : shortKind(op.kind),
80 sig: op.inputs.map(describeInput).join(', '),
81 sub: 'L' + op.range.startLine + (op.writes ? ' writes ' + op.writes : ''),
82 container: zoneIds.has(op.zone) ? op.zone : null,
83 code: { from: op.range.startLine, to: op.range.endLine },
84 openTo: null
85 })
86 }
87
88 // A nested function is a node you can open in turn — Blender's nested groups,
89 // falling out of the data rather than being invented.
90 for (const fn of model.functions || []) {
91 if (fn.parent !== fnId) continue
92 cards.push({
93 id: fn.id,
94 kind: 'nested',
95 title: fn.name,
96 badge: 'nested fn',
97 sig: fn.name + ' (' + fn.params.join(', ') + ')',
98 sub: (model.nodes || []).filter(n => n.container === fn.id).length + ' ops · L' +
99 fn.range.startLine + '-' + fn.range.endLine,
100 container: innermostZoneOf(zones, fn.range),
101 code: { from: fn.range.startLine, to: fn.range.endLine },
102 openTo: null
103 })
104 }
105
106 // An exit with no operation of its own — break, continue — would otherwise be
107 // invisible, even though it is what gives its zone the shape it has.
108 for (const ex of model.exits || []) {
109 if (ex.container !== fnId || ex.node) continue
110 cards.push({
111 id: ex.id,
112 kind: 'exit',
113 title: ex.kind,
114 badge: 'exit',
115 sig: ex.lands ? String(ex.lands.on) : '',
116 sub: 'L' + ex.range.startLine,
117 container: zoneIds.has(ex.zone) ? ex.zone : null,
118 code: { from: ex.range.startLine, to: ex.range.endLine },
119 openTo: null
120 })
121 }
122
123 const present = new Set(cards.map(c => c.id))
124 const links = []
125 for (const op of model.nodes || []) {
126 if (!present.has(op.id)) continue
127 for (const i of op.inputs) {
128 if (i.source === 'node' && present.has(i.node)) links.push({ from: i.node, to: op.id })
129 }
130 }
131 return { cards, links, containers }
132}
133
134/**
135 * Resolve a card's `code` descriptor against the file's lines.
136 *
137 * Returns explicit line NUMBERS rather than a start offset, because the module
138 * body's slice is sparse — it skips over every other symbol's body — and the
139 * numbers are the only way the view can mark where it jumped.
140 */
141export function resolveCode (model, lines, card, maxLines = 120) {
142 if (!card.code) return { numbers: [], lines: [], clipped: false }
143
144 if (card.code.sparse) {
145 const all = ownLines(model, card.code.container)
146 const numbers = all.slice(0, maxLines)
147 return {
148 numbers,
149 lines: numbers.map(l => lines[l - 1] || ''),
150 clipped: all.length > numbers.length
151 }
152 }
153
154 const from = card.code.from
155 const to = Math.min(card.code.to, from + maxLines - 1)
156 const slice = lines.slice(from - 1, to)
157 return {
158 numbers: slice.map((_, i) => from + i),
159 lines: slice,
160 clipped: to < card.code.to
161 }
162}
163
164/** The lines a container's own operations and wires sit on, ascending. */
165export function ownLines (model, containerId) {
166 const own = new Set()
167 for (const list of [model.nodes || [], model.names || []]) {
168 for (const entry of list) {
169 if (entry.container !== containerId) continue
170 for (let l = entry.range.startLine; l <= entry.range.endLine; l++) own.add(l)
171 }
172 }
173 return Array.from(own).sort((a, b) => a - b)
174}
175
176/** The deepest zone covering each line — the semantic channel, per line. */
177export function lineDepths (model, lineCount) {
178 const out = new Array(lineCount + 2).fill(0)
179 for (const z of model.zones || []) {
180 for (let l = z.range.startLine; l <= z.range.endLine; l++) {
181 if (l <= lineCount && z.depth > out[l]) out[l] = z.depth
182 }
183 }
184 return out
185}
186
187/**
188 * The file's own call graph: an operation calling `foo`, where `foo` is a symbol
189 * in this file, is an edge from foo to whatever symbol holds the call. Derived,
190 * never authored.
191 */
192export function fileLinks (model) {
193 const byName = new Map()
194 const byFn = new Map()
195 for (const s of model.symbols || []) {
196 if (s.name) byName.set(s.name, s)
197 if (s.fn) byFn.set(s.fn, s)
198 }
199
200 const seen = new Set()
201 const links = []
202 for (const op of model.nodes || []) {
203 if (op.kind !== 'CallExpression') continue
204 const callee = op.inputs.find(i => i.slot === 'callee')
205 if (!callee || !callee.name) continue
206 const target = byName.get(callee.name.split('.')[0].split('[')[0])
207 const holder = byFn.get(op.container)
208 if (!target || !holder || target.id === holder.id) continue
209 const key = target.id + '->' + holder.id
210 if (seen.has(key)) continue
211 seen.add(key)
212 links.push({ from: target.id, to: holder.id })
213 }
214 return links
215}
216
217export function signatureOf (model, sym) {
218 const fn = (model.functions || []).find(f => f.id === sym.fn)
219 if (fn) return fn.name + ' (' + fn.params.join(', ') + ')'
220 if (sym.kind === 'class') return 'class ' + sym.name
221 return sym.name
222}
223
224export function interiorCounts (model, sym) {
225 if (!sym.fn) return sym.members.length > 0 ? sym.members.length + ' members' : ''
226 return (model.nodes || []).filter(n => n.container === sym.fn).length + ' ops · ' +
227 (model.zones || []).filter(z => z.container === sym.fn).length + ' zones · ' +
228 (model.names || []).filter(n => n.container === sym.fn).length + ' wires'
229}
230
231export function zoneLabel (zone) {
232 let out = zone.shape
233 if (zone.condition) out += ' ' + zone.condition.text
234 if (zone.carried && zone.carried.length > 0) out += ' carries ' + zone.carried.join(',')
235 return out
236}
237
238function describeInput (i) {
239 if (i.literal) return i.name
240 return i.name + (i.source === 'node' ? '' : ' [' + i.source + ']')
241}
242
243function shortKind (kind) {
244 return kind.replace('Expression', '').replace('Statement', '').toLowerCase()
245}
246
247// The innermost zone whose range covers this one — used for a nested function,
248// which has no `zone` field of its own.
249function innermostZoneOf (zones, range) {
250 let best = null
251 for (const z of zones) {
252 if (z.range.startByte <= range.startByte && z.range.endByte >= range.endByte) {
253 if (!best || z.depth > best.depth) best = z
254 }
255 }
256 return best ? best.id : null
257}
lib/floor/level.js +26 -220
……@@ -13,9 +13,14 @@
1313 */
1414
1515import { layout } from '/floor/layout.js'
16import {
17 buildFileLevel, buildSymbolLevel, resolveCode, lineDepths
18} from '/floor/cards.js'
1619
1720const SVG_NS = 'http://www.w3.org/2000/svg'
1821
22// A card's KIND is semantic and comes from cards.js; the mapping to a colour is
23// the view's business and lives only here.
1924const KIND_COLOR = {
2025 function: '#b8642a',
2126 class: '#6b4a86',
……@@ -45,6 +50,8 @@ const view = {
4550 symbolId: null, // null = the file level
4651 expanded: new Set(),
4752 cards: [],
53 links: [],
54 containers: [],
4855 laid: null,
4956 panX: 60,
5057 panY: 100,
……@@ -54,8 +61,6 @@ const view = {
5461 sy: 0
5562}
5663
57// --- data ------------------------------------------------------------------
58
5964async function load (path) {
6065 const res = await fetch('/api/derive?path=' + encodeURIComponent(path))
6166 const data = await res.json()
……@@ -70,11 +75,8 @@ async function load (path) {
7075 view.lineDepth = lineDepths(data.model, view.lines.length)
7176 view.expanded = new Set()
7277
73 const params = new URLSearchParams(location.search)
74 const wantSymbol = params.get('symbol')
75 const sym = wantSymbol
76 ? data.model.symbols.find(s => s.name === wantSymbol)
77 : null
78 const wantSymbol = new URLSearchParams(location.search).get('symbol')
79 const sym = wantSymbol ? data.model.symbols.find(s => s.name === wantSymbol) : null
7880 view.symbolId = sym ? sym.id : null
7981
8082 applyExpandParam()
……@@ -82,223 +84,26 @@ async function load (path) {
8284 rebuild()
8385}
8486
85// The deepest zone covering each line, so expanded code can show a depth rail.
86// Same semantic channel cells.js packs into a byte, at line granularity.
87function lineDepths (model, lineCount) {
88 const out = new Array(lineCount + 2).fill(0)
89 for (const z of model.zones || []) {
90 for (let l = z.range.startLine; l <= z.range.endLine; l++) {
91 if (l <= lineCount && z.depth > out[l]) out[l] = z.depth
92 }
93 }
94 return out
95}
96
9787function currentSymbol () {
9888 if (!view.symbolId) return null
9989 return view.model.symbols.find(s => s.id === view.symbolId) || null
10090}
10191
102function ownLines (containerId) {
103 const own = new Set()
104 for (const n of view.model.nodes) {
105 if (n.container !== containerId) continue
106 for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l)
107 }
108 for (const n of view.model.names) {
109 if (n.container !== containerId) continue
110 for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l)
111 }
112 return Array.from(own).sort((a, b) => a - b)
113}
114
115function codeFor (card) {
116 if (!card.code) return { numbers: [], lines: [], clipped: false }
117 if (card.code.sparse) {
118 const nums = ownLines(card.code.container).slice(0, MAX_CODE_LINES)
119 return {
120 numbers: nums,
121 lines: nums.map(l => view.lines[l - 1] || ''),
122 clipped: ownLines(card.code.container).length > nums.length
123 }
124 }
125 const from = card.code.from
126 const to = Math.min(card.code.to, from + MAX_CODE_LINES - 1)
127 const lines = view.lines.slice(from - 1, to)
128 return { numbers: lines.map((_, i) => from + i), lines, clipped: to < card.code.to }
129}
130
131// --- the two levels, both producing cards ----------------------------------
132
133function buildFileLevel () {
134 const m = view.model
135 const cards = m.symbols.map(sym => ({
136 id: sym.id,
137 color: KIND_COLOR[sym.kind] || '#3a3d44',
138 title: sym.name,
139 kindLabel: sym.kind,
140 sig: signatureOf(sym),
141 sub: interiorCounts(sym) + ' L' + sym.range.startLine + '-' + sym.range.endLine,
142 container: null,
143 // The module body IS the program, so its range is the whole file; its own
144 // code is only the lines its operations and wires sit on.
145 code: sym.kind === 'module-body'
146 ? { sparse: true, container: sym.fn }
147 : { from: sym.range.startLine, to: sym.range.endLine },
148 openTo: sym.fn ? sym.name : null,
149 wide: NODE_W_OPEN,
150 narrow: NODE_W
151 }))
152 return { cards, links: fileLinks(), containers: [] }
153}
154
155function buildSymbolLevel (sym) {
156 const m = view.model
157 const fnId = sym.fn
158
159 // Zones directly in this symbol become CONTAINERS. A zone inside a nested
160 // function belongs to that function's own interior, not to this one.
161 const zones = m.zones.filter(z => z.container === fnId)
162 const zoneIds = new Set(zones.map(z => z.id))
163 const containers = zones.map(z => ({
164 id: z.id,
165 parent: zoneIds.has(z.parent) ? z.parent : null,
166 label: z.shape + (z.condition ? ' ' + clip(z.condition.text, 30) : '') +
167 (z.carried && z.carried.length > 0 ? ' carries ' + z.carried.join(',') : '')
168 }))
169
170 const nameOfNode = new Map()
171 for (const n of m.names) if (n.node) nameOfNode.set(n.node, n.name)
172
173 const cards = []
174 for (const op of m.nodes) {
175 if (op.container !== fnId) continue
176 const named = nameOfNode.get(op.id)
177 const ins = op.inputs.map(i => (i.literal ? i.name : i.name + (i.source === 'node' ? '' : ' [' + i.source + ']')))
178 cards.push({
179 id: op.id,
180 color: op.kind === 'CallExpression' ? KIND_COLOR.call : KIND_COLOR.op,
181 title: op.label,
182 kindLabel: named ? '-> ' + named : shortKind(op.kind),
183 sig: ins.length > 0 ? clip(ins.join(', '), 44) : '',
184 sub: 'L' + op.range.startLine + (op.writes ? ' writes ' + op.writes : ''),
185 container: zoneIds.has(op.zone) ? op.zone : null,
186 code: { from: op.range.startLine, to: op.range.endLine },
187 openTo: null,
188 wide: OP_W_OPEN,
189 narrow: OP_W
190 })
191 }
192
193 // A nested function is a node you can open in turn — Blender's nested groups,
194 // falling out of the data rather than being invented (L1).
195 for (const fn of m.functions) {
196 if (fn.parent !== fnId) continue
197 cards.push({
198 id: fn.id,
199 color: KIND_COLOR.nested,
200 title: fn.name,
201 kindLabel: 'nested fn',
202 sig: fn.name + ' (' + fn.params.join(', ') + ')',
203 sub: m.nodes.filter(n => n.container === fn.id).length + ' ops · L' +
204 fn.range.startLine + '-' + fn.range.endLine,
205 container: zoneOfRange(zones, fn.range),
206 code: { from: fn.range.startLine, to: fn.range.endLine },
207 openTo: null,
208 wide: OP_W_OPEN,
209 narrow: OP_W
210 })
211 }
212
213 // An exit with no operation of its own — break, continue — would otherwise be
214 // invisible, even though it is the thing that makes the zone's shape what it is.
215 for (const ex of m.exits) {
216 if (ex.container !== fnId || ex.node) continue
217 cards.push({
218 id: ex.id,
219 color: KIND_COLOR.exit,
220 title: ex.kind,
221 kindLabel: 'exit',
222 sig: ex.lands ? String(ex.lands.on) : '',
223 sub: 'L' + ex.range.startLine,
224 container: zoneIds.has(ex.zone) ? ex.zone : null,
225 code: { from: ex.range.startLine, to: ex.range.endLine },
226 openTo: null,
227 wide: OP_W_OPEN,
228 narrow: OP_W
229 })
230 }
231
232 const ids = new Set(cards.map(c => c.id))
233 const links = []
234 for (const op of m.nodes) {
235 if (!ids.has(op.id)) continue
236 for (const i of op.inputs) {
237 if (i.source === 'node' && ids.has(i.node)) links.push({ from: i.node, to: op.id })
238 }
239 }
240 return { cards, links, containers }
241}
242
243function zoneOfRange (zones, range) {
244 let best = null
245 for (const z of zones) {
246 if (z.range.startByte <= range.startByte && z.range.endByte >= range.endByte) {
247 if (!best || z.depth > best.depth) best = z
248 }
249 }
250 return best ? best.id : null
251}
252
253function shortKind (kind) {
254 return kind.replace('Expression', '').replace('Statement', '').toLowerCase()
255}
256
257function fileLinks () {
258 const m = view.model
259 const byName = new Map()
260 for (const s of m.symbols) if (s.name) byName.set(s.name, s)
261 const byFn = new Map()
262 for (const s of m.symbols) if (s.fn) byFn.set(s.fn, s)
263
264 const seen = new Set()
265 const links = []
266 for (const op of m.nodes) {
267 if (op.kind !== 'CallExpression') continue
268 const callee = op.inputs.find(i => i.slot === 'callee')
269 if (!callee || !callee.name) continue
270 const target = byName.get(callee.name.split('.')[0].split('[')[0])
271 const holder = byFn.get(op.container)
272 if (!target || !holder || target.id === holder.id) continue
273 const key = target.id + '->' + holder.id
274 if (seen.has(key)) continue
275 seen.add(key)
276 links.push({ from: target.id, to: holder.id })
277 }
278 return links
279}
280
281function signatureOf (sym) {
282 const fn = (view.model.functions || []).find(f => f.id === sym.fn)
283 if (fn) return fn.name + ' (' + fn.params.join(', ') + ')'
284 if (sym.kind === 'class') return 'class ' + sym.name
285 return sym.name
286}
92// --- layout ----------------------------------------------------------------
28793
288function interiorCounts (sym) {
289 const m = view.model
290 if (!sym.fn) return sym.members.length > 0 ? sym.members.length + ' members' : ''
291 return m.nodes.filter(n => n.container === sym.fn).length + ' ops · ' +
292 m.zones.filter(z => z.container === sym.fn).length + ' zones · ' +
293 m.names.filter(n => n.container === sym.fn).length + ' wires'
94// Pixel sizes are the VIEW's business; cards.js has no opinion about them.
95function widthOf (card) {
96 const open = view.expanded.has(card.id)
97 const symbolLevel = card.kind === 'op' || card.kind === 'call' ||
98 card.kind === 'exit' || card.kind === 'nested'
99 if (symbolLevel) return open ? OP_W_OPEN : OP_W
100 return open ? NODE_W_OPEN : NODE_W
294101}
295102
296// --- layout ----------------------------------------------------------------
297
298103function heightOf (card) {
299104 let h = HEAD_H + (card.sig ? LINE_H : 0) + LINE_H + 6
300105 if (view.expanded.has(card.id)) {
301 const code = codeFor(card)
106 const code = resolveCode(view.model, view.lines, card, MAX_CODE_LINES)
302107 let gaps = 0
303108 for (let i = 1; i < code.numbers.length; i++) {
304109 if (code.numbers[i] > code.numbers[i - 1] + 1) gaps++
……@@ -311,7 +116,7 @@ function heightOf (card) {
311116
312117function rebuild () {
313118 const sym = currentSymbol()
314 const built = sym ? buildSymbolLevel(sym) : buildFileLevel()
119 const built = sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model)
315120 view.cards = built.cards
316121 view.links = built.links
317122 view.containers = built.containers
……@@ -320,7 +125,7 @@ function rebuild () {
320125 nodes: built.cards.map(c => ({
321126 id: c.id,
322127 container: c.container,
323 w: view.expanded.has(c.id) ? c.wide : c.narrow,
128 w: widthOf(c),
324129 h: heightOf(c)
325130 })),
326131 links: built.links,
……@@ -396,7 +201,8 @@ function render () {
396201 x: box.x, y: box.y, width: box.w, height: box.h, rx: 8
397202 }))
398203 const label = el('text', { class: 'zone-label', x: box.x + 8, y: box.y + 13 })
399 label.textContent = box.label || ''
204 // cards.js builds the full label; how much of it fits is the view's problem.
205 label.textContent = clip(box.label || '', Math.floor((box.w - 16) / 5.4))
400206 world.appendChild(label)
401207 }
402208
……@@ -430,7 +236,7 @@ function drawCard (world, card, pos) {
430236 g.appendChild(el('rect', { class: 'n-body', width: pos.w, height: pos.h, rx: 5 }))
431237 g.appendChild(el('path', {
432238 class: 'n-head',
433 fill: card.color,
239 fill: KIND_COLOR[card.kind] || '#3a3d44',
434240 d: 'M 0 5 a 5 5 0 0 1 5 -5 h ' + (pos.w - 10) + ' a 5 5 0 0 1 5 5 v ' + (HEAD_H - 5) +
435241 ' h ' + -pos.w + ' z'
436242 }))
……@@ -444,7 +250,7 @@ function drawCard (world, card, pos) {
444250 g.appendChild(title)
445251
446252 const kind = el('text', { class: 'n-sub', x: pos.w - 7, y: 14, 'text-anchor': 'end' })
447 kind.textContent = card.kindLabel
253 kind.textContent = card.badge
448254 g.appendChild(kind)
449255
450256 let y = HEAD_H + 11
……@@ -459,7 +265,7 @@ function drawCard (world, card, pos) {
459265 g.appendChild(sub)
460266
461267 if (open) {
462 const code = codeFor(card)
268 const code = resolveCode(view.model, view.lines, card, MAX_CODE_LINES)
463269 y += 12
464270 let prev = null
465271 for (let i = 0; i < code.lines.length; i++) {
……@@ -544,7 +350,7 @@ function applyExpandParam () {
544350 if (!want) return
545351 if (want === 'all') {
546352 const sym = currentSymbol()
547 const built = sym ? buildSymbolLevel(sym) : buildFileLevel()
353 const built = sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model)
548354 for (const c of built.cards) view.expanded.add(c.id)
549355 return
550356 }
lib/server.js +4 -0
……@@ -286,6 +286,10 @@ export function startServer (tree, opts = {}) {
286286 }
287287 // The view imports the SAME layout module the tests run against, rather than
288288 // a copy of it that can drift.
289 if (url === '/floor/cards.js') {
290 sendFile(req, res, 'floor/cards.js', 'application/javascript; charset=utf-8')
291 return
292 }
289293 if (url === '/floor/layout.js') {
290294 sendFile(req, res, 'layout.js', 'application/javascript; charset=utf-8')
291295 return
test/floor-cards.test.js +165 -0
……@@ -0,0 +1,165 @@
1import { test } from 'brittle'
2import { buildFileLevel, buildSymbolLevel, resolveCode, ownLines, lineDepths, fileLinks } from '../lib/floor/cards.js'
3import { deriveFile } from '../lib/derive.js'
4
5// These were inside level.js and therefore untestable — the view imported the
6// served '/floor/layout.js' path, so node could not load the file at all. They
7// are the logic that decides what each level of the ladder SHOWS, which is
8// exactly the part worth pinning.
9
10const SRC = [
11 "import { helper } from './h.js'", // 1
12 '', // 2
13 'const LIMIT = 10', // 3
14 '', // 4
15 'export function tally (rows) {', // 5
16 ' let total = 0', // 6
17 ' for (const r of rows) {', // 7 zone: foreach
18 ' const bump = (n) => n + 1', // 8 nested function
19 ' if (r.skip) continue', // 9 zone: guard + a continue exit
20 ' const stepped = bump(total)', // 10 a CALL produces a named wire
21 ' total = stepped', // 11
22 ' }', // 12
23 ' return total', // 13
24 '}', // 14
25 '', // 15
26 'export class Store {', // 16
27 ' read (name) { return helper(name) }', // 17
28 '}', // 18
29 '', // 19
30 'const ready = tally([1])', // 20 top-level -> module body
31 ''
32].join('\n')
33
34const LINES = SRC.split('\n')
35const model = deriveFile(SRC, '/x/cards.js')
36const tally = model.symbols.find(s => s.name === 'tally')
37
38test('cards: the file level is one card per symbol', (t) => {
39 const { cards, containers } = buildFileLevel(model)
40
41 t.alike(cards.map(c => c.title).sort(),
42 ['(module body)', 'LIMIT', 'Store', 'ready', 'tally'].sort(), 'five cards')
43 t.alike(containers, [], 'the file level has no containers')
44
45 const byTitle = {}
46 for (const c of cards) byTitle[c.title] = c
47 t.is(byTitle.tally.kind, 'function', 'kind is semantic, not a colour')
48 t.is(byTitle.tally.openTo, 'tally', 'a symbol with a body can be opened')
49 t.is(byTitle.LIMIT.openTo, null, 'a const cannot')
50 t.ok(byTitle.tally.sig.includes('rows'), 'the signature carries its params')
51 t.ok(byTitle.Store.sig.startsWith('class '), 'a class signature says so')
52})
53
54test('cards: the module body card slices sparsely, not the whole file', (t) => {
55 const { cards } = buildFileLevel(model)
56 const body = cards.find(c => c.kind === 'module-body')
57
58 t.ok(body.code.sparse, 'its code descriptor is sparse')
59 const code = resolveCode(model, LINES, body)
60 t.ok(code.numbers.length > 0, 'it resolves to some lines')
61 t.ok(code.numbers.length < LINES.length, 'but not the whole file')
62 t.absent(code.numbers.includes(10), 'and not a line from inside another symbol')
63 t.ok(code.numbers.includes(20), 'while including its own top-level statement')
64})
65
66test('cards: file links are the call graph, deduped and self-free', (t) => {
67 const links = fileLinks(model)
68 const byId = {}
69 for (const s of model.symbols) byId[s.id] = s.name
70
71 const named = links.map(l => byId[l.from] + '->' + byId[l.to])
72 t.ok(named.includes('tally->(module body)'), 'the module body calls tally')
73 for (const l of links) t.not(l.from, l.to, 'no self-edge')
74 t.is(new Set(named).size, named.length, 'no duplicates')
75})
76
77test('cards: the symbol level shows only what the symbol CONTAINS', (t) => {
78 const { cards } = buildSymbolLevel(model, tally)
79 const ids = new Set(cards.map(c => c.id))
80
81 // The discriminating case: `n + 1` lives inside `bump`, which is inside
82 // tally's RANGE. Range containment would wrongly claim it here.
83 const bump = model.functions.find(f => f.name === 'bump')
84 const inBump = model.nodes.filter(n => n.container === bump.id)
85 t.ok(inBump.length > 0, 'bump has operations of its own')
86 for (const op of inBump) t.absent(ids.has(op.id), 'which do not appear in tally')
87
88 // bump itself IS a card here, as something you open in turn.
89 const nested = cards.find(c => c.kind === 'nested')
90 t.ok(nested, 'the nested function is a card')
91 t.is(nested.title, 'bump', 'named from its binding, not anonymous')
92})
93
94test('cards: zones become containers, nested by parent', (t) => {
95 const { containers, cards } = buildSymbolLevel(model, tally)
96
97 const shapes = containers.map(c => c.shape).sort()
98 t.alike(shapes, ['foreach', 'guard'], 'the loop and the guard inside it')
99
100 const loop = containers.find(c => c.shape === 'foreach')
101 const guard = containers.find(c => c.shape === 'guard')
102 t.is(loop.parent, null, 'the loop is top level in this symbol')
103 t.is(guard.parent, loop.id, 'the guard is nested inside it')
104 t.ok(loop.label.startsWith('foreach'), 'the label names the shape')
105 t.ok(loop.label.includes('carries total'), 'and its carried state: ' + loop.label)
106
107 // A card in the guard names the guard, not the loop.
108 const inGuard = cards.filter(c => c.container === guard.id)
109 t.ok(inGuard.length > 0, 'the guard holds at least one card')
110})
111
112test('cards: an exit with no operation of its own still gets a card', (t) => {
113 const { cards } = buildSymbolLevel(model, tally)
114 const exit = cards.find(c => c.kind === 'exit')
115
116 t.ok(exit, 'the continue is visible')
117 t.is(exit.title, 'continue', 'labelled by what it is')
118 t.ok(exit.sig.includes('zone-entry'), 'and says where it lands: ' + exit.sig)
119})
120
121test('cards: a declaration badges the operation that produces it (R3)', (t) => {
122 const { cards } = buildSymbolLevel(model, tally)
123 // `const stepped = bump(total)` — the CALL is the producer, so the call card
124 // carries the name rather than the name floating free.
125 const badged = cards.filter(c => c.badge.startsWith('-> '))
126 t.is(badged.length, 1, 'exactly one card produces a named wire')
127 t.is(badged[0].badge, '-> stepped', 'named for the declaration')
128 t.is(badged[0].kind, 'call', 'and it is the call that produces it')
129})
130
131test('cards: links are dataflow between operations present at this level', (t) => {
132 const { cards, links } = buildSymbolLevel(model, tally)
133 const ids = new Set(cards.map(c => c.id))
134 for (const l of links) {
135 t.ok(ids.has(l.from), 'link source is on screen')
136 t.ok(ids.has(l.to), 'link target is on screen')
137 }
138})
139
140test('cards: resolveCode clips, and reports that it clipped', (t) => {
141 const { cards } = buildFileLevel(model)
142 const tallyCard = cards.find(c => c.title === 'tally')
143
144 const full = resolveCode(model, LINES, tallyCard)
145 t.is(full.numbers[0], 5, 'starts at the symbol')
146 t.absent(full.clipped, 'and is not clipped at the default limit')
147
148 const tight = resolveCode(model, LINES, tallyCard, 3)
149 t.is(tight.lines.length, 3, 'clips to the limit')
150 t.ok(tight.clipped, 'and says so')
151})
152
153test('cards: lineDepths is the per-line semantic channel', (t) => {
154 const depths = lineDepths(model, LINES.length)
155 t.is(depths[6], 0, 'line 6 is outside every zone')
156 t.is(depths[7], 1, 'the for line is depth 1')
157 t.is(depths[9], 2, 'the guard inside it is depth 2')
158 t.is(depths[13], 0, 'and the return is back outside')
159})
160
161test('cards: pure — same model in, same cards out', (t) => {
162 t.alike(buildFileLevel(model), buildFileLevel(model), 'file level')
163 t.alike(buildSymbolLevel(model, tally), buildSymbolLevel(model, tally), 'symbol level')
164 t.alike(ownLines(model, tally.fn), ownLines(model, tally.fn), 'own lines')
165})
test/floor-view.test.js +48 -0
……@@ -35,6 +35,54 @@ test('floor view: no duplicate top-level function declarations', (t) => {
3535 t.alike(dupes, [], 'no name declared twice: ' + dupes.join(', '))
3636})
3737
38test('floor view: every function it calls is declared or imported', (t) => {
39 // A parse gate cannot catch a MISSING function — the file still parses fine.
40 // This test exists because a scripted extraction removed `load` and
41 // `currentSymbol` along with the functions it was told to cut, and the page
42 // would have shipped broken with every other gate green.
43 const src = readFileSync(resolve(LIB, 'floor/level.js'), 'utf-8')
44 const ast = acornParse(src, { ecmaVersion: 'latest', sourceType: 'module' })
45
46 const declared = new Set()
47 for (const n of ast.body) {
48 if (n.type === 'FunctionDeclaration') declared.add(n.id.name)
49 if (n.type === 'VariableDeclaration') {
50 for (const d of n.declarations) if (d.id.type === 'Identifier') declared.add(d.id.name)
51 }
52 if (n.type === 'ImportDeclaration') {
53 for (const sp of n.specifiers) declared.add(sp.local.name)
54 }
55 }
56
57 const called = new Set()
58 const walk = (node) => {
59 if (!node || typeof node.type !== 'string') return
60 if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
61 called.add(node.callee.name)
62 }
63 for (const k of Object.keys(node)) {
64 const v = node[k]
65 if (Array.isArray(v)) v.forEach(walk)
66 else if (v && typeof v === 'object' && typeof v.type === 'string') walk(v)
67 }
68 }
69 walk(ast)
70
71 const builtins = new Set(['fetch', 'parseInt', 'parseFloat', 'String', 'Number',
72 'Set', 'Map', 'Array', 'Object', 'encodeURIComponent', 'decodeURIComponent', 'isNaN'])
73 const missing = [...called].filter(n => !declared.has(n) && !builtins.has(n))
74 t.alike(missing, [], 'nothing calls a function that does not exist: ' + missing.join(', '))
75})
76
77test('floor view: the card builders live in a module node can import', (t) => {
78 const src = readFileSync(resolve(LIB, 'floor/level.js'), 'utf-8')
79 t.ok(src.includes("from '/floor/cards.js'"), 'the view imports them')
80 t.absent(/function buildSymbolLevel/.test(src), 'and does not define its own copy')
81
82 const server = readFileSync(resolve(LIB, 'server.js'), 'utf-8')
83 t.ok(server.includes("'/floor/cards.js'"), 'the server routes that path')
84})
85
3886test('floor view: it imports the real layout module, not a copy', (t) => {
3987 const src = readFileSync(resolve(LIB, 'floor/level.js'), 'utf-8')
4088 t.ok(src.includes("from '/floor/layout.js'"), 'imports the served layout')