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,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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user