The Outputs setting is gone. A Sequence now keeps one free output below the last connected one: nothing plugged in is one output, connecting the spare grows a new one, unplugging the bottom edge shrinks it back. It counts the last *connected* port rather than the number of edges, so unplugging a middle one leaves a gap instead of silently dropping an edge. Removing the setting also removes a bug it caused. Typing in the Outputs field did nothing and the arrow keys did nothing, while dragging the value worked: onInput fires per keystroke, the preview writes into node.params, that emits 'project', and Inspector.refreshValues() sees the node's signature change -- the port list is part of it -- so it rebuilds and destroys the row being typed in. A drag survives because it keeps receiving globalpointermove on the old widget. refreshValues() has no focus guard where refreshLive() does; the trap is still there for the next typed field that changes a node's shape. Ports that depend on the edges around a node mean derivePorts needs the graph, not just the params, so resolveDef and portSignature take it too. And a node whose ports are derived cannot know how many it has from its type, so run is handed the definition resolved for it.
776 lines
28 KiB
TypeScript
776 lines
28 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, runDef } 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>;
|
||
/** 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 registered = 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 } },
|
||
],
|
||
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' },
|
||
},
|
||
],
|
||
// --- 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);
|
||
});
|
||
});
|