/** * 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 { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import { createEmptyGraph, type DeclaredPort, NO_BEHAVIORS, type NodeContext, requireNodeDef, resolveDef, type RunResult, type Value, } from '../../src/core/graph'; import type { PropertyBag } from '../../src/core/model/Properties'; import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers'; interface Case { /** What the case demonstrates – becomes the failure message. */ name: string; inputs?: Record; params?: PropertyBag; context?: Partial; /** Ports the node declares for itself – for the types whose ports are variables it names. */ ports?: DeclaredPort[]; /** Expected data outputs, compared exactly. */ outputs?: Record; /** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */ derivedOutputs?: 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; /** 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); const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports }; const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_BEHAVIORS)! : requireNodeDef(type); const grown = (c.derivedOutputs ?? []).map((key) => ({ key, label: key, type: 'exec' as const })); const def = grown.length > 0 ? { ...registered, outputs: [...registered.outputs, ...grown] } : registered; return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def), ctx }; }; const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y }); const painted = fakeHandle({ width: 100, text: '' }); 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: 'on its own it has the one output, and follows it', next: ['a'] }, { name: 'runs every output it has grown, in order', derivedOutputs: ['b', 'c'], next: ['a', 'b', 'c'] }, ], BehaviorEnd: [ { name: 'hands its inputs back out, which is where the caller reads the result', inputs: { hit: true, damage: 7 }, outputs: { hit: true, damage: 7 }, next: null, }, ], RunBehavior: [ { name: 'asks for the named behavior to run, passing its inputs on', inputs: { power: 3 }, params: { behavior: 'b1' }, call: { behaviorId: 'b1', inputs: { power: 3 } }, next: 'exec', }, { name: 'without a behavior chosen it still continues, calling nothing that exists', call: { behaviorId: '', inputs: {} }, next: 'exec', }, ], 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 }, }, ], // variables // 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', }, ], // 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', inputs: { name: 'Player' }, context: { findByName: (name) => (name === 'Player' ? fakeEntity('Player') : null) }, outputs: { object: fakeEntity('Player') }, }, { name: 'an unknown name is null', inputs: { name: 'Ghost' }, outputs: { object: null } }, ], NameOf: [ { name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } }, { name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } }, ], IsAsset: [ { name: 'matches the asset id', inputs: { object: fakeEntity('Coin', 0, 'coin') }, params: { asset: 'coin' }, outputs: { result: true }, }, { name: 'no asset chosen never matches', inputs: { object: fakeEntity('Coin', 0, 'coin') }, outputs: { result: false }, }, ], IsEnabled: [ { name: 'on by default', inputs: { object: fakeHandle({}) }, outputs: { result: true, own: true } }, { name: 'own switch off', inputs: { object: fakeHandle({ enabled: false }) }, outputs: { result: false, own: false }, }, { name: 'own switch on but a parent is off – "own" still reports the own switch', inputs: { object: fakeHandle({ enabled: true }, fakeHandle({ enabled: false })) }, outputs: { result: false, own: true }, }, { name: 'no object is off', inputs: { object: null }, outputs: { result: false, own: false } }, ], GetPosition: [ { name: 'local and world position of a top-level object are the same', inputs: { object: at('A', 10, 20) }, outputs: { x: 10, y: 20, worldX: 10, worldY: 20 }, }, { name: 'a child reports its local position and the summed world position', inputs: { object: { ...at('child', 10, 20), parent: at('parent', 100, 200) } }, outputs: { x: 10, y: 20, worldX: 110, worldY: 220 }, }, { name: 'no object is the origin', inputs: { object: null }, outputs: { x: 0, y: 0, worldX: 0, worldY: 0 }, }, ], // 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. { 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' } }, ], 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' }, }, ], // lists List: [ { name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } }, { name: 'an unset list is empty, not missing', outputs: { value: [] } }, ], Fill: [ { name: 'repeats one value', inputs: { value: 0, count: 4 }, outputs: { result: [0, 0, 0, 0] } }, { name: 'a negative count gives nothing', inputs: { value: 1, count: -3 }, outputs: { result: [] } }, ], Count: [ { name: 'counts the items', inputs: { list: ['a', 'b'] }, outputs: { count: 2 } }, { name: 'a lone value counts as one', inputs: { list: 7 }, outputs: { count: 1 } }, ], ItemAt: [ { name: 'reads by index, counting from 0', inputs: { list: [4, 5, 6], index: 1 }, outputs: { item: 5 } }, { name: 'past the end is 0, not a crash', inputs: { list: [4], index: 9 }, outputs: { item: 0 } }, ], SetItem: [ { name: 'replaces one item', inputs: { list: [1, 2, 3], index: 1, value: 9 }, outputs: { result: [1, 9, 3] }, }, { name: 'leaves the list it was given untouched – a graph may hold it elsewhere', inputs: { list: Object.freeze([1, 2, 3]), index: 0, value: 9 }, outputs: { result: [9, 2, 3] }, }, { name: 'an index outside the list changes nothing', inputs: { list: [1, 2], index: 5, value: 9 }, outputs: { result: [1, 2] }, }, ], SubList: [ { name: 'takes count items from start', inputs: { list: [1, 2, 3, 4], start: 1, count: 2 }, outputs: { result: [2, 3] }, }, { name: 'asking past the end gives what is there', inputs: { list: [1, 2], start: 1, count: 9 }, outputs: { result: [2] }, }, ], Concat: [ { name: 'one list after the other', inputs: { a: [1], b: [2, 3] }, outputs: { result: [1, 2, 3] } }, { name: 'a lone value joins as one item', inputs: { a: [1], b: 2 }, outputs: { result: [1, 2] } }, ], PackStruct: [ { name: 'bundles its declared inputs into one value', inputs: { x: 2, tags: ['a', 'b'] }, outputs: { value: { x: 2, tags: ['a', 'b'] } }, }, { name: 'a node with no fields yet packs nothing', outputs: { value: {} } }, { name: 'an entity cannot be stored in a bundle, so it packs as its name', inputs: { who: fakeEntity('Player') }, outputs: { value: { who: 'Player' } }, }, ], ExpandStruct: [ { name: 'reads the fields back out, one output each', inputs: { value: { x: 2, tags: ['a'] } }, outputs: { x: 2, tags: ['a'] }, }, { name: 'a value that is not a bundle has no fields', inputs: { value: 7 }, outputs: {} }, ], IndexOf: [ { name: 'finds where a value sits', inputs: { list: [7, 8, 9], value: 8 }, outputs: { index: 1 } }, { name: 'a value that is not in there is -1', inputs: { list: [7], value: 8 }, outputs: { index: -1 } }, ], FindText: [ { name: 'finds where the search first appears', inputs: { text: '..#.#', search: '#' }, outputs: { index: 2 }, }, { name: 'text that does not appear is -1', inputs: { text: '....', search: '#' }, outputs: { index: -1 }, }, ], Join: [ { name: 'sticks values together as text', inputs: { a: 'Coins: ', b: 3 }, outputs: { text: 'Coins: 3' } }, { name: 'an object joins as its name', inputs: { a: '', b: fakeEntity('Coin') }, outputs: { text: 'Coin' }, }, ], Equals: [ { name: 'equal text', inputs: { a: 'a', b: 'a' }, outputs: { result: true } }, { name: 'a number equals its text form', inputs: { a: 3, b: '3' }, outputs: { result: true } }, { name: 'objects compare by identity, not by name', inputs: eqNamesakes(), outputs: { result: false } }, ], Compare: [ { name: 'less than', inputs: { a: 1, b: 2 }, params: { op: '<' }, outputs: { result: true } }, { name: 'greater or equal', inputs: { a: 2, b: 2 }, params: { op: '>=' }, outputs: { result: true } }, { name: 'not equal', inputs: { a: 2, b: 2 }, params: { op: '!=' }, outputs: { result: false } }, { name: 'no operator chosen falls back to the default "<"', inputs: { a: 1, b: 2 }, outputs: { result: true }, }, ], And: [ { name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } }, { name: 'one false', inputs: { a: true, b: false }, outputs: { result: false } }, { name: 'an unconnected input reads as false', inputs: { a: true }, outputs: { result: false } }, ], Or: [ { name: 'one true is enough', inputs: { a: false, b: true }, outputs: { result: true } }, { name: 'both false', inputs: { a: false, b: false }, outputs: { result: false } }, ], Not: [ { name: 'inverts', inputs: { value: true }, outputs: { result: false } }, { name: 'inverts the other way', inputs: { value: false }, outputs: { result: true } }, { name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } }, ], // 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' }, ], 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`: 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 = { BehaviorStart: 'nested-behavior.test.ts', 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(''); // Keys are unique per side, but may repeat across sides (Normalize reads and writes x/y). expect(new Set(def.inputs.map((p) => p.key)).size, `${def.type} input keys unique`).toBe( def.inputs.length, ); expect(new Set(def.outputs.map((p) => p.key)).size, `${def.type} output keys unique`).toBe( def.outputs.length, ); expect( keys.every((k) => k !== ''), `${def.type} has no empty port key`, ).toBe(true); for (const param of def.params) { expect(param.default, `${def.type}.${param.key} has a default`).toBeDefined(); } } }); }); for (const [type, cases] of Object.entries(CASES)) { describe(type, () => { for (const c of cases) { it(c.name, () => { const { result, ctx } = run(type, c); if (c.outputs) expect(result.outputs ?? {}).toEqual(c.outputs); if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next); if (c.repeat !== undefined) expect(result.repeat ?? null).toEqual(c.repeat); if (c.call !== undefined) expect(result.call ?? null).toEqual(c.call); c.after?.(ctx); }); } }); } describe('action nodes write through to the object they are given', () => { it('Move applies dx/dy × scale × dt', () => { const target = at('m', 0, 0); runDef('Move', fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {}); expect({ x: target.x, y: target.y }).toEqual({ x: 50, y: -100 }); }); it('SetPosition places the object absolutely', () => { const target = at('p', 10, 10); runDef('SetPosition', fakeContext(), { object: target, x: 30, y: -40 }, {}); expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 }); }); it('Destroy hands the object to the context', () => { const removed: string[] = []; const ctx = fakeContext({ destroy: (target) => removed.push(target.name) }); runDef('Destroy', ctx, { object: fakeEntity('Coin') }, {}); expect(removed).toEqual(['Coin']); }); it('RestartGame asks the context for a restart', () => { let restarted = false; const ctx = fakeContext({ restart: () => { restarted = true; }, }); runDef('RestartGame', ctx, {}, {}); expect(restarted).toBe(true); }); it('Random stays inside [min, max) over many draws', () => { const ctx = fakeContext(); const draws = Array.from( { length: 200 }, () => runDef('Random', ctx, { min: 5, max: 10 }, {}).outputs!.result as number, ); expect(draws.every((n) => n >= 5 && n < 10)).toBe(true); // Not a constant: a generator stuck on one value would pass the range check above. expect(new Set(draws).size).toBeGreaterThan(1); }); });