Files
feuerwerk/tests/core/walk.test.ts
T
root 2cf788eedd Let a value be a bundle of named fields
PropertyValue was number | string | boolean | list. A struct joins it, which
costs two things the typechecker found rather than I did.

An object on a data edge used to mean a live entity, so asEntity would have
taken a struct for one and asString would have read .name off it. EntityHandle
now carries a symbol brand – a symbol so no field name can impersonate it, and
it never serializes, which suits a thing that exists only while the game runs.
It is built in exactly one place.

String(value) would have written a struct as [object Object] at 39 call sites.
They go through textOf(), which is identical for primitives.

normalizeGraph dropped everything but primitives from node params, so the List
node lost its value on every save ([3,1,4] round-tripped to undefined).
Variables were spared: they have a schema, params have none.
2026-09-10 08:14:18 +02:00

91 lines
3.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { ENTITY_HANDLE, type EntityHandle, worldPositionOf } from '../../src/core/graph';
import { hasParentCycle, parentChain } from '../../src/core/model/walk';
import { makeSpriteScene, place, startWorld } from '../helpers';
interface Node {
id: string;
parent: Node | null;
}
const parentOf = (node: Node) => node.parent;
/** a → b → c, where c is parented back to a (only reachable through a broken document or a bug). */
function cycle(): { a: Node; b: Node; c: Node } {
const a: Node = { id: 'a', parent: null };
const b: Node = { id: 'b', parent: a };
const c: Node = { id: 'c', parent: b };
a.parent = c;
return { a, b, c };
}
describe('parentChain', () => {
it('yields the item and its ancestors, nearest first', () => {
const a: Node = { id: 'a', parent: null };
const b: Node = { id: 'b', parent: a };
expect([...parentChain(b, parentOf)].map((n) => n.id)).toEqual(['b', 'a']);
});
it('terminates on a cyclic parent chain, visiting every item once', () => {
const { c } = cycle();
expect([...parentChain(c, parentOf)].map((n) => n.id)).toEqual(['c', 'b', 'a']);
});
it('reports a cycle only when the parents never reach a root', () => {
const { c } = cycle();
expect(hasParentCycle(c, parentOf)).toBe(true);
expect(hasParentCycle({ id: 'lone', parent: null }, parentOf)).toBe(false);
});
});
describe('cycle-safe walks in the runtime', () => {
it('world position of a cyclic chain terminates and counts each ancestor once', () => {
const brand = { [ENTITY_HANDLE]: true } as const;
const a: EntityHandle = {
...brand,
id: 'a',
assetId: 'x',
name: 'a',
x: 1,
y: 10,
z: 0,
props: {},
parent: null,
};
const b: EntityHandle = {
...brand,
id: 'b',
assetId: 'x',
name: 'b',
x: 2,
y: 20,
z: 0,
props: {},
parent: a,
};
a.parent = b;
expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 });
});
// Guards `travelsWith`: before the shared iterator it walked parents without a seen-set and hung here.
it('moveAndSlide terminates when a collider sits in a cyclic parent chain', () => {
const f = makeSpriteScene();
const moverId = place(f, f.spriteId, { x: 0, y: 0 });
const wallId = place(f, f.spriteId, { x: 200, y: 0 });
const moverCollider = new AddColliderCommand(f.sceneId, moverId);
const wallCollider = new AddColliderCommand(f.sceneId, wallId);
f.stack.execute(moverCollider);
f.stack.execute(wallCollider);
const { world, collisions } = startWorld(f, { collisions: true });
const mover = world.findByObjectId(moverId)!;
const wall = world.findByObjectId(wallId)!;
const wallColliderEntity = world.findByObjectId(wallCollider.objectId)!;
// Close the loop: the wall's collider is its child and the wall is parented to that collider.
wall.parent = wallColliderEntity;
expect(collisions.moveAndSlide(world, mover, 10, 0)).toEqual({ blockedX: false, blockedY: false });
expect(mover.x).toBe(10);
}, 2000);
});