import { test } from 'brittle'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs'
import { deriveFile } from '../lib/derive.js'
const HERE = dirname(fileURLToPath(import.meta.url))
const FIXTURE = resolve(HERE, 'fixtures/derive')
// Ground truth for fixtures/derive/sample.js, counted by hand from the source.
//
// tally()
// !rows unary op + GUARD zone (return null)
// return null return op + exit -> function output
// for (const row of rows) FOREACH zone, item 'row', carried ['total']
// row.skip collapses to a socket label (R5), not a node
// continue exit -> the foreach zone's entry, in a GUARD zone
// total + row.count binary op
// total = ... assign op (a new version of the 'total' wire)
// total > limit binary op + GUARD zone (break)
// break exit -> the foreach zone's exit
// total > 0 binary op
// if/else TWO-EXIT zone; BOTH paths exit, so no convergence
// return total, return 0 two return ops + two exits
//
// label()
// Date.now() call op, 'Date' is a FREE wire
// `${row.name}: ...` one template op (R5: glue folds in)
// format(...) call op, 'format' is an IMPORT wire
// return return op + exit <- the FOURTH return in the file
//
// Totals: 2 functions, 12 operation nodes, 5 zones, 6 exits, 2 named wires
// ('total', 'at' — a loop binding is the zone's item, not a named wire)
function derive (name) {
const path = resolve(FIXTURE, name)
return deriveFile(fs.readFileSync(path, 'utf8'), path)
}
test('derive: the counts the fixture documents', (t) => {
const m = derive('sample.js')
t.absent(m.error, 'no error')
t.is(m.functions.length, 2, '2 functions')
t.is(m.nodes.length, 12, '12 operation nodes')
t.is(m.zones.length, 5, '5 zones')
t.is(m.exits.length, 6, '6 exits — four returns, one continue, one break')
t.is(m.names.length, 2, '2 named wires')
})
test('derive: R2 — a reference or literal is never a node', (t) => {
const m = derive('sample.js')
const kinds = m.nodes.map(n => n.kind)
t.absent(kinds.includes('Identifier'), 'no Identifier node')
t.absent(kinds.includes('Literal'), 'no Literal node')
t.absent(kinds.includes('MemberExpression'), 'no MemberExpression node (R5)')
// `!rows` is ONE node, and `rows` is a wire into it.
const unary = m.nodes.find(n => n.kind === 'UnaryExpression')
t.ok(unary, 'the unary op is a node')
t.is(unary.inputs.length, 1, 'one input')
t.is(unary.inputs[0].name, 'rows', 'fed by the rows wire')
t.is(unary.inputs[0].source, 'param', 'which resolves to a parameter')
})
test('derive: R5 — a property path folds into a socket label', (t) => {
const m = derive('sample.js')
const add = m.nodes.find(n => n.kind === 'BinaryExpression' && n.label === '+')
t.ok(add, 'found the + node')
const labels = add.inputs.map(i => i.name)
t.ok(labels.includes('total'), 'left input is the total wire')
t.ok(labels.includes('row.count'), 'right input is the folded path row.count')
})
test('derive: R3 — a declaration names a wire, literals sit in the socket', (t) => {
const m = derive('sample.js')
const names = m.names.map(n => n.name).sort()
t.alike(names, ['at', 'total'], 'total and at')
const total = m.names.find(n => n.name === 'total')
t.is(total.from, 'literal', 'total is named on a literal, not an operation')
const at = m.names.find(n => n.name === 'at')
t.is(at.from, 'node', 'at is named on an operation')
t.ok(at.node, 'and points at it')
})
test('derive: Z1 — three conditional shapes', (t) => {
const m = derive('sample.js')
const shapes = {}
for (const z of m.zones) shapes[z.shape] = (shapes[z.shape] || 0) + 1
t.is(shapes.guard, 3, '3 guards')
t.is(shapes.foreach, 1, '1 foreach loop')
t.is(shapes['two-exit'], 1, '1 two-exit conditional')
t.absent(shapes['single-branch'], 'no single-branch conditional in this fixture')
})
test('derive: Z4 — every exit lands on an enclosing boundary', (t) => {
const m = derive('sample.js')
const byKind = {}
for (const e of m.exits) {
byKind[e.kind] = (byKind[e.kind] || 0) + 1
t.ok(e.lands, e.kind + ' has a landing site')
t.ok(e.lands.on, e.kind + ' landing names what it lands on')
}
t.is(byKind.return, 4, '4 returns')
t.is(byKind.continue, 1, '1 continue')
t.is(byKind.break, 1, '1 break')
const loop = m.zones.find(z => z.shape === 'foreach')
const cont = m.exits.find(e => e.kind === 'continue')
const brk = m.exits.find(e => e.kind === 'break')
t.is(cont.lands.on, 'zone-entry', 'continue lands on a zone entry')
t.is(cont.lands.id, loop.id, 'specifically the enclosing loop')
t.is(brk.lands.on, 'zone-exit', 'break lands on a zone exit')
t.is(brk.lands.id, loop.id, 'specifically the enclosing loop')
for (const r of m.exits.filter(e => e.kind === 'return')) {
t.is(r.lands.on, 'function-output', 'a return lands on the function output')
}
})
test('derive: Z3 — carried state is the zone output', (t) => {
const m = derive('sample.js')
const loop = m.zones.find(z => z.shape === 'foreach')
t.alike(loop.carried, ['total'], 'the loop carries total')
t.is(loop.item, 'row', 'and binds row as its item')
// Both branches return, so nothing converges out of the if/else.
const cond = m.zones.find(z => z.shape === 'two-exit')
t.alike(cond.carried, [], 'the two-exit zone converges nothing')
})
test('derive: a return is both an operation and an exit, sharing identity', (t) => {
const m = derive('sample.js')
const returnNodes = m.nodes.filter(n => n.kind === 'ReturnStatement')
t.is(returnNodes.length, 4, '4 return nodes')
for (const r of returnNodes) {
const exit = m.exits.find(e => e.node === r.id)
t.ok(exit, 'each return node has the exit that shares its id')
}
})
test('derive: E4 — zone nesting is a tree', (t) => {
const m = derive('sample.js')
const byId = new Map(m.zones.map(z => [z.id, z]))
for (const z of m.zones) {
if (z.parent === null) continue
const p = byId.get(z.parent)
t.ok(p, 'parent exists in the model')
t.ok(p.range.startByte <= z.range.startByte && p.range.endByte >= z.range.endByte,
'parent range contains the child')
t.is(z.depth, p.depth + 1, 'depth is parent depth + 1')
}
const loop = m.zones.find(z => z.shape === 'foreach')
const inner = m.zones.filter(z => z.parent === loop.id)
t.is(inner.length, 2, 'the two inner guards are children of the loop')
})
test('derive: E5 — a reference resolves or is marked', (t) => {
const m = derive('sample.js')
const sources = {}
for (const n of m.nodes) {
for (const i of n.inputs) {
if (i.source) sources[i.source] = (sources[i.source] || 0) + 1
}
}
t.ok(sources.free > 0, 'Date is recorded as free')
t.ok(sources.import > 0, 'format is recorded as an import binding')
t.ok(sources.param > 0, 'rows/limit resolve to parameters')
t.ok(sources.local > 0, 'total/at resolve to locals')
for (const n of m.nodes) {
for (const i of n.inputs) {
if (i.literal) continue
t.ok(i.source, 'every non-literal input names where it came from')
}
}
})
test('derive: E2 — everything carries a position', (t) => {
const m = derive('sample.js')
for (const list of [m.nodes, m.zones, m.exits, m.names]) {
for (const item of list) {
t.ok(item.range, 'has a range')
t.ok(Number.isInteger(item.range.startByte), 'has startByte')
t.ok(Number.isInteger(item.range.endByte), 'has endByte')
t.ok(item.range.startLine >= 1, 'has a 1-based startLine')
t.ok(item.range.endLine >= item.range.startLine, 'endLine follows startLine')
}
}
})
test('derive: R7 — identity is derived from the byte range', (t) => {
const m = derive('sample.js')
for (const n of m.nodes) {
t.is(n.id, m.filePath + ':' + n.range.startByte + '-' + n.range.endByte, 'id is file:start-end')
}
})
test('derive: E1 — the derivation is pure', (t) => {
const a = derive('sample.js')
const b = derive('sample.js')
t.is(JSON.stringify(a), JSON.stringify(b), 'same source, byte-identical model')
})
test('derive: E6 — a parse failure is reported, not thrown', (t) => {
let m = null
try {
m = derive('broken.js')
t.pass('did not throw')
} catch (err) {
t.fail('threw on broken source: ' + err.message)
return
}
t.ok(m.error, 'reports an error')
t.alike(m.nodes, [], 'and no nodes')
})