- Re-architect VoiceSession into full-bleed studio cockpit with overlay slide-over companion drawer - Eliminate width squishing defect on LiveInsightPanel and add native dark/light theme support - Integrate authentic Google Gemini 3.8 Flash TTS with Fenrir persona and fallback audio pipeline - Implement comprehensive Playwright E2E visual regression suite across Desktop, iPad, and iPhone (21 tests, 45 snapshots) - Migrate core application to Svelte 5 runes (, , ) with reactive stores - Maintain .NET 8 Azure Functions serverless endpoints and clean build hygiene
162 lines
4.5 KiB
TypeScript
162 lines
4.5 KiB
TypeScript
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { AudioEngine } from '../src/lib/audio/AudioEngine.svelte';
|
|
import { UserSettingsStore } from '../src/lib/stores/UserSettingsStore.svelte';
|
|
import type { Scenario } from '../types';
|
|
|
|
describe('AudioEngine Reactive Controller', () => {
|
|
const mockScenario: Scenario = {
|
|
id: 'test-s1',
|
|
title: 'Silent Friend',
|
|
description: 'Friend has not replied to your text',
|
|
category: 'Relationships',
|
|
difficulty: 'Beginner',
|
|
systemInstruction: 'INTERNAL CBT LOGIC: You were just busy.',
|
|
initialMessage: 'Hey, sorry I was swamped yesterday.',
|
|
cbtFocus: ['Mind Reading'],
|
|
characterName: 'Sam'
|
|
};
|
|
|
|
let mockAudioContext: any;
|
|
let mockUserMediaStream: any;
|
|
|
|
beforeEach(() => {
|
|
mockAudioContext = {
|
|
state: 'running',
|
|
destination: {},
|
|
createAnalyser: () => ({
|
|
fftSize: 256,
|
|
frequencyBinCount: 128,
|
|
getByteFrequencyData: (arr: Uint8Array) => arr.fill(50)
|
|
}),
|
|
createMediaStreamSource: () => ({
|
|
connect: vi.fn()
|
|
}),
|
|
createBufferSource: () => ({
|
|
buffer: null,
|
|
connect: vi.fn(),
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
onended: null
|
|
}),
|
|
decodeAudioData: async () => ({
|
|
duration: 2.0
|
|
}),
|
|
resume: vi.fn(),
|
|
close: vi.fn()
|
|
};
|
|
|
|
mockUserMediaStream = {
|
|
getTracks: () => [
|
|
{
|
|
stop: vi.fn()
|
|
}
|
|
]
|
|
};
|
|
|
|
vi.stubGlobal('AudioContext', function () {
|
|
return mockAudioContext;
|
|
});
|
|
|
|
vi.stubGlobal('navigator', {
|
|
mediaDevices: {
|
|
getUserMedia: async () => mockUserMediaStream,
|
|
enumerateDevices: async () => []
|
|
}
|
|
});
|
|
|
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => 1);
|
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
test('AudioEngine reports error if OpenRouter API key is missing', async () => {
|
|
const userSettings = new UserSettingsStore();
|
|
userSettings.openRouterApiKey = '';
|
|
|
|
const originalEnv = process.env.OPENROUTER_API_KEY;
|
|
try {
|
|
process.env.OPENROUTER_API_KEY = '';
|
|
const engine = new AudioEngine(mockScenario, userSettings);
|
|
await engine.start();
|
|
|
|
expect(engine.status).toBe('error');
|
|
expect(engine.errorMessage).toContain('OpenRouter API Key is missing');
|
|
} finally {
|
|
if (originalEnv !== undefined) process.env.OPENROUTER_API_KEY = originalEnv;
|
|
}
|
|
});
|
|
|
|
test('AudioEngine initializes, starts, and loads initial greeting when API key is present', async () => {
|
|
const userSettings = new UserSettingsStore();
|
|
userSettings.openRouterApiKey = 'sk-or-v1-mock-valid-key';
|
|
|
|
const engine = new AudioEngine(mockScenario, userSettings);
|
|
// Mock speakText to avoid real network call
|
|
engine.speakText = vi.fn();
|
|
|
|
await engine.start();
|
|
|
|
expect(engine.status).toBe('active');
|
|
expect(engine.errorMessage).toBe(null);
|
|
expect(engine.messages.length).toBe(1);
|
|
expect(engine.messages[0].text).toBe(mockScenario.initialMessage);
|
|
expect(engine.speakText).toHaveBeenCalledWith(mockScenario.initialMessage);
|
|
|
|
engine.destroy();
|
|
});
|
|
|
|
test('toggleMute toggles isMuted state and stops audio', async () => {
|
|
const userSettings = new UserSettingsStore();
|
|
const engine = new AudioEngine(mockScenario, userSettings);
|
|
|
|
expect(engine.isMuted).toBe(false);
|
|
engine.toggleMute();
|
|
expect(engine.isMuted).toBe(true);
|
|
|
|
engine.toggleMute();
|
|
expect(engine.isMuted).toBe(false);
|
|
|
|
engine.destroy();
|
|
});
|
|
|
|
test('stopAllAudio resets AI speaking flags and cancels speech synthesis', () => {
|
|
const cancelMock = vi.fn();
|
|
vi.stubGlobal('speechSynthesis', {
|
|
cancel: cancelMock,
|
|
speak: vi.fn()
|
|
});
|
|
|
|
const userSettings = new UserSettingsStore();
|
|
const engine = new AudioEngine(mockScenario, userSettings);
|
|
engine.isAiSpeaking = true;
|
|
engine.aiAudioLevel = 45;
|
|
|
|
engine.stopAllAudio();
|
|
|
|
expect(engine.isAiSpeaking).toBe(false);
|
|
expect(engine.aiAudioLevel).toBe(0);
|
|
expect(cancelMock).toHaveBeenCalled();
|
|
|
|
engine.destroy();
|
|
});
|
|
|
|
test('activeLevel derived property reflects active speaker volume', () => {
|
|
const userSettings = new UserSettingsStore();
|
|
const engine = new AudioEngine(mockScenario, userSettings);
|
|
|
|
engine.micLevel = 25;
|
|
engine.aiAudioLevel = 60;
|
|
|
|
engine.isAiSpeaking = false;
|
|
expect(engine.activeLevel).toBe(25);
|
|
|
|
engine.isAiSpeaking = true;
|
|
expect(engine.activeLevel).toBe(60);
|
|
|
|
engine.destroy();
|
|
});
|
|
});
|