diff --git a/LOTUS_BUGS.md b/LOTUS_BUGS.md index 6daf74e05..098f81293 100644 --- a/LOTUS_BUGS.md +++ b/LOTUS_BUGS.md @@ -151,7 +151,7 @@ retry … AbortError: Restart delayed event timed out before the HS responded`, ### 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. - **`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). diff --git a/src/app/utils/callSounds.test.ts b/src/app/utils/callSounds.test.ts new file mode 100644 index 000000000..5ddb45dfc --- /dev/null +++ b/src/app/utils/callSounds.test.ts @@ -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 { + this.resumeCalls += 1; + this.state = 'running'; + return Promise.resolve(); + } + close(): Promise { + 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); +});