diff --git a/.gitignore b/.gitignore index 97be8ec3..44f7aa3a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,10 @@ ai/microservices/**/llm/ # superpowers brainstorm companion artifacts .superpowers/ + +# playwright mcp scratch (console logs, page snapshots, ad-hoc screenshots) +.playwright-mcp/ + +# playwright output when run from the repo root (web/.gitignore anchors these to web/) +/test-results/ +/playwright-report/ diff --git a/web/src/components/organisms/persona-picker-modal/index.spec.tsx b/web/src/components/organisms/persona-picker-modal/index.spec.tsx new file mode 100644 index 00000000..2ca23bba --- /dev/null +++ b/web/src/components/organisms/persona-picker-modal/index.spec.tsx @@ -0,0 +1,172 @@ +import { DEFAULT_PERSONA, PERSONAS } from '@/config/personas'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { PersonaPickerModal } from './index'; + +function renderModal( + overrides: Partial[0]> = {}, +) { + const onSelect = vi.fn(); + const onDismiss = vi.fn(); + render( + , + ); + return { onSelect, onDismiss }; +} + +describe('PersonaPickerModal', () => { + it('renders nothing when closed', () => { + render( + , + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders every persona with its name and role', () => { + renderModal(); + for (const persona of PERSONAS) { + expect(screen.getByText(persona.name)).toBeInTheDocument(); + expect(screen.getByText(persona.role)).toBeInTheDocument(); + } + }); + + it('states the difficulty as a word, not colour alone', () => { + renderModal(); + expect(screen.getByText('EASY')).toBeInTheDocument(); + expect(screen.getAllByText('MEDIUM')).toHaveLength(2); + expect(screen.getByText('HARD')).toBeInTheDocument(); + }); + + it('states the turn-taking guarantee in the subtitle', () => { + renderModal(); + expect( + screen.getByText(/you always get to finish your answer/i), + ).toBeInTheDocument(); + }); + + it('is a labelled modal dialog', () => { + renderModal(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAccessibleName('Choose your interviewer'); + }); + + it('selects a persona on click', () => { + const { onSelect } = renderModal(); + fireEvent.click(screen.getByText('Marc Bernard')); + fireEvent.click( + screen.getByRole('button', { name: /start with marc bernard/i }), + ); + expect(onSelect).toHaveBeenCalledWith( + PERSONAS.find((persona) => persona.id === 'marc-bernard'), + ); + }); + + it('dismisses on Escape', () => { + const { onDismiss } = renderModal(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('dismisses when the skip button is used', () => { + const { onDismiss } = renderModal(); + fireEvent.click(screen.getByRole('button', { name: /skip/i })); + expect(onDismiss).toHaveBeenCalled(); + }); + + // Roving tabindex: only the checked radio is reachable by Tab, so focus it + // first and dispatch from there. Keying a node the user cannot reach would + // assert the handler wiring rather than the keyboard path. + it('exposes only the checked radio to Tab', () => { + renderModal(); + const radios = screen.getAllByRole('radio'); + expect(radios).toHaveLength(4); + expect(radios[0]).toHaveAttribute('aria-checked', 'true'); + expect(radios[0]).toHaveAttribute('tabindex', '0'); + expect(radios[1]).toHaveAttribute('tabindex', '-1'); + }); + + it('moves the radio highlight with arrow keys', () => { + renderModal(); + const radios = screen.getAllByRole('radio'); + radios[0].focus(); + expect(radios[0]).toHaveFocus(); + + fireEvent.keyDown(radios[0], { key: 'ArrowRight' }); + expect(screen.getAllByRole('radio')[1]).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('commits the highlighted radio with Enter', () => { + const { onSelect } = renderModal(); + const radios = screen.getAllByRole('radio'); + radios[0].focus(); + fireEvent.keyDown(radios[0], { key: 'Enter' }); + expect(onSelect).toHaveBeenCalledWith(DEFAULT_PERSONA); + }); + + it('locks body scroll while open', () => { + renderModal(); + expect(document.body.style.overflow).toBe('hidden'); + }); + + // #195 regression: on /applications/:id/simulations the modal's scrim + // covers the route's own "Back to roadmap" link, so the modal must offer + // its own back control instead when the caller provides one. + it('renders no back control when backTo is omitted', () => { + renderModal(); + expect( + screen.queryByRole('link', { name: /back/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /back/i }), + ).not.toBeInTheDocument(); + }); + + it('renders the back control and fires onNavigate when backTo is given', () => { + const onNavigate = vi.fn(); + renderModal({ backTo: { label: 'Back to roadmap', onNavigate } }); + const back = screen.getByRole('button', { name: /back to roadmap/i }); + fireEvent.click(back); + expect(onNavigate).toHaveBeenCalledTimes(1); + }); + + // The radios are
, so the browser supplies no built-in + // radiogroup keying: every key the WAI-ARIA pattern mandates is ours to wire. + it('jumps to the first persona on Home', () => { + renderModal({ initialHighlight: PERSONAS[3] }); + fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'Home' }); + const radios = screen.getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('aria-checked', 'true'); + expect(radios[0]).toHaveFocus(); + }); + + it('jumps to the last persona on End', () => { + renderModal({ initialHighlight: PERSONAS[0] }); + fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'End' }); + const radios = screen.getAllByRole('radio'); + expect(radios[radios.length - 1]).toHaveAttribute('aria-checked', 'true'); + expect(radios[radios.length - 1]).toHaveFocus(); + }); + + // Without an explicit initial focus target the trap lands on the first + // focusable child, which is the header's X button — the dialog would open + // focused on "leave" rather than on the cast it is asking about. + it('opens focus on the highlighted persona, not the close button', () => { + renderModal({ initialHighlight: PERSONAS[2] }); + const radios = screen.getAllByRole('radio'); + expect(radios[2]).toHaveFocus(); + }); +}); diff --git a/web/src/components/organisms/persona-picker-modal/index.tsx b/web/src/components/organisms/persona-picker-modal/index.tsx new file mode 100644 index 00000000..593c4f91 --- /dev/null +++ b/web/src/components/organisms/persona-picker-modal/index.tsx @@ -0,0 +1,303 @@ +import { Button } from '@/components/atoms/button'; +import { Icon } from '@/components/atoms/icon'; +import { + DEFAULT_PERSONA, + PERSONAS, + type PersonaDifficulty, + type RecruiterPersona, +} from '@/config/personas'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; +import { cn } from '@/utils/cn'; +import { + type KeyboardEvent as ReactKeyboardEvent, + useEffect, + useRef, + useState, +} from 'react'; + +import type { PersonaPickerModalProps } from './types'; + +/** + * Difficulty is always carried by the level word — the gauge's width and + * colour only reinforce it, so the meaning survives greyscale and + * colour-blindness. + */ +const DIFFICULTY_META: Record< + PersonaDifficulty, + { label: string; width: string; barClass: string; textClass: string } +> = { + easy: { + label: 'EASY', + width: '33%', + barClass: 'bg-success', + textClass: 'text-success', + }, + medium: { + label: 'MEDIUM', + width: '66%', + barClass: 'bg-warning', + textClass: 'text-warning', + }, + hard: { + label: 'HARD', + width: '100%', + barClass: 'bg-error', + textClass: 'text-error', + }, +}; + +/** + * Modal for casting the AI recruiter before a simulation. + * + * Controlled component: `isOpen`/`onSelect`/`onDismiss` only — persistence is + * the caller's job. Highlighting (roving radio) and committing (`onSelect`) + * are deliberately separate steps so arrowing through the cast never starts + * a simulation by accident. + */ +export function PersonaPickerModal({ + isOpen, + initialHighlight = DEFAULT_PERSONA, + onSelect, + onDismiss, + backTo, +}: PersonaPickerModalProps) { + const [highlighted, setHighlighted] = + useState(initialHighlight); + const radioRefs = useRef>({}); + // Open focus on the chosen persona, not the leading close button: the cast is + // what the dialog is asking about, and it is the roving radiogroup's entry + // point. Resolved through a getter because the card refs are only populated + // once children have mounted, after this line runs. Falls back to the first + // focusable child when the card is absent. + const dialogRef = useFocusTrap(isOpen, { + getInitialFocus: () => radioRefs.current[highlighted.id] ?? null, + }); + + // `isOpen: false` hides the modal without unmounting it, so `highlighted` + // survives a close and would reopen showing whatever was last arrowed to — + // contradicting the committed persona, one Enter away from starting the wrong + // recruiter. Re-seed on every closed -> open transition; the caller's `key` + // cannot do this, because it is derived from an id that does not change when + // a dismiss keeps the current pick. + const wasOpenRef = useRef(isOpen); + useEffect(() => { + if (isOpen && !wasOpenRef.current) setHighlighted(initialHighlight); + wasOpenRef.current = isOpen; + }, [isOpen, initialHighlight]); + + useEffect(() => { + if (!isOpen) return; + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onDismiss(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onDismiss]); + + useEffect(() => { + if (!isOpen) return; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const select = (persona: RecruiterPersona) => { + setHighlighted(persona); + // Roving tabindex: keyboard focus follows the checked radio. + radioRefs.current[persona.id]?.focus(); + }; + + const move = (delta: number) => { + const index = PERSONAS.findIndex( + (persona) => persona.id === highlighted.id, + ); + const next = (index + delta + PERSONAS.length) % PERSONAS.length; + select(PERSONAS[next]); + }; + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault(); + move(1); + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault(); + move(-1); + } else if (event.key === 'Home') { + event.preventDefault(); + select(PERSONAS[0]); + } else if (event.key === 'End') { + event.preventDefault(); + select(PERSONAS[PERSONAS.length - 1]); + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(highlighted); + } + }; + + return ( +
+ +
+ + {/* + onKeyDown lives on the group, not each radio: roving tabindex means + only the checked radio is reachable, so the group is the one element + guaranteed to receive the key event from any reachable focus point. + This is the WAI-ARIA radiogroup pattern. + */} +
+ {PERSONAS.map((persona) => { + const meta = DIFFICULTY_META[persona.difficulty]; + const isHighlighted = persona.id === highlighted.id; + return ( + // Key events are handled by the radiogroup above (roving + // tabindex), not per radio — see the comment on the group. + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
{ + radioRefs.current[persona.id] = element; + }} + role="radio" + aria-checked={isHighlighted} + tabIndex={isHighlighted ? 0 : -1} + onClick={() => setHighlighted(persona)} + className={cn( + 'flex cursor-pointer flex-col rounded-xl border p-4 transition-colors duration-150', + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent', + isHighlighted + ? 'border-accent bg-accent-weaker ring-1 ring-accent' + : 'border-border bg-surface hover:border-border-hover hover:bg-surface-hover', + )} + > +
+ + +

+ {persona.name} +

+

+ {persona.role} +

+
+
+ +

+ {persona.description} +

+ +
+
+ + Difficulty + + + {meta.label} + +
+ +
+ ); + })} +
+ +
+
+ {backTo ? ( + + ) : null} + +
+ +
+
+
+ ); +} + +export default PersonaPickerModal; diff --git a/web/src/components/organisms/persona-picker-modal/types.ts b/web/src/components/organisms/persona-picker-modal/types.ts new file mode 100644 index 00000000..ee209288 --- /dev/null +++ b/web/src/components/organisms/persona-picker-modal/types.ts @@ -0,0 +1,24 @@ +import type { RecruiterPersona } from '@/config/personas'; + +export interface PersonaPickerModalProps { + /** Renders nothing when false. */ + isOpen: boolean; + /** + * Card highlighted on mount. Defaults to DEFAULT_PERSONA (Sophie). Pass the + * currently selected persona on the "Change recruiter" path — combine with + * `key={persona.id}` at the call site so the component remounts and re-reads + * this initializer instead of keeping a stale highlight across reopens. + */ + initialHighlight?: RecruiterPersona; + /** Called with the committed persona. */ + onSelect: (persona: RecruiterPersona) => void; + /** Esc, scrim click, close button, or Skip. Caller falls back to DEFAULT_PERSONA. */ + onDismiss: () => void; + /** + * Optional back-navigation affordance rendered in the footer, left of Skip. + * The modal stays a controlled component: it only calls `onNavigate`, it + * never imports the router itself. Omit when there is nowhere to go back + * to (e.g. the standalone /simulations page has no roadmap). + */ + backTo?: { label: string; onNavigate: () => void }; +} diff --git a/web/src/components/organisms/recruiter-avatar/index.tsx b/web/src/components/organisms/recruiter-avatar/index.tsx index 15366f30..bd4a8da1 100644 --- a/web/src/components/organisms/recruiter-avatar/index.tsx +++ b/web/src/components/organisms/recruiter-avatar/index.tsx @@ -15,6 +15,7 @@ const RecruiterAvatar3D = lazy(() => */ export function RecruiterAvatarPanel({ active, + persona, isAiSpeaking, isAwaitingAiResponse, speechTurn, @@ -37,12 +38,13 @@ export function RecruiterAvatarPanel({ const use3d = wants3d && !runtimeFallback; if (avatarMode === 'loading' && !runtimeFallback) { - return ; + return ; } if (!use3d) { return ( }> + }>
-

- {RECRUITER_DISPLAY_NAME} -

-

{RECRUITER_DISPLAY_ROLE}

+

{persona.name}

+

{persona.role}

{initState === 'loading' ? ( diff --git a/web/src/components/organisms/recruiter-avatar/recruiter-avatar-fallback.tsx b/web/src/components/organisms/recruiter-avatar/recruiter-avatar-fallback.tsx index d1bcbbb2..1d5d5c86 100644 --- a/web/src/components/organisms/recruiter-avatar/recruiter-avatar-fallback.tsx +++ b/web/src/components/organisms/recruiter-avatar/recruiter-avatar-fallback.tsx @@ -1,10 +1,8 @@ -import { - RECRUITER_DISPLAY_NAME, - RECRUITER_DISPLAY_ROLE, -} from '@/config/recruiter-avatar'; +import type { RecruiterPersona } from '@/config/personas'; import { cn } from '@/utils/cn'; interface RecruiterAvatarFallbackProps { + persona: RecruiterPersona; isAiSpeaking: boolean; isAwaitingAiResponse: boolean; fallbackReason?: string | null; @@ -16,6 +14,7 @@ interface RecruiterAvatarFallbackProps { * Uses the existing interviewer photo asset. */ export function RecruiterAvatarFallback({ + persona, isAiSpeaking, isAwaitingAiResponse, fallbackReason, @@ -25,15 +24,13 @@ export function RecruiterAvatarFallback({
{`${RECRUITER_DISPLAY_NAME},
-

- {RECRUITER_DISPLAY_NAME} -

-

{RECRUITER_DISPLAY_ROLE}

+

{persona.name}

+

{persona.role}

{isAiSpeaking ? ( diff --git a/web/src/components/organisms/recruiter-avatar/recruiter-avatar-loading.tsx b/web/src/components/organisms/recruiter-avatar/recruiter-avatar-loading.tsx index 57bc535a..fcf7aaf6 100644 --- a/web/src/components/organisms/recruiter-avatar/recruiter-avatar-loading.tsx +++ b/web/src/components/organisms/recruiter-avatar/recruiter-avatar-loading.tsx @@ -1,23 +1,22 @@ -import { - RECRUITER_DISPLAY_NAME, - RECRUITER_DISPLAY_ROLE, -} from '@/config/recruiter-avatar'; +import { DEFAULT_PERSONA, type RecruiterPersona } from '@/config/personas'; import RecruiterAvatarBackdrop from './recruiter-avatar-backdrop'; /** Loading placeholder while WebGL capability or the 3D engine initializes. */ -export function RecruiterAvatarLoading() { +export function RecruiterAvatarLoading({ + persona = DEFAULT_PERSONA, +}: { + persona?: RecruiterPersona; +}) { return (

- {RECRUITER_DISPLAY_NAME} -

-

- {RECRUITER_DISPLAY_ROLE} + {persona.name}

+

{persona.role}

Loading 3D avatar…

diff --git a/web/src/components/organisms/recruiter-avatar/types.ts b/web/src/components/organisms/recruiter-avatar/types.ts index 89030a7c..092b98db 100644 --- a/web/src/components/organisms/recruiter-avatar/types.ts +++ b/web/src/components/organisms/recruiter-avatar/types.ts @@ -1,9 +1,11 @@ +import type { RecruiterPersona } from '@/config/personas'; import type { AiSpeechTurn } from '@/hooks/simulation/useAudioPlayback'; import type { RecruiterAvatarMode } from '@/hooks/simulation/useRecruiterAvatarCapability'; export interface RecruiterAvatarPanelProps { /** Whether the simulation stream is active (user started the call). */ active: boolean; + persona: RecruiterPersona; isAiSpeaking: boolean; isAwaitingAiResponse: boolean; speechTurn: AiSpeechTurn | null; @@ -15,6 +17,7 @@ export interface RecruiterAvatarPanelProps { export interface RecruiterAvatar3DProps { active: boolean; + persona: RecruiterPersona; avatarUrl: string; isAiSpeaking: boolean; isAwaitingAiResponse: boolean; diff --git a/web/src/components/organisms/simulation-video-area/index.tsx b/web/src/components/organisms/simulation-video-area/index.tsx index 0bc0b77b..5bd82386 100644 --- a/web/src/components/organisms/simulation-video-area/index.tsx +++ b/web/src/components/organisms/simulation-video-area/index.tsx @@ -1,6 +1,7 @@ import { Icon } from '@/components/atoms/icon'; import VideoAreaControlsBar from '@/components/molecules/video-area-controls-bar'; import RecruiterAvatarPanel from '@/components/organisms/recruiter-avatar'; +import { DEFAULT_PERSONA, type RecruiterPersona } from '@/config/personas'; import type { AiSpeechTurn } from '@/hooks/simulation/useAudioPlayback'; import type { RecruiterAvatarMode } from '@/hooks/simulation/useRecruiterAvatarCapability'; import { cn } from '@/utils/cn'; @@ -12,6 +13,7 @@ import { useStreamControls } from '../../../hooks/streams/useStreamControls'; import { useVideoStream } from '../../../hooks/streams/useVideoStream'; interface SimulationVideoAreaProps { + persona?: RecruiterPersona; isAiSpeaking?: boolean; isAwaitingAiResponse?: boolean; speechTurn?: AiSpeechTurn | null; @@ -29,6 +31,7 @@ interface SimulationVideoAreaProps { * @returns The SimulationVideoArea component. */ const SimulationVideoArea = ({ + persona = DEFAULT_PERSONA, isAiSpeaking = false, isAwaitingAiResponse = false, speechTurn = null, @@ -140,6 +143,7 @@ const SimulationVideoArea = ({ > ({ + useInterviewSession: vi.fn(() => ({ + isCallActive: false, + isQueued: false, + queuePosition: 0, + estimatedWaitSec: undefined, + inputUrl: '', + interviewID: null, + handleStreamToggle: vi.fn(), + })), + useSimulationWebSocket: () => ({ + sendMessage: vi.fn(), + sendJsonMessage: vi.fn(), + sendPing: vi.fn(), + sendSessionStart: vi.fn(), + lastMessage: null, + lastJsonMessage: null, + readyState: 3, + connect: vi.fn(), + disconnect: vi.fn(), + }), + useAudioPlayback: () => ({ isAiSpeaking: false, speechTurn: 0 }), + useAudioStreaming: () => ({ + isListening: false, + isSpeaking: false, + isRecording: false, + packetsSent: 0, + supportedMimeType: '', + audioError: null, + }), + useVerbalAnalysis: () => ({ + analysis: { latest: null, aggregate: null, history: [] }, + }), + useRecruiterAvatarCapability: () => ({ + avatarMode: 'fallback', + capabilityFallbackReason: null, + }), +})); + +describe('SimulationWorkspace persona picker', () => { + beforeEach(() => { + act(() => usePersonaStore.getState().clearPersona()); + }); + + it('shows the picker when no persona is chosen', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('hides the picker once a persona is chosen', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('shows the chosen persona name and role in the info box', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + const marc = PERSONAS.find((persona) => persona.id === 'marc-bernard')!; + expect(screen.getByText(marc.name)).toBeInTheDocument(); + expect(screen.getByText(new RegExp(marc.role))).toBeInTheDocument(); + }); + + it('offers Change while idle', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + expect( + screen.getByRole('button', { name: /change recruiter/i }), + ).toBeInTheDocument(); + }); + + // Task 5's explicit highlight-on-reopen decision: reopening via "Change + // recruiter" must highlight the currently selected persona, not a stale + // highlight or an unconditional Sophie (the modal's own internal default). + it('highlights the currently selected persona when reopened via Change', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + + fireEvent.click(screen.getByRole('button', { name: /change recruiter/i })); + + const marc = PERSONAS.find((persona) => persona.id === 'marc-bernard')!; + const radios = screen.getAllByRole('radio'); + const marcRadio = radios.find( + (radio) => radio.getAttribute('aria-checked') === 'true', + ); + expect(marcRadio).toHaveAccessibleName(new RegExp(marc.name)); + }); + + // Regression guard: the "Change recruiter" button is gated on !isCallActive, + // NOT isAwaitingAiResponse. These are distinct states: isCallActive is the + // session-wide interview status (should hide during live call), while + // isAwaitingAiResponse flips true/false on every conversational turn (would + // cause the button to flicker visibly if used as the gate). A future refactor + // might conflate them; this test locks the distinction in place. + it('shows Change recruiter when idle (isCallActive: false)', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + expect( + screen.getByRole('button', { name: /change recruiter/i }), + ).toBeInTheDocument(); + }); + + it('hides Change recruiter when call is active (isCallActive: true)', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + vi.mocked(simulationHooks.useInterviewSession).mockReturnValueOnce({ + isCallActive: true, + isQueued: false, + queuePosition: 0, + estimatedWaitSec: undefined, + inputUrl: '', + interviewID: null, + handleStreamToggle: vi.fn(), + }); + render(); + expect( + screen.queryByRole('button', { name: /change recruiter/i }), + ).not.toBeInTheDocument(); + }); + + // Regression for the picker/store desync: useInterviewSession's finally + // block calls clearPersona() when an interview ends ("Leaving it set would + // silently skip the picker on the next visit"), but the workspace stayed + // mounted throughout that call. If isPickerOpen is only ever seeded from a + // useState initializer, this reset is invisible to the UI: the store goes + // back to null, persona quietly resolves to the default via + // getPersonaById(null), and the modal never reappears to let the user + // re-choose. The picker's open state must be derived from the store, not + // snapshotted once at mount. + it('reopens the picker when the store is cleared while mounted', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + act(() => usePersonaStore.getState().clearPersona()); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + // Dismiss is overloaded: on first entry it means "use the default", but once + // a persona is committed, reopening via "Change recruiter" and backing out + // means *cancel*. Writing the default on that path silently downgraded a + // chosen Marc Bernard to Sophie Martin. + it('keeps the committed persona when a reopened picker is dismissed', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + + fireEvent.click(screen.getByRole('button', { name: /change recruiter/i })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(usePersonaStore.getState().selectedPersonaId).toBe('marc-bernard'); + expect(screen.getByText(/Marc Bernard/)).toBeInTheDocument(); + }); + + it('still commits the default when the first-entry picker is dismissed', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(usePersonaStore.getState().selectedPersonaId).toBe( + DEFAULT_PERSONA.id, + ); + }); + + // The modal is hidden, not unmounted, so a stale highlight survives a close. + it('re-seeds the highlight to the committed persona on reopen', () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + render(); + + fireEvent.click(screen.getByRole('button', { name: /change recruiter/i })); + fireEvent.keyDown(screen.getByRole('radiogroup'), { key: 'Home' }); + fireEvent.keyDown(document, { key: 'Escape' }); + fireEvent.click(screen.getByRole('button', { name: /change recruiter/i })); + + const checked = screen + .getAllByRole('radio') + .find((radio) => radio.getAttribute('aria-checked') === 'true'); + expect(checked).toHaveTextContent('Marc Bernard'); + }); +}); diff --git a/web/src/components/organisms/simulation-workspace/index.tsx b/web/src/components/organisms/simulation-workspace/index.tsx index 726410b8..48d9fd15 100644 --- a/web/src/components/organisms/simulation-workspace/index.tsx +++ b/web/src/components/organisms/simulation-workspace/index.tsx @@ -1,16 +1,18 @@ import InfoBox from '@/components/molecules/info-box'; import NotesEditor from '@/components/molecules/notes-editor/notes-editor'; import SimulationQueueBanner from '@/components/molecules/simulation-queue-banner'; +import { PersonaPickerModal } from '@/components/organisms/persona-picker-modal'; import SimulationTranscriptionArea from '@/components/organisms/simulation-transcription-area'; import { TranscriptionProps } from '@/components/organisms/simulation-transcription-area/types'; import SimulationVideoArea from '@/components/organisms/simulation-video-area'; import VerbalAnalysisPanel from '@/components/organisms/verbal-analysis-panel'; import { WebSocketDebugPanel } from '@/components/organisms/websocket-debug-panel'; import { - RECRUITER_DISPLAY_NAME, - RECRUITER_DISPLAY_ROLE, - clearAvatarFallbackForced, -} from '@/config/recruiter-avatar'; + DEFAULT_PERSONA, + type RecruiterPersona, + getPersonaById, +} from '@/config/personas'; +import { clearAvatarFallbackForced } from '@/config/recruiter-avatar'; import { WebSocketPacket, useAudioPlayback, @@ -21,6 +23,7 @@ import { useVerbalAnalysis, } from '@/hooks/simulation'; import type { RecruiterAvatarMode } from '@/hooks/simulation/useRecruiterAvatarCapability'; +import usePersonaStore from '@/stores/usePersonaStore'; import { useCallback, useEffect, useRef, useState } from 'react'; import toast from 'react-hot-toast'; import { ReadyState } from 'react-use-websocket'; @@ -31,6 +34,12 @@ export interface SimulationWorkspaceProps { title?: string; description?: string; contextLabel?: string; + /** + * Passed through to the persona picker modal's optional back control (#195). + * Left undefined on the standalone /simulations page, which has nowhere to + * navigate back to. + */ + backTo?: { label: string; onNavigate: () => void }; } export function SimulationWorkspace({ @@ -38,7 +47,41 @@ export function SimulationWorkspace({ title = 'Simulations', description = 'Practice interview scenarios in a safe environment.', contextLabel, + backTo, }: SimulationWorkspaceProps) { + const selectedPersonaId = usePersonaStore((state) => state.selectedPersonaId); + const setPersona = usePersonaStore((state) => state.setPersona); + // Explicit "Change recruiter" clicks reopen the picker even though a + // persona is already selected. This is intentionally NOT the sole source + // of truth: it is OR-ed with `selectedPersonaId === null` below so the + // picker also reopens whenever the store is cleared out from under a + // mounted workspace (useInterviewSession.clearPersona() on hang-up), not + // just at mount time. See simulation-workspace/index.spec.tsx. + const [wantsPickerOpen, setWantsPickerOpen] = useState(false); + const isPickerOpen = selectedPersonaId === null || wantsPickerOpen; + const persona = getPersonaById(selectedPersonaId); + + const handlePersonaSelect = useCallback( + (chosen: RecruiterPersona) => { + setPersona(chosen.id); + setWantsPickerOpen(false); + }, + [setPersona], + ); + + // Dismiss means two different things depending on why the picker is open. + // First entry (nothing chosen): fall back to the default and persist it, so + // the modal does not nag on the next mount — today's shipped behaviour. + // Reopened via "Change recruiter": the user already has a pick, so dismiss + // means *cancel*. Writing the default here would silently downgrade a chosen + // Marc Bernard back to Sophie on an Esc. + const handlePersonaDismiss = useCallback(() => { + if (selectedPersonaId === null) { + setPersona(DEFAULT_PERSONA.id); + } + setWantsPickerOpen(false); + }, [selectedPersonaId, setPersona]); + const [mediaStream, setMediaStream] = useState(null); const [wsError, setWsError] = useState(null); const [connectionAttempts, setConnectionAttempts] = useState(0); @@ -306,6 +349,7 @@ export function SimulationWorkspace({
+ {!isCallActive && ( + + )}
+ +
); } diff --git a/web/src/config/personas.spec.ts b/web/src/config/personas.spec.ts new file mode 100644 index 00000000..46cbbb68 --- /dev/null +++ b/web/src/config/personas.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_PERSONA, PERSONAS, getPersonaById } from './personas'; + +describe('personas', () => { + it('exposes exactly four personas with unique ids', () => { + expect(PERSONAS).toHaveLength(4); + const ids = PERSONAS.map((persona) => persona.id); + expect(new Set(ids).size).toBe(4); + }); + + it('defaults to Sophie Martin with the values shipped today', () => { + expect(DEFAULT_PERSONA.id).toBe('sophie-martin'); + expect(DEFAULT_PERSONA.name).toBe('Sophie Martin'); + expect(DEFAULT_PERSONA.role).toBe('Recruteuse IT'); + }); + + it('returns the matching persona by id', () => { + expect(getPersonaById('marc-bernard').name).toBe('Marc Bernard'); + }); + + it('falls back to the default for null, undefined or unknown ids', () => { + expect(getPersonaById(null)).toBe(DEFAULT_PERSONA); + expect(getPersonaById(undefined)).toBe(DEFAULT_PERSONA); + expect(getPersonaById('does-not-exist')).toBe(DEFAULT_PERSONA); + }); + + it('ranks Marc Bernard as the sole hard persona', () => { + const hard = PERSONAS.filter((persona) => persona.difficulty === 'hard'); + expect(hard.map((persona) => persona.id)).toEqual(['marc-bernard']); + }); + + // The speech-to-speech pipeline is strict turn-taking: VAD only trims silence + // (ai/microservices/speech-to-speech/engine/pipeline.py:99-102), there is no + // barge-in. Copy promising interruption would describe behaviour the system + // cannot produce. systemPrompt is checked too: it is French, unconsumed by + // the web app today, but it is exactly what a future task will feed to the + // LLM, and the field most likely to gain "coupe la parole" when someone + // tunes Marc's difficulty. + it('never claims a persona interrupts the candidate', () => { + const forbiddenEnglish = + /interrupt|cut you off|cuts you off|talk over|talks over|rush/i; + const forbiddenFrench = + /coupe la parole|couper la parole|interromp|presse|bouscul/i; + for (const persona of PERSONAS) { + expect(persona.description).not.toMatch(forbiddenEnglish); + expect(persona.systemPrompt).not.toMatch(forbiddenFrench); + } + }); +}); diff --git a/web/src/config/personas.ts b/web/src/config/personas.ts new file mode 100644 index 00000000..b404f3ad --- /dev/null +++ b/web/src/config/personas.ts @@ -0,0 +1,115 @@ +/** + * Recruiter personas offered before a simulation. + * + * Difficulty describes *what gets asked* — question depth, follow-ups, scrutiny. + * It must never describe pacing: the speech-to-speech pipeline is strict + * turn-taking (VAD trims silence only, no barge-in), so the AI cannot interrupt + * the candidate. `personas.spec.ts` enforces this. + * + * Role labels are French on purpose: they belong to the French interview fiction + * like the names themselves, and `Recruteuse IT` is the value already shipped. + */ +export type PersonaDifficulty = 'easy' | 'medium' | 'hard'; + +export interface RecruiterPersona { + /** Stable slug persisted in sessionStorage. */ + id: string; + name: string; + /** Displayed under the name. French — see file header. */ + role: string; + /** Card body copy. English. Must not claim interruption. */ + description: string; + difficulty: PersonaDifficulty; + /** Picker portrait initials. */ + initials: string; + /** + * Portrait background classes. Only resolves at runtime because + * `tailwind.css:5-9` safelists these via `@source inline(...)` — the scanner + * never sees this string. Keep within the safelisted set. + */ + accentClass: string; + /** + * French system prompt, authored for the AI developer who will wire the + * pipeline. Intentionally unconsumed by the web app today. + */ + systemPrompt: string; +} + +const BASE_PROMPT_RULES = + 'Tu parles de facon naturelle comme dans une vraie conversation. ' + + 'Tu dois repondre uniquement a la derniere prise de parole du candidat, ' + + 'en une seule reponse courte et naturelle. ' + + "N'ecris jamais un dialogue multi-tours, n'imite jamais des balises comme system: ou user:, " + + "et ne recopie jamais l'historique de conversation."; + +export const PERSONAS: readonly RecruiterPersona[] = [ + { + id: 'sophie-martin', + name: 'Sophie Martin', + role: 'Recruteuse IT', + description: + 'Warm and encouraging. Broad questions, accepts an answer at face value.', + difficulty: 'easy', + initials: 'SM', + accentClass: 'bg-primary', + systemPrompt: + 'Tu es Sophie Martin, recruteuse senior IT chez une ESN francaise. ' + + 'Tu es chaleureuse, professionnelle, patiente et humaine. ' + + BASE_PROMPT_RULES, + }, + { + id: 'thomas-leroy', + name: 'Thomas Leroy', + role: 'Tech Lead', + description: + 'Technical depth. Follows up on vague answers and asks you to justify choices.', + difficulty: 'medium', + initials: 'TL', + accentClass: 'bg-neutral', + systemPrompt: + 'Tu es Thomas Leroy, tech lead dans une equipe produit francaise. ' + + 'Tu es direct et precis. Tu creuses la profondeur technique des reponses ' + + 'et tu demandes de justifier les choix techniques. ' + + BASE_PROMPT_RULES, + }, + { + id: 'claire-dubois', + name: 'Claire Dubois', + role: 'DRH', + description: + 'Structured behavioural questions on motivation and teamwork. Expects concrete examples.', + difficulty: 'medium', + initials: 'CD', + accentClass: 'bg-success', + systemPrompt: + 'Tu es Claire Dubois, directrice des ressources humaines. ' + + 'Tu poses des questions comportementales structurees sur la motivation ' + + 'et le travail en equipe, et tu attends des exemples concrets. ' + + BASE_PROMPT_RULES, + }, + { + id: 'marc-bernard', + name: 'Marc Bernard', + role: 'Directeur', + description: + 'Blunt and demanding. Challenges weak reasoning and pushes back on your answers.', + difficulty: 'hard', + initials: 'MB', + accentClass: 'bg-error', + systemPrompt: + "Tu es Marc Bernard, directeur d'une entreprise francaise. " + + 'Tu es direct et exigeant. Tu remets en question les raisonnements faibles ' + + 'et tu challenges les reponses du candidat. ' + + BASE_PROMPT_RULES, + }, +]; + +/** Sophie carries the values shipped before the picker existed. */ +export const DEFAULT_PERSONA: RecruiterPersona = PERSONAS[0]; + +/** Resolves a persisted id, falling back to the default for null/unknown. */ +export function getPersonaById( + id: string | null | undefined, +): RecruiterPersona { + return PERSONAS.find((persona) => persona.id === id) ?? DEFAULT_PERSONA; +} diff --git a/web/src/config/recruiter-avatar.ts b/web/src/config/recruiter-avatar.ts index 5c7c6528..632908dc 100644 --- a/web/src/config/recruiter-avatar.ts +++ b/web/src/config/recruiter-avatar.ts @@ -46,9 +46,6 @@ export const RECRUITER_AVATAR_SHOW_OPTIONS = { }, }; -export const RECRUITER_DISPLAY_NAME = 'Sophie Martin'; -export const RECRUITER_DISPLAY_ROLE = 'Recruteuse IT'; - const FORCE_FALLBACK_STORAGE_KEY = 'talkup.avatar.forceFallback'; /** diff --git a/web/src/hooks/simulation/useInterviewSession.spec.ts b/web/src/hooks/simulation/useInterviewSession.spec.ts index 1c6f3ea2..3647f2f1 100644 --- a/web/src/hooks/simulation/useInterviewSession.spec.ts +++ b/web/src/hooks/simulation/useInterviewSession.spec.ts @@ -1,4 +1,5 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import usePersonaStore from '@/stores/usePersonaStore'; +import { act, renderHook, waitFor } from '@testing-library/react'; import axios from 'axios'; import toast from 'react-hot-toast'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -161,4 +162,38 @@ describe('useInterviewSession restore', () => { 'Impossible de reprendre la simulation pour le moment. Rechargez la page pour réessayer.', ); }); + + it('clears the persona even when updateInterview rejects on end', async () => { + act(() => usePersonaStore.getState().setPersona('marc-bernard')); + localStorageMock.store.set('currentInterviewID', 'interview-1'); + localStorageMock.store.set('currentInterviewURL', 'wss://example.test/ws'); + mockGetInterviewSession.mockResolvedValue({ + interviewID: 'interview-1', + dbStatus: 'in_progress', + sessionStatus: 'active', + queuePosition: 0, + entrypoint: 'wss://example.test/ws', + }); + mockUpdateInterview.mockRejectedValueOnce(new Error('network down')); + + const { result } = renderHook(() => + useInterviewSession({ + onConnect, + onDisconnect, + }), + ); + + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith('wss://example.test/ws'); + }); + + await act(async () => { + await result.current.handleStreamToggle(false); + }); + + expect(mockUpdateInterview).toHaveBeenCalledWith('interview-1', { + status: 'completed', + }); + expect(usePersonaStore.getState().selectedPersonaId).toBeNull(); + }); }); diff --git a/web/src/hooks/simulation/useInterviewSession.ts b/web/src/hooks/simulation/useInterviewSession.ts index ebcc44db..5c92d526 100644 --- a/web/src/hooks/simulation/useInterviewSession.ts +++ b/web/src/hooks/simulation/useInterviewSession.ts @@ -5,6 +5,7 @@ import { heartbeatInterview, updateInterview, } from '@/services/ai/http'; +import usePersonaStore from '@/stores/usePersonaStore'; import axios from 'axios'; import { useCallback, useEffect, useRef, useState } from 'react'; import toast from 'react-hot-toast'; @@ -389,6 +390,11 @@ export function useInterviewSession({ clearInterviewStorage(); } catch (error) { console.error('Failed to update interview status:', error); + } finally { + // Outside the try: a rejected updateInterview must not strand the + // persona. Leaving it set would silently skip the picker on the + // next visit. + usePersonaStore.getState().clearPersona(); } } diff --git a/web/src/hooks/useFocusTrap.spec.tsx b/web/src/hooks/useFocusTrap.spec.tsx new file mode 100644 index 00000000..3b6e2b95 --- /dev/null +++ b/web/src/hooks/useFocusTrap.spec.tsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useRef, useState } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { useFocusTrap } from './useFocusTrap'; + +function TrapHarness({ isActive }: { isActive: boolean }) { + const ref = useFocusTrap(isActive); + return ( +
+ + {isActive ? ( +
+ + +
+ ) : null} + +
+ ); +} + +function ToggleHarness() { + const [open, setOpen] = useState(false); + return ( +
+ + + {open ? ( + + ) : null} +
+ ); +} + +describe('useFocusTrap', () => { + it('moves focus to the first focusable element when activated', () => { + render(); + expect(screen.getByText('first')).toHaveFocus(); + }); + + it('does not steal focus when inactive', () => { + render(); + expect(document.body).toHaveFocus(); + }); + + it('wraps Tab from the last element back to the first', () => { + render(); + const last = screen.getByText('last'); + last.focus(); + fireEvent.keyDown(last, { key: 'Tab' }); + expect(screen.getByText('first')).toHaveFocus(); + }); + + it('wraps Shift+Tab from the first element back to the last', () => { + render(); + const first = screen.getByText('first'); + first.focus(); + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }); + expect(screen.getByText('last')).toHaveFocus(); + }); + + it('restores focus to the previously focused element on deactivate', () => { + render(); + const trigger = screen.getByText('trigger'); + trigger.focus(); + fireEvent.click(trigger); + expect(screen.getByText('first')).toHaveFocus(); + + fireEvent.click(screen.getByText('close')); + expect(trigger).toHaveFocus(); + }); + + it('focuses the getInitialFocus target instead of the first focusable child', () => { + function InitialFocusHarness() { + const targetRef = useRef(null); + const ref = useFocusTrap(true, { + getInitialFocus: () => targetRef.current, + }); + return ( +
+ + +
+ ); + } + + render(); + expect(screen.getByText('preferred')).toHaveFocus(); + }); + + it('falls back to the first focusable child when getInitialFocus returns null', () => { + function EmptyTargetHarness() { + const ref = useFocusTrap(true, { + getInitialFocus: () => null, + }); + return ( +
+ + +
+ ); + } + + render(); + expect(screen.getByText('first')).toHaveFocus(); + }); +}); diff --git a/web/src/hooks/useFocusTrap.ts b/web/src/hooks/useFocusTrap.ts new file mode 100644 index 00000000..a78ef701 --- /dev/null +++ b/web/src/hooks/useFocusTrap.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react'; + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +function getFocusable(container: HTMLElement): HTMLElement[] { + return Array.from( + container.querySelectorAll(FOCUSABLE_SELECTOR), + ); +} + +/** + * Traps keyboard focus inside a container while `isActive`. + * + * The codebase has no focus-trap library and no other hand-rolled trap, so this + * is the shared primitive: attach the returned ref to a dialog container. + * + * - Moves focus into the container on activate: `getInitialFocus()` when given, + * otherwise the first focusable child. + * - Cycles Tab / Shift+Tab within the container. + * - Restores focus to the previously focused element on deactivate. + * + * Roving selection *within* a radiogroup is the consumer's job — this hook only + * owns the Tab boundary. Pass `getInitialFocus` when the first focusable child + * is not where the user should land (e.g. a leading close button). It is a + * getter, not a ref, so it resolves after children have mounted. + * + * @example + * ```tsx + * const dialogRef = useFocusTrap(isOpen); + * return isOpen ?
: null; + * ``` + */ +export function useFocusTrap( + isActive: boolean, + options?: { getInitialFocus?: () => HTMLElement | null }, +) { + const containerRef = useRef(null); + const previouslyFocusedRef = useRef(null); + // Read through a ref so a caller's inline arrow does not re-run the effect + // (and re-steal focus) on every render. + const getInitialFocusRef = useRef(options?.getInitialFocus); + getInitialFocusRef.current = options?.getInitialFocus; + + useEffect(() => { + if (!isActive) return; + + previouslyFocusedRef.current = document.activeElement as HTMLElement | null; + + const container = containerRef.current; + if (container) { + const focusable = getFocusable(container); + (getInitialFocusRef.current?.() ?? focusable[0] ?? container).focus(); + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return; + + const currentContainer = containerRef.current; + if (!currentContainer) return; + + const focusable = getFocusable(currentContainer); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + return; + } + + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + previouslyFocusedRef.current?.focus(); + }; + }, [isActive]); + + return containerRef; +} + +export default useFocusTrap; diff --git a/web/src/routes/-simulations.spec.tsx b/web/src/routes/-simulations.spec.tsx index eec14f62..9356568f 100644 --- a/web/src/routes/-simulations.spec.tsx +++ b/web/src/routes/-simulations.spec.tsx @@ -4,6 +4,7 @@ import { useAudioStreaming, useInterviewSession, } from '@/hooks/simulation'; +import usePersonaStore from '@/stores/usePersonaStore'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider, @@ -178,6 +179,11 @@ describe('Simulations', () => { // The debug panel is gated behind VITE_SHOW_WS_DEBUG; enable it so the // panel-rendering assertions below have something to find. vi.stubEnv('VITE_SHOW_WS_DEBUG', 'true'); + // These tests exercise the established call/stream flow, not the persona + // picker itself (that flow is covered by simulation-workspace's own + // spec) — pre-select the default persona so the picker modal doesn't + // intercept the "start" button or duplicate the recruiter's name. + act(() => usePersonaStore.getState().setPersona('sophie-martin')); router.history.push('/simulations'); await act(async () => { @@ -185,6 +191,10 @@ describe('Simulations', () => { }); }); + afterEach(() => { + act(() => usePersonaStore.getState().clearPersona()); + }); + afterEach(() => { vi.unstubAllEnvs(); }); diff --git a/web/src/routes/applications/$applicationId/simulations.tsx b/web/src/routes/applications/$applicationId/simulations.tsx index 30ed87a1..1425e56f 100644 --- a/web/src/routes/applications/$applicationId/simulations.tsx +++ b/web/src/routes/applications/$applicationId/simulations.tsx @@ -2,7 +2,7 @@ import { iconMap } from '@/components/atoms/icon/icon-map'; import { SimulationWorkspace } from '@/components/organisms/simulation-workspace'; import { useApplications } from '@/services/applications/hooks'; import { createAuthGuard } from '@/utils/auth.guards'; -import { Link, createFileRoute } from '@tanstack/react-router'; +import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'; const BackIcon = iconMap['arrow-left']; @@ -37,11 +37,22 @@ function BackToRoadmap({ applicationId }: { applicationId: string }) { function ApplicationSimulations() { const { applicationId } = Route.useParams(); + const navigate = useNavigate(); const { data: applications, isLoading } = useApplications(); const application = applications?.find( (item) => item.applicationId === applicationId, ); + // Same destination as the BackToRoadmap link below — this one lives inside + // the persona picker modal, whose scrim otherwise blocks that link on + // first entry (#195 regression from PR #165). + const handleBackToRoadmap = () => { + void navigate({ + to: '/applications/$applicationId/roadmap', + params: { applicationId }, + }); + }; + const contextLabel = application ? [application.jobTitle, application.companyName] .filter(Boolean) @@ -73,6 +84,7 @@ function ApplicationSimulations() { title="" description="Practice with the AI recruiter for this job offer." contextLabel={contextLabel} + backTo={{ label: 'Back to roadmap', onNavigate: handleBackToRoadmap }} />
); diff --git a/web/src/stores/usePersonaStore.spec.ts b/web/src/stores/usePersonaStore.spec.ts new file mode 100644 index 00000000..c44c5707 --- /dev/null +++ b/web/src/stores/usePersonaStore.spec.ts @@ -0,0 +1,39 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import usePersonaStore from './usePersonaStore'; + +describe('usePersonaStore', () => { + beforeEach(() => { + act(() => usePersonaStore.getState().clearPersona()); + window.sessionStorage.clear(); + }); + + it('starts with no persona selected', () => { + const { result } = renderHook(() => usePersonaStore()); + expect(result.current.selectedPersonaId).toBeNull(); + }); + + it('stores a selected persona id', () => { + const { result } = renderHook(() => usePersonaStore()); + act(() => result.current.setPersona('marc-bernard')); + expect(result.current.selectedPersonaId).toBe('marc-bernard'); + }); + + it('clears the selection', () => { + const { result } = renderHook(() => usePersonaStore()); + act(() => result.current.setPersona('marc-bernard')); + act(() => result.current.clearPersona()); + expect(result.current.selectedPersonaId).toBeNull(); + }); + + it('persists to sessionStorage, not localStorage', () => { + const { result } = renderHook(() => usePersonaStore()); + act(() => result.current.setPersona('claire-dubois')); + + expect(window.sessionStorage.getItem('persona-storage')).toContain( + 'claire-dubois', + ); + expect(window.localStorage.getItem('persona-storage')).toBeNull(); + }); +}); diff --git a/web/src/stores/usePersonaStore.ts b/web/src/stores/usePersonaStore.ts new file mode 100644 index 00000000..710d9032 --- /dev/null +++ b/web/src/stores/usePersonaStore.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +/** + * Persona store state and actions. + */ +interface PersonaStore { + /** Persona id chosen for the current tab, or null when unchosen. */ + selectedPersonaId: string | null; + setPersona: (id: string) => void; + clearPersona: () => void; +} + +/** + * Zustand store for the recruiter persona chosen before a simulation. + * + * Backed by **sessionStorage**, not localStorage. The choice only needs to + * survive a mid-interview reload; in localStorage it would outlive the tab and + * silently re-use a long-forgotten pick on a later visit, with no modal shown. + * sessionStorage dies with the tab, so the purge is free. + * + * @example + * ```tsx + * const selectedPersonaId = usePersonaStore((state) => state.selectedPersonaId); + * const persona = getPersonaById(selectedPersonaId); + * ``` + */ +const usePersonaStore = create()( + persist( + (set) => ({ + selectedPersonaId: null, + setPersona: (id) => set({ selectedPersonaId: id }), + clearPersona: () => set({ selectedPersonaId: null }), + }), + { + name: 'persona-storage', + storage: createJSONStorage(() => sessionStorage), + }, + ), +); + +export default usePersonaStore; diff --git a/web/tests/application-simulation-back.spec.ts b/web/tests/application-simulation-back.spec.ts index 1fc1ee6b..7d153c76 100644 --- a/web/tests/application-simulation-back.spec.ts +++ b/web/tests/application-simulation-back.spec.ts @@ -40,11 +40,37 @@ test.beforeEach(async ({ page }) => { ); }); -test('back control on the simulation page returns to the application roadmap', async ({ +test('back control inside the persona picker returns to the application roadmap', async ({ page, }) => { await page.goto(`/applications/${app.applicationId}/simulations`); + // The persona picker modal is up on first entry and its scrim covers the + // page's own "Back to roadmap" link (#195 regression from PR #165) — so + // the real user path back is the modal's own back control. + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + const back = dialog.getByRole('button', { name: /back to roadmap/i }); + await expect(back).toBeVisible(); + + await back.click(); + + await expect(page).toHaveURL( + new RegExp(`/applications/${app.applicationId}/roadmap$`), + ); +}); + +test('back link on the page returns to the roadmap once the modal is dismissed', async ({ + page, +}) => { + await page.goto(`/applications/${app.applicationId}/simulations`); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + const back = page.getByRole('link', { name: /back to roadmap/i }); await expect(back).toBeVisible(); diff --git a/web/tests/persona-picker.spec.ts b/web/tests/persona-picker.spec.ts new file mode 100644 index 00000000..22167f78 --- /dev/null +++ b/web/tests/persona-picker.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + // /auth/status gates the /simulations route (createAuthGuard). Mirror the + // real payload shape — see applications.spec.ts for why 'none' + null org + // is the right stub for a route with no role gate. + await page.route('**/v1/api/auth/status', (route) => + route.fulfill({ + json: { authenticated: true, role: 'none', organizationId: null }, + }), + ); + // The sidebar's account switcher mounts on every authenticated page and + // fetches the current user. Left unmocked it hits the real backend (e2e CI + // runs one since #151), gets a 401, and the axios interceptor's + // failed-refresh path redirects the whole app to /login — so /simulations + // never renders. Serve a stub profile so the switcher is satisfied. + await page.route('**/v1/api/users/me', (route) => + route.fulfill({ + json: { + username: 'qa', + email: 'qa@talkup.ai', + firstName: null, + lastName: null, + profilePicture: null, + avatarAccentColor: null, + }, + }), + ); +}); + +test('asks for a persona on first entry and remembers the choice', async ({ + page, +}) => { + await page.goto('/simulations'); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText('Choose your interviewer'); + + // Four personas are offered as radio cards. + await expect(dialog.getByRole('radio')).toHaveCount(4); + await expect( + dialog.getByRole('radio', { name: /sophie martin/i }), + ).toBeVisible(); + await expect( + dialog.getByRole('radio', { name: /thomas leroy/i }), + ).toBeVisible(); + await expect( + dialog.getByRole('radio', { name: /claire dubois/i }), + ).toBeVisible(); + await expect( + dialog.getByRole('radio', { name: /marc bernard/i }), + ).toBeVisible(); + + // Picking a card highlights it and re-labels the primary footer button. + await dialog.getByRole('radio', { name: /marc bernard/i }).click(); + const startButton = dialog.getByRole('button', { + name: /start with marc bernard/i, + }); + await expect(startButton).toBeVisible(); + await startButton.click(); + + // Committing closes the modal and the chosen name appears on the page + // (InfoBox renders unconditionally; the avatar panel only mounts once a + // media stream is active, which this test does not grant). + await expect(dialog).not.toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Marc Bernard' }), + ).toBeVisible(); + + // sessionStorage survives a reload, so the picker must not re-ask. + await page.reload(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Marc Bernard' }), + ).toBeVisible(); + + // The choice is keyed under 'persona-storage' in sessionStorage. + const stored = await page.evaluate(() => + window.sessionStorage.getItem('persona-storage'), + ); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored as string)).toMatchObject({ + state: { selectedPersonaId: 'marc-bernard' }, + }); +}); + +test('dismissing with Escape falls back to the default recruiter', async ({ + page, +}) => { + await page.goto('/simulations'); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(dialog).not.toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Sophie Martin' }), + ).toBeVisible(); + + const stored = await page.evaluate(() => + window.sessionStorage.getItem('persona-storage'), + ); + expect(JSON.parse(stored as string)).toMatchObject({ + state: { selectedPersonaId: 'sophie-martin' }, + }); +});