Add ESLint and Prettier, and close the node test gaps

Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
  measured against the existing style); Markdown excluded because table-cell
  padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
  "this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
  tests in one go.

Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.

Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
  when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
  checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
  events now; the DOM handlers are thin adapters.

Compare fell back to == without a configured operator although the schema
default is <; aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 10:02:22 +02:00
1 parent f140c2fd77
commit d54a620ebd
113 files changed
+5049 -666

No files matched your search

+618
View File
@@ -0,0 +1,618 @@
/**
* One table covering every built-in node that has a `run`, plus a guard that keeps it that way:
* adding a node without a case here fails `covers every built-in node`. Nodes without `run`
* (the events) are seeded by the interpreter, so they are listed with the test that drives them
* and checked in tests/core/behavior-events.test.ts.
*/
import { describe, expect, it } from 'vitest';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph';
import type { PropertyBag } from '../../src/core/model/Properties';
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER } from '../helpers';
interface Case {
/** What the case demonstrates – becomes the failure message. */
name: string;
inputs?: Record<string, Value>;
params?: PropertyBag;
context?: Partial<NodeContext>;
/** Expected data outputs, compared exactly. */
outputs?: Record<string, Value>;
/** Expected exec continuation; `null` asserts the flow ends here. */
next?: string | string[] | null;
/** Assertions on what the node did to the context or to the handles it was given. */
after?(ctx: FakeContext): void;
}
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
const ctx = fakeContext(c.context);
return { result: requireNodeDef(type).run!(ctx, c.inputs ?? {}, c.params ?? {}), ctx };
};
/** A handle with a fixed position, for the geometry nodes. */
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
pointer: { ...NO_POINTER, ...overrides },
});
const CASES: Record<string, Case[]> = {
// --- flow ------------------------------------------------------------------------------------
Branch: [
{ name: 'true takes then', inputs: { condition: true }, next: 'then' },
{ name: 'false takes else', inputs: { condition: false }, next: 'else' },
{ name: 'a non-empty string is true', inputs: { condition: 'yes' }, next: 'then' },
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
],
Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }],
// --- variables -------------------------------------------------------------------------------
GetVariable: [
{ name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } },
{ name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } },
{ name: 'no variable chosen reads as null', outputs: { value: null } },
],
SetVariable: [
{
name: 'writes a variable, keeping the declared type',
inputs: { value: '250' },
params: { variable: 'speed' },
next: 'exec',
after: (ctx) => expect(ctx.variables.speed).toBe(250),
},
{
name: 'no variable chosen writes nothing',
inputs: { value: 5 },
next: 'exec',
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
// --- references and properties ----------------------------------------------------------------
Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }],
Owner: [
{ name: 'outputs the object the behavior is parented to', outputs: { object: fakeContext().owner } },
],
Parent: [
{ name: 'no object has no parent', inputs: { object: null }, outputs: { parent: null } },
{
name: 'a top-level object has no parent',
inputs: { object: fakeHandle({}) },
outputs: { parent: null },
},
],
ObjectByName: [
{
name: 'asks the context for the name',
params: { name: 'Player' },
context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) },
outputs: { object: fakeEntity('Player') },
},
{ name: 'an unknown name is null', params: { name: 'Ghost' }, outputs: { object: null } },
],
NameOf: [
{ name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } },
{ name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } },
],
IsAsset: [
{
name: 'matches the asset id',
inputs: { object: fakeEntity('Coin', 0, 'coin') },
params: { asset: 'coin' },
outputs: { result: true },
},
{
name: 'no asset chosen never matches',
inputs: { object: fakeEntity('Coin', 0, 'coin') },
outputs: { result: false },
},
],
IsEnabled: [
{ name: 'on by default', inputs: { object: fakeHandle({}) }, outputs: { result: true, own: true } },
{
name: 'own switch off',
inputs: { object: fakeHandle({ enabled: false }) },
outputs: { result: false, own: false },
},
{
name: 'own switch on but a parent is off – "own" still reports the own switch',
inputs: { object: fakeHandle({ enabled: true }, fakeHandle({ enabled: false })) },
outputs: { result: false, own: true },
},
{ name: 'no object is off', inputs: { object: null }, outputs: { result: false, own: false } },
],
GetProperty: [
{
name: 'reads a property',
inputs: { object: fakeHandle({ width: 42 }) },
params: { property: 'width' },
outputs: { value: 42 },
},
{
name: 'an absent property is null',
inputs: { object: fakeHandle({}) },
params: { property: 'width' },
outputs: { value: null },
},
],
GetPosition: [
{
name: 'local and world position of a top-level object are the same',
inputs: { object: at('A', 10, 20) },
outputs: { x: 10, y: 20, worldX: 10, worldY: 20 },
},
{
name: 'a child reports its local position and the summed world position',
inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } },
outputs: { x: 10, y: 20, worldX: 110, worldY: 220 },
},
{
name: 'no object is the origin',
inputs: { object: null },
outputs: { x: 0, y: 0, worldX: 0, worldY: 0 },
},
],
// --- literals and math -------------------------------------------------------------------------
Number: [
{ name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } },
{ name: 'unset is 0', outputs: { value: 0 } },
],
Boolean: [
{ name: 'outputs its setting', params: { value: true }, outputs: { value: true } },
{ name: 'unset is false', outputs: { value: false } },
],
String: [
{ name: 'outputs its setting', params: { value: 'hello' }, outputs: { value: 'hello' } },
{ name: 'unset is empty', outputs: { value: '' } },
],
Add: [
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
],
Subtract: [{ name: 'subtracts', inputs: { a: 2, b: 3 }, outputs: { result: -1 } }],
Multiply: [{ name: 'multiplies', inputs: { a: 2, b: 3 }, outputs: { result: 6 } }],
Divide: [
{ name: 'divides', inputs: { a: 6, b: 4 }, outputs: { result: 1.5 } },
{ name: 'division by zero is 0, not Infinity', inputs: { a: 6, b: 0 }, outputs: { result: 0 } },
],
Min: [{ name: 'the smaller one', inputs: { a: 2, b: -3 }, outputs: { result: -3 } }],
Max: [{ name: 'the larger one', inputs: { a: 2, b: -3 }, outputs: { result: 2 } }],
Abs: [{ name: 'drops the sign', inputs: { value: -4 }, outputs: { result: 4 } }],
Sqrt: [
{ name: 'square root', inputs: { value: 9 }, outputs: { result: 3 } },
{ name: 'negative input is 0, not NaN', inputs: { value: -9 }, outputs: { result: 0 } },
],
Clamp: [
{
name: 'inside the range passes through',
inputs: { value: 0.4, min: 0, max: 1 },
outputs: { result: 0.4 },
},
{ name: 'above the range', inputs: { value: 5, min: 0, max: 1 }, outputs: { result: 1 } },
{ name: 'below the range', inputs: { value: -5, min: 0, max: 1 }, outputs: { result: 0 } },
],
Floor: [
{ name: 'rounds down', inputs: { value: 2.9 }, outputs: { result: 2 } },
{ name: 'rounds down for negatives too', inputs: { value: -2.1 }, outputs: { result: -3 } },
],
Ceil: [
{ name: 'rounds up', inputs: { value: 2.1 }, outputs: { result: 3 } },
{ name: 'a whole number stays put', inputs: { value: 3 }, outputs: { result: 3 } },
],
Round: [
{ name: 'rounds to nearest', inputs: { value: 2.4 }, outputs: { result: 2 } },
{ name: 'half rounds up', inputs: { value: 2.5 }, outputs: { result: 3 } },
{ name: 'negative half rounds towards zero', inputs: { value: -2.5 }, outputs: { result: -2 } },
],
Modulo: [
{ name: 'remainder', inputs: { a: 125, b: 60 }, outputs: { result: 5 } },
{ name: 'modulo zero is 0, not NaN', inputs: { a: 125, b: 0 }, outputs: { result: 0 } },
],
Random: [
// The spread is checked in "Random stays inside [min, max)" below; an empty range is the
// one case with a single right answer.
{ name: 'an empty range always yields min', inputs: { min: 3, max: 3 }, outputs: { result: 3 } },
],
Time: [
{
name: 'reports the clock and the frame length from the context',
context: { time: 12.5, dt: 0.016 },
outputs: { time: 12.5, dt: 0.016 },
},
],
// --- text and comparison -----------------------------------------------------------------------
ToText: [
{ name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } },
{ name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } },
{ name: 'decimals are clamped to 0…10', inputs: { value: 1, decimals: -5 }, outputs: { text: '1' } },
],
Join: [
{ name: 'sticks values together as text', inputs: { a: 'Coins: ', b: 3 }, outputs: { text: 'Coins: 3' } },
{
name: 'an object joins as its name',
inputs: { a: '', b: fakeEntity('Coin') },
outputs: { text: 'Coin' },
},
],
Equals: [
{ name: 'equal text', inputs: { a: 'a', b: 'a' }, outputs: { result: true } },
{ name: 'a number equals its text form', inputs: { a: 3, b: '3' }, outputs: { result: true } },
{ name: 'objects compare by identity, not by name', inputs: eqNamesakes(), outputs: { result: false } },
],
Compare: [
{ name: 'less than', inputs: { a: 1, b: 2 }, params: { op: '<' }, outputs: { result: true } },
{ name: 'greater or equal', inputs: { a: 2, b: 2 }, params: { op: '>=' }, outputs: { result: true } },
{ name: 'not equal', inputs: { a: 2, b: 2 }, params: { op: '!=' }, outputs: { result: false } },
{
name: 'no operator chosen falls back to the default "<"',
inputs: { a: 1, b: 2 },
outputs: { result: true },
},
],
And: [
{ name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } },
{ name: 'one false', inputs: { a: true, b: false }, outputs: { result: false } },
{ name: 'an unconnected input reads as false', inputs: { a: true }, outputs: { result: false } },
],
Or: [
{ name: 'one true is enough', inputs: { a: false, b: true }, outputs: { result: true } },
{ name: 'both false', inputs: { a: false, b: false }, outputs: { result: false } },
],
Not: [
{ name: 'inverts', inputs: { value: true }, outputs: { result: false } },
{ name: 'inverts the other way', inputs: { value: false }, outputs: { result: true } },
{ name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } },
],
// --- vectors and input --------------------------------------------------------------------------
Length: [
{ name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } },
{ name: 'the zero vector has length 0', inputs: { x: 0, y: 0 }, outputs: { result: 0 } },
],
Normalize: [
{
name: 'scales to length 1 and reports the old length',
inputs: { x: 0, y: -4 },
outputs: { x: 0, y: -1, length: 4 },
},
{
name: 'the zero vector stays zero instead of becoming NaN',
inputs: { x: 0, y: 0 },
outputs: { x: 0, y: 0, length: 0 },
},
],
Distance: [
{
name: 'distance between two objects',
inputs: { a: at('a', 0, 0), b: at('b', 3, 4) },
outputs: { result: 5 },
},
{ name: 'a missing object is distance 0', inputs: { a: at('a', 0, 0), b: null }, outputs: { result: 0 } },
],
AxisInput: [
{
name: 'one key gives a unit vector',
context: { isKeyDown: (key) => key === 'ArrowRight' },
outputs: { x: 1, y: 0, active: true },
},
{
name: 'diagonals are normalized, so they are not faster',
context: { isKeyDown: (key) => key === 'ArrowRight' || key === 'ArrowDown' },
// Not Math.SQRT1_2: the node divides by Math.hypot(1, 1), which lands one ulp lower.
outputs: { x: 1 / Math.hypot(1, 1), y: 1 / Math.hypot(1, 1), active: true },
},
{
name: 'opposite keys cancel out',
context: { isKeyDown: (key) => key === 'ArrowLeft' || key === 'ArrowRight' },
outputs: { x: 0, y: 0, active: false },
},
{ name: 'nothing held is inactive', outputs: { x: 0, y: 0, active: false } },
],
KeyIsDown: [
{
name: 'asks the context for the chosen key',
params: { key: 'a' },
context: { isKeyDown: (k) => k === 'a' },
outputs: { down: true },
},
{
name: 'another key is not down',
params: { key: 'b' },
context: { isKeyDown: (k) => k === 'a' },
outputs: { down: false },
},
{ name: 'no key chosen is never down', context: { isKeyDown: () => true }, outputs: { down: true } },
],
MousePosition: [
{
name: 'reports the pointer in world units',
context: pointer({ x: 40, y: -20, inside: true }),
outputs: { x: 40, y: -20, inside: true },
},
{
name: 'outside the camera frame the position is still reported, but "in view" is false',
context: pointer({ x: 5, y: 5, inside: false }),
outputs: { x: 5, y: 5, inside: false },
},
],
MouseButton: [
{
name: 'the frame the button goes down',
context: pointer({ down: true, pressed: true }),
outputs: { down: true, pressed: true, released: false },
},
{
name: 'a later frame while it is still held',
context: pointer({ down: true }),
outputs: { down: true, pressed: false, released: false },
},
{
name: 'the frame it goes up',
context: pointer({ released: true }),
outputs: { down: false, pressed: false, released: true },
},
],
IsHovered: [
{
name: 'the pointer is over the object',
inputs: { object: fakeEntity('Button') },
context: { ...pointer({ inside: true }), hitTest: () => true },
outputs: { result: true },
},
{
name: 'a hit outside the camera frame does not count',
inputs: { object: fakeEntity('Button') },
context: { ...pointer({ inside: false }), hitTest: () => true },
outputs: { result: false },
},
{
name: 'no object is never hovered',
inputs: { object: null },
context: { ...pointer({ inside: true }), hitTest: () => true },
outputs: { result: false },
},
],
IsTouching: [
{
name: 'asks the context',
inputs: { a: fakeEntity('a'), b: fakeEntity('b') },
context: { isTouching: () => true },
outputs: { result: true },
},
{
name: 'a missing object never touches',
inputs: { a: fakeEntity('a'), b: null },
context: { isTouching: () => true },
outputs: { result: false },
},
],
// --- actions --------------------------------------------------------------------------------------
Move: [
{
name: 'moves by (dx, dy) × scale × dt',
inputs: { object: at('m', 0, 0), dx: 1, dy: 0, scale: 100, dt: 0.5 },
next: 'exec',
outputs: { blockedX: false, blockedY: false },
},
{
name: 'no object is a no-op that still continues the flow',
inputs: { object: null, dx: 1, dy: 1 },
next: 'exec',
outputs: { blockedX: false, blockedY: false },
},
],
SetPosition: [
{
name: 'places the object and continues',
inputs: { object: at('p', 0, 0), x: 30, y: -40 },
next: 'exec',
},
{ name: 'no object is a no-op', inputs: { object: null, x: 1, y: 1 }, next: 'exec' },
],
SetProperty: [
{
name: 'writes a property, keeping the existing type',
inputs: { object: fakeHandle({ width: 100 }), value: '250' },
params: { property: 'width' },
next: 'exec',
},
{
name: 'no property name chosen writes nothing',
inputs: { object: fakeHandle({ width: 100 }), value: 5 },
next: 'exec',
},
],
SetEnabled: [
{
name: 'switches an object off',
inputs: { object: fakeHandle({ enabled: true }), enabled: false },
next: 'exec',
},
{ name: 'no object is a no-op', inputs: { object: null, enabled: false }, next: 'exec' },
],
SetText: [
{
name: 'writes the content of a text object',
inputs: { object: fakeHandle({ text: 'a' }), text: 12 },
next: 'exec',
},
],
Spawn: [
{
name: 'no asset chosen logs a hint and continues with no object',
next: 'exec',
outputs: { object: null },
after: (ctx) => expect(ctx.logs).toEqual(['Spawn: no asset selected']),
},
],
Destroy: [
{
name: 'no object is a no-op that still continues the flow',
inputs: { object: null },
next: 'exec',
},
],
Log: [
{
name: 'logs its message and continues',
inputs: { message: 'hi' },
next: 'exec',
after: (ctx) => expect(ctx.logs).toEqual(['hi']),
},
{
name: 'an object logs as its name',
inputs: { message: fakeEntity('Coin') },
next: 'exec',
after: (ctx) => expect(ctx.logs).toEqual(['Coin']),
},
],
StopGame: [{ name: 'ends play and the flow', next: null, after: (ctx) => expect(ctx.stopped).toBe(true) }],
RestartGame: [{ name: 'ends the flow – the world restarts before the next tick', next: null }],
};
/** Two distinct handles that share a name – `Equals` must still tell them apart. */
function eqNamesakes(): Record<string, Value> {
return { a: fakeEntity('Coin'), b: fakeEntity('Coin') };
}
/**
* Nodes without a `run`: the interpreter seeds their outputs when the BehaviorSystem fires them,
* so they are only observable through a running world. The value says which test drives it.
*/
const EVENT_NODES: Record<string, string> = {
OnStart: 'graph.test.ts',
OnUpdate: 'graph.test.ts',
OnKeyDown: 'behavior-events.test.ts',
OnKeyUp: 'behavior-events.test.ts',
OnKeyHeld: 'behavior-events.test.ts',
OnTimer: 'behavior-events.test.ts',
OnCollisionEnter: 'collider.test.ts',
OnCollisionStay: 'behavior-events.test.ts',
OnCollisionExit: 'collider.test.ts',
OnClick: 'nodes.test.ts',
};
describe('node catalog', () => {
it('covers every built-in node', () => {
const uncovered = BUILT_IN_NODES.filter((def) =>
def.run ? !CASES[def.type] : !EVENT_NODES[def.type],
).map((def) => def.type);
expect(uncovered).toEqual([]);
});
it('lists exactly the run-less nodes as events', () => {
const runless = BUILT_IN_NODES.filter((def) => !def.run).map((def) => def.type);
expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort());
});
it('declares well-formed ports and settings', () => {
for (const def of BUILT_IN_NODES) {
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
expect(def.label, `${def.type} has a label`).not.toBe('');
expect(def.description, `${def.type} has a description`).not.toBe('');
// Inputs and outputs are addressed by key within their own side; a key may repeat across
// sides (Normalize reads x/y and writes x/y), never within one.
expect(new Set(def.inputs.map((p) => p.key)).size, `${def.type} input keys unique`).toBe(
def.inputs.length,
);
expect(new Set(def.outputs.map((p) => p.key)).size, `${def.type} output keys unique`).toBe(
def.outputs.length,
);
expect(
keys.every((k) => k !== ''),
`${def.type} has no empty port key`,
).toBe(true);
for (const param of def.params) {
expect(param.default, `${def.type}.${param.key} has a default`).toBeDefined();
}
}
});
});
for (const [type, cases] of Object.entries(CASES)) {
describe(type, () => {
for (const c of cases) {
it(c.name, () => {
const { result, ctx } = run(type, c);
if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs);
if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next);
c.after?.(ctx);
});
}
});
}
// Cases whose point is the mutation they cause – stated here rather than squeezed into the table.
describe('action nodes write through to the object they are given', () => {
it('Move applies dx/dy × scale × dt', () => {
const target = at('m', 0, 0);
requireNodeDef('Move').run!(fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {});
expect({ x: target.x, y: target.y }).toEqual({ x: 50, y: -100 });
});
it('SetPosition places the object absolutely', () => {
const target = at('p', 10, 10);
requireNodeDef('SetPosition').run!(fakeContext(), { object: target, x: 30, y: -40 }, {});
expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 });
});
it('SetProperty keeps the type the property already had', () => {
const target = fakeHandle({ width: 100 });
requireNodeDef('SetProperty').run!(
fakeContext(),
{ object: target, value: '250' },
{ property: 'width' },
);
expect(target.props.width).toBe(250);
});
it('SetProperty without a property name writes nothing', () => {
const target = fakeHandle({ width: 100 });
requireNodeDef('SetProperty').run!(fakeContext(), { object: target, value: 5 }, {});
expect(target.props).toEqual({ width: 100 });
});
it('SetEnabled switches the object off', () => {
const target = fakeHandle({ enabled: true });
requireNodeDef('SetEnabled').run!(fakeContext(), { object: target, enabled: false }, {});
expect(target.props.enabled).toBe(false);
});
it('SetText writes the text property, stringifying numbers', () => {
const target = fakeHandle({ text: 'a' });
requireNodeDef('SetText').run!(fakeContext(), { object: target, text: 12 }, {});
expect(target.props.text).toBe('12');
});
it('Destroy hands the object to the context', () => {
const removed: string[] = [];
const ctx = fakeContext({ destroy: (target) => removed.push(target.name) });
requireNodeDef('Destroy').run!(ctx, { object: fakeEntity('Coin') }, {});
expect(removed).toEqual(['Coin']);
});
it('RestartGame asks the context for a restart', () => {
let restarted = false;
const ctx = fakeContext({
restart: () => {
restarted = true;
},
});
requireNodeDef('RestartGame').run!(ctx, {}, {});
expect(restarted).toBe(true);
});
it('Random stays inside [min, max) over many draws', () => {
const ctx = fakeContext();
const draws = Array.from(
{ length: 200 },
() => requireNodeDef('Random').run!(ctx, { min: 5, max: 10 }, {}).outputs!.result as number,
);
expect(draws.every((n) => n >= 5 && n < 10)).toBe(true);
// Not a constant: a generator stuck on one value would pass the range check above.
expect(new Set(draws).size).toBeGreaterThan(1);
});
});