Files
feuerwerk/tests/core/node-catalog.test.ts
T
rootandClaude Opus 5 e9fa592078 Add the Variable node and show the variable name in node titles
Get/Set variable nodes now title themselves "Get <name>" / "Set <name>", and
the new Variable node just "<name>"; the title is the same folded or open,
and a collapsed node shrinks to it. The redundant "Variable: <name>" row is
gone unless the variable was deleted ("<name>?" is the only hint then).

The Variable node sets its value when an exec flow arrives and exposes the
value at any time. Its output is read when a downstream node pulls it, not
frozen at the write, so count = count + 1 is one Variable node plus an Add.
Get variable keeps reading once per pulse: switching it would change the
behaviour of existing games.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:34:09 +02:00

829 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* One table covering every built-in node that has a `run`: adding a node without a case here
* fails `covers every built-in node`. The run-less event nodes are listed in `EVENT_NODES`.
*/
import { describe, expect, it } from 'vitest';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import {
createEmptyGraph,
type DeclaredPort,
NO_BEHAVIORS,
type NodeContext,
requireNodeDef,
resolveDef,
type RunResult,
type Value,
} from '../../src/core/graph';
import type { PropertyBag } from '../../src/core/model/Properties';
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers';
interface Case {
/** What the case demonstrates – becomes the failure message. */
name: string;
inputs?: Record<string, Value>;
params?: PropertyBag;
context?: Partial<NodeContext>;
/** Ports the node declares for itself – for the types whose ports are variables it names. */
ports?: DeclaredPort[];
/** Expected data outputs, compared exactly. */
outputs?: Record<string, Value>;
/** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */
derivedOutputs?: string[];
/** Expected exec continuation; `null` asserts the flow ends here. */
next?: string | string[] | null;
/** Expected loop request; `null` asserts the node does not loop. */
repeat?: RunResult['repeat'] | null;
/** Expected nested-behavior call; `null` asserts the node calls nothing. */
call?: RunResult['call'] | 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);
const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports };
const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_BEHAVIORS)! : requireNodeDef(type);
const grown = (c.derivedOutputs ?? []).map((key) => ({ key, label: key, type: 'exec' as const }));
const def = grown.length > 0 ? { ...registered, outputs: [...registered.outputs, ...grown] } : registered;
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def), ctx };
};
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: 'on its own it has the one output, and follows it', next: ['a'] },
{ name: 'runs every output it has grown, in order', derivedOutputs: ['b', 'c'], next: ['a', 'b', 'c'] },
],
BehaviorEnd: [
{
name: 'hands its inputs back out, which is where the caller reads the result',
inputs: { hit: true, damage: 7 },
outputs: { hit: true, damage: 7 },
next: null,
},
],
RunBehavior: [
{
name: 'asks for the named behavior to run, passing its inputs on',
inputs: { power: 3 },
params: { behavior: 'b1' },
call: { behaviorId: 'b1', inputs: { power: 3 } },
next: 'exec',
},
{
name: 'without a behavior chosen it still continues, calling nothing that exists',
call: { behaviorId: '', inputs: {} },
next: 'exec',
},
],
Repeat: [
{
name: 'asks for as many body rounds as it was given, then continues at done',
inputs: { times: 3 },
repeat: { port: 'body', index: 'index', times: 3 },
next: 'done',
},
{
name: 'a fractional count is cut and a negative one runs nothing',
inputs: { times: -2.7 },
repeat: { port: 'body', index: 'index', times: 0 },
},
],
// 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 } },
],
GetVariables: [
{
name: 'reads one output per variable it names',
ports: [
{ key: 'speed', kind: 'number' },
{ key: 'nope', kind: 'number' },
],
outputs: { speed: 100, nope: null },
},
{ name: 'without a variable it reads nothing', outputs: {} },
],
SetVariables: [
{
name: 'writes every variable it names, each keeping its declared type',
ports: [{ key: 'speed', kind: 'number' }],
inputs: { speed: '250' },
next: 'exec',
after: (ctx) => expect(ctx.variables.speed).toBe(250),
},
{
name: 'without a variable it writes nothing',
next: 'exec',
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
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 }),
},
],
// Reading without a flow needs the interpreter: see variable-node.test.ts.
Variable: [
{
name: 'writes its variable when the flow arrives, 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',
inputs: { name: 'Player' },
context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) },
outputs: { object: fakeEntity('Player') },
},
{ name: 'an unknown name is null', inputs: { 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.
{ 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' } },
],
ToNumber: [
{ name: 'reads a text as a number', inputs: { value: '42' }, outputs: { result: 42 } },
{ name: 'a word is 0', inputs: { value: 'twelve' }, outputs: { result: 0 } },
],
Substring: [
{
name: 'takes count characters from start',
inputs: { text: 'abcdef', start: 2, count: 3 },
outputs: { text: 'cde' },
},
{
name: 'a negative count reaches to the end',
inputs: { text: 'abcdef', start: 4, count: -1 },
outputs: { text: 'ef' },
},
{
name: 'asking past the end gives what is there',
inputs: { text: 'ab', start: 1, count: 10 },
outputs: { text: 'b' },
},
],
// lists
List: [
{ name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } },
{ name: 'an unset list is empty, not missing', outputs: { value: [] } },
],
Fill: [
{ name: 'repeats one value', inputs: { value: 0, count: 4 }, outputs: { result: [0, 0, 0, 0] } },
{ name: 'a negative count gives nothing', inputs: { value: 1, count: -3 }, outputs: { result: [] } },
],
Count: [
{ name: 'counts the items', inputs: { list: ['a', 'b'] }, outputs: { count: 2 } },
{ name: 'a lone value counts as one', inputs: { list: 7 }, outputs: { count: 1 } },
],
ItemAt: [
{ name: 'reads by index, counting from 0', inputs: { list: [4, 5, 6], index: 1 }, outputs: { item: 5 } },
{ name: 'past the end is 0, not a crash', inputs: { list: [4], index: 9 }, outputs: { item: 0 } },
],
SetItem: [
{
name: 'replaces one item',
inputs: { list: [1, 2, 3], index: 1, value: 9 },
outputs: { result: [1, 9, 3] },
},
{
name: 'leaves the list it was given untouched – a graph may hold it elsewhere',
inputs: { list: Object.freeze([1, 2, 3]), index: 0, value: 9 },
outputs: { result: [9, 2, 3] },
},
{
name: 'an index outside the list changes nothing',
inputs: { list: [1, 2], index: 5, value: 9 },
outputs: { result: [1, 2] },
},
],
SubList: [
{
name: 'takes count items from start',
inputs: { list: [1, 2, 3, 4], start: 1, count: 2 },
outputs: { result: [2, 3] },
},
{
name: 'asking past the end gives what is there',
inputs: { list: [1, 2], start: 1, count: 9 },
outputs: { result: [2] },
},
],
Concat: [
{ name: 'one list after the other', inputs: { a: [1], b: [2, 3] }, outputs: { result: [1, 2, 3] } },
{ name: 'a lone value joins as one item', inputs: { a: [1], b: 2 }, outputs: { result: [1, 2] } },
],
PackStruct: [
{
name: 'bundles its declared inputs into one value',
inputs: { x: 2, tags: ['a', 'b'] },
outputs: { value: { x: 2, tags: ['a', 'b'] } },
},
{ name: 'a node with no fields yet packs nothing', outputs: { value: {} } },
{
name: 'an entity cannot be stored in a bundle, so it packs as its name',
inputs: { who: fakeEntity('Player') },
outputs: { value: { who: 'Player' } },
},
],
ExpandStruct: [
{
name: 'reads the fields back out, one output each',
inputs: { value: { x: 2, tags: ['a'] } },
outputs: { x: 2, tags: ['a'] },
},
{ name: 'a value that is not a bundle has no fields', inputs: { value: 7 }, outputs: {} },
],
IndexOf: [
{ name: 'finds where a value sits', inputs: { list: [7, 8, 9], value: 8 }, outputs: { index: 1 } },
{ name: 'a value that is not in there is -1', inputs: { list: [7], value: 8 }, outputs: { index: -1 } },
],
FindText: [
{
name: 'finds where the search first appears',
inputs: { text: '..#.#', search: '#' },
outputs: { index: 2 },
},
{
name: 'text that does not appear is -1',
inputs: { text: '....', search: '#' },
outputs: { index: -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`: only observable through a running world, since the BehaviorSystem
* seeds their outputs. The value names the test that drives each one.
*/
const EVENT_NODES: Record<string, string> = {
BehaviorStart: 'nested-behavior.test.ts',
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('');
// Keys are unique per side, but may repeat across sides (Normalize reads and writes x/y).
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);
if (c.repeat !== undefined) expect(result.repeat ?? null).toEqual(c.repeat);
if (c.call !== undefined) expect(result.call ?? null).toEqual(c.call);
c.after?.(ctx);
});
}
});
}
describe('action nodes write through to the object they are given', () => {
it('Move applies dx/dy × scale × dt', () => {
const target = at('m', 0, 0);
runDef('Move', 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);
runDef('SetPosition', 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 });
runDef('SetProperty', 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 });
runDef('SetProperty', fakeContext(), { object: target, value: 5 }, {});
expect(target.props).toEqual({ width: 100 });
});
it('SetEnabled switches the object off', () => {
const target = fakeHandle({ enabled: true });
runDef('SetEnabled', fakeContext(), { object: target, enabled: false }, {});
expect(target.props.enabled).toBe(false);
});
it('SetText writes the text property, stringifying numbers', () => {
const target = fakeHandle({ text: 'a' });
runDef('SetText', 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) });
runDef('Destroy', 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;
},
});
runDef('RestartGame', ctx, {}, {});
expect(restarted).toBe(true);
});
it('Random stays inside [min, max) over many draws', () => {
const ctx = fakeContext();
const draws = Array.from(
{ length: 200 },
() => runDef('Random', 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);
});
});