104 lines
3.6 KiB
TypeScript
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);
|
|
});
|
|
});
|