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:
jpremill committed 2026-09-29 23:35:05 -04:00
1 parent c82b112624
commit 1feea40684
148 files changed
+11230 -1706

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import { describe, test, expect } from 'vitest';
import {
DEFAULT_OPENROUTER_MODEL,
DEFAULT_SUPERVISOR_MODEL,
getOpenRouterApiKey,
hasOpenRouterApiKey,
getGeminiApiKey,
hasGeminiApiKey
} from '../services/apiConfig';
describe('API Configuration & Model Defaults', () => {
test('should default to latest Google Gemini 3.8 Flash model', () => {
expect(DEFAULT_OPENROUTER_MODEL).toBe('google/gemini-3.8-flash');
expect(DEFAULT_SUPERVISOR_MODEL).toBe('google/gemini-3.8-flash');
});
test('should return trimmed explicit custom API key when provided', () => {
const key = getOpenRouterApiKey(' sk-or-v1-custom-key-12345 ');
expect(key).toBe('sk-or-v1-custom-key-12345');
});
test('should fallback to environment variable when no argument or stored setting exists', () => {
const originalEnv = process.env.OPENROUTER_API_KEY;
try {
process.env.OPENROUTER_API_KEY = 'sk-or-v1-env-fallback-key';
const key = getOpenRouterApiKey();
expect(key).toBe('sk-or-v1-env-fallback-key');
expect(hasOpenRouterApiKey()).toBe(true);
} finally {
if (originalEnv !== undefined) {
process.env.OPENROUTER_API_KEY = originalEnv;
} else {
delete process.env.OPENROUTER_API_KEY;
}
}
});
test('should return empty string when no keys are configured', () => {
const originalEnv = process.env.OPENROUTER_API_KEY;
try {
process.env.OPENROUTER_API_KEY = '';
const key = getOpenRouterApiKey('');
expect(key).toBe('');
} finally {
if (originalEnv !== undefined) {
process.env.OPENROUTER_API_KEY = originalEnv;
}
}
});
test('should resolve Google Gemini API Key from environment or explicit argument', () => {
expect(getGeminiApiKey('AIzaSyCustomKey')).toBe('AIzaSyCustomKey');
expect(hasGeminiApiKey()).toBe(true);
const originalEnv = process.env.GEMINI_API_KEY;
try {
process.env.GEMINI_API_KEY = 'AIzaSyFallbackKey';
expect(getGeminiApiKey()).toBe('AIzaSyFallbackKey');
} finally {
if (originalEnv !== undefined) {
process.env.GEMINI_API_KEY = originalEnv;
}
}
});
});
+161
View File
@@ -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();
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, test, expect } from 'vitest';
import {
floatTo16BitPCM,
encodeBase64,
decodeBase64,
decodeAudioData
} from '../services/audioUtils';
describe('Audio Utilities & PCM DSP', () => {
test('floatTo16BitPCM converts Float32Array to 16-bit PCM buffer with proper clamping', () => {
// Input with values within range, positive clamp (>1), negative clamp (<-1), and zero
const input = new Float32Array([0.0, 0.5, 1.0, 1.5, -0.5, -1.0, -1.5]);
const buffer = floatTo16BitPCM(input);
expect(buffer.byteLength).toBe(input.length * 2);
const view = new DataView(buffer);
// 0.0 -> 0
expect(view.getInt16(0, true)).toBe(0);
// 0.5 -> 0.5 * 32767 = 16383.5 -> 16383
expect(Math.abs(view.getInt16(2, true) - 16383)).toBeLessThanOrEqual(1);
// 1.0 -> 32767
expect(view.getInt16(4, true)).toBe(0x7fff);
// 1.5 clamped to 1.0 -> 32767
expect(view.getInt16(6, true)).toBe(0x7fff);
// -0.5 -> -0.5 * 32768 = -16384
expect(Math.abs(view.getInt16(8, true) - -16384)).toBeLessThanOrEqual(1);
// -1.0 -> -32768
expect(view.getInt16(10, true)).toBe(-0x8000);
// -1.5 clamped to -1.0 -> -32768
expect(view.getInt16(12, true)).toBe(-0x8000);
});
test('encodeBase64 and decodeBase64 perform lossless roundtrip for arbitrary byte arrays', () => {
const rawBytes = new Uint8Array([0, 1, 255, 128, 42, 64, 18, 99, 127]);
const encoded = encodeBase64(rawBytes);
expect(typeof encoded).toBe('string');
expect(encoded.length).toBeGreaterThan(0);
const decoded = decodeBase64(encoded);
expect(decoded.length).toBe(rawBytes.length);
expect(Array.from(decoded)).toEqual(Array.from(rawBytes));
});
test('decodeAudioData converts raw PCM Int16 bytes into AudioBuffer channels', async () => {
// Construct mock AudioContext with createBuffer
const sampleRate = 24000;
const numChannels = 1;
const mockChannelData = new Float32Array(4);
const mockAudioBuffer = {
length: 4,
numberOfChannels: 1,
sampleRate,
getChannelData: () => mockChannelData
};
const mockCtx = {
createBuffer: (channels: number, length: number, rate: number) => {
expect(channels).toBe(1);
expect(length).toBe(4);
expect(rate).toBe(sampleRate);
return mockAudioBuffer;
}
} as unknown as AudioContext;
// 4 16-bit samples: 0, 32767, -32768, 16384
const int16Samples = new Int16Array([0, 32767, -32768, 16384]);
const byteData = new Uint8Array(int16Samples.buffer);
const result = await decodeAudioData(byteData, mockCtx, sampleRate, numChannels);
expect(result).toBe(mockAudioBuffer);
// Verify channel data normalized between -1.0 and 1.0
expect(mockChannelData[0]).toBe(0);
expect(mockChannelData[1]).toBeCloseTo(1.0, 2);
expect(mockChannelData[2]).toBe(-1.0);
expect(mockChannelData[3]).toBeCloseTo(0.5, 2);
});
test('decodeAudioData handles uneven byte lengths gracefully', async () => {
const mockChannelData = new Float32Array(2);
const mockAudioBuffer = {
length: 2,
numberOfChannels: 1,
sampleRate: 24000,
getChannelData: () => mockChannelData
};
const mockCtx = {
createBuffer: () => mockAudioBuffer
} as unknown as AudioContext;
// 5 bytes (uneven) -> trimmed to 4 bytes (2 samples)
const unevenBytes = new Uint8Array([0, 0, 255, 127, 42]);
const result = await decodeAudioData(unevenBytes, mockCtx);
expect(result).toBe(mockAudioBuffer);
});
});
+246
View File
@@ -0,0 +1,246 @@
import { describe, test, expect, afterEach } from 'vitest';
import {
analyzeTurnWithOpenRouter,
generateSessionSummaryReport,
generateScenarioWithOpenRouter,
streamOpenRouterChat
} from '../services/openRouterService';
import { DEFAULT_OPENROUTER_MODEL } from '../services/apiConfig';
describe('OpenRouter Service Integration', () => {
const originalFetch = globalThis.fetch;
const mockKey = 'sk-or-v1-mock-test-key-999';
afterEach(() => {
globalThis.fetch = originalFetch;
});
test('should throw error when no API key is provided and none in environment', async () => {
const originalEnv = process.env.OPENROUTER_API_KEY;
try {
process.env.OPENROUTER_API_KEY = '';
await expect(
analyzeTurnWithOpenRouter('turn', 'title', 'desc', ['Catastrophizing'], { apiKey: '' })
).rejects.toThrow(/OpenRouter API key is missing/);
} finally {
if (originalEnv !== undefined) process.env.OPENROUTER_API_KEY = originalEnv;
}
});
test('analyzeTurnWithOpenRouter sends expected headers and uses custom model when specified', async () => {
let capturedUrl = '';
let capturedHeaders: any = {};
let capturedBody: any = {};
globalThis.fetch = async (url: any, init: any) => {
capturedUrl = url.toString();
capturedHeaders = init?.headers;
capturedBody = JSON.parse(init?.body as string);
const mockResponse = {
choices: [
{
message: {
content: JSON.stringify({
analysis: { distortions: ['Mind Reading'], isAdaptive: false, evidence: 'User assumed negative intent.' },
rating: { score: 2, reason: 'High distortion presence' },
suggestions: { alternativeThought: 'Ask for clarification first.', behavioralStep: 'Send a follow-up text.' }
})
}
}
]
};
return new Response(JSON.stringify(mockResponse), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
const result = await analyzeTurnWithOpenRouter(
'User said: "They hate me."',
'Silent Friend',
'A friend has not replied.',
['Mind Reading'],
{ apiKey: mockKey, model: 'google/gemini-3.8-flash' }
);
expect(capturedUrl).toBe('https://openrouter.ai/api/v1/chat/completions');
expect(capturedHeaders['Authorization']).toBe(`Bearer ${mockKey}`);
expect(capturedHeaders['HTTP-Referer']).toBe('https://cognitive-clarity.app');
expect(capturedBody.model).toBe('google/gemini-3.8-flash');
expect(capturedBody.response_format?.type).toBe('json_object');
expect(result.analysis.isAdaptive).toBe(false);
expect(result.analysis.distortions).toEqual(['Mind Reading']);
expect(result.rating.score).toBe(2);
});
test('generateScenarioWithOpenRouter defaults to DEFAULT_OPENROUTER_MODEL when model option is omitted', async () => {
let capturedBody: any = {};
globalThis.fetch = async (url: any, init: any) => {
capturedBody = JSON.parse(init?.body as string);
const mockScenario = {
id: 'mock-1',
title: 'Workplace Feedback',
characterName: 'Boss',
description: 'Meeting about quarterly review',
category: 'Workplace',
difficulty: 'Intermediate',
systemInstruction: 'INTERNAL CBT LOGIC: React defensively if blamed.',
initialMessage: 'We need to discuss this report.',
cbtFocus: ['All-or-Nothing Thinking']
};
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(mockScenario) } }] }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
const scenario = await generateScenarioWithOpenRouter(
'Quarterly review',
['All-or-Nothing Thinking'],
'Intermediate',
{ apiKey: mockKey }
);
expect(capturedBody.model).toBe(DEFAULT_OPENROUTER_MODEL);
expect(scenario.title).toBe('Workplace Feedback');
expect(scenario.difficulty).toBe('Intermediate');
});
test('streamOpenRouterChat streams SSE tokens and handles completion', async () => {
const sseChunks = [
'data: {"choices":[{"delta":{"content":"I "}}]}\n\n',
'data: {"choices":[{"delta":{"content":"understand."}}]}\n\n',
'data: [DONE]\n\n'
];
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
for (const chunk of sseChunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
}
});
globalThis.fetch = async () => {
return new Response(stream, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' }
});
};
const collectedChunks: string[] = [];
let completedText = '';
const result = await streamOpenRouterChat(
[{ id: '1', role: 'user', text: 'Hello', timestamp: new Date() }],
'You are a peer.',
{
onChunk: (chunk) => collectedChunks.push(chunk),
onComplete: (full) => { completedText = full; }
},
{ apiKey: mockKey, model: 'google/gemini-3.8-flash' }
);
expect(result).toBe('I understand.');
expect(completedText).toBe('I understand.');
expect(collectedChunks).toEqual(['I ', 'understand.']);
});
test('generateSessionSummaryReport sends all conversation turns and parses clinical report', async () => {
let capturedBody: any = {};
globalThis.fetch = async (url: any, init: any) => {
capturedBody = JSON.parse(init?.body as string);
const mockReport = {
analysis: {
distortions: ['Catastrophizing'],
isAdaptive: false,
evidence: 'I am definitely going to get fired.'
},
rating: {
score: 3,
reason: 'Showed initial panic but engaged in dialogue.'
},
suggestions: {
alternativeThought: 'Receiving negative feedback does not mean immediate termination.',
behavioralStep: 'Schedule a clarifying 1-on-1 meeting next week.'
}
};
return new Response(JSON.stringify({
choices: [{ message: { content: JSON.stringify(mockReport) } }]
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
const messages = [
{ id: '1', role: 'model' as const, text: 'Your performance was lacking this month.', timestamp: new Date() },
{ id: '2', role: 'user' as const, text: 'I am definitely going to get fired.', timestamp: new Date() }
];
const report = await generateSessionSummaryReport(
messages,
'Performance Review',
'Manager discusses quarterly output',
{ apiKey: mockKey, model: 'google/gemini-3.8-flash' }
);
expect(capturedBody.model).toBe('google/gemini-3.8-flash');
expect(report.analysis.distortions).toEqual(['Catastrophizing']);
expect(report.rating.score).toBe(3);
expect(report.suggestions.alternativeThought).toContain('termination');
});
test('synthesizeSpeechWithOpenRouter returns audio ArrayBuffer on success', async () => {
const mockAudioBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
let capturedUrl = '';
let capturedBody: any = {};
globalThis.fetch = async (url: any, init: any) => {
capturedUrl = url.toString();
capturedBody = JSON.parse(init?.body as string);
return new Response(mockAudioBytes.buffer, {
status: 200,
headers: { 'Content-Type': 'audio/mpeg' }
});
};
const { synthesizeSpeechWithOpenRouter } = await import('../services/openRouterService');
const buffer = await synthesizeSpeechWithOpenRouter(
'Good morning. How are you feeling today?',
'nova',
mockKey
);
expect(capturedUrl).toBe('https://openrouter.ai/api/v1/audio/speech');
expect(capturedBody.input).toBe('Good morning. How are you feeling today?');
expect(capturedBody.voice).toBe('nova');
expect(buffer.byteLength).toBe(8);
});
test('synthesizeSpeechWithOpenRouter throws error on non-200 response', async () => {
globalThis.fetch = async () => {
return new Response('Unauthorized voice quota exceeded', {
status: 401,
headers: { 'Content-Type': 'text/plain' }
});
};
const { synthesizeSpeechWithOpenRouter } = await import('../services/openRouterService');
await expect(
synthesizeSpeechWithOpenRouter('Hello', 'alloy', mockKey)
).rejects.toThrow(/OpenRouter TTS synthesis failed with status 401/);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, test, expect } from 'vitest';
import { SCENARIOS, CBT_GLOSSARY } from '../constants';
import { validateScenario } from '../scripts/validate-scenarios';
describe('CBT Clinical Scenarios Catalog', () => {
test('should contain exactly 14 pre-calibrated default scenarios', () => {
expect(SCENARIOS.length).toBe(14);
});
test('every scenario should pass strict schema validation with zero errors', () => {
for (const scenario of SCENARIOS) {
const result = validateScenario(scenario);
expect(result.isValid).toBe(true);
expect(result.errors.length).toBe(0);
}
});
test('every scenario should have an assigned character name and non-empty initial message', () => {
for (const scenario of SCENARIOS) {
expect(scenario.characterName && scenario.characterName.trim().length > 0).toBe(true);
expect(scenario.initialMessage && scenario.initialMessage.trim().length > 0).toBe(true);
expect(scenario.systemInstruction.includes('INTERNAL CBT LOGIC')).toBe(true);
}
});
test('CBT glossary should contain valid definitions for all categories', () => {
expect(CBT_GLOSSARY.length).toBeGreaterThanOrEqual(18);
for (const entry of CBT_GLOSSARY) {
expect(entry.term && entry.term.trim().length > 0).toBe(true);
expect(entry.definition && entry.definition.trim().length > 0).toBe(true);
expect(['Distortion', 'Technique', 'Concept'].includes(entry.category)).toBe(true);
}
});
});
+183
View File
@@ -0,0 +1,183 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import {
getSavedSessions,
saveSession,
deleteSession,
formatSessionAsMarkdown,
downloadFile
} from '../services/sessionStorage';
import type { Scenario, Message, AnalysisResult } from '../types';
describe('Session Storage & Export Service', () => {
const mockStorage: Record<string, string> = {};
const mockScenario: Scenario = {
id: 'test-scenario-1',
title: 'Workplace Disagreement',
description: 'Resolving friction with a manager',
category: 'Workplace',
difficulty: 'Intermediate',
systemInstruction: 'INTERNAL CBT LOGIC: React with skepticism.',
initialMessage: 'Why was this project late?',
cbtFocus: ['All-or-Nothing Thinking', 'Mind Reading'],
characterName: 'Director Vance'
};
const mockMessages: Message[] = [
{
id: 'm1',
role: 'model',
text: 'Why was this project late?',
timestamp: new Date()
},
{
id: 'm2',
role: 'user',
text: 'We encountered unexpected API downtime, but delivered within scope.',
timestamp: new Date()
}
];
const mockAnalysis: AnalysisResult = {
analysis: {
distortions: ['Mind Reading'],
isAdaptive: true,
evidence: 'User acknowledged the delay factually without catastrophic self-blame.'
},
rating: {
score: 4,
reason: 'Strong boundary maintenance and objective attribution.'
},
suggestions: {
alternativeThought: 'Delays happen due to technical constraints; focus on resolution.',
behavioralStep: 'Send an updated post-mortem dashboard to stakeholders.'
}
};
beforeEach(() => {
for (const key in mockStorage) {
delete mockStorage[key];
}
vi.stubGlobal('localStorage', {
getItem: (key: string) => mockStorage[key] || null,
setItem: (key: string, val: string) => {
mockStorage[key] = val;
},
removeItem: (key: string) => {
delete mockStorage[key];
},
clear: () => {
for (const key in mockStorage) {
delete mockStorage[key];
}
}
});
});
test('getSavedSessions returns empty array when storage is empty', () => {
expect(getSavedSessions()).toEqual([]);
});
test('getSavedSessions handles corrupted JSON gracefully', () => {
mockStorage['cognitive-clarity-saved-sessions'] = 'INVALID_JSON_CORRUPTED';
const sessions = getSavedSessions();
expect(sessions).toEqual([]);
});
test('saveSession stores new session and retrieves it', () => {
const saved = saveSession(mockScenario, 'text', mockMessages, mockAnalysis);
expect(saved.scenarioId).toBe(mockScenario.id);
expect(saved.scenarioTitle).toBe(mockScenario.title);
expect(saved.mode).toBe('text');
expect(saved.messages.length).toBe(2);
expect(saved.finalReport?.rating.score).toBe(4);
const all = getSavedSessions();
expect(all.length).toBe(1);
expect(all[0].id).toBe(saved.id);
});
test('saveSession caps history at 50 sessions', () => {
for (let i = 0; i < 55; i++) {
saveSession(
{ ...mockScenario, id: `scenario-${i}` },
'text',
mockMessages
);
}
const all = getSavedSessions();
expect(all.length).toBe(50);
});
test('deleteSession removes specific session by id', () => {
const s1 = saveSession(mockScenario, 'text', mockMessages);
const s2 = saveSession({ ...mockScenario, id: 's2', title: 'Second' }, 'voice', mockMessages);
expect(getSavedSessions().length).toBe(2);
deleteSession(s1.id);
const remaining = getSavedSessions();
expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe(s2.id);
});
test('formatSessionAsMarkdown generates structured markdown report with all sections', () => {
const md = formatSessionAsMarkdown('Workplace Disagreement', mockAnalysis, mockMessages);
expect(md).toContain('# Cognitive Clarity — Clinical CBT Session Report');
expect(md).toContain('**Scenario:** Workplace Disagreement');
expect(md).toContain('✅ Adaptive Growth Observed');
expect(md).toContain('**Mastery Rating:** 4 / 5');
expect(md).toContain('- Mind Reading');
expect(md).toContain('User acknowledged the delay factually');
expect(md).toContain('Delays happen due to technical constraints');
expect(md).toContain('## 3. Session Transcript');
expect(md).toContain('**[AI Partner]**: Why was this project late?');
expect(md).toContain('**[You]**: We encountered unexpected API downtime');
});
test('downloadFile creates object URL and triggers anchor click in DOM', () => {
let clicked = false;
let appended = false;
let removed = false;
let revokedUrl = '';
const mockAnchor = {
href: '',
download: '',
click: () => {
clicked = true;
}
};
vi.stubGlobal('URL', {
createObjectURL: () => 'blob:mock-url-123',
revokeObjectURL: (url: string) => {
revokedUrl = url;
}
});
vi.stubGlobal('document', {
createElement: () => mockAnchor,
body: {
appendChild: () => {
appended = true;
},
removeChild: () => {
removed = true;
}
}
});
downloadFile('# Report', 'report.md', 'text/markdown');
expect(mockAnchor.download).toBe('report.md');
expect(mockAnchor.href).toBe('blob:mock-url-123');
expect(clicked).toBe(true);
expect(appended).toBe(true);
expect(removed).toBe(true);
expect(revokedUrl).toBe('blob:mock-url-123');
});
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { UserSettingsStore } from '../src/lib/stores/UserSettingsStore.svelte';
import { AppStateStore } from '../src/lib/stores/AppStateStore.svelte';
import type { Scenario } from '../types';
describe('Svelte 5 Reactive Stores', () => {
const mockStorage: Record<string, string> = {};
beforeEach(() => {
for (const key in mockStorage) {
delete mockStorage[key];
}
vi.stubGlobal('localStorage', {
getItem: (key: string) => mockStorage[key] || null,
setItem: (key: string, val: string) => {
mockStorage[key] = val;
},
removeItem: (key: string) => {
delete mockStorage[key];
},
clear: () => {
for (const key in mockStorage) {
delete mockStorage[key];
}
}
});
});
test('UserSettingsStore initializes with 3.8 models and correct defaults', () => {
const store = new UserSettingsStore();
expect(store.voiceName).toBe('Zephyr');
expect(store.roleplayModel).toBe('google/gemini-3.8-flash');
expect(store.supervisorModel).toBe('google/gemini-3.8-flash');
expect(store.genderPreference).toBe('Neutral');
});
test('UserSettingsStore updates properties cleanly', () => {
const store = new UserSettingsStore();
store.update({
voiceName: 'Kore',
openRouterApiKey: 'sk-or-v1-svelte-test-key',
roleplayModel: 'google/gemini-3.7-flash'
});
expect(store.voiceName).toBe('Kore');
expect(store.openRouterApiKey).toBe('sk-or-v1-svelte-test-key');
expect(store.roleplayModel).toBe('google/gemini-3.7-flash');
const plain = store.toUserSettings();
expect(plain.voiceName).toBe('Kore');
expect(plain.openRouterApiKey).toBe('sk-or-v1-svelte-test-key');
});
test('UserSettingsStore recovers gracefully from corrupted localStorage', () => {
mockStorage['cognitive-clarity-settings'] = 'MALFORMED_JSON_STRING';
const store = new UserSettingsStore();
expect(store.voiceName).toBe('Zephyr');
expect(store.roleplayModel).toBe('google/gemini-3.8-flash');
});
test('AppStateStore manages scenario catalog and filtering', () => {
const app = new AppStateStore();
expect(app.allScenarios.length).toBe(14);
app.selectedDifficulty = 'Beginner';
expect(app.filteredScenarios.every((s) => s.difficulty === 'Beginner')).toBe(true);
app.selectedDifficulty = 'Advanced';
expect(app.filteredScenarios.every((s) => s.difficulty === 'Advanced')).toBe(true);
app.selectedDifficulty = 'All';
expect(app.filteredScenarios.length).toBe(14);
});
test('AppStateStore adds and removes custom scenarios', () => {
const app = new AppStateStore();
const customScenario: Scenario = {
id: 'custom-test-1',
title: 'Custom Job Negotiation',
description: 'Negotiating offer',
category: 'Workplace',
difficulty: 'Intermediate',
systemInstruction: 'INTERNAL CBT LOGIC: Be firm.',
initialMessage: 'Here is our final offer.',
cbtFocus: ['All-or-Nothing Thinking'],
characterName: 'Hiring Lead'
};
app.addCustomScenario(customScenario);
expect(app.customScenarios.length).toBe(1);
expect(app.allScenarios.length).toBe(15);
expect(app.allScenarios[0].id).toBe('custom-test-1');
app.deleteCustomScenario('custom-test-1');
expect(app.customScenarios.length).toBe(0);
expect(app.allScenarios.length).toBe(14);
});
test('AppStateStore selectScenario and exitSession lifecycle', async () => {
vi.useFakeTimers();
const app = new AppStateStore();
const targetScenario = app.allScenarios[0];
app.selectScenario(targetScenario, 'voice');
expect(app.isPreparing).toBe(true);
expect(app.activeScenario).toBe(null);
vi.advanceTimersByTime(1400);
expect(app.isPreparing).toBe(false);
expect(app.activeScenario?.id).toBe(targetScenario.id);
expect(app.sessionMode).toBe('voice');
app.exitSession();
expect(app.activeScenario).toBe(null);
vi.useRealTimers();
});
});
+206
View File
@@ -0,0 +1,206 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { VoiceAuditionService } from '../services/voiceAuditionService';
import { VOICE_PROFILES } from '../constants';
describe('VoiceAuditionService and Natural Voice Calibration', () => {
let service: VoiceAuditionService;
beforeEach(() => {
service = new VoiceAuditionService();
});
afterEach(() => {
service.stop();
vi.restoreAllMocks();
});
test('VOICE_PROFILES contains Fenrir as the gruff salt-of-the-earth persona mapped to onyx', () => {
const fenrir = VOICE_PROFILES.find((p) => p.id === 'Fenrir');
expect(fenrir).toBeDefined();
expect(fenrir?.label).toContain('Salt of the Earth');
expect(fenrir?.style).toContain('Gruff');
expect(fenrir?.openRouterVoice).toBe('onyx'); // OpenAI/OpenRouter deep, gravelly voice
expect(fenrir?.webSpeechTuning.pitch).toBeLessThan(1.0); // Low, gravelly pitch
expect(fenrir?.sampleText).toContain('honest step at a time');
});
test('VOICE_PROFILES includes 5 distinct clinical voice personas', () => {
expect(VOICE_PROFILES.length).toBe(5);
const voiceIds = VOICE_PROFILES.map((p) => p.id);
expect(voiceIds).toEqual(['Fenrir', 'Zephyr', 'Puck', 'Kore', 'Charon']);
});
test('getProfile returns matching VoiceProfile or falls back to default', () => {
const profile = service.getProfile('Fenrir');
expect(profile.id).toBe('Fenrir');
expect(profile.openRouterVoice).toBe('onyx');
// @ts-expect-error test fallback
const fallback = service.getProfile('NonExistent');
expect(fallback.id).toBe('Fenrir');
});
test('playPreview uses Google Gemini native voice when Gemini API key is available', async () => {
const mockWavBase64 = 'UklGRgAAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
candidates: [{
content: {
parts: [{
inlineData: {
mimeType: 'audio/wav',
data: mockWavBase64
}
}]
}
}]
})
}));
const playMock = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('Audio', class MockAudio {
src = '';
play = playMock;
pause = vi.fn();
currentTime = 0;
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(src: string) {
this.src = src;
}
});
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue('blob:http://localhost/test-gemini-audio'),
revokeObjectURL: vi.fn()
});
await service.playPreview('Fenrir', 'Custom test sentence', undefined, undefined, 'AIzaSyMockKey');
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('gemini-3.8-flash-tts'),
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"voiceName":"Fenrir"')
})
);
expect(playMock).toHaveBeenCalled();
});
test('playPreview uses OpenRouter TTS when apiKey is provided and no Gemini key', async () => {
const originalGemini = process.env.GEMINI_API_KEY;
try {
process.env.GEMINI_API_KEY = '';
const mockArrayBuffer = new Uint8Array([1, 2, 3, 4]).buffer;
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: async () => mockArrayBuffer
}));
const playMock = vi.fn().mockResolvedValue(undefined);
let capturedAudioInstance: any;
vi.stubGlobal('Audio', class MockAudio {
src = '';
play = playMock;
pause = vi.fn();
currentTime = 0;
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(src: string) {
this.src = src;
capturedAudioInstance = this;
}
});
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue('blob:http://localhost/test-audio'),
revokeObjectURL: vi.fn()
});
const onEnded = vi.fn();
await service.playPreview('Fenrir', 'Custom test sentence', 'sk-or-v1-test-key', onEnded);
expect(global.fetch).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/audio/speech',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
model: 'openai/tts-1',
input: 'Custom test sentence',
voice: 'onyx' // Verified: Fenrir maps to onyx
})
})
);
expect(playMock).toHaveBeenCalled();
// Trigger onended
capturedAudioInstance?.onended?.();
expect(onEnded).toHaveBeenCalled();
} finally {
if (originalGemini !== undefined) process.env.GEMINI_API_KEY = originalGemini;
}
});
test('playPreview falls back to Web Speech Synthesis when no apiKey is provided', async () => {
const originalGemini = process.env.GEMINI_API_KEY;
const originalOR = process.env.OPENROUTER_API_KEY;
try {
process.env.GEMINI_API_KEY = '';
process.env.OPENROUTER_API_KEY = '';
const speakMock = vi.fn();
const cancelMock = vi.fn();
vi.stubGlobal('speechSynthesis', {
speak: speakMock,
cancel: cancelMock
});
vi.stubGlobal('SpeechSynthesisUtterance', class MockUtterance {
text: string;
pitch = 1.0;
rate = 1.0;
onend: (() => void) | null = null;
constructor(text: string) {
this.text = text;
}
});
const onEnded = vi.fn();
await service.playPreview('Fenrir', undefined, undefined, onEnded);
expect(speakMock).toHaveBeenCalled();
const utterance = speakMock.mock.calls[0][0];
expect(utterance.text).toContain('honest step at a time');
expect(utterance.pitch).toBe(0.75); // Gruff low pitch verified
expect(utterance.rate).toBe(0.92);
} finally {
if (originalGemini !== undefined) process.env.GEMINI_API_KEY = originalGemini;
if (originalOR !== undefined) process.env.OPENROUTER_API_KEY = originalOR;
}
});
test('stop halts both HTMLAudioElement and speechSynthesis', async () => {
const pauseMock = vi.fn();
const cancelMock = vi.fn();
vi.stubGlobal('speechSynthesis', {
cancel: cancelMock,
speak: vi.fn()
});
// @ts-expect-error simulate active audio
service.currentAudio = {
pause: pauseMock,
currentTime: 10
};
service.stop();
expect(pauseMock).toHaveBeenCalled();
expect(cancelMock).toHaveBeenCalled();
});
});