Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Build

on:
push:
branches: [main, master]
pull_request:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2

- run: bun install --frozen-lockfile

- run: bun run test:run
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ test-results/
playwright-report/
.vercel
.env*.local
screenshots/
9 changes: 9 additions & 0 deletions src/lib/blobGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ function pick<T>(arr: readonly T[], random: () => number): T {
return arr[Math.floor(random() * arr.length)]!;
}

/**
* Get a display-safe name: trimmed and truncated with ellipsis if over 10 chars
*/
export function getDisplayName(name: string, maxLength = 10): string {
const trimmed = name.trim();
if (trimmed.length <= maxLength) return trimmed;
return trimmed.slice(0, maxLength - 1) + "…";
}

/**
* Generate a blob config from a player name
*/
Expand Down
13 changes: 13 additions & 0 deletions src/lib/errorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,16 @@ export function getFriendlyErrorMessage(error: unknown): string {
// Otherwise, return the original message (it might already be readable)
return rawMessage;
}

/**
* Creates a timestamped error state object from an error.
* Useful for storing error state in React components.
*/
export function createErrorState(error: unknown): { message: string; timestamp: number } {
return {
message: getFriendlyErrorMessage(error),
timestamp: Date.now(),
};
}


13 changes: 13 additions & 0 deletions src/lib/soundManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ const state: SoundManagerState = {
// Event listeners for mute state changes
const muteListeners = new Set<(muted: boolean) => void>();

/**
* Reset the sound manager state. Useful for testing.
*/
export function cleanup(): void {
if (state.audioContext) {
state.audioContext.close().catch(() => {});
state.audioContext = null;
}
state.isMuted = loadMuteState();
state.isInitialized = false;
muteListeners.clear();
}

/**
* Load mute state from localStorage
*/
Expand Down
123 changes: 63 additions & 60 deletions tests/unit/soundManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,72 +8,75 @@

import { describe, it, expect, beforeEach, vi } from "vitest";

// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();

// Mock AudioContext
class MockOscillator {
connect() { return this; }
start() {}
stop() {}
frequency = { setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} };
type = "sine";
}

class MockGainNode {
connect() { return this; }
gain = { setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} };
}

class MockBiquadFilterNode {
connect() { return this; }
frequency = { setValueAtTime() {}, exponentialRampToValueAtTime() {} };
type = "lowpass";
}

class MockAudioBufferSourceNode {
connect() { return this; }
start() {}
buffer: AudioBuffer | null = null;
}

class MockAudioContext {
state = "running";
currentTime = 0;
sampleRate = 44100;
destination = {};
createOscillator() { return new MockOscillator(); }
createGain() { return new MockGainNode(); }
createBiquadFilter() { return new MockBiquadFilterNode(); }
createBufferSource() { return new MockAudioBufferSourceNode(); }
createBuffer(channels: number, length: number, sampleRate: number) {
// Mocks must be hoisted above imports — vi.hoisted runs before module evaluation
const { localStorageMock, MockAudioContext } = vi.hoisted(() => {
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getChannelData: () => new Float32Array(length),
} as unknown as AudioBuffer;
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();

class MockOscillator {
connect() { return this; }
start() {}
stop() {}
frequency = { setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} };
type = "sine";
}
resume() { return Promise.resolve(); }
close() { return Promise.resolve(); }
}

// Setup mocks before importing the module
class MockGainNode {
connect() { return this; }
gain = { setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} };
}

class MockBiquadFilterNode {
connect() { return this; }
frequency = { setValueAtTime() {}, exponentialRampToValueAtTime() {} };
type = "lowpass";
}

class MockAudioBufferSourceNode {
connect() { return this; }
start() {}
buffer: AudioBuffer | null = null;
}

class MockAudioContext {
state = "running";
currentTime = 0;
sampleRate = 44100;
destination = {};
createOscillator() { return new MockOscillator(); }
createGain() { return new MockGainNode(); }
createBiquadFilter() { return new MockBiquadFilterNode(); }
createBufferSource() { return new MockAudioBufferSourceNode(); }
createBuffer(channels: number, length: number, sampleRate: number) {
return {
getChannelData: () => new Float32Array(length),
} as unknown as AudioBuffer;
}
resume() { return Promise.resolve(); }
close() { return Promise.resolve(); }
}

return { localStorageMock, MockAudioContext };
});

// These run in hoisted order, before the import below
vi.stubGlobal("localStorage", localStorageMock);
vi.stubGlobal("AudioContext", MockAudioContext);

// Now import the module
// Now the import sees the mocked globals during module evaluation
import {
isMuted,
setMuted,
Expand Down