import { test } from 'brittle'
import {
buildFileLevel, buildSymbolLevel, resolveCode, ownLines, lineDepths, fileLinks,
spanOnLine, displayColumn, marksOwnSpan
} from '../lib/floor/cards.js'
import { deriveFile } from '../lib/derive.js'
// These were inside level.js and therefore untestable — the view imported the
// served '/floor/layout.js' path, so node could not load the file at all. They
// are the logic that decides what each level of the ladder SHOWS, which is
// exactly the part worth pinning.
const SRC = [
"import { helper } from './h.js'", // 1
'', // 2
'const LIMIT = 10', // 3
'', // 4
'export function tally (rows) {', // 5
' let total = 0', // 6
' for (const r of rows) {', // 7 zone: foreach
' const bump = (n) => n + 1', // 8 nested function
' if (r.skip) continue', // 9 zone: guard + a continue exit
' const stepped = bump(total)', // 10 a CALL produces a named wire
' total = stepped', // 11
' }', // 12
' return total', // 13
'}', // 14
'', // 15
'export class Store {', // 16
' read (name) { return helper(name) }', // 17
'}', // 18
'', // 19
'const ready = tally([1])', // 20 top-level -> module body
''
].join('\n')
const LINES = SRC.split('\n')
const model = deriveFile(SRC, '/x/cards.js')
const tally = model.symbols.find(s => s.name === 'tally')
test('cards: the file level is one card per symbol', (t) => {
const { cards, containers } = buildFileLevel(model)
t.alike(cards.map(c => c.title).sort(),
['(module body)', 'LIMIT', 'Store', 'ready', 'tally'].sort(), 'five cards')
t.alike(containers, [], 'the file level has no containers')
const byTitle = {}
for (const c of cards) byTitle[c.title] = c
t.is(byTitle.tally.kind, 'function', 'kind is semantic, not a colour')
t.is(byTitle.tally.openTo, 'tally', 'a symbol with a body can be opened')
t.is(byTitle.LIMIT.openTo, null, 'a const cannot')
t.ok(byTitle.tally.sig.includes('rows'), 'the signature carries its params')
t.ok(byTitle.Store.sig.startsWith('class '), 'a class signature says so')
})
test('cards: the module body card slices sparsely, not the whole file', (t) => {
const { cards } = buildFileLevel(model)
const body = cards.find(c => c.kind === 'module-body')
t.ok(body.code.sparse, 'its code descriptor is sparse')
const code = resolveCode(model, LINES, body)
t.ok(code.numbers.length > 0, 'it resolves to some lines')
t.ok(code.numbers.length < LINES.length, 'but not the whole file')
t.absent(code.numbers.includes(10), 'and not a line from inside another symbol')
t.ok(code.numbers.includes(20), 'while including its own top-level statement')
})
test('cards: file links are the call graph, deduped and self-free', (t) => {
const links = fileLinks(model)
const byId = {}
for (const s of model.symbols) byId[s.id] = s.name
const named = links.map(l => byId[l.from] + '->' + byId[l.to])
t.ok(named.includes('tally->(module body)'), 'the module body calls tally')
for (const l of links) t.not(l.from, l.to, 'no self-edge')
t.is(new Set(named).size, named.length, 'no duplicates')
})
test('cards: the symbol level shows only what the symbol CONTAINS', (t) => {
const { cards } = buildSymbolLevel(model, tally)
const ids = new Set(cards.map(c => c.id))
// The discriminating case: `n + 1` lives inside `bump`, which is inside
// tally's RANGE. Range containment would wrongly claim it here.
const bump = model.functions.find(f => f.name === 'bump')
const inBump = model.nodes.filter(n => n.container === bump.id)
t.ok(inBump.length > 0, 'bump has operations of its own')
for (const op of inBump) t.absent(ids.has(op.id), 'which do not appear in tally')
// bump itself IS a card here, as something you open in turn.
const nested = cards.find(c => c.kind === 'nested')
t.ok(nested, 'the nested function is a card')
t.is(nested.title, 'bump', 'named from its binding, not anonymous')
})
test('cards: zones become containers, nested by parent', (t) => {
const { containers, cards } = buildSymbolLevel(model, tally)
const shapes = containers.map(c => c.shape).sort()
t.alike(shapes, ['foreach', 'guard'], 'the loop and the guard inside it')
const loop = containers.find(c => c.shape === 'foreach')
const guard = containers.find(c => c.shape === 'guard')
t.is(loop.parent, null, 'the loop is top level in this symbol')
t.is(guard.parent, loop.id, 'the guard is nested inside it')
t.ok(loop.label.startsWith('foreach'), 'the label names the shape')
t.ok(loop.label.includes('carries total'), 'and its carried state: ' + loop.label)
// A card in the guard names the guard, not the loop.
const inGuard = cards.filter(c => c.container === guard.id)
t.ok(inGuard.length > 0, 'the guard holds at least one card')
})
test('cards: an exit with no operation of its own still gets a card', (t) => {
const { cards } = buildSymbolLevel(model, tally)
const exit = cards.find(c => c.kind === 'exit')
t.ok(exit, 'the continue is visible')
t.is(exit.title, 'continue', 'labelled by what it is')
t.ok(exit.sig.includes('zone-entry'), 'and says where it lands: ' + exit.sig)
})
test('cards: a declaration badges the operation that produces it (R3)', (t) => {
const { cards } = buildSymbolLevel(model, tally)
// `const stepped = bump(total)` — the CALL is the producer, so the call card
// carries the name rather than the name floating free.
const badged = cards.filter(c => c.badge.startsWith('-> '))
t.is(badged.length, 1, 'exactly one card produces a named wire')
t.is(badged[0].badge, '-> stepped', 'named for the declaration')
t.is(badged[0].kind, 'call', 'and it is the call that produces it')
})
test('cards: links are dataflow between operations present at this level', (t) => {
const { cards, links } = buildSymbolLevel(model, tally)
const ids = new Set(cards.map(c => c.id))
for (const l of links) {
t.ok(ids.has(l.from), 'link source is on screen')
t.ok(ids.has(l.to), 'link target is on screen')
}
})
test('cards: resolveCode clips, and reports that it clipped', (t) => {
const { cards } = buildFileLevel(model)
const tallyCard = cards.find(c => c.title === 'tally')
const full = resolveCode(model, LINES, tallyCard)
t.is(full.numbers[0], 5, 'starts at the symbol')
t.absent(full.clipped, 'and is not clipped at the default limit')
const tight = resolveCode(model, LINES, tallyCard, 3)
t.is(tight.lines.length, 3, 'clips to the limit')
t.ok(tight.clipped, 'and says so')
})
test('cards: lineDepths is the per-line semantic channel', (t) => {
const depths = lineDepths(model, LINES.length)
t.is(depths[6], 0, 'line 6 is outside every zone')
t.is(depths[7], 1, 'the for line is depth 1')
t.is(depths[9], 2, 'the guard inside it is depth 2')
t.is(depths[13], 0, 'and the return is back outside')
})
test('cards: pure — same model in, same cards out', (t) => {
t.alike(buildFileLevel(model), buildFileLevel(model), 'file level')
t.alike(buildSymbolLevel(model, tally), buildSymbolLevel(model, tally), 'symbol level')
t.alike(ownLines(model, tally.fn), ownLines(model, tally.fn), 'own lines')
})
test('cards: spanOnLine marks the node its own columns, in DISPLAYED space', (t) => {
// ` const stepped = bump(total)` — the call `bump(total)` is a sub-span.
const { cards } = buildSymbolLevel(model, tally)
const call = cards.find(c => c.badge === '-> stepped')
const raw = LINES[call.span.startLine - 1]
const marked = spanOnLine(call.span, call.span.startLine, raw)
t.ok(marked, 'the call gets a span on its own line')
t.is(raw.slice(marked.from, marked.to), 'bump(total)', 'which is exactly the call')
})
test('cards: spanOnLine shifts for expanded tabs', (t) => {
// A tab-indented line with a SUB-span: the call inside the return. In source
// columns it starts at 8; displayed, after a width-4 tab, it starts at 11. A
// view ignoring this marks the wrong text on every tab-indented file, which is
// all of GDScript.
const raw = '\treturn foo(x)'
const span = { startLine: 1, endLine: 1, startCol: 8, endCol: 14 }
t.is(displayColumn(raw, 1, 4), 4, 'one tab occupies four display columns')
t.is(displayColumn(raw, 8, 4), 11, 'and shifts everything after it')
const marked = spanOnLine(span, 1, raw, 4)
t.ok(marked, 'a sub-span on a tab-indented line is marked')
const displayed = raw.replace(/\t/g, ' ')
t.is(displayed.slice(marked.from, marked.to), 'foo(x)', 'and marks exactly the call')
// The same span WITHOUT accounting for tabs would land here — wrong text.
t.not(displayed.slice(span.startCol, span.endCol), 'foo(x)',
'which is not what raw source columns would have given')
})
test('cards: spanOnLine says nothing when there is nothing to say', (t) => {
const raw = ' const a = 1'
// A span covering the whole line's content: highlighting it adds no information.
t.is(spanOnLine({ startLine: 1, endLine: 1, startCol: 2, endCol: raw.length }, 1, raw), null,
'whole-line span is not marked')
// A line outside the span entirely.
t.is(spanOnLine({ startLine: 5, endLine: 6, startCol: 0, endCol: 3 }, 1, raw), null,
'a line outside the span is not marked')
// A symbol card's span DOES yield columns — on `export function tally (rows) {`
// it is everything after `export `, which is accurate and useless. That is a
// policy question, not a mechanical one, so it lives in marksOwnSpan.
const { cards } = buildFileLevel(model)
const fn = cards.find(c => c.title === 'tally')
t.ok(spanOnLine(fn.span, fn.span.startLine, LINES[fn.span.startLine - 1]),
'mechanically it still has columns')
t.absent(marksOwnSpan(fn), 'but a multi-line span is not worth marking')
const sym = buildSymbolLevel(model, tally)
const op = sym.cards.find(c => c.kind === 'call')
t.ok(marksOwnSpan(op), 'a single-line operation is')
})
test('cards: a multi-line span marks to the end of the first line and from the start of the last', (t) => {
const lines = ['foo(bar,', ' baz)']
const span = { startLine: 1, endLine: 2, startCol: 0, endCol: 8 }
const first = spanOnLine(span, 1, lines[0])
t.is(first, null, 'the first line is entirely the span, so nothing is marked')
const inner = spanOnLine({ startLine: 1, endLine: 2, startCol: 4, endCol: 8 }, 1, lines[0])
t.is(lines[0].slice(inner.from, inner.to), 'bar,', 'a partial first line marks from its column to the end')
})