// room-glow.test.mjs — room.js's speaking light (0.3.7), which replaced // the ring round a tile and the meter beside its name, and since 0.4.3 is // the warm rim ("T5, warm soft breath"), gated without a browser. // // node --test test/browser/room-glow.test.mjs // // The same trick as room-hearing.test.mjs: the functions and the // constants they read are lifted out of the shipped room.js by name, so a // retuned constant or a renamed function fails here rather than passing // against a copy. // // glowIntensity a reading to the rim's level, by decibels // judgeGlow the gate (speaking, and loud for long enough) and the // target level // glowStyle what a label is given: --lvl and its transition's // duration (attack up, release down, none when reduced), // and --on and how long it waits to go out // calls.css the four layers' formulas, as the mock-up has them import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); const src = readFileSync(roomJS, 'utf8'); const cssFile = fileURLToPath(new URL('../../internal/calls/static/calls.css', import.meta.url)); const css = readFileSync(cssFile, 'utf8'); function lift(source, name) { const start = source.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 = 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 + ' out of room.js'); } 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 NAMES = ['SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD', 'GLOW_QUIET_DB', 'GLOW_LOUD_DB', 'GLOW_ONSET', 'GLOW_ATTACK', 'GLOW_RELEASE', 'GLOW_STEADY']; const CONSTS = NAMES.map(liftConst).join('\n'); // eslint-disable-next-line no-new-func const c = new Function(CONSTS + '; return {' + NAMES.join(',') + '};')(); const build = (name) => // eslint-disable-next-line no-new-func new Function(CONSTS + '\n' + lift(src, 'judgeSpeaking') + '\n' + lift(src, name) + '; return ' + name + ';')(); const glowIntensity = build('glowIntensity'); const glowStyle = build('glowStyle'); // judgeGlow reads glowIntensity; hearingRecord is the record it works on. // eslint-disable-next-line no-new-func const judge = new Function(CONSTS + '\n' + ['judgeSpeaking', 'glowIntensity', 'judgeGlow', 'hearingRecord'] .map((n) => lift(src, n)).join('\n') + '; return { judgeSpeaking, judgeGlow, hearingRecord };')(); const db = (d) => Math.pow(10, d / 20); // One poll, as sampleInto takes it: the speaking gate, then the glow. const hear = (rec, level, now) => { judge.judgeSpeaking(rec, level); return judge.judgeGlow(rec, level, now); }; test('the constants are the native app’s', () => { // SpeakingGlow.swift: -42 to -12 dBFS, 150 ms onset, 50 ms attack, // 300 ms release, 0.6 steady. The old glow's floor is gone. assert.equal(c.GLOW_QUIET_DB, -42); assert.equal(c.GLOW_LOUD_DB, -12); assert.doesNotMatch(src, /GLOW_FLOOR/); assert.equal(c.GLOW_ONSET, 150); assert.equal(c.GLOW_ATTACK, 50); assert.ok(c.GLOW_RELEASE >= 250 && c.GLOW_RELEASE <= 400); assert.equal(c.GLOW_STEADY, 0.6); }); test('glowIntensity: silence is none, loud is all, louder is never dimmer', () => { assert.equal(glowIntensity(0), 0); assert.equal(glowIntensity(undefined), 0); assert.equal(glowIntensity(NaN), 0); assert.equal(glowIntensity(db(-60)), 0); assert.ok(Math.abs(glowIntensity(db(-27)) - 0.5) < 1e-9); assert.ok(Math.abs(glowIntensity(db(-12)) - 1) < 1e-9); assert.equal(glowIntensity(5), 1); let last = 0; for (let l = 0.0001; l <= 1; l *= 1.3) { const x = glowIntensity(l); assert.ok(x >= last); last = x; } }); test('silence never lights it', () => { const rec = judge.hearingRecord(); for (let t = 1000; t < 6000; t += 200) assert.equal(hear(rec, 0, t), 0); for (let t = 6000; t < 9000; t += 200) assert.equal(hear(rec, db(-50), t), 0, 'a quiet room is not a voice'); assert.equal(rec.glowLit, false); }); test('a brief noise does not light it; a voice that keeps up does', () => { const rec = judge.hearingRecord(); // A click: one loud poll. The speaking gate holds for its hangover; // the glow waits for a second loud poll that never comes. assert.equal(hear(rec, db(-6), 1000), 0); assert.equal(rec.speaking, true); for (let t = 1200; t <= 3000; t += 200) assert.equal(hear(rec, 0, t), 0, 'at ' + t); assert.equal(rec.glowLit, false); // Speech: loud polls 200 ms apart light it on the second. assert.equal(hear(rec, db(-20), 4000), 0); const lit = hear(rec, db(-20), 4200); assert.equal(rec.glowLit, true); assert.ok(Math.abs(lit - glowIntensity(db(-20))) < 1e-9); // Between syllables its level falls away, still lit. assert.equal(hear(rec, db(-45), 4400), 0); assert.equal(rec.glowLit, true); // And goes out once the speaking gate lets go. let t = 4600; while (rec.speaking) hear(rec, 0, (t += 200)); assert.equal(rec.glowLit, false); assert.equal(rec.glow, 0); }); test('glowStyle: attack going up, release coming down, a steady rim under reduced motion', () => { assert.deepEqual(glowStyle(true, 0.8, 0, false), { lvl: '0.80', time: c.GLOW_ATTACK + 'ms', on: '1', onTime: '0ms' }); assert.deepEqual(glowStyle(true, 0.3, 0.8, false), { lvl: '0.30', time: c.GLOW_RELEASE + 'ms', on: '1', onTime: '0ms' }); assert.deepEqual(glowStyle(false, 0.9, 0.3, false), { lvl: '0.00', time: c.GLOW_RELEASE + 'ms', on: '0', onTime: c.GLOW_RELEASE + 'ms' }, 'not lit is nothing, whatever the target — and --on waits out the release'); for (const target of [0.3, 0.55, 1]) { assert.deepEqual(glowStyle(true, target, 0.1, true), { lvl: '0.60', time: '0ms', on: '1', onTime: '0ms' }, 'reduced: one level'); } assert.deepEqual(glowStyle(false, 0, 0.6, true), { lvl: '0.00', time: '0ms', on: '0', onTime: '0ms' }, 'reduced: off at once'); // Two places, so an unchanged level writes nothing to the style. assert.equal(glowStyle(true, 0.41234, 0, false).lvl, '0.41'); }); // The rule the ::after's box-shadow encodes, read back out of calls.css // and evaluated: T5's four layers, where `on` is 0 or 1 and `lvl` 0..1. function rimLayers(on, lvl) { const block = css.match(/\.calls-tile-name::after \{[^}]*box-shadow:([^;]*);/); assert.ok(block, 'calls.css has no .calls-tile-name::after box-shadow'); const layers = block[1].split(/,\s*\n/).map((l) => l.trim()); const num = (expr) => // eslint-disable-next-line no-new-func new Function('on', 'lvl', 'return ' + expr.replace(/calc/g, '').replace(/var\(--on\)/g, 'on') .replace(/var\(--lvl\)/g, 'lvl').replace(/px/g, '') + ';')(on, lvl); return layers.map((l) => { // offset-x offset-y blur [spread] colour const m = l.match(/^(inset )?0 0 (calc\(.*?\)|[0-9.]+(?:px)?)(?: ([0-9.]+px))? hsl\((\d+) (\d+)% (\d+)% \/ (calc\(.*\))\)$/); assert.ok(m, 'a layer calls.css draws that this test cannot read: ' + l); return { inset: !!m[1], blur: num(m[2]), spread: m[3] ? num(m[3]) : 0, h: +m[4], s: +m[5], l: +m[6], a: num(m[7]) }; }); } test('calls.css draws T5 from --on and --lvl: rim, inner light, outer breath', () => { const close = (a, b, what) => assert.ok(Math.abs(a - b) < 1e-9, what + ': ' + a + ' is not ' + b); for (const lvl of [0, 0.25, 0.5, 0.75, 1]) { const [rim, inner, outer, ...rest] = rimLayers(1, lvl); assert.equal(rest.length, 0, 'three layers; T5 has no top highlight'); assert.deepEqual([rim.inset, rim.blur, rim.spread, rim.h, rim.s, rim.l], [true, 0, 1, 38, 80, 82], 'the rim'); close(rim.a, 0.1 + lvl * 0.45, 'rim alpha at ' + lvl); assert.deepEqual([inner.inset, inner.spread, inner.h, inner.s, inner.l], [true, 0, 38, 80, 72], 'the inner light'); close(inner.blur, 3 + lvl * 6, 'inner blur at ' + lvl); close(inner.a, 0.06 + lvl * 0.3, 'inner alpha at ' + lvl); assert.deepEqual([outer.inset, outer.spread, outer.h, outer.s, outer.l], [false, 0, 38, 80, 66], 'the outer breath'); close(outer.blur, 1 + lvl * 7, 'outer blur at ' + lvl); close(outer.a, lvl * 0.25, 'outer alpha at ' + lvl); } for (const lvl of [0, 0.6, 1]) { for (const layer of rimLayers(0, lvl)) assert.equal(layer.a, 0, 'off is nothing at ' + lvl); } }); test('calls.css transitions --lvl and --on, only without reduced motion, and has no old glow', () => { assert.match(css, /@property --lvl/); assert.match(css, /@property --on/); assert.match(css, /\.calls-tile-name::after \{[^}]*position: absolute;[^}]*inset: 0;[^}]*border-radius: inherit;[^}]*pointer-events: none;/); assert.match(css, /@media \(prefers-reduced-motion: no-preference\) \{\s*\.calls-tile-name \{\s*transition:\s*--lvl var\(--lvl-t, 300ms\) ease-out,\s*--on var\(--on-t, 0ms\) step-end;/, 'the transitions are only for people who have not asked for less motion'); assert.doesNotMatch(css, /--glow|46 204 219/, 'the teal glow is gone'); assert.doesNotMatch(src, /--glow/, 'and room.js sets none of it'); assert.doesNotMatch(css, /\.calls-tile-speaking/, 'the ring is gone'); assert.doesNotMatch(css, /\.calls-meter/, 'the meter is gone'); assert.doesNotMatch(src, /calls-meter|calls-tile-speaking/, 'and room.js draws neither'); });