@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @joystyk/testing
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bb095c7: Share canonical IPv4 and IPv6 /64 rate-limit keys across Node services. Keep room
|
|
8
|
+
initialization diagnostics on the server behind a client-safe reference, and expire old
|
|
9
|
+
message-rate strikes. Extend hidden-information conformance checks with transition-aware
|
|
10
|
+
perturbations so private events remain testable when recipients gain cards.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- Updated dependencies [bb095c7]
|
|
15
|
+
- Updated dependencies [bb095c7]
|
|
16
|
+
- @joystyk/contracts@0.2.0
|
|
17
|
+
- @joystyk/session@0.2.0
|
|
18
|
+
- @joystyk/kit@0.2.0
|
|
19
|
+
|
|
20
|
+
## 0.1.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- Prepare the first compiled library release: ESM and API declarations for every public entry point, runtime assets, and checks against installed npm tarballs. Games and applications remain private.
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- Updated dependencies
|
|
29
|
+
- @joystyk/contracts@0.1.0
|
|
30
|
+
- @joystyk/kit@0.1.0
|
|
31
|
+
- @joystyk/session@0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joystyk contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Third-party material in the Joystyk library release
|
|
2
|
+
|
|
3
|
+
This notice preserves third-party attribution. Original Joystyk code in the 17
|
|
4
|
+
platform libraries under packages/ uses the MIT license in packages/LICENSE.
|
|
5
|
+
That choice does not license the private applications, games or other repository
|
|
6
|
+
material, and does not replace the third-party terms below.
|
|
7
|
+
|
|
8
|
+
@joystyk/contracts
|
|
9
|
+
------------------
|
|
10
|
+
Standard Schema v1 type definitions from @standard-schema/spec 1.1.0:
|
|
11
|
+
https://github.com/standard-schema/standard-schema
|
|
12
|
+
https://standardschema.dev
|
|
13
|
+
Copyright (c) 2024 Colin McDonnell. MIT License.
|
|
14
|
+
The complete license and source/change notes accompany the distributed
|
|
15
|
+
dist/standard-schema.d.ts definitions. Changes: individually exported declarations
|
|
16
|
+
and formatting; the declarations themselves are unchanged.
|
|
17
|
+
|
|
18
|
+
@joystyk/identity and @joystyk/content
|
|
19
|
+
-----------------------------------
|
|
20
|
+
Word blocklists derived from List of Dirty, Naughty, Obscene, and Otherwise Bad
|
|
21
|
+
Words, copyright Shutterstock, Inc. and contributors:
|
|
22
|
+
https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words
|
|
23
|
+
Licensed under Creative Commons Attribution 4.0 International:
|
|
24
|
+
https://creativecommons.org/licenses/by/4.0/
|
|
25
|
+
Identity uses the English list at commit
|
|
26
|
+
5faf2ba42d7b1c0977169ec3611df25a3c08eb13. Changes include reducing or dropping
|
|
27
|
+
phrases, dropping words with everyday meanings, substituting stems for inflections
|
|
28
|
+
and adding entries. Identity's README and content's lists/blocklist.txt retain
|
|
29
|
+
their source-specific provenance and changes.
|
|
30
|
+
|
|
31
|
+
@joystyk/content
|
|
32
|
+
---------------
|
|
33
|
+
SCOWL-derived common, vocabulary and ranked word lists use SCOWL 2020.12.07.
|
|
34
|
+
Their complete source licenses and attribution accompany the package in
|
|
35
|
+
lists/SCOWL-COPYRIGHT. Word-list transformations and build provenance are described
|
|
36
|
+
in the package README. Preserve that copyright file when redistributing the lists.
|
package/README.md
CHANGED
|
@@ -1,3 +1,192 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @joystyk/testing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Dev-time helpers that games and platform packages use from their own test files. The heart
|
|
4
|
+
of the package is the pair of **conformance suites** that check the
|
|
5
|
+
game contract (Joystyk host reference): a game opts in
|
|
6
|
+
with one short test file, and the suite plays hundreds of seeded random games against its
|
|
7
|
+
engine or simulation. Around them sit the fakes and builders that make other tests quick to
|
|
8
|
+
write: a test RNG, a fake clock, a fake storage port, replay and undo helpers, and fixture
|
|
9
|
+
builders.
|
|
10
|
+
|
|
11
|
+
## npm package
|
|
12
|
+
|
|
13
|
+
The first `0.1.0` release is being prepared; it has not been published. Once available:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install @joystyk/testing
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Node consumers require Node 24.15 or later. Browser entry points require a modern
|
|
20
|
+
browser and should be loaded in the browser. The root entry integrates with Vitest
|
|
21
|
+
4.1.11; use it inside a Vitest test. Fixtures can also be imported separately.
|
|
22
|
+
|
|
23
|
+
| Import | Format |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `@joystyk/testing` | ES module and TypeScript declarations |
|
|
26
|
+
| `@joystyk/testing/fixtures` | ES module and TypeScript declarations |
|
|
27
|
+
|
|
28
|
+
Only the entries listed above are public API. Installed packages contain compiled ESM,
|
|
29
|
+
type declarations and runtime assets. Repository-relative links describe the Joystyk
|
|
30
|
+
workspace and are not needed to use the npm package.
|
|
31
|
+
|
|
32
|
+
It depends on `@joystyk/contracts`, `@joystyk/kit` and `@joystyk/session` (whose runner
|
|
33
|
+
replay it cross-checks against), and on `vitest` as a peer. Never import it from production
|
|
34
|
+
code.
|
|
35
|
+
|
|
36
|
+
## Conformance for a discrete engine
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// games/nim/engine/conformance.test.ts
|
|
40
|
+
import { runEngineConformance } from '@joystyk/testing';
|
|
41
|
+
import { engine, migrations, perturbHidden } from './index.ts';
|
|
42
|
+
|
|
43
|
+
runEngineConformance(engine, {
|
|
44
|
+
name: 'nim',
|
|
45
|
+
configs: [{ heap: 5 }, { heap: 21 }],
|
|
46
|
+
seatCounts: [2],
|
|
47
|
+
// Candidate commands for a seat: all legal moves, or a sample. Illegal ones are fine;
|
|
48
|
+
// the engine's answer decides. Without this the suite asks engine.ai for moves.
|
|
49
|
+
commands: (state, seat) => [1, 2, 3].map((count) => ({ type: 'take', count })),
|
|
50
|
+
perturbHidden, // games with hidden information; see below
|
|
51
|
+
invariants: (state) => state.heap >= 0 || 'the heap is negative',
|
|
52
|
+
saves: { version: 2, migrations, fixtures: [/* one stored state per shipped save version */] },
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`runEngineConformance` registers a `describe` block with one `it` per check, so a failure
|
|
57
|
+
names the rule it broke. The games are played once, in the first case to run, and every
|
|
58
|
+
case reads the shared report. A check that doesn't apply (no AI, no hidden information) is
|
|
59
|
+
reported as skipped, with the reason.
|
|
60
|
+
|
|
61
|
+
Each failure prints what broke, the game's seed, the number of inputs before it, the input
|
|
62
|
+
being checked, and the replay of every accepted input so far:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
[no-leak] … : failed, 14 failures
|
|
66
|
+
#1 viewFor(state, 0) changed when only hidden information changed: $.others[1][0].id: "q6y31" vs "brh76"
|
|
67
|
+
at: setup 0, game 3, seed "conformance/0/3", after 4 inputs
|
|
68
|
+
rerun only this game: add `only: { setup: 0, game: 3 }` to the options
|
|
69
|
+
replay: {"layer":2,"gameId":"test-game",…,"inputs":[…]}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Add the `only` option to replay just that game, or pass the printed replay to
|
|
73
|
+
`replayEngine(engine, replay)` to reach the failing state in a debugger.
|
|
74
|
+
|
|
75
|
+
### What it checks
|
|
76
|
+
|
|
77
|
+
| Check | What fails it |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `determinism` | The same seed and inputs give different states, beats, jobs, barriers, assistance, outcomes or random state; or `apply` answers the same arguments twice in two ways |
|
|
80
|
+
| `replay` | The recorded log, after a JSON round trip, doesn't reproduce the final state, outcome and barriers, through this package's replay and through the session runner's |
|
|
81
|
+
| `json` | A config, state, view, redacted event, command, job request, decision key or outcome isn't JSON (the message names the path, e.g. `-0 at $.lean`) |
|
|
82
|
+
| `schemas` | A value fails the engine's own schema; or a command, state, config or job schema validates asynchronously, which runners can't wait for |
|
|
83
|
+
| `no-leak` | `viewFor` or `decisionKey` changes when `perturbHidden` changes only what that viewer can't see, for every seat and the spectator; `eventFor` tells a viewer different things about two transitions that look identical to that viewer; or `perturbHidden` never changes anything |
|
|
84
|
+
| `rejections` | `apply` throws, mutates its (frozen) input, or rejects without a JSON `GameError` |
|
|
85
|
+
| `turns` | A command from a seat outside `pending()` (including seats that don't exist) is accepted; `pending()` lists unknown or repeated seats, or isn't empty after the outcome; a `timeout` or `seat` input (when `platformInputs` sends them) is refused while the game is in progress |
|
|
86
|
+
| `resign` | A `resign` from any seat, in turn or out of turn, is refused while the game is in progress |
|
|
87
|
+
| `invariants` | The `invariants` hook returns `false` or a message, or throws, on any state a game reaches |
|
|
88
|
+
| `ai` | `ai.choose`, at any level and for any pending seat, throws or picks a command the engine rejects |
|
|
89
|
+
| `saves` | A state saved at a random point doesn't survive JSON and `schemas.state` unchanged, or a game restored from it ends differently; a save fixture is missing for a shipped version, or doesn't migrate and validate |
|
|
90
|
+
| `jobs` | A job outcome for an id never requested, already answered or superseded is accepted; an awaited job accepts a value but not a failure, or the reverse; a job id repeats; a job kind is undeclared or has no fake runner; a payload or the fake runner's value fails its schema |
|
|
91
|
+
| `stale` | While a seat's `decisionKey` stays the same, the set of commands the engine accepts from it changes, so the room would let a stale command through |
|
|
92
|
+
| `undo` | Undo by replay doesn't return to the state before the human's previous decision, steps back past an `undoBarrier`, or refuses although no barrier is in the way; or a barrier differs from the spec's `undoBarrier` rule |
|
|
93
|
+
| `assist` | A transition's `assist` isn't `'hint'`, `'check'` or `'reveal'`, differs from the spec's `assist` rule, or isn't reproduced by replay |
|
|
94
|
+
| `session-hooks` | A transition sets `startsClock` without the engine declaring `clockStart: 'transition'`, or sets it twice in one game; or `worthSaving` throws, mutates its state, answers something other than a boolean, or answers differently for the same state or its JSON copy. Skipped when the engine declares neither hook |
|
|
95
|
+
| `outcome` | A game has no outcome after `maxSteps` inputs; nobody can move although the game isn't over; the outcome is malformed (every seat placed once, places from 1, no platform-written fields); a finished game accepts a command or a resignation |
|
|
96
|
+
|
|
97
|
+
The suite deep-freezes every state and input it hands the engine, and restores the random
|
|
98
|
+
state after every trial, as the session runner does. It tries each candidate input on a
|
|
99
|
+
copy of the generator before committing one, so trials never change the game.
|
|
100
|
+
|
|
101
|
+
### Options
|
|
102
|
+
|
|
103
|
+
| Option | Default | Meaning |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `setups` or `configs` × `seatCounts` | required | How games start: `{ config, seats }`, seats as a count or `SeatInfo`s |
|
|
106
|
+
| `commands` | `engine.ai` | Candidate commands for a seat in a state |
|
|
107
|
+
| `perturbHidden` | none (no-leak skipped) | `(state, viewer, rng) => state` changing only what `viewer` can't see |
|
|
108
|
+
| `perturbTransition` | none | Optional `(state, viewer, rng, transition) => state` retry helper: preserve current knowledge, what the viewer learns in `transition.states`, and cards required by `transition.input`; vary everything still hidden |
|
|
109
|
+
| `invariants` | none | `(state) => void \| boolean \| string` |
|
|
110
|
+
| `games`, `maxSteps`, `seed` | 12, 400, `"conformance"` | Games per setup, inputs per game, base seed |
|
|
111
|
+
| `requireOutcome` | true | Fail games that don't finish in `maxSteps` |
|
|
112
|
+
| `platformInputs` | `{ resign: true }` | Also send `timeout` and `seat` inputs with `{ timeout: true, seatChanges: true }` |
|
|
113
|
+
| `jobs`, `jobFailureRate` | none, 0.1 | Fake job runners by kind, `(payload, rng) => value`, and how often a job fails instead |
|
|
114
|
+
| `undoBarrier`, `assist` | none | The spec's rules: which inputs are barriers, and which assistance each input is |
|
|
115
|
+
| `saves` | round trip only | `{ version, firstVersion?, migrations, fixtures }` |
|
|
116
|
+
| `only` | all games | `{ setup, game }`, to rerun one reported game |
|
|
117
|
+
| `timeoutMs` | 300 000 | Per vitest case (`runEngineConformance` and `runSimulationConformance`) |
|
|
118
|
+
|
|
119
|
+
`checkEngine(engine, options)` runs the same suite without vitest and returns a
|
|
120
|
+
`ConformanceReport`; `formatReport` turns it into text and `expectConformance` throws unless
|
|
121
|
+
it passed. Use it to assert on the report itself, as this package's own tests do.
|
|
122
|
+
|
|
123
|
+
## Conformance for a fixed-step simulation
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { runSimulationConformance } from '@joystyk/testing';
|
|
127
|
+
|
|
128
|
+
runSimulationConformance(simulation, {
|
|
129
|
+
configs: [{ length: 40, limit: 600 }],
|
|
130
|
+
ticks: 1000,
|
|
131
|
+
inputs: (state, tick, rng) => (rng.int(0, 5) === 0 ? [{ type: 'nudge', dir: 1 }] : []),
|
|
132
|
+
requireOutcome: true,
|
|
133
|
+
invariants: (state) => state.pos >= 0,
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Check | What fails it |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| `determinism` | A second run with the same seed and tick inputs gives different results or random state |
|
|
140
|
+
| `replay` | The tick log, after a JSON round trip, doesn't reproduce the final state and outcome, here or through the session scheduler's replay |
|
|
141
|
+
| `checkpoint` | Saving at a tick, restoring through JSON and `schemas.state`, and running on gives a different end than running straight through |
|
|
142
|
+
| `json`, `schemas` | A config, state, input, event or outcome isn't JSON or fails its schema |
|
|
143
|
+
| `purity` | `step` throws, mutates its frozen input, or answers the same state, inputs and generator in two ways |
|
|
144
|
+
| `invariants` | The hook fails on a state |
|
|
145
|
+
| `outcome` | A malformed outcome, or none within `ticks` when `requireOutcome` is set |
|
|
146
|
+
| `budget` | The mean step exceeds `stepBudgetMs` (off unless set; budgets belong on a reference machine) |
|
|
147
|
+
|
|
148
|
+
## Helpers
|
|
149
|
+
|
|
150
|
+
| Helper | Use |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| `testRng(...parts)`, `testSeed(...parts)`, `testSeeds(base, n)`, `cloneRng(rng)` | Seeded kit generators for tests, namespaced so they never equal a production seed |
|
|
153
|
+
| `createFakeClock(start)` | `now`, `setTimer`, `setTimeout`, `advance`, `advanceTo`, `runAll`; the shape of the room server's `Clock` port |
|
|
154
|
+
| `manualFrames()` | A fixed-step runner's `frames` port that runs only when the test says: `run(n)`, `advance(n)` and `until(condition, what)`, which counts game time in frames (testing under load (Joystyk host reference)) |
|
|
155
|
+
| `waitUntil(condition, what)`, `pause(ms)`, `nextTask()`, `WAIT_BUDGET_MS` | Waits on a condition with a budget only a real failure exhausts, instead of a sleep |
|
|
156
|
+
| `createFakeStorage(initial)` | The contract's `GameStorage` in memory, storing JSON text, with `entries`, `calls` and `failNext(op)` |
|
|
157
|
+
| `runnerClock(fake)`, `createMemorySaveStore()` | The session runner's `Clock` on a fake clock, and a save slot in memory with `slot`, `writes` and `clears`, to play a game through `createLocalSession` |
|
|
158
|
+
| `replayEngine`, `replaySimulation` | Replay a log and report each input's barrier, assistance and whether a human sent it |
|
|
159
|
+
| `undoByReplay(engine, replay)` | Undo as the local runner does it: back to the start of the human's previous decision, refusing to cross a barrier, with the assistance record (`'undo'` included) |
|
|
160
|
+
| `seats`, `commandInput`, `resignInput`, `timeoutInput`, `seatInput`, `jobResult`, `jobFailure` | Fixture builders for seats and inputs |
|
|
161
|
+
| `engineReplay`, `simulationReplay`, `saveEnvelope`, `saveFixture` | Replays, saves and save fixtures with test defaults |
|
|
162
|
+
| `guardSchema(guard)`, `anySchema()`, `validate(schema, value)` | Standard Schemas from type guards, and running any schema |
|
|
163
|
+
| `firstDifference`, `deepEqual`, `deepFreeze`, `jsonRoundTrip` | What the suites use to compare and protect values |
|
|
164
|
+
|
|
165
|
+
## Fixtures
|
|
166
|
+
|
|
167
|
+
`@joystyk/testing/fixtures` holds small, correct games, each with conformance options that
|
|
168
|
+
pass, for platform tests (runners, rooms, the two-client harness) that need a real engine:
|
|
169
|
+
|
|
170
|
+
| Fixture | Layer | Exercises |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| `nim` | 2 | Public state, runner-driven AI, saves with a migration chain |
|
|
173
|
+
| `drawPlay` | 2 | Hidden hands and deck, opaque card ids, redacted events, two to four seats |
|
|
174
|
+
| `sealedBids` | 2 | Simultaneous moves, a custom `decisionKey`, hidden choices |
|
|
175
|
+
| `diceRace` | 2 | Jobs: requests, awaited ids and failures; solo and two seats |
|
|
176
|
+
| `hintPuzzle` | 2 | Solo hidden information, undo barriers, `assist: 'hint'`, and both session hooks (`clockStart`, `worthSaving`) |
|
|
177
|
+
| `drift` | 3 | A fixed-step simulation with seeded wind and scripted inputs |
|
|
178
|
+
|
|
179
|
+
## Tests
|
|
180
|
+
|
|
181
|
+
- `fixtures.test.ts` runs both suites through the vitest wrappers on every fixture, then
|
|
182
|
+
asserts from the reports that each fixture exercised the checks it was built for.
|
|
183
|
+
- `broken.test.ts` runs the suites on deliberately broken fixtures (`broken.test-util.ts`),
|
|
184
|
+
one flaw each, and asserts that the right check fires: leaking ids, spectator views,
|
|
185
|
+
events and decision keys; non-deterministic, mutating and throwing engines; `undefined`
|
|
186
|
+
and `-0` in state; asynchronous and disagreeing schemas; wrong-turn acceptance; refused
|
|
187
|
+
and late resignations; endless and stuck games; illegal AI moves; frozen decision keys;
|
|
188
|
+
lost cards; missing barriers and wrong assistance; a clock started twice or undeclared,
|
|
189
|
+
and an impure or mutating `worthSaving`; job ids accepted, refused or reused;
|
|
190
|
+
missing and broken save fixtures; and the simulation equivalents. It also proves that a
|
|
191
|
+
reported seed and replay reproduce the failure.
|
|
192
|
+
- `helpers.test.ts` covers the RNG, clock, storage, builders, deep helpers and undo.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { EngineInput, EngineReplay, Json, Replay, RngState, SaveEnvelope, SeatChange, SeatId, SeatInfo, SimulationReplay, TickInput, Validator } from '@joystyk/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* `count` human seats with ids 0 … count − 1 and stable names. Pass `ai` to make some seats
|
|
4
|
+
* AI seats: `seats(4, { ai: [1, 2, 3], aiLevel: 'easy' })`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const seats: (count: number, options?: {
|
|
7
|
+
readonly ai?: readonly SeatId[];
|
|
8
|
+
readonly aiLevel?: string;
|
|
9
|
+
}) => SeatInfo[];
|
|
10
|
+
/** A `command` input. */
|
|
11
|
+
export declare const commandInput: <C>(seat: SeatId, command: C) => EngineInput<C>;
|
|
12
|
+
/** A `resign` input. */
|
|
13
|
+
export declare const resignInput: <C = never>(seat: SeatId) => EngineInput<C>;
|
|
14
|
+
/** A `timeout` input. */
|
|
15
|
+
export declare const timeoutInput: <C = never>(seat: SeatId) => EngineInput<C>;
|
|
16
|
+
/** A `seat` input. */
|
|
17
|
+
export declare const seatInput: <C = never>(seat: SeatId, change: SeatChange) => EngineInput<C>;
|
|
18
|
+
/** A `job` input carrying a successful outcome (without presentation, as the runner feeds it). */
|
|
19
|
+
export declare const jobResult: <C = never>(jobId: string, value: Json) => EngineInput<C>;
|
|
20
|
+
/** A `job` input carrying a failed outcome. */
|
|
21
|
+
export declare const jobFailure: <C = never>(jobId: string, reason?: "timeout" | "failed") => EngineInput<C>;
|
|
22
|
+
/**
|
|
23
|
+
* A Standard Schema from a type guard. Enough for fixtures and tests; real games use a schema
|
|
24
|
+
* library (Zod 4 by default, D-22).
|
|
25
|
+
*/
|
|
26
|
+
export declare const guardSchema: <T>(guard: (value: unknown) => value is T, message?: string) => Validator<T>;
|
|
27
|
+
/** A Standard Schema that accepts anything as `T`. Use it only where the test isn't about schemas. */
|
|
28
|
+
export declare const anySchema: <T>() => Validator<T>;
|
|
29
|
+
/** A save fixture: the stored state of one shipped save version. */
|
|
30
|
+
export type SaveFixture = {
|
|
31
|
+
/** A label for reports, e.g. "v1 mid-game". */
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly saveVersion: number;
|
|
34
|
+
readonly state: Json;
|
|
35
|
+
};
|
|
36
|
+
/** A save fixture, for the `saves` option of the engine suite. */
|
|
37
|
+
export declare const saveFixture: (name: string, saveVersion: number, state: Json) => SaveFixture;
|
|
38
|
+
/** A save envelope with test defaults for everything but the state and the random state. */
|
|
39
|
+
export declare const saveEnvelope: (fields: Pick<SaveEnvelope, "state" | "rng"> & Partial<SaveEnvelope>) => SaveEnvelope;
|
|
40
|
+
/** A Layer 2 replay with test defaults. */
|
|
41
|
+
export declare const engineReplay: <C extends Json = Json, Cfg extends Json = Json>(fields: {
|
|
42
|
+
readonly config: Cfg;
|
|
43
|
+
readonly seats: readonly SeatInfo[];
|
|
44
|
+
readonly seed: RngState;
|
|
45
|
+
readonly inputs: readonly EngineInput<C>[];
|
|
46
|
+
readonly gameId?: string;
|
|
47
|
+
readonly rulesVersion?: number;
|
|
48
|
+
}) => EngineReplay<C, Cfg>;
|
|
49
|
+
/** A Layer 3 replay with test defaults. */
|
|
50
|
+
export declare const simulationReplay: <I extends Json = Json, Cfg extends Json = Json>(fields: {
|
|
51
|
+
readonly config: Cfg;
|
|
52
|
+
readonly seed: RngState;
|
|
53
|
+
readonly inputs: readonly TickInput<I>[];
|
|
54
|
+
readonly endTick: number;
|
|
55
|
+
readonly gameId?: string;
|
|
56
|
+
readonly rulesVersion?: number;
|
|
57
|
+
}) => SimulationReplay<I, Cfg>;
|
|
58
|
+
/** Narrows a replay by layer. */
|
|
59
|
+
export declare const isEngineReplay: (replay: Replay) => replay is EngineReplay;
|
package/dist/builders.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
//#region src/builders.ts
|
|
2
|
+
var NAMES = [
|
|
3
|
+
"Ana",
|
|
4
|
+
"Ben",
|
|
5
|
+
"Cleo",
|
|
6
|
+
"Dev",
|
|
7
|
+
"Eli",
|
|
8
|
+
"Fay",
|
|
9
|
+
"Gus",
|
|
10
|
+
"Hana"
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* `count` human seats with ids 0 … count − 1 and stable names. Pass `ai` to make some seats
|
|
14
|
+
* AI seats: `seats(4, { ai: [1, 2, 3], aiLevel: 'easy' })`.
|
|
15
|
+
*/
|
|
16
|
+
var seats = (count, options = {}) => Array.from({ length: count }, (_, id) => {
|
|
17
|
+
const name = NAMES[id] ?? `Seat ${id + 1}`;
|
|
18
|
+
if (!(options.ai ?? []).includes(id)) return {
|
|
19
|
+
id,
|
|
20
|
+
name,
|
|
21
|
+
controller: "human"
|
|
22
|
+
};
|
|
23
|
+
return options.aiLevel === void 0 ? {
|
|
24
|
+
id,
|
|
25
|
+
name: `${name} (AI)`,
|
|
26
|
+
controller: "ai"
|
|
27
|
+
} : {
|
|
28
|
+
id,
|
|
29
|
+
name: `${name} (AI)`,
|
|
30
|
+
controller: "ai",
|
|
31
|
+
aiLevel: options.aiLevel
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
/** A `command` input. */
|
|
35
|
+
var commandInput = (seat, command) => ({
|
|
36
|
+
kind: "command",
|
|
37
|
+
seat,
|
|
38
|
+
command
|
|
39
|
+
});
|
|
40
|
+
/** A `resign` input. */
|
|
41
|
+
var resignInput = (seat) => ({
|
|
42
|
+
kind: "resign",
|
|
43
|
+
seat
|
|
44
|
+
});
|
|
45
|
+
/** A `timeout` input. */
|
|
46
|
+
var timeoutInput = (seat) => ({
|
|
47
|
+
kind: "timeout",
|
|
48
|
+
seat
|
|
49
|
+
});
|
|
50
|
+
/** A `seat` input. */
|
|
51
|
+
var seatInput = (seat, change) => ({
|
|
52
|
+
kind: "seat",
|
|
53
|
+
seat,
|
|
54
|
+
change
|
|
55
|
+
});
|
|
56
|
+
/** A `job` input carrying a successful outcome (without presentation, as the runner feeds it). */
|
|
57
|
+
var jobResult = (jobId, value) => ({
|
|
58
|
+
kind: "job",
|
|
59
|
+
jobId,
|
|
60
|
+
outcome: {
|
|
61
|
+
ok: true,
|
|
62
|
+
value
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
/** A `job` input carrying a failed outcome. */
|
|
66
|
+
var jobFailure = (jobId, reason = "failed") => ({
|
|
67
|
+
kind: "job",
|
|
68
|
+
jobId,
|
|
69
|
+
outcome: {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
/**
|
|
75
|
+
* A Standard Schema from a type guard. Enough for fixtures and tests; real games use a schema
|
|
76
|
+
* library (Zod 4 by default, D-22).
|
|
77
|
+
*/
|
|
78
|
+
var guardSchema = (guard, message = "Invalid value.") => ({ "~standard": {
|
|
79
|
+
version: 1,
|
|
80
|
+
vendor: "joystyk-testing",
|
|
81
|
+
validate: (value) => guard(value) ? { value } : { issues: [{ message }] }
|
|
82
|
+
} });
|
|
83
|
+
/** A Standard Schema that accepts anything as `T`. Use it only where the test isn't about schemas. */
|
|
84
|
+
var anySchema = () => ({ "~standard": {
|
|
85
|
+
version: 1,
|
|
86
|
+
vendor: "joystyk-testing",
|
|
87
|
+
validate: (value) => ({ value })
|
|
88
|
+
} });
|
|
89
|
+
/** A save fixture, for the `saves` option of the engine suite. */
|
|
90
|
+
var saveFixture = (name, saveVersion, state) => ({
|
|
91
|
+
name,
|
|
92
|
+
saveVersion,
|
|
93
|
+
state
|
|
94
|
+
});
|
|
95
|
+
/** A save envelope with test defaults for everything but the state and the random state. */
|
|
96
|
+
var saveEnvelope = (fields) => ({
|
|
97
|
+
gameId: "test-game",
|
|
98
|
+
build: "0.0.0-test",
|
|
99
|
+
rulesVersion: 1,
|
|
100
|
+
saveVersion: 1,
|
|
101
|
+
seats: seats(2),
|
|
102
|
+
...fields
|
|
103
|
+
});
|
|
104
|
+
/** A Layer 2 replay with test defaults. */
|
|
105
|
+
var engineReplay = (fields) => ({
|
|
106
|
+
layer: 2,
|
|
107
|
+
gameId: fields.gameId ?? "test-game",
|
|
108
|
+
rulesVersion: fields.rulesVersion ?? 1,
|
|
109
|
+
config: fields.config,
|
|
110
|
+
seats: fields.seats,
|
|
111
|
+
seed: fields.seed,
|
|
112
|
+
inputs: fields.inputs
|
|
113
|
+
});
|
|
114
|
+
/** A Layer 3 replay with test defaults. */
|
|
115
|
+
var simulationReplay = (fields) => ({
|
|
116
|
+
layer: 3,
|
|
117
|
+
gameId: fields.gameId ?? "test-game",
|
|
118
|
+
rulesVersion: fields.rulesVersion ?? 1,
|
|
119
|
+
config: fields.config,
|
|
120
|
+
seed: fields.seed,
|
|
121
|
+
inputs: fields.inputs,
|
|
122
|
+
endTick: fields.endTick
|
|
123
|
+
});
|
|
124
|
+
/** Narrows a replay by layer. */
|
|
125
|
+
var isEngineReplay = (replay) => replay.layer === 2;
|
|
126
|
+
//#endregion
|
|
127
|
+
export { anySchema, commandInput, engineReplay, guardSchema, isEngineReplay, jobFailure, jobResult, resignInput, saveEnvelope, saveFixture, seatInput, seats, simulationReplay, timeoutInput };
|
|
128
|
+
|
|
129
|
+
//# sourceMappingURL=builders.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"builders.js","names":[],"sources":["../src/builders.ts"],"sourcesContent":["// Fixture builders: seats, inputs, schemas, saves and replays, without boilerplate.\n\nimport type {\n EngineInput,\n EngineReplay,\n Json,\n JobOutcome,\n Replay,\n RngState,\n SaveEnvelope,\n SeatChange,\n SeatId,\n SeatInfo,\n SimulationReplay,\n StandardSchemaV1,\n TickInput,\n Validator,\n} from '@joystyk/contracts';\n\nconst NAMES = ['Ana', 'Ben', 'Cleo', 'Dev', 'Eli', 'Fay', 'Gus', 'Hana'];\n\n/**\n * `count` human seats with ids 0 … count − 1 and stable names. Pass `ai` to make some seats\n * AI seats: `seats(4, { ai: [1, 2, 3], aiLevel: 'easy' })`.\n */\nexport const seats = (\n count: number,\n options: { readonly ai?: readonly SeatId[]; readonly aiLevel?: string } = {},\n): SeatInfo[] =>\n Array.from({ length: count }, (_, id): SeatInfo => {\n const name = NAMES[id] ?? `Seat ${id + 1}`;\n if (!(options.ai ?? []).includes(id)) return { id, name, controller: 'human' };\n return options.aiLevel === undefined\n ? { id, name: `${name} (AI)`, controller: 'ai' }\n : { id, name: `${name} (AI)`, controller: 'ai', aiLevel: options.aiLevel };\n });\n\n/** A `command` input. */\nexport const commandInput = <C>(seat: SeatId, command: C): EngineInput<C> => ({ kind: 'command', seat, command });\n\n/** A `resign` input. */\nexport const resignInput = <C = never>(seat: SeatId): EngineInput<C> => ({ kind: 'resign', seat });\n\n/** A `timeout` input. */\nexport const timeoutInput = <C = never>(seat: SeatId): EngineInput<C> => ({ kind: 'timeout', seat });\n\n/** A `seat` input. */\nexport const seatInput = <C = never>(seat: SeatId, change: SeatChange): EngineInput<C> => ({\n kind: 'seat',\n seat,\n change,\n});\n\n/** A `job` input carrying a successful outcome (without presentation, as the runner feeds it). */\nexport const jobResult = <C = never>(jobId: string, value: Json): EngineInput<C> => ({\n kind: 'job',\n jobId,\n outcome: { ok: true, value },\n});\n\n/** A `job` input carrying a failed outcome. */\nexport const jobFailure = <C = never>(jobId: string, reason: 'timeout' | 'failed' = 'failed'): EngineInput<C> => ({\n kind: 'job',\n jobId,\n outcome: { ok: false, reason } satisfies JobOutcome,\n});\n\n/**\n * A Standard Schema from a type guard. Enough for fixtures and tests; real games use a schema\n * library (Zod 4 by default, D-22).\n */\nexport const guardSchema = <T>(guard: (value: unknown) => value is T, message = 'Invalid value.'): Validator<T> => ({\n '~standard': {\n version: 1,\n vendor: 'joystyk-testing',\n validate: (value): StandardSchemaV1.Result<T> => (guard(value) ? { value } : { issues: [{ message }] }),\n },\n});\n\n/** A Standard Schema that accepts anything as `T`. Use it only where the test isn't about schemas. */\nexport const anySchema = <T>(): Validator<T> => ({\n '~standard': { version: 1, vendor: 'joystyk-testing', validate: (value) => ({ value: value as T }) },\n});\n\n/** A save fixture: the stored state of one shipped save version. */\nexport type SaveFixture = {\n /** A label for reports, e.g. \"v1 mid-game\". */\n readonly name: string;\n readonly saveVersion: number;\n readonly state: Json;\n};\n\n/** A save fixture, for the `saves` option of the engine suite. */\nexport const saveFixture = (name: string, saveVersion: number, state: Json): SaveFixture => ({\n name,\n saveVersion,\n state,\n});\n\n/** A save envelope with test defaults for everything but the state and the random state. */\nexport const saveEnvelope = (fields: Pick<SaveEnvelope, 'state' | 'rng'> & Partial<SaveEnvelope>): SaveEnvelope => ({\n gameId: 'test-game',\n build: '0.0.0-test',\n rulesVersion: 1,\n saveVersion: 1,\n seats: seats(2),\n ...fields,\n});\n\n/** A Layer 2 replay with test defaults. */\nexport const engineReplay = <C extends Json = Json, Cfg extends Json = Json>(fields: {\n readonly config: Cfg;\n readonly seats: readonly SeatInfo[];\n readonly seed: RngState;\n readonly inputs: readonly EngineInput<C>[];\n readonly gameId?: string;\n readonly rulesVersion?: number;\n}): EngineReplay<C, Cfg> => ({\n layer: 2,\n gameId: fields.gameId ?? 'test-game',\n rulesVersion: fields.rulesVersion ?? 1,\n config: fields.config,\n seats: fields.seats,\n seed: fields.seed,\n inputs: fields.inputs,\n});\n\n/** A Layer 3 replay with test defaults. */\nexport const simulationReplay = <I extends Json = Json, Cfg extends Json = Json>(fields: {\n readonly config: Cfg;\n readonly seed: RngState;\n readonly inputs: readonly TickInput<I>[];\n readonly endTick: number;\n readonly gameId?: string;\n readonly rulesVersion?: number;\n}): SimulationReplay<I, Cfg> => ({\n layer: 3,\n gameId: fields.gameId ?? 'test-game',\n rulesVersion: fields.rulesVersion ?? 1,\n config: fields.config,\n seed: fields.seed,\n inputs: fields.inputs,\n endTick: fields.endTick,\n});\n\n/** Narrows a replay by layer. */\nexport const isEngineReplay = (replay: Replay): replay is EngineReplay => replay.layer === 2;\n"],"mappings":";AAmBA,IAAM,QAAQ;CAAC;CAAO;CAAO;CAAQ;CAAO;CAAO;CAAO;CAAO;AAAM;;;;;AAMvE,IAAa,SACX,OACA,UAA0E,CAAC,MAE3E,MAAM,KAAK,EAAE,QAAQ,MAAM,IAAI,GAAG,OAAiB;CACjD,MAAM,OAAO,MAAM,OAAO,QAAQ,KAAK;CACvC,IAAI,EAAE,QAAQ,MAAM,CAAC,EAAA,CAAG,SAAS,EAAE,GAAG,OAAO;EAAE;EAAI;EAAM,YAAY;CAAQ;CAC7E,OAAO,QAAQ,YAAY,KAAA,IACvB;EAAE;EAAI,MAAM,GAAG,KAAK;EAAQ,YAAY;CAAK,IAC7C;EAAE;EAAI,MAAM,GAAG,KAAK;EAAQ,YAAY;EAAM,SAAS,QAAQ;CAAQ;AAC7E,CAAC;;AAGH,IAAa,gBAAmB,MAAc,aAAgC;CAAE,MAAM;CAAW;CAAM;AAAQ;;AAG/G,IAAa,eAA0B,UAAkC;CAAE,MAAM;CAAU;AAAK;;AAGhG,IAAa,gBAA2B,UAAkC;CAAE,MAAM;CAAW;AAAK;;AAGlG,IAAa,aAAwB,MAAc,YAAwC;CACzF,MAAM;CACN;CACA;AACF;;AAGA,IAAa,aAAwB,OAAe,WAAiC;CACnF,MAAM;CACN;CACA,SAAS;EAAE,IAAI;EAAM;CAAM;AAC7B;;AAGA,IAAa,cAAyB,OAAe,SAA+B,cAA8B;CAChH,MAAM;CACN;CACA,SAAS;EAAE,IAAI;EAAO;CAAO;AAC/B;;;;;AAMA,IAAa,eAAkB,OAAuC,UAAU,sBAAoC,EAClH,aAAa;CACX,SAAS;CACT,QAAQ;CACR,WAAW,UAAuC,MAAM,KAAK,IAAI,EAAE,MAAM,IAAI,EAAE,QAAQ,CAAC,EAAE,QAAQ,CAAC,EAAE;AACvG,EACF;;AAGA,IAAa,mBAAoC,EAC/C,aAAa;CAAE,SAAS;CAAG,QAAQ;CAAmB,WAAW,WAAW,EAAS,MAAW;AAAG,EACrG;;AAWA,IAAa,eAAe,MAAc,aAAqB,WAA8B;CAC3F;CACA;CACA;AACF;;AAGA,IAAa,gBAAgB,YAAuF;CAClH,QAAQ;CACR,OAAO;CACP,cAAc;CACd,aAAa;CACb,OAAO,MAAM,CAAC;CACd,GAAG;AACL;;AAGA,IAAa,gBAAgE,YAOhD;CAC3B,OAAO;CACP,QAAQ,OAAO,UAAU;CACzB,cAAc,OAAO,gBAAgB;CACrC,QAAQ,OAAO;CACf,OAAO,OAAO;CACd,MAAM,OAAO;CACb,QAAQ,OAAO;AACjB;;AAGA,IAAa,oBAAoE,YAOhD;CAC/B,OAAO;CACP,QAAQ,OAAO,UAAU;CACzB,cAAc,OAAO,gBAAgB;CACrC,QAAQ,OAAO;CACf,MAAM,OAAO;CACb,QAAQ,OAAO;CACf,SAAS,OAAO;AAClB;;AAGA,IAAa,kBAAkB,WAA2C,OAAO,UAAU"}
|
package/dist/clock.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A scheduled callback; cancelling it twice, or after it fired, is harmless. */
|
|
2
|
+
export type TimerHandle = {
|
|
3
|
+
cancel(): void;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* A manual clock with timers. It has the shape of the room server's `Clock` port
|
|
7
|
+
* (`now` and `setTimer`, room-server.md section 4.1) plus controls for tests.
|
|
8
|
+
*/
|
|
9
|
+
export interface FakeClock {
|
|
10
|
+
/** Current time in milliseconds. */
|
|
11
|
+
now(): number;
|
|
12
|
+
/** Calls `fire` once the clock reaches `at`. A time in the past fires on the next advance. */
|
|
13
|
+
setTimer(at: number, fire: () => void): TimerHandle;
|
|
14
|
+
/** Calls `fire` after `delayMs` more milliseconds. */
|
|
15
|
+
setTimeout(fire: () => void, delayMs: number): TimerHandle;
|
|
16
|
+
/**
|
|
17
|
+
* Moves time forward by `ms`, firing due timers in time order (ties in the order they were
|
|
18
|
+
* set). The clock reads each timer's own time while it fires, and timers set by a callback
|
|
19
|
+
* fire in the same call if they fall due. Returns how many fired.
|
|
20
|
+
*/
|
|
21
|
+
advance(ms: number): number;
|
|
22
|
+
/** Like `advance`, to an absolute time. Moving backwards throws. */
|
|
23
|
+
advanceTo(time: number): number;
|
|
24
|
+
/** Advances to each pending timer in turn until none is left. Throws after `limit` firings. */
|
|
25
|
+
runAll(limit?: number): number;
|
|
26
|
+
/** How many timers are waiting. */
|
|
27
|
+
pendingTimers(): number;
|
|
28
|
+
}
|
|
29
|
+
/** A fake clock starting at `start` milliseconds (default 0). */
|
|
30
|
+
export declare const createFakeClock: (start?: number) => FakeClock;
|
package/dist/clock.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//#region src/clock.ts
|
|
2
|
+
/** A fake clock starting at `start` milliseconds (default 0). */
|
|
3
|
+
var createFakeClock = (start = 0) => {
|
|
4
|
+
let time = start;
|
|
5
|
+
let order = 0;
|
|
6
|
+
let timers = [];
|
|
7
|
+
const setTimer = (at, fire) => {
|
|
8
|
+
if (!Number.isFinite(at)) throw new RangeError(`Timer time must be finite, got ${at}.`);
|
|
9
|
+
const timer = {
|
|
10
|
+
at,
|
|
11
|
+
order: order += 1,
|
|
12
|
+
fire,
|
|
13
|
+
cancelled: false
|
|
14
|
+
};
|
|
15
|
+
timers.push(timer);
|
|
16
|
+
return { cancel: () => {
|
|
17
|
+
timer.cancelled = true;
|
|
18
|
+
} };
|
|
19
|
+
};
|
|
20
|
+
const nextDue = (until) => {
|
|
21
|
+
timers = timers.filter((t) => !t.cancelled);
|
|
22
|
+
let best;
|
|
23
|
+
for (const t of timers) if (t.at <= until && (best === void 0 || t.at < best.at || t.at === best.at && t.order < best.order)) best = t;
|
|
24
|
+
return best;
|
|
25
|
+
};
|
|
26
|
+
const advanceTo = (target) => {
|
|
27
|
+
if (target < time) throw new RangeError(`The fake clock can't go back from ${time} to ${target}.`);
|
|
28
|
+
let fired = 0;
|
|
29
|
+
for (let timer = nextDue(target); timer !== void 0; timer = nextDue(target)) {
|
|
30
|
+
timer.cancelled = true;
|
|
31
|
+
time = Math.max(time, timer.at);
|
|
32
|
+
fired += 1;
|
|
33
|
+
timer.fire();
|
|
34
|
+
}
|
|
35
|
+
time = target;
|
|
36
|
+
return fired;
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
now: () => time,
|
|
40
|
+
setTimer,
|
|
41
|
+
setTimeout: (fire, delayMs) => setTimer(time + delayMs, fire),
|
|
42
|
+
advance: (ms) => advanceTo(time + ms),
|
|
43
|
+
advanceTo,
|
|
44
|
+
runAll: (limit = 1e4) => {
|
|
45
|
+
let fired = 0;
|
|
46
|
+
for (let timer = nextDue(Infinity); timer !== void 0; timer = nextDue(Infinity)) {
|
|
47
|
+
if (fired >= limit) throw new Error(`runAll fired ${limit} timers and timers remain; a timer probably reschedules itself.`);
|
|
48
|
+
fired += advanceTo(Math.max(time, timer.at));
|
|
49
|
+
}
|
|
50
|
+
return fired;
|
|
51
|
+
},
|
|
52
|
+
pendingTimers: () => timers.filter((t) => !t.cancelled).length
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
export { createFakeClock };
|
|
57
|
+
|
|
58
|
+
//# sourceMappingURL=clock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clock.js","names":[],"sources":["../src/clock.ts"],"sourcesContent":["// A fake clock: time moves only when a test says so.\n\n/** A scheduled callback; cancelling it twice, or after it fired, is harmless. */\nexport type TimerHandle = { cancel(): void };\n\n/**\n * A manual clock with timers. It has the shape of the room server's `Clock` port\n * (`now` and `setTimer`, room-server.md section 4.1) plus controls for tests.\n */\nexport interface FakeClock {\n /** Current time in milliseconds. */\n now(): number;\n /** Calls `fire` once the clock reaches `at`. A time in the past fires on the next advance. */\n setTimer(at: number, fire: () => void): TimerHandle;\n /** Calls `fire` after `delayMs` more milliseconds. */\n setTimeout(fire: () => void, delayMs: number): TimerHandle;\n /**\n * Moves time forward by `ms`, firing due timers in time order (ties in the order they were\n * set). The clock reads each timer's own time while it fires, and timers set by a callback\n * fire in the same call if they fall due. Returns how many fired.\n */\n advance(ms: number): number;\n /** Like `advance`, to an absolute time. Moving backwards throws. */\n advanceTo(time: number): number;\n /** Advances to each pending timer in turn until none is left. Throws after `limit` firings. */\n runAll(limit?: number): number;\n /** How many timers are waiting. */\n pendingTimers(): number;\n}\n\ntype Timer = { readonly at: number; readonly order: number; readonly fire: () => void; cancelled: boolean };\n\n/** A fake clock starting at `start` milliseconds (default 0). */\nexport const createFakeClock = (start = 0): FakeClock => {\n let time = start;\n let order = 0;\n let timers: Timer[] = [];\n\n const setTimer = (at: number, fire: () => void): TimerHandle => {\n if (!Number.isFinite(at)) throw new RangeError(`Timer time must be finite, got ${at}.`);\n const timer: Timer = { at, order: (order += 1), fire, cancelled: false };\n timers.push(timer);\n return {\n cancel: () => {\n timer.cancelled = true;\n },\n };\n };\n\n const nextDue = (until: number): Timer | undefined => {\n timers = timers.filter((t) => !t.cancelled);\n let best: Timer | undefined;\n for (const t of timers) {\n if (t.at <= until && (best === undefined || t.at < best.at || (t.at === best.at && t.order < best.order)))\n best = t;\n }\n return best;\n };\n\n const advanceTo = (target: number): number => {\n if (target < time) throw new RangeError(`The fake clock can't go back from ${time} to ${target}.`);\n let fired = 0;\n for (let timer = nextDue(target); timer !== undefined; timer = nextDue(target)) {\n timer.cancelled = true;\n time = Math.max(time, timer.at);\n fired += 1;\n timer.fire();\n }\n time = target;\n return fired;\n };\n\n return {\n now: () => time,\n setTimer,\n setTimeout: (fire, delayMs) => setTimer(time + delayMs, fire),\n advance: (ms) => advanceTo(time + ms),\n advanceTo,\n runAll: (limit = 10_000) => {\n let fired = 0;\n for (let timer = nextDue(Infinity); timer !== undefined; timer = nextDue(Infinity)) {\n if (fired >= limit)\n throw new Error(`runAll fired ${limit} timers and timers remain; a timer probably reschedules itself.`);\n fired += advanceTo(Math.max(time, timer.at));\n }\n return fired;\n },\n pendingTimers: () => timers.filter((t) => !t.cancelled).length,\n };\n};\n"],"mappings":";;AAiCA,IAAa,mBAAmB,QAAQ,MAAiB;CACvD,IAAI,OAAO;CACX,IAAI,QAAQ;CACZ,IAAI,SAAkB,CAAC;CAEvB,MAAM,YAAY,IAAY,SAAkC;EAC9D,IAAI,CAAC,OAAO,SAAS,EAAE,GAAG,MAAM,IAAI,WAAW,kCAAkC,GAAG,EAAE;EACtF,MAAM,QAAe;GAAE;GAAI,OAAQ,SAAS;GAAI;GAAM,WAAW;EAAM;EACvE,OAAO,KAAK,KAAK;EACjB,OAAO,EACL,cAAc;GACZ,MAAM,YAAY;EACpB,EACF;CACF;CAEA,MAAM,WAAW,UAAqC;EACpD,SAAS,OAAO,QAAQ,MAAM,CAAC,EAAE,SAAS;EAC1C,IAAI;EACJ,KAAK,MAAM,KAAK,QACd,IAAI,EAAE,MAAM,UAAU,SAAS,KAAA,KAAa,EAAE,KAAK,KAAK,MAAO,EAAE,OAAO,KAAK,MAAM,EAAE,QAAQ,KAAK,QAChG,OAAO;EAEX,OAAO;CACT;CAEA,MAAM,aAAa,WAA2B;EAC5C,IAAI,SAAS,MAAM,MAAM,IAAI,WAAW,qCAAqC,KAAK,MAAM,OAAO,EAAE;EACjG,IAAI,QAAQ;EACZ,KAAK,IAAI,QAAQ,QAAQ,MAAM,GAAG,UAAU,KAAA,GAAW,QAAQ,QAAQ,MAAM,GAAG;GAC9E,MAAM,YAAY;GAClB,OAAO,KAAK,IAAI,MAAM,MAAM,EAAE;GAC9B,SAAS;GACT,MAAM,KAAK;EACb;EACA,OAAO;EACP,OAAO;CACT;CAEA,OAAO;EACL,WAAW;EACX;EACA,aAAa,MAAM,YAAY,SAAS,OAAO,SAAS,IAAI;EAC5D,UAAU,OAAO,UAAU,OAAO,EAAE;EACpC;EACA,SAAS,QAAQ,QAAW;GAC1B,IAAI,QAAQ;GACZ,KAAK,IAAI,QAAQ,QAAQ,QAAQ,GAAG,UAAU,KAAA,GAAW,QAAQ,QAAQ,QAAQ,GAAG;IAClF,IAAI,SAAS,OACX,MAAM,IAAI,MAAM,gBAAgB,MAAM,gEAAgE;IACxG,SAAS,UAAU,KAAK,IAAI,MAAM,MAAM,EAAE,CAAC;GAC7C;GACA,OAAO;EACT;EACA,qBAAqB,OAAO,QAAQ,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC;CAC1D;AACF"}
|
package/dist/deep.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first place where `a` and `b` differ, as a readable path with both values, or null when
|
|
3
|
+
* they are structurally equal. Numbers compare with `Object.is`, so `0` and `-0` differ, and
|
|
4
|
+
* a missing key differs from a key holding `undefined`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const firstDifference: (a: unknown, b: unknown, path?: string) => string | null;
|
|
7
|
+
/** True when `a` and `b` are structurally equal (see `firstDifference`). */
|
|
8
|
+
export declare const deepEqual: (a: unknown, b: unknown) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Freezes `value` and everything reachable from it, then returns it. The suites freeze every
|
|
11
|
+
* state, command and input they hand to an engine, so an engine that mutates its input
|
|
12
|
+
* throws instead of silently corrupting the game.
|
|
13
|
+
*/
|
|
14
|
+
export declare const deepFreeze: <T>(value: T) => T;
|
|
15
|
+
/**
|
|
16
|
+
* Why `value` isn't JSON, with the path to the first offending part, or null when it is.
|
|
17
|
+
* The contract's `isJson` answers yes or no; failure messages need the where and why.
|
|
18
|
+
*/
|
|
19
|
+
export declare const jsonProblem: (value: unknown, path?: string, seen?: Set<object>) => string | null;
|
|
20
|
+
/** A JSON round trip: what a value looks like after a socket, a save or a replay file. */
|
|
21
|
+
export declare const jsonRoundTrip: <T>(value: T) => T;
|
|
22
|
+
/** A short, single-line rendering of any value for failure messages. */
|
|
23
|
+
export declare const show: (value: unknown) => string;
|
|
24
|
+
/** The message of anything thrown. */
|
|
25
|
+
export declare const errorMessage: (error: unknown) => string;
|