| 1 | // room-connecting.test.mjs — "<name> is connecting and can't hear you yet" |
| 2 | // (spec §15.24, 2026-10-01), gated without a browser. |
| 3 | // |
| 4 | // node --test test/browser/room-connecting.test.mjs |
| 5 | // |
| 6 | // The functions are lifted out of the shipped room.js by name, with the |
| 7 | // constants they read, as room-reconnect.test.mjs does: |
| 8 | // |
| 9 | // connectingStep the watch: the grace, clearing on `hearing`, the |
| 10 | // fallback for a client that does not say, the cap, and |
| 11 | // a rejoin starting it again |
| 12 | // hearsYou a peer's report on us: true, false, or does not say |
| 13 | // peerNotice what a tile says, and what replaces what |
| 14 | // judgeHeard this tab's own half: when a path counts as heard |
| 15 | import { test } from 'node:test'; |
| 16 | import assert from 'node:assert/strict'; |
| 17 | import { readFileSync } from 'node:fs'; |
| 18 | import { fileURLToPath } from 'node:url'; |
| 19 | |
| 20 | const src = readFileSync(fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)), 'utf8'); |
| 21 | |
| 22 | function lift(name) { |
| 23 | const start = src.indexOf('function ' + name + '('); |
| 24 | assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); |
| 25 | let depth = 0; |
| 26 | for (let i = src.indexOf('{', start); i < src.length; i++) { |
| 27 | if (src[i] === '{') depth += 1; |
| 28 | else if (src[i] === '}') { |
| 29 | depth -= 1; |
| 30 | if (depth === 0) return src.slice(start, i + 1); |
| 31 | } |
| 32 | } |
| 33 | assert.fail('unbalanced braces reading ' + name); |
| 34 | } |
| 35 | function liftConst(name) { |
| 36 | const m = src.match(new RegExp('const ' + name + ' = ([0-9.]+);')); |
| 37 | assert.ok(m, name + ' is not a numeric const in room.js any more'); |
| 38 | return 'const ' + name + ' = ' + m[1] + ';'; |
| 39 | } |
| 40 | const CONSTS = ['CONNECTING_GRACE', 'CONNECTING_CAP'].map(liftConst).join('\n'); |
| 41 | const names = ['connectingRecord', 'connectingStep', 'hearsYou', 'peerNotice', 'remoteNotice', 'connectingWords', |
| 42 | 'canHearWords', 'judgeHeard', 'hearingMoved']; |
| 43 | // eslint-disable-next-line no-new-func |
| 44 | const fns = new Function(CONSTS + '\n' + names.map(lift).join('\n') + '; return { ' + names.join(', ') + |
| 45 | ', GRACE: CONNECTING_GRACE, CAP: CONNECTING_CAP };')(); |
| 46 | const { connectingRecord, connectingStep, hearsYou, peerNotice, judgeHeard, hearingMoved, GRACE, CAP } = fns; |
| 47 | |
| 48 | const ME = 'm'.repeat(32); |
| 49 | const SAM = 's'.repeat(32); |
| 50 | const waiting = { epoch: 1, hearsMe: false, reported: true, connected: false, acked: false }; |
| 51 | |
| 52 | // Five looks a second from `from` to `to` seconds; when Sam's words |
| 53 | // showed, and when they cleared with "can hear you now". |
| 54 | function run(rec, from, to, sam) { |
| 55 | const shown = []; |
| 56 | const cleared = []; |
| 57 | for (let t = from * 10; t < to * 10; t += 2) { |
| 58 | const s = Math.round(t) / 10; |
| 59 | const p = sam(s); |
| 60 | const people = p ? new Map([[SAM, p]]) : new Map(); |
| 61 | if (connectingStep(rec, s * 1000, people).includes(SAM)) cleared.push(s); |
| 62 | const r = rec.peers.get(SAM); |
| 63 | if (r && r.shown) shown.push(s); |
| 64 | } |
| 65 | return { shown, cleared }; |
| 66 | } |
| 67 | |
| 68 | test('the timings are the ones the spec names', () => { |
| 69 | assert.equal(GRACE, 1000); |
| 70 | assert.equal(CAP, 20000); |
| 71 | }); |
| 72 | |
| 73 | test('an ordinary join, heard within the grace, says nothing at all', () => { |
| 74 | const rec = connectingRecord(); |
| 75 | const seen = run(rec, 0, 10, (t) => ({ ...waiting, connected: t >= 0.4, hearsMe: t >= 0.8 })); |
| 76 | assert.deepEqual(seen.shown, []); |
| 77 | assert.deepEqual(seen.cleared, [], 'nothing was said, so "can hear you now" is not said either'); |
| 78 | assert.equal(rec.peers.get(SAM).done, true); |
| 79 | }); |
| 80 | |
| 81 | test('a slow join says so after the grace, and clears the moment they hear us', () => { |
| 82 | const rec = connectingRecord(); |
| 83 | const seen = run(rec, 0, 12, (t) => ({ ...waiting, connected: t >= 5, hearsMe: t >= 6 })); |
| 84 | assert.equal(seen.shown[0], 1); |
| 85 | assert.equal(seen.shown.at(-1), 5.8); |
| 86 | assert.deepEqual(seen.cleared, [6]); |
| 87 | }); |
| 88 | |
| 89 | test('once cleared it stays cleared, whatever their list does after', () => { |
| 90 | const rec = connectingRecord(); |
| 91 | const seen = run(rec, 0, 30, (t) => ({ ...waiting, connected: true, hearsMe: t >= 3 && t < 10 })); |
| 92 | assert.deepEqual(seen.cleared, [3]); |
| 93 | assert.equal(seen.shown.at(-1), 2.8, '§15.23 and §15.20 speak for a problem after that'); |
| 94 | }); |
| 95 | |
| 96 | test('it is given up at the cap, silently', () => { |
| 97 | const rec = connectingRecord(); |
| 98 | const seen = run(rec, 0, 40, () => waiting); |
| 99 | assert.equal(seen.shown[0], 1); |
| 100 | assert.equal(seen.shown.at(-1), 19.8); |
| 101 | assert.deepEqual(seen.cleared, [], 'a cap is not their hearing us'); |
| 102 | assert.equal(rec.peers.get(SAM).done, true); |
| 103 | }); |
| 104 | |
| 105 | test('a client that does not say who it hears: its first report and a connection clear it', () => { |
| 106 | const rec = connectingRecord(); |
| 107 | const seen = run(rec, 0, 10, (t) => ({ epoch: 1, hearsMe: null, reported: t >= 4, connected: t >= 2, acked: false })); |
| 108 | assert.deepEqual(seen.cleared, [4]); |
| 109 | }); |
| 110 | |
| 111 | test('or a connection and receiver reports showing our audio reaching them', () => { |
| 112 | const rec = connectingRecord(); |
| 113 | const seen = run(rec, 0, 10, (t) => ({ epoch: 1, hearsMe: null, reported: false, connected: t >= 2, acked: t >= 3 })); |
| 114 | assert.deepEqual(seen.cleared, [3]); |
| 115 | const rec2 = connectingRecord(); |
| 116 | const unconnected = run(rec2, 0, 10, () => ({ epoch: 1, hearsMe: null, reported: true, connected: false, acked: true })); |
| 117 | assert.deepEqual(unconnected.cleared, [], 'never without a connection'); |
| 118 | }); |
| 119 | |
| 120 | test('a client that says who it hears is believed over our own evidence', () => { |
| 121 | const rec = connectingRecord(); |
| 122 | const seen = run(rec, 0, 10, () => ({ epoch: 1, hearsMe: false, reported: true, connected: true, acked: true })); |
| 123 | assert.deepEqual(seen.cleared, []); |
| 124 | assert.equal(seen.shown.length > 0, true); |
| 125 | }); |
| 126 | |
| 127 | test('a rejoin starts it again, and so does a new connection to the same person', () => { |
| 128 | const rec = connectingRecord(); |
| 129 | const seen = run(rec, 0, 20, (t) => { |
| 130 | if (t >= 5 && t < 6) return null; // gone from the roster |
| 131 | if (t < 5) return { ...waiting, connected: true, hearsMe: t >= 2 }; |
| 132 | return { ...waiting, connected: t >= 9, hearsMe: t >= 9 }; |
| 133 | }); |
| 134 | assert.deepEqual(seen.cleared, [2, 9]); |
| 135 | assert.equal(seen.shown.includes(7), true); |
| 136 | assert.equal(seen.shown.includes(6.8), false, 'the grace again from the moment they are back'); |
| 137 | |
| 138 | const rec2 = connectingRecord(); |
| 139 | const rebuilt = run(rec2, 0, 20, (t) => ({ ...waiting, epoch: t < 8 ? 1 : 2, connected: true, hearsMe: t < 1 || t >= 12 })); |
| 140 | assert.deepEqual(rebuilt.cleared, [12]); |
| 141 | assert.equal(rebuilt.shown[0], 9); |
| 142 | }); |
| 143 | |
| 144 | test('hearsYou: listed, not listed, or not saying', () => { |
| 145 | assert.equal(hearsYou({ muted: false, cameraOff: false, hearing: [ME] }, ME), true); |
| 146 | assert.equal(hearsYou({ muted: false, cameraOff: false, hearing: [] }, ME), false); |
| 147 | assert.equal(hearsYou({ muted: false, cameraOff: false }, ME), null); |
| 148 | assert.equal(hearsYou(null, ME), null); |
| 149 | }); |
| 150 | |
| 151 | test('peerNotice: a more specific report replaces "connecting", and "Reconnecting" waits until after', () => { |
| 152 | const plain = { muted: false, cameraOff: false, hearing: [] }; |
| 153 | assert.deepEqual(peerNotice('Sam', plain, ME, false, true, false, false), { kind: '', text: '' }, 'inside the grace'); |
| 154 | assert.deepEqual(peerNotice('Sam', plain, ME, false, true, true, false), |
| 155 | { kind: 'connecting', text: 'Sam is connecting and can’t hear you yet' }); |
| 156 | assert.deepEqual(peerNotice('Sam', plain, ME, true, true, true, false).kind, 'connecting', |
| 157 | 'our own mute does not matter: it is about their hearing us when we speak'); |
| 158 | assert.deepEqual(peerNotice('Sam', plain, ME, false, true, true, true).kind, 'connecting', |
| 159 | 'a connection that has never been up is not reconnecting'); |
| 160 | assert.deepEqual(peerNotice('Sam', { ...plain, audioBlocked: true }, ME, false, true, false, false), |
| 161 | { kind: 'report', text: 'Sam’s browser is blocking sound — they need to click to hear you.' }, |
| 162 | 'even inside the grace: it is their own report'); |
| 163 | assert.deepEqual(peerNotice('Sam', { ...plain, noSoundFrom: [ME] }, ME, false, true, true, false), |
| 164 | { kind: 'report', text: 'Sam isn’t getting any sound from you.' }); |
| 165 | assert.deepEqual(peerNotice('Sam', { ...plain, noSoundFrom: [ME] }, ME, true, true, true, false).kind, 'connecting', |
| 166 | 'no sound from us while we are muted is expected, so it does not replace anything'); |
| 167 | assert.deepEqual(peerNotice('Sam', plain, ME, false, false, false, true), |
| 168 | { kind: 'reconnecting', text: 'Reconnecting to Sam…' }); |
| 169 | assert.deepEqual(peerNotice('Sam', { ...plain, audioBlocked: true }, ME, false, false, false, false).kind, 'report'); |
| 170 | assert.deepEqual(peerNotice('Sam', plain, ME, false, false, false, false), { kind: '', text: '' }); |
| 171 | }); |
| 172 | |
| 173 | test('judgeHeard: arriving and decoded, or a muted sender on a live connection', () => { |
| 174 | const base = { connected: true, bad: false, hasReceiver: true, receiverLive: true, packets: 0, samples: 0, senderMuted: false }; |
| 175 | assert.equal(judgeHeard(false, base), false, 'nothing has arrived'); |
| 176 | assert.equal(judgeHeard(false, { ...base, packets: 10 }), false, 'packets, but nothing decoded'); |
| 177 | assert.equal(judgeHeard(false, { ...base, packets: 10, samples: 480 }), true); |
| 178 | assert.equal(judgeHeard(false, { ...base, connected: false, packets: 10, samples: 480 }), false, 'not before the connection is up'); |
| 179 | assert.equal(judgeHeard(false, { ...base, senderMuted: true }), true, 'a muted sender must not look unheard'); |
| 180 | assert.equal(judgeHeard(false, { ...base, senderMuted: true, hasReceiver: false, receiverLive: false }), true, |
| 181 | 'nor one with no microphone at all'); |
| 182 | assert.equal(judgeHeard(false, { ...base, senderMuted: true, receiverLive: false }), false, 'but not on a dead receiver'); |
| 183 | assert.equal(judgeHeard(true, base), true, 'once heard, kept while the connection lasts'); |
| 184 | assert.equal(judgeHeard(true, { ...base, connected: false }), true, 'a blip is not losing them'); |
| 185 | assert.equal(judgeHeard(true, { ...base, bad: true }), false, 'a failed connection is'); |
| 186 | }); |
| 187 | |
| 188 | test('hearingMoved: a gain, or a loss of someone still here, and never a departure alone', () => { |
| 189 | const here = new Set(['a', 'b']); |
| 190 | assert.equal(hearingMoved('', 'a', here), true); |
| 191 | assert.equal(hearingMoved('a', 'a,b', here), true); |
| 192 | assert.equal(hearingMoved('a,b', 'a', here), true, 'b is still here and no longer heard'); |
| 193 | assert.equal(hearingMoved('a,c', 'a', here), false, 'c has left: not worth a report from everyone'); |
| 194 | assert.equal(hearingMoved('a', 'a', here), false); |
| 195 | assert.equal(hearingMoved('', '', here), false); |
| 196 | }); |
| 197 | |