/** * 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; params?: PropertyBag; context?: Partial; /** Expected data outputs, compared exactly. */ outputs?: Record; /** 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): Partial => ({ pointer: { ...NO_POINTER, ...overrides }, }); const CASES: Record = { // --- 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 { 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 = { 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); }); });