From eac096d99ec4909e47172f3d402a4f9c52de42bf Mon Sep 17 00:00:00 2001 From: Martins Aloba Date: Sun, 17 May 2026 07:12:34 -0500 Subject: [PATCH 1/3] =?UTF-8?q?test(15-2):=20hook=20integration=20tests=20?= =?UTF-8?q?=E2=80=94=20usePronunciation=20full=20mocked-API=20coverage=20(?= =?UTF-8?q?13=20cases)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/use-pronunciation.test.tsx | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 src/hooks/__tests__/use-pronunciation.test.tsx diff --git a/src/hooks/__tests__/use-pronunciation.test.tsx b/src/hooks/__tests__/use-pronunciation.test.tsx new file mode 100644 index 0000000..1a3dfc9 --- /dev/null +++ b/src/hooks/__tests__/use-pronunciation.test.tsx @@ -0,0 +1,387 @@ +/** + * Story 15-2 — mocked-API integration tests for `usePronunciation` hook. + * + * The hook wraps Azure Speech pronunciation assessment (Edge Function call) + * with a state machine for recording → assess → history-append. Tests use + * `react-test-renderer` + `act` + `HookHost` consumer pattern (Story 12-1 + * P8 / 12-9 / 14-X precedent). + * + * Mocks: + * - `use-audio-recorder` → stubbed recorder + * - `expo-file-system/legacy` → stubbed readAsStringAsync + * - `@/src/lib/pronunciation` → mock `assessPronunciation` only; PASS + * THROUGH the real `identifyWeakSounds` (pure aggregator already tested + * by Story 15-1 pronunciation.test.ts) + * - `@/src/lib/sentry` → captureError + addBreadcrumb stubs + * - `@/src/lib/error-messages` → classifyError stub returning {message} + */ + +/* eslint-disable import/first -- jest.mock factories must precede imports */ + +jest.mock("@/src/hooks/use-audio-recorder", () => ({ + __esModule: true, + useAudioRecorder: jest.fn(), +})); + +jest.mock("expo-file-system/legacy", () => ({ + __esModule: true, + readAsStringAsync: jest.fn(), + EncodingType: { Base64: "base64" }, +})); + +jest.mock("@/src/lib/pronunciation", () => { + // PASS THROUGH the real identifyWeakSounds (pure aggregator, Story 15-1). + const actual = jest.requireActual("@/src/lib/pronunciation"); + return { + __esModule: true, + ...actual, + assessPronunciation: jest.fn(), + }; +}); + +jest.mock("@/src/lib/sentry", () => ({ + __esModule: true, + captureError: jest.fn(), + addBreadcrumb: jest.fn(), +})); + +jest.mock("@/src/lib/error-messages", () => ({ + __esModule: true, + classifyError: jest.fn((_err: unknown, fallback: string) => ({ + message: fallback, + category: "network", + })), +})); + +import { readAsStringAsync } from "expo-file-system/legacy"; +import React from "react"; +import { Text } from "react-native"; +import { act, create } from "react-test-renderer"; + +import { useAudioRecorder } from "@/src/hooks/use-audio-recorder"; +import { usePronunciation, type UsePronunciationReturn } from "@/src/hooks/use-pronunciation"; +import { assessPronunciation, type PronunciationResult } from "@/src/lib/pronunciation"; +import { captureError } from "@/src/lib/sentry"; + +const mockUseAudioRecorder = useAudioRecorder as jest.Mock; +const mockReadAsStringAsync = readAsStringAsync as jest.Mock; +const mockAssessPronunciation = assessPronunciation as jest.Mock; +const mockCaptureError = captureError as jest.Mock; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeResult(overrides: Partial = {}): PronunciationResult { + return { + accuracyScore: 85, + fluencyScore: 80, + completenessScore: 90, + prosodyScore: 75, + overallScore: 82, + words: [ + { + word: "bonjour", + accuracyScore: 85, + errorType: "None", + phonemes: [ + { phoneme: "b", accuracyScore: 90 }, + { phoneme: "ɔ̃", accuracyScore: 80 }, + ], + }, + ], + weakPhonemes: [{ phoneme: "ɔ̃", accuracyScore: 50 }], + ...overrides, + }; +} + +function makeRecorderStub(overrides: Record = {}) { + return { + isRecording: false, + hasPermission: true, + durationMs: 0, + error: null, + requestPermission: jest.fn(async () => true), + startRecording: jest.fn(async () => undefined), + stopRecording: jest.fn(async () => "file:///tmp/audio.wav" as string | null), + getBase64Audio: jest.fn(async () => "stub-base64"), + ...overrides, + }; +} + +interface HookHostProps { + result: { current: UsePronunciationReturn | null }; +} + +function HookHost({ result }: HookHostProps): React.ReactElement { + const value = usePronunciation(); + result.current = value; + return host; +} + +const activeRenderers: ReturnType[] = []; + +function renderHost() { + const result: { current: UsePronunciationReturn | null } = { current: null }; + let renderer: ReturnType; + act(() => { + renderer = create(); + }); + activeRenderers.push(renderer!); + return { result, renderer: renderer! }; +} + +beforeEach(() => { + jest.clearAllMocks(); + // Default recorder stub — tests can override with mockReturnValueOnce. + mockUseAudioRecorder.mockReturnValue(makeRecorderStub()); + mockReadAsStringAsync.mockResolvedValue("stub-base64-audio"); +}); + +afterEach(() => { + for (const renderer of activeRenderers) { + try { + act(() => renderer.unmount()); + } catch { + // already unmounted + } + } + activeRenderers.length = 0; +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Story 15-2 — usePronunciation", () => { + describe("Initial state", () => { + it("Case 1: initial state is { isAssessing:false, result:null, weakSounds:[], history:[], error:null, isRecording:false }", () => { + const { result } = renderHost(); + expect(result.current?.isAssessing).toBe(false); + expect(result.current?.result).toBeNull(); + expect(result.current?.weakSounds).toEqual([]); + expect(result.current?.history).toEqual([]); + expect(result.current?.error).toBeNull(); + expect(result.current?.isRecording).toBe(false); + }); + }); + + describe("startAssessment", () => { + it("Case 2: clears prior result + error AND delegates to recorder.startRecording", async () => { + const recorderStub = makeRecorderStub(); + mockUseAudioRecorder.mockReturnValue(recorderStub); + // Seed state with a prior result via finishAssessment happy path + mockAssessPronunciation.mockResolvedValueOnce(makeResult()); + const { result } = renderHost(); + await act(async () => { + await result.current!.finishAssessment("bonjour"); + }); + expect(result.current?.result).not.toBeNull(); + + // Now call startAssessment — should clear result + error, fire recorder + await act(async () => { + await result.current!.startAssessment(); + }); + expect(result.current?.result).toBeNull(); + expect(result.current?.error).toBeNull(); + expect(recorderStub.startRecording).toHaveBeenCalledTimes(1); + }); + }); + + describe("finishAssessment — async path", () => { + it("Case 3: happy path — recorder URI → readAsStringAsync → assessPronunciation → state updated with result + history + weakSounds", async () => { + const expectedResult = makeResult({ overallScore: 88 }); + mockAssessPronunciation.mockResolvedValueOnce(expectedResult); + const { result } = renderHost(); + let returnedValue: PronunciationResult | null = null; + await act(async () => { + returnedValue = await result.current!.finishAssessment("bonjour le monde"); + }); + // Return value is the resolved result + expect(returnedValue).toEqual(expectedResult); + // State is updated + expect(result.current?.isAssessing).toBe(false); + expect(result.current?.result).toEqual(expectedResult); + expect(result.current?.history).toEqual([expectedResult]); + expect(result.current?.error).toBeNull(); + // Verify the call chain + expect(mockReadAsStringAsync).toHaveBeenCalledWith( + "file:///tmp/audio.wav", + expect.objectContaining({ encoding: "base64" }) + ); + expect(mockAssessPronunciation).toHaveBeenCalledWith("stub-base64-audio", "bonjour le monde"); + }); + + it("Case 4: recorder returns null (no audio) → error set + isAssessing:false + no captureError + no readAsStringAsync call", async () => { + mockUseAudioRecorder.mockReturnValue( + makeRecorderStub({ stopRecording: jest.fn(async () => null) }) + ); + const { result } = renderHost(); + let returnedValue: PronunciationResult | null = makeResult(); + await act(async () => { + returnedValue = await result.current!.finishAssessment("bonjour"); + }); + expect(returnedValue).toBeNull(); + expect(result.current?.isAssessing).toBe(false); + expect(result.current?.error).toBe("No audio recorded"); + expect(mockCaptureError).not.toHaveBeenCalled(); + expect(mockReadAsStringAsync).not.toHaveBeenCalled(); + expect(mockAssessPronunciation).not.toHaveBeenCalled(); + }); + + it("Case 5: assessPronunciation throws → captureError fires with `pronunciation-assessment` feature tag + error set via classifyError + isAssessing:false", async () => { + const azureErr = new Error("Azure 503 Service Unavailable"); + mockAssessPronunciation.mockRejectedValueOnce(azureErr); + const { result } = renderHost(); + let returnedValue: PronunciationResult | null = makeResult(); + await act(async () => { + returnedValue = await result.current!.finishAssessment("bonjour"); + }); + expect(returnedValue).toBeNull(); + expect(result.current?.isAssessing).toBe(false); + expect(result.current?.error).toBe( + "Pronunciation assessment failed. Please try recording again." + ); + expect(mockCaptureError).toHaveBeenCalledWith(azureErr, "pronunciation-assessment"); + }); + + it("Case 6: returns the resolved PronunciationResult value (callable contract — not just state update)", async () => { + const expected = makeResult({ accuracyScore: 99 }); + mockAssessPronunciation.mockResolvedValueOnce(expected); + const { result } = renderHost(); + let returnedValue: PronunciationResult | null = null; + await act(async () => { + returnedValue = await result.current!.finishAssessment("test"); + }); + expect(returnedValue).toEqual(expected); + expect(returnedValue).toBe(expected); // Reference identity preserved + }); + }); + + describe("assessFromUri — async path (skips recording)", () => { + it("Case 7: happy path — reads from given URI → assessPronunciation → state updated (recorder NOT called)", async () => { + const expectedResult = makeResult({ overallScore: 70 }); + mockAssessPronunciation.mockResolvedValueOnce(expectedResult); + const recorderStub = makeRecorderStub(); + mockUseAudioRecorder.mockReturnValue(recorderStub); + const { result } = renderHost(); + let returnedValue: PronunciationResult | null = null; + await act(async () => { + returnedValue = await result.current!.assessFromUri("file:///custom/path.wav", "salut"); + }); + expect(returnedValue).toEqual(expectedResult); + expect(result.current?.result).toEqual(expectedResult); + expect(result.current?.history).toEqual([expectedResult]); + // assessFromUri skips recording — neither start nor stop should fire + expect(recorderStub.startRecording).not.toHaveBeenCalled(); + expect(recorderStub.stopRecording).not.toHaveBeenCalled(); + // But readAsStringAsync IS called with the passed URI + expect(mockReadAsStringAsync).toHaveBeenCalledWith( + "file:///custom/path.wav", + expect.objectContaining({ encoding: "base64" }) + ); + }); + + it("Case 8: assessFromUri error path — assessPronunciation throws → captureError + classifyError + isAssessing:false", async () => { + const err = new Error("Azure timeout"); + mockAssessPronunciation.mockRejectedValueOnce(err); + const { result } = renderHost(); + let returnedValue: PronunciationResult | null = makeResult(); + await act(async () => { + returnedValue = await result.current!.assessFromUri("file:///x.wav", "test"); + }); + expect(returnedValue).toBeNull(); + expect(result.current?.isAssessing).toBe(false); + expect(result.current?.error).toBe( + "Pronunciation assessment failed. Please try recording again." + ); + expect(mockCaptureError).toHaveBeenCalledWith(err, "pronunciation-assessment"); + }); + }); + + describe("clearResult", () => { + it("Case 9: clearResult resets result + error but PRESERVES history (Story 12-12 FIFO + Story 15-1 weakSounds aggregation continuity)", async () => { + mockAssessPronunciation.mockResolvedValueOnce(makeResult()); + const { result } = renderHost(); + await act(async () => { + await result.current!.finishAssessment("bonjour"); + }); + expect(result.current?.result).not.toBeNull(); + expect(result.current?.history).toHaveLength(1); + + act(() => { + result.current!.clearResult(); + }); + expect(result.current?.result).toBeNull(); + expect(result.current?.error).toBeNull(); + // history is NOT cleared + expect(result.current?.history).toHaveLength(1); + }); + }); + + describe("getWeakPhonemes", () => { + it("Case 10: getWeakPhonemes returns state.result.weakPhonemes when result exists, [] when null", async () => { + const withWeak = makeResult({ + weakPhonemes: [ + { phoneme: "ʁ", accuracyScore: 40 }, + { phoneme: "ɔ̃", accuracyScore: 50 }, + ], + }); + mockAssessPronunciation.mockResolvedValueOnce(withWeak); + const { result } = renderHost(); + // Before any assessment: result is null → [] + expect(result.current!.getWeakPhonemes()).toEqual([]); + // After assessment: returns weakPhonemes from the result + await act(async () => { + await result.current!.finishAssessment("test"); + }); + expect(result.current!.getWeakPhonemes()).toEqual([ + { phoneme: "ʁ", accuracyScore: 40 }, + { phoneme: "ɔ̃", accuracyScore: 50 }, + ]); + }); + }); + + describe("History accumulation + Story 12-12 FIFO cap integration", () => { + it("Case 11: 3 sequential finishAssessment calls produce history.length === 3", async () => { + mockAssessPronunciation + .mockResolvedValueOnce(makeResult({ accuracyScore: 70 })) + .mockResolvedValueOnce(makeResult({ accuracyScore: 80 })) + .mockResolvedValueOnce(makeResult({ accuracyScore: 90 })); + const { result } = renderHost(); + for (let i = 0; i < 3; i++) { + await act(async () => { + await result.current!.finishAssessment(`test ${i}`); + }); + } + expect(result.current?.history).toHaveLength(3); + expect(result.current?.history[0].accuracyScore).toBe(70); + expect(result.current?.history[2].accuracyScore).toBe(90); + }); + + it("Case 12: 51 sequential finishAssessment calls → history capped at 50 (Story 12-12 MAX_PRONUNCIATION_HISTORY); oldest evicted", async () => { + // Pre-load 51 distinct results into the mock queue + for (let i = 0; i < 51; i++) { + mockAssessPronunciation.mockResolvedValueOnce(makeResult({ accuracyScore: i })); + } + const { result } = renderHost(); + for (let i = 0; i < 51; i++) { + await act(async () => { + await result.current!.finishAssessment(`test ${i}`); + }); + } + expect(result.current?.history).toHaveLength(50); + // First entry (accuracyScore=0) was evicted; oldest surviving is accuracyScore=1 + expect(result.current?.history[0].accuracyScore).toBe(1); + expect(result.current?.history[49].accuracyScore).toBe(50); + }); + }); + + describe("isRecording mirroring", () => { + it("Case 13: isRecording mirrors recorder.isRecording (live-state delegation)", () => { + mockUseAudioRecorder.mockReturnValue(makeRecorderStub({ isRecording: true })); + const { result } = renderHost(); + expect(result.current?.isRecording).toBe(true); + }); + }); +}); From fad6a379c7884e5f6637a7622c9f9bab9df1ebdb Mon Sep 17 00:00:00 2001 From: Martins Aloba Date: Sun, 17 May 2026 07:12:41 -0500 Subject: [PATCH 2/3] chore(15-2): Story 15-2 spec + CLAUDE.md paragraph + sprint-status done --- CLAUDE.md | 2 + .../15-2-hook-integration-tests.md | 132 ++++++++++++++++++ .../sprint-status.yaml | 4 +- 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/15-2-hook-integration-tests.md diff --git a/CLAUDE.md b/CLAUDE.md index 2bc4428..ede926b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,6 +201,8 @@ A reusable pattern for capping in-memory data structures + UI work-budgets. Two **Lib unit tests — `srs.ts` SM-2 algorithm + `pronunciation.ts` `identifyWeakSounds` + `cache.ts` core API + `use-dictation.ts` `compareSentences`:** post-Epic-15.1, the FIRST Epic 15 (Test Coverage & QA Infrastructure) story closes the largest pure-function-test gap surfaced by the Story 12-10 audit refresh footnote. Per the coverage inventory at story-creation time, `activity.ts` was already fully tested (Story 9-2 + 12-3); `memory.ts` + `error-tracker.ts` had substantial partial coverage (sanitize via Story 9-4 prompt-injection.test.ts + dedup via Story 11-6 error-tracker-dedupe.test.ts + e2e clustering via 11-6); `pronunciation.ts` had history-cap coverage (Story 12-12) but NOT `identifyWeakSounds`. Story 15-1 lands **GAP-only scope** across **4 pure-function surfaces** (no source-module modifications — test-only): **(1) NEW [`src/lib/__tests__/srs.test.ts`](src/lib/__tests__/srs.test.ts)** (14 cases) pins the full SM-2 algorithm contract — 3 incorrect-response cases (quality 0/1/2 ease-factor deltas −0.80/−0.54/−0.32 + reset to `repetitions=0`, `intervalDays=1`); 3 correct-response cases (quality 3/4/5 ease-factor deltas −0.14/0/+0.10 + interval progression 1 → 6 → round(prev × ef)); 3 ease-factor 1.3-floor clamping cases (boundary + already-at-floor + just-above-floor); 3-consecutive-correct chain verification; 365-day interval cap; midnight-snap `nextReview` math via Date.now() before/after delta check; no-mutation invariant via input-snapshot equality + determinism via twice-call equivalence. **(2) NEW [`src/lib/__tests__/pronunciation.test.ts`](src/lib/__tests__/pronunciation.test.ts)** (10 cases) pins `identifyWeakSounds` contract — empty input + no-weak happy path + below-threshold-but-count<3 NOT flagged + single-phoneme-aggregated-across-results + same-phoneme-aggregated-across-words-in-one-result + threshold boundaries (`avgScore < 70` strict + `count >= 3` boundary) + multi-phoneme ranking ASCENDING by avgScore (worst first) + no-mutation deep-equal. **(3) NEW [`src/lib/__tests__/cache.test.ts`](src/lib/__tests__/cache.test.ts)** (20 cases) covers the AsyncStorage path (existing `cache-flush.test.ts` covers write-queue idempotency; existing `cache-secure-routing.test.ts` covers the Story 12-7 SecureStore fork) — `getCache` empty/fresh/TTL-fresh-boundary/TTL-expired-boundary/corrupted-JSON/throw paths each with `captureError` routing pinned; `setCache` envelope shape `{data, timestamp, ttlMs}` + DEFAULT_TTL_MS (1 hour) fallback + namespacing `@companion_cache::` (different userIds never collide); `setCache` → `getCache` round-trip with nested object structure; `invalidateCache` happy + error paths; `cacheWithFallback` 4-path matrix (fetcher-succeeds-writes-cache, fetcher-throws-stale-cache-hit-returns-fromCache:true, fetcher-throws-no-cache-rethrows-original, fetcher-throws-cache-also-throws-captures-fallback-read-error-rethrows-original); constant pins for `CACHE_KEYS` (5 keys) + `CACHE_TTL` (6 durations) + `SECURE_CACHE_KEYS` size===1 with PROFILE in / SKILLS+VOCABULARY out. **(4) NEW [`src/hooks/__tests__/use-dictation-compare.test.ts`](src/hooks/__tests__/use-dictation-compare.test.ts)** (15 cases) pins the pure word-by-word `compareSentences` helper from [`src/hooks/use-dictation.ts:88`](src/hooks/use-dictation.ts#L88) (despite living in a hook file, the function is pure — no React, no async, no side effects, so it fits 15-1 scope). Test mocks `@/src/hooks/use-audio-player` (transitive `expo-audio` native crash in Jest) + `@/src/lib/openai` (transitive supabase chain) at module load to keep the import boundary clean. Cases cover: perfect-match → 100% / one-wrong-word with `typed` populated → round(2/3 × 100)=67% / missing-word semantics (user shorter → past-user-length positions are "missing", overlap positions compare normally) / extra-word (user longer → extra tokens IGNORED; only original-length iterated) / empty-userInput → all "missing" / empty-original → empty wordResults + accuracy=0 + isFullyCorrect=true vacuously / both-empty same / case-insensitive ("Bonjour" vs "bonjour") with display preserving original capitalization / accent-insensitive ("café" vs "cafe") with display preserving accent / trailing punctuation stripped / internal comma stripped / multi-space collapses / leading+trailing whitespace trimmed / apostrophe stripped ("l'eau" matches "leau") / fresh wordResults array per call (no shared module-level cache). **3 operator decisions resolved per Recommended** (Q1 defer `assessPronunciation` Edge wrapper to 15-1-followup; Q2 defer `memory.ts` async/DB-touching paths to 15-2; Q3 defer `error-tracker.ts` non-dedup paths to 15-2) — keeps 15-1 pure-function-only per the Epic 14 retro lesson that test-writing stories are at scope-drift risk. **Explicitly out of scope** (filed for follow-up if motivated): `memory.ts` `extractFacts`/`persistMemories`/`retrieveMemories`; `error-tracker.ts` `getTopErrors`/`extractErrorsFromCorrections`/`persistErrorPatterns`/`getRecentResolvedError`; `pronunciation.ts` `assessPronunciation` Edge Function wrapper; `cache.ts` `enqueueWrite`/`flushWriteQueue` (already in `cache-flush.test.ts`)/`clearUserCache`/`clearAllCache` multi-key sweeps; `use-dictation.ts` `analyzeErrorPatterns`. **Cross-story invariants preserved by construction:** zero source-module modifications (Story 15-1 is test-only); Story 9-3 Sentry allowlist zero-diff (cache tests verify EXISTING `cache-get`/`cache-set`/`cache-invalidate`/`cache-fallback-read` feature tags fire; no new tags); Story 12-7 SecureStore fork unchanged (cache.test.ts uses non-secure key `"skills"` to exercise AsyncStorage path); all Epic 14 invariants orthogonal. **+59 net Jest cases** (2099 → 2158; spec target +50-65 — squarely in range). 4 new test files; 0 modified source files. All 5 design-system gates green (type-check 0 errors / lint 0 warnings / prettier clean / check:tokens clean / jest 113 suites / 2158 tests). Verified 2026-05-16, story 15-1. +**Hook integration tests — `usePronunciation` mocked-API coverage:** post-Epic-15.2 closes the largest hook-test gap surfaced by the 15-2 coverage inventory. Pre-15-2 `usePronunciation` had zero direct test coverage; the other 3 hooks in the spec inventory (`use-auth`, `use-exercise`, `use-realtime-voice`) already had substantial shape/binding tests (Story 12-2 + 10-8 + 12-1 P8). Story 15-2 lands NEW [`src/hooks/__tests__/use-pronunciation.test.tsx`](src/hooks/__tests__/use-pronunciation.test.tsx) (13 cases via `react-test-renderer` + `act` + `HookHost` consumer pattern from Story 12-1 P8 / 12-9 / 14-X / 15-1 precedent). Mocks `use-audio-recorder` (stubbed recorder), `expo-file-system/legacy` (stubbed `readAsStringAsync`), `@/src/lib/pronunciation` (mock `assessPronunciation` only — passes through real `identifyWeakSounds` aggregator already pinned by Story 15-1 `pronunciation.test.ts`), `@/src/lib/sentry`, `@/src/lib/error-messages`. Cases cover the full state machine: initial-state pin; `startAssessment` clears prior result + delegates to `recorder.startRecording`; `finishAssessment` 4 paths (happy-with-state-update + recorder-returns-null + assess-throws + return-value-callable-contract); `assessFromUri` 2 paths (happy + error); `clearResult` resets result/error but PRESERVES history (Story 12-12 FIFO continuity invariant); `getWeakPhonemes` null + populated; history accumulation 3 sequential calls = length 3 + 51 sequential calls = capped at 50 per Story 12-12 `MAX_PRONUNCIATION_HISTORY` (oldest evicted via real `appendCappedHistory` integration); `isRecording` mirrors recorder. **3 operator decisions resolved per Recommended:** Q1 react-test-renderer continuity (vs RTL `renderHook`), Q2 defer use-auth/use-exercise/use-realtime-voice full-flow integration extensions to `15-2-followup-existing-hook-flow-integration` (Epic 14 14-4 R1-22-patch lesson — broad scope balloons), Q3 mock at `assessPronunciation` lib boundary (NOT `supabase.functions.invoke` one level deeper). **Cross-story invariants preserved:** Story 9-3 Sentry allowlist zero-diff (test verifies existing `pronunciation-assessment` feature tag fires); Story 12-12 `appendCappedHistory` + `MAX_PRONUNCIATION_HISTORY = 50` unchanged (verified via 51-call eviction); Story 15-1 `identifyWeakSounds` unchanged (test uses real helper via `jest.requireActual`). **+13 net Jest cases** (2159 → 2172; spec target +12-15 squarely in range). 1 new test file; 0 source-module modifications. All 5 design-system gates green. Verified 2026-05-17, story 15-2. + ### Routing (`app/`) Expo Router file-based routing with three route groups: diff --git a/_bmad-output/implementation-artifacts/15-2-hook-integration-tests.md b/_bmad-output/implementation-artifacts/15-2-hook-integration-tests.md new file mode 100644 index 0000000..cf556fc --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-2-hook-integration-tests.md @@ -0,0 +1,132 @@ +# Story 15.2: Hook integration tests — `use-pronunciation` (full mocked-API coverage) + +Status: review + + + +## Story + +As **a developer maintaining the Companion codebase**, +I want **the `use-pronunciation` hook (which has zero direct test coverage today) to have full mocked-API integration tests covering its 3 async paths (`startAssessment` / `finishAssessment` / `assessFromUri`), state machine, and error handling** — +so that **future refactors to the Azure pronunciation Edge Function or the recording lifecycle don't silently regress the practice screen at [`app/(tabs)/practice/pronunciation.tsx`]()**. + +## Background — Why This Story Exists + +### Spec deliverable (refined) + +[`_bmad-output/planning-artifacts/shippable-roadmap.md`](_bmad-output/planning-artifacts/shippable-roadmap.md) line 294 — Epic 15 deliverable 15.2: + +> 15.2 Hook integration tests with @testing-library/react-native — `use-auth` (sign-in success/failure, token refresh), `use-exercise` (MCQ + writing flows), `use-realtime-voice` (mocked WebSocket), `use-pronunciation` (mocked API). + +### Coverage inventory (2026-05-17) + +| Hook | Existing tests | Status | +| --- | --- | --- | +| `use-auth` | `use-auth.test.tsx` (6 cases — Story 12-2 hook-binding) + `use-auth-line-budget.test.ts` | ✅ Substantial shape coverage; sign-in flow integration cases would extend (DEFERRED) | +| `use-exercise` | `use-exercise.test.ts` (11 cases — Story 10-8 persist contract + question-stem-hash payload) | ✅ Substantial persistence coverage; MCQ+writing full-flow integration would extend (DEFERRED) | +| `use-realtime-voice` | `use-realtime-voice.test.tsx` (6 cases — Story 12-1 orchestrator binding) + `use-realtime-voice-line-budget.test.ts` + Story 11-2 reconnect + barge-in coverage via `realtime-orchestrator.test.ts` | ✅ Substantial integration coverage at the orchestrator level (the hook is a thin React binding per Story 12-1's god-hook decomposition); WS-flow extensions DEFERRED | +| **`use-pronunciation`** | **none** | **GAP — 15-2 scope** | + +### Why GAP-only + +Per Epic 14 retro lesson and Story 15-1 precedent: test-writing stories that touch many modules at once produce broad review-patch surface (Story 14-4 → 22 R1 patches). The `use-pronunciation` hook is the only hook in the spec's target list with zero direct coverage; the other 3 have substantial shape/binding tests. Full-flow integration extensions to existing test files belong in a follow-up to avoid scope drift. + +### What `use-pronunciation` does (170 LOC) + +The hook wraps Azure Speech Service pronunciation assessment ([`src/lib/pronunciation.ts`](src/lib/pronunciation.ts) `assessPronunciation` Edge Function call). Public surface: + +- **State:** `{isAssessing, result, weakSounds, history, error, isRecording}` +- **Actions:** `startAssessment()`, `finishAssessment(referenceText)`, `assessFromUri(uri, referenceText)`, `clearResult()`, `getWeakPhonemes()` +- **Dependencies:** `useAudioRecorder` (recording lifecycle), `expo-file-system` (read audio as base64), `assessPronunciation` (Edge call), `identifyWeakSounds` (pure aggregator from Story 15-1 `pronunciation.test.ts`), `appendCappedHistory` (Story 12-12 FIFO cap), `classifyError` (Story 9-X error-messaging), `captureError` (Story 9-3 Sentry). + +## Acceptance Criteria + +### AC-A: NEW `src/hooks/__tests__/use-pronunciation.test.tsx` (≥12 cases) + +Test file uses `@testing-library/react-native` (already installed at v13.3.3) OR `react-test-renderer` (Story 15-1 / 12-9 / 12-1 P8 precedent). The former is simpler for hooks; pick the simpler approach unless there's a specific reason. Story 15-1 used `react-test-renderer` for runtime tests — same precedent applies here. + +Mocks (jest.mock at file top): +- `@/src/hooks/use-audio-recorder` → stubbed `{startRecording, stopRecording, isRecording}` controlled per-test +- `expo-file-system/legacy` → stubbed `readAsStringAsync` returning a fixed base64 +- `@/src/lib/pronunciation` → stubbed `assessPronunciation` (mocked Edge call) + REAL `identifyWeakSounds` (already pure, Story 15-1 tested) +- `@/src/lib/sentry` → stubbed `captureError` for assertion +- `@/src/lib/error-messages` → stubbed `classifyError` returning a known `{message}` envelope + +Required cases (≥12): + +1. **Initial state**: `{isAssessing: false, result: null, weakSounds: [], history: [], error: null, isRecording: false}` immediately on mount. +2. **`startAssessment` clears prior result/error AND delegates to recorder.startRecording**: set state to have a prior result (via successful `finishAssessment`), then call `startAssessment`, verify `result === null` + `error === null` + `recorder.startRecording` was called. +3. **`finishAssessment` happy path**: recorder returns a URI; readAsStringAsync returns base64; assessPronunciation resolves with a result; state updates with `{isAssessing: false, result: , history: [], weakSounds: }`. +4. **`finishAssessment` recorder returns null** (user cancelled / no audio): `error === "No audio recorded"` + `isAssessing: false` + no captureError fired. +5. **`finishAssessment` `assessPronunciation` throws** (network / Azure error): `captureError(err, "pronunciation-assessment")` fires + `error` set via `classifyError` + `isAssessing: false`. +6. **`finishAssessment` returns the resolved PronunciationResult value** (not just stores in state — the callable contract). +7. **`assessFromUri` happy path** (skips recording; reads from given uri): readAsStringAsync called with the passed uri; assessPronunciation called with the resulting base64; state updates same as finishAssessment. +8. **`assessFromUri` error path** (`assessPronunciation` throws): captureError + classifyError surfacing + `isAssessing: false`. +9. **`clearResult` resets result + error but PRESERVES history**: after a successful assessment, call `clearResult`, verify `result === null && error === null && history.length === 1` (history NOT cleared). +10. **`getWeakPhonemes` returns `state.result.weakPhonemes` when result exists, `[]` when null**. +11. **History accumulation**: 3 sequential `finishAssessment` calls produce `history.length === 3` (delegated to Story 12-12 `appendCappedHistory` — verify the cap helper is invoked, not duplicated). +12. **History FIFO cap integration**: after 51 sequential `finishAssessment` calls, `history.length === 50` (the `MAX_PRONUNCIATION_HISTORY = 50` cap from Story 12-12 fires; first result evicted). This is a sanity-level integration check, not a re-test of Story 12-12. +13. **`isRecording` mirrors `recorder.isRecording`**: when the mocked recorder's `isRecording === true`, the hook return reflects it. + +### AC-B: Quality gates + +14. All 5 design-system gates green (type-check / lint / format / check:tokens / jest). +15. **Net test growth target:** **+12 to +15 net Jest cases** (2159 → 2171-2174). + +### AC-C: Cross-story invariants + +16. Story 9-3 Sentry allowlist zero-diff (verifies existing `pronunciation-assessment` feature tag fires). +17. Story 12-12 `appendCappedHistory` + `MAX_PRONUNCIATION_HISTORY = 50` unchanged (15-2 verifies integration, doesn't modify). +18. Story 15-1 `identifyWeakSounds` unchanged (15-2 uses the real helper rather than mocking — Story 15-1 already pinned its contract). +19. **No source-module modifications** — test-only story. + +### Z. Polish Requirements + +- [x] All quality gates pass: `npm run type-check && npm run lint && npm run format:check && npm run check:tokens && npx jest`. + +### Story File Self-Check + +- [x] `git status` lists this file as Untracked. +- [x] `npx prettier --check` passes. + +## Operator Decisions + +| Q | Question | Options | Recommended | +| --- | --- | --- | --- | +| **Q1** | `@testing-library/react-native` vs `react-test-renderer` for the test file? | (a) RTL — simpler hook-test API via `renderHook`; (b) react-test-renderer — Story 15-1 precedent | **(b) react-test-renderer** — consistency with Story 15-1 + 12-9 + 12-1 P8 + 14-X. Switching test frameworks mid-epic creates pattern drift. | +| **Q2** | Extend existing `use-auth.test.tsx` / `use-exercise.test.ts` / `use-realtime-voice.test.tsx` with integration-flow cases? | (a) Include in 15-2; (b) Defer to `15-2-followup-existing-hook-flow-integration` | **(b) Defer** — existing tests provide substantial shape/binding coverage; full-flow extensions would balloon R1 patches per Epic 14 14-4 lesson. | +| **Q3** | Mock `assessPronunciation` (the lib-level Edge wrapper) OR `supabase.functions.invoke` (one level deeper)? | (a) Mock `assessPronunciation`; (b) Mock supabase | **(a) Mock `assessPronunciation`** — boundary is at the lib-export level; the Edge function call is `pronunciation.ts`'s concern and is OUT of `use-pronunciation`'s scope. | + +## Out of Scope + +- `use-auth` / `use-exercise` / `use-realtime-voice` integration-flow extensions (deferred to `15-2-followup-existing-hook-flow-integration`) +- `assessPronunciation()` Edge wrapper coverage (Story 15-1 Q1 deferral; would belong to `15-1-followup-pronunciation-edge-wrapper`) +- `useAudioRecorder` hook internal tests (deferred to a separate hook-test follow-up) +- Source-module modifications + +## Tasks / Subtasks + +- [x] **Task 1: Write `src/hooks/__tests__/use-pronunciation.test.tsx`** (AC: 1-13) + - [x] Set up jest.mock factories for `use-audio-recorder`, `expo-file-system/legacy`, `@/src/lib/pronunciation` (mock `assessPronunciation` only; pass through `identifyWeakSounds`), `@/src/lib/sentry`, `@/src/lib/error-messages`. + - [x] Write 13 cases per the AC table. +- [x] **Task 2: Quality gates** (AC: 14-15) +- [x] **Task 3: Housekeeping** — sprint-status, CLAUDE.md paragraph (per Epic 14 retro AI #5). + +## Dev Notes + +- The hook uses `useState` with functional updaters consistently (Story 12-12 P2-22 closure-fix pattern preserved). +- `expo-file-system/legacy` is the legacy import path; the mock path should match exactly. +- `recorder.startRecording()` and `recorder.stopRecording()` are async; mocks should return Promises. +- The `setState((s) => ({...s, ...}))` functional-updater pattern means test assertions need to wait for the next React tick after each state-mutating call. + +## Dev Agent Record + +### Agent Model Used + +(to be filled in by /bmad-dev-story) + +### Debug Log References + +### Completion Notes List + +### File List diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 5405e25..f9499f9 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-03-25 -last_updated: 2026-05-16 # Story 15-1 done. R1 review (5 patches: HIGH × 2 useFakeTimers time-flake fix + dictation empty-original semantic-trap TODO; MED × 3 Case 17 tightening + ref-inequality + typed-case-preservation Case 14a). 2159 tests (+1 net R1). All 5 gates green. PR #111. +last_updated: 2026-05-17 # Story 15-2 done. NEW use-pronunciation.test.tsx (+13 cases — 2172 total). All 5 gates green. Test-only story; 0 source-module modifications. project: companion project_key: NOKEY tracking_system: file-system @@ -207,7 +207,7 @@ development_status: # Epic 15: Test Coverage & QA Infrastructure (P1) epic-15: in-progress # 2026-05-16: auto-flipped backlog → in-progress when 15-1 story file was created. Epic 14 retro AIs surfaced at kickoff per Epic 13 AI #4 accountability gate; "Proceed + acknowledge" mode selected (AI #8 absorbed by 15.3 scope; others orthogonal docs/workflow). 15-1-lib-unit-tests: done # PR #111. R1 patches: HIGH × 2 (useFakeTimers time-flake fix; dictation empty-original semantic-trap TODO + 15-1-followup filed) + MED × 3 (Case 17 distinctive-marker assertion; Case 14 ref-inequality; new Case 14a typed-preserves-case). +1 net R1 (2158 → 2159). All 5 gates green. Original: 2026-05-16: Story 15-1 implementation complete. 4 NEW test files (srs.test.ts 14 cases + pronunciation.test.ts 10 cases + cache.test.ts 20 cases + use-dictation-compare.test.ts 15 cases) = +59 net Jest cases (2099 → 2158; squarely within spec target +50-65). 0 source-module modifications — test-only story. All 5 quality gates green. CLAUDE.md paragraph added per Epic 14 retro AI #5. Original: Story 15-1 spec file created. FIRST Epic 15 story. Pure-function lib unit tests for 4 modules where direct tests don't yet exist: srs.ts (SM-2 algorithm full coverage), pronunciation.ts identifyWeakSounds (pure aggregator), cache.ts core API (getCache/setCache/invalidateCache/cacheWithFallback happy + TTL boundary), use-dictation.ts compareSentences (pure word-comparison helper). Coverage inventory completed: activity.ts already fully tested (Story 9-2 + 12-3); memory.ts + error-tracker.ts have substantial partial coverage (sanitize via 9-4 + dedup via 11-6); pronunciation.ts has history-cap test (12-12); the GAP-only scope targets the 4 untested-or-partially-tested pure-function surfaces. Async/DB-touching paths (assessPronunciation Edge wrapper, memory.ts extractFacts/persistMemories/retrieveMemories, error-tracker.ts non-dedup paths) DEFERRED to 15-1-followups or 15-2 hook-integration scope per Story 14-4 R1-22-patch lesson (broad enforcement-rule changes interact with every surface; test-writing stories at analogous risk). 3 operator-decision items Q1-Q3 all resolved per Recommended (defer Edge wrapper / memory async / error-tracker non-dedup). 0 source-module modifications — test-only story. Spec target: +50-65 net Jest cases (2099 → 2149-2164). Status: ready-for-dev. Awaiting /bmad-dev-story. - 15-2-hook-integration-tests: backlog + 15-2-hook-integration-tests: done # 2026-05-17: NEW use-pronunciation.test.tsx (13 cases) — Story 12-12 FIFO + Story 15-1 identifyWeakSounds integration verified. +13 net Jest cases (2159 → 2172). 0 source-module modifications. All 5 gates green. 15-2-followup-existing-hook-flow-integration filed for use-auth/use-exercise/use-realtime-voice extensions. 15-3-edge-function-deno-tests: backlog 15-4-golden-flow-e2e: backlog 15-5-ai-schema-regression-tests: backlog From 1ee54db9fc8c8d642b35f8d36a7b7a6122dde615 Mon Sep 17 00:00:00 2001 From: Martins Aloba Date: Sun, 17 May 2026 14:45:26 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(15-2):=20R1=20patches=20HIGH=20=C3=97?= =?UTF-8?q?=204=20+=20MED=20=C3=97=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-agent adversarial review surfaced 9 actionable findings on the usePronunciation hook integration test. HIGH: - BH-1 + EH-7 + EH-10: weakSounds assertion missing entirely. Added explicit empty-state assertion to Case 3 (default fixture scores ≥ 80 → identifyWeakSounds returns []). NEW Case 3b uses a low-scoring × 3 same-phoneme fixture so the aggregator actually produces a non-empty weakSounds list — pins the full integration wiring (hook → identifyWeakSounds → state.weakSounds). - BH-2: Case 13 only tested snapshot-at-mount, not mirroring. NEW Case 13b uses renderer.update + flipped mock to verify the hook reflects recorder state CHANGES, not just initial-render values. - EH-1: no audio-permission-denied path. NEW Case 4b stubs hasPermission:false + recorder.error + stopRecording → null and verifies the hook routes through the no-audio branch without firing captureError (user-driven, not Sentry-page-worthy). - EH-8: Case 12 FIFO check didn't pin non-containment. Added explicit `history.some(h => h.accuracyScore === 0)).toBe(false)` so a regression that evicts from the tail (LIFO) or fails to evict at all is caught. - EH-9: no transient isAssessing assertion. NEW Case 6b uses a hand-rolled deferred promise (Story 13-3 P8 pattern) to verify isAssessing flips true mid-Azure-call and false after settle. MED: - BH-5: NEW Case 8c asserts assessFromUri callback reference is stable across re-renders (pins useCallback empty-deps contract). - BH-6: added `expect(isAssessing).toBe(false)` assertion to Case 2 pinning that startAssessment does NOT set isAssessing — only finishAssessment does (Azure call in-flight semantic). - EH-4: NEW Case 9b verifies clearResult on initial empty state is a no-op (pre-init defense). - EH-6: NEW Case 8b stubs readAsStringAsync rejection (real-world cached-file-gone race) and verifies captureError + error surfaced + assessPronunciation NOT called. Deferred (filed as follow-ups): - 15-2-followup-reentrant-guard (EH-3 — low real-world hazard) - 15-2-followup-getweakphonemes-ref-stability (EH-5) - 15-2-followup-permission-denied-distinct-error (UX decision) - 15-2-followup-distinct-fs-error-tag (telemetry granularity) Story file housekeeping: Status: review → done; populated File List + Completion Notes per Acceptance Auditor APPROVE_WITH_NOTES. +7 net test cases (13 → 20). All 4 quality gates green: type-check 0 / lint 0 / prettier clean / jest 2179 tests pass (+7 from this PR). --- .../15-2-hook-integration-tests.md | 18 +- .../__tests__/use-pronunciation.test.tsx | 187 +++++++++++++++++- 2 files changed, 201 insertions(+), 4 deletions(-) diff --git a/_bmad-output/implementation-artifacts/15-2-hook-integration-tests.md b/_bmad-output/implementation-artifacts/15-2-hook-integration-tests.md index cf556fc..802d0b6 100644 --- a/_bmad-output/implementation-artifacts/15-2-hook-integration-tests.md +++ b/_bmad-output/implementation-artifacts/15-2-hook-integration-tests.md @@ -1,6 +1,6 @@ # Story 15.2: Hook integration tests — `use-pronunciation` (full mocked-API coverage) -Status: review +Status: done @@ -123,10 +123,24 @@ Required cases (≥12): ### Agent Model Used -(to be filled in by /bmad-dev-story) +Claude Sonnet 4.6 (claude-sonnet-4-6) via /bmad-dev-story + /bmad-code-review workflows in autopilot mode. ### Debug Log References ### Completion Notes List +- **NEW** `src/hooks/__tests__/use-pronunciation.test.tsx` — 20 cases (13 original + 7 R1 patches). Tests cover full hook lifecycle: initial state, startAssessment, finishAssessment (4 paths: happy / no-audio / Azure error / permission-denied), assessFromUri (3 paths: happy / Azure error / FS error / ref-stability), clearResult (with-state + empty-state), getWeakPhonemes (null + populated), history accumulation (3-call + 51-call Story 12-12 FIFO cap with non-containment), isRecording mirroring (snapshot + live-state delegation across re-renders), transient isAssessing via deferred-promise pattern, weakSounds aggregator end-to-end via low-scoring × 3 phoneme fixture. +- **R1 patches applied** (HIGH × 4 + MED × 5): BH-1 weakSounds assertion in Case 3 + new Case 3b for non-empty aggregator integration (R1 EH-7 + EH-10 fixture realism); BH-2 new Case 13b for live-state delegation across re-renders; BH-5 new Case 8c for assessFromUri ref-stability; BH-6 isAssessing post-startAssessment assertion in Case 2; EH-1 new Case 4b for permission-denied path; EH-4 new Case 9b for clearResult-before-init; EH-6 new Case 8b for readAsStringAsync rejection; EH-8 non-containment FIFO assertion in Case 12; EH-9 new Case 6b for transient isAssessing via deferred promise. +- **Deferred** (filed as follow-ups): EH-3 re-entrant guard → `15-2-followup-reentrant-guard` (low real-world hazard, impl-defined behavior); EH-5 `getWeakPhonemes` ref-stability → `15-2-followup-getweakphonemes-ref-stability`; permission-denied distinct error message → `15-2-followup-permission-denied-distinct-error` (operator decision on UX); distinct FS error feature tag → `15-2-followup-distinct-fs-error-tag` (operator decision on telemetry granularity). +- **Quality gates green**: type-check 0 errors / lint 0 warnings / prettier clean / jest test passes (20/20). + ### File List + +**New:** + +- `src/hooks/__tests__/use-pronunciation.test.tsx` — 20 Jest cases (13 original + 7 R1 patches) + +**Modified:** + +- `_bmad-output/implementation-artifacts/sprint-status.yaml` — 15-2 → done +- `CLAUDE.md` — Story 15-2 architecture paragraph appended diff --git a/src/hooks/__tests__/use-pronunciation.test.tsx b/src/hooks/__tests__/use-pronunciation.test.tsx index 1a3dfc9..1b8cf4e 100644 --- a/src/hooks/__tests__/use-pronunciation.test.tsx +++ b/src/hooks/__tests__/use-pronunciation.test.tsx @@ -167,7 +167,7 @@ describe("Story 15-2 — usePronunciation", () => { }); describe("startAssessment", () => { - it("Case 2: clears prior result + error AND delegates to recorder.startRecording", async () => { + it("Case 2: clears prior result + error AND delegates to recorder.startRecording (R1 BH-6: also asserts isAssessing stays false during recording phase)", async () => { const recorderStub = makeRecorderStub(); mockUseAudioRecorder.mockReturnValue(recorderStub); // Seed state with a prior result via finishAssessment happy path @@ -185,6 +185,10 @@ describe("Story 15-2 — usePronunciation", () => { expect(result.current?.result).toBeNull(); expect(result.current?.error).toBeNull(); expect(recorderStub.startRecording).toHaveBeenCalledTimes(1); + // R1 BH-6: `startAssessment` does NOT set isAssessing — "assessing" means + // Azure call in-flight (set by finishAssessment), not recording. Pinning + // this contract so a future refactor moving the flag earlier is caught. + expect(result.current?.isAssessing).toBe(false); }); }); @@ -210,6 +214,46 @@ describe("Story 15-2 — usePronunciation", () => { expect.objectContaining({ encoding: "base64" }) ); expect(mockAssessPronunciation).toHaveBeenCalledWith("stub-base64-audio", "bonjour le monde"); + // R1 BH-1: weakSounds is the aggregated-across-history field (not + // result.weakPhonemes which is per-call). The default fixture has all + // phonemes scoring ≥ 80, so identifyWeakSounds returns [] (its filter + // requires count ≥ 3 AND avgScore < 70). Pinning the empty case so a + // future refactor that breaks the `identifyWeakSounds(newHistory)` + // wiring is caught. + expect(result.current?.weakSounds).toEqual([]); + }); + + it("Case 3b: weakSounds aggregator wired correctly — 3+ same-phoneme low-scoring entries produce non-empty weakSounds (R1 BH-1 + EH-7 + EH-10)", async () => { + // identifyWeakSounds threshold is `count >= 3 && avgScore < 70`. + // Each result contributes 1 occurrence of the weak phoneme "ʁ" with + // score 40. After 3 assessments the aggregate is count=3, avg=40 → + // weakSounds should contain "ʁ". + const lowScoringFixture = () => + makeResult({ + words: [ + { + word: "rouge", + accuracyScore: 40, + errorType: "Mispronunciation", + phonemes: [{ phoneme: "ʁ", accuracyScore: 40 }], + }, + ], + }); + mockAssessPronunciation + .mockResolvedValueOnce(lowScoringFixture()) + .mockResolvedValueOnce(lowScoringFixture()) + .mockResolvedValueOnce(lowScoringFixture()); + const { result } = renderHost(); + for (let i = 0; i < 3; i++) { + await act(async () => { + await result.current!.finishAssessment(`rouge ${i}`); + }); + } + // After 3 assessments with the same low-scoring phoneme, the + // aggregator returns a non-empty weakSounds list. + expect(result.current?.weakSounds.length).toBeGreaterThan(0); + // The weak phoneme "ʁ" must appear in the aggregated list. + expect(result.current?.weakSounds.some((w) => w.phoneme === "ʁ")).toBe(true); }); it("Case 4: recorder returns null (no audio) → error set + isAssessing:false + no captureError + no readAsStringAsync call", async () => { @@ -229,6 +273,36 @@ describe("Story 15-2 — usePronunciation", () => { expect(mockAssessPronunciation).not.toHaveBeenCalled(); }); + it("Case 4b: audio-permission-denied surface — recorder.hasPermission=false + recorder.error set + stopRecording returns null (R1 EH-1)", async () => { + // Real-world iOS/Android first-run scenario: user taps mic, denies + // permission. `useAudioRecorder` returns hasPermission:false + + // error: "Microphone permission denied" + stopRecording → null. + // The hook's permission-denied path routes through the same + // null-audio branch (Case 4 above) — pinning that contract. + mockUseAudioRecorder.mockReturnValue( + makeRecorderStub({ + hasPermission: false, + error: "Microphone permission denied", + stopRecording: jest.fn(async () => null), + }) + ); + const { result } = renderHost(); + let returnedValue: PronunciationResult | null = makeResult(); + await act(async () => { + returnedValue = await result.current!.finishAssessment("bonjour"); + }); + expect(returnedValue).toBeNull(); + expect(result.current?.isAssessing).toBe(false); + // Hook currently surfaces "No audio recorded" (Case 4 contract). + // Filed `15-2-followup-permission-denied-distinct-error` if operators + // want a distinct user-visible message vs no-audio. Until then this + // pins the current behavior. + expect(result.current?.error).toBe("No audio recorded"); + // captureError should NOT fire on the permission-denied path (it's + // user-driven, not a system error worth Sentry-paging on). + expect(mockCaptureError).not.toHaveBeenCalled(); + }); + it("Case 5: assessPronunciation throws → captureError fires with `pronunciation-assessment` feature tag + error set via classifyError + isAssessing:false", async () => { const azureErr = new Error("Azure 503 Service Unavailable"); mockAssessPronunciation.mockRejectedValueOnce(azureErr); @@ -256,6 +330,35 @@ describe("Story 15-2 — usePronunciation", () => { expect(returnedValue).toEqual(expected); expect(returnedValue).toBe(expected); // Reference identity preserved }); + + it("Case 6b: transient isAssessing — set true synchronously before Azure call, reset false after settle (R1 EH-9 deferred-resolve pattern)", async () => { + // Hand-rolled deferred promise so we can observe the mid-flight state + // before the assessment resolves (Story 13-3 P8 pattern). + let resolveAssess!: (v: PronunciationResult) => void; + const deferred = new Promise((resolve) => { + resolveAssess = resolve; + }); + mockAssessPronunciation.mockReturnValueOnce(deferred); + const { result } = renderHost(); + // Fire finishAssessment WITHOUT awaiting — captures the in-flight state. + let finishPromise!: Promise; + await act(async () => { + finishPromise = result.current!.finishAssessment("test"); + }); + // At this point: readAsStringAsync has resolved, assessPronunciation + // has been called but its promise is still pending. The hook's + // setState({...prev, isAssessing: true, error: null}) should have + // already committed. + expect(result.current?.isAssessing).toBe(true); + // Resolve the assessment. + await act(async () => { + resolveAssess(makeResult({ accuracyScore: 77 })); + await finishPromise; + }); + // Post-settle: isAssessing is false again. + expect(result.current?.isAssessing).toBe(false); + expect(result.current?.result).not.toBeNull(); + }); }); describe("assessFromUri — async path (skips recording)", () => { @@ -297,6 +400,45 @@ describe("Story 15-2 — usePronunciation", () => { ); expect(mockCaptureError).toHaveBeenCalledWith(err, "pronunciation-assessment"); }); + + it("Case 8b: readAsStringAsync rejection — file gone / permissions → captureError + error surfaced (R1 EH-6)", async () => { + // Real-world scenario: cached audio file deleted by an OS background + // cleaner between recording and assess. readAsStringAsync rejects; + // the hook's catch arm at use-pronunciation.ts wraps both file-read + // and assessment errors with the same feature tag. + const fsErr = new Error("ENOENT: file not found"); + mockReadAsStringAsync.mockRejectedValueOnce(fsErr); + const { result } = renderHost(); + let returnedValue: PronunciationResult | null = makeResult(); + await act(async () => { + returnedValue = await result.current!.assessFromUri("file:///deleted.wav", "test"); + }); + expect(returnedValue).toBeNull(); + expect(result.current?.isAssessing).toBe(false); + expect(result.current?.error).toBe( + "Pronunciation assessment failed. Please try recording again." + ); + // Sentry captures the FS error with the same `pronunciation-assessment` + // feature tag. If operators want distinct telemetry for file-read vs + // assessment errors, file `15-2-followup-distinct-fs-error-tag`. + expect(mockCaptureError).toHaveBeenCalledWith(fsErr, "pronunciation-assessment"); + expect(mockAssessPronunciation).not.toHaveBeenCalled(); + }); + + it("Case 8c: assessFromUri callback reference is stable across re-renders (R1 BH-5: pins useCallback empty-deps contract)", async () => { + const { result, renderer } = renderHost(); + const ref1 = result.current!.assessFromUri; + // Force a re-render by mutating a recorder stub that triggers no + // state change. The useAudioRecorder mock returns the same stub. + act(() => { + renderer.update(); + }); + const ref2 = result.current!.assessFromUri; + // Pin reference stability — useCallback([]) deps mean the function + // should be identical across renders unless a future maintainer adds + // a dep (which would silently re-render consumers that depend on it). + expect(ref2).toBe(ref1); + }); }); describe("clearResult", () => { @@ -317,6 +459,24 @@ describe("Story 15-2 — usePronunciation", () => { // history is NOT cleared expect(result.current?.history).toHaveLength(1); }); + + it("Case 9b: clearResult on initial empty state is a no-op (R1 EH-4 pre-init defense)", () => { + // User taps "Clear" before any assessment — should not crash, should + // leave the empty state unchanged. + const { result } = renderHost(); + // Initial empty state pin + expect(result.current?.result).toBeNull(); + expect(result.current?.error).toBeNull(); + expect(result.current?.history).toEqual([]); + // Invoke clearResult against empty state + act(() => { + result.current!.clearResult(); + }); + // Post-state: still empty, history still [] + expect(result.current?.result).toBeNull(); + expect(result.current?.error).toBeNull(); + expect(result.current?.history).toEqual([]); + }); }); describe("getWeakPhonemes", () => { @@ -374,14 +534,37 @@ describe("Story 15-2 — usePronunciation", () => { // First entry (accuracyScore=0) was evicted; oldest surviving is accuracyScore=1 expect(result.current?.history[0].accuracyScore).toBe(1); expect(result.current?.history[49].accuracyScore).toBe(50); + // R1 EH-8: explicit non-containment so a future regression that + // evicts from the tail (LIFO) or fails to evict at all is caught. + // The original accuracyScore=0 entry must NOT appear anywhere. + expect(result.current?.history.some((h) => h.accuracyScore === 0)).toBe(false); }); }); describe("isRecording mirroring", () => { - it("Case 13: isRecording mirrors recorder.isRecording (live-state delegation)", () => { + it("Case 13: isRecording mirrors recorder.isRecording at first render (snapshot)", () => { mockUseAudioRecorder.mockReturnValue(makeRecorderStub({ isRecording: true })); const { result } = renderHost(); expect(result.current?.isRecording).toBe(true); }); + + it("Case 13b: isRecording mirrors recorder state changes across re-renders (R1 BH-2 live-state delegation)", () => { + // Two mock returns: first render = false, second render = true. + // After forcing a re-render, the hook should reflect the new state. + mockUseAudioRecorder + .mockReturnValueOnce(makeRecorderStub({ isRecording: false })) + .mockReturnValue(makeRecorderStub({ isRecording: true })); + const { result, renderer } = renderHost(); + expect(result.current?.isRecording).toBe(false); + // Force a re-render by re-invoking renderer.update — the second + // useAudioRecorder mock return fires. + act(() => { + renderer.update(); + }); + // The hook should now reflect the new recorder state, NOT the + // snapshot-at-mount value. Pin this so a future refactor caching + // `isRecording` into local state breaks the mirroring contract. + expect(result.current?.isRecording).toBe(true); + }); }); });