Initial Commit
This commit is contained in:
28
packages/gaming/games/ecs/tests/dep-graph.test.ts
Normal file
28
packages/gaming/games/ecs/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const ALLOWED_RUNTIME_DEPS = new Set([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
|
||||
describe("dependency graph", () => {
|
||||
it("runtime dependencies are restricted to the declared allowlist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = JSON.parse(
|
||||
await readFile(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies: Record<string, string> };
|
||||
const declared = new Set(Object.keys(pkg.dependencies));
|
||||
const extras = [...declared].filter(
|
||||
(d) => !ALLOWED_RUNTIME_DEPS.has(d)
|
||||
);
|
||||
expect(extras).toEqual([]);
|
||||
});
|
||||
});
|
||||
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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
52
packages/gaming/games/ecs/tests/purity.test.ts
Normal file
52
packages/gaming/games/ecs/tests/purity.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC_ROOT = join(__dirname, "..", "src");
|
||||
const BANNED_PATTERNS = [
|
||||
/\bfrom\s+["']node:/,
|
||||
/\bfrom\s+["']fs["']/,
|
||||
/\bfrom\s+["']path["']/,
|
||||
/\bfrom\s+["']crypto["']/,
|
||||
/\brequire\(["']node:/,
|
||||
/\bprocess\.[a-zA-Z]/,
|
||||
/\bBuffer\.[a-zA-Z]/,
|
||||
/\bwindow\./,
|
||||
/\bdocument\./,
|
||||
/\bnavigator\./,
|
||||
];
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* walk(full);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
entry.name.endsWith(".ts") &&
|
||||
!entry.name.endsWith(".test.ts") &&
|
||||
!entry.name.endsWith(".spec.ts") &&
|
||||
!entry.name.endsWith(".bench.ts")
|
||||
) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("purity guards", () => {
|
||||
it("built dist references no Node or browser globals", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const offenders: Array<{ file: string; pattern: string }> = [];
|
||||
for await (const file of walk(SRC_ROOT)) {
|
||||
const source = await readFile(file, "utf8");
|
||||
for (const pattern of BANNED_PATTERNS) {
|
||||
if (pattern.test(source)) {
|
||||
offenders.push({ file, pattern: pattern.source });
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
40
packages/gaming/games/ecs/tests/setup/polyfills.ts
Normal file
40
packages/gaming/games/ecs/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/ecs.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it. Also
|
||||
* provides a `webcrypto`-backed `globalThis.crypto` for bare-Node runs.
|
||||
*/
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user