A Tetris example project needs to draw 200 board cells and scan rows for a full line. Neither is expressible without iteration, so the node system gets a Repeat node plus the missing text side: Substring, Find text and To number. The interpreter drops its data cache once per round -- otherwise every round would recompute with the first round's values. Object by name takes its name as an input now, not just as a setting; addressing "c" + index is impossible otherwise. An unconnected input reads the stored setting, so saved projects are untouched. The board itself is a 200-character text, "." for empty. That makes both operations of Tetris a single expression instead of a loop, and each frame attempts exactly one move -- rotate beats sideways beats fall -- so the fit check exists once in the whole graph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
669 lines
24 KiB
TypeScript
669 lines
24 KiB
TypeScript
/**
|
||
* 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 { 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;
|
||
/** Expected loop request; `null` asserts the node does not loop. */
|
||
repeat?: RunResult['repeat'] | 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 };
|
||
};
|
||
|
||
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'] }],
|
||
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 } },
|
||
],
|
||
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',
|
||
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' },
|
||
},
|
||
],
|
||
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> = {
|
||
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);
|
||
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);
|
||
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);
|
||
});
|
||
});
|