Files

104 lines
3.6 KiB
TypeScript

import { beforeAll, 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 { loadPhysics } from '../../src/runtime/physics/engine';
import { COLLISION_SYSTEMS, 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.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
beforeAll(async () => {
await loadPhysics();
});
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`: a parent walk without a seen-set hangs 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, { physics, 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)).toMatchObject({
blockedX: false,
blockedY: false,
});
expect(mover.x).toBe(10);
// And a whole tick, which walks every collider up to the body it belongs to.
world.tick(1 / 60);
expect(collisions.enters).toEqual([]);
}, 2000);
});
});