@irtio/testing 0.1.0 → 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.
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The matchers, as plain functions.
|
|
3
3
|
*
|
|
4
4
|
* They live apart from `matchers.ts` so the package's main entry can export them without importing
|
|
5
5
|
* Vitest: `@irtio/testing` has Vitest as an *optional* peer, and a bot or a plain Node script that
|
|
@@ -18,12 +18,29 @@ interface MatcherResult {
|
|
|
18
18
|
declare const TRACE_TAIL = 10;
|
|
19
19
|
/** No client can see a collection its role must not — in its view or in a frame it received. */
|
|
20
20
|
declare function toHaveNoVisibilityLeaks(received: unknown): MatcherResult;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Every client's view equals the authority projected to that client's role — and, for clients
|
|
23
|
+
* whose state stream has been idle at least `interpDelayMs` of fake time, the interpolated
|
|
24
|
+
* `room.render` path too (a mid-interpolation client is legitimately behind and is skipped).
|
|
25
|
+
*/
|
|
22
26
|
declare function toHaveConverged(received: unknown): MatcherResult;
|
|
23
27
|
/** Some call to `rpcName` came back as an error `REPLY`. */
|
|
24
28
|
declare function toHaveRejected(received: unknown, rpcName: string): MatcherResult;
|
|
25
29
|
/** No client received more than `bytesPerTick` outbound bytes per room tick, on average. */
|
|
26
30
|
declare function toStayUnderBandwidth(received: unknown, bytesPerTick: number): MatcherResult;
|
|
31
|
+
/** Options for `toStayWithinPrediction`. An omitted bound is not checked. */
|
|
32
|
+
interface PredictionBounds {
|
|
33
|
+
/** Numeric units a single correction may snap a client's prediction. */
|
|
34
|
+
readonly maxMagnitude?: number;
|
|
35
|
+
/** Corrections that may outrun the client's resim window (snap instead of replaying). */
|
|
36
|
+
readonly maxSnaps?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every client's mispredictions stayed within bounds: no single correction snapped further than
|
|
40
|
+
* `maxMagnitude` numeric units, and no client snapped past the resim window more than `maxSnaps`
|
|
41
|
+
* times. Reads `t.predictionStats()`, which each client's `correct` events feed.
|
|
42
|
+
*/
|
|
43
|
+
declare function toStayWithinPrediction(received: unknown, opts: PredictionBounds): MatcherResult;
|
|
27
44
|
/**
|
|
28
45
|
* The matcher table. Pass it to `expect.extend` yourself, or import `@irtio/testing/matchers`
|
|
29
46
|
* for the side effect.
|
|
@@ -33,6 +50,7 @@ declare const matchers: {
|
|
|
33
50
|
toHaveConverged: typeof toHaveConverged;
|
|
34
51
|
toHaveRejected: typeof toHaveRejected;
|
|
35
52
|
toStayUnderBandwidth: typeof toStayUnderBandwidth;
|
|
53
|
+
toStayWithinPrediction: typeof toStayWithinPrediction;
|
|
36
54
|
};
|
|
37
55
|
|
|
38
|
-
export { type MatcherResult as M, TRACE_TAIL as T, toHaveNoVisibilityLeaks as a, toHaveRejected as b, toStayUnderBandwidth as c, matchers as m, toHaveConverged as t };
|
|
56
|
+
export { type MatcherResult as M, type PredictionBounds as P, TRACE_TAIL as T, toHaveNoVisibilityLeaks as a, toHaveRejected as b, toStayUnderBandwidth as c, toStayWithinPrediction as d, matchers as m, toHaveConverged as t };
|
|
@@ -41,13 +41,21 @@ function materialize(ext, view) {
|
|
|
41
41
|
}
|
|
42
42
|
return out;
|
|
43
43
|
}
|
|
44
|
-
function divergence(ext, authority, client, visible) {
|
|
44
|
+
function divergence(ext, authority, client, visible, memberships = /* @__PURE__ */ new Map()) {
|
|
45
45
|
const out = [];
|
|
46
46
|
for (const [name, cd] of computeDirty(ext, authority, client)) {
|
|
47
47
|
if (!visible.has(name)) continue;
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
const desc = ext.collection(name);
|
|
49
|
+
const membership = desc.visibility === "spatial-grid" ? memberships.get(name) : void 0;
|
|
50
|
+
for (const id of cd.added) {
|
|
51
|
+
if (!membership || !membership.has(id)) out.push(`${name}[${id}]: only in the client's view`);
|
|
52
|
+
}
|
|
53
|
+
for (const id of cd.removed) {
|
|
54
|
+
if (!membership || membership.has(id))
|
|
55
|
+
out.push(`${name}[${id}]: missing from the client's view`);
|
|
56
|
+
}
|
|
50
57
|
for (const [id, rd] of cd.updated) {
|
|
58
|
+
if (membership && !membership.has(id)) continue;
|
|
51
59
|
const fields = [...rd.mask.fields].map((i) => fieldNameAt(ext, name, i)).filter((n) => n !== void 0);
|
|
52
60
|
const what = rd.owner ? [...fields, "owner"] : fields;
|
|
53
61
|
out.push(`${name}[${id}]: ${what.join(", ")}`);
|
|
@@ -91,9 +99,15 @@ import {
|
|
|
91
99
|
formatError,
|
|
92
100
|
rpcTable
|
|
93
101
|
} from "@irtio/protocol";
|
|
94
|
-
import {
|
|
102
|
+
import {
|
|
103
|
+
Mulberry32,
|
|
104
|
+
RoomCore,
|
|
105
|
+
RoomFullError,
|
|
106
|
+
createVisibilityPolicy,
|
|
107
|
+
visibleNames
|
|
108
|
+
} from "@irtio/runtime";
|
|
95
109
|
import { FakeClock, HarnessHost, frameTypeName } from "@irtio/runtime/test";
|
|
96
|
-
import { decodeDelta } from "@irtio/schema";
|
|
110
|
+
import { decodeDelta, decodeSnapshot } from "@irtio/schema";
|
|
97
111
|
|
|
98
112
|
// src/link.ts
|
|
99
113
|
var InProcessSocket = class {
|
|
@@ -155,6 +169,9 @@ var JOIN_TIMEOUT_MS = 3e4;
|
|
|
155
169
|
async function microtasks(turns = MICROTASK_TURNS) {
|
|
156
170
|
for (let i = 0; i < turns; i++) await Promise.resolve();
|
|
157
171
|
}
|
|
172
|
+
function detailOf(detail) {
|
|
173
|
+
return detail ? { detail } : {};
|
|
174
|
+
}
|
|
158
175
|
var TestHarness = class {
|
|
159
176
|
clock = new FakeClock();
|
|
160
177
|
host;
|
|
@@ -170,9 +187,14 @@ var TestHarness = class {
|
|
|
170
187
|
roleOverrides = /* @__PURE__ */ new Map();
|
|
171
188
|
traceLog = [];
|
|
172
189
|
frameLeaks = [];
|
|
190
|
+
spatialSeen = /* @__PURE__ */ new Map();
|
|
173
191
|
rejectionLog = [];
|
|
174
192
|
/** Reconnects completed per client id — bumped in `handleHello` on a resumed `WELCOME`. */
|
|
175
193
|
reconnectCounts = /* @__PURE__ */ new Map();
|
|
194
|
+
/** Prediction accounting per client id, fed by each room's `correct` events. */
|
|
195
|
+
predictions = /* @__PURE__ */ new Map();
|
|
196
|
+
/** What the render clock needs per client: its interp delay, plus the link's worst delay. */
|
|
197
|
+
renderMeta = /* @__PURE__ */ new Map();
|
|
176
198
|
nextClientId = 1;
|
|
177
199
|
droppedFrames = 0;
|
|
178
200
|
/** Bumped whenever a frame is scheduled or delivered; the settle loop watches it. */
|
|
@@ -199,6 +221,8 @@ var TestHarness = class {
|
|
|
199
221
|
roomId: this.roomId,
|
|
200
222
|
seed: options.seed ?? 1
|
|
201
223
|
});
|
|
224
|
+
this.host.core = this.coreRef;
|
|
225
|
+
this.host.serializeForSave = () => this.coreRef.snapshot();
|
|
202
226
|
this.coreRef.start();
|
|
203
227
|
}
|
|
204
228
|
// -------------------------------------------------------------------------
|
|
@@ -454,6 +478,14 @@ var TestHarness = class {
|
|
|
454
478
|
link.connected = true;
|
|
455
479
|
link.role = joined.role;
|
|
456
480
|
this.byId.set(link.clientId, link);
|
|
481
|
+
const decodedJoin = decodeSnapshot(this.ext, joined.snapshot).state;
|
|
482
|
+
const seen = /* @__PURE__ */ new Map();
|
|
483
|
+
for (const desc of this.ext.collections) {
|
|
484
|
+
if (desc.visibility !== "spatial-grid") continue;
|
|
485
|
+
const collection = decodedJoin[desc.name];
|
|
486
|
+
seen.set(desc.name, new Set(collection.ids()));
|
|
487
|
+
}
|
|
488
|
+
this.spatialSeen.set(link.clientId, seen);
|
|
457
489
|
this.roleById.set(link.clientId, joined.role);
|
|
458
490
|
if (reconnecting) {
|
|
459
491
|
this.reconnectCounts.set(link.clientId, (this.reconnectCounts.get(link.clientId) ?? 0) + 1);
|
|
@@ -504,15 +536,36 @@ var TestHarness = class {
|
|
|
504
536
|
return;
|
|
505
537
|
}
|
|
506
538
|
const keep = visibleNames(this.ext, link.role);
|
|
539
|
+
const policy = createVisibilityPolicy(this.ext, this.plain, link.clientId, link.role);
|
|
507
540
|
for (const dc of delta.collections) {
|
|
508
|
-
|
|
541
|
+
const desc = this.ext.collection(dc.name);
|
|
542
|
+
const seen = this.spatialSeen.get(link.clientId)?.get(dc.name) ?? /* @__PURE__ */ new Set();
|
|
543
|
+
const leaked = dc.ops.filter((op) => {
|
|
544
|
+
if (!keep.has(dc.name)) return true;
|
|
545
|
+
if (desc.visibility !== "spatial-grid") return false;
|
|
546
|
+
if (op.op === "remove") return !seen.has(op.id);
|
|
547
|
+
return !policy.maySeeEntity(desc, op.id);
|
|
548
|
+
});
|
|
549
|
+
if (desc.visibility === "spatial-grid") {
|
|
550
|
+
for (const op of dc.ops) {
|
|
551
|
+
if (op.op === "remove") seen.delete(op.id);
|
|
552
|
+
else if (policy.maySeeEntity(desc, op.id)) seen.add(op.id);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (leaked.length === 0) continue;
|
|
509
556
|
this.frameLeaks.push({
|
|
510
557
|
clientId: link.clientId,
|
|
511
558
|
role: link.role,
|
|
512
559
|
collection: dc.name,
|
|
513
560
|
kind: "frame",
|
|
514
|
-
ids:
|
|
515
|
-
tick: delta.tick
|
|
561
|
+
ids: leaked.map((o) => o.id),
|
|
562
|
+
tick: delta.tick,
|
|
563
|
+
...detailOf(
|
|
564
|
+
policy.describeSpatial(
|
|
565
|
+
desc,
|
|
566
|
+
leaked.map((o) => o.id)
|
|
567
|
+
)
|
|
568
|
+
)
|
|
516
569
|
});
|
|
517
570
|
}
|
|
518
571
|
}
|
|
@@ -610,9 +663,17 @@ var TestHarness = class {
|
|
|
610
663
|
...this.writeIntervalMs !== void 0 ? { writeIntervalMs: this.writeIntervalMs } : {},
|
|
611
664
|
...spec.role !== void 0 ? { role: spec.role } : {},
|
|
612
665
|
...spec.name !== void 0 ? { name: spec.name } : {},
|
|
666
|
+
...spec.interpDelayMs !== void 0 ? { interpDelayMs: spec.interpDelayMs } : {},
|
|
613
667
|
...spec.rpc !== void 0 ? { rpc: spec.rpc } : {}
|
|
614
668
|
};
|
|
615
669
|
const room = await this.awaitJoin(joinRoom(this.definition.schema, options));
|
|
670
|
+
room.on("correct", (correction) => this.recordCorrection(room.me, correction));
|
|
671
|
+
const latency = spec.latency ?? this.latency;
|
|
672
|
+
this.renderMeta.set(room.me, {
|
|
673
|
+
// The client's own default: 2 × the tick interval it learned from WELCOME, floored at 50.
|
|
674
|
+
interpDelayMs: spec.interpDelayMs ?? Math.max(50, 2 * Math.round(this.intervalMs)),
|
|
675
|
+
marginMs: (latency?.rttMs ?? 0) + (latency?.jitterMs ?? 0)
|
|
676
|
+
});
|
|
616
677
|
const client = Object.create(room);
|
|
617
678
|
Object.defineProperties(client, {
|
|
618
679
|
id: { get: () => room.me, enumerable: true },
|
|
@@ -675,8 +736,25 @@ var TestHarness = class {
|
|
|
675
736
|
for (const client of this.clientList) {
|
|
676
737
|
const role = this.roleFor(client.id);
|
|
677
738
|
const keep = visibleNames(this.ext, role);
|
|
739
|
+
const policy = createVisibilityPolicy(this.ext, this.plain, client.id, role);
|
|
678
740
|
const view = client.view;
|
|
679
741
|
for (const c of this.ext.collections) {
|
|
742
|
+
if (c.visibility === "spatial-grid") {
|
|
743
|
+
const coll = view[c.name];
|
|
744
|
+
const ids = coll ? [...coll.ids()].filter((id) => !policy.maySeeEntity(c, id)) : [];
|
|
745
|
+
if (ids.length > 0) {
|
|
746
|
+
leaks.push({
|
|
747
|
+
clientId: client.id,
|
|
748
|
+
role,
|
|
749
|
+
collection: c.name,
|
|
750
|
+
kind: "view",
|
|
751
|
+
ids,
|
|
752
|
+
tick: this.coreRef.tick,
|
|
753
|
+
...detailOf(policy.describeSpatial(c, ids))
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
680
758
|
if (keep.has(c.name)) continue;
|
|
681
759
|
if (c.kind === "entity") {
|
|
682
760
|
const coll = view[c.name];
|
|
@@ -704,15 +782,96 @@ var TestHarness = class {
|
|
|
704
782
|
}
|
|
705
783
|
return leaks;
|
|
706
784
|
}
|
|
785
|
+
recordCorrection(clientId, correction) {
|
|
786
|
+
let stats = this.predictions.get(clientId);
|
|
787
|
+
if (!stats) {
|
|
788
|
+
stats = { corrections: 0, magnitude: 0, maxMagnitude: 0, snaps: 0, replayed: 0 };
|
|
789
|
+
this.predictions.set(clientId, stats);
|
|
790
|
+
}
|
|
791
|
+
let magnitude = 0;
|
|
792
|
+
for (const field of correction.fields) {
|
|
793
|
+
const prev = correction.previous[field];
|
|
794
|
+
const next = correction.patch[field];
|
|
795
|
+
if (typeof prev === "number" && typeof next === "number") {
|
|
796
|
+
magnitude += Math.abs(next - prev);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
stats.corrections++;
|
|
800
|
+
stats.magnitude += magnitude;
|
|
801
|
+
if (magnitude > stats.maxMagnitude) stats.maxMagnitude = magnitude;
|
|
802
|
+
if (correction.snapped) stats.snaps++;
|
|
803
|
+
stats.replayed += correction.replayed;
|
|
804
|
+
}
|
|
805
|
+
/** Prediction accounting per client id. Every joined client has an entry, zeroed if untouched. */
|
|
806
|
+
predictionStats() {
|
|
807
|
+
const out = /* @__PURE__ */ new Map();
|
|
808
|
+
for (const client of this.clientList) {
|
|
809
|
+
const stats = this.predictions.get(client.id);
|
|
810
|
+
out.set(
|
|
811
|
+
client.id,
|
|
812
|
+
stats ? { ...stats } : { corrections: 0, magnitude: 0, maxMagnitude: 0, snaps: 0, replayed: 0 }
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
return out;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Render-path divergence (D20): `room.render` diffed against the authority, but **only** for
|
|
819
|
+
* clients whose inbound state stream has been idle for at least `interpDelayMs` of fake time
|
|
820
|
+
* (plus the link's worst-case delay). The render clock draws at `now − interpDelayMs` and holds
|
|
821
|
+
* past the newest delta, so an idle client's render must equal its settled state — while a
|
|
822
|
+
* client mid-interpolation is legitimately behind and is skipped, never flaked on.
|
|
823
|
+
*/
|
|
824
|
+
renderConvergence() {
|
|
825
|
+
const out = [];
|
|
826
|
+
for (const client of this.clientList) {
|
|
827
|
+
if (client.status !== "connected") continue;
|
|
828
|
+
const meta = this.renderMeta.get(client.id);
|
|
829
|
+
if (!meta) continue;
|
|
830
|
+
const idleMs = this.clock.time - this.lastStateFrameAt(client.id);
|
|
831
|
+
if (idleMs < meta.interpDelayMs + meta.marginMs) continue;
|
|
832
|
+
const role = this.roleFor(client.id);
|
|
833
|
+
const policy = createVisibilityPolicy(this.ext, this.plain, client.id, role);
|
|
834
|
+
const view = materialize(this.ext, client.render);
|
|
835
|
+
out.push({
|
|
836
|
+
clientId: client.id,
|
|
837
|
+
differences: divergence(
|
|
838
|
+
this.ext,
|
|
839
|
+
this.plain,
|
|
840
|
+
view,
|
|
841
|
+
visibleNames(this.ext, role),
|
|
842
|
+
policy.memberships
|
|
843
|
+
).map((d) => `render: ${d}`)
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return out;
|
|
847
|
+
}
|
|
848
|
+
/** When the room last sent this client a state-bearing frame, per the trace (send time). */
|
|
849
|
+
lastStateFrameAt(clientId) {
|
|
850
|
+
for (let i = this.traceLog.length - 1; i >= 0; i--) {
|
|
851
|
+
const entry = this.traceLog[i];
|
|
852
|
+
if (!entry || entry.dir !== "out" || entry.clientId !== clientId) continue;
|
|
853
|
+
if (entry.frame === "DELTA" || entry.frame === "CORRECT" || entry.frame === "WELCOME") {
|
|
854
|
+
return entry.at;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return Number.NEGATIVE_INFINITY;
|
|
858
|
+
}
|
|
707
859
|
/** Per-client divergence from the authority, restricted to what that client's role may see. */
|
|
708
860
|
convergence() {
|
|
709
861
|
const out = [];
|
|
710
862
|
for (const client of this.clientList) {
|
|
711
863
|
const role = this.roleFor(client.id);
|
|
864
|
+
const policy = createVisibilityPolicy(this.ext, this.plain, client.id, role);
|
|
712
865
|
const view = materialize(this.ext, client.view);
|
|
713
866
|
out.push({
|
|
714
867
|
clientId: client.id,
|
|
715
|
-
differences: divergence(
|
|
868
|
+
differences: divergence(
|
|
869
|
+
this.ext,
|
|
870
|
+
this.plain,
|
|
871
|
+
view,
|
|
872
|
+
visibleNames(this.ext, role),
|
|
873
|
+
policy.memberships
|
|
874
|
+
)
|
|
716
875
|
});
|
|
717
876
|
}
|
|
718
877
|
return out;
|
|
@@ -777,14 +936,19 @@ function toHaveNoVisibilityLeaks(received) {
|
|
|
777
936
|
t,
|
|
778
937
|
`expected no visibility leaks, found ${leaks.length}:
|
|
779
938
|
${leaks.map(
|
|
780
|
-
(l) => ` ${l.clientId} (${l.role}) saw ${l.collection} [${l.ids.join(", ")}] in its ${l.kind} at tick ${l.tick}`
|
|
939
|
+
(l) => ` ${l.clientId} (${l.role}) saw ${l.collection} [${l.ids.join(", ")}] in its ${l.kind} at tick ${l.tick}${l.detail ? ` (${l.detail})` : ""}`
|
|
781
940
|
).join("\n")}`
|
|
782
941
|
)
|
|
783
942
|
};
|
|
784
943
|
}
|
|
785
944
|
function toHaveConverged(received) {
|
|
786
945
|
const t = harnessOf(received);
|
|
787
|
-
const
|
|
946
|
+
const byClient = /* @__PURE__ */ new Map();
|
|
947
|
+
for (const r of [...t.convergence(), ...t.renderConvergence()]) {
|
|
948
|
+
if (r.differences.length === 0) continue;
|
|
949
|
+
byClient.set(r.clientId, [...byClient.get(r.clientId) ?? [], ...r.differences]);
|
|
950
|
+
}
|
|
951
|
+
const behind = [...byClient].map(([clientId, differences]) => ({ clientId, differences }));
|
|
788
952
|
return {
|
|
789
953
|
pass: behind.length === 0,
|
|
790
954
|
message: () => behind.length === 0 ? withTrace(t, "expected at least one client to differ from the authority, none did") : withTrace(
|
|
@@ -829,11 +993,33 @@ function toStayUnderBandwidth(received, bytesPerTick) {
|
|
|
829
993
|
)
|
|
830
994
|
};
|
|
831
995
|
}
|
|
996
|
+
function toStayWithinPrediction(received, opts) {
|
|
997
|
+
const t = harnessOf(received);
|
|
998
|
+
const maxMagnitude = opts.maxMagnitude ?? Number.POSITIVE_INFINITY;
|
|
999
|
+
const maxSnaps = opts.maxSnaps ?? Number.POSITIVE_INFINITY;
|
|
1000
|
+
const stats = [...t.predictionStats()];
|
|
1001
|
+
const offenders = stats.filter(([, s]) => s.maxMagnitude > maxMagnitude || s.snaps > maxSnaps);
|
|
1002
|
+
const describe = ([clientId, s]) => ` ${clientId}: ${s.corrections} correction(s), magnitude ${s.magnitude.toFixed(1)} (worst ${s.maxMagnitude.toFixed(1)}), ${s.snaps} snap(s), ${s.replayed} replayed`;
|
|
1003
|
+
const bounds = `maxMagnitude ${maxMagnitude}, maxSnaps ${maxSnaps}`;
|
|
1004
|
+
return {
|
|
1005
|
+
pass: offenders.length === 0,
|
|
1006
|
+
message: () => offenders.length === 0 ? withTrace(
|
|
1007
|
+
t,
|
|
1008
|
+
`expected some client to exceed the prediction bounds (${bounds}); none did:
|
|
1009
|
+
${stats.map(describe).join("\n") || " no clients joined"}`
|
|
1010
|
+
) : withTrace(
|
|
1011
|
+
t,
|
|
1012
|
+
`expected every client to stay within the prediction bounds (${bounds}), ${offenders.length} did not:
|
|
1013
|
+
${offenders.map(describe).join("\n")}`
|
|
1014
|
+
)
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
832
1017
|
var matchers = {
|
|
833
1018
|
toHaveNoVisibilityLeaks,
|
|
834
1019
|
toHaveConverged,
|
|
835
1020
|
toHaveRejected,
|
|
836
|
-
toStayUnderBandwidth
|
|
1021
|
+
toStayUnderBandwidth,
|
|
1022
|
+
toStayWithinPrediction
|
|
837
1023
|
};
|
|
838
1024
|
|
|
839
1025
|
export {
|
|
@@ -847,5 +1033,6 @@ export {
|
|
|
847
1033
|
toHaveConverged,
|
|
848
1034
|
toHaveRejected,
|
|
849
1035
|
toStayUnderBandwidth,
|
|
1036
|
+
toStayWithinPrediction,
|
|
850
1037
|
matchers
|
|
851
1038
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { RoomDefinition } from '@irtio/server';
|
|
|
3
3
|
import { Room, ClientState, RelayRoom, Scheduler } from '@irtio/client';
|
|
4
4
|
import { RoomCore } from '@irtio/runtime';
|
|
5
5
|
import { TraceEntry, HarnessHost, VisibilityLeak, FakeClock } from '@irtio/runtime/test';
|
|
6
|
-
export { TraceEntry, VisibilityLeak } from '@irtio/runtime/test';
|
|
7
|
-
export { M as MatcherResult, T as TRACE_TAIL, m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as toHaveRejected, c as toStayUnderBandwidth } from './assertions-
|
|
6
|
+
export { TraceEntry, VisibilityLeak, initPhysics } from '@irtio/runtime/test';
|
|
7
|
+
export { M as MatcherResult, P as PredictionBounds, T as TRACE_TAIL, m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as toHaveRejected, c as toStayUnderBandwidth, d as toStayWithinPrediction } from './assertions-Cj1Sejhj.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* `@irtio/testing`'s public contract: what `testRoom` hands back, and the two option bags.
|
|
@@ -64,6 +64,8 @@ interface TestJoinSpec<Role extends string = string> {
|
|
|
64
64
|
readonly name?: string;
|
|
65
65
|
/** Link simulation for this client only. */
|
|
66
66
|
readonly latency?: LatencySpec;
|
|
67
|
+
/** Render delay for this client's `room.render` (D20), handed to `joinRoom`. */
|
|
68
|
+
readonly interpDelayMs?: number;
|
|
67
69
|
/**
|
|
68
70
|
* Implementations of the schema's `client(...)` RPCs, handed straight to `joinRoom({ rpc })`.
|
|
69
71
|
* Without this there is no way to test a server-to-client call succeeding — the client replies
|
|
@@ -109,6 +111,19 @@ type TestClient<S, Role extends string = RoleOf<S> & string> = Omit<Room<S, Role
|
|
|
109
111
|
/** How many times this client has completed a reconnect (a resumed `WELCOME` after a drop). */
|
|
110
112
|
readonly reconnects: number;
|
|
111
113
|
};
|
|
114
|
+
/** Per-client prediction accounting, accumulated from the room's `correct` events (week 8). */
|
|
115
|
+
interface PredictionStats {
|
|
116
|
+
/** Correction ops this client received (one `CORRECT` frame may carry several). */
|
|
117
|
+
readonly corrections: number;
|
|
118
|
+
/** Summed numeric distance between the local prediction and the server's values. */
|
|
119
|
+
readonly magnitude: number;
|
|
120
|
+
/** The single worst correction's numeric distance. */
|
|
121
|
+
readonly maxMagnitude: number;
|
|
122
|
+
/** Corrections that outran the client's resim window and snapped instead of replaying. */
|
|
123
|
+
readonly snaps: number;
|
|
124
|
+
/** Total pending local writes re-applied over corrections. */
|
|
125
|
+
readonly replayed: number;
|
|
126
|
+
}
|
|
112
127
|
/** One `REPLY` that came back with `ok: false`, keyed by the rpc the client had called. */
|
|
113
128
|
interface RejectedCall {
|
|
114
129
|
readonly clientId: string;
|
|
@@ -165,6 +180,12 @@ interface TestRoom<S extends AnySchema> {
|
|
|
165
180
|
clientId: string;
|
|
166
181
|
differences: string[];
|
|
167
182
|
}[];
|
|
183
|
+
/**
|
|
184
|
+
* Per-client prediction accounting, from each room's `correct` events: corrections seen,
|
|
185
|
+
* numeric misprediction magnitude (total and worst), snaps and replayed writes. A client that
|
|
186
|
+
* was never corrected has an all-zero entry.
|
|
187
|
+
*/
|
|
188
|
+
predictionStats(): Map<string, PredictionStats>;
|
|
168
189
|
/** Outbound bytes per client and the per-tick average, as `toStayUnderBandwidth` reads them. */
|
|
169
190
|
bandwidth(): {
|
|
170
191
|
clientId: string;
|
|
@@ -273,9 +294,14 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
|
|
|
273
294
|
private readonly roleOverrides;
|
|
274
295
|
private readonly traceLog;
|
|
275
296
|
private readonly frameLeaks;
|
|
297
|
+
private readonly spatialSeen;
|
|
276
298
|
private readonly rejectionLog;
|
|
277
299
|
/** Reconnects completed per client id — bumped in `handleHello` on a resumed `WELCOME`. */
|
|
278
300
|
private readonly reconnectCounts;
|
|
301
|
+
/** Prediction accounting per client id, fed by each room's `correct` events. */
|
|
302
|
+
private readonly predictions;
|
|
303
|
+
/** What the render clock needs per client: its interp delay, plus the link's worst delay. */
|
|
304
|
+
private readonly renderMeta;
|
|
279
305
|
private nextClientId;
|
|
280
306
|
private droppedFrames;
|
|
281
307
|
/** Bumped whenever a frame is scheduled or delivered; the settle loop watches it. */
|
|
@@ -373,6 +399,22 @@ declare class TestHarness<S extends AnySchema> implements TestRoom<S> {
|
|
|
373
399
|
pretendRole(clientId: string, role: string): void;
|
|
374
400
|
private roleFor;
|
|
375
401
|
checkVisibility(): VisibilityLeak[];
|
|
402
|
+
private recordCorrection;
|
|
403
|
+
/** Prediction accounting per client id. Every joined client has an entry, zeroed if untouched. */
|
|
404
|
+
predictionStats(): Map<string, PredictionStats>;
|
|
405
|
+
/**
|
|
406
|
+
* Render-path divergence (D20): `room.render` diffed against the authority, but **only** for
|
|
407
|
+
* clients whose inbound state stream has been idle for at least `interpDelayMs` of fake time
|
|
408
|
+
* (plus the link's worst-case delay). The render clock draws at `now − interpDelayMs` and holds
|
|
409
|
+
* past the newest delta, so an idle client's render must equal its settled state — while a
|
|
410
|
+
* client mid-interpolation is legitimately behind and is skipped, never flaked on.
|
|
411
|
+
*/
|
|
412
|
+
renderConvergence(): {
|
|
413
|
+
clientId: string;
|
|
414
|
+
differences: string[];
|
|
415
|
+
}[];
|
|
416
|
+
/** When the room last sent this client a state-bearing frame, per the trace (send time). */
|
|
417
|
+
private lastStateFrameAt;
|
|
376
418
|
/** Per-client divergence from the authority, restricted to what that client's role may see. */
|
|
377
419
|
convergence(): {
|
|
378
420
|
clientId: string;
|
|
@@ -477,7 +519,7 @@ declare function materialize(ext: AnySchema, view: AnyRecord): PlainState;
|
|
|
477
519
|
* Where a client's view differs from the authority, restricted to the collections its role may
|
|
478
520
|
* see. Empty ⇒ converged.
|
|
479
521
|
*/
|
|
480
|
-
declare function divergence(ext: AnySchema, authority: PlainState, client: PlainState, visible: ReadonlySet<string
|
|
522
|
+
declare function divergence(ext: AnySchema, authority: PlainState, client: PlainState, visible: ReadonlySet<string>, memberships?: ReadonlyMap<string, ReadonlySet<string>>): string[];
|
|
481
523
|
|
|
482
524
|
/**
|
|
483
525
|
* `@irtio/testing` — the blessed test API.
|
|
@@ -521,7 +563,7 @@ declare function divergence(ext: AnySchema, authority: PlainState, client: Plain
|
|
|
521
563
|
* ```
|
|
522
564
|
*
|
|
523
565
|
* Importing this module does **not** pull Vitest in. `@irtio/testing/matchers` does, and
|
|
524
|
-
* registers the
|
|
566
|
+
* registers the five matchers as a side effect.
|
|
525
567
|
*
|
|
526
568
|
* Every method that touches the clock — `join`, `tick`, `run`, `until` on both `testRoom` and
|
|
527
569
|
* `testRelay` — is `async` and **must be awaited**; see `harness.ts`'s docblock for why.
|
|
@@ -539,4 +581,4 @@ declare function testRoom<S extends AnySchema>(definition: RoomDefinition<S>, op
|
|
|
539
581
|
/** Starts a schema-less relay room in-process and returns the test handle. See the module docblock. */
|
|
540
582
|
declare function testRelay(options?: TestRelayOptions): Promise<TestRelay>;
|
|
541
583
|
|
|
542
|
-
export { type LatencySpec, type RejectedCall, type TestClient, TestHarness, type TestJoinSpec, type TestRelay, type TestRelayClient, TestRelayHarness, type TestRelayJoinSpec, type TestRelayOptions, type TestRoom, type TestRoomOptions, type UntilOptions, clockScheduler, divergence, materialize, testRelay, testRoom };
|
|
584
|
+
export { type LatencySpec, type PredictionStats, type RejectedCall, type TestClient, TestHarness, type TestJoinSpec, type TestRelay, type TestRelayClient, TestRelayHarness, type TestRelayJoinSpec, type TestRelayOptions, type TestRoom, type TestRoomOptions, type UntilOptions, clockScheduler, divergence, materialize, testRelay, testRoom };
|
package/dist/index.js
CHANGED
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
toHaveConverged,
|
|
10
10
|
toHaveNoVisibilityLeaks,
|
|
11
11
|
toHaveRejected,
|
|
12
|
-
toStayUnderBandwidth
|
|
13
|
-
|
|
12
|
+
toStayUnderBandwidth,
|
|
13
|
+
toStayWithinPrediction
|
|
14
|
+
} from "./chunk-6EHPS7A5.js";
|
|
14
15
|
|
|
15
16
|
// src/relay-harness.ts
|
|
16
17
|
import { joinRelay } from "@irtio/client";
|
|
@@ -446,6 +447,7 @@ var TestRelayHarness = class {
|
|
|
446
447
|
};
|
|
447
448
|
|
|
448
449
|
// src/index.ts
|
|
450
|
+
import { initPhysics } from "@irtio/runtime/test";
|
|
449
451
|
async function testRoom(definition, options = {}) {
|
|
450
452
|
return new TestHarness(definition, options);
|
|
451
453
|
}
|
|
@@ -458,6 +460,7 @@ export {
|
|
|
458
460
|
TestRelayHarness,
|
|
459
461
|
clockScheduler,
|
|
460
462
|
divergence,
|
|
463
|
+
initPhysics,
|
|
461
464
|
matchers,
|
|
462
465
|
materialize,
|
|
463
466
|
testRelay,
|
|
@@ -465,5 +468,6 @@ export {
|
|
|
465
468
|
toHaveConverged,
|
|
466
469
|
toHaveNoVisibilityLeaks,
|
|
467
470
|
toHaveRejected,
|
|
468
|
-
toStayUnderBandwidth
|
|
471
|
+
toStayUnderBandwidth,
|
|
472
|
+
toStayWithinPrediction
|
|
469
473
|
};
|
package/dist/matchers.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
import { P as PredictionBounds } from './assertions-Cj1Sejhj.js';
|
|
2
|
+
export { m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as toHaveRejected, c as toStayUnderBandwidth, d as toStayWithinPrediction } from './assertions-Cj1Sejhj.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* `@irtio/testing/matchers` — the Vitest entry.
|
|
5
6
|
*
|
|
6
|
-
* Importing this module registers the
|
|
7
|
+
* Importing this module registers the five matchers and augments `expect`'s types. It is a
|
|
7
8
|
* separate entry point on purpose: Vitest is an **optional** peer of `@irtio/testing`, so the main
|
|
8
9
|
* entry (and anything that only wants `testRoom`) never touches it.
|
|
9
10
|
*
|
|
@@ -12,6 +13,7 @@ export { m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as
|
|
|
12
13
|
* expect(t).toHaveNoVisibilityLeaks();
|
|
13
14
|
* ```
|
|
14
15
|
*/
|
|
16
|
+
|
|
15
17
|
/** The matchers this package adds to `expect`. */
|
|
16
18
|
interface IrtioMatchers<R = unknown> {
|
|
17
19
|
/** No client's view or received frame named a collection its role must not see. */
|
|
@@ -22,10 +24,12 @@ interface IrtioMatchers<R = unknown> {
|
|
|
22
24
|
toHaveRejected(rpcName: string): R;
|
|
23
25
|
/** No client averaged more than `bytesPerTick` outbound bytes per room tick. */
|
|
24
26
|
toStayUnderBandwidth(bytesPerTick: number): R;
|
|
27
|
+
/** No client's mispredictions exceeded the given magnitude/snap bounds. */
|
|
28
|
+
toStayWithinPrediction(opts: PredictionBounds): R;
|
|
25
29
|
}
|
|
26
30
|
declare module 'vitest' {
|
|
27
31
|
interface Matchers<T = any> extends IrtioMatchers<T> {
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
export type
|
|
35
|
+
export { type IrtioMatchers, PredictionBounds };
|
package/dist/matchers.js
CHANGED
|
@@ -3,8 +3,9 @@ import {
|
|
|
3
3
|
toHaveConverged,
|
|
4
4
|
toHaveNoVisibilityLeaks,
|
|
5
5
|
toHaveRejected,
|
|
6
|
-
toStayUnderBandwidth
|
|
7
|
-
|
|
6
|
+
toStayUnderBandwidth,
|
|
7
|
+
toStayWithinPrediction
|
|
8
|
+
} from "./chunk-6EHPS7A5.js";
|
|
8
9
|
|
|
9
10
|
// src/matchers.ts
|
|
10
11
|
import { expect } from "vitest";
|
|
@@ -14,5 +15,6 @@ export {
|
|
|
14
15
|
toHaveConverged,
|
|
15
16
|
toHaveNoVisibilityLeaks,
|
|
16
17
|
toHaveRejected,
|
|
17
|
-
toStayUnderBandwidth
|
|
18
|
+
toStayUnderBandwidth,
|
|
19
|
+
toStayWithinPrediction
|
|
18
20
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "irtio's blessed test API: testRoom() in-process rooms with real client semantics, plus Vitest matchers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
"dist"
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@irtio/client": "0.
|
|
27
|
-
"@irtio/protocol": "0.
|
|
28
|
-
"@irtio/runtime": "0.
|
|
29
|
-
"@irtio/
|
|
30
|
-
"@irtio/
|
|
26
|
+
"@irtio/client": "0.2.0",
|
|
27
|
+
"@irtio/protocol": "0.2.0",
|
|
28
|
+
"@irtio/runtime": "0.2.0",
|
|
29
|
+
"@irtio/schema": "0.2.0",
|
|
30
|
+
"@irtio/server": "0.2.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"vitest": ">=3"
|