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>
215 lines
7.0 KiB
TypeScript
215 lines
7.0 KiB
TypeScript
/**
|
||
* The event nodes have no `run` – the BehaviorSystem decides when they fire. These tests drive a
|
||
* real world and check that timing: key edges vs. held keys, the timer's own clock, and the
|
||
* difference between a collision that starts and one that continues.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||
import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture, startWorld } from '../helpers';
|
||
|
||
/** A scene with a 100×60 sprite and a behavior placed below it, ready to have an event wired up. */
|
||
function scene(): SceneFixture & { behavior: BehaviorAsset; box: string } {
|
||
const f = makeSpriteScene('Box');
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||
const behavior = makeBehavior(f, 'Logic');
|
||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||
place(f, behavior.id, { x: 0, y: 0 }, box);
|
||
return { ...f, behavior, box };
|
||
}
|
||
|
||
/** Wires `<event> → Log(message)` so a fired event shows up as one entry in `world.log`. */
|
||
function logOn(
|
||
f: SceneFixture & { behavior: BehaviorAsset },
|
||
event: string,
|
||
params: Record<string, string | number> = {},
|
||
message = event,
|
||
): void {
|
||
const edit = graphEditor(f, f.behavior);
|
||
const node = edit.addNode(event, params);
|
||
const log = edit.addNode('Log', { message });
|
||
edit.connect(node, 'exec', log, 'exec');
|
||
}
|
||
|
||
describe('key events', () => {
|
||
it('On key down fires once per press, not while the key stays held', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
frame();
|
||
expect(world.log).toEqual([]);
|
||
|
||
input.press('a');
|
||
frame();
|
||
expect(world.log).toEqual(['down']);
|
||
|
||
input.press('a'); // auto-repeat while held: no new edge
|
||
frame();
|
||
frame();
|
||
expect(world.log).toEqual(['down']);
|
||
|
||
input.release('a');
|
||
input.press('a');
|
||
frame();
|
||
expect(world.log).toEqual(['down', 'down']);
|
||
});
|
||
|
||
it('On key up fires once when the key is released', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
input.press('a');
|
||
frame();
|
||
frame();
|
||
expect(world.log).toEqual([]);
|
||
|
||
input.release('a');
|
||
frame();
|
||
expect(world.log).toEqual(['up']);
|
||
|
||
frame(); // the edge is consumed, not sticky
|
||
expect(world.log).toEqual(['up']);
|
||
});
|
||
|
||
it('On key held fires every frame the key is down', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
input.press('a');
|
||
frame();
|
||
frame();
|
||
frame();
|
||
expect(world.log).toEqual(['held', 'held', 'held']);
|
||
|
||
input.release('a');
|
||
frame();
|
||
expect(world.log).toHaveLength(3);
|
||
});
|
||
|
||
it('only the node whose key matches fires', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyDown', { key: 'a' }, 'a');
|
||
logOn(f, 'OnKeyDown', { key: 'b' }, 'b');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
input.press('b');
|
||
frame();
|
||
expect(world.log).toEqual(['b']);
|
||
});
|
||
|
||
it('losing the window releases every held key, so nothing stays down behind the user’s back', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyUp', { key: 'a' }, 'up');
|
||
logOn(f, 'OnKeyHeld', { key: 'a' }, 'held');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
|
||
input.press('a');
|
||
frame();
|
||
expect(world.log).toEqual(['held']);
|
||
|
||
input.releaseAll();
|
||
frame();
|
||
expect(world.log).toEqual(['held', 'up']);
|
||
frame();
|
||
expect(world.log).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
describe('On timer', () => {
|
||
it('fires on its own clock, independent of the frame rate', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnTimer', { interval: 1 }, 'tick');
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
|
||
for (let i = 0; i < 4; i++) frame(0.2); // 0.8 s
|
||
expect(world.log).toEqual([]);
|
||
frame(0.2); // 1.0 s
|
||
expect(world.log).toEqual(['tick']);
|
||
for (let i = 0; i < 5; i++) frame(0.2); // 2.0 s
|
||
expect(world.log).toEqual(['tick', 'tick']);
|
||
});
|
||
|
||
it('catches up when one frame is longer than the interval', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
|
||
frame(2); // a hitch worth four intervals
|
||
expect(world.log).toEqual(['tick', 'tick', 'tick', 'tick']);
|
||
});
|
||
|
||
it('two timers keep separate clocks', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnTimer', { interval: 1 }, 'slow');
|
||
logOn(f, 'OnTimer', { interval: 0.5 }, 'fast');
|
||
const { world, frame } = startWorld(f, { behaviors: true });
|
||
|
||
frame(0.5);
|
||
expect(world.log).toEqual(['fast']);
|
||
frame(0.5);
|
||
expect(world.log).toEqual(['fast', 'slow', 'fast']);
|
||
});
|
||
});
|
||
|
||
describe('On collision stay', () => {
|
||
it('fires every frame the overlap lasts, while enter fires only on the first one', () => {
|
||
const f = makeSpriteScene('Crate');
|
||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 60));
|
||
const behavior = makeBehavior(f, 'Hit');
|
||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const b = place(f, f.spriteId, { x: 300, y: 0 });
|
||
f.stack.execute(new AddColliderCommand(f.sceneId, a));
|
||
f.stack.execute(new AddColliderCommand(f.sceneId, b));
|
||
place(f, behavior.id, { x: 0, y: 0 }, a);
|
||
const fixture = { ...f, behavior };
|
||
logOn(fixture, 'OnCollisionEnter', {}, 'enter');
|
||
logOn(fixture, 'OnCollisionStay', {}, 'stay');
|
||
logOn(fixture, 'OnCollisionExit', {}, 'exit');
|
||
|
||
const { world, frame } = startWorld(f, { collisions: true, behaviors: true });
|
||
const crateB = world.findByObjectId(b)!;
|
||
|
||
frame(0.1);
|
||
expect(world.log).toEqual([]);
|
||
|
||
crateB.x = 50; // now overlapping
|
||
frame(0.1);
|
||
expect(world.log).toEqual(['enter']);
|
||
frame(0.1);
|
||
frame(0.1);
|
||
expect(world.log).toEqual(['enter', 'stay', 'stay']);
|
||
|
||
crateB.x = 500;
|
||
frame(0.1);
|
||
expect(world.log).toEqual(['enter', 'stay', 'stay', 'exit']);
|
||
frame(0.1);
|
||
expect(world.log).toHaveLength(4);
|
||
});
|
||
});
|
||
|
||
describe('a switched-off behavior receives no events', () => {
|
||
it('ignores keys and timers until it is switched back on', () => {
|
||
const f = scene();
|
||
logOn(f, 'OnKeyDown', { key: 'a' }, 'down');
|
||
logOn(f, 'OnTimer', { interval: 0.5 }, 'tick');
|
||
const { world, input, frame } = startWorld(f, { behaviors: true });
|
||
const logic = world.entities.find((e) => e.name === 'Logic')!;
|
||
|
||
logic.props.enabled = false;
|
||
input.press('a');
|
||
frame(1);
|
||
expect(world.log).toEqual([]);
|
||
|
||
logic.props.enabled = true;
|
||
input.release('a');
|
||
input.press('a');
|
||
frame(1);
|
||
// The timer starts counting when the behavior runs again – no backlog from the off period.
|
||
expect(world.log).toEqual(['down', 'tick', 'tick']);
|
||
});
|
||
});
|