Files
feuerwerk/tests/core/node-catalog.test.ts
T
adminandClaude Opus 5.5 f97961c8ff
game-maker / gate (push) Successful in 3m35s
game-maker / suite (push) Failing after 16m36s
Play and stop a song from a behavior in the game #336
A song could only be heard in its tab. Now a game plays it through
Play song and ends it through Stop song, with its tracks as they are
set. A playing pattern or song also no longer undoes the pause of the
toolbar.

- Play song plays from the start of the song and then round the loop
  of its ruler, so an intro is heard once. Without loop it plays once
  to its end. Inputs are song, volume, fade in, loop (on by default)
  and detached (off by default). It gives out playback.
- Stop song stops one song, every song for none, or one playback by
  its number, over its fade out. Stop sound with no sound named stops
  songs too. Stop pattern with no pattern named stops only patterns.
- Songs play side by side, as patterns do. They are owned like a
  sound, so leaving the scene ends one unless it is detached. Pause
  game and the time scale leave the music alone.
- Each playing is heard through a master of its own, silent until its
  first note. The fades move it, and a song's tracks sit inside it on
  buses of their own. They go once the playing and its voices have.
- Notes and clips are struck the same way in the editor and the game
  (strikeNote, playClip). What a sequence needs comes from one place
  (core/music/needs).

Findings:
- Reading the audio clock resumed a suspended context. A playing
  pattern read it every 25 ms and so undid the toolbar's pause. The
  library now leaves a context alone while the toolbar holds it.
- A pattern played once to its end asked the list of playings about
  its voices, and that list asked the pattern again. Once its last
  voice was gone this ran until the stack overflowed. The scheduler of
  a playing now asks only after voices.
- e2e 64 reads the playings through __gm.sound.playbacks() now.

Tests:
- song-play.test.ts (new): 8 tests, node catalog +8 cases
- sound-library.test.ts: 66 tests, 4 new
- e2e 64 (melektaus-atx): 23 of 23 checks passed
- e2e 65 (melektaus-atx): ALL PASSED, 22 checks (+4)
- Gate (melektaus-atx): CHECK EXIT 0, 251 files, 3903 tests
- Counter-checks: the old way of asking after voices, a clock read
  that wakes a held context, and a start after a fade out began each
  fail 1 test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:38:44 +02:00

2518 lines
87 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,
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: 'the easings of an animation key: cubic in',
inputs: { a: 0, b: 8, t: 0.5 },
params: { ease: 'cubic', direction: 'in' },
outputs: { result: 1 },
},
{
name: 'quad out',
inputs: { a: 0, b: 8, t: 0.5 },
params: { ease: 'quad', direction: 'out' },
outputs: { result: 6 },
},
{
name: 'a curve without a direction set eases both ways',
inputs: { a: 0, b: 8, t: 0.25 },
params: { ease: 'cubic' },
outputs: { result: 0.5 },
},
{
name: 'step stays at a until t reaches 1',
inputs: { a: 2, b: 8, t: 0.99 },
params: { ease: 'step' },
outputs: { result: 2 },
},
{
name: 'step at t 1 is b',
inputs: { a: 2, b: 8, t: 1 },
params: { ease: 'step' },
outputs: { result: 8 },
},
{
name: 'a direction says nothing for linear',
inputs: { a: 0, b: 8, t: 0.5 },
params: { ease: 'linear', direction: 'in' },
outputs: { result: 4 },
},
{
name: 'quad in-out, slow at both ends',
inputs: { a: 0, b: 8, t: 0.25 },
params: { ease: 'quad', direction: 'in-out' },
outputs: { result: 1 },
},
{
name: 'quad in-out, half way at the middle',
inputs: { a: 0, b: 8, t: 0.5 },
params: { ease: 'quad', direction: 'in-out' },
outputs: { result: 4 },
},
{
name: 'an unknown easing runs linear',
inputs: { a: 0, b: 8, t: 0.25 },
params: { ease: 'in' },
outputs: { result: 2 },
},
{ 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: 'quad', direction: '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, length: 0, tail: 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), its length and its tail (#324)',
inputs: { sound: 'snd-1', volume: 0.5, loop: 'true' },
next: 'exec',
// The fake context rings a voice on for its number in tenths of a second.
outputs: { voice: 1, length: 0.25, tail: 0.35 },
// 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: {}, tempo: 120 },
]),
},
{
name: 'hands on its tempo for a Delay synced to beats, held to 20 to 400 BPM (#332)',
inputs: { sound: 'snd-1', volume: 1, loop: '', tempo: 1000 },
next: 'exec',
after: (ctx) => expect(ctx.played[0]?.tempo).toBe(400),
},
{
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 }),
},
],
PlayNote: [
{
name: 'no instrument is a no-op that still continues the flow',
inputs: { instrument: '', note: 60 },
next: 'exec',
outputs: { voice: 0 },
after: (ctx) => expect(ctx.notes).toEqual([]),
},
{
name: 'hands the instrument on with its note, velocity, volume and duration, and gives out the voice (#331)',
inputs: {
instrument: 'ins-1',
note: 64,
velocity: 0.5,
volume: 0.8,
duration: 0.25,
fxParams: { cut: 2 },
},
next: 'exec',
outputs: { voice: 1 },
after: (ctx) =>
expect(ctx.notes).toEqual([
{
instrumentId: 'ins-1',
note: 64,
velocity: 0.5,
volume: 0.8,
duration: 0.25,
detached: false,
instrumentParams: { cut: 2 },
tempo: 120,
},
]),
},
{
name: 'hands on its tempo for a Delay synced to beats (#332)',
inputs: { instrument: 'ins-1', note: 60, tempo: 90 },
next: 'exec',
after: (ctx) => expect(ctx.notes[0]?.tempo).toBe(90),
},
],
InstrumentParameterValues: [
{
name: 'bundles its picked parameters as numbers, as for a sound FX',
ports: [{ key: 'cut', kind: 'number' }],
inputs: { cut: '0.5' },
outputs: { params: { cut: 0.5 } },
},
{ name: 'with none picked the bundle is empty', outputs: { params: {} } },
],
PlayPattern: [
{
name: 'no pattern is a no-op that still continues the flow',
inputs: { pattern: '' },
next: 'exec',
outputs: { playback: 0 },
after: (ctx) => expect(ctx.patterns).toEqual([]),
},
{
name: 'hands the pattern on with its volume, looping and owned unless asked otherwise',
inputs: { pattern: 'pat-1', volume: 0.5, loop: true },
next: 'exec',
outputs: { playback: 1 },
after: (ctx) =>
expect(ctx.patterns).toEqual([{ patternId: 'pat-1', volume: 0.5, loop: true, detached: false }]),
},
{
name: 'plays once and detached when asked to',
inputs: { pattern: 'pat-1', loop: false, detached: true },
next: 'exec',
after: (ctx) => expect(ctx.patterns[0]).toMatchObject({ loop: false, detached: true }),
},
],
StopPattern: [
{
name: 'stops the pattern it names, letting its notes ring when asked to',
inputs: { pattern: 'pat-1', letRing: true },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['pattern pat-1 (let ring)']),
},
{
name: 'naming none stops every pattern',
inputs: { pattern: '' },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['pattern ']),
},
{
name: 'with a playback connected stops only that one, whatever pattern is named',
inputs: { pattern: 'pat-1', playback: 4 },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['voice 4']),
},
{
name: 'a connected playback of 0 – a Play pattern that played nothing – stops nothing',
inputs: { pattern: 'pat-1', playback: 0 },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual([]),
},
],
PlaySong: [
{
name: 'no song is a no-op that still continues the flow',
inputs: { song: '' },
next: 'exec',
outputs: { playback: 0 },
after: (ctx) => expect(ctx.songs).toEqual([]),
},
{
name: 'hands the song on with its volume and fade in, looping and owned unless asked otherwise',
inputs: { song: 'song-1', volume: 0.5, fadeIn: 2, loop: true },
next: 'exec',
outputs: { playback: 1 },
after: (ctx) =>
expect(ctx.songs).toEqual([
{ songId: 'song-1', volume: 0.5, fadeIn: 2, loop: true, detached: false },
]),
},
{
name: 'plays once and detached when asked to',
inputs: { song: 'song-1', volume: 1, fadeIn: 0, loop: false, detached: true },
next: 'exec',
after: (ctx) => expect(ctx.songs[0]).toMatchObject({ loop: false, detached: true }),
},
],
StopSong: [
{
name: 'stops the song it names over its fade out',
inputs: { song: 'song-1', fadeOut: 1.5 },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['song song-1 (fade 1.5)']),
},
{
name: 'naming none stops every song',
inputs: { song: '', fadeOut: 0 },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['song (fade 0)']),
},
{
name: 'with a playback connected stops only that one, whatever song is named',
inputs: { song: 'song-1', playback: 4, fadeOut: 2 },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['playback 4 (fade 2)']),
},
{
name: 'a connected playback of 0 – a Play song that played nothing – stops nothing',
inputs: { song: 'song-1', playback: 0 },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual([]),
},
],
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(['']),
},
{
name: 'lets the echoes of the sound ring when asked to (#324)',
inputs: { sound: 'snd-1', letRing: true },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['snd-1 (let ring)']),
},
{
name: 'with a voice connected silences only that one, whatever sound is named',
inputs: { sound: 'snd-1', voice: 3, letRing: true },
next: 'exec',
after: (ctx) => expect(ctx.silenced).toEqual(['voice 3 (let ring)']),
},
{
name: 'a connected voice of 0 – a Play sound that played nothing – silences nothing, not everything',
inputs: { sound: '', voice: 0 },
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 }, time: 0, ease: 'linear' }]),
},
{
name: 'hands on the time the values take to get there',
inputs: { voice: 4, fxParams: { depth: -64 }, time: 1.28 },
next: 'exec',
after: (ctx) =>
expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: -64 }, time: 1.28, ease: 'linear' }]),
},
{
name: 'a time below 0 is a quick glide',
inputs: { voice: 4, fxParams: { depth: 3 }, time: -2 },
next: 'exec',
after: (ctx) =>
expect(ctx.tuned).toEqual([{ voice: 4, fxParams: { depth: 3 }, time: 0, ease: 'linear' }]),
},
{
name: 'hands on the easing the values take: a curve with its direction as a share of the way',
inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 },
params: { ease: 'cubic', direction: 'in' },
next: 'exec',
after: (ctx) => {
const ease = ctx.tuned[0]!.ease;
expect(typeof ease === 'function' && [ease(0), ease(0.5), ease(1)]).toEqual([0, 0.125, 1]);
},
},
{
name: 'step is handed on by name, the clock jumps at the end',
inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 },
params: { ease: 'step' },
next: 'exec',
after: (ctx) => expect(ctx.tuned[0]!.ease).toBe('step'),
},
{
name: 'along a Curve asset, as a share that may overshoot',
inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 },
params: { ease: 'curve', curve: 'c' },
lookup: overshootingCurve,
next: 'exec',
after: (ctx) => {
const ease = ctx.tuned[0]!.ease;
expect(typeof ease === 'function' && ease(0.5)).toBeCloseTo(1.5, 6);
},
},
{
name: 'a Curve asset that is gone is linear',
inputs: { voice: 4, fxParams: { depth: 3 }, time: 1 },
params: { ease: 'curve', curve: 'gone' },
lookup: overshootingCurve,
next: 'exec',
after: (ctx) => expect(ctx.tuned[0]!.ease).toBe('linear'),
},
{
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',
OnFixedUpdate: 'fixed-update.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',
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);
});
});