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:
+1
-1
@@ -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).
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user