Initial Commit
This commit is contained in:
163
packages/gaming/games/ecs/tests/property.test.ts
Normal file
163
packages/gaming/games/ecs/tests/property.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { BitSet } from "../src/bitset/bitset.js";
|
||||
import { defineQuery } from "../src/query/query.js";
|
||||
import { defineComponent } from "../src/schema/schema.js";
|
||||
import { createWorld } from "../src/world/world.js";
|
||||
|
||||
const MAX_BITS = 128;
|
||||
const MAX_ENTITIES = 32;
|
||||
|
||||
function bitsetFromBits(size: number, bits: ReadonlyArray<number>): BitSet {
|
||||
const set = new BitSet(size);
|
||||
for (const b of bits) set.set(b);
|
||||
return set;
|
||||
}
|
||||
|
||||
function bruteContainsAll(a: BitSet, b: BitSet, size: number): boolean {
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (b.has(i) && !a.has(i)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function bruteIntersects(a: BitSet, b: BitSet, size: number): boolean {
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (a.has(i) && b.has(i)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe("property invariants", () => {
|
||||
it("bitset algebra matches brute-force on random masks", {
|
||||
timeout: 10000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: MAX_BITS }),
|
||||
fc.array(fc.nat(MAX_BITS - 1), { maxLength: 16 }),
|
||||
fc.array(fc.nat(MAX_BITS - 1), { maxLength: 16 }),
|
||||
(rawSize, aBits, bBits) => {
|
||||
const size = Math.max(rawSize, ...aBits, ...bBits, 0) + 1;
|
||||
const a = bitsetFromBits(size, aBits);
|
||||
const b = bitsetFromBits(size, bBits);
|
||||
expect(a.containsAll(b)).toBe(bruteContainsAll(a, b, size));
|
||||
expect(a.intersects(b)).toBe(b.intersects(a));
|
||||
expect(a.intersects(b)).toBe(bruteIntersects(a, b, size));
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it("query result equals brute-force scan on random worlds", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
const Position = defineComponent("Position", { x: 0 });
|
||||
const Velocity = defineComponent("Velocity", { dx: 0 });
|
||||
const Disabled = defineComponent("Disabled", { d: true });
|
||||
const schemas = [Position, Velocity, Disabled] as const;
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.tuple(fc.boolean(), fc.boolean(), fc.boolean()), {
|
||||
maxLength: MAX_ENTITIES,
|
||||
}),
|
||||
fc.subarray([Position, Velocity, Disabled], {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
fc.subarray([Position, Velocity, Disabled], {
|
||||
minLength: 0,
|
||||
maxLength: 3,
|
||||
}),
|
||||
(entitySpec, include, exclude) => {
|
||||
const world = createWorld();
|
||||
for (const s of schemas) world.registerComponent(s);
|
||||
const entities: number[] = [];
|
||||
for (const flags of entitySpec) {
|
||||
const entity = world.spawn();
|
||||
entities.push(entity);
|
||||
for (let i = 0; i < schemas.length; i++) {
|
||||
if (flags[i] === true) {
|
||||
world.addComponent(entity, schemas[i]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
const descriptor = defineQuery(
|
||||
include,
|
||||
exclude.length > 0 ? exclude : undefined
|
||||
);
|
||||
const queried = [...world.query(descriptor)].sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
const expected = entities
|
||||
.filter((e) => {
|
||||
for (const s of include) {
|
||||
if (!world.hasComponent(e, s)) return false;
|
||||
}
|
||||
for (const s of exclude) {
|
||||
if (world.hasComponent(e, s)) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a - b);
|
||||
expect(queried).toEqual(expected);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it("entity count tracks spawn / despawn monotonically", {
|
||||
timeout: 10000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.boolean(), { maxLength: MAX_ENTITIES }),
|
||||
(ops) => {
|
||||
const world = createWorld();
|
||||
const live: number[] = [];
|
||||
for (const op of ops) {
|
||||
if (op || live.length === 0) {
|
||||
live.push(world.spawn());
|
||||
} else {
|
||||
const last = live.pop();
|
||||
if (last !== undefined) world.despawn(last);
|
||||
}
|
||||
}
|
||||
expect(world.entityCount).toBe(live.length);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it("snapshot then restore preserves observable state", {
|
||||
timeout: 15000,
|
||||
}, () => {
|
||||
const Position = defineComponent("Position", { x: 0, y: 0 });
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.record({ x: fc.integer(), y: fc.integer() }), {
|
||||
maxLength: MAX_ENTITIES,
|
||||
}),
|
||||
(positions) => {
|
||||
const a = createWorld();
|
||||
a.registerComponent(Position);
|
||||
for (const p of positions) {
|
||||
const entity = a.spawn();
|
||||
a.addComponent(entity, Position, p);
|
||||
}
|
||||
const snap = a.snapshot();
|
||||
const b = createWorld();
|
||||
b.registerComponent(Position);
|
||||
b.restore(snap);
|
||||
expect(b.entityCount).toBe(a.entityCount);
|
||||
expect(b.archetypeCount).toBe(a.archetypeCount);
|
||||
}
|
||||
),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user