| 1 | // room-glow.test.mjs — room.js's speaking light (0.3.7), which replaced |
| 2 | // the ring round a tile and the meter beside its name, and since 0.4.3 is |
| 3 | // the warm rim ("T5, warm soft breath"), gated without a browser. |
| 4 | // |
| 5 | // node --test test/browser/room-glow.test.mjs |
| 6 | // |
| 7 | // The same trick as room-hearing.test.mjs: the functions and the |
| 8 | // constants they read are lifted out of the shipped room.js by name, so a |
| 9 | // retuned constant or a renamed function fails here rather than passing |
| 10 | // against a copy. |
| 11 | // |
| 12 | // glowIntensity a reading to the rim's level, by decibels |
| 13 | // judgeGlow the gate (speaking, and loud for long enough) and the |
| 14 | // target level |
| 15 | // glowStyle what a label is given: --lvl and its transition's |
| 16 | // duration (attack up, release down, none when reduced), |
| 17 | // and --on and how long it waits to go out |
| 18 | // calls.css the four layers' formulas, as the mock-up has them |
| 19 | import { test } from 'node:test'; |
| 20 | import assert from 'node:assert/strict'; |
| 21 | import { readFileSync } from 'node:fs'; |
| 22 | import { fileURLToPath } from 'node:url'; |
| 23 | |
| 24 | const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url)); |
| 25 | const src = readFileSync(roomJS, 'utf8'); |
| 26 | const cssFile = fileURLToPath(new URL('../../internal/calls/static/calls.css', import.meta.url)); |
| 27 | const css = readFileSync(cssFile, 'utf8'); |
| 28 | |
| 29 | function lift(source, name) { |
| 30 | const start = source.indexOf('function ' + name + '('); |
| 31 | assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing'); |
| 32 | let depth = 0; |
| 33 | for (let i = source.indexOf('{', start); i < source.length; i++) { |
| 34 | if (source[i] === '{') depth += 1; |
| 35 | else if (source[i] === '}') { |
| 36 | depth -= 1; |
| 37 | if (depth === 0) return source.slice(start, i + 1); |
| 38 | } |
| 39 | } |
| 40 | assert.fail('unbalanced braces reading ' + name + ' out of room.js'); |
| 41 | } |
| 42 | |
| 43 | function liftConst(name) { |
| 44 | const m = src.match(new RegExp('const ' + name + ' = (-?[0-9.]+);')); |
| 45 | assert.ok(m, name + ' is not a numeric const in room.js any more'); |
| 46 | return 'const ' + name + ' = ' + m[1] + ';'; |
| 47 | } |
| 48 | |
| 49 | const NAMES = ['SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD', 'GLOW_QUIET_DB', 'GLOW_LOUD_DB', |
| 50 | 'GLOW_ONSET', 'GLOW_ATTACK', 'GLOW_RELEASE', 'GLOW_STEADY']; |
| 51 | const CONSTS = NAMES.map(liftConst).join('\n'); |
| 52 | // eslint-disable-next-line no-new-func |
| 53 | const c = new Function(CONSTS + '; return {' + NAMES.join(',') + '};')(); |
| 54 | const build = (name) => |
| 55 | // eslint-disable-next-line no-new-func |
| 56 | new Function(CONSTS + '\n' + lift(src, 'judgeSpeaking') + '\n' + lift(src, name) + '; return ' + name + ';')(); |
| 57 | const glowIntensity = build('glowIntensity'); |
| 58 | const glowStyle = build('glowStyle'); |
| 59 | // judgeGlow reads glowIntensity; hearingRecord is the record it works on. |
| 60 | // eslint-disable-next-line no-new-func |
| 61 | const judge = new Function(CONSTS + '\n' + ['judgeSpeaking', 'glowIntensity', 'judgeGlow', 'hearingRecord'] |
| 62 | .map((n) => lift(src, n)).join('\n') + '; return { judgeSpeaking, judgeGlow, hearingRecord };')(); |
| 63 | |
| 64 | const db = (d) => Math.pow(10, d / 20); |
| 65 | // One poll, as sampleInto takes it: the speaking gate, then the glow. |
| 66 | const hear = (rec, level, now) => { |
| 67 | judge.judgeSpeaking(rec, level); |
| 68 | return judge.judgeGlow(rec, level, now); |
| 69 | }; |
| 70 | |
| 71 | test('the constants are the native app’s', () => { |
| 72 | // SpeakingGlow.swift: -42 to -12 dBFS, 150 ms onset, 50 ms attack, |
| 73 | // 300 ms release, 0.6 steady. The old glow's floor is gone. |
| 74 | assert.equal(c.GLOW_QUIET_DB, -42); |
| 75 | assert.equal(c.GLOW_LOUD_DB, -12); |
| 76 | assert.doesNotMatch(src, /GLOW_FLOOR/); |
| 77 | assert.equal(c.GLOW_ONSET, 150); |
| 78 | assert.equal(c.GLOW_ATTACK, 50); |
| 79 | assert.ok(c.GLOW_RELEASE >= 250 && c.GLOW_RELEASE <= 400); |
| 80 | assert.equal(c.GLOW_STEADY, 0.6); |
| 81 | }); |
| 82 | |
| 83 | test('glowIntensity: silence is none, loud is all, louder is never dimmer', () => { |
| 84 | assert.equal(glowIntensity(0), 0); |
| 85 | assert.equal(glowIntensity(undefined), 0); |
| 86 | assert.equal(glowIntensity(NaN), 0); |
| 87 | assert.equal(glowIntensity(db(-60)), 0); |
| 88 | assert.ok(Math.abs(glowIntensity(db(-27)) - 0.5) < 1e-9); |
| 89 | assert.ok(Math.abs(glowIntensity(db(-12)) - 1) < 1e-9); |
| 90 | assert.equal(glowIntensity(5), 1); |
| 91 | let last = 0; |
| 92 | for (let l = 0.0001; l <= 1; l *= 1.3) { |
| 93 | const x = glowIntensity(l); |
| 94 | assert.ok(x >= last); |
| 95 | last = x; |
| 96 | } |
| 97 | }); |
| 98 | |
| 99 | test('silence never lights it', () => { |
| 100 | const rec = judge.hearingRecord(); |
| 101 | for (let t = 1000; t < 6000; t += 200) assert.equal(hear(rec, 0, t), 0); |
| 102 | for (let t = 6000; t < 9000; t += 200) assert.equal(hear(rec, db(-50), t), 0, 'a quiet room is not a voice'); |
| 103 | assert.equal(rec.glowLit, false); |
| 104 | }); |
| 105 | |
| 106 | test('a brief noise does not light it; a voice that keeps up does', () => { |
| 107 | const rec = judge.hearingRecord(); |
| 108 | // A click: one loud poll. The speaking gate holds for its hangover; |
| 109 | // the glow waits for a second loud poll that never comes. |
| 110 | assert.equal(hear(rec, db(-6), 1000), 0); |
| 111 | assert.equal(rec.speaking, true); |
| 112 | for (let t = 1200; t <= 3000; t += 200) assert.equal(hear(rec, 0, t), 0, 'at ' + t); |
| 113 | assert.equal(rec.glowLit, false); |
| 114 | // Speech: loud polls 200 ms apart light it on the second. |
| 115 | assert.equal(hear(rec, db(-20), 4000), 0); |
| 116 | const lit = hear(rec, db(-20), 4200); |
| 117 | assert.equal(rec.glowLit, true); |
| 118 | assert.ok(Math.abs(lit - glowIntensity(db(-20))) < 1e-9); |
| 119 | // Between syllables its level falls away, still lit. |
| 120 | assert.equal(hear(rec, db(-45), 4400), 0); |
| 121 | assert.equal(rec.glowLit, true); |
| 122 | // And goes out once the speaking gate lets go. |
| 123 | let t = 4600; |
| 124 | while (rec.speaking) hear(rec, 0, (t += 200)); |
| 125 | assert.equal(rec.glowLit, false); |
| 126 | assert.equal(rec.glow, 0); |
| 127 | }); |
| 128 | |
| 129 | test('glowStyle: attack going up, release coming down, a steady rim under reduced motion', () => { |
| 130 | assert.deepEqual(glowStyle(true, 0.8, 0, false), { lvl: '0.80', time: c.GLOW_ATTACK + 'ms', on: '1', onTime: '0ms' }); |
| 131 | assert.deepEqual(glowStyle(true, 0.3, 0.8, false), { lvl: '0.30', time: c.GLOW_RELEASE + 'ms', on: '1', onTime: '0ms' }); |
| 132 | assert.deepEqual(glowStyle(false, 0.9, 0.3, false), |
| 133 | { lvl: '0.00', time: c.GLOW_RELEASE + 'ms', on: '0', onTime: c.GLOW_RELEASE + 'ms' }, |
| 134 | 'not lit is nothing, whatever the target — and --on waits out the release'); |
| 135 | for (const target of [0.3, 0.55, 1]) { |
| 136 | assert.deepEqual(glowStyle(true, target, 0.1, true), { lvl: '0.60', time: '0ms', on: '1', onTime: '0ms' }, |
| 137 | 'reduced: one level'); |
| 138 | } |
| 139 | assert.deepEqual(glowStyle(false, 0, 0.6, true), { lvl: '0.00', time: '0ms', on: '0', onTime: '0ms' }, |
| 140 | 'reduced: off at once'); |
| 141 | // Two places, so an unchanged level writes nothing to the style. |
| 142 | assert.equal(glowStyle(true, 0.41234, 0, false).lvl, '0.41'); |
| 143 | }); |
| 144 | |
| 145 | // The rule the ::after's box-shadow encodes, read back out of calls.css |
| 146 | // and evaluated: T5's four layers, where `on` is 0 or 1 and `lvl` 0..1. |
| 147 | function rimLayers(on, lvl) { |
| 148 | const block = css.match(/\.calls-tile-name::after \{[^}]*box-shadow:([^;]*);/); |
| 149 | assert.ok(block, 'calls.css has no .calls-tile-name::after box-shadow'); |
| 150 | const layers = block[1].split(/,\s*\n/).map((l) => l.trim()); |
| 151 | const num = (expr) => |
| 152 | // eslint-disable-next-line no-new-func |
| 153 | new Function('on', 'lvl', 'return ' + expr.replace(/calc/g, '').replace(/var\(--on\)/g, 'on') |
| 154 | .replace(/var\(--lvl\)/g, 'lvl').replace(/px/g, '') + ';')(on, lvl); |
| 155 | return layers.map((l) => { |
| 156 | // offset-x offset-y blur [spread] colour |
| 157 | const m = l.match(/^(inset )?0 0 (calc\(.*?\)|[0-9.]+(?:px)?)(?: ([0-9.]+px))? hsl\((\d+) (\d+)% (\d+)% \/ (calc\(.*\))\)$/); |
| 158 | assert.ok(m, 'a layer calls.css draws that this test cannot read: ' + l); |
| 159 | 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]) }; |
| 160 | }); |
| 161 | } |
| 162 | |
| 163 | test('calls.css draws T5 from --on and --lvl: rim, inner light, outer breath', () => { |
| 164 | const close = (a, b, what) => assert.ok(Math.abs(a - b) < 1e-9, what + ': ' + a + ' is not ' + b); |
| 165 | for (const lvl of [0, 0.25, 0.5, 0.75, 1]) { |
| 166 | const [rim, inner, outer, ...rest] = rimLayers(1, lvl); |
| 167 | assert.equal(rest.length, 0, 'three layers; T5 has no top highlight'); |
| 168 | assert.deepEqual([rim.inset, rim.blur, rim.spread, rim.h, rim.s, rim.l], [true, 0, 1, 38, 80, 82], 'the rim'); |
| 169 | close(rim.a, 0.1 + lvl * 0.45, 'rim alpha at ' + lvl); |
| 170 | assert.deepEqual([inner.inset, inner.spread, inner.h, inner.s, inner.l], [true, 0, 38, 80, 72], 'the inner light'); |
| 171 | close(inner.blur, 3 + lvl * 6, 'inner blur at ' + lvl); |
| 172 | close(inner.a, 0.06 + lvl * 0.3, 'inner alpha at ' + lvl); |
| 173 | assert.deepEqual([outer.inset, outer.spread, outer.h, outer.s, outer.l], [false, 0, 38, 80, 66], 'the outer breath'); |
| 174 | close(outer.blur, 1 + lvl * 7, 'outer blur at ' + lvl); |
| 175 | close(outer.a, lvl * 0.25, 'outer alpha at ' + lvl); |
| 176 | } |
| 177 | for (const lvl of [0, 0.6, 1]) { |
| 178 | for (const layer of rimLayers(0, lvl)) assert.equal(layer.a, 0, 'off is nothing at ' + lvl); |
| 179 | } |
| 180 | }); |
| 181 | |
| 182 | test('calls.css transitions --lvl and --on, only without reduced motion, and has no old glow', () => { |
| 183 | assert.match(css, /@property --lvl/); |
| 184 | assert.match(css, /@property --on/); |
| 185 | assert.match(css, /\.calls-tile-name::after \{[^}]*position: absolute;[^}]*inset: 0;[^}]*border-radius: inherit;[^}]*pointer-events: none;/); |
| 186 | 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;/, |
| 187 | 'the transitions are only for people who have not asked for less motion'); |
| 188 | assert.doesNotMatch(css, /--glow|46 204 219/, 'the teal glow is gone'); |
| 189 | assert.doesNotMatch(src, /--glow/, 'and room.js sets none of it'); |
| 190 | assert.doesNotMatch(css, /\.calls-tile-speaking/, 'the ring is gone'); |
| 191 | assert.doesNotMatch(css, /\.calls-meter/, 'the meter is gone'); |
| 192 | assert.doesNotMatch(src, /calls-meter|calls-tile-speaking/, 'and room.js draws neither'); |
| 193 | }); |
| 194 | |