Add ESLint and Prettier, and close the node test gaps
Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f140c2fd77
commit
d54a620ebd
113 files changed
+5049
-666
No files matched your search
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* One table covering every built-in node that has a `run`, plus a guard that keeps it that way:
|
||||
* adding a node without a case here fails `covers every built-in node`. Nodes without `run`
|
||||
* (the events) are seeded by the interpreter, so they are listed with the test that drives them
|
||||
* and checked in tests/core/behavior-events.test.ts.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph';
|
||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER } from '../helpers';
|
||||
|
||||
interface Case {
|
||||
/** What the case demonstrates – becomes the failure message. */
|
||||
name: string;
|
||||
inputs?: Record<string, Value>;
|
||||
params?: PropertyBag;
|
||||
context?: Partial<NodeContext>;
|
||||
/** Expected data outputs, compared exactly. */
|
||||
outputs?: Record<string, Value>;
|
||||
/** Expected exec continuation; `null` asserts the flow ends here. */
|
||||
next?: string | string[] | null;
|
||||
/** Assertions on what the node did to the context or to the handles it was given. */
|
||||
after?(ctx: FakeContext): void;
|
||||
}
|
||||
|
||||
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
|
||||
const ctx = fakeContext(c.context);
|
||||
return { result: requireNodeDef(type).run!(ctx, c.inputs ?? {}, c.params ?? {}), ctx };
|
||||
};
|
||||
|
||||
/** A handle with a fixed position, for the geometry nodes. */
|
||||
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
|
||||
|
||||
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
|
||||
pointer: { ...NO_POINTER, ...overrides },
|
||||
});
|
||||
|
||||
const CASES: Record<string, Case[]> = {
|
||||
// --- flow ------------------------------------------------------------------------------------
|
||||
Branch: [
|
||||
{ name: 'true takes then', inputs: { condition: true }, next: 'then' },
|
||||
{ name: 'false takes else', inputs: { condition: false }, next: 'else' },
|
||||
{ name: 'a non-empty string is true', inputs: { condition: 'yes' }, next: 'then' },
|
||||
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
|
||||
],
|
||||
Sequence: [{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] }],
|
||||
|
||||
// --- variables -------------------------------------------------------------------------------
|
||||
GetVariable: [
|
||||
{ name: 'reads a variable', params: { variable: 'speed' }, outputs: { value: 100 } },
|
||||
{ name: 'an unknown variable reads as null', params: { variable: 'nope' }, outputs: { value: null } },
|
||||
{ name: 'no variable chosen reads as null', outputs: { value: null } },
|
||||
],
|
||||
SetVariable: [
|
||||
{
|
||||
name: 'writes a variable, keeping the declared type',
|
||||
inputs: { value: '250' },
|
||||
params: { variable: 'speed' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.variables.speed).toBe(250),
|
||||
},
|
||||
{
|
||||
name: 'no variable chosen writes nothing',
|
||||
inputs: { value: 5 },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.variables).toEqual({ speed: 100 }),
|
||||
},
|
||||
],
|
||||
|
||||
// --- references and properties ----------------------------------------------------------------
|
||||
Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }],
|
||||
Owner: [
|
||||
{ name: 'outputs the object the behavior is parented to', outputs: { object: fakeContext().owner } },
|
||||
],
|
||||
Parent: [
|
||||
{ name: 'no object has no parent', inputs: { object: null }, outputs: { parent: null } },
|
||||
{
|
||||
name: 'a top-level object has no parent',
|
||||
inputs: { object: fakeHandle({}) },
|
||||
outputs: { parent: null },
|
||||
},
|
||||
],
|
||||
ObjectByName: [
|
||||
{
|
||||
name: 'asks the context for the name',
|
||||
params: { name: 'Player' },
|
||||
context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) },
|
||||
outputs: { object: fakeEntity('Player') },
|
||||
},
|
||||
{ name: 'an unknown name is null', params: { name: 'Ghost' }, outputs: { object: null } },
|
||||
],
|
||||
NameOf: [
|
||||
{ name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } },
|
||||
{ name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } },
|
||||
],
|
||||
IsAsset: [
|
||||
{
|
||||
name: 'matches the asset id',
|
||||
inputs: { object: fakeEntity('Coin', 0, 'coin') },
|
||||
params: { asset: 'coin' },
|
||||
outputs: { result: true },
|
||||
},
|
||||
{
|
||||
name: 'no asset chosen never matches',
|
||||
inputs: { object: fakeEntity('Coin', 0, 'coin') },
|
||||
outputs: { result: false },
|
||||
},
|
||||
],
|
||||
IsEnabled: [
|
||||
{ name: 'on by default', inputs: { object: fakeHandle({}) }, outputs: { result: true, own: true } },
|
||||
{
|
||||
name: 'own switch off',
|
||||
inputs: { object: fakeHandle({ enabled: false }) },
|
||||
outputs: { result: false, own: false },
|
||||
},
|
||||
{
|
||||
name: 'own switch on but a parent is off – "own" still reports the own switch',
|
||||
inputs: { object: fakeHandle({ enabled: true }, fakeHandle({ enabled: false })) },
|
||||
outputs: { result: false, own: true },
|
||||
},
|
||||
{ name: 'no object is off', inputs: { object: null }, outputs: { result: false, own: false } },
|
||||
],
|
||||
GetProperty: [
|
||||
{
|
||||
name: 'reads a property',
|
||||
inputs: { object: fakeHandle({ width: 42 }) },
|
||||
params: { property: 'width' },
|
||||
outputs: { value: 42 },
|
||||
},
|
||||
{
|
||||
name: 'an absent property is null',
|
||||
inputs: { object: fakeHandle({}) },
|
||||
params: { property: 'width' },
|
||||
outputs: { value: null },
|
||||
},
|
||||
],
|
||||
GetPosition: [
|
||||
{
|
||||
name: 'local and world position of a top-level object are the same',
|
||||
inputs: { object: at('A', 10, 20) },
|
||||
outputs: { x: 10, y: 20, worldX: 10, worldY: 20 },
|
||||
},
|
||||
{
|
||||
name: 'a child reports its local position and the summed world position',
|
||||
inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } },
|
||||
outputs: { x: 10, y: 20, worldX: 110, worldY: 220 },
|
||||
},
|
||||
{
|
||||
name: 'no object is the origin',
|
||||
inputs: { object: null },
|
||||
outputs: { x: 0, y: 0, worldX: 0, worldY: 0 },
|
||||
},
|
||||
],
|
||||
|
||||
// --- literals and math -------------------------------------------------------------------------
|
||||
Number: [
|
||||
{ name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } },
|
||||
{ name: 'unset is 0', outputs: { value: 0 } },
|
||||
],
|
||||
Boolean: [
|
||||
{ name: 'outputs its setting', params: { value: true }, outputs: { value: true } },
|
||||
{ name: 'unset is false', outputs: { value: false } },
|
||||
],
|
||||
String: [
|
||||
{ name: 'outputs its setting', params: { value: 'hello' }, outputs: { value: 'hello' } },
|
||||
{ name: 'unset is empty', outputs: { value: '' } },
|
||||
],
|
||||
Add: [
|
||||
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
|
||||
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
|
||||
],
|
||||
Subtract: [{ name: 'subtracts', inputs: { a: 2, b: 3 }, outputs: { result: -1 } }],
|
||||
Multiply: [{ name: 'multiplies', inputs: { a: 2, b: 3 }, outputs: { result: 6 } }],
|
||||
Divide: [
|
||||
{ name: 'divides', inputs: { a: 6, b: 4 }, outputs: { result: 1.5 } },
|
||||
{ name: 'division by zero is 0, not Infinity', inputs: { a: 6, b: 0 }, outputs: { result: 0 } },
|
||||
],
|
||||
Min: [{ name: 'the smaller one', inputs: { a: 2, b: -3 }, outputs: { result: -3 } }],
|
||||
Max: [{ name: 'the larger one', inputs: { a: 2, b: -3 }, outputs: { result: 2 } }],
|
||||
Abs: [{ name: 'drops the sign', inputs: { value: -4 }, outputs: { result: 4 } }],
|
||||
Sqrt: [
|
||||
{ name: 'square root', inputs: { value: 9 }, outputs: { result: 3 } },
|
||||
{ name: 'negative input is 0, not NaN', inputs: { value: -9 }, outputs: { result: 0 } },
|
||||
],
|
||||
Clamp: [
|
||||
{
|
||||
name: 'inside the range passes through',
|
||||
inputs: { value: 0.4, min: 0, max: 1 },
|
||||
outputs: { result: 0.4 },
|
||||
},
|
||||
{ name: 'above the range', inputs: { value: 5, min: 0, max: 1 }, outputs: { result: 1 } },
|
||||
{ name: 'below the range', inputs: { value: -5, min: 0, max: 1 }, outputs: { result: 0 } },
|
||||
],
|
||||
Floor: [
|
||||
{ name: 'rounds down', inputs: { value: 2.9 }, outputs: { result: 2 } },
|
||||
{ name: 'rounds down for negatives too', inputs: { value: -2.1 }, outputs: { result: -3 } },
|
||||
],
|
||||
Ceil: [
|
||||
{ name: 'rounds up', inputs: { value: 2.1 }, outputs: { result: 3 } },
|
||||
{ name: 'a whole number stays put', inputs: { value: 3 }, outputs: { result: 3 } },
|
||||
],
|
||||
Round: [
|
||||
{ name: 'rounds to nearest', inputs: { value: 2.4 }, outputs: { result: 2 } },
|
||||
{ name: 'half rounds up', inputs: { value: 2.5 }, outputs: { result: 3 } },
|
||||
{ name: 'negative half rounds towards zero', inputs: { value: -2.5 }, outputs: { result: -2 } },
|
||||
],
|
||||
Modulo: [
|
||||
{ name: 'remainder', inputs: { a: 125, b: 60 }, outputs: { result: 5 } },
|
||||
{ name: 'modulo zero is 0, not NaN', inputs: { a: 125, b: 0 }, outputs: { result: 0 } },
|
||||
],
|
||||
Random: [
|
||||
// The spread is checked in "Random stays inside [min, max)" below; an empty range is the
|
||||
// one case with a single right answer.
|
||||
{ name: 'an empty range always yields min', inputs: { min: 3, max: 3 }, outputs: { result: 3 } },
|
||||
],
|
||||
Time: [
|
||||
{
|
||||
name: 'reports the clock and the frame length from the context',
|
||||
context: { time: 12.5, dt: 0.016 },
|
||||
outputs: { time: 12.5, dt: 0.016 },
|
||||
},
|
||||
],
|
||||
|
||||
// --- text and comparison -----------------------------------------------------------------------
|
||||
ToText: [
|
||||
{ name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } },
|
||||
{ name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } },
|
||||
{ name: 'decimals are clamped to 0…10', inputs: { value: 1, decimals: -5 }, outputs: { text: '1' } },
|
||||
],
|
||||
Join: [
|
||||
{ name: 'sticks values together as text', inputs: { a: 'Coins: ', b: 3 }, outputs: { text: 'Coins: 3' } },
|
||||
{
|
||||
name: 'an object joins as its name',
|
||||
inputs: { a: '', b: fakeEntity('Coin') },
|
||||
outputs: { text: 'Coin' },
|
||||
},
|
||||
],
|
||||
Equals: [
|
||||
{ name: 'equal text', inputs: { a: 'a', b: 'a' }, outputs: { result: true } },
|
||||
{ name: 'a number equals its text form', inputs: { a: 3, b: '3' }, outputs: { result: true } },
|
||||
{ name: 'objects compare by identity, not by name', inputs: eqNamesakes(), outputs: { result: false } },
|
||||
],
|
||||
Compare: [
|
||||
{ name: 'less than', inputs: { a: 1, b: 2 }, params: { op: '<' }, outputs: { result: true } },
|
||||
{ name: 'greater or equal', inputs: { a: 2, b: 2 }, params: { op: '>=' }, outputs: { result: true } },
|
||||
{ name: 'not equal', inputs: { a: 2, b: 2 }, params: { op: '!=' }, outputs: { result: false } },
|
||||
{
|
||||
name: 'no operator chosen falls back to the default "<"',
|
||||
inputs: { a: 1, b: 2 },
|
||||
outputs: { result: true },
|
||||
},
|
||||
],
|
||||
And: [
|
||||
{ name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } },
|
||||
{ name: 'one false', inputs: { a: true, b: false }, outputs: { result: false } },
|
||||
{ name: 'an unconnected input reads as false', inputs: { a: true }, outputs: { result: false } },
|
||||
],
|
||||
Or: [
|
||||
{ name: 'one true is enough', inputs: { a: false, b: true }, outputs: { result: true } },
|
||||
{ name: 'both false', inputs: { a: false, b: false }, outputs: { result: false } },
|
||||
],
|
||||
Not: [
|
||||
{ name: 'inverts', inputs: { value: true }, outputs: { result: false } },
|
||||
{ name: 'inverts the other way', inputs: { value: false }, outputs: { result: true } },
|
||||
{ name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } },
|
||||
],
|
||||
|
||||
// --- vectors and input --------------------------------------------------------------------------
|
||||
Length: [
|
||||
{ name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } },
|
||||
{ name: 'the zero vector has length 0', inputs: { x: 0, y: 0 }, outputs: { result: 0 } },
|
||||
],
|
||||
Normalize: [
|
||||
{
|
||||
name: 'scales to length 1 and reports the old length',
|
||||
inputs: { x: 0, y: -4 },
|
||||
outputs: { x: 0, y: -1, length: 4 },
|
||||
},
|
||||
{
|
||||
name: 'the zero vector stays zero instead of becoming NaN',
|
||||
inputs: { x: 0, y: 0 },
|
||||
outputs: { x: 0, y: 0, length: 0 },
|
||||
},
|
||||
],
|
||||
Distance: [
|
||||
{
|
||||
name: 'distance between two objects',
|
||||
inputs: { a: at('a', 0, 0), b: at('b', 3, 4) },
|
||||
outputs: { result: 5 },
|
||||
},
|
||||
{ name: 'a missing object is distance 0', inputs: { a: at('a', 0, 0), b: null }, outputs: { result: 0 } },
|
||||
],
|
||||
AxisInput: [
|
||||
{
|
||||
name: 'one key gives a unit vector',
|
||||
context: { isKeyDown: (key) => key === 'ArrowRight' },
|
||||
outputs: { x: 1, y: 0, active: true },
|
||||
},
|
||||
{
|
||||
name: 'diagonals are normalized, so they are not faster',
|
||||
context: { isKeyDown: (key) => key === 'ArrowRight' || key === 'ArrowDown' },
|
||||
// Not Math.SQRT1_2: the node divides by Math.hypot(1, 1), which lands one ulp lower.
|
||||
outputs: { x: 1 / Math.hypot(1, 1), y: 1 / Math.hypot(1, 1), active: true },
|
||||
},
|
||||
{
|
||||
name: 'opposite keys cancel out',
|
||||
context: { isKeyDown: (key) => key === 'ArrowLeft' || key === 'ArrowRight' },
|
||||
outputs: { x: 0, y: 0, active: false },
|
||||
},
|
||||
{ name: 'nothing held is inactive', outputs: { x: 0, y: 0, active: false } },
|
||||
],
|
||||
KeyIsDown: [
|
||||
{
|
||||
name: 'asks the context for the chosen key',
|
||||
params: { key: 'a' },
|
||||
context: { isKeyDown: (k) => k === 'a' },
|
||||
outputs: { down: true },
|
||||
},
|
||||
{
|
||||
name: 'another key is not down',
|
||||
params: { key: 'b' },
|
||||
context: { isKeyDown: (k) => k === 'a' },
|
||||
outputs: { down: false },
|
||||
},
|
||||
{ name: 'no key chosen is never down', context: { isKeyDown: () => true }, outputs: { down: true } },
|
||||
],
|
||||
MousePosition: [
|
||||
{
|
||||
name: 'reports the pointer in world units',
|
||||
context: pointer({ x: 40, y: -20, inside: true }),
|
||||
outputs: { x: 40, y: -20, inside: true },
|
||||
},
|
||||
{
|
||||
name: 'outside the camera frame the position is still reported, but "in view" is false',
|
||||
context: pointer({ x: 5, y: 5, inside: false }),
|
||||
outputs: { x: 5, y: 5, inside: false },
|
||||
},
|
||||
],
|
||||
MouseButton: [
|
||||
{
|
||||
name: 'the frame the button goes down',
|
||||
context: pointer({ down: true, pressed: true }),
|
||||
outputs: { down: true, pressed: true, released: false },
|
||||
},
|
||||
{
|
||||
name: 'a later frame while it is still held',
|
||||
context: pointer({ down: true }),
|
||||
outputs: { down: true, pressed: false, released: false },
|
||||
},
|
||||
{
|
||||
name: 'the frame it goes up',
|
||||
context: pointer({ released: true }),
|
||||
outputs: { down: false, pressed: false, released: true },
|
||||
},
|
||||
],
|
||||
IsHovered: [
|
||||
{
|
||||
name: 'the pointer is over the object',
|
||||
inputs: { object: fakeEntity('Button') },
|
||||
context: { ...pointer({ inside: true }), hitTest: () => true },
|
||||
outputs: { result: true },
|
||||
},
|
||||
{
|
||||
name: 'a hit outside the camera frame does not count',
|
||||
inputs: { object: fakeEntity('Button') },
|
||||
context: { ...pointer({ inside: false }), hitTest: () => true },
|
||||
outputs: { result: false },
|
||||
},
|
||||
{
|
||||
name: 'no object is never hovered',
|
||||
inputs: { object: null },
|
||||
context: { ...pointer({ inside: true }), hitTest: () => true },
|
||||
outputs: { result: false },
|
||||
},
|
||||
],
|
||||
IsTouching: [
|
||||
{
|
||||
name: 'asks the context',
|
||||
inputs: { a: fakeEntity('a'), b: fakeEntity('b') },
|
||||
context: { isTouching: () => true },
|
||||
outputs: { result: true },
|
||||
},
|
||||
{
|
||||
name: 'a missing object never touches',
|
||||
inputs: { a: fakeEntity('a'), b: null },
|
||||
context: { isTouching: () => true },
|
||||
outputs: { result: false },
|
||||
},
|
||||
],
|
||||
|
||||
// --- actions --------------------------------------------------------------------------------------
|
||||
Move: [
|
||||
{
|
||||
name: 'moves by (dx, dy) × scale × dt',
|
||||
inputs: { object: at('m', 0, 0), dx: 1, dy: 0, scale: 100, dt: 0.5 },
|
||||
next: 'exec',
|
||||
outputs: { blockedX: false, blockedY: false },
|
||||
},
|
||||
{
|
||||
name: 'no object is a no-op that still continues the flow',
|
||||
inputs: { object: null, dx: 1, dy: 1 },
|
||||
next: 'exec',
|
||||
outputs: { blockedX: false, blockedY: false },
|
||||
},
|
||||
],
|
||||
SetPosition: [
|
||||
{
|
||||
name: 'places the object and continues',
|
||||
inputs: { object: at('p', 0, 0), x: 30, y: -40 },
|
||||
next: 'exec',
|
||||
},
|
||||
{ name: 'no object is a no-op', inputs: { object: null, x: 1, y: 1 }, next: 'exec' },
|
||||
],
|
||||
SetProperty: [
|
||||
{
|
||||
name: 'writes a property, keeping the existing type',
|
||||
inputs: { object: fakeHandle({ width: 100 }), value: '250' },
|
||||
params: { property: 'width' },
|
||||
next: 'exec',
|
||||
},
|
||||
{
|
||||
name: 'no property name chosen writes nothing',
|
||||
inputs: { object: fakeHandle({ width: 100 }), value: 5 },
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
SetEnabled: [
|
||||
{
|
||||
name: 'switches an object off',
|
||||
inputs: { object: fakeHandle({ enabled: true }), enabled: false },
|
||||
next: 'exec',
|
||||
},
|
||||
{ name: 'no object is a no-op', inputs: { object: null, enabled: false }, next: 'exec' },
|
||||
],
|
||||
SetText: [
|
||||
{
|
||||
name: 'writes the content of a text object',
|
||||
inputs: { object: fakeHandle({ text: 'a' }), text: 12 },
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
Spawn: [
|
||||
{
|
||||
name: 'no asset chosen logs a hint and continues with no object',
|
||||
next: 'exec',
|
||||
outputs: { object: null },
|
||||
after: (ctx) => expect(ctx.logs).toEqual(['Spawn: no asset selected']),
|
||||
},
|
||||
],
|
||||
Destroy: [
|
||||
{
|
||||
name: 'no object is a no-op that still continues the flow',
|
||||
inputs: { object: null },
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
Log: [
|
||||
{
|
||||
name: 'logs its message and continues',
|
||||
inputs: { message: 'hi' },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.logs).toEqual(['hi']),
|
||||
},
|
||||
{
|
||||
name: 'an object logs as its name',
|
||||
inputs: { message: fakeEntity('Coin') },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.logs).toEqual(['Coin']),
|
||||
},
|
||||
],
|
||||
StopGame: [{ name: 'ends play and the flow', next: null, after: (ctx) => expect(ctx.stopped).toBe(true) }],
|
||||
RestartGame: [{ name: 'ends the flow – the world restarts before the next tick', next: null }],
|
||||
};
|
||||
|
||||
/** Two distinct handles that share a name – `Equals` must still tell them apart. */
|
||||
function eqNamesakes(): Record<string, Value> {
|
||||
return { a: fakeEntity('Coin'), b: fakeEntity('Coin') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nodes without a `run`: the interpreter seeds their outputs when the BehaviorSystem fires them,
|
||||
* so they are only observable through a running world. The value says which test drives it.
|
||||
*/
|
||||
const EVENT_NODES: Record<string, string> = {
|
||||
OnStart: 'graph.test.ts',
|
||||
OnUpdate: 'graph.test.ts',
|
||||
OnKeyDown: 'behavior-events.test.ts',
|
||||
OnKeyUp: 'behavior-events.test.ts',
|
||||
OnKeyHeld: 'behavior-events.test.ts',
|
||||
OnTimer: 'behavior-events.test.ts',
|
||||
OnCollisionEnter: 'collider.test.ts',
|
||||
OnCollisionStay: 'behavior-events.test.ts',
|
||||
OnCollisionExit: 'collider.test.ts',
|
||||
OnClick: 'nodes.test.ts',
|
||||
};
|
||||
|
||||
describe('node catalog', () => {
|
||||
it('covers every built-in node', () => {
|
||||
const uncovered = BUILT_IN_NODES.filter((def) =>
|
||||
def.run ? !CASES[def.type] : !EVENT_NODES[def.type],
|
||||
).map((def) => def.type);
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists exactly the run-less nodes as events', () => {
|
||||
const runless = BUILT_IN_NODES.filter((def) => !def.run).map((def) => def.type);
|
||||
expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort());
|
||||
});
|
||||
|
||||
it('declares well-formed ports and settings', () => {
|
||||
for (const def of BUILT_IN_NODES) {
|
||||
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
|
||||
expect(def.label, `${def.type} has a label`).not.toBe('');
|
||||
expect(def.description, `${def.type} has a description`).not.toBe('');
|
||||
// Inputs and outputs are addressed by key within their own side; a key may repeat across
|
||||
// sides (Normalize reads x/y and writes x/y), never within one.
|
||||
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);
|
||||
c.after?.(ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cases whose point is the mutation they cause – stated here rather than squeezed into the table.
|
||||
describe('action nodes write through to the object they are given', () => {
|
||||
it('Move applies dx/dy × scale × dt', () => {
|
||||
const target = at('m', 0, 0);
|
||||
requireNodeDef('Move').run!(fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {});
|
||||
expect({ x: target.x, y: target.y }).toEqual({ x: 50, y: -100 });
|
||||
});
|
||||
|
||||
it('SetPosition places the object absolutely', () => {
|
||||
const target = at('p', 10, 10);
|
||||
requireNodeDef('SetPosition').run!(fakeContext(), { object: target, x: 30, y: -40 }, {});
|
||||
expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 });
|
||||
});
|
||||
|
||||
it('SetProperty keeps the type the property already had', () => {
|
||||
const target = fakeHandle({ width: 100 });
|
||||
requireNodeDef('SetProperty').run!(
|
||||
fakeContext(),
|
||||
{ object: target, value: '250' },
|
||||
{ property: 'width' },
|
||||
);
|
||||
expect(target.props.width).toBe(250);
|
||||
});
|
||||
|
||||
it('SetProperty without a property name writes nothing', () => {
|
||||
const target = fakeHandle({ width: 100 });
|
||||
requireNodeDef('SetProperty').run!(fakeContext(), { object: target, value: 5 }, {});
|
||||
expect(target.props).toEqual({ width: 100 });
|
||||
});
|
||||
|
||||
it('SetEnabled switches the object off', () => {
|
||||
const target = fakeHandle({ enabled: true });
|
||||
requireNodeDef('SetEnabled').run!(fakeContext(), { object: target, enabled: false }, {});
|
||||
expect(target.props.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('SetText writes the text property, stringifying numbers', () => {
|
||||
const target = fakeHandle({ text: 'a' });
|
||||
requireNodeDef('SetText').run!(fakeContext(), { object: target, text: 12 }, {});
|
||||
expect(target.props.text).toBe('12');
|
||||
});
|
||||
|
||||
it('Destroy hands the object to the context', () => {
|
||||
const removed: string[] = [];
|
||||
const ctx = fakeContext({ destroy: (target) => removed.push(target.name) });
|
||||
requireNodeDef('Destroy').run!(ctx, { object: fakeEntity('Coin') }, {});
|
||||
expect(removed).toEqual(['Coin']);
|
||||
});
|
||||
|
||||
it('RestartGame asks the context for a restart', () => {
|
||||
let restarted = false;
|
||||
const ctx = fakeContext({
|
||||
restart: () => {
|
||||
restarted = true;
|
||||
},
|
||||
});
|
||||
requireNodeDef('RestartGame').run!(ctx, {}, {});
|
||||
expect(restarted).toBe(true);
|
||||
});
|
||||
|
||||
it('Random stays inside [min, max) over many draws', () => {
|
||||
const ctx = fakeContext();
|
||||
const draws = Array.from(
|
||||
{ length: 200 },
|
||||
() => requireNodeDef('Random').run!(ctx, { min: 5, max: 10 }, {}).outputs!.result as number,
|
||||
);
|
||||
expect(draws.every((n) => n >= 5 && n < 10)).toBe(true);
|
||||
// Not a constant: a generator stuck on one value would pass the range check above.
|
||||
expect(new Set(draws).size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user