Files
feuerwerk/tests/core/on-behavior.test.ts
T
adminandClaude Opus 5.5 46c620ae3b
game-maker / gate (push) Successful in 2m48s
game-maker / suite (push) Failing after 13m34s
Give a behavior more ways in and out, and hear it as an event (#294)
A behavior run by another one had a single flow port at its Start
and at its End, so it could not branch for its caller. And it could
not be used as an event: a call needs a pulse at its flow input,
and the events of a called graph never fire. An input like
"Confirm" (Space or Enter) had to be built again in every graph.

- A declared port of a Start or an End node can be of the kind
  Flow. It sits above the values and shares their names, exec stays
  reserved. Only these two nodes declare one, a struct does not.
- Run behavior shows a flow port of the Start as a further flow
  input. A call arriving through it leaves the Start through the
  output of that name and not through exec.
- Run behavior shows a flow port of the End as a further flow
  output. The call goes on through the one the End was reached by
  last, and then through exec. exec fires always, as before.
- On behavior is a new event node without a flow input. The
  behavior it names runs at the node for as long as the behavior
  around it runs, with all of its own events and one set of
  variables per node.
- Its outputs are those of the End. It fires once the inner pulse
  is through, by the output the End was reached by last, exec only
  for End.exec. A pulse that never reaches the End is not seen.
- The value ports of the Start are its inputs, read anew for each
  inner pulse. The Start itself never fires there.
- It nests to any depth. Under a Run behavior it stays silent. A
  behavior hearing itself, however far round, is not started and
  the node says "A behavior cannot listen to itself".
- The palette offers every other behavior with an End once more as
  "On <name>". A node naming one without End says so.
- An On node removed while the game runs ends its behavior with On
  disable and On destroy. The sounds of the entity end only with
  the behavior placed on it.
- Renaming and deleting a declared port follows the edges at On
  behavior nodes as it does at Run behavior nodes.
- The flow outputs of the Start of a behavior placed on an object
  never fire, like its exec.
- The engine prompt of the benchmark names flow ports and On
  behavior.

Tests:
- Gate on melektaus-atx right before this commit: 215 files, 3319
  tests (3278 + 41), exit 0.
- e2e 19 on melektaus-atx: 39 checks (+4), all pass. e2e
  1-6,17-24,26,36,51: 628 checks, all pass.
- 19 unit tests in flow-ports.test.ts, 19 in on-behavior.test.ts
  on a real world, the rest in the node catalog.
- Four new e2e checks in module 19: a flow port declared by mouse
  at the End, On Confirm in the graph, the call leaving through the
  further output while playing, Enter and Space on a real keyboard.
- 19 unit counter-probes all fail their tests. One did not at first
  (a removed node kept its instance), a test was added. Two browser
  counter-probes fail their checks.
- One screenshot looked at: On Confirm with exec and pressed, the
  second flow pin at the calling node.
- The e2e sum of 1680 is counted (1676 + 4), the full suite and
  the smokes did not run.
- Not seen in the browser: flow inputs at the Start, nesting, the
  ring, values through On behavior, the palette entry. No test
  holds that the sounds of the entity go on when a heard behavior
  ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:33:34 +02:00

476 lines
19 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.
/**
* `On behavior` (#294): another behavior used as an event. It lives at the node for as long as the
* behavior around it runs, its own events fire, and each of its pulses that reaches its end node
* goes on at the node hearing it. Driven through a real world, since the running game keeps it.
*/
import { describe, expect, it } from 'vitest';
import { AddNodeCommand, RemoveNodeCommand } from '../../src/core/commands/graphCommands';
import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
import {
BEHAVIOR_END,
BEHAVIOR_PARAM,
BEHAVIOR_START,
callProblem,
type GraphNode,
ON_BEHAVIOR,
resolveDef,
RUN_BEHAVIOR,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
place,
type ProjectFixture,
type SceneFixture,
startWorld,
} from '../helpers';
/** "Confirm": Space or Enter going down reaches the end node by its flow port `pressed`. */
function confirm(f: ProjectFixture, name = 'Confirm'): { asset: BehaviorAsset; end: GraphNode } {
const asset = makeBehavior(f, name);
const edit = graphEditor(f, asset);
const end = edit.addNode(BEHAVIOR_END);
f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'pressed', kind: 'flow' }));
for (const key of [' ', 'Enter']) edit.connect(edit.addNode('OnKeyDown', { key }), 'exec', end, 'pressed');
return { asset, end };
}
/** A behavior on a box hearing `heard`, with a Log on each named output of the node. */
function hearing(
f: SceneFixture,
heard: BehaviorAsset,
outputs: readonly string[],
name = 'Player',
): { asset: BehaviorAsset; node: GraphNode; objectId: string } {
const asset = makeBehavior(f, name);
const edit = graphEditor(f, asset);
const node = edit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: heard.id });
for (const port of outputs)
edit.connect(node, port, edit.addNode('Log', { message: `${name} ${port}` }), 'exec');
const box = place(f, f.spriteId, { x: 0, y: 0 });
return { asset, node, objectId: place(f, asset.id, { x: 0, y: 0 }, box) };
}
describe('On behavior', () => {
it('fires when an event of the behavior it hears reaches that behavior’s end node', () => {
const f = makeSpriteScene();
hearing(f, confirm(f).asset, ['pressed', 'exec']);
const { world, input, frame } = startWorld(f, { behaviors: true });
frame();
expect(world.log).toEqual([]);
input.press('Enter');
frame();
expect(world.log).toEqual(['Player pressed']);
input.release('Enter');
input.press(' ');
frame();
expect(world.log).toEqual(['Player pressed', 'Player pressed']);
});
it('stays silent for a pulse that does not reach the end node', () => {
const f = makeSpriteScene();
const { asset } = confirm(f);
const edit = graphEditor(f, asset);
edit.connect(edit.addNode('OnUpdate'), 'exec', edit.addNode('Log', { message: 'inner update' }), 'exec');
hearing(f, asset, ['pressed', 'exec']);
const { world, frame } = startWorld(f, { behaviors: true });
frame();
frame();
expect(world.log).toEqual(['inner update', 'inner update']);
});
it('fires `exec` only when the end node was reached by its plain input', () => {
const f = makeSpriteScene();
const { asset, end } = confirm(f);
const edit = graphEditor(f, asset);
edit.connect(edit.addNode('OnKeyDown', { key: 'x' }), 'exec', end, 'exec');
hearing(f, asset, ['pressed', 'exec']);
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('x');
frame();
expect(world.log).toEqual(['Player exec']);
input.press('Enter');
frame();
expect(world.log).toEqual(['Player exec', 'Player pressed']);
});
it('fires once, by the way reached last, when one pulse reaches the end node twice', () => {
const f = makeSpriteScene();
const asset = makeBehavior(f, 'Twice');
const edit = graphEditor(f, asset);
const end = edit.addNode(BEHAVIOR_END);
for (const key of ['first', 'second'])
f.stack.execute(new AddPortCommand(asset.id, end.id, { key, kind: 'flow' }));
const both = edit.addNode('Sequence');
edit.wire([
[edit.addNode('OnKeyDown', { key: 'a' }), 'exec', both, 'exec'],
[both, 'a', end, 'first'],
[both, 'b', end, 'second'],
]);
hearing(f, asset, ['first', 'second', 'exec']);
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame();
expect(world.log).toEqual(['Player second']);
});
it('has the outputs of the end node and the value inputs of the start node, under the behavior’s name', () => {
const f = makeSpriteScene();
const { asset, end } = confirm(f);
const start = graphEditor(f, asset).addNode(BEHAVIOR_START);
f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'way', kind: 'flow' }));
f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'amount', kind: 'number' }));
f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'score', kind: 'number' }));
const { asset: outer, node } = hearing(f, asset, []);
const def = resolveDef(node, outer.graph, graphLookup(f.project))!;
expect(def.label).toBe('On Confirm');
expect(def.category).toBe('event');
expect(def.inputs.map((p) => `${p.key}:${p.type}`)).toEqual(['amount:number']);
expect(def.outputs.map((p) => `${p.key}:${p.type}`)).toEqual([
'exec:exec',
'pressed:exec',
'score:number',
]);
});
it('hands its inputs to the start node, read anew for every pulse, and the end node’s values out', () => {
const f = makeSpriteScene();
const asset = makeBehavior(f, 'Doubled');
const edit = graphEditor(f, asset);
const start = edit.addNode(BEHAVIOR_START);
const end = edit.addNode(BEHAVIOR_END);
f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'amount', kind: 'number' }));
f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'twice', kind: 'number' }));
const times = edit.addNode('Multiply', { b: 2 });
edit.wire([
[edit.addNode('OnKeyDown', { key: 'a' }), 'exec', end, 'exec'],
[start, 'amount', times, 'a'],
[times, 'result', end, 'twice'],
]);
const outer = makeBehavior(f, 'Player', ['count']);
const oEdit = graphEditor(f, outer);
const node = oEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id });
const log = oEdit.addNode('Log');
const plus = oEdit.addNode('Add', { b: 1 });
const set = oEdit.variables(['count'], { write: true });
const get = oEdit.variables(['count']);
oEdit.wire([
[get, 'count', node, 'amount'],
[node, 'exec', log, 'exec'],
[node, 'twice', log, 'message'],
[log, 'exec', set, 'exec'],
[get, 'count', plus, 'a'],
[plus, 'result', set, 'count'],
]);
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, outer.id, { x: 0, y: 0 }, box);
const { world, input, frame } = startWorld(f, { behaviors: true });
for (let i = 0; i < 3; i++) {
input.press('a');
frame();
input.release('a');
frame();
}
expect(world.log).toEqual(['0', '2', '4']);
});
it('keeps the variables of the behavior it hears alive, one set per node', () => {
const f = makeSpriteScene();
const asset = makeBehavior(f, 'Counter', ['count']);
const edit = graphEditor(f, asset);
const end = edit.addNode(BEHAVIOR_END);
f.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'count', kind: 'number' }));
const plus = edit.addNode('Add', { b: 1 });
const set = edit.variables(['count'], { write: true });
const get = edit.variables(['count']);
edit.wire([
[edit.addNode('OnKeyDown', { key: 'a' }), 'exec', set, 'exec'],
[get, 'count', plus, 'a'],
[plus, 'result', set, 'count'],
[set, 'exec', end, 'exec'],
[get, 'count', end, 'count'],
]);
// Not kept from call to call: what holds the count here is the node it lives at.
asset.keptVariables = [];
const outer = makeBehavior(f, 'Player', ['count']);
const oEdit = graphEditor(f, outer);
for (const name of ['one', 'two']) {
const node = oEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id });
const say = oEdit.addNode('Join', { a: `${name} ` });
const log = oEdit.addNode('Log');
oEdit.wire([
[node, 'exec', log, 'exec'],
[node, 'count', say, 'b'],
[say, 'text', log, 'message'],
]);
}
const box = place(f, f.spriteId, { x: 0, y: 0 });
const objectId = place(f, outer.id, { x: 0, y: 0 }, box);
const { world, input, frame } = startWorld(f, { behaviors: true });
for (let i = 0; i < 2; i++) {
input.press('a');
frame();
input.release('a');
frame();
}
expect(world.log).toEqual(['one 1', 'two 1', 'one 2', 'two 2']);
// The outer behavior's own variable of the same name is another one.
expect(world.entities.find((e) => e.objectId === objectId)?.props.count).toBe(0);
});
it('runs the lifecycle events of the behavior it hears in step with the one around it', () => {
const f = makeSpriteScene();
const asset = makeBehavior(f, 'Life');
const edit = graphEditor(f, asset);
const end = edit.addNode(BEHAVIOR_END);
for (const type of ['OnStart', 'OnEnable', 'OnDisable', 'OnDestroy']) {
f.stack.execute(new AddPortCommand(asset.id, end.id, { key: type, kind: 'flow' }));
edit.connect(edit.addNode(type), 'exec', end, type);
}
const { asset: outer } = hearing(f, asset, ['OnStart', 'OnEnable', 'OnDisable', 'OnDestroy']);
const oEdit = graphEditor(f, outer);
oEdit.connect(oEdit.addNode('OnStart'), 'exec', oEdit.addNode('Log', { message: 'own start' }), 'exec');
const { world, frame } = startWorld(f, { behaviors: true });
frame();
expect(world.log).toEqual(['own start', 'Player OnStart', 'Player OnEnable']);
world.destroy();
expect(world.log.slice(3)).toEqual(['Player OnDisable', 'Player OnDestroy']);
});
it('hears a timer of that behavior', () => {
const f = makeSpriteScene();
const asset = makeBehavior(f, 'Tick');
const edit = graphEditor(f, asset);
edit.connect(edit.addNode('OnTimer', { interval: 0.5 }), 'exec', edit.addNode(BEHAVIOR_END), 'exec');
hearing(f, asset, ['exec']);
const { world, frame } = startWorld(f, { behaviors: true });
frame(0.3);
expect(world.log).toEqual([]);
frame(0.3);
expect(world.log).toEqual(['Player exec']);
});
it('goes as deep as behaviors hear each other', () => {
const f = makeSpriteScene();
const inner = confirm(f).asset;
const middle = makeBehavior(f, 'Menu');
const mEdit = graphEditor(f, middle);
const end = mEdit.addNode(BEHAVIOR_END);
f.stack.execute(new AddPortCommand(middle.id, end.id, { key: 'chosen', kind: 'flow' }));
mEdit.connect(mEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id }), 'pressed', end, 'chosen');
hearing(f, middle, ['chosen', 'exec']);
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('Enter');
frame();
expect(world.log).toEqual(['Player chosen']);
});
it('stays silent in a behavior that is run as a node, like every event there', () => {
const f = makeSpriteScene();
const inner = confirm(f).asset;
const called = makeBehavior(f, 'Called');
const cEdit = graphEditor(f, called);
cEdit.connect(
cEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id }),
'pressed',
cEdit.addNode('Log', { message: 'heard in a call' }),
'exec',
);
cEdit.addNode(BEHAVIOR_START);
const outer = makeBehavior(f, 'Player');
const oEdit = graphEditor(f, outer);
oEdit.connect(
oEdit.addNode('OnUpdate'),
'exec',
oEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: called.id }),
'exec',
);
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, outer.id, { x: 0, y: 0 }, box);
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('Enter');
frame();
expect(world.log).toEqual([]);
});
it('does not count as a running behavior of its own', () => {
const f = makeSpriteScene();
const { objectId } = hearing(f, confirm(f).asset, ['pressed']);
const { behaviors, frame } = startWorld(f, { behaviors: true });
frame();
expect(behaviors?.runningIds()).toEqual([objectId]);
});
it('lights up the graph of the behavior it hears', () => {
const f = makeSpriteScene();
const { asset, end } = confirm(f);
hearing(f, asset, ['pressed']);
const { behaviors, input, frame } = startWorld(f, { behaviors: true });
frame();
expect(behaviors?.traceFor(asset.id).nodes.has(end.id)).toBe(false);
input.press('Enter');
frame();
expect(behaviors?.traceFor(asset.id).nodes.has(end.id)).toBe(true);
});
});
describe('On behavior while the graph is edited during a run', () => {
it('starts hearing a node added to the running graph, and stops hearing a removed one', () => {
const f = makeSpriteScene();
const heard = confirm(f).asset;
const outer = makeBehavior(f, 'Player');
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, outer.id, { x: 0, y: 0 }, box);
const { world, behaviors, input, frame } = startWorld(f, { behaviors: true });
const tap = () => {
input.press('Enter');
frame();
input.release('Enter');
frame();
};
tap();
expect(world.log).toEqual([]);
const add = new AddNodeCommand(outer.id, ON_BEHAVIOR, 0, 0, { [BEHAVIOR_PARAM]: heard.id });
f.stack.execute(add);
const edit = graphEditor(f, outer);
edit.connect(add.node, 'pressed', edit.addNode('Log', { message: 'heard' }), 'exec');
behaviors?.syncFromModel();
tap();
expect(world.log).toEqual(['heard']);
f.stack.execute(new RemoveNodeCommand(outer.id, add.node.id));
behaviors?.syncFromModel();
tap();
expect(world.log).toEqual(['heard']);
});
it('ends the behavior a removed node was hearing: it says goodbye and runs no more', () => {
const f = makeSpriteScene();
const heard = makeBehavior(f, 'Chatty');
const hEdit = graphEditor(f, heard);
hEdit.addNode(BEHAVIOR_END);
hEdit.connect(
hEdit.addNode('OnUpdate'),
'exec',
hEdit.addNode('Log', { message: 'inner update' }),
'exec',
);
hEdit.connect(hEdit.addNode('OnDestroy'), 'exec', hEdit.addNode('Log', { message: 'inner bye' }), 'exec');
const { asset: outer, node } = hearing(f, heard, []);
const { world, behaviors, frame } = startWorld(f, { behaviors: true });
frame();
expect(world.log).toEqual(['inner update']);
f.stack.execute(new RemoveNodeCommand(outer.id, node.id));
behaviors?.syncFromModel();
frame();
frame();
expect(world.log).toEqual(['inner update', 'inner bye']);
});
});
describe('an On behavior node that cannot fire', () => {
const problemOf = (f: ProjectFixture, outer: BehaviorAsset, node: GraphNode) =>
callProblem(node, graphLookup(f.project), outer.id);
it('says so: nothing picked, the behavior gone, or no end node to be reached', () => {
const f = makeSpriteScene();
const outer = makeBehavior(f, 'Player');
const edit = graphEditor(f, outer);
expect(problemOf(f, outer, edit.addNode(ON_BEHAVIOR))).toBe('No behavior picked');
expect(problemOf(f, outer, edit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: 'nope' }))).toBe(
'That behavior is gone',
);
const endless = makeBehavior(f, 'Endless');
expect(problemOf(f, outer, edit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: endless.id }))).toBe(
'No End node – never fires',
);
expect(
problemOf(f, outer, edit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: confirm(f).asset.id })),
).toBeNull();
});
it('is reported for a behavior hearing itself, directly or through others, and such a circle does not run', () => {
const f = makeSpriteScene();
const a = makeBehavior(f, 'A');
const b = makeBehavior(f, 'B');
const aEdit = graphEditor(f, a);
const bEdit = graphEditor(f, b);
const aEnd = aEdit.addNode(BEHAVIOR_END);
const bEnd = bEdit.addNode(BEHAVIOR_END);
const hearsB = aEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: b.id });
const hearsA = bEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: a.id });
const hearsSelf = aEdit.addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: a.id });
aEdit.connect(hearsB, 'exec', aEnd, 'exec');
bEdit.connect(hearsA, 'exec', bEnd, 'exec');
bEdit.connect(bEdit.addNode('OnKeyDown', { key: 'a' }), 'exec', bEnd, 'exec');
aEdit.connect(hearsB, 'exec', aEdit.addNode('Log', { message: 'A heard B' }), 'exec');
expect(problemOf(f, a, hearsB)).toBe('A behavior cannot listen to itself');
expect(problemOf(f, b, hearsA)).toBe('A behavior cannot listen to itself');
expect(problemOf(f, a, hearsSelf)).toBe('A behavior cannot listen to itself');
const box = place(f, f.spriteId, { x: 0, y: 0 });
place(f, a.id, { x: 0, y: 0 }, box);
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame();
// B runs once below A; the A it names in turn is the one already running, so it is not built again.
expect(world.log).toEqual(['A heard B']);
expect(world.stopRequested).toBe(false);
});
});
describe('the ports of an On behavior node follow the behavior it hears', () => {
it('keeps its edges through a rename at the end node and loses them with the port', () => {
const f = makeSpriteScene();
const { asset, end } = confirm(f);
const { asset: outer } = hearing(f, asset, ['pressed']);
const ports = () => outer.graph.edges.map((e) => e.from.port);
expect(ports()).toEqual(['pressed']);
f.stack.execute(new RenamePortCommand(asset.id, end.id, 'pressed', 'confirmed'));
expect(ports()).toEqual(['confirmed']);
f.stack.execute(new RemovePortCommand(asset.id, end.id, 'confirmed'));
expect(ports()).toEqual([]);
f.stack.undo();
expect(ports()).toEqual(['confirmed']);
});
it('keeps the typed-in value of an input through a rename at the start node', () => {
const f = makeSpriteScene();
const { asset } = confirm(f);
const start = graphEditor(f, asset).addNode(BEHAVIOR_START);
f.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'amount', kind: 'number' }));
const outer = makeBehavior(f, 'Player');
const node = graphEditor(f, outer).addNode(ON_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id, amount: 5 });
f.stack.execute(new RenamePortCommand(asset.id, start.id, 'amount', 'size'));
expect(node.params.size).toBe(5);
expect(node.params.amount).toBeUndefined();
});
});