OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / test / derive-gdscript.test.js

238 lines · javascript ref:
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.gd, counted by hand.
//
// tally()
//   rows == null             binary op       + GUARD zone (return null)
//   return null              return op       + exit -> function output
//   for row in rows          FOREACH zone, item 'row', carried ['total']
//   row.skip                 a path, folds to a socket label (R5)
//   continue                 exit -> the foreach zone's entry, in a GUARD zone
//                            (parses as IdentifierExpressionNode: this grammar
//                             has NO break/continue node at all)
//   total + row.count        binary op
//   total = ...              assign op (writes the class-level '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, 2 bands; both paths exit
//   return total, return 0   two return ops  + two exits
//
// label()
//   Time.get_ticks_msec()    call op, 'Time' is a FREE wire
//   str(row.name, at)        call op, 'str' is FREE
//   return                   return op + exit
//
// Totals: 2 functions, 11 operation nodes, 5 zones, 6 exits, 2 named wires
//         ('total' at class scope, 'at' in label; the loop's 'row' 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 gd: the counts the fixture documents', (t) => {
  const m = derive('sample.gd')

  t.absent(m.error, 'no error')
  t.is(m.language, 'gdscript', 'reports its language')
  t.is(m.functions.length, 2, '2 functions')
  t.is(m.nodes.length, 11, '11 operation nodes')
  t.is(m.zones.length, 5, '5 zones')
  t.is(m.exits.length, 6, '6 exits')
  t.is(m.names.length, 2, '2 named wires')
})

test('derive gd: Z1 — three conditional shapes', (t) => {
  const m = derive('sample.gd')
  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')

  const cond = m.zones.find(z => z.shape === 'two-exit')
  t.is(cond.bands.length, 2, 'then + else')
})

test('derive gd: break and continue resolve despite having no grammar node', (t) => {
  const m = derive('sample.gd')
  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.ok(cont, 'found the continue')
  t.ok(brk, 'found the break')
  t.is(cont.lands.on, 'zone-entry', 'continue lands on a zone entry')
  t.is(cont.lands.id, loop.id, 'the enclosing loop')
  t.is(brk.lands.on, 'zone-exit', 'break lands on a zone exit')
  t.is(brk.lands.id, loop.id, 'the enclosing loop')

  // And they are NOT mistaken for operations or wires.
  const labels = m.nodes.map(n => n.label)
  t.absent(labels.includes('break'), 'break is not an operation')
  t.absent(labels.includes('continue'), 'continue is not an operation')
})

test('derive gd: Z3 — carried state', (t) => {
  const m = derive('sample.gd')
  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')

  const cond = m.zones.find(z => z.shape === 'two-exit')
  t.alike(cond.carried, [], 'the two-exit zone converges nothing')
})

test('derive gd: R5 — a property path folds into a socket label', (t) => {
  const m = derive('sample.gd')
  const add = m.nodes.find(n => n.label === '+')
  t.ok(add, 'found the + node')
  const names = add.inputs.map(i => i.name)
  t.ok(names.includes('total'), 'left input is the total wire')
  t.ok(names.includes('row.count'), 'right input is the folded path')

  const kinds = m.nodes.map(n => n.kind)
  t.absent(kinds.includes('SubscriptExpressionNode'), 'no path node survives')
})

test('derive gd: R3 — declarations name wires', (t) => {
  const m = derive('sample.gd')
  const names = m.names.map(n => n.name).sort()
  t.alike(names, ['at', 'total'], 'total and at')
  t.is(m.names.find(n => n.name === 'total').from, 'literal', 'total from a literal')
  t.is(m.names.find(n => n.name === 'at').from, 'node', 'at from an operation')
})

test('derive gd: E5 — references resolve or are marked free', (t) => {
  const m = derive('sample.gd')
  const call = m.nodes.find(n => n.label === 'Time.get_ticks_msec')
  t.ok(call, 'found the call')
  t.is(call.inputs[0].source, 'free', 'Time is free — a GDScript global')

  const add = m.nodes.find(n => n.label === '+')
  const totalIn = add.inputs.find(i => i.name === 'total')
  t.is(totalIn.source, 'local', 'the class-level var resolves')

  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 gd: E2/R7 — positions and derived ids', (t) => {
  const m = derive('sample.gd')
  for (const list of [m.nodes, m.zones, m.exits, m.names]) {
    for (const item of list) {
      t.ok(Number.isInteger(item.range.startByte), 'has startByte')
      t.ok(item.range.startLine >= 1, 'has a 1-based line')
      t.ok(item.range.endLine >= item.range.startLine, 'endLine follows')
    }
  }
  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 gd: E1 — the derivation is pure', (t) => {
  const a = derive('sample.gd')
  const b = derive('sample.gd')
  t.is(JSON.stringify(a), JSON.stringify(b), 'byte-identical model')
})

test('derive gd: an error node does not stop the derivation', (t) => {
  const m = derive('inferred.gd')
  t.ok(m.errors > 0, 'the := is reported as a grammar error')
  t.absent(m.error, 'but the file still derives')
  t.alike(m.names.map(n => n.name), ['count'], 'and the wire is still named')
  t.ok(m.functions.length === 1, 'and the function is still found')
})

test('derive gd: a mis-parse marks the model PARTIAL', (t) => {
  // lezer RECOVERS from a construct it does not know, so the model looks
  // complete while structure has gone missing. That must be visible.
  const clean = derive('sample.gd')
  t.absent(clean.partial, 'the clean fixture is not partial')

  const m = derive('inferred.gd')
  t.ok(m.partial, ':= makes the model partial')
  t.ok(m.partialReason.includes('grammar error'), 'and says why: ' + m.partialReason)
})

test('derive gd: `static func` is mis-parsed, so the model says PARTIAL', (t) => {
  const src = [
    'static func f(rows):',
    '\tfor row in rows:',
    '\t\tif row.skip:',
    '\t\t\tcontinue',
    '\treturn 0',
    ''
  ].join('\n')
  const m = deriveFile(src, resolve(FIXTURE, 'synthetic-static.gd'))

  t.ok(m.errors > 0, 'the grammar reports errors on `static func`')
  t.ok(m.partial, 'so the model is partial')
  // Recovery is good enough HERE that the loop survives and the continue still
  // lands. On real code it is not: material-maker/parse_args.gd has 111 error
  // nodes, loops that come back spanning a single line, and three continues
  // whose enclosing `for` was lost entirely. That file is not a fixture on
  // purpose — pinning a test to a repo we do not own is the mistake that has
  // parser/traverse/tree red right now.
  t.is(m.exits.filter(e => e.lands.on === 'unresolved').length, 0,
    'this small case still resolves, which is why PARTIAL is the signal, not the exits')
})

test('derive gd: an exit with nowhere to land is reported, never dropped (E3)', (t) => {
  // A `continue` outside any loop. Invalid GDScript, which the grammar happily
  // reads as an identifier — so this is also how a LOST loop presents.
  const m = deriveFile('func f():\n\tcontinue\n', resolve(FIXTURE, 'synthetic-orphan.gd'))

  const orphans = m.exits.filter(e => e.lands.on === 'unresolved')
  t.is(orphans.length, 1, 'the continue is still reported')
  t.is(orphans[0].kind, 'continue', 'as a continue')
  t.is(orphans[0].lands.id, null, 'with no landing id')
  t.ok(m.partial, 'and the model is partial')
  t.ok(m.partialReason.includes('landing nowhere'), 'saying so: ' + m.partialReason)
})

// The point of the model: the SAME program in two languages agrees on control
// flow. Node counts differ (JS has a template literal), the structure does not.
test('derive: the JS and GDScript twins agree on structure', (t) => {
  const js = derive('sample.js')
  const gd = derive('sample.gd')

  const shapes = (m) => {
    const out = {}
    for (const z of m.zones) out[z.shape] = (out[z.shape] || 0) + 1
    return out
  }
  const exits = (m) => {
    const out = {}
    for (const e of m.exits) out[e.kind] = (out[e.kind] || 0) + 1
    return out
  }

  t.is(gd.zones.length, js.zones.length, 'same zone count')
  t.alike(shapes(gd), shapes(js), 'same zone shapes')
  t.alike(exits(gd), exits(js), 'same exit kinds')
  t.is(gd.functions.length, js.functions.length, 'same function count')

  const gdLoop = gd.zones.find(z => z.shape === 'foreach')
  const jsLoop = js.zones.find(z => z.shape === 'foreach')
  t.alike(gdLoop.carried, jsLoop.carried, 'same carried state')
  t.is(gdLoop.item, jsLoop.item, 'same loop item')
  t.is(gdLoop.condition.text, 'rows', 'the foreach is driven by the ITERABLE')
  t.is(jsLoop.condition.text, 'rows', 'and JS agrees')
})