feat(ui): redesign voice cockpit with clinical companion drawer, Svelte 5 migration, and Gemini 3.8 voice
- 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
This commit is contained in:
1 parent
c82b112624
commit
1feea40684
148 files changed
+11230
-1706
No files matched your search
@@ -0,0 +1,161 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user