Files
feuerwerk/tests/core/node-catalog.test.ts
T
adminandClaude Opus 5 ffdf0a8ddb
game-maker / gate (push) Successful in 2m4s
game-maker / suite (push) Successful in 7m26s
The new node "Raycast all" answers with every collider along the ray instead of only the nearest one: it carries the same inputs and settings as Raycast (Ignore owner, Hit triggers, Layers from #158) plus "max hits" where 0 means all, and it hands back parallel lists sorted by distance - objects, colliders, points x/y, normals x/y, distances - next to count, so index i of every list speaks about the same hit; a collider counts once no matter how many convex parts its shape is built from, while a tilemap counts per cell it is hit in, and the filter sits in Solids.raycastAll like the one of #158, the one path the editor run and Play both take, so all 12 new unit tests run through describe.each against both systems; the node needed the value system to learn something it could not do - a graph list carried numbers, text, booleans and bundles but no object handles, asList turned an object into its name, so objects/colliders would have been lists of names, which loses the object when two carry the same name, and collider children usually do; Value now knows a travelling list that may hold handles and "Item at" gives such an item out as an object, ready for Destroy, Apply force or Name of, while the single door into a stored file - writeTyped/asTypeOf - runs the list through the new storedList() so every object becomes its name again and nothing living reaches a project file, which is its own unit test over JSON.stringify; three list nodes follow the handles: "Index of" compares objects by identity because a name is not the object of that name, "Set item" and "Fill" write an object as an object, and "Join text" prints the names instead of the innards; this decision was mine to make because the question ran into the MCP timeout - the alternative was a list of names plus "Object by name", which is the smaller change but lossy; 4 new e2e checks in the new block 43e (shield and floor in one go reading 2 and "145, 280", the second object answering to its name, which only a real object in a list can do, "max hits" 1 stopping at the nearest, and the node showing the row) and 18 new unit tests, 12 in raycast.test.ts, 4 on the value system and 2 in the node catalog; two counter-checks hold: not counting tilemap cells one by one drops 2 unit tests, and answering with the nearest hit only drops module 43 with 2 FAILs; one finding on the way - the node read "maxHits" while every other input carries a spoken label, because int() took the key as its label, so it has a third parameter now, and the e2e check caught that, no unit test did (#157)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:54:12 +02:00

1345 lines
48 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 { requireNodeDef } from '../../src/core/commands/require';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import {
createEmptyGraph,
type DeclaredPort,
type GraphLookup,
NO_LOOKUP,
type NodeContext,
type PhysicsControl,
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[];
/** Exec inputs grown the same way, and the one the pulse arrives through (`exec` unless said). */
derivedInputs?: string[];
entry?: 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;
/** The project around the graph – for a node whose step is written in another asset. */
lookup?: GraphLookup;
/** Assertions on what the node did to the context or to the handles it was given. */
after?(ctx: FakeContext): void;
}
/** A lookup holding one machine "m" with the given states – what the two state readers name. */
const machineWithStates = (states: { id: string; name: string }[]): GraphLookup => ({
...NO_LOOKUP,
machine: (id) => (id === 'm' ? { name: 'M', states, start: '', transitions: [] } : null),
});
/** A lookup holding one machine "m" with the given transitions – what `State transition` reads. */
const machineLookup = (transitions: { from: string; to: string; name?: string }[]): GraphLookup => ({
...NO_LOOKUP,
machine: (id) =>
id === 'm'
? { name: 'M', states: [], start: '', transitions: transitions.map((t) => ({ name: '', ...t })) }
: null,
});
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_LOOKUP)! : requireNodeDef(type);
const execPorts = (keys: string[] = []) => keys.map((key) => ({ key, label: key, type: 'exec' as const }));
const def = {
...registered,
inputs: [...execPorts(c.derivedInputs), ...registered.inputs],
outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)],
};
const lookup = c.lookup ?? NO_LOOKUP;
// A pure reader has no `run`: its outputs are what it answers at the moment they are asked for,
// which is exactly what the interpreter pulls (see `GraphInterpreter.readAll`).
const result = def.run
? def.run(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec', lookup)
: { outputs: def.read!(ctx, c.params ?? {}, (key) => c.inputs?.[key] ?? null, def, lookup) };
return { result, ctx };
};
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
/** Its own entity, because Emit's whole effect is the count it leaves on the one it is handed. */
const emitTarget = fakeEntity('Sparks');
/** Their own entity each: the animation nodes leave players on the object they are handed. */
const dancer = fakeEntity('Dancer');
const painted = fakeHandle({ width: 100, text: '' });
type PhysicsDouble = PhysicsControl & { calls: unknown[][] };
/** Records what the physics nodes ask for; "crate" is the one object with a dynamic body. */
const physicsDouble = (): PhysicsDouble => {
const calls: unknown[][] = [];
const has = (target: { id: string }) => target.id === 'crate';
return {
calls,
velocityOf: (target) => (has(target) ? { x: 12, y: -3, angular: 45 } : null),
setVelocity: (target, x, y) => (calls.push(['setVelocity', target.id, x, y]), has(target)),
setAngularVelocity: (target, degrees) => (
calls.push(['setAngularVelocity', target.id, degrees]),
has(target)
),
applyImpulse: (target, x, y) => (calls.push(['applyImpulse', target.id, x, y]), has(target)),
setGravity: (x, y) => void calls.push(['setGravity', x, y]),
};
};
const physicsCalls = (ctx: FakeContext) => (ctx.physics as PhysicsDouble).calls;
const crate = fakeEntity('crate');
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
pointer: { ...NO_POINTER, ...overrides },
});
const CASES: Record<string, Case[]> = {
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',
},
],
StateBranch: [
{
name: 'in no state yet, the flow leaves along else',
inputs: { machine: 'm' },
derivedOutputs: ['s1'],
next: 'else',
},
{
name: 'follows the output of the current state',
inputs: { machine: 'm' },
context: { states: { m: 's1' } },
derivedOutputs: ['s1', 's2'],
next: 's1',
},
{
name: 'a state that is not among its outputs (another machine, a deleted state) takes else',
inputs: { machine: 'm' },
context: { states: { m: 'gone' } },
derivedOutputs: ['s1'],
next: 'else',
},
{
// The point of #58: a machine arriving as a value has no state outputs at all.
name: 'a machine the graph cannot name has no state outputs, so else is the whole node',
inputs: { machine: 'm' },
context: { states: { m: 's1' } },
next: 'else',
},
],
SetStateByName: [
{
name: 'moves the machine to the state of that name',
inputs: { machine: 'm', state: 'Open' },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }),
},
{
name: 'a state id names the same state, so a rename cannot break a graph that used the id',
inputs: { machine: 'm', state: 's1' },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }),
},
{
name: 'a name no state answers to moves nothing and takes "not found"',
inputs: { machine: 'm', state: 'Ajar' },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
next: 'notFound',
after: (ctx) => expect(ctx.states).toEqual({}),
},
{
name: 'a name two states share is no answer: the graph never picked between them',
inputs: { machine: 'm', state: 'Open' },
lookup: machineWithStates([
{ id: 's1', name: 'Open' },
{ id: 's2', name: 'Open' },
]),
next: 'notFound',
after: (ctx) => expect(ctx.states).toEqual({}),
},
{
name: 'without a machine there is nothing to set',
inputs: { machine: '', state: 'Open' },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
next: 'notFound',
after: (ctx) => expect(ctx.states).toEqual({}),
},
],
SetState: [
{
name: 'puts the machine into the state whose input the flow arrived through',
inputs: { machine: 'm' },
derivedInputs: ['s1', 's2'],
entry: 's2',
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
},
{
name: 'arriving through no state input changes nothing',
inputs: { machine: 'm' },
derivedInputs: ['s1'],
entry: 'exec',
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({}),
},
{
name: 'without a machine there is nothing to set',
inputs: { machine: '' },
derivedInputs: ['s1'],
entry: 's1',
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({}),
},
],
CurrentState: [
{
name: 'names the state the machine is in, and hands out its id as well',
inputs: { machine: 'm' },
context: { states: { m: 's1' } },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
outputs: { name: 'Open', id: 's1' },
},
{
name: 'in no state, both outputs are empty',
inputs: { machine: 'm' },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
outputs: { name: '', id: '' },
},
{
// A state of another machine, or one that was deleted: the id stands, the name is gone.
name: 'a state the machine does not know keeps its id but has no name',
inputs: { machine: 'm' },
context: { states: { m: 'gone' } },
lookup: machineWithStates([{ id: 's1', name: 'Open' }]),
outputs: { name: '', id: 'gone' },
},
],
IsInState: [
{
name: 'is true on the current state and false on every other',
inputs: { machine: 'm' },
context: { states: { m: 's2' } },
derivedOutputs: ['s1', 's2'],
outputs: { s1: false, s2: true },
},
{
name: 'in no state, every output is false rather than unanswered',
inputs: { machine: 'm' },
derivedOutputs: ['s1', 's2'],
outputs: { s1: false, s2: false },
},
],
StateTransition: [
{
name: 'follows the transition of the asked-for name out of the current state',
inputs: { machine: 'm' },
params: { transition: 'alarm' },
context: { states: { m: 's1' } },
lookup: machineLookup([{ from: 's1', to: 's2', name: 'alarm' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
},
{
// The point of naming transitions: one node works from every state that has that name.
name: 'picks the same name out of whichever state the machine is in',
inputs: { machine: 'm' },
params: { transition: 'alarm' },
context: { states: { m: 'searching' } },
lookup: machineLookup([
{ from: 'patrolling', to: 'chasing', name: 'alarm' },
{ from: 'chasing', to: 'searching', name: 'lost' },
{ from: 'searching', to: 'chasing', name: 'alarm' },
]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 'chasing' }),
},
{
name: 'a name the current state does not carry moves nothing',
inputs: { machine: 'm' },
params: { transition: 'lost' },
context: { states: { m: 's1' } },
lookup: machineLookup([{ from: 's1', to: 's2', name: 'alarm' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }),
},
{
name: 'two of the name out of one state is no move: the graph never picked between them',
inputs: { machine: 'm' },
params: { transition: 'alarm' },
context: { states: { m: 's1' } },
lookup: machineLookup([
{ from: 's1', to: 's2', name: 'alarm' },
{ from: 's1', to: 's3', name: 'alarm' },
]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's1' }),
},
{
// An unnamed transition is a name too, so a machine nobody named still steps.
name: 'with no name asked for, follows the one unnamed transition',
inputs: { machine: 'm' },
context: { states: { m: 's1' } },
lookup: machineLookup([{ from: 's1', to: 's2' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
},
{
name: 'a state with nothing drawn out of it stays put',
inputs: { machine: 'm' },
context: { states: { m: 's2' } },
lookup: machineLookup([{ from: 's1', to: 's2' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
},
{
name: 'in no state yet there is nothing to move from',
inputs: { machine: 'm' },
lookup: machineLookup([{ from: 's1', to: 's2' }]),
next: 'exec',
after: (ctx) => expect(ctx.states).toEqual({}),
},
],
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 },
},
],
// Reading needs the interpreter, which asks `read` at the moment: see variable-node.test.ts.
Variables: [
{
name: 'writes the variables switched to Set, each keeping its declared type',
ports: [
{ key: 'speed', kind: 'number', write: true },
{ key: 'lives', kind: 'number' },
],
inputs: { speed: '250', lives: 9 },
context: { variables: { speed: 100, lives: 3 } },
next: 'exec',
after: (ctx) => expect(ctx.variables).toEqual({ speed: 250, lives: 3 }),
},
{
name: 'without a variable it writes nothing',
next: 'exec',
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
},
],
Properties: [
{
name: 'writes the properties switched to Set, each keeping the type it already had',
ports: [
{ key: 'width', kind: 'number', write: true },
{ key: 'text', kind: 'text', write: true },
{ key: 'height', kind: 'number' },
],
inputs: { object: painted, width: '250', text: 7 },
next: 'exec',
after: () => expect(painted.props).toEqual({ width: 250, text: '7' }),
},
{
name: 'no object is a no-op',
ports: [{ key: 'width', kind: 'number', write: true }],
inputs: { object: null, width: 5 },
next: 'exec',
},
],
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 } },
],
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 },
},
],
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: '' } },
],
Texture: [
{ name: 'outputs the id it was set to', params: { value: 'fr-0-1' }, outputs: { value: 'fr-0-1' } },
{ name: 'unset is no picture', outputs: { value: '' } },
],
StateMachine: [
{ name: 'outputs the id it was set to', params: { value: 'sm-1' }, outputs: { value: 'sm-1' } },
{ name: 'unset is no machine', outputs: { value: '' } },
],
Sound: [
{ name: 'outputs the id it was set to', params: { value: 'snd-1' }, outputs: { value: 'snd-1' } },
{ name: 'unset is no sound', outputs: { value: '' } },
],
Scene: [
{ name: 'outputs the id it was set to', params: { value: 'sc-1' }, outputs: { value: 'sc-1' } },
{ name: 'unset is no scene', outputs: { value: '' } },
],
Animation: [
{ name: 'outputs the id it was set to', params: { value: 'anim-1' }, outputs: { value: 'anim-1' } },
{ name: 'unset is no animation', 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 },
},
],
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' },
},
],
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 } },
],
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 },
},
],
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, hit: false, normalX: 0, normalY: 0 },
},
{
name: 'with collision it hands on what the move met',
inputs: { object: at('m', 0, 0), dx: 4, dy: 2, scale: 1, dt: 1 },
params: { collide: true },
context: {
moveAndSlide: (_target: unknown, dx: number, dy: number) => ({
blockedX: dx === 4,
blockedY: dy === 3,
hit: true,
normal: { x: -1, y: 0 },
}),
},
next: 'exec',
outputs: { blockedX: true, blockedY: false, hit: true, normalX: -1, normalY: 0 },
},
{
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, hit: false, normalX: 0, normalY: 0 },
},
],
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' },
],
ApplyForce: [
{
name: 'pushes for the length of the frame: force × dt, handed on as an impulse',
inputs: { object: crate, x: 10, y: -4 },
context: { physics: physicsDouble() },
next: 'exec',
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['applyImpulse', 'crate', 5, -2]]),
},
{
name: 'no object asks nothing and continues',
inputs: { object: null, x: 10, y: 0 },
context: { physics: physicsDouble() },
next: 'exec',
after: (ctx) => expect(physicsCalls(ctx)).toEqual([]),
},
{
name: 'without physics (the editor run) it continues',
inputs: { object: crate, x: 1, y: 1 },
next: 'exec',
},
],
ApplyImpulse: [
{
name: 'kicks the object with the impulse as given',
inputs: { object: crate, x: 3, y: -4 },
context: { physics: physicsDouble() },
next: 'exec',
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['applyImpulse', 'crate', 3, -4]]),
},
{ name: 'without physics it continues', inputs: { object: crate, x: 1, y: 1 }, next: 'exec' },
],
SetVelocity: [
{
name: 'sets the velocity as given',
inputs: { object: crate, x: 120, y: -30 },
context: { physics: physicsDouble() },
next: 'exec',
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['setVelocity', 'crate', 120, -30]]),
},
{ name: 'without physics it continues', inputs: { object: crate, x: 1, y: 1 }, next: 'exec' },
],
SetAngularVelocity: [
{
name: 'sets the turning speed in degrees per second',
inputs: { object: crate, speed: 90 },
context: { physics: physicsDouble() },
next: 'exec',
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['setAngularVelocity', 'crate', 90]]),
},
{ name: 'without physics it continues', inputs: { object: crate, speed: 90 }, next: 'exec' },
],
GetVelocity: [
{
name: 'reads velocity and turning speed',
inputs: { object: crate },
context: { physics: physicsDouble() },
outputs: { x: 12, y: -3, angular: 45 },
},
{
name: 'an object without a dynamic body reads 0',
inputs: { object: fakeEntity('wall') },
context: { physics: physicsDouble() },
outputs: { x: 0, y: 0, angular: 0 },
},
{ name: 'without physics it reads 0', inputs: { object: crate }, outputs: { x: 0, y: 0, angular: 0 } },
],
SetGravity: [
{
name: 'sets the scene gravity',
inputs: { x: 0, y: -500 },
context: { physics: physicsDouble() },
next: 'exec',
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['setGravity', 0, -500]]),
},
{ name: 'without physics it continues', inputs: { x: 0, y: 1 }, next: 'exec' },
],
Raycast: [
{
name: 'hands the ray to the context, ignoring the owner, and puts out what it met',
inputs: { fromX: 10, fromY: 20, dirX: 0, dirY: 1, length: 300 },
context: {
raycast: (from, direction, length, options) => {
expect([from, direction, length, options.ignore?.id, options.triggers]).toEqual([
{ x: 10, y: 20 },
{ x: 0, y: 1 },
300,
'owner',
false,
]);
return {
object: crate,
collider: painted,
point: { x: 10, y: 120 },
normal: { x: 0, y: -1 },
distance: 100,
};
},
},
next: 'exec',
outputs: {
hit: true,
object: crate,
collider: painted,
pointX: 10,
pointY: 120,
normalX: 0,
normalY: -1,
distance: 100,
},
},
{
name: 'without "Ignore owner" and with "Hit triggers" it says so; a miss puts out nothing',
inputs: { fromX: 0, fromY: 0, dirX: 1, dirY: 0, length: 50 },
params: { ignoreOwner: false, triggers: true },
context: {
raycast: (_from, _direction, _length, options) => {
expect([options.ignore, options.triggers]).toEqual([null, true]);
return null;
},
},
next: 'exec',
outputs: {
hit: false,
object: null,
collider: null,
pointX: 0,
pointY: 0,
normalX: 0,
normalY: 0,
distance: 0,
},
},
],
RaycastAll: [
{
name: 'puts the hits out as parallel lists, nearest first, and counts them (#157)',
inputs: { fromX: 10, fromY: 20, dirX: 0, dirY: 1, length: 300, maxHits: 2 },
context: {
raycastAll: (from, direction, length, options) => {
expect([from, direction, length, options.ignore?.id, options.triggers, options.maxHits]).toEqual([
{ x: 10, y: 20 },
{ x: 0, y: 1 },
300,
'owner',
false,
2,
]);
return [
{
object: crate,
collider: painted,
point: { x: 10, y: 120 },
normal: { x: 0, y: -1 },
distance: 100,
},
{
object: dancer,
collider: dancer,
point: { x: 10, y: 220 },
normal: { x: 0, y: 1 },
distance: 200,
},
];
},
},
next: 'exec',
outputs: {
count: 2,
// The objects travel as objects, not as their names: that is what a list may carry now.
objects: [crate, dancer],
colliders: [painted, dancer],
pointsX: [10, 10],
pointsY: [120, 220],
normalsX: [0, 0],
normalsY: [-1, 1],
distances: [100, 200],
},
},
{
name: 'a ray that meets nothing puts out empty lists and a count of 0',
inputs: { fromX: 0, fromY: 0, dirX: 1, dirY: 0, length: 50, maxHits: -3 },
params: { ignoreOwner: false, triggers: true },
context: {
raycastAll: (_from, _direction, _length, options) => {
// A negative "max hits" is no limit at all, the same as 0.
expect([options.ignore, options.triggers, options.maxHits]).toEqual([null, true, 0]);
return [];
},
},
next: 'exec',
outputs: {
count: 0,
objects: [],
colliders: [],
pointsX: [],
pointsY: [],
normalsX: [],
normalsY: [],
distances: [],
},
},
],
IgnoreOneWay: [
{
name: 'asks the context to let the object through for the seconds given',
inputs: { object: crate, seconds: 0.4 },
context: { ignoreOneWay: (target, seconds) => expect([target.id, seconds]).toEqual(['crate', 0.4]) },
next: 'exec',
},
{ name: 'no object is a no-op that continues', inputs: { object: null, seconds: 1 }, 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',
},
],
Emit: [
{
name: 'no source is a no-op that still continues the flow',
inputs: { source: null },
next: 'exec',
},
{
name: 'counts a pulse on the source it is given',
inputs: { source: emitTarget },
next: 'exec',
after: () => expect(emitTarget.emits).toBe(1),
},
],
PlayAnimation: [
{
name: 'no object is a no-op that still continues the flow',
inputs: { object: null, animation: 'anim-1' },
next: 'exec',
},
{
name: 'starts a player on the object, and a second play leaves it running',
inputs: { object: dancer, animation: 'anim-1' },
next: 'exec',
after: () => {
expect(dancer.animations).toMatchObject([{ animationId: 'anim-1', time: 0, playing: true }]);
},
},
],
PauseAnimation: [
{
name: 'holds the player where it stands',
inputs: { object: dancer, animation: 'anim-1' },
next: 'exec',
after: () => expect(dancer.animations?.map((p) => p.playing)).toEqual([false]),
},
],
StopAnimation: [
{
name: 'with no animation named ends every player on the object',
inputs: { object: dancer, animation: '' },
next: 'exec',
after: () => expect(dancer.animations).toEqual([]),
},
],
PlaySound: [
{
name: 'no sound is a no-op that still continues the flow',
inputs: { sound: '', volume: 1, loop: '' },
next: 'exec',
after: (ctx) => expect(ctx.played).toEqual([]),
},
{
name: 'hands the id on with the volume and loop it was given',
inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' },
next: 'exec',
// What the two mean – a factor on the asset's volume, an answer that overrules its loop
// flag – is the BehaviorSystem's to resolve, since only it holds the project (see
// `sounds.test.ts`).
after: (ctx) =>
expect(ctx.played).toEqual([{ assetId: 'snd-1', volume: 0.5, loop: true, detached: false }]),
},
{
name: 'detached says the voice is nobody’s, so destroying this object leaves it playing',
inputs: { sound: 'snd-1', volume: 1, loop: '', detached: true },
next: 'exec',
after: (ctx) => expect(ctx.played[0]?.detached).toBe(true),
},
{
name: 'saying nothing about looping leaves the answer to the asset',
inputs: { sound: 'snd-1', volume: 1, loop: '' },
next: 'exec',
after: (ctx) => expect(ctx.played[0]?.loop).toBeUndefined(),
},
{
name: 'off is an answer of its own, not the absence of one',
inputs: { sound: 'snd-1', volume: 1, loop: 'false' },
next: 'exec',
after: (ctx) => expect(ctx.played[0]?.loop).toBe(false),
},
],
LoadScene: [
{
name: 'asks the context for the scene it names',
inputs: { scene: 'sc-1' },
next: 'exec',
after: (ctx) => expect(ctx.loaded).toEqual(['sc-1']),
},
{
name: 'naming no scene asks for nothing, and the flow carries on',
inputs: { scene: '' },
next: 'exec',
after: (ctx) => expect(ctx.loaded).toEqual([]),
},
],
StopSound: [
{
name: 'silences the sound it names',
inputs: { sound: 'snd-1' },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['snd-1']),
},
{
name: 'naming none asks for everything',
inputs: { sound: '' },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['']),
},
],
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',
OnEnable: 'behavior-events.test.ts',
OnUpdate: 'graph.test.ts',
OnDisable: 'behavior-events.test.ts',
OnDestroy: 'behavior-events.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',
OnAnimationFinished: 'animation.test.ts',
OnStateEntered: 'state-machine.test.ts',
OnStateExited: 'state-machine.test.ts',
};
describe('node catalog', () => {
it('covers every built-in node', () => {
const uncovered = BUILT_IN_NODES.filter((def) =>
(def.run ?? def.read) ? !CASES[def.type] : !EVENT_NODES[def.type],
).map((def) => def.type);
expect(uncovered).toEqual([]);
});
it('lists exactly the nodes that neither run nor read as events', () => {
const runless = BUILT_IN_NODES.filter((def) => !def.run && !def.read).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('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);
});
});