// room-connecting.test.mjs — " is connecting and can't hear you yet" // (spec §15.24, 2026-10-01), gated without a browser. // // node --test test/browser/room-connecting.test.mjs // // The functions are lifted out of the shipped room.js by name, with the // constants they read, as room-reconnect.test.mjs does: // // connectingStep the watch: the grace, clearing on `hearing`, the // fallback for a client that does not say, the cap, and // a rejoin starting it again // hearsYou a peer's report on us: true, false, or does not say // peerNotice what a tile says, and what replaces what // judgeHeard this tab's own half: when a path counts as heard import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const src = readFileSync(fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)), 'utf8'); function lift(name) { const start = src.indexOf('function ' + name + '('); assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); let depth = 0; for (let i = src.indexOf('{', start); i < src.length; i++) { if (src[i] === '{') depth += 1; else if (src[i] === '}') { depth -= 1; if (depth === 0) return src.slice(start, i + 1); } } assert.fail('unbalanced braces reading ' + name); } function liftConst(name) { const m = src.match(new RegExp('const ' + name + ' = ([0-9.]+);')); assert.ok(m, name + ' is not a numeric const in room.js any more'); return 'const ' + name + ' = ' + m[1] + ';'; } const CONSTS = ['CONNECTING_GRACE', 'CONNECTING_CAP'].map(liftConst).join('\n'); const names = ['connectingRecord', 'connectingStep', 'hearsYou', 'peerNotice', 'remoteNotice', 'connectingWords', 'canHearWords', 'judgeHeard', 'hearingMoved']; // eslint-disable-next-line no-new-func const fns = new Function(CONSTS + '\n' + names.map(lift).join('\n') + '; return { ' + names.join(', ') + ', GRACE: CONNECTING_GRACE, CAP: CONNECTING_CAP };')(); const { connectingRecord, connectingStep, hearsYou, peerNotice, judgeHeard, hearingMoved, GRACE, CAP } = fns; const ME = 'm'.repeat(32); const SAM = 's'.repeat(32); const waiting = { epoch: 1, hearsMe: false, reported: true, connected: false, acked: false }; // Five looks a second from `from` to `to` seconds; when Sam's words // showed, and when they cleared with "can hear you now". function run(rec, from, to, sam) { const shown = []; const cleared = []; for (let t = from * 10; t < to * 10; t += 2) { const s = Math.round(t) / 10; const p = sam(s); const people = p ? new Map([[SAM, p]]) : new Map(); if (connectingStep(rec, s * 1000, people).includes(SAM)) cleared.push(s); const r = rec.peers.get(SAM); if (r && r.shown) shown.push(s); } return { shown, cleared }; } test('the timings are the ones the spec names', () => { assert.equal(GRACE, 1000); assert.equal(CAP, 20000); }); test('an ordinary join, heard within the grace, says nothing at all', () => { const rec = connectingRecord(); const seen = run(rec, 0, 10, (t) => ({ ...waiting, connected: t >= 0.4, hearsMe: t >= 0.8 })); assert.deepEqual(seen.shown, []); assert.deepEqual(seen.cleared, [], 'nothing was said, so "can hear you now" is not said either'); assert.equal(rec.peers.get(SAM).done, true); }); test('a slow join says so after the grace, and clears the moment they hear us', () => { const rec = connectingRecord(); const seen = run(rec, 0, 12, (t) => ({ ...waiting, connected: t >= 5, hearsMe: t >= 6 })); assert.equal(seen.shown[0], 1); assert.equal(seen.shown.at(-1), 5.8); assert.deepEqual(seen.cleared, [6]); }); test('once cleared it stays cleared, whatever their list does after', () => { const rec = connectingRecord(); const seen = run(rec, 0, 30, (t) => ({ ...waiting, connected: true, hearsMe: t >= 3 && t < 10 })); assert.deepEqual(seen.cleared, [3]); assert.equal(seen.shown.at(-1), 2.8, '§15.23 and §15.20 speak for a problem after that'); }); test('it is given up at the cap, silently', () => { const rec = connectingRecord(); const seen = run(rec, 0, 40, () => waiting); assert.equal(seen.shown[0], 1); assert.equal(seen.shown.at(-1), 19.8); assert.deepEqual(seen.cleared, [], 'a cap is not their hearing us'); assert.equal(rec.peers.get(SAM).done, true); }); test('a client that does not say who it hears: its first report and a connection clear it', () => { const rec = connectingRecord(); const seen = run(rec, 0, 10, (t) => ({ epoch: 1, hearsMe: null, reported: t >= 4, connected: t >= 2, acked: false })); assert.deepEqual(seen.cleared, [4]); }); test('or a connection and receiver reports showing our audio reaching them', () => { const rec = connectingRecord(); const seen = run(rec, 0, 10, (t) => ({ epoch: 1, hearsMe: null, reported: false, connected: t >= 2, acked: t >= 3 })); assert.deepEqual(seen.cleared, [3]); const rec2 = connectingRecord(); const unconnected = run(rec2, 0, 10, () => ({ epoch: 1, hearsMe: null, reported: true, connected: false, acked: true })); assert.deepEqual(unconnected.cleared, [], 'never without a connection'); }); test('a client that says who it hears is believed over our own evidence', () => { const rec = connectingRecord(); const seen = run(rec, 0, 10, () => ({ epoch: 1, hearsMe: false, reported: true, connected: true, acked: true })); assert.deepEqual(seen.cleared, []); assert.equal(seen.shown.length > 0, true); }); test('a rejoin starts it again, and so does a new connection to the same person', () => { const rec = connectingRecord(); const seen = run(rec, 0, 20, (t) => { if (t >= 5 && t < 6) return null; // gone from the roster if (t < 5) return { ...waiting, connected: true, hearsMe: t >= 2 }; return { ...waiting, connected: t >= 9, hearsMe: t >= 9 }; }); assert.deepEqual(seen.cleared, [2, 9]); assert.equal(seen.shown.includes(7), true); assert.equal(seen.shown.includes(6.8), false, 'the grace again from the moment they are back'); const rec2 = connectingRecord(); const rebuilt = run(rec2, 0, 20, (t) => ({ ...waiting, epoch: t < 8 ? 1 : 2, connected: true, hearsMe: t < 1 || t >= 12 })); assert.deepEqual(rebuilt.cleared, [12]); assert.equal(rebuilt.shown[0], 9); }); test('hearsYou: listed, not listed, or not saying', () => { assert.equal(hearsYou({ muted: false, cameraOff: false, hearing: [ME] }, ME), true); assert.equal(hearsYou({ muted: false, cameraOff: false, hearing: [] }, ME), false); assert.equal(hearsYou({ muted: false, cameraOff: false }, ME), null); assert.equal(hearsYou(null, ME), null); }); test('peerNotice: a more specific report replaces "connecting", and "Reconnecting" waits until after', () => { const plain = { muted: false, cameraOff: false, hearing: [] }; assert.deepEqual(peerNotice('Sam', plain, ME, false, true, false, false), { kind: '', text: '' }, 'inside the grace'); assert.deepEqual(peerNotice('Sam', plain, ME, false, true, true, false), { kind: 'connecting', text: 'Sam is connecting and can’t hear you yet' }); assert.deepEqual(peerNotice('Sam', plain, ME, true, true, true, false).kind, 'connecting', 'our own mute does not matter: it is about their hearing us when we speak'); assert.deepEqual(peerNotice('Sam', plain, ME, false, true, true, true).kind, 'connecting', 'a connection that has never been up is not reconnecting'); assert.deepEqual(peerNotice('Sam', { ...plain, audioBlocked: true }, ME, false, true, false, false), { kind: 'report', text: 'Sam’s browser is blocking sound — they need to click to hear you.' }, 'even inside the grace: it is their own report'); assert.deepEqual(peerNotice('Sam', { ...plain, noSoundFrom: [ME] }, ME, false, true, true, false), { kind: 'report', text: 'Sam isn’t getting any sound from you.' }); assert.deepEqual(peerNotice('Sam', { ...plain, noSoundFrom: [ME] }, ME, true, true, true, false).kind, 'connecting', 'no sound from us while we are muted is expected, so it does not replace anything'); assert.deepEqual(peerNotice('Sam', plain, ME, false, false, false, true), { kind: 'reconnecting', text: 'Reconnecting to Sam…' }); assert.deepEqual(peerNotice('Sam', { ...plain, audioBlocked: true }, ME, false, false, false, false).kind, 'report'); assert.deepEqual(peerNotice('Sam', plain, ME, false, false, false, false), { kind: '', text: '' }); }); test('judgeHeard: arriving and decoded, or a muted sender on a live connection', () => { const base = { connected: true, bad: false, hasReceiver: true, receiverLive: true, packets: 0, samples: 0, senderMuted: false }; assert.equal(judgeHeard(false, base), false, 'nothing has arrived'); assert.equal(judgeHeard(false, { ...base, packets: 10 }), false, 'packets, but nothing decoded'); assert.equal(judgeHeard(false, { ...base, packets: 10, samples: 480 }), true); assert.equal(judgeHeard(false, { ...base, connected: false, packets: 10, samples: 480 }), false, 'not before the connection is up'); assert.equal(judgeHeard(false, { ...base, senderMuted: true }), true, 'a muted sender must not look unheard'); assert.equal(judgeHeard(false, { ...base, senderMuted: true, hasReceiver: false, receiverLive: false }), true, 'nor one with no microphone at all'); assert.equal(judgeHeard(false, { ...base, senderMuted: true, receiverLive: false }), false, 'but not on a dead receiver'); assert.equal(judgeHeard(true, base), true, 'once heard, kept while the connection lasts'); assert.equal(judgeHeard(true, { ...base, connected: false }), true, 'a blip is not losing them'); assert.equal(judgeHeard(true, { ...base, bad: true }), false, 'a failed connection is'); }); test('hearingMoved: a gain, or a loss of someone still here, and never a departure alone', () => { const here = new Set(['a', 'b']); assert.equal(hearingMoved('', 'a', here), true); assert.equal(hearingMoved('a', 'a,b', here), true); assert.equal(hearingMoved('a,b', 'a', here), true, 'b is still here and no longer heard'); assert.equal(hearingMoved('a,c', 'a', here), false, 'c has left: not worth a report from everyone'); assert.equal(hearingMoved('a', 'a', here), false); assert.equal(hearingMoved('', '', here), false); });