peer-kit / test / api.test.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
/**
 * The consumer contract, asserted against dist/.
 *
 * The bot (bot/src/bot/*.cljs) is the only consumer, and it reaches peer-kit
 * through CLJS `:refer` on the package root — which compiles to real ESM named
 * imports. So a rename or a lost class member here is a broken bot, not a type
 * error. The first test is literally the bot's import list; the rest pin the
 * class SHAPES that survived the port: `shadow.cljs.modern/defclass` has no
 * syntax for statics, getters or async methods, so those are attached by hand
 * (string-keyed, per dev/docs/CLJS.md) and are exactly the parts that could
 * quietly vanish.
 *
 * test/types/consumer.ts is the compile-time half of the same contract.
 */
import test from "node:test";
import assert from "node:assert/strict";
import { Hub } from "./helpers/mock-hub.mjs";

const kit = await import("../dist/index.js");

test("the bot's value-level imports all resolve", async () => {
  // bot.rooms / bot.main / fleet.* / rl.* — grouped as the bot requires them
  const byModule = {
    "bot.rooms": ["GAMES", "SeanceClient", "NeonGridClient", "TesseraClient",
                  "LampionClient", "OdeonClient", "ValleyBlocksClient"],
    "bot.main": ["loadIdentity", "Social", "installOrigin", "installBrowserGlobals"],
    "bot.fleet.link": ["loadIdentity", "suiteFriendLink"],
    "bot.fleet.bootstrap": ["parseFriendLink"],
    "bot.fleet.matchmaker": ["GAMES", "newRoomSecret"],
    "bot.rl.train": ["SeanceGym", "NeonGridGym", "ValleyBlocksGym", "mulberry32"],
    "bot.rl.valleyblocks_policy": ["boardMetrics", "stockPick"],
  };
  for (const [mod, names] of Object.entries(byModule)) {
    for (const n of names) {
      assert.notEqual(kit[n], undefined, `${mod} requires ${n}, which is undefined`);
    }
  }
  // GAMES is iterated by Object.keys in bot.rooms/app-of-host — key ORDER and
  // the per-app fields are part of that contract
  assert.deepEqual(Object.keys(kit.GAMES),
    ["neon-grid", "valley-blocks", "tessera", "seance", "lampion", "odeon"]);
  for (const [name, def] of Object.entries(kit.GAMES)) {
    assert.deepEqual(Object.keys(def), ["name", "host", "salt", "protocolPrefix"], name);
    assert.equal(def.name, name);
    assert.match(def.host, /^game[1-6]\.ardegazu\.ro$/);
    assert.equal(def.salt, `${def.host}/v2`);
    assert.match(def.protocolPrefix, /^\/[a-z0-9-]+\/2\/$/);
  }
  assert.match(kit.newRoomSecret(), /^[A-Za-z0-9_-]{43}$/);
});

test("every game client is a GameRoom subclass with an INHERITED polymorphic join", () => {
  const clients = {
    SeanceClient: "joinSeance",
    NeonGridClient: "joinGrid",
    TesseraClient: "joinTessera",
    LampionClient: "joinLampion",
    OdeonClient: "joinOdeon",
    ValleyBlocksClient: "joinBlocks",
  };
  for (const [name, named] of Object.entries(clients)) {
    const C = kit[name];
    assert.equal(typeof C, "function", name);
    assert.ok(C.prototype instanceof kit.GameRoom, `${name} extends GameRoom`);
    // `bot.rooms` calls `Client.join(opts)` on whichever class it looked up, so
    // static inheritance has to hold AND `this` inside join has to be the
    // concrete constructor (defclass compiles to a real ES class, so it does)
    assert.equal(typeof C.join, "function", `${name}.join`);
    assert.equal(C.join, kit.GameRoom.join, `${name}.join is the inherited static`);
    assert.equal(typeof C[named], "function", `${name}.${named}`);
  }
  // The polymorphism itself (`this-as ctor` → `new ctor`) only fires after
  // connect() resolves, which needs the relay, so it is not exercised offline
  // here. What IS checked offline: `join` is the single inherited static above
  // (so `this` is the concrete constructor at the call site), it takes one
  // argument, and every subclass constructor works standalone — the vector and
  // host-driver suites build SeanceClient/NeonGridClient/ValleyBlocksClient
  // instances directly over the in-memory hub and drive whole matches through
  // them.
  assert.equal(kit.GameRoom.join.length, 1, "join(opts)");
});

test("GameRoom's instance surface survived the port", () => {
  const p = kit.GameRoom.prototype;
  for (const m of ["score", "claimBeats", "sendToHost", "injectLocal", "wake", "close"]) {
    assert.equal(typeof p[m], "function", `GameRoom.prototype.${m}`);
  }
  for (const g of ["myId", "hosting", "leaderboard"]) {
    const d = Object.getOwnPropertyDescriptor(p, g);
    assert.ok(d && typeof d.get === "function", `GameRoom.prototype.${g} is a getter`);
  }
  // the base score()/claimBeats() defaults, and that subclasses override them
  const hub = new Hub();
  const peer = hub.join("mmm");
  const base = new kit.GameRoom(peer, { app: "seance", roomSecret: "s", nick: "n", keepAliveMs: 0 });
  assert.equal(base.score(), null, "the base never co-signs");
  assert.equal(base.claimBeats("aaa", {}), false, "no host yet ⇒ nothing outranks");
  base.hostId = "zzz";
  assert.equal(base.claimBeats("aaa", {}), true, "plain lowest-id is the base rule");
  assert.equal(base.claimBeats("zzzz", {}), false);

  const seance = new kit.SeanceClient(hub.join("s1"), { app: "seance", roomSecret: "s", nick: "n", keepAliveMs: 0 });
  seance.wins = 3;
  assert.equal(seance.score(), 3, "séance co-signs its win count");
  assert.equal(seance.seat, -1, "not seated yet");
  assert.deepEqual(Object.keys(seance.view()),
    ["bell", "seat", "candles", "rooms", "hintNot", "isMedium"]);
  assert.equal(seance.startMatch(), false, "no host driver ⇒ nothing to start");
  assert.equal(seance.host, null);

  const grid = new kit.NeonGridClient(hub.join("g1"), { app: "neon-grid", roomSecret: "s", nick: "n", keepAliveMs: 0 });
  grid.points = 7;
  assert.equal(grid.score(), 7);
  grid.hostId = "zzz";
  // neon-grid overrides with live-beats-idle: a live claimant wins even from a
  // higher id, which plain lowest-id would have refused
  assert.equal(grid.claimBeats("zzzz", { live: 1 }), true, "live beats our idle host");
  assert.equal(grid.claimBeats("aaa", {}), true, "…and idle-vs-idle falls back to lowest id");

  const blocks = new kit.ValleyBlocksClient(hub.join("b1"), { app: "valley-blocks", roomSecret: "s", nick: "n", keepAliveMs: 0 });
  assert.equal(blocks.score(), null, "no seat ⇒ never sign");
  assert.equal(blocks.roundWins, 0);

  const odeon = new kit.OdeonClient(hub.join("o1"), { app: "odeon", roomSecret: "s", nick: "n", keepAliveMs: 0, perform: false });
  assert.equal(odeon.score(), null, "a cooperative jam is never on the leaderboard");
});

test("the sims and gyms expose the members the trainer drives them by", () => {
  const rng = kit.mulberry32(1);
  const sim = new kit.SeanceSim({ rng, now: () => 0, roster: () => [], addWin: () => {} });
  for (const g of ["running", "bell", "order", "candles", "mediumSeat", "hintNot", "haunt", "inWalk"]) {
    const d = Object.getOwnPropertyDescriptor(kit.SeanceSim.prototype, g);
    assert.ok(d && typeof d.get === "function", `SeanceSim.prototype.${g} is a getter`);
  }
  for (const m of ["start", "step", "pick", "peerGone", "snapshot", "abort"]) {
    assert.equal(typeof sim[m], "function", `SeanceSim#${m}`);
  }
  const ng = new kit.NeonGridSim({ roster: () => [], addScore: () => {}, resetScores: () => {} });
  for (const m of ["newMatch", "startRound", "input", "peerGone", "step", "abort", "snapshot"]) {
    assert.equal(typeof ng[m], "function", `NeonGridSim#${m}`);
  }
  assert.ok(ng.grid instanceof Uint8Array);
  const vb = new kit.ValleyBlocksSim({ roster: () => [], addWin: () => {} });
  for (const m of ["startRound", "onBoardState", "playerLeft", "snapshot", "abort"]) {
    assert.equal(typeof vb[m], "function", `ValleyBlocksSim#${m}`);
  }
  assert.equal(vb.mode, "race-survival");
  assert.equal(vb.target, 5000);

  const e = new kit.ValleyBlocksEngine(7);
  for (const m of ["update", "setSoftDrop", "softStep", "move", "rotate", "hardDrop", "holdPiece"]) {
    assert.equal(typeof e[m], "function", `ValleyBlocksEngine#${m}`);
  }
  for (const g of ["nextQueue", "ghostY"]) {
    const d = Object.getOwnPropertyDescriptor(kit.ValleyBlocksEngine.prototype, g);
    assert.ok(d && typeof d.get === "function", `ValleyBlocksEngine.prototype.${g} is a getter`);
  }
  assert.equal(e.seed, 7);

  for (const [G, opts] of [
    [kit.SeanceGym, { players: 2, seed: 1 }],
    [kit.NeonGridGym, { players: 2 }],
    [kit.ValleyBlocksGym, { players: 2, seed: 1 }],
  ]) {
    const g = new G(opts);
    assert.equal(typeof g.reset, "function");
    assert.equal(typeof g.step, "function");
    assert.equal(g.players, 2, "players is a public readable field");
  }
});

test("the host drivers keep the shape GameRoom drives them through", () => {
  const hub = new Hub();
  const room = new kit.SeanceClient(hub.join("aaa"), {
    app: "seance", roomSecret: "s", nick: "n", keepAliveMs: 0, host: {},
  });
  const d = room.host;
  assert.ok(d instanceof kit.HostDriver, "SeanceHost extends HostDriver");
  assert.equal(room.hostDriver, d, "the client attached it as the room's driver");
  // HostDriverLike — what games/room.cljs calls on it
  for (const m of ["onClaimed", "onSelfFrame", "onForeignClaim", "close"]) {
    assert.equal(typeof d[m], "function", `HostDriverLike#${m}`);
  }
  // the per-game hooks the base dispatches to, overridden by every subclass
  for (const m of ["concedeRule", "startMatch", "onGameFrame", "execStep",
                   "simPeerGone", "sendSnap", "roundLive", "simRunning", "abortSim"]) {
    assert.equal(typeof d[m], "function", `hook ${m}`);
    assert.notEqual(d[m], kit.HostDriver.prototype[m], `${m} is overridden, not the abstract stub`);
  }
  // the abstract stubs throw rather than silently no-op
  assert.throws(() => kit.HostDriver.prototype.startMatch.call({}), /abstract/);
  // the policy defaults
  assert.deepEqual({ ...d.policy }, {
    minPlayers: 2, expectedPlayers: 2, startTimeoutMs: 15000, maxMatches: Infinity,
  });
  assert.equal(d.phase, "idle");
  assert.equal(d.matches, 0);
  assert.ok(d.roster instanceof Map);
  assert.equal(typeof d.onMatchEnd, "function");
  assert.equal(typeof d.startNow, "function");
  d.close();

  // valley-blocks overrides broadcastRoster (game2's 3-tuple wire shape)
  const vbRoom = new kit.ValleyBlocksClient(hub.join("bbb"), {
    app: "valley-blocks", roomSecret: "s", nick: "n", keepAliveMs: 0,
    host: { mode: "race", target: 900 },
  });
  assert.notEqual(vbRoom.host.broadcastRoster, kit.HostDriver.prototype.broadcastRoster);
  assert.equal(vbRoom.host.sim.mode, "race");
  assert.equal(vbRoom.host.sim.target, 900);
  vbRoom.host.close();
});

test("Social exposes the wrapper surface the bot uses", () => {
  assert.equal(typeof kit.Social.start, "function", "Social.start is a static factory");
  for (const m of ["friendLink", "stop"]) {
    assert.equal(typeof kit.Social.prototype[m], "function", `Social.prototype.${m}`);
  }
  const nodeDesc = Object.getOwnPropertyDescriptor(kit.Social.prototype, "node");
  assert.ok(nodeDesc && typeof nodeDesc.get === "function", "Social.prototype.node is a getter");
  assert.equal(typeof kit.suiteFriendLink, "function");
  // Social extends EventEmitter — the bot does social.on("presence"|"invite"|…)
  assert.equal(typeof kit.Social.prototype.on, "function");
});

test("RoomPeer keeps its EventEmitter surface and delegating members", () => {
  assert.equal(typeof kit.RoomPeer.prototype.on, "function");
  for (const m of ["send", "broadcast", "identityOf", "close"]) {
    assert.equal(typeof kit.RoomPeer.prototype[m], "function", `RoomPeer.prototype.${m}`);
  }
  const d = Object.getOwnPropertyDescriptor(kit.RoomPeer.prototype, "myId");
  assert.ok(d && typeof d.get === "function", "RoomPeer.prototype.myId is a getter");
});

test("installOrigin is stateful: idempotent, and throws on a conflicting origin", async () => {
  // module-level state, ported faithfully — so this runs in its own process via
  // a fresh import of a separate module instance is not possible; instead
  // assert the observable contract on the shared instance.
  const { installOrigin, DEFAULT_ORIGIN } = kit;
  installOrigin(DEFAULT_ORIGIN);
  installOrigin(DEFAULT_ORIGIN); // idempotent
  assert.throws(() => installOrigin("https://elsewhere.example"), /already installed/);
  // the WebSocket global really was replaced
  assert.equal(typeof globalThis.WebSocket, "function");
  // …and selfPeerIds hangs off globalThis so sibling nodes never dial each other
  const { selfPeerIds } = await import("../dist/internals.js");
  const s = selfPeerIds();
  assert.ok(s instanceof Set);
  assert.equal(s, globalThis.__ardegazuSelfPeers, "shared across kits via the global");
  assert.equal(selfPeerIds(), s, "and memoized");
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/peer-kit.git