// room-layout.test.mjs — Speaker view and the viewer's order (0.4.0), // gated without a browser. // // node --test test/browser/room-layout.test.mjs // // The same trick as room-glow.test.mjs: the functions are lifted out of // the shipped room.js and call-layout.js by name, so a retuned constant // or a renamed function fails here rather than passing against a copy. // // speakerStep who has the floor: the dominance delay, the minimum // hold, interjections, pauses, leavers // orderMerge arrivals on the end, leavers out, remembered keys back // orderMove a move among the people here // stageFor pin, then the newest screen, then the speaker // storedLayout Focus is Speaker import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); const roomJS = read('../../internal/calls/static/room.js'); const layoutJS = read('../../internal/calls/static/call-layout.js'); const swift = read('../../native/apple/Sources/CallsKit/SpeakerView.swift'); function lift(source, name) { const start = source.indexOf('function ' + name + '('); assert.notEqual(start, -1, name + ' is not there any more — this test is stale, not passing'); let depth = 0; for (let i = source.indexOf('{', start); i < source.length; i++) { if (source[i] === '{') depth += 1; else if (source[i] === '}') { depth -= 1; if (depth === 0) return source.slice(start, i + 1); } } assert.fail('unbalanced braces reading ' + name); } function liftConst(name) { const m = roomJS.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 = ['SPEAKER_DOMINANCE', 'SPEAKER_HOLD'].map(liftConst).join('\n'); // eslint-disable-next-line no-new-func const room = new Function(CONSTS + '\n' + lift(roomJS, 'speakerRecord') + '\n' + lift(roomJS, 'speakerStep') + '; return { speakerRecord, speakerStep, SPEAKER_DOMINANCE, SPEAKER_HOLD };')(); // eslint-disable-next-line no-new-func const layout = new Function(['storedLayout', 'orderMerge', 'orderShown', 'orderMove', 'stageFor'] .map((n) => lift(layoutJS, n)).join('\n') + '; return { storedLayout, orderMerge, orderShown, orderMove, stageFor };')(); // Five polls a second, as pollLevels takes them, from `from` ms to `to`. function run(rec, from, to, speaking, present = ['ana', 'tom', 'li']) { const here = new Set(present); for (let t = from; t < to; t += 200) { const voices = present.map((peer) => ({ peer, speaking: peer in speaking, level: speaking[peer] || 0.001 })); room.speakerStep(rec, voices, here, t); } } test('the thresholds are the native app’s', () => { const dominance = Number(swift.match(/static let dominance: Double = ([0-9.]+)/)[1]); const hold = Number(swift.match(/static let minimumHold: Double = ([0-9.]+)/)[1]); assert.equal(room.SPEAKER_DOMINANCE, dominance * 1000); assert.equal(room.SPEAKER_HOLD, hold * 1000); assert.ok(room.SPEAKER_DOMINANCE >= 1200 && room.SPEAKER_DOMINANCE <= 1500); assert.ok(room.SPEAKER_HOLD >= 3000); }); test('the floor moves only after the dominance delay', () => { const rec = room.speakerRecord(); run(rec, 0, 1300, { ana: 0.2 }); assert.equal(rec.current, '', '1.2 s is not yet a turn'); run(rec, 1400, 1500, { ana: 0.2 }); assert.equal(rec.current, 'ana'); }); test('the loudest speaker is the dominant one', () => { const rec = room.speakerRecord(); run(rec, 0, 2000, { ana: 0.05, tom: 0.3 }); assert.equal(rec.current, 'tom'); }); test('an interjection does not take it, nor does a pause', () => { const rec = room.speakerRecord(); run(rec, 0, 5000, { ana: 0.2 }); run(rec, 5000, 6000, { ana: 0.1, tom: 0.4 }); run(rec, 6000, 10000, { ana: 0.2 }); assert.equal(rec.current, 'ana'); run(rec, 10000, 40000, {}); assert.equal(rec.current, 'ana', 'silence changes nothing'); }); test('the minimum hold holds through a takeover', () => { const rec = room.speakerRecord(); run(rec, 0, 1500, { ana: 0.2 }); assert.equal(rec.current, 'ana'); const took = rec.since; run(rec, 1600, took + 2900, { tom: 0.3 }); assert.equal(rec.current, 'ana'); run(rec, took + 3000, took + 3100, { tom: 0.3 }); assert.equal(rec.current, 'tom'); }); test('two people alternating quickly never make it flap', () => { const rec = room.speakerRecord(); let switches = 0; for (let t = 0; t < 60000; t += 200) { const who = Math.floor(t / 800) % 2 === 0 ? 'ana' : 'tom'; const other = who === 'ana' ? 'tom' : 'ana'; if (room.speakerStep(rec, [{ peer: who, speaking: true, level: 0.3 }, { peer: other, speaking: true, level: 0.1 }], new Set(['ana', 'tom']), t)) switches += 1; } assert.equal(switches, 0); }); test('a leaver loses the floor at once', () => { const rec = room.speakerRecord(); run(rec, 0, 2000, { li: 0.2 }); assert.equal(rec.current, 'li'); assert.equal(room.speakerStep(rec, [], new Set(['ana', 'tom']), 2200), true); assert.equal(rec.current, ''); run(rec, 2400, 3900, { ana: 0.2 }, ['ana', 'tom']); assert.equal(rec.current, 'ana', 'and the next needs only the dominance delay'); }); test('storedLayout: Focus is Speaker', () => { assert.equal(layout.storedLayout('focus'), 'speaker'); assert.equal(layout.storedLayout('speaker'), 'speaker'); assert.equal(layout.storedLayout('grid'), 'grid'); assert.equal(layout.storedLayout(null), 'speaker'); assert.equal(layout.storedLayout('mosaic'), 'speaker'); }); test('orderMerge and orderMove: arrivals on the end, leavers out, moves kept', () => { let order = layout.orderMerge({ keys: [], present: [] }, ['self', 'name:Ana']); order = layout.orderMerge(order, ['self', 'name:Ana', 'name:Tom']); assert.deepEqual(layout.orderShown(order), ['self', 'name:Ana', 'name:Tom']); order = layout.orderMove(order, 'name:Tom', 0); assert.deepEqual(layout.orderShown(order), ['name:Tom', 'self', 'name:Ana']); assert.equal(layout.orderMove(order, 'name:Tom', 0), null, 'already there'); order = layout.orderMerge(order, ['name:Li', 'self', 'name:Tom', 'name:Ana']); assert.deepEqual(layout.orderShown(order), ['name:Tom', 'self', 'name:Ana', 'name:Li']); order = layout.orderMerge(order, ['self', 'name:Tom', 'name:Li']); assert.ok(!order.keys.includes('name:Ana'), 'a leaver is removed'); order = layout.orderMove(order, 'self', 99); assert.deepEqual(layout.orderShown(order), ['name:Tom', 'name:Li', 'self']); }); test('a kept order comes back as people arrive', () => { let order = { keys: ['name:Li', 'name:Ana', 'self'], present: [] }; order = layout.orderMerge(order, ['self']); assert.deepEqual(layout.orderShown(order), ['self']); assert.deepEqual(order.keys, ['name:Li', 'name:Ana', 'self'], 'the absent are remembered, not removed'); order = layout.orderMerge(order, ['self', 'name:Ana', 'name:Li', 'name:Sam']); assert.deepEqual(layout.orderShown(order), ['name:Li', 'name:Ana', 'self', 'name:Sam']); }); test('stageFor: pin, then the newest screen, then the speaker, then the order', () => { const others = ['tom', 'ana']; assert.equal(layout.stageFor('', '', others, [], 'self'), 'tom'); assert.equal(layout.stageFor('', 'ana', others, [], 'self'), 'ana'); assert.equal(layout.stageFor('', 'ana', others, ['tom#share', 'ana#share'], 'self'), 'ana#share'); assert.equal(layout.stageFor('tom', 'ana', others, ['ana#share'], 'self'), 'tom', 'a pin beats a new screen'); assert.equal(layout.stageFor('gone', 'ana', others, [], 'self'), 'ana', 'a pin that left does not hold'); assert.equal(layout.stageFor('', '', [], [], 'self'), 'self', 'alone'); assert.equal(layout.stageFor('', 'self', others, [], 'self'), 'tom', 'never yourself unless pinned'); });