| 1 | // room-layout.test.mjs — Speaker view and the viewer's order (0.4.0), |
| 2 | // gated without a browser. |
| 3 | // |
| 4 | // node --test test/browser/room-layout.test.mjs |
| 5 | // |
| 6 | // The same trick as room-glow.test.mjs: the functions are lifted out of |
| 7 | // the shipped room.js and call-layout.js by name, so a retuned constant |
| 8 | // or a renamed function fails here rather than passing against a copy. |
| 9 | // |
| 10 | // speakerStep who has the floor: the dominance delay, the minimum |
| 11 | // hold, interjections, pauses, leavers |
| 12 | // orderMerge arrivals on the end, leavers out, remembered keys back |
| 13 | // orderMove a move among the people here |
| 14 | // stageFor pin, then the newest screen, then the speaker |
| 15 | // storedLayout Focus is Speaker |
| 16 | import { test } from 'node:test'; |
| 17 | import assert from 'node:assert/strict'; |
| 18 | import { readFileSync } from 'node:fs'; |
| 19 | import { fileURLToPath } from 'node:url'; |
| 20 | |
| 21 | const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); |
| 22 | const roomJS = read('../../internal/calls/static/room.js'); |
| 23 | const layoutJS = read('../../internal/calls/static/call-layout.js'); |
| 24 | const swift = read('../../native/apple/Sources/CallsKit/SpeakerView.swift'); |
| 25 | |
| 26 | function lift(source, name) { |
| 27 | const start = source.indexOf('function ' + name + '('); |
| 28 | assert.notEqual(start, -1, name + ' is not there any more — this test is stale, not passing'); |
| 29 | let depth = 0; |
| 30 | for (let i = source.indexOf('{', start); i < source.length; i++) { |
| 31 | if (source[i] === '{') depth += 1; |
| 32 | else if (source[i] === '}') { |
| 33 | depth -= 1; |
| 34 | if (depth === 0) return source.slice(start, i + 1); |
| 35 | } |
| 36 | } |
| 37 | assert.fail('unbalanced braces reading ' + name); |
| 38 | } |
| 39 | function liftConst(name) { |
| 40 | const m = roomJS.match(new RegExp('const ' + name + ' = (-?[0-9.]+);')); |
| 41 | assert.ok(m, name + ' is not a numeric const in room.js any more'); |
| 42 | return 'const ' + name + ' = ' + m[1] + ';'; |
| 43 | } |
| 44 | const CONSTS = ['SPEAKER_DOMINANCE', 'SPEAKER_HOLD'].map(liftConst).join('\n'); |
| 45 | // eslint-disable-next-line no-new-func |
| 46 | const room = new Function(CONSTS + '\n' + lift(roomJS, 'speakerRecord') + '\n' + lift(roomJS, 'speakerStep') + |
| 47 | '; return { speakerRecord, speakerStep, SPEAKER_DOMINANCE, SPEAKER_HOLD };')(); |
| 48 | // eslint-disable-next-line no-new-func |
| 49 | const layout = new Function(['storedLayout', 'orderMerge', 'orderShown', 'orderMove', 'stageFor'] |
| 50 | .map((n) => lift(layoutJS, n)).join('\n') + '; return { storedLayout, orderMerge, orderShown, orderMove, stageFor };')(); |
| 51 | |
| 52 | // Five polls a second, as pollLevels takes them, from `from` ms to `to`. |
| 53 | function run(rec, from, to, speaking, present = ['ana', 'tom', 'li']) { |
| 54 | const here = new Set(present); |
| 55 | for (let t = from; t < to; t += 200) { |
| 56 | const voices = present.map((peer) => ({ peer, speaking: peer in speaking, level: speaking[peer] || 0.001 })); |
| 57 | room.speakerStep(rec, voices, here, t); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | test('the thresholds are the native app’s', () => { |
| 62 | const dominance = Number(swift.match(/static let dominance: Double = ([0-9.]+)/)[1]); |
| 63 | const hold = Number(swift.match(/static let minimumHold: Double = ([0-9.]+)/)[1]); |
| 64 | assert.equal(room.SPEAKER_DOMINANCE, dominance * 1000); |
| 65 | assert.equal(room.SPEAKER_HOLD, hold * 1000); |
| 66 | assert.ok(room.SPEAKER_DOMINANCE >= 1200 && room.SPEAKER_DOMINANCE <= 1500); |
| 67 | assert.ok(room.SPEAKER_HOLD >= 3000); |
| 68 | }); |
| 69 | |
| 70 | test('the floor moves only after the dominance delay', () => { |
| 71 | const rec = room.speakerRecord(); |
| 72 | run(rec, 0, 1300, { ana: 0.2 }); |
| 73 | assert.equal(rec.current, '', '1.2 s is not yet a turn'); |
| 74 | run(rec, 1400, 1500, { ana: 0.2 }); |
| 75 | assert.equal(rec.current, 'ana'); |
| 76 | }); |
| 77 | |
| 78 | test('the loudest speaker is the dominant one', () => { |
| 79 | const rec = room.speakerRecord(); |
| 80 | run(rec, 0, 2000, { ana: 0.05, tom: 0.3 }); |
| 81 | assert.equal(rec.current, 'tom'); |
| 82 | }); |
| 83 | |
| 84 | test('an interjection does not take it, nor does a pause', () => { |
| 85 | const rec = room.speakerRecord(); |
| 86 | run(rec, 0, 5000, { ana: 0.2 }); |
| 87 | run(rec, 5000, 6000, { ana: 0.1, tom: 0.4 }); |
| 88 | run(rec, 6000, 10000, { ana: 0.2 }); |
| 89 | assert.equal(rec.current, 'ana'); |
| 90 | run(rec, 10000, 40000, {}); |
| 91 | assert.equal(rec.current, 'ana', 'silence changes nothing'); |
| 92 | }); |
| 93 | |
| 94 | test('the minimum hold holds through a takeover', () => { |
| 95 | const rec = room.speakerRecord(); |
| 96 | run(rec, 0, 1500, { ana: 0.2 }); |
| 97 | assert.equal(rec.current, 'ana'); |
| 98 | const took = rec.since; |
| 99 | run(rec, 1600, took + 2900, { tom: 0.3 }); |
| 100 | assert.equal(rec.current, 'ana'); |
| 101 | run(rec, took + 3000, took + 3100, { tom: 0.3 }); |
| 102 | assert.equal(rec.current, 'tom'); |
| 103 | }); |
| 104 | |
| 105 | test('two people alternating quickly never make it flap', () => { |
| 106 | const rec = room.speakerRecord(); |
| 107 | let switches = 0; |
| 108 | for (let t = 0; t < 60000; t += 200) { |
| 109 | const who = Math.floor(t / 800) % 2 === 0 ? 'ana' : 'tom'; |
| 110 | const other = who === 'ana' ? 'tom' : 'ana'; |
| 111 | if (room.speakerStep(rec, [{ peer: who, speaking: true, level: 0.3 }, { peer: other, speaking: true, level: 0.1 }], |
| 112 | new Set(['ana', 'tom']), t)) switches += 1; |
| 113 | } |
| 114 | assert.equal(switches, 0); |
| 115 | }); |
| 116 | |
| 117 | test('a leaver loses the floor at once', () => { |
| 118 | const rec = room.speakerRecord(); |
| 119 | run(rec, 0, 2000, { li: 0.2 }); |
| 120 | assert.equal(rec.current, 'li'); |
| 121 | assert.equal(room.speakerStep(rec, [], new Set(['ana', 'tom']), 2200), true); |
| 122 | assert.equal(rec.current, ''); |
| 123 | run(rec, 2400, 3900, { ana: 0.2 }, ['ana', 'tom']); |
| 124 | assert.equal(rec.current, 'ana', 'and the next needs only the dominance delay'); |
| 125 | }); |
| 126 | |
| 127 | test('storedLayout: Focus is Speaker', () => { |
| 128 | assert.equal(layout.storedLayout('focus'), 'speaker'); |
| 129 | assert.equal(layout.storedLayout('speaker'), 'speaker'); |
| 130 | assert.equal(layout.storedLayout('grid'), 'grid'); |
| 131 | assert.equal(layout.storedLayout(null), 'speaker'); |
| 132 | assert.equal(layout.storedLayout('mosaic'), 'speaker'); |
| 133 | }); |
| 134 | |
| 135 | test('orderMerge and orderMove: arrivals on the end, leavers out, moves kept', () => { |
| 136 | let order = layout.orderMerge({ keys: [], present: [] }, ['self', 'name:Ana']); |
| 137 | order = layout.orderMerge(order, ['self', 'name:Ana', 'name:Tom']); |
| 138 | assert.deepEqual(layout.orderShown(order), ['self', 'name:Ana', 'name:Tom']); |
| 139 | order = layout.orderMove(order, 'name:Tom', 0); |
| 140 | assert.deepEqual(layout.orderShown(order), ['name:Tom', 'self', 'name:Ana']); |
| 141 | assert.equal(layout.orderMove(order, 'name:Tom', 0), null, 'already there'); |
| 142 | order = layout.orderMerge(order, ['name:Li', 'self', 'name:Tom', 'name:Ana']); |
| 143 | assert.deepEqual(layout.orderShown(order), ['name:Tom', 'self', 'name:Ana', 'name:Li']); |
| 144 | order = layout.orderMerge(order, ['self', 'name:Tom', 'name:Li']); |
| 145 | assert.ok(!order.keys.includes('name:Ana'), 'a leaver is removed'); |
| 146 | order = layout.orderMove(order, 'self', 99); |
| 147 | assert.deepEqual(layout.orderShown(order), ['name:Tom', 'name:Li', 'self']); |
| 148 | }); |
| 149 | |
| 150 | test('a kept order comes back as people arrive', () => { |
| 151 | let order = { keys: ['name:Li', 'name:Ana', 'self'], present: [] }; |
| 152 | order = layout.orderMerge(order, ['self']); |
| 153 | assert.deepEqual(layout.orderShown(order), ['self']); |
| 154 | assert.deepEqual(order.keys, ['name:Li', 'name:Ana', 'self'], 'the absent are remembered, not removed'); |
| 155 | order = layout.orderMerge(order, ['self', 'name:Ana', 'name:Li', 'name:Sam']); |
| 156 | assert.deepEqual(layout.orderShown(order), ['name:Li', 'name:Ana', 'self', 'name:Sam']); |
| 157 | }); |
| 158 | |
| 159 | test('stageFor: pin, then the newest screen, then the speaker, then the order', () => { |
| 160 | const others = ['tom', 'ana']; |
| 161 | assert.equal(layout.stageFor('', '', others, [], 'self'), 'tom'); |
| 162 | assert.equal(layout.stageFor('', 'ana', others, [], 'self'), 'ana'); |
| 163 | assert.equal(layout.stageFor('', 'ana', others, ['tom#share', 'ana#share'], 'self'), 'ana#share'); |
| 164 | assert.equal(layout.stageFor('tom', 'ana', others, ['ana#share'], 'self'), 'tom', 'a pin beats a new screen'); |
| 165 | assert.equal(layout.stageFor('gone', 'ana', others, [], 'self'), 'ana', 'a pin that left does not hold'); |
| 166 | assert.equal(layout.stageFor('', '', [], [], 'self'), 'self', 'alone'); |
| 167 | assert.equal(layout.stageFor('', 'self', others, [], 'self'), 'tom', 'never yourself unless pinned'); |
| 168 | }); |
| 169 | |