Adding up the frame's dt in On update makes anything that counts in steps depend on the frame rate. A pitch sweep sounds a little different on every run. On fixed update runs 60 times per second of game time with dt exactly 1/60, so the same graph gives the same result every time. - fixedSteps in src/core/time.ts is the one rule for the fixed step: spend the frame in steps of 1/60, keep the rest, catch up 8 steps at most after a hitch. - The physics now counts its steps with fixedSteps too. It behaves as before, and On fixed update runs exactly as often as the engine steps. - The steps run after On mouse wheel and before On update, each as a kind of its own with the usual Order setting. - Each step gets its own context, so a node inside that reads dt (Time, Move, Apply force) gets 1/60 rather than the frame. - A paused game has no steps, also for a behavior that runs while paused. - The engine still takes all of a frame's steps before the behaviors run. A force set in On fixed update acts from the next frame on. Tests: - fixed-update.test.ts (new): 5 tests. Step counting, uneven frames adding up to 60 steps a second, the hitch cap, two steps at 30 Hz before On update with dt 1/60 for the event and for Time, one step every other frame at 120 Hz, none while paused. - event-order, node-catalog, physics-system, game-pause: 430 tests together with the new file, all green. - Counter-check: with the frame's dt inside a step and without the pause guard both world tests fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2252 lines
79 KiB
TypeScript
2252 lines
79 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 { 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,
|
||
worldPositionOf,
|
||
} 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;
|
||
/** Expected elements to test against a Match; `null` asserts the node searches nothing. */
|
||
find?: RunResult['find'] | 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 curve "c" (#247): straight up to 1.5 at the middle, back down to 1 at the end. */
|
||
const overshootingCurve: GraphLookup = {
|
||
...NO_LOOKUP,
|
||
curve: (id) =>
|
||
id === 'c'
|
||
? [
|
||
{ x: 0, y: 0 },
|
||
{ x: 0.5, y: 1.5 },
|
||
{ x: 1, y: 1 },
|
||
]
|
||
: 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 });
|
||
/** At (100, 0) in the world, below a parent that is moved, turned a quarter and doubled. */
|
||
const nestedMover = {
|
||
...at('mover', 0, 0),
|
||
parent: { ...at('frame', 100, 0), props: { rotation: 90, scale: 2 } },
|
||
};
|
||
/** 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');
|
||
/** An object whose once-reset "swing" has run out and blends back over half a second (#233). */
|
||
const swinging = {
|
||
...fakeEntity('Fighter'),
|
||
animations: [
|
||
{
|
||
animationId: 'swing',
|
||
time: 1,
|
||
playing: true,
|
||
order: 1,
|
||
fade: { seconds: 0.5, elapsed: 0, ended: true },
|
||
},
|
||
],
|
||
};
|
||
const swingLookup: GraphLookup = {
|
||
...NO_LOOKUP,
|
||
animation: (id) => (id === 'swing' ? { frames: 10, fps: 10, playback: 'once-reset' } : null),
|
||
};
|
||
const packed = fakeEntity('Player');
|
||
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]),
|
||
jointStateOf: (target) => (target.id === 'hinge' ? { value: 30, speed: -12 } : null),
|
||
};
|
||
};
|
||
const physicsCalls = (ctx: FakeContext) => (ctx.physics as PhysicsDouble).calls;
|
||
const crate = fakeEntity('crate');
|
||
/** A tilemap of 4 x 2 cells of 10 units, placed at (100, 50). */
|
||
const map = { ...fakeEntity('map', 100), y: 50, props: { columns: 4, rows: 2, tileSize: 10 } };
|
||
|
||
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
|
||
pointer: { ...NO_POINTER, ...overrides },
|
||
});
|
||
|
||
/** Player › (Arm › Hitbox #2), Hitbox #1 – the arm comes first in scene order – and a lone Coin. */
|
||
const hero = fakeEntity('Player');
|
||
const arm = { ...fakeEntity('Arm'), parent: hero };
|
||
const deepBox = { ...fakeEntity('Hitbox'), id: 'hitbox-2', parent: arm };
|
||
const nearBox = { ...fakeEntity('Hitbox'), id: 'hitbox-1', parent: hero };
|
||
const coins = [fakeEntity('Coin', 0, 'coin'), { ...fakeEntity('Coin', 0, 'coin'), id: 'coin-2' }];
|
||
const scene: Partial<NodeContext> = { objects: () => [hero, arm, deepBox, nearBox, ...coins] };
|
||
|
||
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 }, entry: 'exec' },
|
||
next: 'exec',
|
||
},
|
||
{
|
||
name: 'names the flow input the call came in by, so the start node leaves through that one',
|
||
params: { behavior: 'b1' },
|
||
entry: 'open',
|
||
call: { behaviorId: 'b1', inputs: {}, entry: 'open' },
|
||
next: 'exec',
|
||
},
|
||
{
|
||
name: 'without a behavior chosen it still continues, calling nothing that exists',
|
||
call: { behaviorId: '', inputs: {}, entry: 'exec' },
|
||
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({}),
|
||
},
|
||
],
|
||
TextureSize: [
|
||
{
|
||
name: "hands out the picture's own pixel size",
|
||
inputs: { texture: 'coin' },
|
||
lookup: { ...NO_LOOKUP, textureSize: (id) => (id === 'coin' ? { w: 8, h: 6 } : null) },
|
||
outputs: { width: 8, height: 6 },
|
||
},
|
||
{
|
||
name: 'no picture is 0 × 0',
|
||
inputs: { texture: '' },
|
||
outputs: { width: 0, height: 0 },
|
||
},
|
||
],
|
||
AnimationPlaying: [
|
||
{
|
||
name: 'a once-reset pass blending out still runs, for as long as its blend out lasts (#233)',
|
||
inputs: { object: swinging, animation: 'swing' },
|
||
lookup: swingLookup,
|
||
outputs: { playing: true, progress: 1, timeLeft: 0.5 },
|
||
},
|
||
{
|
||
name: 'with no animation named answers for what runs on the object',
|
||
inputs: { object: swinging, animation: '' },
|
||
lookup: swingLookup,
|
||
outputs: { playing: true, progress: 1, timeLeft: 0.5 },
|
||
},
|
||
{
|
||
name: 'another animation is not playing',
|
||
inputs: { object: swinging, animation: 'walk' },
|
||
lookup: swingLookup,
|
||
outputs: { playing: false, progress: 0, timeLeft: 0 },
|
||
},
|
||
{
|
||
name: 'no object plays nothing',
|
||
inputs: { object: null, animation: '' },
|
||
lookup: swingLookup,
|
||
outputs: { playing: false, progress: 0, timeLeft: 0 },
|
||
},
|
||
],
|
||
AnimationLength: [
|
||
{
|
||
name: 'one pass in seconds: ten frames at 10 fps',
|
||
inputs: { animation: 'swing' },
|
||
lookup: swingLookup,
|
||
outputs: { seconds: 1 },
|
||
},
|
||
{ name: 'no animation is 0', inputs: { animation: '' }, outputs: { seconds: 0 } },
|
||
],
|
||
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 },
|
||
},
|
||
],
|
||
|
||
// The rounds, the test and where the flow goes on are the interpreter's: see find-match.test.ts.
|
||
Find: [
|
||
{
|
||
name: 'hands the elements to test and goes on from its Match, not from itself',
|
||
inputs: { list: ['a', 'b'] },
|
||
outputs: { item: null, index: -1 },
|
||
next: null,
|
||
find: ['a', 'b'],
|
||
},
|
||
{ name: 'no list tests nothing', inputs: { list: null }, find: [] },
|
||
],
|
||
|
||
// The rounds themselves and the break are the interpreter's: see "For each" in graph.test.ts.
|
||
ForEach: [
|
||
{
|
||
name: 'asks for one body round per element, handing out the elements as item',
|
||
inputs: { list: ['a', 'b'] },
|
||
outputs: { item: null, index: -1, stopped: false },
|
||
repeat: {
|
||
port: 'body',
|
||
index: 'index',
|
||
times: 2,
|
||
items: { port: 'item', values: ['a', 'b'] },
|
||
stopWhen: 'break',
|
||
stopped: 'stopped',
|
||
exhausted: { item: null, index: -1, stopped: false },
|
||
},
|
||
next: 'done',
|
||
},
|
||
{
|
||
name: 'no list runs nothing',
|
||
inputs: { list: null },
|
||
repeat: {
|
||
port: 'body',
|
||
index: 'index',
|
||
times: 0,
|
||
items: { port: 'item', values: [] },
|
||
stopWhen: 'break',
|
||
stopped: 'stopped',
|
||
exhausted: { item: null, index: -1, stopped: false },
|
||
},
|
||
},
|
||
],
|
||
|
||
// 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 } },
|
||
],
|
||
ChildByName: [
|
||
{
|
||
name: 'asks the context below the object it is given',
|
||
inputs: { object: fakeEntity('Player'), name: 'Hitbox' },
|
||
context: {
|
||
childByName: (parent, name) =>
|
||
parent.name === 'Player' && name === 'Hitbox' ? fakeEntity('Hitbox') : null,
|
||
},
|
||
outputs: { child: fakeEntity('Hitbox') },
|
||
},
|
||
{
|
||
name: 'an unknown name is null',
|
||
inputs: { object: fakeEntity('Player'), name: 'Ghost' },
|
||
outputs: { child: null },
|
||
},
|
||
{ name: 'no object is null', inputs: { object: null, name: 'Hitbox' }, outputs: { child: null } },
|
||
],
|
||
LayerOf: [
|
||
{
|
||
name: 'names the layer the context says the object is on (#211)',
|
||
inputs: { object: fakeEntity('Player') },
|
||
context: { collisionLayerOf: (target) => (target.name === 'Player' ? 1 : null) },
|
||
lookup: { ...NO_LOOKUP, collisionLayers: () => ['Ground', 'Ghost'] },
|
||
outputs: { name: 'Ghost', number: 1 },
|
||
},
|
||
{
|
||
name: 'a layer the project has not named goes by its number',
|
||
inputs: { object: fakeEntity('Player') },
|
||
context: { collisionLayerOf: () => 4 },
|
||
lookup: { ...NO_LOOKUP, collisionLayers: () => ['Ground'] },
|
||
outputs: { name: 'Layer 4', number: 4 },
|
||
},
|
||
{
|
||
name: 'an object without a collider is on no layer',
|
||
inputs: { object: fakeEntity('Player') },
|
||
outputs: { name: '', number: -1 },
|
||
},
|
||
{ name: 'no object is on no layer', inputs: { object: null }, outputs: { name: '', number: -1 } },
|
||
],
|
||
RenderLayerOf: [
|
||
{
|
||
name: 'names the render layer the context says the object is drawn on (#305)',
|
||
inputs: { object: fakeEntity('Coin') },
|
||
context: { renderLayerOf: (target) => (target.name === 'Coin' ? 1 : null) },
|
||
lookup: { ...NO_LOOKUP, renderLayers: () => ['Default', 'UI'] },
|
||
outputs: { name: 'UI', number: 1 },
|
||
},
|
||
{
|
||
name: 'a render layer the project has not named goes by its number',
|
||
inputs: { object: fakeEntity('Coin') },
|
||
context: { renderLayerOf: () => 3 },
|
||
lookup: { ...NO_LOOKUP, renderLayers: () => ['Default'] },
|
||
outputs: { name: 'Layer 3', number: 3 },
|
||
},
|
||
{
|
||
name: 'an object that is not drawn is on no render layer',
|
||
inputs: { object: fakeEntity('Cam') },
|
||
outputs: { name: '', number: -1 },
|
||
},
|
||
{ name: 'no object is on no render layer', inputs: { object: null }, outputs: { name: '', number: -1 } },
|
||
],
|
||
ObjectsByName: [
|
||
{
|
||
name: 'every object so named, in scene order',
|
||
inputs: { name: 'Hitbox' },
|
||
context: scene,
|
||
outputs: { objects: [deepBox, nearBox] },
|
||
},
|
||
{
|
||
name: 'none so named is the empty list',
|
||
inputs: { name: 'Ghost' },
|
||
context: scene,
|
||
outputs: { objects: [] },
|
||
},
|
||
],
|
||
ChildrenByName: [
|
||
{
|
||
name: 'every one below, the nearer level first',
|
||
inputs: { object: hero, name: 'Hitbox' },
|
||
context: scene,
|
||
outputs: { children: [nearBox, deepBox] },
|
||
},
|
||
{
|
||
name: 'nothing above or beside the object',
|
||
inputs: { object: arm, name: 'Hitbox' },
|
||
context: scene,
|
||
outputs: { children: [deepBox] },
|
||
},
|
||
{
|
||
name: 'no object is the empty list',
|
||
inputs: { object: null, name: 'Hitbox' },
|
||
context: scene,
|
||
outputs: { children: [] },
|
||
},
|
||
],
|
||
Children: [
|
||
{
|
||
name: 'the direct children only',
|
||
inputs: { object: hero, recursive: false },
|
||
context: scene,
|
||
outputs: { children: [arm, nearBox] },
|
||
},
|
||
{
|
||
name: 'recursive: level by level',
|
||
inputs: { object: hero, recursive: true },
|
||
context: scene,
|
||
outputs: { children: [arm, nearBox, deepBox] },
|
||
},
|
||
{
|
||
name: 'a leaf has none',
|
||
inputs: { object: deepBox, recursive: true },
|
||
context: scene,
|
||
outputs: { children: [] },
|
||
},
|
||
{ name: 'no object has none', inputs: { object: null }, context: scene, outputs: { children: [] } },
|
||
],
|
||
ObjectsOfAsset: [
|
||
{
|
||
name: 'every instance of the asset',
|
||
inputs: { asset: 'coin' },
|
||
context: scene,
|
||
outputs: { objects: coins },
|
||
},
|
||
{
|
||
name: 'no asset chosen is the empty list',
|
||
inputs: { asset: '' },
|
||
context: scene,
|
||
outputs: { objects: [] },
|
||
},
|
||
],
|
||
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'), asset: 'coin' },
|
||
outputs: { result: true },
|
||
},
|
||
{
|
||
name: 'another asset does not match',
|
||
inputs: { object: fakeEntity('Coin', 0, 'coin'), asset: 'gem' },
|
||
outputs: { result: false },
|
||
},
|
||
{
|
||
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: 'a top-level object is at its own position',
|
||
inputs: { object: at('A', 10, 20) },
|
||
outputs: { x: 10, y: 20 },
|
||
},
|
||
{
|
||
name: 'a child reports its position local to the parent',
|
||
inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } },
|
||
outputs: { x: 10, y: 20 },
|
||
},
|
||
{ name: 'no object is the origin', inputs: { object: null }, outputs: { x: 0, y: 0 } },
|
||
],
|
||
GetWorldPosition: [
|
||
{
|
||
name: 'a top-level object is at its own position',
|
||
inputs: { object: at('A', 10, 20) },
|
||
outputs: { x: 10, y: 20 },
|
||
},
|
||
{
|
||
name: 'a child is at the summed position of its parents',
|
||
inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } },
|
||
outputs: { x: 110, y: 220 },
|
||
},
|
||
{ name: 'no object is the origin', inputs: { object: null }, outputs: { x: 0, y: 0 } },
|
||
],
|
||
GetZ: [
|
||
{
|
||
name: 'a child reports its own z and the z summed up with its parents',
|
||
inputs: { object: { ...at('child', 0, 0), z: 2, parent: { ...at('parent', 0, 0), z: 5 } } },
|
||
outputs: { z: 2, worldZ: 7 },
|
||
},
|
||
{ name: 'no object is 0', inputs: { object: null }, outputs: { z: 0, worldZ: 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: '' } },
|
||
],
|
||
Asset: [
|
||
{ name: 'outputs the id it was set to', params: { value: 'crate' }, outputs: { value: 'crate' } },
|
||
{ name: 'unset is no asset', 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 } },
|
||
],
|
||
Lerp: [
|
||
{ name: 'linear by default', inputs: { a: 10, b: 20, t: 0.25 }, outputs: { result: 12.5 } },
|
||
{ name: 'ease in', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'in' }, outputs: { result: 2 } },
|
||
{ name: 'ease out', inputs: { a: 0, b: 8, t: 0.5 }, params: { ease: 'out' }, outputs: { result: 6 } },
|
||
{
|
||
name: 'ease in-out, slow at both ends',
|
||
inputs: { a: 0, b: 8, t: 0.25 },
|
||
params: { ease: 'inOut' },
|
||
outputs: { result: 1 },
|
||
},
|
||
{
|
||
name: 'ease in-out, half way at the middle',
|
||
inputs: { a: 0, b: 8, t: 0.5 },
|
||
params: { ease: 'inOut' },
|
||
outputs: { result: 4 },
|
||
},
|
||
{ name: 't past 1 stops at b', inputs: { a: 3, b: -5, t: 1.2 }, outputs: { result: -5 } },
|
||
{
|
||
name: 't below 0 stops at a',
|
||
inputs: { a: 3, b: -5, t: -0.5 },
|
||
params: { ease: 'out' },
|
||
outputs: { result: 3 },
|
||
},
|
||
{
|
||
name: 'along a Curve asset, overshooting b',
|
||
inputs: { a: 0, b: 8, t: 0.5 },
|
||
params: { ease: 'curve', curve: 'c' },
|
||
lookup: overshootingCurve,
|
||
outputs: { result: 12 },
|
||
},
|
||
{
|
||
name: 'along a Curve asset that is gone: linear',
|
||
inputs: { a: 0, b: 8, t: 0.25 },
|
||
params: { ease: 'curve', curve: 'gone' },
|
||
lookup: overshootingCurve,
|
||
outputs: { result: 2 },
|
||
},
|
||
],
|
||
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: [] } },
|
||
{
|
||
name: 'with a type, hands out the items without the mark they carry in the node (#271)',
|
||
params: { structType: 'loot', value: [{ Name: 'Gem', $struct: 'loot' }] },
|
||
outputs: { value: [{ Name: 'Gem' }] },
|
||
},
|
||
],
|
||
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] },
|
||
},
|
||
],
|
||
AddItem: [
|
||
{ name: 'appends by default', inputs: { list: [1, 2], value: 3 }, outputs: { result: [1, 2, 3] } },
|
||
{
|
||
name: 'inserts before index',
|
||
inputs: { list: [1, 2, 3], value: 9, index: 1 },
|
||
outputs: { result: [1, 9, 2, 3] },
|
||
},
|
||
{
|
||
name: 'index equal to the length appends',
|
||
inputs: { list: [1, 2], value: 9, index: 2 },
|
||
outputs: { result: [1, 2, 9] },
|
||
},
|
||
{
|
||
name: 'an index outside the list appends instead of dropping the value',
|
||
inputs: { list: [1, 2], value: 9, index: 7 },
|
||
outputs: { result: [1, 2, 9] },
|
||
},
|
||
{
|
||
name: 'leaves the list it was given untouched – a graph may hold it elsewhere',
|
||
inputs: { list: Object.freeze([1, 2]), value: 3 },
|
||
outputs: { result: [1, 2, 3] },
|
||
},
|
||
{
|
||
name: 'a list goes in as one item, not joined',
|
||
inputs: { list: [[1]], value: [2, 3] },
|
||
outputs: { result: [[1], [2, 3]] },
|
||
},
|
||
{ name: 'an unset list starts empty', inputs: { value: 5 }, outputs: { result: [5] } },
|
||
],
|
||
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 packs as the object it is, so a struct can hold one',
|
||
inputs: { who: packed },
|
||
outputs: { value: { who: packed } as never },
|
||
},
|
||
],
|
||
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 } },
|
||
{
|
||
name: 'two numbers ignore float noise',
|
||
inputs: { a: 5.999999999999972, b: 6 },
|
||
outputs: { result: true },
|
||
},
|
||
{ name: 'two numbers still tell 6.001 from 6', inputs: { a: 6.001, b: 6 }, outputs: { result: false } },
|
||
// The inline field of an `any` pin is text: a typed 130 against time - late at 130 s.
|
||
{
|
||
name: 'a noisy number equals the typed text 130',
|
||
inputs: { a: 129.99999999999997, b: '130' },
|
||
outputs: { result: true },
|
||
},
|
||
{ name: 'empty text is not 0', inputs: { a: 0, b: '' }, outputs: { result: false } },
|
||
{ name: 'two texts still compare as text', inputs: { a: '1.0', b: '1' }, 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 } },
|
||
// time - late after 6 s of summed dt (#224): the noise counts as equal under every operator.
|
||
{
|
||
name: '== ignores float noise',
|
||
inputs: { a: 5.999999999999972, b: 6 },
|
||
params: { op: '==' },
|
||
outputs: { result: true },
|
||
},
|
||
{
|
||
name: '!= ignores float noise',
|
||
inputs: { a: 5.999999999999972, b: 6 },
|
||
params: { op: '!=' },
|
||
outputs: { result: false },
|
||
},
|
||
{
|
||
name: '< ignores float noise',
|
||
inputs: { a: 5.999999999999972, b: 6 },
|
||
params: { op: '<' },
|
||
outputs: { result: false },
|
||
},
|
||
{
|
||
name: '>= ignores float noise',
|
||
inputs: { a: 5.999999999999972, b: 6 },
|
||
params: { op: '>=' },
|
||
outputs: { result: true },
|
||
},
|
||
{
|
||
name: '> ignores float noise',
|
||
inputs: { a: 6.000000000000028, b: 6 },
|
||
params: { op: '>' },
|
||
outputs: { result: false },
|
||
},
|
||
{
|
||
name: '<= ignores float noise',
|
||
inputs: { a: 6.000000000000028, b: 6 },
|
||
params: { op: '<=' },
|
||
outputs: { result: true },
|
||
},
|
||
{
|
||
name: 'the tolerance grows with the values',
|
||
inputs: { a: 1e6, b: 1e6 + 1e-4 },
|
||
params: { op: '==' },
|
||
outputs: { result: true },
|
||
},
|
||
{
|
||
name: 'a real difference stays one',
|
||
inputs: { a: 6.001, b: 6 },
|
||
params: { op: '==' },
|
||
outputs: { result: false },
|
||
},
|
||
{
|
||
name: 'a tiny value still differs from 0',
|
||
inputs: { a: 1e-6, b: 0 },
|
||
params: { op: '>' },
|
||
outputs: { result: true },
|
||
},
|
||
{
|
||
name: 'no operator chosen falls back to the default "<"',
|
||
inputs: { a: 1, b: 2 },
|
||
outputs: { result: true },
|
||
},
|
||
],
|
||
FirstNotEmpty: [
|
||
{
|
||
name: 'a is taken when it has a value',
|
||
inputs: { a: 'Hero', b: 'Nobody' },
|
||
outputs: { value: 'Hero' },
|
||
},
|
||
{ name: 'an unconnected a gives b', inputs: { b: 5 }, outputs: { value: 5 } },
|
||
{ name: '0 counts as empty', inputs: { a: 0, b: 5 }, outputs: { value: 5 } },
|
||
{ name: 'empty text counts as empty', inputs: { a: '', b: 'x' }, outputs: { value: 'x' } },
|
||
{ name: 'false counts as empty', inputs: { a: false, b: true }, outputs: { value: true } },
|
||
{ name: 'an empty list counts as empty', inputs: { a: [], b: [1] }, outputs: { value: [1] } },
|
||
{ name: 'a position (0, 0) counts as empty', inputs: { a: { x: 0, y: 0 }, b: 3 }, outputs: { value: 3 } },
|
||
{
|
||
name: 'a struct with one value set is kept',
|
||
inputs: { a: { x: 0, y: 2 }, b: 3 },
|
||
outputs: { value: { x: 0, y: 2 } },
|
||
},
|
||
{
|
||
name: 'an object is never empty',
|
||
inputs: { a: fakeEntity('Coin'), b: 1 },
|
||
outputs: { value: fakeEntity('Coin') },
|
||
},
|
||
{ name: 'both empty gives b', inputs: { a: '', b: 0 }, outputs: { value: 0 } },
|
||
{
|
||
name: '"nothing there" keeps a 0',
|
||
inputs: { a: 0, b: 5 },
|
||
params: { empty: 'nothing' },
|
||
outputs: { value: 0 },
|
||
},
|
||
{
|
||
name: '"nothing there" keeps a false',
|
||
inputs: { a: false, b: true },
|
||
params: { empty: 'nothing' },
|
||
outputs: { value: false },
|
||
},
|
||
{
|
||
name: '"nothing there" still falls back on empty text',
|
||
inputs: { a: '', b: 'x' },
|
||
params: { empty: 'nothing' },
|
||
outputs: { value: 'x' },
|
||
},
|
||
{
|
||
name: '"nothing there" still falls back on an empty list',
|
||
inputs: { a: [], b: [1] },
|
||
params: { empty: 'nothing' },
|
||
outputs: { value: [1] },
|
||
},
|
||
{
|
||
name: '"nothing there" keeps a position (0, 0)',
|
||
inputs: { a: { x: 0, y: 0 }, b: 3 },
|
||
params: { empty: 'nothing' },
|
||
outputs: { value: { x: 0, y: 0 } },
|
||
},
|
||
{
|
||
name: '"only a missing value" keeps an empty text',
|
||
inputs: { a: '', b: 'x' },
|
||
params: { empty: 'null' },
|
||
outputs: { value: '' },
|
||
},
|
||
{
|
||
name: '"only a missing value" keeps an empty list',
|
||
inputs: { a: [], b: [1] },
|
||
params: { empty: 'null' },
|
||
outputs: { value: [] },
|
||
},
|
||
{
|
||
name: '"only a missing value" still falls back on an unconnected a',
|
||
inputs: { b: 5 },
|
||
params: { empty: 'null' },
|
||
outputs: { value: 5 },
|
||
},
|
||
{
|
||
name: 'an unknown setting behaves like the default',
|
||
inputs: { a: 0, b: 5 },
|
||
params: { empty: 'nonsense' },
|
||
outputs: { value: 5 },
|
||
},
|
||
],
|
||
Select: [
|
||
{
|
||
name: 'true takes the true input',
|
||
inputs: { condition: true, false: 'Idle', true: 'Walk' },
|
||
outputs: { value: 'Walk' },
|
||
},
|
||
{
|
||
name: 'false takes the false input',
|
||
inputs: { condition: false, false: 'Idle', true: 'Walk' },
|
||
outputs: { value: 'Idle' },
|
||
},
|
||
{ name: 'an unconnected condition is false', inputs: { false: 1, true: 2 }, outputs: { value: 1 } },
|
||
{
|
||
name: 'an empty picked input gives null',
|
||
inputs: { condition: true, false: 1 },
|
||
outputs: { value: null },
|
||
},
|
||
{
|
||
name: 'a value is passed on as it is, even an empty one',
|
||
inputs: { condition: false, false: 0, true: 5 },
|
||
outputs: { value: 0 },
|
||
},
|
||
],
|
||
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: 'measures in world units, also below a parent',
|
||
inputs: { a: { ...at('a', 3, 4), parent: at('p', 100, 0) }, b: at('b', 100, 0) },
|
||
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 } },
|
||
],
|
||
KeyWasPressed: [
|
||
{
|
||
name: 'asks the context for the press of the chosen key',
|
||
params: { key: 'a' },
|
||
context: { wasKeyPressed: (k) => k === 'a', wasKeyReleased: () => true },
|
||
outputs: { pressed: true },
|
||
},
|
||
{
|
||
name: 'another key was not pressed, and a held one is not a press',
|
||
params: { key: 'b' },
|
||
context: { wasKeyPressed: (k) => k === 'a', isKeyDown: () => true },
|
||
outputs: { pressed: false },
|
||
},
|
||
],
|
||
KeyWasReleased: [
|
||
{
|
||
name: 'asks the context for the release of the chosen key',
|
||
params: { key: 'a' },
|
||
context: { wasKeyReleased: (k) => k === 'a', wasKeyPressed: () => false },
|
||
outputs: { released: true },
|
||
},
|
||
{
|
||
name: 'another key was not released',
|
||
params: { key: 'b' },
|
||
context: { wasKeyReleased: (k) => k === 'a', wasKeyPressed: () => true },
|
||
outputs: { released: false },
|
||
},
|
||
],
|
||
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 },
|
||
},
|
||
{
|
||
name: 'through the camera given, where that camera sees the mouse',
|
||
inputs: { camera: fakeEntity('Ui camera') },
|
||
context: {
|
||
...pointer({ x: 100, y: 50, inside: true }),
|
||
pointerThrough: (camera) =>
|
||
camera.id === 'Ui camera' ? { x: 1050, y: 25, inside: false } : { x: 0, y: 0, inside: true },
|
||
},
|
||
outputs: { x: 1050, y: 25, 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 },
|
||
},
|
||
{
|
||
name: 'through the camera given, the test is where that camera sees the mouse',
|
||
inputs: { object: fakeEntity('Button'), camera: fakeEntity('Ui camera') },
|
||
context: {
|
||
...pointer({ x: 100, y: 50, inside: true }),
|
||
pointerThrough: () => ({ x: 1050, y: 25, inside: true }),
|
||
hitTest: (_, x, y) => x === 1050 && y === 25,
|
||
},
|
||
outputs: { result: true },
|
||
},
|
||
{
|
||
name: 'off the picture of the camera given, nothing is hovered',
|
||
inputs: { object: fakeEntity('Button'), camera: fakeEntity('Ui camera') },
|
||
context: {
|
||
...pointer({ x: 1050, y: 25, inside: true }),
|
||
pointerThrough: () => ({ x: 1050, y: 25, inside: false }),
|
||
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 },
|
||
},
|
||
],
|
||
MoveToward: [
|
||
{
|
||
name: 'steps speed × dt toward the point',
|
||
inputs: { object: at('m', 0, 0), x: 10, y: 0, speed: 4, dt: 0.5 },
|
||
next: 'exec',
|
||
outputs: { arrived: false, distance: 8, hit: false },
|
||
},
|
||
{
|
||
name: 'stops on the point instead of running past it',
|
||
inputs: { object: at('m', 0, 0), x: 3, y: 4, speed: 100, dt: 1 },
|
||
next: 'exec',
|
||
outputs: { arrived: true, distance: 0, hit: false },
|
||
},
|
||
{
|
||
name: 'counts the point in world units under a moved, turned parent',
|
||
inputs: { object: nestedMover, x: 100, y: 10, speed: 100, dt: 1 },
|
||
next: 'exec',
|
||
after: () => {
|
||
const world = worldPositionOf(nestedMover);
|
||
expect(world.x).toBeCloseTo(100, 9);
|
||
expect(world.y).toBeCloseTo(10, 9);
|
||
},
|
||
},
|
||
{
|
||
name: 'with collision a solid in the way holds it back and says so',
|
||
inputs: { object: at('m', 0, 0), x: 10, y: 0, speed: 100, dt: 1 },
|
||
params: { collide: true },
|
||
context: {
|
||
moveAndSlide: () => ({ blockedX: true, blockedY: false, hit: true, normal: { x: -1, y: 0 } }),
|
||
},
|
||
next: 'exec',
|
||
outputs: { arrived: false, distance: 10, hit: true },
|
||
},
|
||
{
|
||
name: 'no object is a no-op that still continues the flow',
|
||
inputs: { object: null, x: 1, y: 1 },
|
||
next: 'exec',
|
||
outputs: { arrived: false, distance: 0, hit: 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' },
|
||
],
|
||
SetZ: [
|
||
{ name: 'sets the z and continues', inputs: { object: at('z', 0, 0), z: 3 }, next: 'exec' },
|
||
{ name: 'no object is a no-op', inputs: { object: null, z: 3 }, 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 } },
|
||
],
|
||
JointPosition: [
|
||
{
|
||
name: 'reads how far the joint stands from where it was placed, and how fast',
|
||
inputs: { joint: fakeEntity('hinge') },
|
||
context: { physics: physicsDouble() },
|
||
outputs: { value: 30, speed: -12 },
|
||
},
|
||
{
|
||
name: 'a joint the engine does not read reads 0',
|
||
inputs: { joint: fakeEntity('rope') },
|
||
context: { physics: physicsDouble() },
|
||
outputs: { value: 0, speed: 0 },
|
||
},
|
||
{
|
||
name: 'without physics it reads 0',
|
||
inputs: { joint: fakeEntity('hinge') },
|
||
outputs: { value: 0, speed: 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' },
|
||
],
|
||
TileAt: [
|
||
{
|
||
name: 'asks the context for the cell, floored to whole cells, and says what lies there (#135)',
|
||
inputs: { tilemap: map, layer: 'Ground', cellX: 2.7, cellY: 1 },
|
||
context: {
|
||
tileAt: (target, layer, cell) => {
|
||
expect([target.id, layer, cell]).toEqual(['map', 'Ground', { x: 2, y: 1 }]);
|
||
return 'grass';
|
||
},
|
||
},
|
||
outputs: { tile: 'grass', empty: false },
|
||
},
|
||
{
|
||
name: 'an empty cell, and one the context cannot answer, both read as empty',
|
||
inputs: { tilemap: map, layer: '', cellX: 0, cellY: 0 },
|
||
context: { tileAt: () => null },
|
||
outputs: { tile: '', empty: true },
|
||
},
|
||
{ name: 'no tilemap is an empty cell', inputs: { tilemap: null }, outputs: { tile: '', empty: true } },
|
||
],
|
||
SetTile: [
|
||
{
|
||
name: 'hands the cell and the tile to the context and says whether it took them',
|
||
inputs: { tilemap: map, layer: 'Ground', cellX: 3, cellY: 4.5, tile: 'door' },
|
||
context: {
|
||
setTile: (target, layer, cell, tile) => {
|
||
expect([target.id, layer, cell, tile]).toEqual(['map', 'Ground', { x: 3, y: 4 }, 'door']);
|
||
return true;
|
||
},
|
||
},
|
||
next: 'exec',
|
||
outputs: { done: true },
|
||
},
|
||
{
|
||
name: 'no tilemap sets nothing and continues',
|
||
inputs: { tilemap: null },
|
||
next: 'exec',
|
||
outputs: { done: false },
|
||
},
|
||
],
|
||
WorldToCell: [
|
||
{
|
||
name: 'finds the cell under a world point of a moved map, and whether it is on the grid',
|
||
// 4 x 2 cells of 10 around (100, 50): the grid runs from (80, 40) to (120, 60).
|
||
inputs: { tilemap: map, x: 95, y: 55 },
|
||
context: { isTilemap: () => true },
|
||
outputs: { cellX: 1, cellY: 1, inside: true },
|
||
},
|
||
{
|
||
name: 'a point off the grid still names a cell, but not an inside one',
|
||
inputs: { tilemap: map, x: 75, y: 45 },
|
||
context: { isTilemap: () => true },
|
||
outputs: { cellX: -1, cellY: 0, inside: false },
|
||
},
|
||
{
|
||
name: 'something that is no tilemap has no cells',
|
||
inputs: { tilemap: map, x: 95, y: 55 },
|
||
outputs: { cellX: 0, cellY: 0, inside: false },
|
||
},
|
||
],
|
||
CellToWorld: [
|
||
{
|
||
name: 'puts out the middle of the cell in world units',
|
||
inputs: { tilemap: map, cellX: 1, cellY: 1 },
|
||
context: { isTilemap: () => true },
|
||
outputs: { x: 95, y: 55 },
|
||
},
|
||
{ name: 'no tilemap answers the origin', inputs: { tilemap: null, cellX: 1 }, outputs: { x: 0, y: 0 } },
|
||
],
|
||
Tile: [{ name: 'hands out the tile chosen', params: { value: 'door' }, outputs: { value: 'door' } }],
|
||
Spawn: [
|
||
{
|
||
name: 'places the asset that arrives on its input, local to the parent',
|
||
inputs: { asset: 'crate', x: 1, y: 2, z: 3, parent: fakeEntity('Box') },
|
||
context: {
|
||
spawn: (assetId, position, parent) =>
|
||
assetId === 'crate' &&
|
||
position.x === 1 &&
|
||
position.y === 2 &&
|
||
position.z === 3 &&
|
||
parent?.name === 'Box'
|
||
? fakeEntity('Crate')
|
||
: null,
|
||
},
|
||
next: 'exec',
|
||
outputs: { object: fakeEntity('Crate') },
|
||
},
|
||
{
|
||
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']),
|
||
},
|
||
],
|
||
AddJoint: [
|
||
{
|
||
name: 'places the picked joint on the object at (x, y), connected to the other',
|
||
inputs: { jointAsset: 'rope', object: fakeEntity('Box'), connected: fakeEntity('Hook'), x: 4, y: -6 },
|
||
context: {
|
||
addJoint: (assetId, owner, connected, at) =>
|
||
assetId === 'rope' &&
|
||
owner.name === 'Box' &&
|
||
connected?.name === 'Hook' &&
|
||
at.x === 4 &&
|
||
at.y === -6
|
||
? fakeEntity('Rope')
|
||
: null,
|
||
},
|
||
next: 'exec',
|
||
outputs: { joint: fakeEntity('Rope') },
|
||
},
|
||
{
|
||
name: 'hands the scene on as null when nothing is connected',
|
||
inputs: { jointAsset: 'rope', object: fakeEntity('Box'), connected: null },
|
||
context: { addJoint: (_asset, _owner, connected) => (connected === null ? fakeEntity('Rope') : null) },
|
||
next: 'exec',
|
||
outputs: { joint: fakeEntity('Rope') },
|
||
},
|
||
{
|
||
name: 'without a picked joint places nothing and says so',
|
||
inputs: { jointAsset: '', object: fakeEntity('Box') },
|
||
next: 'exec',
|
||
outputs: { joint: null },
|
||
after: (ctx) => expect(ctx.logs).toEqual(['Add joint: no joint selected']),
|
||
},
|
||
{
|
||
name: 'an asset that is no joint places nothing and says so',
|
||
inputs: { jointAsset: 'crate', object: fakeEntity('Box') },
|
||
next: 'exec',
|
||
outputs: { joint: null },
|
||
after: (ctx) => expect(ctx.logs).toEqual(['Add joint: asset crate is no joint']),
|
||
},
|
||
],
|
||
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',
|
||
outputs: { voice: 0 },
|
||
after: (ctx) => expect(ctx.played).toEqual([]),
|
||
},
|
||
{
|
||
name: 'hands the id on with the volume and loop it was given, and gives out the voice (#315)',
|
||
inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' },
|
||
next: 'exec',
|
||
outputs: { voice: 1 },
|
||
// 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, fx: '', fxParams: {} },
|
||
]),
|
||
},
|
||
{
|
||
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),
|
||
},
|
||
{
|
||
name: 'hands on the sound FX it names (#311)',
|
||
inputs: { sound: 'snd-1', volume: 1, loop: '', fx: 'fx-1' },
|
||
next: 'exec',
|
||
after: (ctx) => expect(ctx.played[0]?.fx).toBe('fx-1'),
|
||
},
|
||
{
|
||
name: 'hands on the numbers of its fx params, and nothing for what is no bundle',
|
||
inputs: { sound: 'snd-1', volume: 1, loop: '', fx: 'fx-1', fxParams: { depth: -12, name: 'x' } },
|
||
next: 'exec',
|
||
after: (ctx) => expect(ctx.played[0]?.fxParams).toEqual({ depth: -12 }),
|
||
},
|
||
],
|
||
SoundFxParameters: [
|
||
{
|
||
name: 'bundles its picked parameters as numbers',
|
||
ports: [
|
||
{ key: 'depth', kind: 'number' },
|
||
{ key: 'loud', kind: 'number' },
|
||
],
|
||
inputs: { depth: '-12', loud: 3 },
|
||
outputs: { params: { depth: -12, loud: 3 } },
|
||
},
|
||
{ name: 'with none picked the bundle is empty', outputs: { params: {} } },
|
||
],
|
||
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(['']),
|
||
},
|
||
],
|
||
SetSoundFxParameters: [
|
||
{
|
||
name: 'hands the numbers of the bundle to the voice it is given',
|
||
inputs: { voice: 4, fxParams: { depth: 3, label: 'x' } },
|
||
next: 'exec',
|
||
after: (ctx) => expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: 3 } }]),
|
||
},
|
||
{
|
||
name: 'voice 0 – nothing was played – reaches nothing, and the flow carries on',
|
||
inputs: { voice: 0, fxParams: { depth: 3 } },
|
||
next: 'exec',
|
||
after: (ctx) => expect(ctx.tuned).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 }],
|
||
PauseGame: [
|
||
{ name: 'pauses the game and flows on', next: 'exec', after: (ctx) => expect(ctx.paused).toBe(true) },
|
||
],
|
||
ResumeGame: [
|
||
{
|
||
name: 'lets a paused game run on and flows on',
|
||
context: { paused: true } as Partial<NodeContext>,
|
||
next: 'exec',
|
||
after: (ctx) => expect(ctx.paused).toBe(false),
|
||
},
|
||
],
|
||
IsGamePaused: [
|
||
{ name: 'false while the game runs', outputs: { paused: false } },
|
||
{
|
||
name: 'true while it is paused',
|
||
context: { paused: true } as Partial<NodeContext>,
|
||
outputs: { paused: true },
|
||
},
|
||
],
|
||
SetTimeScale: [
|
||
{
|
||
name: 'sets the scale and flows on',
|
||
inputs: { scale: 0.5 },
|
||
next: 'exec',
|
||
after: (ctx) => expect(ctx.scale).toBe(0.5),
|
||
},
|
||
{
|
||
name: '0 freezes the game',
|
||
inputs: { scale: 0 },
|
||
next: 'exec',
|
||
after: (ctx) => expect(ctx.scale).toBe(0),
|
||
},
|
||
{
|
||
name: 'a negative scale is 0 – time never runs backwards',
|
||
inputs: { scale: -2 },
|
||
after: (ctx) => expect(ctx.scale).toBe(0),
|
||
},
|
||
],
|
||
TimeScale: [
|
||
{
|
||
name: 'answers the scale the run goes at',
|
||
context: { scale: 0.25 } as Partial<NodeContext>,
|
||
outputs: { scale: 0.25 },
|
||
},
|
||
],
|
||
};
|
||
|
||
/** 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',
|
||
OnBehavior: 'on-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',
|
||
OnMouseWheel: 'clip-areas.test.ts',
|
||
OnAnimationFinished: 'animation.test.ts',
|
||
OnFixedUpdate: 'fixed-update.test.ts',
|
||
OnStateEntered: 'state-machine.test.ts',
|
||
OnStateExited: 'state-machine.test.ts',
|
||
};
|
||
|
||
/** A node that ends the block another one opens never runs itself – the opener runs it (`Match`). */
|
||
const closesBlock = (type: string) => BUILT_IN_NODES.some((def) => def.opens?.type === type);
|
||
|
||
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] && !closesBlock(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 && !closesBlock(def.type)).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);
|
||
if (c.find !== undefined) expect(result.find ?? null).toEqual(c.find);
|
||
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('SetZ sets only the z', () => {
|
||
const target = { ...at('z', 10, 20), z: 1 };
|
||
runDef('SetZ', fakeContext(), { object: target, z: -4 }, {});
|
||
expect({ x: target.x, y: target.y, z: target.z }).toEqual({ x: 10, y: 20, z: -4 });
|
||
});
|
||
|
||
it('Destroy hands the object to the context, children and all unless told to keep them', () => {
|
||
const removed: string[] = [];
|
||
const moved = [fakeEntity('Lid'), fakeEntity('Nail')];
|
||
const ctx = fakeContext({
|
||
destroy: (target, keepChildren) => {
|
||
removed.push(`${target.name} ${keepChildren}`);
|
||
return keepChildren ? moved : [];
|
||
},
|
||
});
|
||
expect(runDef('Destroy', ctx, { object: fakeEntity('Coin') }, {}).outputs).toEqual({ children: [] });
|
||
const kept = runDef('Destroy', ctx, { object: fakeEntity('Crate'), keepChildren: true }, {});
|
||
expect(kept.outputs).toEqual({ children: moved });
|
||
expect(removed).toEqual(['Coin false', 'Crate true']);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|