oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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
19import { test } from 'node:test';
20import assert from 'node:assert/strict';
21import { readFileSync } from 'node:fs';
22import { fileURLToPath } from 'node:url';
23
24const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
25const src = readFileSync(roomJS, 'utf8');
26const cssFile = fileURLToPath(new URL('../../internal/calls/static/calls.css', import.meta.url));
27const css = readFileSync(cssFile, 'utf8');
28
29function 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
43function 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
49const NAMES = ['SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD', 'GLOW_QUIET_DB', 'GLOW_LOUD_DB',
50 'GLOW_ONSET', 'GLOW_ATTACK', 'GLOW_RELEASE', 'GLOW_STEADY'];
51const CONSTS = NAMES.map(liftConst).join('\n');
52// eslint-disable-next-line no-new-func
53const c = new Function(CONSTS + '; return {' + NAMES.join(',') + '};')();
54const build = (name) =>
55 // eslint-disable-next-line no-new-func
56 new Function(CONSTS + '\n' + lift(src, 'judgeSpeaking') + '\n' + lift(src, name) + '; return ' + name + ';')();
57const glowIntensity = build('glowIntensity');
58const glowStyle = build('glowStyle');
59// judgeGlow reads glowIntensity; hearingRecord is the record it works on.
60// eslint-disable-next-line no-new-func
61const judge = new Function(CONSTS + '\n' + ['judgeSpeaking', 'glowIntensity', 'judgeGlow', 'hearingRecord']
62 .map((n) => lift(src, n)).join('\n') + '; return { judgeSpeaking, judgeGlow, hearingRecord };')();
63
64const db = (d) => Math.pow(10, d / 20);
65// One poll, as sampleInto takes it: the speaking gate, then the glow.
66const hear = (rec, level, now) => {
67 judge.judgeSpeaking(rec, level);
68 return judge.judgeGlow(rec, level, now);
69};
70
71test('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
83test('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
99test('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
106test('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
129test('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.
147function 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
163test('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
182test('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