From 27a43eba2b58331e6a4d0f89d32431432c79a504 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 31 Aug 2026 09:39:34 -0400 Subject: [PATCH 01/26] feat(headphones): remove "don't show again" from the headphone notice Shown before every session now, with no permanent silence option. CLAUDE.md's own justification for the notice being opt-out was that "whether the call is on speakers is a property of the machine and the meeting, not a setting, so it can change between sessions on the same install" - a permanent tick contradicted exactly that: the one fact the dialog exists to establish was the one fact a stale tick could no longer speak to. Drops headphoneNoticeAcknowledged entirely (main store, its migration backfill, and the renderer Config type) now that nothing reads it. This is a product-wide change to the live flow, done here because the mock interview feature (following) reuses this same dialog and would otherwise need to reason about a flag left over from a different feature's consent. --- src/main/store/config.store.ts | 14 -------- .../components/custom/control-panel/index.tsx | 11 +++---- .../custom/headphone-notice-dialog.tsx | 33 +++---------------- src/renderer/types/config.ts | 3 -- test/config-store.test.mjs | 8 ----- 5 files changed, 9 insertions(+), 60 deletions(-) diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 10c2ccc6..92816bac 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -34,15 +34,6 @@ export interface RuntimeConfig { // suggestions come back as headline + keyword bullets instead of full sentences professionalMode: boolean; - - /** - * The user has ticked "do not show this again" on the headphone notice. - * - * Opt-out rather than opt-in: whether the call is coming out of speakers is a property of the - * machine and the meeting, not a setting this app can read, so the only reliable signal is - * the user's own answer and it is asked for again on every session until they silence it. - */ - headphoneNoticeAcknowledged: boolean; } // Default runtime configuration @@ -66,8 +57,6 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { // opt-in: prose is what every existing user already expects from the panel professionalMode: false, - - headphoneNoticeAcknowledged: false, }; // interviewConf (full name, profile, context) used to be cached under `runtime`, but it's now @@ -266,9 +255,6 @@ export const configStore = new ConfigStore(); if (raw?.professionalMode === undefined) { migration.professionalMode = false; } - if (raw?.headphoneNoticeAcknowledged === undefined) { - migration.headphoneNoticeAcknowledged = false; - } // perform migration only if there are values to set if (Object.keys(migration).length > 0) { configStore.updateConfig(migration); diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index cea314b1..34234d41 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -144,13 +144,10 @@ export default function ControlPanel() { // Before the permission gate, and before anything opens a socket: on speakers the echo is // already in the audio by the time the first question is asked, and the failure it causes - // is silent. Nothing here can detect the output route, so the user is asked. - if (!config?.headphoneNoticeAcknowledged) { - setHeadphoneNoticeOpen(true); - return; - } - - await startAfterNotice(); + // is silent. Nothing here can detect the output route, so the user is asked - every session, + // since whether the call is on speakers is a property of the machine and the meeting, not a + // setting that stays true once answered. + setHeadphoneNoticeOpen(true); }; const stateConfig: Record = { diff --git a/src/renderer/components/custom/headphone-notice-dialog.tsx b/src/renderer/components/custom/headphone-notice-dialog.tsx index 6a08fb05..7966a05b 100644 --- a/src/renderer/components/custom/headphone-notice-dialog.tsx +++ b/src/renderer/components/custom/headphone-notice-dialog.tsx @@ -1,8 +1,6 @@ import { Headphones, MicOff, Volume2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, @@ -11,7 +9,6 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { useConfigStore } from '@/hooks/use-config-store'; interface HeadphoneNoticeDialogProps { open: boolean; @@ -31,30 +28,18 @@ interface HeadphoneNoticeDialogProps { * There is no reliable way to detect this from the renderer - `enumerateDevices()` reports what * exists, not what the sound is coming out of - so the user's own answer is the only signal * available, and it is asked for rather than guessed at. + * + * Shown before every session, deliberately with no "don't show again" - whether the call is on + * speakers is a property of the machine and the meeting, not a setting, and it can change between + * any two sessions on the same install. A permanent silence option contradicted that: the one + * fact this dialog exists to establish was the one fact a stale tick could no longer speak to. */ export default function HeadphoneNoticeDialog({ open, onOpenChange, onProceed, }: HeadphoneNoticeDialogProps) { - const { config, updateConfig } = useConfigStore(); - const [dontShowAgain, setDontShowAgain] = useState(false); - - // The dialog is mounted for the life of the control panel, so the tick would otherwise - // survive a Cancel and be waiting - already checked - the next time it opens. Silencing a - // warning is then one click the user did not knowingly make. - useEffect(() => { - if (open) setDontShowAgain(false); - }, [open]); - const handleProceed = () => { - // Persisted on the way through rather than on the tick, so a user who changes their mind and - // cancels has not already silenced a warning they never acted on. - if (dontShowAgain && !config?.headphoneNoticeAcknowledged) { - updateConfig({ headphoneNoticeAcknowledged: true }).catch((e) => - console.error('Failed to persist the headphone notice preference', e) - ); - } onOpenChange(false); onProceed(); }; @@ -84,14 +69,6 @@ export default function HeadphoneNoticeDialog({ /> - - + + +
+ + +
+ + + + ); +} diff --git a/src/renderer/pages/mock-interview/session.tsx b/src/renderer/pages/mock-interview/session.tsx new file mode 100644 index 00000000..cb372ff1 --- /dev/null +++ b/src/renderer/pages/mock-interview/session.tsx @@ -0,0 +1,189 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useMicLevel } from '@/hooks/use-mic-level'; +import { mockTranscriptionService } from '@/services/mock-transcription.service'; +import type { MockInterviewSessionState } from '@/types/mock-interview'; +import { MockInterviewState } from '@/types/mock-interview'; + +interface SessionScreenProps { + session: MockInterviewSessionState; + onSkip: () => Promise; + onDone: () => Promise; + onRepeat: () => Promise; + onEnd: () => Promise; +} + +const THINKING_LABEL: Partial> = { + [MockInterviewState.Starting]: 'Starting…', + [MockInterviewState.Generating]: 'Thinking of the next question…', + [MockInterviewState.Evaluating]: 'Thinking…', + [MockInterviewState.Scoring]: 'Scoring the interview…', +}; + +export function SessionScreen({ session, onSkip, onDone, onRepeat, onEnd }: SessionScreenProps) { + const { state, currentQuestion, setup, questionNumber, currentAnswerText } = session; + const [busy, setBusy] = useState<'skip' | 'done' | 'end' | 'repeat' | null>(null); + const [answerReady, setAnswerReady] = useState(currentQuestion?.hasAudio ?? true); + const levelRingRef = useRef(null); + const levelRef = useMicLevel(mockTranscriptionService.getStream()); + + // Reset the "I'm ready" gate whenever the on-screen question actually changes. + const questionKey = currentQuestion?.text ?? ''; + useEffect(() => { + setAnswerReady(currentQuestion?.hasAudio ?? true); + }, [questionKey, currentQuestion?.hasAudio]); + + // Live level ring, written directly to the DOM so this does not re-render at animation-frame + // rate - see use-mic-level.ts. + useEffect(() => { + if (state !== MockInterviewState.Listening) return; + let raf = 0; + const tick = () => { + const scale = 1 + Math.min(levelRef.current, 1) * 0.4; + if (levelRingRef.current) { + levelRingRef.current.style.transform = `scale(${scale})`; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [state, levelRef]); + + const withBusy = (key: typeof busy, action: () => Promise) => async () => { + setBusy(key); + try { + await action(); + } finally { + setBusy(null); + } + }; + + const isFollowUp = currentQuestion?.isFollowUp ?? false; + const totalQuestions = setup?.question_count ?? 0; + const progressValue = totalQuestions > 0 ? (questionNumber / totalQuestions) * 100 : 0; + const showReadyPrompt = + state === MockInterviewState.Listening && currentQuestion && !currentQuestion.hasAudio && !answerReady; + const isThinking = state in THINKING_LABEL; + const canControl = state === MockInterviewState.Speaking || state === MockInterviewState.Listening; + + return ( +
+
+
+
+ + {isFollowUp ? 'Follow-up' : `Question ${questionNumber} of ${totalQuestions}`} + + {isFollowUp && Follow-up} +
+ +
+ + + +
+ {state === MockInterviewState.Speaking && ( + + ); +} diff --git a/src/renderer/pages/mock-interview/setup.tsx b/src/renderer/pages/mock-interview/setup.tsx new file mode 100644 index 00000000..69887c41 --- /dev/null +++ b/src/renderer/pages/mock-interview/setup.tsx @@ -0,0 +1,243 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; + +import HeadphoneNoticeDialog from '@/components/custom/headphone-notice-dialog'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useAppState } from '@/hooks/use-app-state'; +import { useAudioInputDevices } from '@/hooks/use-audio-devices'; +import { useConfigStore } from '@/hooks/use-config-store'; +import { useConfigurationDialog } from '@/hooks/use-configuration-dialog'; +import { getElectron } from '@/lib/utils'; +import { getLanguageOption } from '@/types/language'; +import type { MockInterviewSetup } from '@/types/mock-interview'; +import { MockDifficulty, MockSeniority } from '@/types/mock-interview'; + +const DIFFICULTIES: { value: MockDifficulty; label: string; description: string }[] = [ + { value: MockDifficulty.Easy, label: 'Warm-up', description: 'Straightforward questions, one clear ask each.' }, + { value: MockDifficulty.Standard, label: 'Standard', description: 'What an ordinary interviewer would actually ask.' }, + { value: MockDifficulty.Hard, label: 'Hard', description: 'Probing questions on trade-offs and edge cases.' }, +]; + +const SENIORITIES: { value: MockSeniority; label: string }[] = [ + { value: MockSeniority.Junior, label: 'Junior' }, + { value: MockSeniority.Mid, label: 'Mid-level' }, + { value: MockSeniority.Senior, label: 'Senior' }, + { value: MockSeniority.Staff, label: 'Staff+' }, +]; + +const QUESTION_COUNTS = [3, 5, 8, 12] as const; + +interface SetupScreenProps { + onStart: (setup: MockInterviewSetup) => Promise; +} + +export function SetupScreen({ onStart }: SetupScreenProps) { + const { appState } = useAppState(); + const { config } = useConfigStore(); + const { openConfigurationDialog } = useConfigurationDialog(); + const { devices: audioInputDevices, ready: audioDevicesReady } = useAudioInputDevices(); + + const [role, setRole] = useState(''); + const [seniority, setSeniority] = useState(MockSeniority.Mid); + const [difficulty, setDifficulty] = useState(MockDifficulty.Standard); + const [questionCount, setQuestionCount] = useState(8); + const [starting, setStarting] = useState(false); + const [headphoneNoticeOpen, setHeadphoneNoticeOpen] = useState(false); + + const language = config?.language; + const languageOption = getLanguageOption(language); + + const selectedAudioInputDeviceName = config?.audioInputDeviceName ?? ''; + const noAudioInputDevices = audioDevicesReady && audioInputDevices.length === 0; + const audioInputDeviceNotFound = + audioDevicesReady && + audioInputDevices.length > 0 && + selectedAudioInputDeviceName !== '' && + !audioInputDevices.some((d) => d.name === selectedAudioInputDeviceName); + + const checkCanStart = (): boolean => { + if (!appState?.interviewConfigLoaded) { + toast.error('Could not load your saved configuration. Reconnecting - try again in a moment.'); + void getElectron()?.account?.refresh(); + return false; + } + if (!appState?.interviewConfig?.fullName) { + toast.error('Full name is not set'); + openConfigurationDialog(); + return false; + } + if (!appState?.interviewConfig?.hasProfileData) { + toast.error('Profile data is not set'); + openConfigurationDialog(); + return false; + } + if (noAudioInputDevices) { + toast.error('No microphone was detected. Connect one and try again.'); + return false; + } + if (audioInputDeviceNotFound) { + toast.error(`Audio input device "${selectedAudioInputDeviceName}" is not found`); + return false; + } + if (!role.trim()) { + toast.error('Enter the role you are practicing for'); + return false; + } + return true; + }; + + const startAfterNotice = async () => { + setStarting(true); + try { + await onStart({ + role: role.trim(), + seniority, + difficulty, + question_count: questionCount, + }); + } catch (error) { + console.error('Failed to start mock interview:', error); + toast.error(error instanceof Error ? error.message : 'Failed to start the mock interview'); + } finally { + setStarting(false); + } + }; + + const handleStartClick = () => { + if (!checkCanStart()) return; + setHeadphoneNoticeOpen(true); + }; + + return ( +
+ + + Mock interview + + The AI asks, you answer out loud. Nothing is saved unless you export it. + + + + +
+ + setRole(e.target.value)} + /> +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + setDifficulty(v as MockDifficulty)} + className="grid grid-cols-1 gap-2 sm:grid-cols-3" + > + {DIFFICULTIES.map((d) => ( + + ))} + +
+ +
+ +
+
+ {languageOption.nativeName} + ({languageOption.name}) +
+ + {languageOption.hasVoice ? 'Voice' : 'Text only'} + +
+

+ Change the interview language from the main screen's language picker. +

+ {!languageOption.hasVoice && ( + + + The interviewer will write its questions instead of speaking them. You still + answer out loud, and the scoring is the same. + + + )} +
+
+ + + + +
+ + void startAfterNotice()} + /> +
+ ); +} diff --git a/src/renderer/router.tsx b/src/renderer/router.tsx index 0e8bf604..2c11cffb 100644 --- a/src/renderer/router.tsx +++ b/src/renderer/router.tsx @@ -7,6 +7,7 @@ import LoginPage from './pages/auth/login'; import SignupPage from './pages/auth/signup'; import IndexPage from './pages/index'; import MainPage from './pages/main/index'; +import MockInterviewPage from './pages/mock-interview/index'; import PaymentPage from './pages/payment'; // MainFrame is a layout route rather than a wrapper around RouterProvider so that the chrome it @@ -27,6 +28,10 @@ export const router = createHashRouter([ path: '/main', element: , }, + { + path: '/mock-interview', + element: , + }, { path: '/auth', element: , diff --git a/src/renderer/types/language.ts b/src/renderer/types/language.ts index 9a4f55b8..a5984b31 100644 --- a/src/renderer/types/language.ts +++ b/src/renderer/types/language.ts @@ -42,6 +42,12 @@ export interface LanguageOption { nativeName: string; /** Two letters for the control bar, so the current language is readable without opening it. */ short: string; + /** + * Whether Deepgram's Aura TTS can speak this language, mirroring the backend's + * `DEEPGRAM_TTS_VOICES`. Only read by the mock interview setup screen - the live picker has no + * use for it, since the live assistant never speaks. + */ + hasVoice: boolean; } /** @@ -51,36 +57,41 @@ export interface LanguageOption { * `short` is the uppercased ISO code rather than anything derived from the name, which is what * keeps it two characters wide for every entry - the trigger reserves that much and no more, and * a three-letter label there would push the control out of the bar's rhythm. + * + * One entry per line, deliberately: `language.test.mjs` parses this array with a regex matched + * against each entry as one line, and letting Prettier wrap the longer names onto several lines + * would silently drop them out of that check. */ +// prettier-ignore export const LANGUAGES: readonly LanguageOption[] = [ - { code: Language.English, name: 'English', nativeName: 'English', short: 'EN' }, - { code: Language.Spanish, name: 'Spanish', nativeName: 'Español', short: 'ES' }, - { code: Language.German, name: 'German', nativeName: 'Deutsch', short: 'DE' }, - { code: Language.French, name: 'French', nativeName: 'Français', short: 'FR' }, - { code: Language.Portuguese, name: 'Portuguese', nativeName: 'Português', short: 'PT' }, - { code: Language.Italian, name: 'Italian', nativeName: 'Italiano', short: 'IT' }, - { code: Language.Dutch, name: 'Dutch', nativeName: 'Nederlands', short: 'NL' }, - { code: Language.Polish, name: 'Polish', nativeName: 'Polski', short: 'PL' }, - { code: Language.Russian, name: 'Russian', nativeName: 'Русский', short: 'RU' }, - { code: Language.Ukrainian, name: 'Ukrainian', nativeName: 'Українська', short: 'UK' }, - { code: Language.Czech, name: 'Czech', nativeName: 'Čeština', short: 'CS' }, - { code: Language.Romanian, name: 'Romanian', nativeName: 'Română', short: 'RO' }, - { code: Language.Greek, name: 'Greek', nativeName: 'Ελληνικά', short: 'EL' }, - { code: Language.Hungarian, name: 'Hungarian', nativeName: 'Magyar', short: 'HU' }, - { code: Language.Swedish, name: 'Swedish', nativeName: 'Svenska', short: 'SV' }, - { code: Language.Danish, name: 'Danish', nativeName: 'Dansk', short: 'DA' }, - { code: Language.Norwegian, name: 'Norwegian', nativeName: 'Norsk', short: 'NO' }, - { code: Language.Finnish, name: 'Finnish', nativeName: 'Suomi', short: 'FI' }, - { code: Language.Turkish, name: 'Turkish', nativeName: 'Türkçe', short: 'TR' }, - { code: Language.Hindi, name: 'Hindi', nativeName: 'हिन्दी', short: 'HI' }, - { code: Language.Japanese, name: 'Japanese', nativeName: '日本語', short: 'JA' }, - { code: Language.Korean, name: 'Korean', nativeName: '한국어', short: 'KO' }, - { code: Language.Chinese, name: 'Chinese', nativeName: '中文', short: 'ZH' }, - { code: Language.Vietnamese, name: 'Vietnamese', nativeName: 'Tiếng Việt', short: 'VI' }, - { code: Language.Thai, name: 'Thai', nativeName: 'ไทย', short: 'TH' }, - { code: Language.Indonesian, name: 'Indonesian', nativeName: 'Bahasa Indonesia', short: 'ID' }, - { code: Language.Arabic, name: 'Arabic', nativeName: 'العربية', short: 'AR' }, - { code: Language.Hebrew, name: 'Hebrew', nativeName: 'עברית', short: 'HE' }, + { code: Language.English, name: 'English', nativeName: 'English', short: 'EN', hasVoice: true }, + { code: Language.Spanish, name: 'Spanish', nativeName: 'Español', short: 'ES', hasVoice: true }, + { code: Language.German, name: 'German', nativeName: 'Deutsch', short: 'DE', hasVoice: true }, + { code: Language.French, name: 'French', nativeName: 'Français', short: 'FR', hasVoice: true }, + { code: Language.Portuguese, name: 'Portuguese', nativeName: 'Português', short: 'PT', hasVoice: false }, + { code: Language.Italian, name: 'Italian', nativeName: 'Italiano', short: 'IT', hasVoice: true }, + { code: Language.Dutch, name: 'Dutch', nativeName: 'Nederlands', short: 'NL', hasVoice: true }, + { code: Language.Polish, name: 'Polish', nativeName: 'Polski', short: 'PL', hasVoice: false }, + { code: Language.Russian, name: 'Russian', nativeName: 'Русский', short: 'RU', hasVoice: false }, + { code: Language.Ukrainian, name: 'Ukrainian', nativeName: 'Українська', short: 'UK', hasVoice: false }, + { code: Language.Czech, name: 'Czech', nativeName: 'Čeština', short: 'CS', hasVoice: false }, + { code: Language.Romanian, name: 'Romanian', nativeName: 'Română', short: 'RO', hasVoice: false }, + { code: Language.Greek, name: 'Greek', nativeName: 'Ελληνικά', short: 'EL', hasVoice: false }, + { code: Language.Hungarian, name: 'Hungarian', nativeName: 'Magyar', short: 'HU', hasVoice: false }, + { code: Language.Swedish, name: 'Swedish', nativeName: 'Svenska', short: 'SV', hasVoice: false }, + { code: Language.Danish, name: 'Danish', nativeName: 'Dansk', short: 'DA', hasVoice: false }, + { code: Language.Norwegian, name: 'Norwegian', nativeName: 'Norsk', short: 'NO', hasVoice: false }, + { code: Language.Finnish, name: 'Finnish', nativeName: 'Suomi', short: 'FI', hasVoice: false }, + { code: Language.Turkish, name: 'Turkish', nativeName: 'Türkçe', short: 'TR', hasVoice: false }, + { code: Language.Hindi, name: 'Hindi', nativeName: 'हिन्दी', short: 'HI', hasVoice: false }, + { code: Language.Japanese, name: 'Japanese', nativeName: '日本語', short: 'JA', hasVoice: true }, + { code: Language.Korean, name: 'Korean', nativeName: '한국어', short: 'KO', hasVoice: false }, + { code: Language.Chinese, name: 'Chinese', nativeName: '中文', short: 'ZH', hasVoice: false }, + { code: Language.Vietnamese, name: 'Vietnamese', nativeName: 'Tiếng Việt', short: 'VI', hasVoice: false }, + { code: Language.Thai, name: 'Thai', nativeName: 'ไทย', short: 'TH', hasVoice: false }, + { code: Language.Indonesian, name: 'Indonesian', nativeName: 'Bahasa Indonesia', short: 'ID', hasVoice: false }, + { code: Language.Arabic, name: 'Arabic', nativeName: 'العربية', short: 'AR', hasVoice: false }, + { code: Language.Hebrew, name: 'Hebrew', nativeName: 'עברית', short: 'HE', hasVoice: false }, ]; const BY_CODE = new Map(LANGUAGES.map((option) => [option.code, option])); From c5c8902740f463fd023f13caa80849fad458edb2 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 31 Aug 2026 09:42:32 -0400 Subject: [PATCH 07/26] feat(mock-interview): add report export and fix the close-guard dispatch bug - export-mock-markdown.ts: builds the mock report the same way export-markdown.ts builds the live one, and carries the same no-electron-import constraint so a .test.mjs can load it directly. Reads report.questions when scoring succeeded (it carries scores and stronger answers) and falls back to the raw answers when it did not - the transcript is still worth exporting even when the model failed to score it. - export-labels.ts: every language gains mockInterview/question/yourAnswer/score/ strengths/gaps/strongerAnswer, for the same reason the existing five fields are translated at all - the report is handed to someone who was not there and may not read English. - export-markdown.ts: generateExportFilename takes an optional prefix (defaulted so the existing caller is unchanged), so a mock report and a live interview export don't collide in a downloads folder under the same "report-" name. - tools.service.ts: exportMockReport(), guarded on the same "answers with real content" standard hasMockContent uses. - save-history-dialog.tsx: now dispatches to exportTranscript or exportMockReport by which subject actually has content. This is a real bug fix, not just plumbing - the close guard was already widened (a previous commit) to fire on hasHistory || hasMockContent, and this dialog would otherwise have called exportTranscript unconditionally, which throws "There is nothing to export yet" for a mock-only session. --- src/main/ipc/tools.ts | 3 + src/main/services/tools.service.ts | 52 +++++ src/main/utils/export-labels.ts | 215 +++++++++++++++++- src/main/utils/export-markdown.ts | 4 +- src/main/utils/export-mock-markdown.ts | 78 +++++++ .../components/custom/save-history-dialog.tsx | 14 +- src/renderer/hooks/use-tools.tsx | 17 ++ 7 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 src/main/utils/export-mock-markdown.ts diff --git a/src/main/ipc/tools.ts b/src/main/ipc/tools.ts index 7ef267f7..0412a881 100644 --- a/src/main/ipc/tools.ts +++ b/src/main/ipc/tools.ts @@ -8,6 +8,9 @@ export function registerToolsHandlers(): void { ipcMain.handle('tools:export-transcript', async (_event, format: ExportFormat = 'docx') => { return toolsService.exportTranscript(format); }); + ipcMain.handle('tools:export-mock-report', async (_event, format: ExportFormat = 'docx') => { + return toolsService.exportMockReport(format); + }); ipcMain.handle('tools:clear-all', async () => { await toolsService.clearAll(); }); diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 42d9ca67..6ee235ac 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -7,7 +7,9 @@ import { configStore } from '../store/config.store.js'; import { ExportFormat } from '../types/export.js'; import { GenerateSummarizeRequest } from '../types/llm.js'; import { buildExportMarkdown, generateExportFilename } from '../utils/export-markdown.js'; +import { buildMockExportMarkdown } from '../utils/export-mock-markdown.js'; import { appStateService } from './app-state.service.js'; +import { mockInterviewService } from './mock-interview.service.js'; import { actionSuggestionService } from './suggestion-action.service.js'; import { liveSuggestionService } from './suggestion-live.service.js'; import { transcriptService } from './transcript.service.js'; @@ -87,6 +89,56 @@ class ToolsService { return filePath; } + /** + * Export the mock interview report - own guard, own filename prefix, own save dialog. + * + * Guarded on real answer content, the same standard `hasMockContent` uses, so this cannot bill + * nothing into a document titled as a record of an interview that did not happen. + */ + async exportMockReport(format: ExportFormat = 'docx'): Promise { + const session = mockInterviewService.getState(); + const hasContent = session.answers.some((a) => !a.skipped && a.answer.trim().length > 0); + if (!hasContent || !session.setup) { + throw new Error('There is nothing to export yet. Answer at least one question first.'); + } + + const conf = configStore.getConfig(); + const fullMarkdown = buildMockExportMarkdown({ + setup: session.setup, + answers: session.answers, + report: session.report, + language: conf.language, + }); + + const isMarkdown = format === 'md'; + + const { canceled, filePath } = await dialog.showSaveDialog({ + title: 'Save Mock Interview Report', + defaultPath: generateExportFilename(format, 'mock-interview'), + filters: isMarkdown + ? [{ name: 'Markdown', extensions: ['md'] }] + : [{ name: 'Word Document', extensions: ['docx'] }], + }); + + if (canceled || !filePath) return null; + + if (isMarkdown) { + await fs.writeFile(filePath, fullMarkdown, 'utf8'); + return filePath; + } + + const docxBlob = await convertMarkdownToDocx(fullMarkdown, { + documentType: 'document', + style: { + heading1Alignment: 'CENTER', + heading5Alignment: 'CENTER', + }, + }); + + await fs.writeFile(filePath, Buffer.from(await docxBlob.arrayBuffer())); + return filePath; + } + async clearAll(): Promise { // Clear in-memory state transcriptService.clear(); diff --git a/src/main/utils/export-labels.ts b/src/main/utils/export-labels.ts index e97b27d4..1e809873 100644 --- a/src/main/utils/export-labels.ts +++ b/src/main/utils/export-labels.ts @@ -15,8 +15,9 @@ import { DEFAULT_LANGUAGE, Language } from '../types/language.js'; * button on a Spanish interview is an inconvenience to one person for one session; the report is * handed to someone who was not there, and may not read English at all. * - * Five nouns, and no attempt at a general localisation layer. The rest of the document is the - * candidate's name, timestamps and text that already arrived in the right language. + * The mock-interview fields below follow it for the same reason: `export-mock-markdown.ts` builds + * a report from the model's own scored output (already in the interview's language) plus these + * five nouns and two labels wrapped around it. */ export interface ExportLabels { /** Heading over the full transcript section. */ @@ -29,6 +30,20 @@ export interface ExportLabels { interviewer: string; /** Label on the export timestamp under the report title. */ dateTime: string; + /** Title of the mock-interview report document. */ + mockInterview: string; + /** Label before each question in the mock-interview report. */ + question: string; + /** Label before the candidate's transcribed answer. */ + yourAnswer: string; + /** Label before a per-question or overall numeric score. */ + score: string; + /** Heading over the report's strengths list. */ + strengths: string; + /** Heading over the report's gaps list. */ + gaps: string; + /** Label before the model's rewritten, stronger version of an answer. */ + strongerAnswer: string; } const LABELS: Record = { @@ -38,6 +53,13 @@ const LABELS: Record = { suggestion: 'Suggestion', interviewer: 'Interviewer', dateTime: 'Date/Time', + mockInterview: 'Mock Interview', + question: 'Question', + yourAnswer: 'Your Answer', + score: 'Score', + strengths: 'Strengths', + gaps: 'Gaps', + strongerAnswer: 'Stronger Answer', }, [Language.Spanish]: { transcripts: 'Transcripciones', @@ -45,6 +67,13 @@ const LABELS: Record = { suggestion: 'Sugerencia', interviewer: 'Entrevistador', dateTime: 'Fecha y hora', + mockInterview: 'Entrevista Simulada', + question: 'Pregunta', + yourAnswer: 'Tu Respuesta', + score: 'Puntuación', + strengths: 'Fortalezas', + gaps: 'Áreas de Mejora', + strongerAnswer: 'Respuesta Más Sólida', }, [Language.German]: { transcripts: 'Transkripte', @@ -52,6 +81,13 @@ const LABELS: Record = { suggestion: 'Vorschlag', interviewer: 'Interviewer', dateTime: 'Datum/Uhrzeit', + mockInterview: 'Übungsinterview', + question: 'Frage', + yourAnswer: 'Deine Antwort', + score: 'Bewertung', + strengths: 'Stärken', + gaps: 'Schwächen', + strongerAnswer: 'Stärkere Antwort', }, [Language.French]: { transcripts: 'Transcriptions', @@ -59,6 +95,13 @@ const LABELS: Record = { suggestion: 'Suggestion', interviewer: 'Intervieweur', dateTime: 'Date/heure', + mockInterview: 'Entretien Simulé', + question: 'Question', + yourAnswer: 'Votre Réponse', + score: 'Score', + strengths: 'Points Forts', + gaps: 'Points à Améliorer', + strongerAnswer: 'Réponse Plus Solide', }, [Language.Portuguese]: { transcripts: 'Transcrições', @@ -66,6 +109,13 @@ const LABELS: Record = { suggestion: 'Sugestão', interviewer: 'Entrevistador', dateTime: 'Data/hora', + mockInterview: 'Entrevista Simulada', + question: 'Pergunta', + yourAnswer: 'Sua Resposta', + score: 'Pontuação', + strengths: 'Pontos Fortes', + gaps: 'Pontos a Melhorar', + strongerAnswer: 'Resposta Mais Forte', }, [Language.Italian]: { transcripts: 'Trascrizioni', @@ -73,6 +123,13 @@ const LABELS: Record = { suggestion: 'Suggerimento', interviewer: 'Intervistatore', dateTime: 'Data/ora', + mockInterview: 'Colloquio Simulato', + question: 'Domanda', + yourAnswer: 'La Tua Risposta', + score: 'Punteggio', + strengths: 'Punti di Forza', + gaps: 'Aree di Miglioramento', + strongerAnswer: 'Risposta Più Forte', }, [Language.Dutch]: { transcripts: 'Transcripties', @@ -80,6 +137,13 @@ const LABELS: Record = { suggestion: 'Suggestie', interviewer: 'Interviewer', dateTime: 'Datum/tijd', + mockInterview: 'Proefinterview', + question: 'Vraag', + yourAnswer: 'Jouw Antwoord', + score: 'Score', + strengths: 'Sterke Punten', + gaps: 'Verbeterpunten', + strongerAnswer: 'Sterker Antwoord', }, [Language.Polish]: { transcripts: 'Transkrypcje', @@ -87,6 +151,13 @@ const LABELS: Record = { suggestion: 'Sugestia', interviewer: 'Prowadzący rozmowę', dateTime: 'Data/godzina', + mockInterview: 'Symulacja Rozmowy Kwalifikacyjnej', + question: 'Pytanie', + yourAnswer: 'Twoja Odpowiedź', + score: 'Wynik', + strengths: 'Mocne Strony', + gaps: 'Obszary do Poprawy', + strongerAnswer: 'Mocniejsza Odpowiedź', }, [Language.Russian]: { transcripts: 'Расшифровки', @@ -94,6 +165,13 @@ const LABELS: Record = { suggestion: 'Подсказка', interviewer: 'Интервьюер', dateTime: 'Дата и время', + mockInterview: 'Пробное Собеседование', + question: 'Вопрос', + yourAnswer: 'Ваш Ответ', + score: 'Оценка', + strengths: 'Сильные Стороны', + gaps: 'Слабые Стороны', + strongerAnswer: 'Более Сильный Ответ', }, [Language.Ukrainian]: { transcripts: 'Розшифровки', @@ -101,6 +179,13 @@ const LABELS: Record = { suggestion: 'Підказка', interviewer: 'Інтерв’юер', dateTime: 'Дата й час', + mockInterview: 'Пробна Співбесіда', + question: 'Питання', + yourAnswer: 'Ваша Відповідь', + score: 'Оцінка', + strengths: 'Сильні Сторони', + gaps: 'Слабкі Сторони', + strongerAnswer: 'Сильніша Відповідь', }, [Language.Czech]: { transcripts: 'Přepisy', @@ -108,6 +193,13 @@ const LABELS: Record = { suggestion: 'Návrh', interviewer: 'Tazatel', dateTime: 'Datum a čas', + mockInterview: 'Zkušební Pohovor', + question: 'Otázka', + yourAnswer: 'Vaše Odpověď', + score: 'Skóre', + strengths: 'Silné Stránky', + gaps: 'Slabé Stránky', + strongerAnswer: 'Silnější Odpověď', }, [Language.Romanian]: { transcripts: 'Transcrieri', @@ -115,6 +207,13 @@ const LABELS: Record = { suggestion: 'Sugestie', interviewer: 'Intervievator', dateTime: 'Data și ora', + mockInterview: 'Interviu Simulat', + question: 'Întrebare', + yourAnswer: 'Răspunsul Tău', + score: 'Scor', + strengths: 'Puncte Forte', + gaps: 'Puncte Slabe', + strongerAnswer: 'Răspuns Mai Puternic', }, [Language.Greek]: { transcripts: 'Μεταγραφές', @@ -122,6 +221,13 @@ const LABELS: Record = { suggestion: 'Πρόταση', interviewer: 'Συνεντευκτής', dateTime: 'Ημερομηνία/ώρα', + mockInterview: 'Δοκιμαστική Συνέντευξη', + question: 'Ερώτηση', + yourAnswer: 'Η Απάντησή σας', + score: 'Βαθμολογία', + strengths: 'Δυνατά Σημεία', + gaps: 'Σημεία Βελτίωσης', + strongerAnswer: 'Ισχυρότερη Απάντηση', }, [Language.Hungarian]: { transcripts: 'Átiratok', @@ -129,6 +235,13 @@ const LABELS: Record = { suggestion: 'Javaslat', interviewer: 'Kérdező', dateTime: 'Dátum/idő', + mockInterview: 'Próbainterjú', + question: 'Kérdés', + yourAnswer: 'Az Ön Válasza', + score: 'Pontszám', + strengths: 'Erősségek', + gaps: 'Fejlesztendő Területek', + strongerAnswer: 'Erősebb Válasz', }, [Language.Swedish]: { transcripts: 'Transkriptioner', @@ -136,6 +249,13 @@ const LABELS: Record = { suggestion: 'Förslag', interviewer: 'Intervjuare', dateTime: 'Datum/tid', + mockInterview: 'Övningsintervju', + question: 'Fråga', + yourAnswer: 'Ditt Svar', + score: 'Poäng', + strengths: 'Styrkor', + gaps: 'Förbättringsområden', + strongerAnswer: 'Starkare Svar', }, [Language.Danish]: { transcripts: 'Transskriptioner', @@ -143,6 +263,13 @@ const LABELS: Record = { suggestion: 'Forslag', interviewer: 'Interviewer', dateTime: 'Dato/klokkeslæt', + mockInterview: 'Prøveinterview', + question: 'Spørgsmål', + yourAnswer: 'Dit Svar', + score: 'Score', + strengths: 'Styrker', + gaps: 'Forbedringspunkter', + strongerAnswer: 'Stærkere Svar', }, [Language.Norwegian]: { transcripts: 'Transkripsjoner', @@ -150,6 +277,13 @@ const LABELS: Record = { suggestion: 'Forslag', interviewer: 'Intervjuer', dateTime: 'Dato/tid', + mockInterview: 'Prøveintervju', + question: 'Spørsmål', + yourAnswer: 'Ditt Svar', + score: 'Poengsum', + strengths: 'Styrker', + gaps: 'Forbedringspunkter', + strongerAnswer: 'Sterkere Svar', }, [Language.Finnish]: { transcripts: 'Litteroinnit', @@ -157,6 +291,13 @@ const LABELS: Record = { suggestion: 'Ehdotus', interviewer: 'Haastattelija', dateTime: 'Päivämäärä/aika', + mockInterview: 'Harjoitushaastattelu', + question: 'Kysymys', + yourAnswer: 'Vastauksesi', + score: 'Pisteet', + strengths: 'Vahvuudet', + gaps: 'Kehityskohteet', + strongerAnswer: 'Vahvempi Vastaus', }, [Language.Turkish]: { transcripts: 'Transkriptler', @@ -164,6 +305,13 @@ const LABELS: Record = { suggestion: 'Öneri', interviewer: 'Görüşmeci', dateTime: 'Tarih/saat', + mockInterview: 'Deneme Mülakatı', + question: 'Soru', + yourAnswer: 'Cevabınız', + score: 'Puan', + strengths: 'Güçlü Yönler', + gaps: 'Geliştirilmesi Gereken Yönler', + strongerAnswer: 'Daha Güçlü Cevap', }, [Language.Hindi]: { transcripts: 'प्रतिलेख', @@ -171,6 +319,13 @@ const LABELS: Record = { suggestion: 'सुझाव', interviewer: 'साक्षात्कारकर्ता', dateTime: 'दिनांक/समय', + mockInterview: 'मॉक इंटरव्यू', + question: 'प्रश्न', + yourAnswer: 'आपका उत्तर', + score: 'स्कोर', + strengths: 'ताकतें', + gaps: 'सुधार के क्षेत्र', + strongerAnswer: 'बेहतर उत्तर', }, [Language.Japanese]: { transcripts: '文字起こし', @@ -178,6 +333,13 @@ const LABELS: Record = { suggestion: '提案', interviewer: '面接官', dateTime: '日時', + mockInterview: '模擬面接', + question: '質問', + yourAnswer: 'あなたの回答', + score: 'スコア', + strengths: '強み', + gaps: '改善点', + strongerAnswer: 'より良い回答', }, [Language.Korean]: { transcripts: '대화록', @@ -185,6 +347,13 @@ const LABELS: Record = { suggestion: '제안', interviewer: '면접관', dateTime: '날짜/시간', + mockInterview: '모의 면접', + question: '질문', + yourAnswer: '답변', + score: '점수', + strengths: '강점', + gaps: '개선점', + strongerAnswer: '더 나은 답변', }, [Language.Chinese]: { transcripts: '转录文本', @@ -192,6 +361,13 @@ const LABELS: Record = { suggestion: '建议', interviewer: '面试官', dateTime: '日期/时间', + mockInterview: '模拟面试', + question: '问题', + yourAnswer: '你的回答', + score: '得分', + strengths: '优势', + gaps: '待改进之处', + strongerAnswer: '更好的回答', }, [Language.Vietnamese]: { transcripts: 'Bản ghi', @@ -199,6 +375,13 @@ const LABELS: Record = { suggestion: 'Gợi ý', interviewer: 'Người phỏng vấn', dateTime: 'Ngày/giờ', + mockInterview: 'Phỏng Vấn Thử', + question: 'Câu Hỏi', + yourAnswer: 'Câu Trả Lời Của Bạn', + score: 'Điểm Số', + strengths: 'Điểm Mạnh', + gaps: 'Điểm Cần Cải Thiện', + strongerAnswer: 'Câu Trả Lời Tốt Hơn', }, [Language.Thai]: { transcripts: 'บทถอดเสียง', @@ -206,6 +389,13 @@ const LABELS: Record = { suggestion: 'ข้อเสนอแนะ', interviewer: 'ผู้สัมภาษณ์', dateTime: 'วันที่/เวลา', + mockInterview: 'การสัมภาษณ์จำลอง', + question: 'คำถาม', + yourAnswer: 'คำตอบของคุณ', + score: 'คะแนน', + strengths: 'จุดแข็ง', + gaps: 'จุดที่ควรปรับปรุง', + strongerAnswer: 'คำตอบที่ดีกว่า', }, [Language.Indonesian]: { transcripts: 'Transkrip', @@ -213,6 +403,13 @@ const LABELS: Record = { suggestion: 'Saran', interviewer: 'Pewawancara', dateTime: 'Tanggal/waktu', + mockInterview: 'Wawancara Simulasi', + question: 'Pertanyaan', + yourAnswer: 'Jawaban Anda', + score: 'Skor', + strengths: 'Kekuatan', + gaps: 'Area yang Perlu Ditingkatkan', + strongerAnswer: 'Jawaban yang Lebih Kuat', }, [Language.Arabic]: { transcripts: 'النصوص', @@ -220,6 +417,13 @@ const LABELS: Record = { suggestion: 'اقتراح', interviewer: 'المحاور', dateTime: 'التاريخ/الوقت', + mockInterview: 'مقابلة تجريبية', + question: 'السؤال', + yourAnswer: 'إجابتك', + score: 'النتيجة', + strengths: 'نقاط القوة', + gaps: 'نقاط التحسين', + strongerAnswer: 'إجابة أقوى', }, [Language.Hebrew]: { transcripts: 'תמלולים', @@ -227,6 +431,13 @@ const LABELS: Record = { suggestion: 'הצעה', interviewer: 'המראיין', dateTime: 'תאריך/שעה', + mockInterview: 'ראיון תרגול', + question: 'שאלה', + yourAnswer: 'התשובה שלך', + score: 'ציון', + strengths: 'חוזקות', + gaps: 'נקודות לשיפור', + strongerAnswer: 'תשובה חזקה יותר', }, }; diff --git a/src/main/utils/export-markdown.ts b/src/main/utils/export-markdown.ts index 8e2704da..d8a700d2 100644 --- a/src/main/utils/export-markdown.ts +++ b/src/main/utils/export-markdown.ts @@ -61,7 +61,7 @@ export function buildExportMarkdown({ return `${summaryPart}\n\n${transcripts.length > 0 ? transcriptsPart : ''}\n\n${suggestions.length > 0 ? suggestionsPart : ''}`.trim(); } -export function generateExportFilename(format: ExportFormat): string { +export function generateExportFilename(format: ExportFormat, prefix: string = 'report'): string { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); @@ -78,5 +78,5 @@ export function generateExportFilename(format: ExportFormat): string { // extension. const ext = format === 'md' ? 'md' : 'docx'; - return `report-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.${ext}`; + return `${prefix}-${yyyy}-${mm}-${dd}_${hh}-${min}-${ss}.${ext}`; } diff --git a/src/main/utils/export-mock-markdown.ts b/src/main/utils/export-mock-markdown.ts new file mode 100644 index 00000000..0192690e --- /dev/null +++ b/src/main/utils/export-mock-markdown.ts @@ -0,0 +1,78 @@ +import { Language } from '../types/language.js'; +import { + MockAnswer, + MockInterviewSetup, + MockQuestionScore, + MockReport, +} from '../types/mock-interview.js'; +import { getExportLabels } from './export-labels.js'; + +interface ExportMockMarkdownInput { + setup: MockInterviewSetup; + answers: MockAnswer[]; + report: MockReport | null; + language: Language; +} + +/** + * Builds the mock-interview report every export format is rendered from. + * + * Kept free of any `electron` import, the same constraint `export-markdown.ts` carries, so it + * stays loadable outside an Electron process for a `.test.mjs` file to exercise directly. + * + * Reads from `report.questions` when scoring succeeded (it carries the scores and the stronger + * answers) and falls back to the raw `answers` when it did not - the transcript is still worth + * exporting even when the model failed to score it. + */ +export function buildMockExportMarkdown({ + setup, + answers, + report, + language, +}: ExportMockMarkdownInput): string { + const labels = getExportLabels(language); + const datetimeNow = new Date().toLocaleString(); + + const lines: string[] = []; + lines.push(`# ${labels.mockInterview} - ${setup.role}`); + lines.push(`##### ${labels.dateTime}: ${datetimeNow}`); + lines.push(''); + + if (report) { + lines.push(`## ${labels.score}: ${report.overall_score}/100`); + lines.push(''); + if (report.strengths.length > 0) { + lines.push(`### ${labels.strengths}`); + for (const s of report.strengths) lines.push(`- ${s}`); + lines.push(''); + } + if (report.gaps.length > 0) { + lines.push(`### ${labels.gaps}`); + for (const g of report.gaps) lines.push(`- ${g}`); + lines.push(''); + } + } + + const entries: (MockQuestionScore | MockAnswer)[] = report?.questions.length + ? report.questions + : answers; + + entries.forEach((entry, index) => { + lines.push(`#### ${labels.question} ${index + 1}`); + lines.push(entry.question); + lines.push(''); + lines.push(`##### ${labels.yourAnswer}`); + lines.push(entry.answer || '-'); + lines.push(''); + if ('score' in entry) { + lines.push(`##### ${labels.score}: ${entry.score}/100`); + lines.push(entry.justification); + lines.push(''); + lines.push(`##### ${labels.strongerAnswer}`); + lines.push(entry.stronger_answer); + lines.push(''); + } + }); + + return lines.join('\n').trim(); +} diff --git a/src/renderer/components/custom/save-history-dialog.tsx b/src/renderer/components/custom/save-history-dialog.tsx index c97045bb..af130eb8 100644 --- a/src/renderer/components/custom/save-history-dialog.tsx +++ b/src/renderer/components/custom/save-history-dialog.tsx @@ -11,6 +11,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { useAppState } from '@/hooks/use-app-state'; import { type SaveHistoryReason, useSaveHistoryPrompt } from '@/hooks/use-save-history-guard'; import useTools from '@/hooks/use-tools'; import { getElectron } from '@/lib/utils'; @@ -55,9 +56,18 @@ const COPY: Record(null); + // Widened to trigger on either subject (see window-close-guard.ts), so it has to know which + // export to call. The two are mutually exclusive in the ordinary case - a live session and a + // mock one cannot run at the same time - but a live session left uncleared before a mock one + // starts can leave both true at once; that rare case prefers the live export, which is the + // subject this dialog has served the longest. + const isMockSubject = appState?.hasMockContent === true && appState?.hasHistory !== true; + const exportFn = isMockSubject ? exportMockReport : exportTranscript; + // Main vetoes a close that would lose the interview and asks here instead, so the window is // held open until one of these two replies is sent. Registered once, for the lifetime of the // app: the prompt can arrive at any moment and there is no component tied to closing. @@ -76,7 +86,7 @@ export default function SaveHistoryDialog() { const save = async (format: ExportFormat) => { setSaving(format); try { - const filePath = await exportTranscript(format); + const filePath = await exportFn(format); // Cancelled at the system save dialog. That is backing out of the file, not out of the // question, so the prompt stays up rather than reading as a decision to discard. if (!filePath) return; diff --git a/src/renderer/hooks/use-tools.tsx b/src/renderer/hooks/use-tools.tsx index 1d921fe3..a8eab96d 100644 --- a/src/renderer/hooks/use-tools.tsx +++ b/src/renderer/hooks/use-tools.tsx @@ -22,6 +22,22 @@ export default function useTools() { } }; + const exportMockReport = async (format: ExportFormat): Promise => { + setExporting(true); + try { + const electron = getElectron(); + if (!electron) { + throw new Error('Electron API not available'); + } + return await electron.tools.exportMockReport(format); + } catch (error) { + console.error('Failed to export mock interview report:', error); + throw error; + } finally { + setExporting(false); + } + }; + const clearAll = async () => { const electron = getElectron(); if (!electron) { @@ -41,6 +57,7 @@ export default function useTools() { return { exporting, exportTranscript, + exportMockReport, clearAll, setPlaceholderData, } as const; From 7c62e4fe4f2d488422905306f50d75c8ebc234f0 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 31 Aug 2026 09:43:11 -0400 Subject: [PATCH 08/26] test(mock-interview): cover the state machine, gate, isolation, and export Six new files, plus edits to language.test.mjs (hasVoice) and app-state.test.mjs (hasMockContent), all passing alongside the existing suite: - mock-interview-state.test.mjs: drives mockInterviewService through a real session via a fake global fetch rather than a mocked class, so every failure mode is a real HTTP response shape. Pins the terminal-state invariant directly - zero answers never reaches Finished, a failed report still reaches Finished with the transcript intact, a follow-up does not advance the question counter, isActive() is false at both terminal values and true everywhere between them (the exact signal the mutual- exclusion and action-suggestion guards key off). - mock-interview-gate.test.mjs: source-level, the same reason audio-device-switch. test.mjs is - renderer code with no runtime harness here. Pins acquire-before-play, release inside playQuestion's own finally, the watchdog armed at acquire and cleared at release, and the generation-token check preceding the unmute. - mock-transcription-isolation.test.mjs: source-level - never opens loopback capture, never calls the live transcription ingest channel, constructs exactly one AudioWsStream on ch_1. - mock-action-suggestion-block.test.mjs: drives runningState to Running *and* a mock session active at once - the adversarial case the explicit guard exists for, not just the ordinary Idle case that would pass on the emergent behaviour alone and prove nothing about it. - speech-chunks.test.mjs, mock-export.test.mjs (every language has the full extended label set). Writing these caught four real bugs before they shipped, on top of the rate-limiting one in the backend PR: a chunking merge that grouped sentences by checking the wrong neighbour, a Japanese merge that inserted an English-style space, an export test whose own expectation ignored the merge threshold it was testing against, and generateNextQuestion's fail-forward silently discarding the first-question failure a user needs to see. --- test/app-state.test.mjs | 51 ++++++ test/language.test.mjs | 13 +- test/mock-action-suggestion-block.test.mjs | 69 ++++++++ test/mock-export.test.mjs | 116 +++++++++++++ test/mock-interview-gate.test.mjs | 84 +++++++++ test/mock-interview-state.test.mjs | 192 +++++++++++++++++++++ test/mock-transcription-isolation.test.mjs | 58 +++++++ test/run.mjs | 11 ++ test/speech-chunks.test.mjs | 54 ++++++ 9 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 test/mock-action-suggestion-block.test.mjs create mode 100644 test/mock-export.test.mjs create mode 100644 test/mock-interview-gate.test.mjs create mode 100644 test/mock-interview-state.test.mjs create mode 100644 test/mock-transcription-isolation.test.mjs create mode 100644 test/speech-chunks.test.mjs diff --git a/test/app-state.test.mjs b/test/app-state.test.mjs index 52ccce99..63452227 100644 --- a/test/app-state.test.mjs +++ b/test/app-state.test.mjs @@ -104,5 +104,56 @@ export async function run() { sent.at(-1).payload.transcripts?.[0]?.text === 'Transcripts will be here' ); + // hasMockContent is derived the same way hasHistory is - stripped from incoming updates and + // recomputed from the mockInterview session, never trusted from the caller. It must also never + // contaminate hasHistory, since a close guard reading `hasHistory || hasMockContent` would + // otherwise treat the two subjects as one. + const mockSession = (answers) => ({ + state: 'listening', + setup: { role: 'Engineer', seniority: 'mid', difficulty: 'standard', question_count: 3 }, + currentQuestion: null, + questionNumber: 1, + answers, + currentAnswerText: '', + report: null, + reportError: null, + error: null, + }); + + appStateService.updateState({ mockInterview: mockSession([]) }); + check( + 'a mock session with no answers has no content', + appStateService.getState().hasMockContent === false + ); + check('and does not flip hasHistory', appStateService.getState().hasHistory === false); + + appStateService.updateState({ + mockInterview: mockSession([{ question: 'Q1', kind: 'technical', answer: '', skipped: true }]), + }); + check('a skipped question is not content', appStateService.getState().hasMockContent === false); + + appStateService.updateState({ + mockInterview: mockSession([ + { question: 'Q1', kind: 'technical', answer: 'A real answer', skipped: false }, + ]), + }); + check('a real answer is content', appStateService.getState().hasMockContent === true); + + appStateService.updateState({ hasMockContent: false }); + check( + 'an incoming hasMockContent is ignored', + appStateService.getState().hasMockContent === true + ); + + // The mock session carries no CV field at all - there is nothing to broadcast that resembles + // the live interviewConfig reduction, so the broadcast-size check above already covers it + // structurally. This confirms the type-level guarantee holds through updateState too. + check( + 'the mock session broadcasts with no profile_data field', + !('profile_data' in appStateService.getRendererState().mockInterview) + ); + + appStateService.updateState({ mockInterview: null }); + return failures; } diff --git a/test/language.test.mjs b/test/language.test.mjs index 3615e8fc..19a4c7f1 100644 --- a/test/language.test.mjs +++ b/test/language.test.mjs @@ -53,13 +53,14 @@ export async function run() { ); const entries = [ ...rendererSource.matchAll( - /\{ code: Language\.(\w+), name: '([^']*)', nativeName: '([^']*)', short: '([^']*)' \}/g + /\{ code: Language\.(\w+), name: '([^']*)', nativeName: '([^']*)', short: '([^']*)', hasVoice: (true|false) \}/g ), ].map((m) => ({ member: m[1], name: m[2], nativeName: m[3], short: m[4], + hasVoice: m[5] === 'true', code: memberCodes.get(m[1]), })); @@ -118,6 +119,16 @@ export async function run() { ['ja', 'zh', 'th'].every((code) => Object.values(Language).includes(code)) ); + // Deepgram's Aura TTS speaks 7 of these 28 languages, mirroring the backend's + // DEEPGRAM_TTS_VOICES map. Getting this wrong is quiet in both directions: a language wrongly + // marked hasVoice sends a mock-interview /speak request that always comes back empty, and one + // wrongly marked false denies a real voice to a user who has one. + const ttsLanguages = new Set(['en', 'es', 'de', 'fr', 'nl', 'it', 'ja']); + check( + 'hasVoice is exactly the 7 Aura-supported languages', + entries.every((entry) => entry.hasVoice === ttsLanguages.has(entry.code)) + ); + // The store is the single source for every consumer, so it is where an unknown code has to die. configStore.updateConfig({ language: 'de' }); check('a chosen language round-trips', configStore.getConfig().language === 'de'); diff --git a/test/mock-action-suggestion-block.test.mjs b/test/mock-action-suggestion-block.test.mjs new file mode 100644 index 00000000..7fde8d2a --- /dev/null +++ b/test/mock-action-suggestion-block.test.mjs @@ -0,0 +1,69 @@ +/** + * Action suggestions are blocked during a mock interview today only as a side effect of mock mode + * never setting `RunningState.Running` - all three entry points below gate on that first. That is + * emergent, not designed: the day mock interview reuses `RunningState` for its own purposes (a + * genuinely tempting refactor, since it would reuse the surface-hiding machinery), the four global + * hotkeys that reach these methods go live during practice with nothing failing anywhere. + * + * So this drives `runningState` to `Running` *and* a mock session active at the same time - the + * future state the explicit guard exists to catch - rather than only the ordinary Idle case, + * which would pass on the emergent behaviour alone and prove nothing about the guard. + */ +import { createChecker, loadMain } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('mock-action-suggestion-block'); + + const { appStateService } = await loadMain('services/app-state.service.js'); + const { actionSuggestionService } = await loadMain('services/suggestion-action.service.js'); + + const activeMockSession = { + state: 'listening', + setup: { role: 'Engineer', seniority: 'mid', difficulty: 'standard', question_count: 3 }, + currentQuestion: null, + questionNumber: 1, + answers: [], + currentAnswerText: '', + report: null, + reportError: null, + error: null, + }; + + // The adversarial case: RunningState forced to Running, as if mock mode had been changed to set + // it. If the explicit guard were ever removed, these three would go on to their old + // `runningState !== Running` check, find it satisfied, and proceed - taking a screenshot, + // billing a suggestion, mid-practice-session. + appStateService.updateState({ runningState: 'running', mockInterview: activeMockSession }); + + const before = actionSuggestionService.getSuggestions().length; + + await actionSuggestionService.clearImages(); + check('clearImages refuses during a mock interview even with runningState=Running', true); + + await actionSuggestionService.captureScreenshot(); + check( + 'captureScreenshot takes no screenshot during a mock interview', + actionSuggestionService.getSuggestions().length === before + ); + check( + 'and does not report having uploaded anything', + actionSuggestionService.hasUploadedImages() === false + ); + + await actionSuggestionService.startGenerateSuggestion(); + check( + 'startGenerateSuggestion refuses during a mock interview even with runningState=Running', + true + ); + + // Once the mock session ends, the ordinary RunningState gate takes back over - these are not + // permanently disabled by having run once during a mock session. + appStateService.updateState({ runningState: 'idle', mockInterview: null }); + await actionSuggestionService.captureScreenshot(); + check( + 'after the mock session ends, the ordinary Idle refusal still works', + actionSuggestionService.getSuggestions().length === before + ); + + return failures; +} diff --git a/test/mock-export.test.mjs b/test/mock-export.test.mjs new file mode 100644 index 00000000..4b46750c --- /dev/null +++ b/test/mock-export.test.mjs @@ -0,0 +1,116 @@ +/** + * The mock-interview report is assembled as Markdown the same way the live export is - see + * tools-export.test.mjs - and carries the same requirement that every language in the picker has + * a full set of labels, not just the ones exercised by hand during development. + */ +import { createChecker, loadMain } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('mock-export'); + + const { buildMockExportMarkdown } = await loadMain('utils/export-mock-markdown.js'); + const { getExportLabels } = await loadMain('utils/export-labels.js'); + const { Language } = await loadMain('types/language.js'); + + const setup = { + role: 'Backend Engineer', + seniority: 'mid', + difficulty: 'standard', + question_count: 2, + }; + + const withReport = buildMockExportMarkdown({ + setup, + answers: [ + { + question: 'Tell me about yourself.', + kind: 'behavioral', + answer: 'I am an engineer.', + skipped: false, + }, + ], + report: { + overall_score: 82, + strengths: ['Clear communication'], + gaps: ['Limited depth on scaling'], + questions: [ + { + question: 'Tell me about yourself.', + answer: 'I am an engineer.', + score: 82, + justification: 'Concise and relevant.', + stronger_answer: 'A fuller answer with more detail.', + }, + ], + }, + language: Language.English, + }); + + check( + 'titles the document with the role', + withReport.includes('Mock Interview - Backend Engineer') + ); + check('includes the overall score', withReport.includes('Score: 82/100')); + check('includes strengths', withReport.includes('Clear communication')); + check('includes gaps', withReport.includes('Limited depth on scaling')); + check('includes the question', withReport.includes('Tell me about yourself.')); + check('includes the stronger answer', withReport.includes('A fuller answer with more detail.')); + + // A failed report still exports the raw transcript - the terminal-state invariant's visible + // form. No score section, no stronger-answer section, but the Q&A itself must survive. + const withoutReport = buildMockExportMarkdown({ + setup, + answers: [ + { + question: 'Tell me about yourself.', + kind: 'behavioral', + answer: 'I am an engineer.', + skipped: false, + }, + ], + report: null, + language: Language.English, + }); + check( + 'a missing report still includes the question', + withoutReport.includes('Tell me about yourself.') + ); + check('a missing report still includes the answer', withoutReport.includes('I am an engineer.')); + check('a missing report has no score section', !withoutReport.includes('Score:')); + + // A skipped question has no answer text - '-' rather than an empty line, so the document does + // not read as truncated. + const withSkip = buildMockExportMarkdown({ + setup, + answers: [{ question: 'Skipped one.', kind: 'technical', answer: '', skipped: true }], + report: null, + language: Language.English, + }); + check('a skipped question renders a placeholder rather than nothing', withSkip.endsWith('\n-')); + + // Every language the picker offers needs the full extended label set - a missing field would + // fall back silently to undefined appearing in the document rather than throwing. + const requiredFields = [ + 'transcripts', + 'suggestions', + 'suggestion', + 'interviewer', + 'dateTime', + 'mockInterview', + 'question', + 'yourAnswer', + 'score', + 'strengths', + 'gaps', + 'strongerAnswer', + ]; + for (const code of Object.values(Language)) { + const labels = getExportLabels(code); + check( + `${code} has every export label`, + requiredFields.every((field) => typeof labels[field] === 'string' && labels[field].length > 0) + ); + } + + return failures; +} diff --git a/test/mock-interview-gate.test.mjs b/test/mock-interview-gate.test.mjs new file mode 100644 index 00000000..1454a836 --- /dev/null +++ b/test/mock-interview-gate.test.mjs @@ -0,0 +1,84 @@ +/** + * The transmit gate in `mock-tts.service.ts` is renderer code with no runtime harness here, the + * same reason `audio-device-switch.test.mjs` is source-level. The ordering it pins is what keeps + * a stranded mic mute impossible: acquire before play, release in a `finally` so every exit path + * reaches it, a watchdog armed at acquire and cleared at release, and a generation check that + * stops a superseded release from reopening a newer acquisition's gate. + */ +import { codeOnly, createChecker, methodBody, readSource } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('mock-interview-gate'); + + const source = readSource( + new URL('../src/renderer/services/mock-tts.service.ts', import.meta.url) + ); + + const acquireBody = methodBody(source, 'acquire(): number {'); + const releaseBody = methodBody(source, 'release(mySeq: number): void {'); + const forceReleaseBody = methodBody(source, 'forceReleaseNow(): void {'); + const playQuestionBody = methodBody( + source, + 'async playQuestion(chunks: string[]): Promise {' + ); + const playBlobBody = methodBody(source, 'private playBlob(blob: Blob): Promise {'); + + check('MicGate.acquire exists', acquireBody.length > 0); + check('MicGate.release exists', releaseBody.length > 0); + check('MicGate.forceReleaseNow exists', forceReleaseBody.length > 0); + check('playQuestion exists', playQuestionBody.length > 0); + check('playBlob exists', playBlobBody.length > 0); + + // The mechanism: mute before play, not after. + const acquireCall = playQuestionBody.indexOf('this.gate.acquire()'); + const loopStart = playQuestionBody.indexOf('for (let i = 0'); + check( + 'the gate is acquired before the playback loop starts', + acquireCall !== -1 && acquireCall < loopStart + ); + check('playBlob actually calls play()', playBlobBody.includes('audio.play()')); + + // The release is unconditional: it must be inside playQuestion's own finally, not only on the + // success path, so a synthesis failure, a decode error, or a superseding stop() all reach it. + const finallyIndex = playQuestionBody.lastIndexOf('finally {'); + const releaseCall = playQuestionBody.lastIndexOf('this.gate.release(mySeq)'); + check('playQuestion has a finally block', finallyIndex !== -1); + check('the release call is inside it', releaseCall !== -1 && releaseCall > finallyIndex); + + // The watchdog: armed the moment the mic is muted, covering an HTMLAudioElement that never + // fires `ended` or `error` - the one case the finally above cannot reach because nothing ever + // resolves the playBlob promise it is waiting on. + const muteCall = acquireBody.indexOf('this.track.enabled = false'); + const watchdogArm = acquireBody.indexOf('this.watchdogTimer = window.setTimeout'); + check('acquire mutes the track', muteCall !== -1); + check('acquire arms the watchdog', watchdogArm !== -1); + check('the watchdog is armed after muting', muteCall < watchdogArm); + + const watchdogClear = releaseBody.indexOf('window.clearTimeout(this.watchdogTimer)'); + check('release clears the watchdog', watchdogClear !== -1); + + // The generation token: a release presenting a stale token must not reopen a newer + // acquisition's gate. This is what makes a late `ended` from a superseded utterance harmless. + const staleGuard = releaseBody.indexOf('if (mySeq !== this.seq) return;'); + const unmuteCall = releaseBody.indexOf('this.track.enabled = true'); + check('release checks the token', staleGuard !== -1); + check('the stale-token check precedes the unmute', staleGuard !== -1 && staleGuard < unmuteCall); + + // forceReleaseNow bumps the token itself, so anything already in flight for the old token is + // invalidated - this is the state-driven belt use-mock-interview.ts calls. + check( + 'forceReleaseNow invalidates the current token before opening the mic', + codeOnly(forceReleaseBody).indexOf('this.seq += 1') < + codeOnly(forceReleaseBody).indexOf('this.track.enabled = true') + ); + + // stop() must abort in-flight playback (bumping playSeq so the loop's own checks return) as + // well as forcing the gate open - releasing the gate alone would leave the audio element + // playing with the mic back on, which is not a fix. + const stopBody = methodBody(source, 'stop(): void {'); + check('stop exists', stopBody.length > 0); + check('stop supersedes the playback loop', stopBody.includes('this.playSeq += 1')); + check('stop forces the gate open', stopBody.includes('this.gate.forceReleaseNow()')); + + return failures; +} diff --git a/test/mock-interview-state.test.mjs b/test/mock-interview-state.test.mjs new file mode 100644 index 00000000..a6b6d75f --- /dev/null +++ b/test/mock-interview-state.test.mjs @@ -0,0 +1,192 @@ +/** + * The mock interview's terminal-state invariant, the same one `use-assistant-service.ts` + * documents for `RunningState`: whatever fails on the way, the state always lands on `Idle` or + * `Finished`, and no control is left permanently disabled. + * + * `mockInterviewService` builds its own `MockInterviewApi`, so these drive it through a fake + * `globalThis.fetch` rather than mocking the class - every failure mode below is a real HTTP + * response shape (a 500, or a payload the schema would reject), not a stubbed method throwing on + * command. + * + * English always has an Aura voice, so every question this file generates enters `Speaking` + * before `Listening` - `toListening()` stands in for the renderer's `speechFinished()` report + * once TTS playback ends, and is a harmless no-op if the state already reached `Listening` on its + * own (the text-only path, not exercised here). + */ +import { createChecker, loadMain } from './helpers.mjs'; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +export async function run() { + const { check, failures } = createChecker('mock-interview-state'); + + const { mockInterviewService } = await loadMain('services/mock-interview.service.js'); + const { MockInterviewState } = await loadMain('types/mock-interview.js'); + + const originalFetch = globalThis.fetch; + const state = { + questionShouldFail: false, + turnDecision: { action: 'next', follow_up_question: '' }, + reportShouldFail: false, + }; + + globalThis.fetch = async (url) => { + const path = String(url); + if (path.includes('/mock-interview/question')) { + if (state.questionShouldFail) return jsonResponse({ detail: 'error' }, 500); + return jsonResponse({ text: 'A question', kind: 'technical' }); + } + if (path.includes('/mock-interview/turn')) { + return jsonResponse(state.turnDecision); + } + if (path.includes('/mock-interview/report')) { + if (state.reportShouldFail) return jsonResponse({ detail: 'error' }, 500); + return jsonResponse({ + overall_score: 80, + strengths: ['clear'], + gaps: ['depth'], + questions: [], + }); + } + return new Response(null, { status: 404 }); + }; + + const setup = { + role: 'Backend Engineer', + seniority: 'mid', + difficulty: 'standard', + question_count: 2, + }; + const toListening = () => mockInterviewService.speechFinished(); + + try { + // Zero-answer end must not produce a report - the mock analogue of the export guard. + await mockInterviewService.start(setup); + check( + 'starting leaves Idle', + mockInterviewService.getState().state !== MockInterviewState.Idle + ); + await mockInterviewService.endSession(); + check( + 'ending with zero answers lands on Idle, not Finished', + mockInterviewService.getState().state === MockInterviewState.Idle + ); + check('and produced no report', mockInterviewService.getState().report === null); + + // The ordinary path: one question, one answer, reaches Finished with a report. + mockInterviewService.clear(); + await mockInterviewService.start({ ...setup, question_count: 1 }); + await toListening(); + mockInterviewService.ingestAnswer('final', 'My real answer.'); + await mockInterviewService.answerFinished(); + check( + 'a single-question session reaches Finished', + mockInterviewService.getState().state === MockInterviewState.Finished + ); + check('with a report', mockInterviewService.getState().report !== null); + check('and no error', mockInterviewService.getState().reportError === null); + + // A failed report must not strand the session mid-Scoring - it still reaches Finished, with + // the transcript intact and an error saying the score could not be produced. + state.reportShouldFail = true; + mockInterviewService.clear(); + await mockInterviewService.start({ ...setup, question_count: 1 }); + await toListening(); + mockInterviewService.ingestAnswer('final', 'Another real answer.'); + await mockInterviewService.answerFinished(); + check( + 'a failed report still reaches Finished, not stuck in Scoring', + mockInterviewService.getState().state === MockInterviewState.Finished + ); + check('with reportError set', mockInterviewService.getState().reportError !== null); + check('and the transcript preserved', mockInterviewService.getState().answers.length === 1); + state.reportShouldFail = false; + + // A session that cannot generate even its first question must not be stuck in + // Starting/Generating - it has to fail back to Idle so the setup screen is reachable again. + state.questionShouldFail = true; + mockInterviewService.clear(); + let startError = null; + try { + await mockInterviewService.start(setup); + } catch (e) { + startError = e; + } + check('a start that cannot generate a question throws', startError !== null); + check( + 'and lands back on Idle rather than stuck in Generating', + mockInterviewService.getState().state === MockInterviewState.Idle + ); + state.questionShouldFail = false; + + // Skipping every question reaches Idle (nothing to score), and a skip must not itself count + // as content. + mockInterviewService.clear(); + await mockInterviewService.start({ ...setup, question_count: 2 }); + await toListening(); + await mockInterviewService.skipQuestion(); + check( + 'skipping the first question advances to the second', + mockInterviewService.getState().questionNumber === 2 + ); + await toListening(); + await mockInterviewService.skipQuestion(); + check( + 'skipping every question reaches Idle, not Finished', + mockInterviewService.getState().state === MockInterviewState.Idle + ); + + // End interview mid-session, with real content, still scores what was given. + mockInterviewService.clear(); + await mockInterviewService.start({ ...setup, question_count: 5 }); + await toListening(); + mockInterviewService.ingestAnswer('final', 'Answered before ending early.'); + await mockInterviewService.answerFinished(); + // Now on question 2 (question_count is 5), still Speaking/Listening either way - endSession + // must reach Finished from wherever the flow currently is. + await mockInterviewService.endSession(); + check( + 'ending mid-session with a real answer still reaches Finished', + mockInterviewService.getState().state === MockInterviewState.Finished + ); + + // A follow-up does not advance the question counter. + mockInterviewService.clear(); + state.turnDecision = { action: 'follow_up', follow_up_question: 'Can you say more?' }; + await mockInterviewService.start({ ...setup, question_count: 3 }); + await toListening(); + const beforeFollowUp = mockInterviewService.getState().questionNumber; + mockInterviewService.ingestAnswer('final', 'A vague answer.'); + await mockInterviewService.answerFinished(); + check( + 'a follow-up keeps the same question number', + mockInterviewService.getState().questionNumber === beforeFollowUp + ); + check( + 'and marks the current question as a follow-up', + mockInterviewService.getState().currentQuestion?.isFollowUp === true + ); + state.turnDecision = { action: 'next', follow_up_question: '' }; + + // isActive() must be false at both terminal values, and true everywhere in between - this is + // exactly the signal the action-suggestion block and startAssistant refusal key off. + mockInterviewService.clear(); + check('isActive() is false when Idle', mockInterviewService.isActive() === false); + await mockInterviewService.start({ ...setup, question_count: 1 }); + check('isActive() is true mid-session', mockInterviewService.isActive() === true); + await toListening(); + mockInterviewService.ingestAnswer('final', 'Last one.'); + await mockInterviewService.answerFinished(); + check('isActive() is false when Finished', mockInterviewService.isActive() === false); + } finally { + globalThis.fetch = originalFetch; + mockInterviewService.clear(); + } + + return failures; +} diff --git a/test/mock-transcription-isolation.test.mjs b/test/mock-transcription-isolation.test.mjs new file mode 100644 index 00000000..de3d49f1 --- /dev/null +++ b/test/mock-transcription-isolation.test.mjs @@ -0,0 +1,58 @@ +/** + * `mock-transcription.service.ts` must never capture loopback audio and must never write into + * the live transcript pipeline. Both are silent failures if they regress: capturing loopback + * would feed the interviewer's own TTS voice back in as "interviewer audio", reopening exactly + * the acoustic-feedback problem the transmit gate exists to solve, through a second door; and + * calling `transcription.ingest` would put a practice answer into `appState.transcripts`, flip + * `hasHistory`, and fire a live suggestion (and its cost) for an answer nobody asked the live + * assistant to hear. + * + * Source-level, the same reason `audio-device-switch.test.mjs` is: renderer code with no runtime + * harness here. + */ +import { codeOnly, createChecker, readSource } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('mock-transcription-isolation'); + + const raw = readSource( + new URL('../src/renderer/services/mock-transcription.service.ts', import.meta.url) + ); + const source = codeOnly(raw); + + check('never opens the loopback display capture', !source.includes('getDisplayMedia')); + check('never enables the loopback IPC bridge', !source.includes('enableLoopbackAudio')); + check( + 'never calls the live transcription ingest channel', + !source.includes('transcription.ingest') + ); + check('never starts the live transcription IPC session', !source.includes('transcription.start')); + + // The one channel it is allowed to touch is the shared auth-token setter - not session-specific + // state, just where the ASR relay reads its Bearer token from. + check( + 'still sets the session token for the ASR relay', + source.includes('transcription.setSessionToken') + ); + + // Ingests through the mock-specific channel instead. + check( + 'ingests through mockInterview.ingestAnswer', + source.includes('electron.mockInterview.ingestAnswer') + ); + + // Exactly one AudioWsStream, on ch_1 (mic) - never ch_0 (loopback). + const channelConstructions = [...source.matchAll(/new AudioWsStream\(/g)]; + check('constructs exactly one AudioWsStream', channelConstructions.length === 1); + check("that channel is 'ch_1'", source.includes("new AudioWsStream('ch_1'")); + check("it never constructs a 'ch_0' channel", !source.includes("'ch_0'")); + + // Composes the existing class rather than reimplementing capture - reusing AudioWsStream is + // what keeps its switchSeq/setStream race guards untouched by this file entirely. + check( + 'imports AudioWsStream from the live service rather than duplicating it', + source.includes("from './live-transcription.service'") && source.includes('AudioWsStream') + ); + + return failures; +} diff --git a/test/run.mjs b/test/run.mjs index 15700da6..f7119821 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -27,9 +27,20 @@ for (const module of [ // reads the same running state through its own copy of window-control. './running-surface.test.mjs', './tools-export.test.mjs', + './mock-export.test.mjs', // After tools-export: it drives the shared appStateService singleton through the placeholder // and back, which the export helpers above do not read. './save-history.test.mjs', + // Drives mockInterviewService through a real session via a fake global fetch - after + // save-history, which is the last test to depend on appStateService's placeholder state. + './mock-interview-state.test.mjs', + // Drives appStateService.runningState and mockInterview together, so it must run after + // mock-interview-state seeds no lasting mockInterview state of its own (mock-interview-state + // clears the service on every branch, and the service's own clear() resets appState too). + './mock-action-suggestion-block.test.mjs', + './mock-interview-gate.test.mjs', + './mock-transcription-isolation.test.mjs', + './speech-chunks.test.mjs', './audio-device-switch.test.mjs', './language-switch.test.mjs', './rtl-rendering.test.mjs', diff --git a/test/speech-chunks.test.mjs b/test/speech-chunks.test.mjs new file mode 100644 index 00000000..248e066e --- /dev/null +++ b/test/speech-chunks.test.mjs @@ -0,0 +1,54 @@ +/** + * `splitIntoSpeechChunks` decides the sentence boundaries the mock interview synthesizes and + * plays incrementally - a lookahead of one, so time-to-first-audio is the first sentence rather + * than the whole question. Getting a boundary wrong is not a crash, it is a chunk that clips a + * sentence mid-word or a chunk so short the TTS request overhead swamps the sentence itself. + */ +import { createChecker, loadMain } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('speech-chunks'); + + const { splitIntoSpeechChunks } = await loadMain('utils/speech-chunks.js'); + + check('empty input yields no chunks', splitIntoSpeechChunks('', 'en').length === 0); + check('whitespace-only input yields no chunks', splitIntoSpeechChunks(' ', 'en').length === 0); + + const twoSentences = splitIntoSpeechChunks( + 'Tell me about a time you disagreed with a coworker. How did you resolve the conflict?', + 'en' + ); + check('two real sentences split into two chunks', twoSentences.length === 2); + check('the first chunk keeps its terminal punctuation', twoSentences[0].endsWith('.')); + check( + 'the second chunk is the second sentence', + twoSentences[1] === 'How did you resolve the conflict?' + ); + + // "Mr." is shorter than the minimum chunk length, so it must merge into the next sentence + // rather than being spoken as its own one-word utterance. + const abbreviation = splitIntoSpeechChunks('Mr. Lee asked about your background.', 'en'); + check('a short abbreviation is not its own chunk', abbreviation.length === 1); + check('it merges into the following sentence', abbreviation[0].startsWith('Mr.')); + + // Japanese uses '。' rather than '.', the same reason transcript-join.ts is language-aware. + const japanese = splitIntoSpeechChunks('自己紹介をしてください。得意な技術は何ですか?', 'ja'); + check('Japanese splits on its own sentence-final punctuation', japanese.length === 2); + check('the first Japanese chunk ends on 。', japanese[0].endsWith('。')); + + // A run-on sentence with no punctuation must still be bounded, so the first chunk's audio does + // not wait on the entire question being written out. + const longWord = 'word '.repeat(80).trim(); + const bounded = splitIntoSpeechChunks(longWord, 'en'); + check('an unpunctuated run-on is still split', bounded.length > 1); + check( + 'every chunk stays at or under the maximum', + bounded.every((chunk) => chunk.length <= 240) + ); + + // A single short sentence is exactly one chunk - the common case for a mock-interview question. + const single = splitIntoSpeechChunks('What is your greatest strength?', 'en'); + check('a single sentence is a single chunk', single.length === 1 && single[0].length > 0); + + return failures; +} From 2b25117e4850bc904488dc7f26c552549412df63 Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 31 Aug 2026 10:12:15 -0400 Subject: [PATCH 09/26] fix(mock-interview): end stale sessions and arm the silence backstop - The page's unmount cleanup closed over `session` from its initial (empty-deps) render, which is null before a session ever starts. Navigating away mid-interview never called endSession(), leaving the main-process state machine stuck outside Idle. Track the live session in a ref updated every render and read that in cleanup. - MOCK_ANSWER_SILENCE_MS was defined and documented but never wired up, so a candidate who stopped talking and forgot to click "Done answering" waited forever. Arm a timer on real speech in ingestAnswer that calls answerFinished() after the silence window, cleared on every path that leaves Listening. - Add a regression test pinning the silence-timeout behavior. --- src/main/services/mock-interview.service.ts | 30 +++++++++++++++- src/renderer/pages/mock-interview/index.tsx | 10 ++++-- test/mock-interview-state.test.mjs | 38 +++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/main/services/mock-interview.service.ts b/src/main/services/mock-interview.service.ts index 9bb8b7ec..398017ee 100644 --- a/src/main/services/mock-interview.service.ts +++ b/src/main/services/mock-interview.service.ts @@ -1,5 +1,5 @@ import { MockInterviewApi } from '../api/mock-interview.js'; -import { MOCK_MAX_FOLLOW_UPS_PER_QUESTION } from '../consts.js'; +import { MOCK_ANSWER_SILENCE_MS, MOCK_MAX_FOLLOW_UPS_PER_QUESTION } from '../consts.js'; import { configStore } from '../store/config.store.js'; import { Language, TTS_LANGUAGES } from '../types/language.js'; import { @@ -51,6 +51,7 @@ class MockInterviewService { private sessionSeq = 0; private followUpCount = 0; private finalAnswerText = ''; + private silenceTimer: NodeJS.Timeout | null = null; /** Captured at `start()` and fixed for the session - see the docstring on `start`. */ private language: Language = Language.English; @@ -157,7 +158,29 @@ class MockInterviewService { } } + /** + * Silence backstop for "Done answering" - a candidate who stops talking for + * `MOCK_ANSWER_SILENCE_MS` without pressing the button is treated as finished. Armed only from + * `ingestAnswer`, once real speech has actually arrived, so a pause to think before answering + * never auto-submits an empty answer. + */ + private clearSilenceTimer(): void { + if (this.silenceTimer !== null) { + clearTimeout(this.silenceTimer); + this.silenceTimer = null; + } + } + + private armSilenceTimer(): void { + this.clearSilenceTimer(); + this.silenceTimer = setTimeout(() => { + this.silenceTimer = null; + void this.answerFinished(); + }, MOCK_ANSWER_SILENCE_MS); + } + private installQuestion(text: string, kind: MockQuestionKind, isFollowUp: boolean): void { + this.clearSilenceTimer(); const hasAudio = TTS_LANGUAGES.has(this.language); const question: MockCurrentQuestion = { text, @@ -232,6 +255,7 @@ class MockInterviewService { currentAnswerText: `${this.finalAnswerText}${partialSep}${trimmed}`, }; } + this.armSilenceTimer(); this.broadcast(); } @@ -240,6 +264,7 @@ class MockInterviewService { if (this.session.state !== MockInterviewState.Listening || !this.session.currentQuestion) { return; } + this.clearSilenceTimer(); const seq = this.sessionSeq; const question = this.session.currentQuestion; const answerText = this.finalAnswerText.trim(); @@ -304,6 +329,7 @@ class MockInterviewService { ) { return; } + this.clearSilenceTimer(); const seq = this.sessionSeq; const question = this.session.currentQuestion; if (question) { @@ -388,6 +414,7 @@ class MockInterviewService { */ async endSession(): Promise { if (!this.isActive()) return; + this.clearSilenceTimer(); const seq = ++this.sessionSeq; this.setState(MockInterviewState.Stopping); this.broadcast(); @@ -408,6 +435,7 @@ class MockInterviewService { * "Practise again" never scores a new session's answers against the previous one's. */ clear(): void { + this.clearSilenceTimer(); this.sessionSeq += 1; this.followUpCount = 0; this.finalAnswerText = ''; diff --git a/src/renderer/pages/mock-interview/index.tsx b/src/renderer/pages/mock-interview/index.tsx index 1bff0b77..ea77d648 100644 --- a/src/renderer/pages/mock-interview/index.tsx +++ b/src/renderer/pages/mock-interview/index.tsx @@ -32,6 +32,8 @@ export default function MockInterviewPage() { const { exportMockReport } = useTools(); const redirectedToLogin = useRef(false); const endedOnUnmount = useRef(false); + const sessionRef = useRef(session); + sessionRef.current = session; useEffect(() => { getElectron()?.setStealth(false); @@ -44,11 +46,15 @@ export default function MockInterviewPage() { }, [appState?.isLoggedIn, navigate]); // Ends an in-progress session if this page unmounts without going through the report screen - - // see the module docstring for why this is a fallback rather than the intended UX. + // see the module docstring for why this is a fallback rather than the intended UX. Reads + // `sessionRef` rather than `session` directly: this effect only runs once (mount/unmount), so a + // cleanup closing over `session` would see whatever it was at mount - almost always `null`, + // before a session has even started - never the live state at the moment the page actually + // unmounts. The ref is kept current on every render instead. useEffect(() => { return () => { if (endedOnUnmount.current) return; - const state = session?.state; + const state = sessionRef.current?.state; if (state && state !== MockInterviewState.Idle && state !== MockInterviewState.Finished) { endedOnUnmount.current = true; void endSession(); diff --git a/test/mock-interview-state.test.mjs b/test/mock-interview-state.test.mjs index a6b6d75f..e0baae3e 100644 --- a/test/mock-interview-state.test.mjs +++ b/test/mock-interview-state.test.mjs @@ -183,6 +183,44 @@ export async function run() { mockInterviewService.ingestAnswer('final', 'Last one.'); await mockInterviewService.answerFinished(); check('isActive() is false when Finished', mockInterviewService.isActive() === false); + + // The silence backstop: real speech arrives, then nothing more. The armed timer must fire + // answerFinished() on its own, exactly as if "Done answering" had been clicked - a candidate + // who trails off must not be stranded in Listening forever. + mockInterviewService.clear(); + await mockInterviewService.start({ ...setup, question_count: 1 }); + await toListening(); + + const originalSetTimeout = globalThis.setTimeout; + let silenceCallback = null; + globalThis.setTimeout = (callback, delay) => { + silenceCallback = callback; + // A real, inert timer standing in for the captured one, so the service's own + // `clearTimeout(this.silenceTimer)` calls stay valid - unref'd so it cannot hold the test + // process open if this branch is ever reached without the manual fire below. + const timer = originalSetTimeout(() => {}, delay); + timer.unref?.(); + return timer; + }; + try { + mockInterviewService.ingestAnswer('final', 'Answered, then silence.'); + check('a silence timer is armed once real speech arrives', typeof silenceCallback === 'function'); + } finally { + globalThis.setTimeout = originalSetTimeout; + } + + silenceCallback(); + // answerFinished() runs its own chain of mocked-but-async fetch calls from here; give it a + // few real ticks to resolve rather than asserting on the exact microtask it is on. + await new Promise((resolve) => originalSetTimeout(resolve, 50)); + check( + 'firing the silence timeout reaches Finished, the same as clicking Done answering', + mockInterviewService.getState().state === MockInterviewState.Finished + ); + check( + 'and the trailing answer was captured before the timeout fired', + mockInterviewService.getState().answers[0]?.answer === 'Answered, then silence.' + ); } finally { globalThis.fetch = originalFetch; mockInterviewService.clear(); From 55bd295c55323adb4e30a7188d5f55d881f3c01e Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 31 Aug 2026 10:24:01 -0400 Subject: [PATCH 10/26] test(language): pin TTS_LANGUAGES against the voiced set Three tables decide which languages Aura speaks and only two were pinned: the backend's DEEPGRAM_TTS_VOICES (test_tts_language.py) and the renderer's hasVoice picker metadata. TTS_LANGUAGES was unpinned and is the decisive one - installQuestion reads it to set hasAudio, which decides whether the session enters Speaking at all. The damaging drift direction is silent: a language the backend voices going missing here means the session never requests audio, so it runs text-only while the setup screen still shows the "Voice" badge. The other direction recovers on its own, since a /speak answering 204 falls through speechFailed to the same text-only path. --- test/language.test.mjs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/language.test.mjs b/test/language.test.mjs index 19a4c7f1..e58d951e 100644 --- a/test/language.test.mjs +++ b/test/language.test.mjs @@ -10,7 +10,8 @@ import { createChecker, loadMain, readSource } from './helpers.mjs'; export async function run() { const { check, failures } = createChecker('language'); - const { Language, DEFAULT_LANGUAGE, resolveLanguage } = await loadMain('types/language.js'); + const { Language, DEFAULT_LANGUAGE, resolveLanguage, TTS_LANGUAGES } = + await loadMain('types/language.js'); const { transcriptSeparator } = await loadMain('utils/transcript-join.js'); const { configStore } = await loadMain('store/config.store.js'); @@ -129,6 +130,22 @@ export async function run() { entries.every((entry) => entry.hasVoice === ttsLanguages.has(entry.code)) ); + // Three tables have to agree on which languages Aura speaks, and only two of them were pinned. + // The renderer's `hasVoice` above is display metadata - it draws the badge on the setup screen. + // The backend's DEEPGRAM_TTS_VOICES is pinned by test_tts_language.py. This one, TTS_LANGUAGES, + // is the decisive one: `installQuestion` reads it to set `hasAudio`, which is what decides + // whether the session enters Speaking and asks for audio at all. + // + // Drift here is quiet, and the damaging direction is a language the backend voices going + // missing from this set: the session never requests audio, so it is text-only for the rest of + // the session while the setup screen still shows the "Voice" badge - no 204, no error, nothing + // to see. The other direction recovers on its own (a /speak that answers 204 falls through + // speechFailed to the text-only path), which is exactly why only this direction stays silent. + check( + 'TTS_LANGUAGES is exactly the same 7 the picker marks as voiced', + JSON.stringify([...TTS_LANGUAGES].sort()) === JSON.stringify([...ttsLanguages].sort()) + ); + // The store is the single source for every consumer, so it is where an unknown code has to die. configStore.updateConfig({ language: 'de' }); check('a chosen language round-trips', configStore.getConfig().language === 'de'); From c80cfdda09bbdca232d548898a770d1338b447bb Mon Sep 17 00:00:00 2001 From: alpha Date: Mon, 31 Aug 2026 11:04:35 -0400 Subject: [PATCH 11/26] fix(mock-interview): UI/UX pass - a11y labels, motion, empty states Reviewed the three mock-interview screens against the project's own Web Interface Guidelines. Icon aria-hidden and button spinner motion were already fine (Lucide icons default to aria-hidden; spinners match the ~16 others across the app that don't honor prefers-reduced-motion, so leaving these alone keeps them consistent rather than a one-off deviation). session.tsx: - The level ring's rAF loop wrote `transform` every frame while the element also carried `transition-transform`, so the browser interpolated toward each new value instead of applying it - the ring visibly lagged the voice it was meant to track. Removed the transition; a per-frame write and a CSS transition on the same property don't mix. - The same loop now stops under prefers-reduced-motion. Unlike the button spinners, this one runs continuously for the entire Listening state (potentially minutes) and is JS-driven, not a bounded CSS animation - the case the guideline is actually for. - Question text and status (state changes, new questions) now sit in an aria-live="polite" region so a screen reader user is told when the interviewer's turn changes. The live answer transcript is deliberately left out - it updates on every ASR partial and would spam. - wrap-break-word on both question and answer text, matching the convention already used in live-suggestions-panel.tsx and safe-markdown.tsx. setup.tsx: - Seniority and Questions are Radix Select triggers, which are buttons rather than form controls that `htmlFor` can reach. Associated via id + aria-labelledby, the exact pattern already documented and used in llm-group.tsx. Difficulty's RadioGroup labelled the same way. - Role input gained a name and the correct autocomplete token (organization-title - it's a job title field, not a value to suppress autofill on). - Math.round((n * 2.5) / 1) simplified to Math.round(n * 2.5) - dead division by 1. report.tsx: - MockReport.strengths/gaps carry no min_length, unlike questions (min_length=1), so a report scoring an interview with nothing notable in one direction rendered a blank card with a header and no content. Both now show "Nothing specific noted." when empty. --- src/renderer/pages/mock-interview/report.tsx | 28 ++++++++++++------- src/renderer/pages/mock-interview/session.tsx | 24 ++++++++++++---- src/renderer/pages/mock-interview/setup.tsx | 18 ++++++++---- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/src/renderer/pages/mock-interview/report.tsx b/src/renderer/pages/mock-interview/report.tsx index 73ac67dd..59a73821 100644 --- a/src/renderer/pages/mock-interview/report.tsx +++ b/src/renderer/pages/mock-interview/report.tsx @@ -91,11 +91,15 @@ export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: Rep Strengths -
    - {report.strengths.map((s, i) => ( -
  • {s}
  • - ))} -
+ {report.strengths.length > 0 ? ( +
    + {report.strengths.map((s, i) => ( +
  • {s}
  • + ))} +
+ ) : ( +

Nothing specific noted.

+ )}
@@ -103,11 +107,15 @@ export function ReportScreen({ session, onExport, onPracticeAgain, onDone }: Rep Gaps -
    - {report.gaps.map((g, i) => ( -
  • {g}
  • - ))} -
+ {report.gaps.length > 0 ? ( +
    + {report.gaps.map((g, i) => ( +
  • {g}
  • + ))} +
+ ) : ( +

Nothing specific noted.

+ )}
diff --git a/src/renderer/pages/mock-interview/session.tsx b/src/renderer/pages/mock-interview/session.tsx index cb372ff1..0b7819f2 100644 --- a/src/renderer/pages/mock-interview/session.tsx +++ b/src/renderer/pages/mock-interview/session.tsx @@ -40,8 +40,16 @@ export function SessionScreen({ session, onSkip, onDone, onRepeat, onEnd }: Sess // Live level ring, written directly to the DOM so this does not re-render at animation-frame // rate - see use-mic-level.ts. + // + // The ring carries no `transition-transform`: this writes `transform` every frame, and a + // transition on the same property makes the browser interpolate towards each new value instead + // of applying it, so the ring lags the voice it is meant to track and never reaches the peaks. + // + // Reduced motion stops the loop rather than damping it. The ring is decorative - the transcript + // below is what actually reports that speech is being heard - so a static ring loses nothing. useEffect(() => { if (state !== MockInterviewState.Listening) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; let raf = 0; const tick = () => { const scale = 1 + Math.min(levelRef.current, 1) * 0.4; @@ -91,20 +99,26 @@ export function SessionScreen({ session, onSkip, onDone, onRepeat, onEnd }: Sess