A behavior that has a Start and an End node can now be dropped into any other graph as a node of its own. What is declared on the start node becomes the inputs of that node, what hangs on the end node its outputs; the port list is derived from the sub graph, not copied, so declaring a port in one graph grows the node in the other without touching it. The node carries the behavior's name and the palette lists behaviors by name, so a call is one grab instead of a node plus a dropdown. An embedded behavior has no object and therefore no place for variables: every call starts on the asset defaults and communicates only through its ports. That is the conservative direction - state can still be added later, and shared state already has a home in a behavior object in the scene. Node ids are unique per graph only, so each call gets its own pulse cache; without that, two graphs would collide. Renaming a port used to silently drop the value typed on the calling node. It is carried along now, project wide, like the edges.
695 lines
25 KiB
TypeScript
695 lines
25 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;
|
||
/** 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);
|
||
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'] }],
|
||
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 } },
|
||
],
|
||
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> = {
|
||
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);
|
||
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);
|
||
});
|
||
});
|