test(callSounds): cover join/leave sound design + AudioContext lifecycle

callSounds.ts had no tests despite 106 lines of user-facing audio logic. Adds 13
tests (mocking AudioContext) pinning: the chime/soft/retro join+leave melodies
(frequencies, oscillator types, stagger), the click-avoidance gain envelope and
osc->gain->destination wiring, and the defensive contracts — unknown style is a
no-op that never creates a context, a throwing AudioContext constructor is
swallowed, and the shared context is reused / recreated-when-closed / resumed-
when-suspended. Suite is now 527 tests; refreshed the stale count in LOTUS_BUGS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 18:49:02 -04:00
parent 5af024f7e7
commit 1daa8aa9b1
2 changed files with 230 additions and 1 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`,
### Code Hygiene / DevEx ### Code Hygiene / DevEx
- **Automated test suite — 231 tests across ~26 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck), state (settings, sessions, recentSearches, upload, typingMembers), plugins (matrix-to, call/utils, markdown/utils), lotus/avatarDecorations, search filters. Prevention work has caught + fixed **2 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface). - **Automated test suite — 527 tests across 60 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **3 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
- **Extensive `as any` casts** across `src/` — gradual typing cleanup. - **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk. - **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo). - **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
+229
View File
@@ -0,0 +1,229 @@
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { playCallJoinSound, playCallLeaveSound, unlockCallSounds } from './callSounds';
// ── Minimal Web Audio mock ──────────────────────────────────────────────────
// callSounds.ts reaches for the global `AudioContext` lazily (inside its own
// getAudioContext), so we can swap a fake in before each test and inspect the
// oscillators/gains it schedules.
type RampCall = { value: number; time: number };
class FakeParam {
value = 0;
setValueAtTimeCalls: RampCall[] = [];
linearRampCalls: RampCall[] = [];
expRampCalls: RampCall[] = [];
setValueAtTime(value: number, time: number): this {
this.setValueAtTimeCalls.push({ value, time });
return this;
}
linearRampToValueAtTime(value: number, time: number): this {
this.linearRampCalls.push({ value, time });
return this;
}
exponentialRampToValueAtTime(value: number, time: number): this {
this.expRampCalls.push({ value, time });
return this;
}
}
class FakeOscillator {
type = 'sine';
frequency = { value: 0 };
startCalls: number[] = [];
stopCalls: number[] = [];
connectedTo: unknown = null;
connect(node: unknown): unknown {
this.connectedTo = node;
return node;
}
start(t: number): void {
this.startCalls.push(t);
}
stop(t: number): void {
this.stopCalls.push(t);
}
}
class FakeGain {
gain = new FakeParam();
connectedTo: unknown = null;
connect(node: unknown): unknown {
this.connectedTo = node;
return node;
}
}
class FakeAudioContext {
static instances: FakeAudioContext[] = [];
static throwOnConstruct = false;
state: 'running' | 'suspended' | 'closed' = 'running';
// currentTime 0 keeps `start = now + at` exactly equal to the table offsets
// (no float drift), so scheduling assertions stay precise.
currentTime = 0;
destination = { id: 'destination' };
oscillators: FakeOscillator[] = [];
gains: FakeGain[] = [];
resumeCalls = 0;
constructor() {
if (FakeAudioContext.throwOnConstruct) throw new Error('AudioContext unavailable');
FakeAudioContext.instances.push(this);
}
createOscillator(): FakeOscillator {
const osc = new FakeOscillator();
this.oscillators.push(osc);
return osc;
}
createGain(): FakeGain {
const gain = new FakeGain();
this.gains.push(gain);
return gain;
}
resume(): Promise<void> {
this.resumeCalls += 1;
this.state = 'running';
return Promise.resolve();
}
close(): Promise<void> {
this.state = 'closed';
return Promise.resolve();
}
}
const current = (): FakeAudioContext => {
const ctx = FakeAudioContext.instances.at(-1);
assert.ok(ctx, 'expected an AudioContext to have been created');
return ctx;
};
const freqs = (ctx: FakeAudioContext): number[] => ctx.oscillators.map((o) => o.frequency.value);
beforeEach(() => {
// The module keeps a single shared AudioContext. Close any instance it may
// still hold so the next call forces a fresh one, then reset our registry.
FakeAudioContext.instances.forEach((i) => {
i.state = 'closed';
});
FakeAudioContext.instances = [];
FakeAudioContext.throwOnConstruct = false;
(globalThis as unknown as { AudioContext: unknown }).AudioContext = FakeAudioContext;
});
// ── Sound design (regression-pins the melodies) ─────────────────────────────
test('chime join plays D5 then A5 as sine notes, staggered', () => {
playCallJoinSound('chime');
const ctx = current();
assert.equal(ctx.oscillators.length, 2);
assert.deepEqual(
ctx.oscillators.map((o) => o.type),
['sine', 'sine'],
);
assert.deepEqual(freqs(ctx), [587.33, 880]);
assert.deepEqual(
ctx.oscillators.map((o) => o.startCalls[0]),
[0, 0.1],
);
});
test('chime leave reverses the join melody', () => {
playCallLeaveSound('chime');
assert.deepEqual(freqs(current()), [880, 587.33]);
});
test('soft join is a single triangle note at the right gain', () => {
playCallJoinSound('soft');
const ctx = current();
assert.equal(ctx.oscillators.length, 1);
assert.equal(ctx.oscillators[0].type, 'triangle');
assert.equal(ctx.oscillators[0].frequency.value, 523.25);
assert.equal(ctx.gains[0].gain.linearRampCalls[0].value, 0.18);
});
test('soft leave drops to the lower triangle note', () => {
playCallLeaveSound('soft');
assert.deepEqual(freqs(current()), [392]);
});
test('retro join is three ascending square notes', () => {
playCallJoinSound('retro');
const ctx = current();
assert.deepEqual(
ctx.oscillators.map((o) => o.type),
['square', 'square', 'square'],
);
assert.deepEqual(freqs(ctx), [440, 554.37, 659.25]);
assert.deepEqual(
ctx.oscillators.map((o) => o.startCalls[0]),
[0, 0.07, 0.14],
);
});
test('retro leave is three descending square notes', () => {
playCallLeaveSound('retro');
assert.deepEqual(freqs(current()), [659.25, 554.37, 440]);
});
// ── Envelope (click-avoidance shape) ────────────────────────────────────────
test('each note ramps 0 -> peak -> ~0 to avoid clicks, and wires osc->gain->out', () => {
playCallJoinSound('soft');
const ctx = current();
const { gain } = ctx.gains[0];
assert.equal(gain.setValueAtTimeCalls[0].value, 0); // starts silent
assert.equal(gain.linearRampCalls[0].value, 0.18); // attack to peak
assert.equal(gain.expRampCalls[0].value, 0.0001); // exp decay can't hit 0
// osc -> gain -> destination
assert.equal(ctx.oscillators[0].connectedTo, ctx.gains[0]);
assert.equal(ctx.gains[0].connectedTo, ctx.destination);
assert.equal(ctx.oscillators[0].startCalls.length, 1);
assert.equal(ctx.oscillators[0].stopCalls.length, 1);
});
// ── Dispatch / defensive contracts ──────────────────────────────────────────
test('an unknown style is a no-op and never even creates a context', () => {
assert.doesNotThrow(() => playCallJoinSound('bogus' as never));
assert.doesNotThrow(() => playCallLeaveSound('bogus' as never));
assert.equal(FakeAudioContext.instances.length, 0);
});
test('does not throw when AudioContext construction fails (unsupported env)', () => {
FakeAudioContext.throwOnConstruct = true;
assert.doesNotThrow(() => playCallJoinSound('chime'));
assert.equal(FakeAudioContext.instances.length, 0);
});
// ── Shared-context lifecycle ────────────────────────────────────────────────
test('unlockCallSounds primes a single running context', () => {
unlockCallSounds();
assert.equal(FakeAudioContext.instances.length, 1);
assert.equal(current().state, 'running');
});
test('reuses one running context across multiple sounds', () => {
playCallJoinSound('chime');
playCallLeaveSound('chime');
playCallJoinSound('soft');
assert.equal(FakeAudioContext.instances.length, 1);
});
test('recreates the context after it has been closed', () => {
playCallJoinSound('soft');
current().state = 'closed';
playCallJoinSound('soft');
assert.equal(FakeAudioContext.instances.length, 2);
});
test('resumes (without recreating) a suspended context', () => {
playCallJoinSound('soft');
const ctx = current();
ctx.state = 'suspended';
playCallJoinSound('soft');
assert.equal(FakeAudioContext.instances.length, 1);
assert.equal(ctx.resumeCalls, 1);
});