OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / test / floor-cards.test.js

233 lines · javascript ref:
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')
})