@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 four matchers, as plain functions.
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
- /** Every client's view equals the authority projected to that client's role. */
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
- for (const id of cd.added) out.push(`${name}[${id}]: only in the client's view`);
49
- for (const id of cd.removed) out.push(`${name}[${id}]: missing from the client's view`);
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 { Mulberry32, RoomCore, RoomFullError, visibleNames } from "@irtio/runtime";
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
- if (keep.has(dc.name) || dc.ops.length === 0) continue;
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: dc.ops.map((o) => o.id),
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(this.ext, this.plain, view, visibleNames(this.ext, role))
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 behind = t.convergence().filter((r) => r.differences.length > 0);
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-BP4as6ol.js';
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>): 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 four matchers as a side effect.
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
- } from "./chunk-O3OZKC3D.js";
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
  };
@@ -1,9 +1,10 @@
1
- export { m as matchers, t as toHaveConverged, a as toHaveNoVisibilityLeaks, b as toHaveRejected, c as toStayUnderBandwidth } from './assertions-BP4as6ol.js';
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 four matchers and augments `expect`'s types. It is a
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 { IrtioMatchers };
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
- } from "./chunk-O3OZKC3D.js";
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.1.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.1.0",
27
- "@irtio/protocol": "0.1.0",
28
- "@irtio/runtime": "0.1.0",
29
- "@irtio/server": "0.1.0",
30
- "@irtio/schema": "0.1.0"
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"