@joystyk/testing 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/NOTICE +36 -0
- package/README.md +191 -2
- package/dist/builders.d.ts +59 -0
- package/dist/builders.js +129 -0
- package/dist/builders.js.map +1 -0
- package/dist/clock.d.ts +30 -0
- package/dist/clock.js +58 -0
- package/dist/clock.js.map +1 -0
- package/dist/deep.d.ts +25 -0
- package/dist/deep.js +105 -0
- package/dist/deep.js.map +1 -0
- package/dist/engine-conformance.d.ts +166 -0
- package/dist/engine-conformance.js +995 -0
- package/dist/engine-conformance.js.map +1 -0
- package/dist/fixtures/dice-race.d.ts +41 -0
- package/dist/fixtures/dice-race.js +153 -0
- package/dist/fixtures/dice-race.js.map +1 -0
- package/dist/fixtures/draw-play.d.ts +54 -0
- package/dist/fixtures/draw-play.js +188 -0
- package/dist/fixtures/draw-play.js.map +1 -0
- package/dist/fixtures/drift.d.ts +34 -0
- package/dist/fixtures/drift.js +109 -0
- package/dist/fixtures/drift.js.map +1 -0
- package/dist/fixtures/guards.d.ts +11 -0
- package/dist/fixtures/guards.js +16 -0
- package/dist/fixtures/guards.js.map +1 -0
- package/dist/fixtures/hint-puzzle.d.ts +47 -0
- package/dist/fixtures/hint-puzzle.js +129 -0
- package/dist/fixtures/hint-puzzle.js.map +1 -0
- package/dist/fixtures/index.d.ts +6 -0
- package/dist/fixtures/index.js +7 -0
- package/dist/fixtures/nim.d.ts +34 -0
- package/dist/fixtures/nim.js +141 -0
- package/dist/fixtures/nim.js.map +1 -0
- package/dist/fixtures/sealed-bids.d.ts +46 -0
- package/dist/fixtures/sealed-bids.js +141 -0
- package/dist/fixtures/sealed-bids.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/replay.d.ts +58 -0
- package/dist/replay.js +110 -0
- package/dist/replay.js.map +1 -0
- package/dist/report.d.ts +68 -0
- package/dist/report.js +104 -0
- package/dist/report.js.map +1 -0
- package/dist/rng.d.ts +18 -0
- package/dist/rng.js +25 -0
- package/dist/rng.js.map +1 -0
- package/dist/schema.d.ts +13 -0
- package/dist/schema.js +21 -0
- package/dist/schema.js.map +1 -0
- package/dist/session-ports.d.ts +16 -0
- package/dist/session-ports.js +32 -0
- package/dist/session-ports.js.map +1 -0
- package/dist/simulation-conformance.d.ts +71 -0
- package/dist/simulation-conformance.js +312 -0
- package/dist/simulation-conformance.js.map +1 -0
- package/dist/storage.d.ts +29 -0
- package/dist/storage.js +63 -0
- package/dist/storage.js.map +1 -0
- package/dist/vitest.d.ts +32 -0
- package/dist/vitest.js +45 -0
- package/dist/vitest.js.map +1 -0
- package/dist/waiting.d.ts +58 -0
- package/dist/waiting.js +97 -0
- package/dist/waiting.js.map +1 -0
- package/package.json +55 -3
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { simulationReplay } from "./builders.js";
|
|
2
|
+
import { deepEqual, deepFreeze, errorMessage, firstDifference, jsonProblem, jsonRoundTrip, show } from "./deep.js";
|
|
3
|
+
import { replaySimulation as replaySimulation$1 } from "./replay.js";
|
|
4
|
+
import { Recorder } from "./report.js";
|
|
5
|
+
import { cloneRng, testRng, testSeed } from "./rng.js";
|
|
6
|
+
import { formatIssues, validate } from "./schema.js";
|
|
7
|
+
import { isJson } from "@joystyk/contracts";
|
|
8
|
+
import { restoreRng } from "@joystyk/kit";
|
|
9
|
+
import { replaySimulation } from "@joystyk/session";
|
|
10
|
+
//#region src/simulation-conformance.ts
|
|
11
|
+
/** The checks this suite runs, in report order. */
|
|
12
|
+
var SIMULATION_CHECKS = [
|
|
13
|
+
{
|
|
14
|
+
id: "determinism",
|
|
15
|
+
title: "Determinism: the same seed and tick inputs give the same states and events"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "replay",
|
|
19
|
+
title: "Replay: the recorded tick log reproduces the final state"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "checkpoint",
|
|
23
|
+
title: "Checkpoints: saving, restoring and continuing equals running straight through"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "json",
|
|
27
|
+
title: "JSON safety of configs, states, inputs, events and outcomes"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "schemas",
|
|
31
|
+
title: "Schema agreement: every value passes the simulation's own schemas"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "purity",
|
|
35
|
+
title: "Step purity: the same state, inputs and generator give the same next state, without mutation"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "invariants",
|
|
39
|
+
title: "Invariants hold on every state the simulation reaches"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "outcome",
|
|
43
|
+
title: "Outcomes are valid and detected in time"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "budget",
|
|
47
|
+
title: "Step-time budget"
|
|
48
|
+
}
|
|
49
|
+
];
|
|
50
|
+
var now = () => globalThis.performance?.now() ?? 0;
|
|
51
|
+
var outcomeProblem = (outcome) => {
|
|
52
|
+
if (![
|
|
53
|
+
"won",
|
|
54
|
+
"lost",
|
|
55
|
+
"draw",
|
|
56
|
+
"completed",
|
|
57
|
+
"abandoned"
|
|
58
|
+
].includes(outcome.result)) return `unknown result ${show(outcome.result)}`;
|
|
59
|
+
if (typeof outcome.summary !== "string") return "summary must be a string";
|
|
60
|
+
if (outcome.elapsedMs !== void 0 || outcome.undosUsed !== void 0 || outcome.assists !== void 0) return "simulations never set elapsedMs, undosUsed or assists; the scheduler adds them";
|
|
61
|
+
if (outcome.placements.length === 0) return "placements is empty; a solo game has one entry";
|
|
62
|
+
for (const p of outcome.placements) if (!Number.isInteger(p.place) || p.place < 1) return `seat ${p.seat} has place ${show(p.place)}; places start at 1`;
|
|
63
|
+
if (new Set(outcome.placements.map((p) => p.seat)).size !== outcome.placements.length) return "a seat is placed twice";
|
|
64
|
+
return null;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Runs the Layer 3 conformance suite against `simulation` and returns a report. It never
|
|
68
|
+
* throws for a failing check. Use `runSimulationConformance` to get one vitest case per check.
|
|
69
|
+
*/
|
|
70
|
+
var checkSimulation = async (simulation, options) => {
|
|
71
|
+
if (options.configs.length === 0) throw new TypeError("Simulation conformance needs at least one config.");
|
|
72
|
+
const rec = new Recorder(SIMULATION_CHECKS, options.maxFailuresPerCheck ?? 3);
|
|
73
|
+
const runs = options.runs ?? 4;
|
|
74
|
+
const ticks = options.ticks ?? 1e3;
|
|
75
|
+
const baseSeed = options.seed ?? "conformance";
|
|
76
|
+
const gameId = options.gameId ?? "test-game";
|
|
77
|
+
if (options.invariants === void 0) rec.skip("invariants", "no invariants hook given");
|
|
78
|
+
if (options.stepBudgetMs === void 0) rec.skip("budget", "no stepBudgetMs given");
|
|
79
|
+
const validations = [];
|
|
80
|
+
const checkSchema = (schema, value, what, fail) => {
|
|
81
|
+
rec.ran("schemas");
|
|
82
|
+
validations.push(validate(schema, value).then((result) => {
|
|
83
|
+
if (!result.ok) fail(`${what} fails its schema: ${formatIssues(result.issues)}; value ${show(value)}`);
|
|
84
|
+
}, (error) => fail(`${what}: the schema threw ${errorMessage(error)}`)));
|
|
85
|
+
};
|
|
86
|
+
let totalRuns = 0;
|
|
87
|
+
let totalTicks = 0;
|
|
88
|
+
let stepTime = 0;
|
|
89
|
+
let stepCount = 0;
|
|
90
|
+
for (const [configIndex, config] of options.configs.entries()) {
|
|
91
|
+
rec.ran("json");
|
|
92
|
+
if (!isJson(config)) rec.fail("json", {
|
|
93
|
+
message: `config ${configIndex} isn't JSON: ${jsonProblem(config)}`,
|
|
94
|
+
setup: configIndex
|
|
95
|
+
});
|
|
96
|
+
checkSchema(simulation.schemas.config, config, `config ${configIndex}`, (message) => rec.fail("schemas", {
|
|
97
|
+
message,
|
|
98
|
+
setup: configIndex
|
|
99
|
+
}));
|
|
100
|
+
for (let runIndex = 0; runIndex < runs; runIndex += 1) {
|
|
101
|
+
if (options.only !== void 0 && (options.only.setup !== configIndex || options.only.game !== runIndex)) continue;
|
|
102
|
+
totalRuns += 1;
|
|
103
|
+
const seed = `${baseSeed}/${configIndex}/${runIndex}`;
|
|
104
|
+
const seedState = testSeed(seed, "engine");
|
|
105
|
+
const driver = testRng(seed, "driver");
|
|
106
|
+
const log = [];
|
|
107
|
+
const states = [];
|
|
108
|
+
const rngs = [];
|
|
109
|
+
const results = [];
|
|
110
|
+
const replayUpTo = (endTick) => simulationReplay({
|
|
111
|
+
gameId,
|
|
112
|
+
rulesVersion: simulation.rulesVersion,
|
|
113
|
+
config,
|
|
114
|
+
seed: seedState,
|
|
115
|
+
inputs: log.filter((entry) => entry.tick < endTick),
|
|
116
|
+
endTick
|
|
117
|
+
});
|
|
118
|
+
const fail = (id, message, tick, input) => {
|
|
119
|
+
const failure = {
|
|
120
|
+
message,
|
|
121
|
+
setup: configIndex,
|
|
122
|
+
game: runIndex,
|
|
123
|
+
seed,
|
|
124
|
+
step: tick,
|
|
125
|
+
replay: replayUpTo(tick),
|
|
126
|
+
...input === void 0 ? {} : { input }
|
|
127
|
+
};
|
|
128
|
+
rec.fail(id, failure);
|
|
129
|
+
};
|
|
130
|
+
const rng = restoreRng(seedState);
|
|
131
|
+
let started;
|
|
132
|
+
try {
|
|
133
|
+
started = simulation.init(config, rng);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
fail("purity", `init threw: ${errorMessage(error)}`, 0);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!started.ok) {
|
|
139
|
+
fail("outcome", `init rejected config ${configIndex}: ${show(started.error)}`, 0);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
let state = deepFreeze(started.value);
|
|
143
|
+
let outcome = null;
|
|
144
|
+
let tick = 0;
|
|
145
|
+
const checkState = (value, at) => {
|
|
146
|
+
rec.ran("json");
|
|
147
|
+
if (!isJson(value)) fail("json", `the state isn't JSON: ${jsonProblem(value)}`, at);
|
|
148
|
+
checkSchema(simulation.schemas.state, value, "the state", (m) => fail("schemas", m, at));
|
|
149
|
+
if (options.invariants !== void 0) {
|
|
150
|
+
rec.ran("invariants");
|
|
151
|
+
try {
|
|
152
|
+
const result = options.invariants(value);
|
|
153
|
+
if (result === false || typeof result === "string") fail("invariants", `invariant violated: ${result === false ? "the invariant hook returned false" : result}`, at);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
fail("invariants", `invariant violated: ${errorMessage(error)}`, at);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const findOutcome = (value, at) => {
|
|
160
|
+
const found = simulation.outcome(value);
|
|
161
|
+
if (found !== null) {
|
|
162
|
+
rec.ran("outcome");
|
|
163
|
+
if (!isJson(found)) fail("json", `the outcome isn't JSON: ${jsonProblem(found)}`, at);
|
|
164
|
+
const problem = outcomeProblem(found);
|
|
165
|
+
if (problem !== null) fail("outcome", `invalid outcome: ${problem}; outcome ${show(found)}`, at);
|
|
166
|
+
}
|
|
167
|
+
return found;
|
|
168
|
+
};
|
|
169
|
+
checkState(state, 0);
|
|
170
|
+
outcome = findOutcome(state, 0);
|
|
171
|
+
let broken = false;
|
|
172
|
+
for (; tick < ticks && outcome === null; tick += 1) {
|
|
173
|
+
states.push(state);
|
|
174
|
+
rngs.push(rng.save());
|
|
175
|
+
let inputs = [];
|
|
176
|
+
try {
|
|
177
|
+
inputs = deepFreeze([...options.inputs?.(state, tick, driver) ?? []]);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
fail("purity", `the inputs generator threw: ${errorMessage(error)}`, tick);
|
|
180
|
+
}
|
|
181
|
+
for (const input of inputs) {
|
|
182
|
+
rec.ran("json");
|
|
183
|
+
if (!isJson(input)) fail("json", `an input isn't JSON: ${jsonProblem(input)}`, tick, input);
|
|
184
|
+
checkSchema(simulation.schemas.input, input, "an input", (m) => fail("schemas", m, tick, input));
|
|
185
|
+
log.push({
|
|
186
|
+
tick,
|
|
187
|
+
input
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
let trial = null;
|
|
191
|
+
const trialRng = cloneRng(rng);
|
|
192
|
+
try {
|
|
193
|
+
trial = simulation.step(state, inputs, trialRng);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const message = errorMessage(error);
|
|
196
|
+
fail("purity", /read[- ]only|not extensible|frozen|Cannot (assign|add|delete|define)/i.test(message) ? `step mutated its input (states and inputs are frozen): ${message}` : `step threw: ${message}`, tick);
|
|
197
|
+
broken = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
let result;
|
|
201
|
+
const t0 = now();
|
|
202
|
+
try {
|
|
203
|
+
result = deepFreeze(simulation.step(state, inputs, rng));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
fail("purity", `step threw on a second call with the same arguments: ${errorMessage(error)}`, tick);
|
|
206
|
+
broken = true;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
stepTime += now() - t0;
|
|
210
|
+
stepCount += 1;
|
|
211
|
+
rec.ran("purity");
|
|
212
|
+
const diff = firstDifference(trial, result) ?? firstDifference(trialRng.save(), rng.save());
|
|
213
|
+
if (diff !== null) fail("purity", `step gave two different answers for the same state, inputs and generator: ${diff}`, tick);
|
|
214
|
+
rec.ran("json");
|
|
215
|
+
if (!isJson(result)) fail("json", `a step result isn't JSON: ${jsonProblem(result)}`, tick);
|
|
216
|
+
for (const event of result.events ?? []) checkSchema(simulation.schemas.event, event, "an event", (m) => fail("schemas", m, tick));
|
|
217
|
+
results.push(result);
|
|
218
|
+
state = result.state;
|
|
219
|
+
checkState(state, tick + 1);
|
|
220
|
+
outcome = findOutcome(state, tick + 1);
|
|
221
|
+
}
|
|
222
|
+
totalTicks += tick;
|
|
223
|
+
if (broken) continue;
|
|
224
|
+
states.push(state);
|
|
225
|
+
rngs.push(rng.save());
|
|
226
|
+
if (outcome === null && options.requireOutcome === true) {
|
|
227
|
+
rec.ran("outcome");
|
|
228
|
+
fail("outcome", `no outcome after ${ticks} ticks (raise ticks, or drop requireOutcome)`, tick);
|
|
229
|
+
}
|
|
230
|
+
const endTick = tick;
|
|
231
|
+
const final = state;
|
|
232
|
+
const inputsAt = (t) => log.filter((entry) => entry.tick === t).map((entry) => entry.input);
|
|
233
|
+
rec.ran("determinism");
|
|
234
|
+
try {
|
|
235
|
+
const again = restoreRng(seedState);
|
|
236
|
+
const init = simulation.init(config, again);
|
|
237
|
+
let current = init.ok ? init.value : null;
|
|
238
|
+
const initDiff = init.ok ? firstDifference(states[0], init.value) : "init rejected";
|
|
239
|
+
if (initDiff !== null) fail("determinism", `init gave a different state for the same seed: ${initDiff}`, 0);
|
|
240
|
+
for (let t = 0; t < endTick && current !== null && initDiff === null; t += 1) {
|
|
241
|
+
const result = simulation.step(current, inputsAt(t), again);
|
|
242
|
+
const d = firstDifference(results[t], result) ?? firstDifference(rngs[t + 1], again.save());
|
|
243
|
+
if (d !== null) {
|
|
244
|
+
fail("determinism", `a second run from the same seed diverged at tick ${t}: ${d}`, t);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
current = result.state;
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
fail("determinism", `a second run threw: ${errorMessage(error)}`, 0);
|
|
251
|
+
}
|
|
252
|
+
{
|
|
253
|
+
rec.ran("replay");
|
|
254
|
+
const replay = replayUpTo(endTick);
|
|
255
|
+
if (!isJson(replay)) fail("replay", "the replay isn't JSON", endTick);
|
|
256
|
+
else try {
|
|
257
|
+
const result = replaySimulation$1(simulation, jsonRoundTrip(replay));
|
|
258
|
+
if (!result.ok) fail("replay", `the replay doesn't start: ${show(result.error)}`, endTick);
|
|
259
|
+
else {
|
|
260
|
+
const d = firstDifference(final, result.value.state) ?? firstDifference(outcome, result.value.outcome);
|
|
261
|
+
if (d !== null) fail("replay", `the replay ends somewhere else: ${d}`, endTick);
|
|
262
|
+
}
|
|
263
|
+
const runner = replaySimulation(simulation, jsonRoundTrip(replay));
|
|
264
|
+
if (!runner.ok) fail("replay", `the session runner's replay doesn't start: ${show(runner.error)}`, endTick);
|
|
265
|
+
else {
|
|
266
|
+
const d = firstDifference(final, runner.value.state) ?? firstDifference(rngs[endTick], runner.value.rng);
|
|
267
|
+
if (d !== null) fail("replay", `the session runner's replay ends somewhere else: ${d}`, endTick);
|
|
268
|
+
}
|
|
269
|
+
} catch (error) {
|
|
270
|
+
fail("replay", `replaying threw: ${errorMessage(error)}`, endTick);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
{
|
|
274
|
+
rec.ran("checkpoint");
|
|
275
|
+
const k = Math.min(options.checkpointAt ?? Math.floor(endTick / 2), endTick);
|
|
276
|
+
const checkpoint = {
|
|
277
|
+
state: states[k],
|
|
278
|
+
rng: rngs[k],
|
|
279
|
+
tick: k
|
|
280
|
+
};
|
|
281
|
+
if (!isJson(checkpoint)) fail("checkpoint", "the checkpoint isn't JSON", k);
|
|
282
|
+
else {
|
|
283
|
+
const restored = jsonRoundTrip(checkpoint);
|
|
284
|
+
const decoded = await validate(simulation.schemas.state, restored.state);
|
|
285
|
+
if (!decoded.ok) fail("checkpoint", `a checkpoint state fails schemas.state: ${formatIssues(decoded.issues)}`, k);
|
|
286
|
+
else if (!deepEqual(decoded.value, states[k])) fail("checkpoint", `schemas.state changed a valid checkpoint state: ${firstDifference(states[k], decoded.value)}`, k);
|
|
287
|
+
else try {
|
|
288
|
+
const resumed = restoreRng(restored.rng);
|
|
289
|
+
let current = decoded.value;
|
|
290
|
+
for (let t = k; t < endTick; t += 1) current = simulation.step(current, inputsAt(t), resumed).state;
|
|
291
|
+
const d = firstDifference(final, current);
|
|
292
|
+
if (d !== null) fail("checkpoint", `resuming from the checkpoint at tick ${k} ended differently: ${d}`, k);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
fail("checkpoint", `resuming from a checkpoint threw: ${errorMessage(error)}`, k);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
await Promise.all(validations);
|
|
301
|
+
if (options.stepBudgetMs !== void 0 && stepCount > 0) {
|
|
302
|
+
rec.ran("budget");
|
|
303
|
+
const mean = stepTime / stepCount;
|
|
304
|
+
if (mean > options.stepBudgetMs) rec.fail("budget", { message: `the mean step took ${mean.toFixed(3)} ms, over the ${options.stepBudgetMs} ms budget` });
|
|
305
|
+
else rec.note("budget", `mean step ${mean.toFixed(3)} ms`);
|
|
306
|
+
}
|
|
307
|
+
return rec.report(options.name ?? "simulation", totalRuns, totalTicks);
|
|
308
|
+
};
|
|
309
|
+
//#endregion
|
|
310
|
+
export { SIMULATION_CHECKS, checkSimulation };
|
|
311
|
+
|
|
312
|
+
//# sourceMappingURL=simulation-conformance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"simulation-conformance.js","names":["replaySimulation","runnerReplaySimulation"],"sources":["../src/simulation-conformance.ts"],"sourcesContent":["// The Layer 3 conformance suite (game-contract.md, \"Conformance tests\", Layer 3). It runs\n// fixed-step simulations from fixed seeds with generated tick inputs and checks determinism,\n// replay, checkpoints, JSON safety, schemas, step purity, invariants and outcomes.\n\nimport {\n isJson,\n type FixedStepSimulation,\n type Json,\n type Outcome,\n type Rng,\n type RngState,\n type StepResult,\n type TickInput,\n type Validator,\n} from '@joystyk/contracts';\nimport { restoreRng } from '@joystyk/kit';\nimport { replaySimulation as runnerReplaySimulation } from '@joystyk/session';\nimport { simulationReplay } from './builders.ts';\nimport { deepEqual, deepFreeze, errorMessage, firstDifference, jsonProblem, jsonRoundTrip, show } from './deep.ts';\nimport type { InvariantResult } from './engine-conformance.ts';\nimport { replaySimulation } from './replay.ts';\nimport { Recorder, type ConformanceReport, type Failure } from './report.ts';\nimport { cloneRng, testRng, testSeed } from './rng.ts';\nimport { formatIssues, validate } from './schema.ts';\n\n/** Options for `checkSimulation` and `runSimulationConformance`. */\nexport type SimulationConformanceOptions<State, Input, Config> = {\n /** A name for reports and the vitest `describe` block. */\n readonly name?: string;\n /** The game id written into replays. Default \"test-game\". */\n readonly gameId?: string;\n /** The configs to start from; each runs `runs` times with different seeds. */\n readonly configs: readonly Config[];\n /** Runs per config. Default 4. */\n readonly runs?: number;\n /** Ticks per run (a run also stops at its outcome). Default 1000. */\n readonly ticks?: number;\n /** The base seed. Run `r` of config `c` uses the seed string `${seed}/${c}/${r}`. Default \"conformance\". */\n readonly seed?: string;\n /** Scripted inputs for a tick. Draw any randomness from `rng`. Default: no inputs. */\n readonly inputs?: (state: State, tick: number, rng: Rng) => readonly Input[];\n /** Called on every state the simulation reaches. */\n readonly invariants?: (state: State) => InvariantResult;\n /** Fail a run that has no outcome after `ticks` ticks. Default false. */\n readonly requireOutcome?: boolean;\n /** The tick of the checkpoint in the checkpoint-equivalence check. Default half the run. */\n readonly checkpointAt?: number;\n /** Fail when the mean step takes longer than this many milliseconds. Off by default. */\n readonly stepBudgetMs?: number;\n /** Run only one run, to reproduce a reported failure (`setup` is the config index). */\n readonly only?: { readonly setup: number; readonly game: number };\n /** Failures kept per check in the report. Default 3. */\n readonly maxFailuresPerCheck?: number;\n};\n\n/** The checks this suite runs, in report order. */\nexport const SIMULATION_CHECKS = [\n { id: 'determinism', title: 'Determinism: the same seed and tick inputs give the same states and events' },\n { id: 'replay', title: 'Replay: the recorded tick log reproduces the final state' },\n { id: 'checkpoint', title: 'Checkpoints: saving, restoring and continuing equals running straight through' },\n { id: 'json', title: 'JSON safety of configs, states, inputs, events and outcomes' },\n { id: 'schemas', title: \"Schema agreement: every value passes the simulation's own schemas\" },\n {\n id: 'purity',\n title: 'Step purity: the same state, inputs and generator give the same next state, without mutation',\n },\n { id: 'invariants', title: 'Invariants hold on every state the simulation reaches' },\n { id: 'outcome', title: 'Outcomes are valid and detected in time' },\n { id: 'budget', title: 'Step-time budget' },\n] as const;\n\n/** The id of one simulation check. */\nexport type SimulationCheckId = (typeof SIMULATION_CHECKS)[number]['id'];\n\nconst now = (): number => (globalThis as { performance?: { now(): number } }).performance?.now() ?? 0;\n\nconst outcomeProblem = (outcome: Outcome): string | null => {\n if (!['won', 'lost', 'draw', 'completed', 'abandoned'].includes(outcome.result))\n return `unknown result ${show(outcome.result)}`;\n if (typeof outcome.summary !== 'string') return 'summary must be a string';\n if (outcome.elapsedMs !== undefined || outcome.undosUsed !== undefined || outcome.assists !== undefined) {\n return 'simulations never set elapsedMs, undosUsed or assists; the scheduler adds them';\n }\n if (outcome.placements.length === 0) return 'placements is empty; a solo game has one entry';\n for (const p of outcome.placements) {\n if (!Number.isInteger(p.place) || p.place < 1)\n return `seat ${p.seat} has place ${show(p.place)}; places start at 1`;\n }\n if (new Set(outcome.placements.map((p) => p.seat)).size !== outcome.placements.length)\n return 'a seat is placed twice';\n return null;\n};\n\n/**\n * Runs the Layer 3 conformance suite against `simulation` and returns a report. It never\n * throws for a failing check. Use `runSimulationConformance` to get one vitest case per check.\n */\nexport const checkSimulation = async <\n State extends Json,\n Input extends Json,\n View,\n Event extends Json,\n Config extends Json,\n>(\n simulation: FixedStepSimulation<State, Input, View, Event, Config>,\n options: SimulationConformanceOptions<State, Input, Config>,\n): Promise<ConformanceReport> => {\n if (options.configs.length === 0) throw new TypeError('Simulation conformance needs at least one config.');\n const rec = new Recorder(SIMULATION_CHECKS, options.maxFailuresPerCheck ?? 3);\n const runs = options.runs ?? 4;\n const ticks = options.ticks ?? 1000;\n const baseSeed = options.seed ?? 'conformance';\n const gameId = options.gameId ?? 'test-game';\n if (options.invariants === undefined) rec.skip('invariants', 'no invariants hook given');\n if (options.stepBudgetMs === undefined) rec.skip('budget', 'no stepBudgetMs given');\n\n const validations: Promise<void>[] = [];\n const checkSchema = (\n schema: Validator<unknown>,\n value: unknown,\n what: string,\n fail: (message: string) => void,\n ): void => {\n rec.ran('schemas');\n validations.push(\n validate(schema, value).then(\n (result) => {\n if (!result.ok) fail(`${what} fails its schema: ${formatIssues(result.issues)}; value ${show(value)}`);\n },\n (error: unknown) => fail(`${what}: the schema threw ${errorMessage(error)}`),\n ),\n );\n };\n\n let totalRuns = 0;\n let totalTicks = 0;\n let stepTime = 0;\n let stepCount = 0;\n\n for (const [configIndex, config] of options.configs.entries()) {\n rec.ran('json');\n if (!isJson(config))\n rec.fail('json', { message: `config ${configIndex} isn't JSON: ${jsonProblem(config)}`, setup: configIndex });\n checkSchema(simulation.schemas.config, config, `config ${configIndex}`, (message) =>\n rec.fail('schemas', { message, setup: configIndex }),\n );\n\n for (let runIndex = 0; runIndex < runs; runIndex += 1) {\n if (options.only !== undefined && (options.only.setup !== configIndex || options.only.game !== runIndex))\n continue;\n totalRuns += 1;\n const seed = `${baseSeed}/${configIndex}/${runIndex}`;\n const seedState = testSeed(seed, 'engine');\n const driver = testRng(seed, 'driver');\n const log: TickInput<Input>[] = [];\n const states: State[] = [];\n const rngs: RngState[] = [];\n const results: StepResult<State, Event>[] = [];\n\n const replayUpTo = (endTick: number) =>\n simulationReplay({\n gameId,\n rulesVersion: simulation.rulesVersion,\n config,\n seed: seedState,\n inputs: log.filter((entry) => entry.tick < endTick),\n endTick,\n });\n const fail = (id: SimulationCheckId, message: string, tick: number, input?: unknown): void => {\n const failure: Failure = {\n message,\n setup: configIndex,\n game: runIndex,\n seed,\n step: tick,\n replay: replayUpTo(tick),\n ...(input === undefined ? {} : { input }),\n };\n rec.fail(id, failure);\n };\n\n const rng = restoreRng(seedState);\n let started: ReturnType<typeof simulation.init>;\n try {\n started = simulation.init(config, rng);\n } catch (error) {\n fail('purity', `init threw: ${errorMessage(error)}`, 0);\n continue;\n }\n if (!started.ok) {\n fail('outcome', `init rejected config ${configIndex}: ${show(started.error)}`, 0);\n continue;\n }\n let state = deepFreeze(started.value);\n let outcome: Outcome | null = null;\n let tick = 0;\n\n const checkState = (value: State, at: number): void => {\n rec.ran('json');\n if (!isJson(value)) fail('json', `the state isn't JSON: ${jsonProblem(value)}`, at);\n checkSchema(simulation.schemas.state, value, 'the state', (m) => fail('schemas', m, at));\n if (options.invariants !== undefined) {\n rec.ran('invariants');\n try {\n const result = options.invariants(value);\n if (result === false || typeof result === 'string') {\n fail(\n 'invariants',\n `invariant violated: ${result === false ? 'the invariant hook returned false' : result}`,\n at,\n );\n }\n } catch (error) {\n fail('invariants', `invariant violated: ${errorMessage(error)}`, at);\n }\n }\n };\n const findOutcome = (value: State, at: number): Outcome | null => {\n const found = simulation.outcome(value);\n if (found !== null) {\n rec.ran('outcome');\n if (!isJson(found)) fail('json', `the outcome isn't JSON: ${jsonProblem(found)}`, at);\n const problem = outcomeProblem(found);\n if (problem !== null) fail('outcome', `invalid outcome: ${problem}; outcome ${show(found)}`, at);\n }\n return found;\n };\n\n checkState(state, 0);\n outcome = findOutcome(state, 0);\n let broken = false;\n for (; tick < ticks && outcome === null; tick += 1) {\n states.push(state);\n rngs.push(rng.save());\n let inputs: readonly Input[] = [];\n try {\n inputs = deepFreeze([...(options.inputs?.(state, tick, driver) ?? [])]);\n } catch (error) {\n fail('purity', `the inputs generator threw: ${errorMessage(error)}`, tick);\n }\n for (const input of inputs) {\n rec.ran('json');\n if (!isJson(input)) fail('json', `an input isn't JSON: ${jsonProblem(input)}`, tick, input);\n checkSchema(simulation.schemas.input, input, 'an input', (m) => fail('schemas', m, tick, input));\n log.push({ tick, input });\n }\n\n // Purity: a trial step on a copy of the generator, then the real one; they must agree.\n let trial: StepResult<State, Event> | null = null;\n const trialRng = cloneRng(rng);\n try {\n trial = simulation.step(state, inputs, trialRng);\n } catch (error) {\n const message = errorMessage(error);\n const mutation = /read[- ]only|not extensible|frozen|Cannot (assign|add|delete|define)/i.test(message);\n fail(\n 'purity',\n mutation ? `step mutated its input (states and inputs are frozen): ${message}` : `step threw: ${message}`,\n tick,\n );\n broken = true;\n break;\n }\n let result: StepResult<State, Event>;\n const t0 = now();\n try {\n result = deepFreeze(simulation.step(state, inputs, rng));\n } catch (error) {\n fail('purity', `step threw on a second call with the same arguments: ${errorMessage(error)}`, tick);\n broken = true;\n break;\n }\n stepTime += now() - t0;\n stepCount += 1;\n rec.ran('purity');\n const diff = firstDifference(trial, result) ?? firstDifference(trialRng.save(), rng.save());\n if (diff !== null)\n fail('purity', `step gave two different answers for the same state, inputs and generator: ${diff}`, tick);\n\n rec.ran('json');\n if (!isJson(result)) fail('json', `a step result isn't JSON: ${jsonProblem(result)}`, tick);\n for (const event of result.events ?? [])\n checkSchema(simulation.schemas.event, event, 'an event', (m) => fail('schemas', m, tick));\n results.push(result);\n state = result.state;\n checkState(state, tick + 1);\n outcome = findOutcome(state, tick + 1);\n }\n totalTicks += tick;\n if (broken) continue;\n states.push(state);\n rngs.push(rng.save());\n if (outcome === null && options.requireOutcome === true) {\n rec.ran('outcome');\n fail('outcome', `no outcome after ${ticks} ticks (raise ticks, or drop requireOutcome)`, tick);\n }\n const endTick = tick;\n const final = state;\n const inputsAt = (t: number): Input[] => log.filter((entry) => entry.tick === t).map((entry) => entry.input);\n\n // Determinism: a second run from the same seed with the recorded inputs.\n {\n rec.ran('determinism');\n try {\n const again = restoreRng(seedState);\n const init = simulation.init(config, again);\n let current = init.ok ? init.value : null;\n const initDiff = init.ok ? firstDifference(states[0], init.value) : 'init rejected';\n if (initDiff !== null) fail('determinism', `init gave a different state for the same seed: ${initDiff}`, 0);\n for (let t = 0; t < endTick && current !== null && initDiff === null; t += 1) {\n const result = simulation.step(current, inputsAt(t), again);\n const d = firstDifference(results[t], result) ?? firstDifference(rngs[t + 1], again.save());\n if (d !== null) {\n fail('determinism', `a second run from the same seed diverged at tick ${t}: ${d}`, t);\n break;\n }\n current = result.state;\n }\n } catch (error) {\n fail('determinism', `a second run threw: ${errorMessage(error)}`, 0);\n }\n }\n\n // Replay through the replay format, after a JSON round trip.\n {\n rec.ran('replay');\n const replay = replayUpTo(endTick);\n if (!isJson(replay)) fail('replay', \"the replay isn't JSON\", endTick);\n else {\n try {\n const result = replaySimulation(simulation, jsonRoundTrip(replay));\n if (!result.ok) fail('replay', `the replay doesn't start: ${show(result.error)}`, endTick);\n else {\n const d = firstDifference(final, result.value.state) ?? firstDifference(outcome, result.value.outcome);\n if (d !== null) fail('replay', `the replay ends somewhere else: ${d}`, endTick);\n }\n // The session scheduler's own replay.\n const runner = runnerReplaySimulation(simulation, jsonRoundTrip(replay));\n if (!runner.ok) fail('replay', `the session runner's replay doesn't start: ${show(runner.error)}`, endTick);\n else {\n const d = firstDifference(final, runner.value.state) ?? firstDifference(rngs[endTick], runner.value.rng);\n if (d !== null) fail('replay', `the session runner's replay ends somewhere else: ${d}`, endTick);\n }\n } catch (error) {\n fail('replay', `replaying threw: ${errorMessage(error)}`, endTick);\n }\n }\n }\n\n // Checkpoint equivalence: save at a tick, restore after JSON, run the rest.\n {\n rec.ran('checkpoint');\n const k = Math.min(options.checkpointAt ?? Math.floor(endTick / 2), endTick);\n const checkpoint = { state: states[k], rng: rngs[k], tick: k };\n if (!isJson(checkpoint)) fail('checkpoint', \"the checkpoint isn't JSON\", k);\n else {\n const restored = jsonRoundTrip(checkpoint);\n const decoded = await validate(simulation.schemas.state, restored.state);\n if (!decoded.ok)\n fail('checkpoint', `a checkpoint state fails schemas.state: ${formatIssues(decoded.issues)}`, k);\n else if (!deepEqual(decoded.value, states[k])) {\n fail(\n 'checkpoint',\n `schemas.state changed a valid checkpoint state: ${firstDifference(states[k], decoded.value)}`,\n k,\n );\n } else {\n try {\n const resumed = restoreRng(restored.rng as RngState);\n let current = decoded.value;\n for (let t = k; t < endTick; t += 1) {\n current = simulation.step(current, inputsAt(t), resumed).state;\n }\n const d = firstDifference(final, current);\n if (d !== null)\n fail('checkpoint', `resuming from the checkpoint at tick ${k} ended differently: ${d}`, k);\n } catch (error) {\n fail('checkpoint', `resuming from a checkpoint threw: ${errorMessage(error)}`, k);\n }\n }\n }\n }\n }\n }\n\n await Promise.all(validations);\n if (options.stepBudgetMs !== undefined && stepCount > 0) {\n rec.ran('budget');\n const mean = stepTime / stepCount;\n if (mean > options.stepBudgetMs) {\n rec.fail('budget', {\n message: `the mean step took ${mean.toFixed(3)} ms, over the ${options.stepBudgetMs} ms budget`,\n });\n } else {\n rec.note('budget', `mean step ${mean.toFixed(3)} ms`);\n }\n }\n return rec.report(options.name ?? 'simulation', totalRuns, totalTicks);\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type GameStorage, type Json } from '@joystyk/contracts';
|
|
2
|
+
export type StorageOperation = 'get' | 'set' | 'delete';
|
|
3
|
+
/** One call the fake storage received, in order. */
|
|
4
|
+
export type StorageCall = {
|
|
5
|
+
readonly op: 'get';
|
|
6
|
+
readonly key: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly op: 'set';
|
|
9
|
+
readonly key: string;
|
|
10
|
+
readonly value: Json;
|
|
11
|
+
} | {
|
|
12
|
+
readonly op: 'delete';
|
|
13
|
+
readonly key: string;
|
|
14
|
+
};
|
|
15
|
+
/** `GameStorage` plus what a test needs to inspect and break it. */
|
|
16
|
+
export interface FakeStorage extends GameStorage {
|
|
17
|
+
/** The stored values, decoded, keyed by storage key. */
|
|
18
|
+
entries(): Record<string, Json>;
|
|
19
|
+
/** Every call so far, in order (failed calls included). */
|
|
20
|
+
calls(): readonly StorageCall[];
|
|
21
|
+
/** Makes the next call of `op` reject with `error` (default: an Error naming the key). */
|
|
22
|
+
failNext(op: StorageOperation, error?: unknown): void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* An in-memory `GameStorage`. Values are stored as JSON text, as a real store serializes
|
|
26
|
+
* them, so reading returns a fresh copy and storing a value that isn't JSON (`undefined`,
|
|
27
|
+
* `NaN`, a `Date`, a class instance) rejects with a `TypeError` instead of passing silently.
|
|
28
|
+
*/
|
|
29
|
+
export declare const createFakeStorage: (initial?: Readonly<Record<string, Json>>) => FakeStorage;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isJson } from "@joystyk/contracts";
|
|
2
|
+
//#region src/storage.ts
|
|
3
|
+
/**
|
|
4
|
+
* An in-memory `GameStorage`. Values are stored as JSON text, as a real store serializes
|
|
5
|
+
* them, so reading returns a fresh copy and storing a value that isn't JSON (`undefined`,
|
|
6
|
+
* `NaN`, a `Date`, a class instance) rejects with a `TypeError` instead of passing silently.
|
|
7
|
+
*/
|
|
8
|
+
var createFakeStorage = (initial = {}) => {
|
|
9
|
+
const data = /* @__PURE__ */ new Map();
|
|
10
|
+
const log = [];
|
|
11
|
+
const failures = [];
|
|
12
|
+
const put = (key, value) => {
|
|
13
|
+
if (!isJson(value)) throw new TypeError(`Storage values must be JSON; "${key}" isn't.`);
|
|
14
|
+
data.set(key, JSON.stringify(value));
|
|
15
|
+
};
|
|
16
|
+
for (const [key, value] of Object.entries(initial)) put(key, value);
|
|
17
|
+
const takeFailure = (op, key) => {
|
|
18
|
+
const index = failures.findIndex((f) => f.op === op);
|
|
19
|
+
if (index === -1) return;
|
|
20
|
+
const [failure] = failures.splice(index, 1);
|
|
21
|
+
throw failure?.error ?? /* @__PURE__ */ new Error(`Fake storage: ${op} "${key}" failed.`);
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
get: async (key) => {
|
|
25
|
+
log.push({
|
|
26
|
+
op: "get",
|
|
27
|
+
key
|
|
28
|
+
});
|
|
29
|
+
takeFailure("get", key);
|
|
30
|
+
const text = data.get(key);
|
|
31
|
+
return text === void 0 ? null : JSON.parse(text);
|
|
32
|
+
},
|
|
33
|
+
set: async (key, value) => {
|
|
34
|
+
log.push({
|
|
35
|
+
op: "set",
|
|
36
|
+
key,
|
|
37
|
+
value
|
|
38
|
+
});
|
|
39
|
+
takeFailure("set", key);
|
|
40
|
+
put(key, value);
|
|
41
|
+
},
|
|
42
|
+
delete: async (key) => {
|
|
43
|
+
log.push({
|
|
44
|
+
op: "delete",
|
|
45
|
+
key
|
|
46
|
+
});
|
|
47
|
+
takeFailure("delete", key);
|
|
48
|
+
data.delete(key);
|
|
49
|
+
},
|
|
50
|
+
entries: () => Object.fromEntries([...data].map(([key, text]) => [key, JSON.parse(text)])),
|
|
51
|
+
calls: () => log.slice(),
|
|
52
|
+
failNext: (op, error) => {
|
|
53
|
+
failures.push({
|
|
54
|
+
op,
|
|
55
|
+
error: error ?? /* @__PURE__ */ new Error(`Fake storage: injected ${op} failure.`)
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
export { createFakeStorage };
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=storage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage.js","names":[],"sources":["../src/storage.ts"],"sourcesContent":["// A fake storage port: the contract's `GameStorage`, in memory, with failure injection.\n\nimport { isJson, type GameStorage, type Json } from '@joystyk/contracts';\n\nexport type StorageOperation = 'get' | 'set' | 'delete';\n\n/** One call the fake storage received, in order. */\nexport type StorageCall =\n | { readonly op: 'get'; readonly key: string }\n | { readonly op: 'set'; readonly key: string; readonly value: Json }\n | { readonly op: 'delete'; readonly key: string };\n\n/** `GameStorage` plus what a test needs to inspect and break it. */\nexport interface FakeStorage extends GameStorage {\n /** The stored values, decoded, keyed by storage key. */\n entries(): Record<string, Json>;\n /** Every call so far, in order (failed calls included). */\n calls(): readonly StorageCall[];\n /** Makes the next call of `op` reject with `error` (default: an Error naming the key). */\n failNext(op: StorageOperation, error?: unknown): void;\n}\n\n/**\n * An in-memory `GameStorage`. Values are stored as JSON text, as a real store serializes\n * them, so reading returns a fresh copy and storing a value that isn't JSON (`undefined`,\n * `NaN`, a `Date`, a class instance) rejects with a `TypeError` instead of passing silently.\n */\nexport const createFakeStorage = (initial: Readonly<Record<string, Json>> = {}): FakeStorage => {\n const data = new Map<string, string>();\n const log: StorageCall[] = [];\n const failures: { op: StorageOperation; error: unknown }[] = [];\n\n const put = (key: string, value: Json): void => {\n if (!isJson(value)) throw new TypeError(`Storage values must be JSON; \"${key}\" isn't.`);\n data.set(key, JSON.stringify(value));\n };\n for (const [key, value] of Object.entries(initial)) put(key, value);\n\n const takeFailure = (op: StorageOperation, key: string): void => {\n const index = failures.findIndex((f) => f.op === op);\n if (index === -1) return;\n const [failure] = failures.splice(index, 1);\n throw failure?.error ?? new Error(`Fake storage: ${op} \"${key}\" failed.`);\n };\n\n return {\n get: async (key) => {\n log.push({ op: 'get', key });\n takeFailure('get', key);\n const text = data.get(key);\n return text === undefined ? null : (JSON.parse(text) as Json);\n },\n set: async (key, value) => {\n log.push({ op: 'set', key, value });\n takeFailure('set', key);\n put(key, value);\n },\n delete: async (key) => {\n log.push({ op: 'delete', key });\n takeFailure('delete', key);\n data.delete(key);\n },\n entries: () => Object.fromEntries([...data].map(([key, text]) => [key, JSON.parse(text) as Json])),\n calls: () => log.slice(),\n failNext: (op, error) => {\n failures.push({ op, error: error ?? new Error(`Fake storage: injected ${op} failure.`) });\n },\n };\n};\n"],"mappings":";;;;;;;AA2BA,IAAa,qBAAqB,UAA0C,CAAC,MAAmB;CAC9F,MAAM,uBAAO,IAAI,IAAoB;CACrC,MAAM,MAAqB,CAAC;CAC5B,MAAM,WAAuD,CAAC;CAE9D,MAAM,OAAO,KAAa,UAAsB;EAC9C,IAAI,CAAC,OAAO,KAAK,GAAG,MAAM,IAAI,UAAU,iCAAiC,IAAI,SAAS;EACtF,KAAK,IAAI,KAAK,KAAK,UAAU,KAAK,CAAC;CACrC;CACA,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,GAAG,IAAI,KAAK,KAAK;CAElE,MAAM,eAAe,IAAsB,QAAsB;EAC/D,MAAM,QAAQ,SAAS,WAAW,MAAM,EAAE,OAAO,EAAE;EACnD,IAAI,UAAU,IAAI;EAClB,MAAM,CAAC,WAAW,SAAS,OAAO,OAAO,CAAC;EAC1C,MAAM,SAAS,yBAAS,IAAI,MAAM,iBAAiB,GAAG,IAAI,IAAI,UAAU;CAC1E;CAEA,OAAO;EACL,KAAK,OAAO,QAAQ;GAClB,IAAI,KAAK;IAAE,IAAI;IAAO;GAAI,CAAC;GAC3B,YAAY,OAAO,GAAG;GACtB,MAAM,OAAO,KAAK,IAAI,GAAG;GACzB,OAAO,SAAS,KAAA,IAAY,OAAQ,KAAK,MAAM,IAAI;EACrD;EACA,KAAK,OAAO,KAAK,UAAU;GACzB,IAAI,KAAK;IAAE,IAAI;IAAO;IAAK;GAAM,CAAC;GAClC,YAAY,OAAO,GAAG;GACtB,IAAI,KAAK,KAAK;EAChB;EACA,QAAQ,OAAO,QAAQ;GACrB,IAAI,KAAK;IAAE,IAAI;IAAU;GAAI,CAAC;GAC9B,YAAY,UAAU,GAAG;GACzB,KAAK,OAAO,GAAG;EACjB;EACA,eAAe,OAAO,YAAY,CAAC,GAAG,IAAI,CAAC,CAAC,KAAK,CAAC,KAAK,UAAU,CAAC,KAAK,KAAK,MAAM,IAAI,CAAS,CAAC,CAAC;EACjG,aAAa,IAAI,MAAM;EACvB,WAAW,IAAI,UAAU;GACvB,SAAS,KAAK;IAAE;IAAI,OAAO,yBAAS,IAAI,MAAM,0BAA0B,GAAG,UAAU;GAAE,CAAC;EAC1F;CACF;AACF"}
|
package/dist/vitest.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DiscreteEngine, FixedStepSimulation, Json } from '@joystyk/contracts';
|
|
2
|
+
import { type EngineConformanceOptions } from './engine-conformance.js';
|
|
3
|
+
import { type ConformanceReport } from './report.js';
|
|
4
|
+
import { type SimulationConformanceOptions } from './simulation-conformance.js';
|
|
5
|
+
/** Options the vitest wrappers add. */
|
|
6
|
+
export type VitestOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* Timeout for each case in milliseconds. The suite plays its games once, in the first case,
|
|
9
|
+
* so that case takes all the time: over a minute for the heaviest games on a loaded machine.
|
|
10
|
+
* Default 300 000.
|
|
11
|
+
*/
|
|
12
|
+
readonly timeoutMs?: number;
|
|
13
|
+
};
|
|
14
|
+
/** Thrown by a failing conformance case; its message is the check's report. */
|
|
15
|
+
export declare class ConformanceError extends Error {
|
|
16
|
+
readonly name = "ConformanceError";
|
|
17
|
+
}
|
|
18
|
+
/** Throws a `ConformanceError` with the whole report unless every check passed or was skipped. */
|
|
19
|
+
export declare const expectConformance: (report: ConformanceReport) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Registers the Layer 2 conformance suite as vitest cases: a `describe` block with one `it`
|
|
22
|
+
* per check. The suite plays its games once, in whichever case runs first; a failing case
|
|
23
|
+
* prints the failures with their seeds and input sequences. Call it at the top level of a
|
|
24
|
+
* test file:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* runEngineConformance(engine, { configs: [{ heap: 10 }], seatCounts: [2], commands });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare const runEngineConformance: <State extends Json, Command extends Json, View extends Json, Event extends Json, Config extends Json>(engine: DiscreteEngine<State, Command, View, Event, Config>, options: EngineConformanceOptions<State, Command, Config> & VitestOptions) => void;
|
|
31
|
+
/** Registers the Layer 3 conformance suite as vitest cases, like `runEngineConformance`. */
|
|
32
|
+
export declare const runSimulationConformance: <State extends Json, Input extends Json, View, Event extends Json, Config extends Json>(simulation: FixedStepSimulation<State, Input, View, Event, Config>, options: SimulationConformanceOptions<State, Input, Config> & VitestOptions) => void;
|
package/dist/vitest.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { checkById, formatCheck, formatReport } from "./report.js";
|
|
2
|
+
import { ENGINE_CHECKS, checkEngine } from "./engine-conformance.js";
|
|
3
|
+
import { SIMULATION_CHECKS, checkSimulation } from "./simulation-conformance.js";
|
|
4
|
+
import { describe, it } from "vitest";
|
|
5
|
+
//#region src/vitest.ts
|
|
6
|
+
var CASE_TIMEOUT_MS = 3e5;
|
|
7
|
+
/** Thrown by a failing conformance case; its message is the check's report. */
|
|
8
|
+
var ConformanceError = class extends Error {
|
|
9
|
+
name = "ConformanceError";
|
|
10
|
+
};
|
|
11
|
+
/** Throws a `ConformanceError` with the whole report unless every check passed or was skipped. */
|
|
12
|
+
var expectConformance = (report) => {
|
|
13
|
+
if (!report.ok) throw new ConformanceError(formatReport(report));
|
|
14
|
+
};
|
|
15
|
+
var register = (subject, checks, run, timeoutMs) => {
|
|
16
|
+
describe(`conformance: ${subject}`, () => {
|
|
17
|
+
let report;
|
|
18
|
+
for (const check of checks) it(`[${check.id}] ${check.title}`, async (context) => {
|
|
19
|
+
report ??= run();
|
|
20
|
+
const result = checkById(await report, check.id);
|
|
21
|
+
if (result.status === "skipped") {
|
|
22
|
+
context.skip(result.note);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (result.status === "failed") throw new ConformanceError(formatCheck(result));
|
|
26
|
+
}, timeoutMs);
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Registers the Layer 2 conformance suite as vitest cases: a `describe` block with one `it`
|
|
31
|
+
* per check. The suite plays its games once, in whichever case runs first; a failing case
|
|
32
|
+
* prints the failures with their seeds and input sequences. Call it at the top level of a
|
|
33
|
+
* test file:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* runEngineConformance(engine, { configs: [{ heap: 10 }], seatCounts: [2], commands });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
var runEngineConformance = (engine, options) => register(options.name ?? "engine", ENGINE_CHECKS, () => checkEngine(engine, options), options.timeoutMs ?? CASE_TIMEOUT_MS);
|
|
40
|
+
/** Registers the Layer 3 conformance suite as vitest cases, like `runEngineConformance`. */
|
|
41
|
+
var runSimulationConformance = (simulation, options) => register(options.name ?? "simulation", SIMULATION_CHECKS, () => checkSimulation(simulation, options), options.timeoutMs ?? CASE_TIMEOUT_MS);
|
|
42
|
+
//#endregion
|
|
43
|
+
export { ConformanceError, expectConformance, runEngineConformance, runSimulationConformance };
|
|
44
|
+
|
|
45
|
+
//# sourceMappingURL=vitest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.js","names":[],"sources":["../src/vitest.ts"],"sourcesContent":["// The vitest face of the suites: one `it` per check, each failing with a reproducible report.\n\nimport type { DiscreteEngine, FixedStepSimulation, Json } from '@joystyk/contracts';\nimport { describe, it } from 'vitest';\nimport { checkEngine, ENGINE_CHECKS, type EngineConformanceOptions } from './engine-conformance.ts';\nimport { checkById, formatCheck, formatReport, type ConformanceReport } from './report.ts';\nimport { checkSimulation, SIMULATION_CHECKS, type SimulationConformanceOptions } from './simulation-conformance.ts';\n\n/** Options the vitest wrappers add. */\nexport type VitestOptions = {\n /**\n * Timeout for each case in milliseconds. The suite plays its games once, in the first case,\n * so that case takes all the time: over a minute for the heaviest games on a loaded machine.\n * Default 300 000.\n */\n readonly timeoutMs?: number;\n};\n\nconst CASE_TIMEOUT_MS = 300_000;\n\n/** Thrown by a failing conformance case; its message is the check's report. */\nexport class ConformanceError extends Error {\n override readonly name = 'ConformanceError';\n}\n\n/** Throws a `ConformanceError` with the whole report unless every check passed or was skipped. */\nexport const expectConformance = (report: ConformanceReport): void => {\n if (!report.ok) throw new ConformanceError(formatReport(report));\n};\n\nconst register = (\n subject: string,\n checks: readonly { readonly id: string; readonly title: string }[],\n run: () => Promise<ConformanceReport>,\n timeoutMs: number,\n): void => {\n describe(`conformance: ${subject}`, () => {\n let report: Promise<ConformanceReport> | undefined;\n for (const check of checks) {\n it(\n `[${check.id}] ${check.title}`,\n async (context) => {\n report ??= run();\n const result = checkById(await report, check.id);\n if (result.status === 'skipped') {\n context.skip(result.note);\n return;\n }\n if (result.status === 'failed') throw new ConformanceError(formatCheck(result));\n },\n timeoutMs,\n );\n }\n });\n};\n\n/**\n * Registers the Layer 2 conformance suite as vitest cases: a `describe` block with one `it`\n * per check. The suite plays its games once, in whichever case runs first; a failing case\n * prints the failures with their seeds and input sequences. Call it at the top level of a\n * test file:\n *\n * ```ts\n * runEngineConformance(engine, { configs: [{ heap: 10 }], seatCounts: [2], commands });\n * ```\n */\nexport const runEngineConformance = <\n State extends Json,\n Command extends Json,\n View extends Json,\n Event extends Json,\n Config extends Json,\n>(\n engine: DiscreteEngine<State, Command, View, Event, Config>,\n options: EngineConformanceOptions<State, Command, Config> & VitestOptions,\n): void =>\n register(\n options.name ?? 'engine',\n ENGINE_CHECKS,\n () => checkEngine(engine, options),\n options.timeoutMs ?? CASE_TIMEOUT_MS,\n );\n\n/** Registers the Layer 3 conformance suite as vitest cases, like `runEngineConformance`. */\nexport const runSimulationConformance = <\n State extends Json,\n Input extends Json,\n View,\n Event extends Json,\n Config extends Json,\n>(\n simulation: FixedStepSimulation<State, Input, View, Event, Config>,\n options: SimulationConformanceOptions<State, Input, Config> & VitestOptions,\n): void =>\n register(\n options.name ?? 'simulation',\n SIMULATION_CHECKS,\n () => checkSimulation(simulation, options),\n options.timeoutMs ?? CASE_TIMEOUT_MS,\n );\n"],"mappings":";;;;;AAkBA,IAAM,kBAAkB;;AAGxB,IAAa,mBAAb,cAAsC,MAAM;CAC1C,OAAyB;AAC3B;;AAGA,IAAa,qBAAqB,WAAoC;CACpE,IAAI,CAAC,OAAO,IAAI,MAAM,IAAI,iBAAiB,aAAa,MAAM,CAAC;AACjE;AAEA,IAAM,YACJ,SACA,QACA,KACA,cACS;CACT,SAAS,gBAAgB,iBAAiB;EACxC,IAAI;EACJ,KAAK,MAAM,SAAS,QAClB,GACE,IAAI,MAAM,GAAG,IAAI,MAAM,SACvB,OAAO,YAAY;GACjB,WAAW,IAAI;GACf,MAAM,SAAS,UAAU,MAAM,QAAQ,MAAM,EAAE;GAC/C,IAAI,OAAO,WAAW,WAAW;IAC/B,QAAQ,KAAK,OAAO,IAAI;IACxB;GACF;GACA,IAAI,OAAO,WAAW,UAAU,MAAM,IAAI,iBAAiB,YAAY,MAAM,CAAC;EAChF,GACA,SACF;CAEJ,CAAC;AACH;;;;;;;;;;;AAYA,IAAa,wBAOX,QACA,YAEA,SACE,QAAQ,QAAQ,UAChB,qBACM,YAAY,QAAQ,OAAO,GACjC,QAAQ,aAAa,eACvB;;AAGF,IAAa,4BAOX,YACA,YAEA,SACE,QAAQ,QAAQ,cAChB,yBACM,gBAAgB,YAAY,OAAO,GACzC,QAAQ,aAAa,eACvB"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { FrameScheduler } from '@joystyk/session';
|
|
2
|
+
/**
|
|
3
|
+
* The default wall-clock budget for a wait: generous, but under the browser and e2e
|
|
4
|
+
* projects' 60-second test timeout, so a wait that fails names what never happened instead of
|
|
5
|
+
* ending in a bare timeout.
|
|
6
|
+
*/
|
|
7
|
+
export declare const WAIT_BUDGET_MS = 45000;
|
|
8
|
+
/** Resolves after `ms` milliseconds: for yielding to the page, never for waiting on a result. */
|
|
9
|
+
export declare const pause: (ms: number) => Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Resolves on a fresh task, without the 4 ms clamp nested timers get, so the events,
|
|
12
|
+
* commands and due timers queued before it can run.
|
|
13
|
+
*/
|
|
14
|
+
export declare const nextTask: () => Promise<void>;
|
|
15
|
+
export type WaitOptions = {
|
|
16
|
+
/** The wall-clock budget. Default `WAIT_BUDGET_MS`. */
|
|
17
|
+
readonly timeoutMs?: number;
|
|
18
|
+
/** How often to check. Default 10 ms. */
|
|
19
|
+
readonly intervalMs?: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Resolves once `condition` holds, checking every few milliseconds. Fails with "Timed out
|
|
23
|
+
* waiting for <what>" after the budget, which by default is most of the test's own timeout.
|
|
24
|
+
*/
|
|
25
|
+
export declare const waitUntil: (condition: () => boolean, what: string, options?: WaitOptions) => Promise<void>;
|
|
26
|
+
export type FrameWaitOptions = {
|
|
27
|
+
/** The most frames that may run a callback before the wait fails: game time. Default 3600 (a minute at 60 Hz). */
|
|
28
|
+
readonly maxFrames?: number;
|
|
29
|
+
/** The wall-clock budget, for waits on the UI while the runner is paused. Default `WAIT_BUDGET_MS`. */
|
|
30
|
+
readonly timeoutMs?: number;
|
|
31
|
+
};
|
|
32
|
+
/** A frame scheduler that runs only when the test says so. */
|
|
33
|
+
export interface ManualFrames {
|
|
34
|
+
/** Pass this as the runner's `frames` port. */
|
|
35
|
+
readonly port: FrameScheduler;
|
|
36
|
+
/**
|
|
37
|
+
* Runs `count` frames, each `frameMs` after the last, and returns how many of them had a
|
|
38
|
+
* callback waiting. A paused or finished runner requests none, so it returns 0.
|
|
39
|
+
*/
|
|
40
|
+
run(count: number): number;
|
|
41
|
+
/** True when a callback waits for the next frame. */
|
|
42
|
+
pending(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Runs `count` frames, yielding to the page between them as `until` does, so input that
|
|
45
|
+
* arrives meanwhile is handled. For checking that nothing happens over some game time.
|
|
46
|
+
* Returns how many frames had a callback waiting.
|
|
47
|
+
*/
|
|
48
|
+
advance(count: number): Promise<number>;
|
|
49
|
+
/**
|
|
50
|
+
* Runs one frame at a time until `condition` holds, yielding to the page between frames
|
|
51
|
+
* so events, commands and timers run. Game time can't outrun the check, so a phase that
|
|
52
|
+
* lasts a few ticks is never skipped, and a busy machine only makes the wait take longer.
|
|
53
|
+
* Fails after `maxFrames` frames that ran a callback, or after the wall-clock budget.
|
|
54
|
+
*/
|
|
55
|
+
until(condition: () => boolean, what: string, options?: FrameWaitOptions): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
/** A manual frame scheduler for a fixed-step runner, at `frameMs` per frame (default 60 Hz). */
|
|
58
|
+
export declare const manualFrames: (frameMs?: number) => ManualFrames;
|