diff --git a/README.md b/README.md index 783fcf4..0005933 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ The bundled provider remains deterministic and network-free. No real provider ex Deleting an uploaded asset uses a durable staged-delete plan so database and filesystem failures can be retried safely. Successful deletion removes metadata and stored bytes. Final transcript segments already committed remain in the analysis session; their nullable source link is cleared by the database foreign key. Delete the analysis session to remove those transcript segments. Upload, transcription, and deletion use session-scoped action receipts for idempotency. +The first v0.6 increment adds Uploaded Audio accessibility improvements and automated accessibility regression coverage: initial loading is announced, per-asset status changes remain polite, and cancelling/deleting is exposed as an explicit busy state without moving keyboard focus during polling refreshes. + ## Question Boundary Detector Question Boundary Detector decides only whether the persisted, ordered final interviewer transcript has formed a complete question. Candidate and unknown speaker segments are excluded. Interim text can remain visible in the Lab, but never becomes a finalized-question source. diff --git a/ROADMAP.md b/ROADMAP.md index b129c06..39e4e96 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,9 +44,15 @@ Implemented: asynchronous enqueue/reuse responses, persistent five-state jobs, c The worker is an explicitly enabled non-production embedded Node loop using only the deterministic Fake provider. It provides at-least-once provider invocation and exactly-once committed transcript effect. A real provider, production/background deployment, external queue, Redis, object storage, auth, diarization, capture, answers, and scoring remain future/out of scope. +## v0.6 (in progress): Accessibility and local operations + +The first increment covers Uploaded Audio accessibility improvements and deterministic component-level polling tests. It adds automated accessibility regression coverage for loading, status, busy, error, keyboard, focus, and destructive-action behavior, together with controlled-timer coverage for polling, visibility changes, failures, recovery, aborts, and unmount cleanup. + +Remaining v0.6 work: database backup/import, configurable retention, and content-free observability. + ## Later candidates -Accessibility audits, database backup/import, configurable retention, provider observability without content logging, and additional deterministic practice formats. +Additional deterministic practice formats remain later candidates. A later natural-follow-up mode would need a separate product and ethical review. It should remain practice-only, consented, bounded to the stored competency/question context, visibly distinguish generated follow-ups, keep application-owned state and persistence, prohibit scoring/suggested answers, and include deterministic limits and Fake-provider tests before any real-provider rollout. Covert assistance and multi-agent orchestration are not planned. diff --git a/src/features/uploaded-audio/components/uploaded-audio-panel.tsx b/src/features/uploaded-audio/components/uploaded-audio-panel.tsx index a59f03a..538f326 100644 --- a/src/features/uploaded-audio/components/uploaded-audio-panel.tsx +++ b/src/features/uploaded-audio/components/uploaded-audio-panel.tsx @@ -21,24 +21,32 @@ export function UploadedAudioPanel({ sessionId: string; onTranscriptCommitted: () => Promise; }) { - const [assets, setAssets] = useState>([]); + const [assets, setAssets] = useState>( + [], + ); const [maximumBytes, setMaximumBytes] = useState(0); const [speakerRole, setSpeakerRole] = useState("interviewer"); const [file, setFile] = useState(null); + const [initialLoading, setInitialLoading] = useState(true); const [busyId, setBusyId] = useState(null); + const [deletingId, setDeletingId] = useState(null); const [error, setError] = useState(""); const inputRef = useRef(null); const mountedRef = useRef(false); const pollTimerRef = useRef(null); const pollingRequestRef = useRef(false); const pollAbortRef = useRef(null); + const actionAbortRef = useRef(null); + const loadRequestSequenceRef = useRef(0); + const onTranscriptCommittedRef = useRef(onTranscriptCommitted); const notifiedCompletedJobs = useRef(new Set()); const assetsRef = useRef>([]); const scheduleNextPollRef = useRef<() => void>(() => undefined); const load = useCallback( async (signal?: AbortSignal) => { + const requestSequence = ++loadRequestSequenceRef.current; const response = await fetch( `/api/analysis-sessions/${sessionId}/uploaded-audio`, { cache: "no-store", signal }, @@ -47,24 +55,39 @@ export function UploadedAudioPanel({ assets?: ReadonlyArray; maximumBytes?: number; } & ApiErrorPayload; + if ( + !mountedRef.current || + signal?.aborted || + requestSequence !== loadRequestSequenceRef.current + ) + return undefined; if (!response.ok || !payload.assets) throw new Error( payload.error?.message ?? "Uploaded audio could not be loaded.", ); - if (!mountedRef.current) return payload.assets; assetsRef.current = payload.assets; setAssets(payload.assets); setMaximumBytes(payload.maximumBytes ?? 0); for (const asset of payload.assets) { const job = asset.latestJob; - if (job?.status !== "completed" || notifiedCompletedJobs.current.has(job.id)) + if ( + !mountedRef.current || + job?.status !== "completed" || + notifiedCompletedJobs.current.has(job.id) + ) continue; notifiedCompletedJobs.current.add(job.id); - await onTranscriptCommitted(); + await onTranscriptCommittedRef.current(); } + if ( + !mountedRef.current || + signal?.aborted || + requestSequence !== loadRequestSequenceRef.current + ) + return undefined; return payload.assets; }, - [onTranscriptCommitted, sessionId], + [sessionId], ); const stopPolling = useCallback(() => { @@ -81,22 +104,33 @@ export function UploadedAudioPanel({ if (!mountedRef.current || pollTimerRef.current !== null) return; pollTimerRef.current = window.setTimeout(async () => { pollTimerRef.current = null; - if (!mountedRef.current || pollingRequestRef.current) return; + if (!mountedRef.current) return; + if (pollingRequestRef.current) { + scheduleNextPollRef.current(); + return; + } pollingRequestRef.current = true; const controller = new AbortController(); pollAbortRef.current = controller; try { const current = await load(controller.signal); - if (mountedRef.current && current && hasActiveJob(current)) - scheduleNextPollRef.current(); + if (mountedRef.current) { + setError(""); + if (current && hasActiveJob(current)) scheduleNextPollRef.current(); + } } catch (caught) { - if (!(caught instanceof DOMException && caught.name === "AbortError")) { + if ( + mountedRef.current && + !controller.signal.aborted && + !(caught instanceof DOMException && caught.name === "AbortError") + ) { setError( caught instanceof Error ? caught.message : "Uploaded audio could not be loaded.", ); - if (mountedRef.current) scheduleNextPollRef.current(); + if (hasActiveJob(assetsRef.current)) + scheduleNextPollRef.current(); } } finally { pollingRequestRef.current = false; @@ -107,6 +141,10 @@ export function UploadedAudioPanel({ [load], ); + useEffect(() => { + onTranscriptCommittedRef.current = onTranscriptCommitted; + }, [onTranscriptCommitted]); + useEffect(() => { scheduleNextPollRef.current = () => schedulePoll(); }, [schedulePoll]); @@ -119,12 +157,20 @@ export function UploadedAudioPanel({ if (current && hasActiveJob(current)) schedulePoll(); }) .catch((caught) => { - if (!(caught instanceof DOMException && caught.name === "AbortError")) + if ( + mountedRef.current && + !controller.signal.aborted && + !(caught instanceof DOMException && caught.name === "AbortError") + ) setError( caught instanceof Error ? caught.message : "Uploaded audio could not be loaded.", ); + }) + .finally(() => { + if (mountedRef.current && !controller.signal.aborted) + setInitialLoading(false); }); const visibilityChanged = () => { if (!hasActiveJob(assetsRef.current)) return; @@ -134,7 +180,10 @@ export function UploadedAudioPanel({ document.addEventListener("visibilitychange", visibilityChanged); return () => { mountedRef.current = false; + loadRequestSequenceRef.current += 1; controller.abort(); + actionAbortRef.current?.abort(); + actionAbortRef.current = null; stopPolling(); document.removeEventListener("visibilitychange", visibilityChanged); }; @@ -144,6 +193,8 @@ export function UploadedAudioPanel({ if (!file || busyId) return; setBusyId("upload"); setError(""); + const controller = new AbortController(); + actionAbortRef.current = controller; try { const formData = new FormData(); formData.set("actionId", crypto.randomUUID()); @@ -151,18 +202,32 @@ export function UploadedAudioPanel({ formData.set("file", file); const response = await fetch( `/api/analysis-sessions/${sessionId}/uploaded-audio`, - { method: "POST", body: formData, cache: "no-store" }, + { + method: "POST", + body: formData, + cache: "no-store", + signal: controller.signal, + }, ); const payload = (await response.json()) as ApiErrorPayload; if (!response.ok) throw new Error(payload.error?.message ?? "Audio upload failed."); + if (!mountedRef.current || controller.signal.aborted) return; setFile(null); if (inputRef.current) inputRef.current.value = ""; - await load(); + await load(controller.signal); } catch (caught) { - setError(caught instanceof Error ? caught.message : "Audio upload failed."); + if ( + mountedRef.current && + !controller.signal.aborted && + !(caught instanceof DOMException && caught.name === "AbortError") + ) + setError( + caught instanceof Error ? caught.message : "Audio upload failed.", + ); } finally { - setBusyId(null); + if (actionAbortRef.current === controller) actionAbortRef.current = null; + if (mountedRef.current) setBusyId(null); } } @@ -170,6 +235,8 @@ export function UploadedAudioPanel({ if (busyId) return; setBusyId(assetId); setError(""); + const controller = new AbortController(); + actionAbortRef.current = controller; try { const response = await fetch( `/api/analysis-sessions/${sessionId}/uploaded-audio/${assetId}/transcribe`, @@ -178,6 +245,7 @@ export function UploadedAudioPanel({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ actionId: crypto.randomUUID() }), cache: "no-store", + signal: controller.signal, }, ); const payload = (await response.json()) as ApiErrorPayload; @@ -185,17 +253,26 @@ export function UploadedAudioPanel({ throw new Error( payload.error?.message ?? "Audio transcription could not be queued.", ); - const current = await load(); - if (response.status === 202 && current && hasActiveJob(current)) schedulePoll(); + if (!mountedRef.current || controller.signal.aborted) return; + const current = await load(controller.signal); + if (response.status === 202 && current && hasActiveJob(current)) + schedulePoll(); } catch (caught) { - setError( - caught instanceof Error - ? caught.message - : "Audio transcription could not be queued.", - ); - await load().catch(() => undefined); + if ( + mountedRef.current && + !controller.signal.aborted && + !(caught instanceof DOMException && caught.name === "AbortError") + ) { + setError( + caught instanceof Error + ? caught.message + : "Audio transcription could not be queued.", + ); + await load(controller.signal).catch(() => undefined); + } } finally { - setBusyId(null); + if (actionAbortRef.current === controller) actionAbortRef.current = null; + if (mountedRef.current) setBusyId(null); } } @@ -208,7 +285,10 @@ export function UploadedAudioPanel({ ) return; setBusyId(assetId); + setDeletingId(assetId); setError(""); + const controller = new AbortController(); + actionAbortRef.current = controller; try { const response = await fetch( `/api/analysis-sessions/${sessionId}/uploaded-audio/${assetId}`, @@ -217,25 +297,41 @@ export function UploadedAudioPanel({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ actionId: crypto.randomUUID() }), cache: "no-store", + signal: controller.signal, }, ); const payload = (await response.json()) as ApiErrorPayload; if (!response.ok) throw new Error(payload.error?.message ?? "Audio deletion failed."); - const current = await load(); - if (!current || !hasActiveJob(current)) stopPolling(); + if (!mountedRef.current || controller.signal.aborted) return; + stopPolling(); + const current = await load(controller.signal); + if (current && hasActiveJob(current)) schedulePoll(); } catch (caught) { - setError(caught instanceof Error ? caught.message : "Audio deletion failed."); + if ( + mountedRef.current && + !controller.signal.aborted && + !(caught instanceof DOMException && caught.name === "AbortError") + ) + setError( + caught instanceof Error ? caught.message : "Audio deletion failed.", + ); } finally { - setBusyId(null); + if (actionAbortRef.current === controller) actionAbortRef.current = null; + if (mountedRef.current) { + setBusyId(null); + setDeletingId(null); + } } } function statusLabel(asset: UploadedAudioAssetView) { - if (asset.status === "deleting") return "Cancelling/deleting"; + if (deletingId === asset.id || asset.status === "deleting") + return "Cancelling/deleting"; const job = asset.latestJob; if (!job) return asset.status === "completed" ? "Completed" : asset.status; - if (job.status === "queued") return job.attemptCount > 0 ? "Retrying" : "Queued"; + if (job.status === "queued") + return job.attemptCount > 0 ? "Retrying" : "Queued"; if (job.status === "running") return "Transcribing"; if (job.status === "completed") return "Completed"; if (job.status === "failed") return "Failed"; @@ -249,8 +345,8 @@ export function UploadedAudioPanel({

Uploaded Audio

- Uploading never transcribes. The visible Transcribe action queues bounded - local processing and returns immediately. + Uploading never transcribes. The visible Transcribe action queues + bounded local processing and returns immediately.

One declared role applies to the whole file. There is no diarization, @@ -297,18 +393,33 @@ export function UploadedAudioPanel({ Upload audio - {error ?

{error}

: null} - {assets.length ? ( + {error ? ( +

+ {error} +

+ ) : null} + {initialLoading ? ( +

+ Loading uploaded audio… +

+ ) : assets.length ? (
    {assets.map((asset) => { const active = asset.latestJob?.status === "queued" || asset.latestJob?.status === "running"; + const deleting = + deletingId === asset.id || asset.status === "deleting"; const canRetry = - asset.latestJob?.status === "failed" && asset.status !== "deleting"; + asset.latestJob?.status === "failed" && + asset.status !== "deleting"; const canStart = !asset.latestJob && asset.status === "uploaded"; return ( -
  1. +
  2. {asset.originalFilename} @@ -316,12 +427,14 @@ export function UploadedAudioPanel({

    - {asset.speakerRole} · {asset.mimeType} · {asset.byteSize} bytes + {asset.speakerRole} · {asset.mimeType} · {asset.byteSize}{" "} + bytes {asset.providerLabel ? ` · ${asset.providerLabel}` : ""}

    {asset.latestJob?.status === "failed" ? (

    - Transcription failed safely ({asset.latestJob.safeErrorCode ?? "safe error"}). + Transcription failed safely ( + {asset.latestJob.safeErrorCode ?? "safe error"}).

    ) : null}
    @@ -337,11 +450,15 @@ export function UploadedAudioPanel({
  3. @@ -353,7 +470,8 @@ export function UploadedAudioPanel({ )}

    Cancelling cannot stop provider code already in memory, but stale output - cannot commit. Final transcript segments already committed survive asset deletion. + cannot commit. Final transcript segments already committed survive asset + deletion.

    ); diff --git a/tests/unit/uploaded-audio-panel.test.tsx b/tests/unit/uploaded-audio-panel.test.tsx new file mode 100644 index 0000000..36943e2 --- /dev/null +++ b/tests/unit/uploaded-audio-panel.test.tsx @@ -0,0 +1,972 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { UploadedAudioPanel } from "@/features/uploaded-audio/components/uploaded-audio-panel"; +import type { + PublicTranscriptionJobSummary, + TranscriptionJobStatus, + UploadedAudioAssetView, + UploadedAudioStatus, +} from "@/features/uploaded-audio/domain/uploaded-audio"; + +const sessionId = "11111111-1111-4111-8111-111111111111"; +const firstActionId = "22222222-2222-4222-8222-222222222222"; +const secondActionId = "33333333-3333-4333-8333-333333333333"; +const visibilityDescriptor = Object.getOwnPropertyDescriptor( + document, + "visibilityState", +); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function job( + status: TranscriptionJobStatus, + overrides: Partial = {}, +): PublicTranscriptionJobSummary { + return Object.freeze({ + id: `job-${status}`, + status, + attemptCount: status === "running" ? 1 : 0, + maximumAttempts: 3, + availableAt: 20_000, + safeErrorCode: + status === "failed" ? "UPLOADED_AUDIO_PROVIDER_TEMPORARY" : null, + createdAt: 10_000, + updatedAt: 11_000, + completedAt: status === "completed" ? 11_000 : null, + failedAt: status === "failed" ? 11_000 : null, + cancelledAt: status === "cancelled" ? 11_000 : null, + ...overrides, + }); +} + +function asset( + options: { + status?: UploadedAudioStatus; + latestJob?: PublicTranscriptionJobSummary | null; + filename?: string; + id?: string; + } = {}, +): UploadedAudioAssetView { + const status = options.status ?? "uploaded"; + return Object.freeze({ + id: options.id ?? "asset-one", + analysisSessionId: sessionId, + speakerRole: "interviewer", + originalFilename: options.filename ?? "practice.wav", + mimeType: "audio/wav", + byteSize: 44, + sha256: "a".repeat(64), + status, + providerLabel: status === "completed" ? "deterministic-fake" : null, + createdAt: 10_000, + updatedAt: 11_000, + completedAt: status === "completed" ? 11_000 : null, + failedAt: status === "failed" ? 11_000 : null, + errorCode: status === "failed" ? "SAFE_ASSET_FAILURE" : null, + transcriptSegmentCount: status === "completed" ? 2 : 0, + latestJob: options.latestJob ?? null, + }); +} + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function listResponse(assets: ReadonlyArray) { + return jsonResponse({ assets, maximumBytes: 26_214_400 }); +} + +function fetchSequence( + ...responses: ReadonlyArray> +) { + const mock = vi.fn(); + for (const response of responses) + mock.mockImplementationOnce(() => Promise.resolve(response)); + vi.stubGlobal("fetch", mock); + return mock; +} + +async function flushPromises() { + await act(async () => { + for (let index = 0; index < 8; index += 1) await Promise.resolve(); + }); +} + +async function advance(milliseconds: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(milliseconds); + }); +} + +function renderPanel(onTranscriptCommitted = vi.fn(async () => undefined)) { + const rendered = render( + , + ); + return { ...rendered, onTranscriptCommitted }; +} + +function requestMethod(call: Parameters) { + return call[1]?.method ?? "GET"; +} + +function setVisibility(value: "hidden" | "visible") { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value, + }); + document.dispatchEvent(new Event("visibilitychange")); +} + +beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + if (visibilityDescriptor) + Object.defineProperty(document, "visibilityState", visibilityDescriptor); + else + delete (document as unknown as { visibilityState?: string }) + .visibilityState; +}); + +describe("UploadedAudioPanel public states", () => { + it("shows initial loading, then an empty state without polling or transcription", async () => { + const pending = deferred(); + const fetchMock = fetchSequence(pending.promise); + renderPanel(); + + expect(screen.getByRole("status")).toHaveTextContent( + "Loading uploaded audio", + ); + expect( + screen.queryByText("No uploaded audio assets."), + ).not.toBeInTheDocument(); + + pending.resolve(listResponse([])); + await flushPromises(); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.getByText("No uploaded audio assets.")).toBeVisible(); + expect(vi.getTimerCount()).toBe(0); + expect(fetchMock).toHaveBeenCalledOnce(); + expect( + fetchMock.mock.calls.some((call) => requestMethod(call) === "POST"), + ).toBe(false); + }); + + it("renders uploaded metadata and enabled explicit actions without auto-transcribing", async () => { + const fetchMock = fetchSequence(listResponse([asset()])); + renderPanel(); + await flushPromises(); + + const card = screen.getByTestId("uploaded-audio-asset"); + expect(card).toHaveTextContent("practice.wav"); + expect(card).toHaveTextContent("interviewer · audio/wav · 44 bytes"); + expect( + within(card).getByRole("button", { name: /Transcribe practice\.wav/ }), + ).toBeEnabled(); + expect( + within(card).getByRole("button", { + name: /Delete uploaded audio practice\.wav/, + }), + ).toBeEnabled(); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([ + ["queued", "Queued"], + ["running", "Transcribing"], + ] as const)( + "renders an active %s job and schedules polling", + async (status, label) => { + fetchSequence( + listResponse([ + asset({ + status: status === "running" ? "transcribing" : "uploaded", + latestJob: job(status), + }), + ]), + ); + renderPanel(); + await flushPromises(); + + const card = screen.getByTestId("uploaded-audio-asset"); + expect( + within(card).getByTestId("uploaded-audio-status"), + ).toHaveTextContent(label); + expect(within(card).getByTestId("uploaded-audio-status")).toHaveAttribute( + "aria-live", + "polite", + ); + expect(card).toHaveAttribute("aria-busy", "true"); + expect( + within(card).getByRole("button", { name: /Transcribe practice\.wav/ }), + ).toBeDisabled(); + expect( + within(card).getByRole("button", { + name: /Delete uploaded audio practice\.wav/, + }), + ).toBeEnabled(); + expect(vi.getTimerCount()).toBe(1); + }, + ); + + it("renders a future queued retry as Retrying without inventing a status", async () => { + fetchSequence( + listResponse([ + asset({ + latestJob: job("queued", { + attemptCount: 1, + availableAt: Date.now() + 5_000, + }), + }), + ]), + ); + renderPanel(); + await flushPromises(); + + expect(screen.getByTestId("uploaded-audio-status")).toHaveTextContent( + "Retrying", + ); + expect( + screen.queryByText("retrying", { exact: true }), + ).not.toBeInTheDocument(); + expect(vi.getTimerCount()).toBe(1); + }); + + it("renders a completed job, stops polling, and announces completion once", async () => { + const committed = vi.fn(async () => undefined); + fetchSequence( + listResponse([ + asset({ status: "completed", latestJob: job("completed") }), + ]), + ); + renderPanel(committed); + await flushPromises(); + + expect(screen.getByTestId("uploaded-audio-status")).toHaveTextContent( + "Completed", + ); + expect(screen.getByTestId("uploaded-audio-asset")).toHaveAttribute( + "aria-busy", + "false", + ); + expect(committed).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not repeat a completion callback for identical GET results in one mount", async () => { + const committed = vi.fn(async () => undefined); + const completed = asset({ + status: "completed", + latestJob: job("completed", { id: "stable-completed-job" }), + }); + const queued = asset({ + id: "asset-two", + latestJob: job("queued", { id: "active-job" }), + }); + fetchSequence( + listResponse([completed, queued]), + listResponse([completed, queued]), + ); + renderPanel(committed); + await flushPromises(); + await advance(1_000); + + expect(committed).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(1); + }); + + it("emits completion once for each newly observed completed job", async () => { + const committed = vi.fn(async () => undefined); + const firstCompleted = asset({ + status: "completed", + latestJob: job("completed", { id: "completed-job-one" }), + }); + const active = asset({ + id: "asset-two", + latestJob: job("queued", { id: "active-job" }), + }); + const secondCompleted = asset({ + id: "asset-two", + status: "completed", + latestJob: job("completed", { id: "completed-job-two" }), + }); + fetchSequence( + listResponse([firstCompleted, active]), + listResponse([firstCompleted, secondCompleted]), + ); + renderPanel(committed); + await flushPromises(); + await advance(1_000); + + expect(committed).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it("renders failed status and a safe alert while enabling retry and stopping polling", async () => { + fetchSequence( + listResponse([asset({ status: "failed", latestJob: job("failed") })]), + ); + renderPanel(); + await flushPromises(); + + const card = screen.getByTestId("uploaded-audio-asset"); + expect(within(card).getByTestId("uploaded-audio-status")).toHaveTextContent( + "Failed", + ); + expect(within(card).getByRole("alert")).toHaveTextContent( + "UPLOADED_AUDIO_PROVIDER_TEMPORARY", + ); + expect( + within(card).getByRole("button", { + name: /Retry transcription for practice\.wav/, + }), + ).toBeEnabled(); + expect( + within(card).getByRole("button", { + name: /Delete uploaded audio practice\.wav/, + }), + ).toBeEnabled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([ + ["cancelled", "transcribing", "Cancelled", "false", true], + ["cancelled", "deleting", "Cancelling/deleting", "true", false], + ] as const)( + "renders terminal/deleting state for a %s job on a %s asset", + async (jobStatus, assetStatus, label, ariaBusy, deleteEnabled) => { + fetchSequence( + listResponse([ + asset({ + status: assetStatus, + latestJob: job(jobStatus), + }), + ]), + ); + renderPanel(); + await flushPromises(); + + const card = screen.getByTestId("uploaded-audio-asset"); + expect( + within(card).getByTestId("uploaded-audio-status"), + ).toHaveTextContent(label); + expect(card).toHaveAttribute("aria-busy", ariaBusy); + expect( + within(card).getByRole("button", { name: /Transcribe practice\.wav/ }), + ).toBeDisabled(); + const deleteButton = within(card).getByRole("button", { + name: + assetStatus === "deleting" + ? /Cancelling\/deleting uploaded audio/ + : /Delete uploaded audio/, + }); + expect(deleteButton).toHaveProperty("disabled", !deleteEnabled); + expect(vi.getTimerCount()).toBe(0); + }, + ); +}); + +describe("UploadedAudioPanel polling lifecycle", () => { + it("uses recursive timeouts and never overlaps an unresolved polling GET", async () => { + const polling = deferred(); + const interval = vi.spyOn(window, "setInterval"); + const fetchMock = fetchSequence( + listResponse([asset({ latestJob: job("queued") })]), + polling.promise, + ); + renderPanel(); + await flushPromises(); + + expect(interval).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(1); + await advance(1_000); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + + await advance(10_000); + expect(fetchMock).toHaveBeenCalledTimes(2); + polling.resolve(listResponse([asset({ latestJob: job("queued") })])); + await flushPromises(); + expect(vi.getTimerCount()).toBe(1); + }); + + it.each(["completed", "failed", "cancelled"] as const)( + "stops polling after a %s terminal result", + async (terminal) => { + const terminalAsset = asset({ + status: + terminal === "completed" + ? "completed" + : terminal === "failed" + ? "failed" + : "transcribing", + latestJob: job(terminal), + }); + const fetchMock = fetchSequence( + listResponse([asset({ latestJob: job("queued") })]), + listResponse([terminalAsset]), + ); + renderPanel(); + await flushPromises(); + await advance(1_000); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + expect(screen.getByTestId("uploaded-audio-status")).toHaveTextContent( + terminal === "completed" + ? "Completed" + : terminal === "failed" + ? "Failed" + : "Cancelled", + ); + }, + ); + + it("does not schedule another poll when terminal completion notification fails", async () => { + const committed = vi.fn(async () => { + throw new Error("Transcript refresh failed"); + }); + const fetchMock = fetchSequence( + listResponse([asset({ latestJob: job("queued") })]), + listResponse([ + asset({ status: "completed", latestJob: job("completed") }), + ]), + ); + renderPanel(committed); + await flushPromises(); + await advance(1_000); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(committed).toHaveBeenCalledOnce(); + expect(screen.getByRole("alert")).toHaveTextContent( + "Transcript refresh failed", + ); + expect(vi.getTimerCount()).toBe(0); + }); + + it("clears pending timers and aborts the initial GET on unmount", async () => { + const pending = deferred(); + let signal: AbortSignal | undefined; + const fetchMock = vi.fn((_input, init) => { + signal = init?.signal ?? undefined; + return pending.promise; + }); + vi.stubGlobal("fetch", fetchMock); + const { unmount } = renderPanel(); + + expect(signal?.aborted).toBe(false); + unmount(); + expect(signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + + pending.resolve( + listResponse([ + asset({ status: "completed", latestJob: job("completed") }), + ]), + ); + await flushPromises(); + }); + + it("aborts an in-flight poll and ignores late completion after unmount", async () => { + const polling = deferred(); + const committed = vi.fn(async () => undefined); + const fetchMock = fetchSequence( + listResponse([asset({ latestJob: job("running") })]), + polling.promise, + ); + const { unmount } = renderPanel(committed); + await flushPromises(); + await advance(1_000); + const pollingSignal = fetchMock.mock.calls[1]?.[1]?.signal; + + unmount(); + expect(pollingSignal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + polling.resolve( + listResponse([ + asset({ status: "completed", latestJob: job("completed") }), + ]), + ); + await flushPromises(); + expect(committed).not.toHaveBeenCalled(); + }); + + it("handles a polling rejection after unmount without scheduling work", async () => { + const polling = deferred(); + const fetchMock = fetchSequence( + listResponse([asset({ latestJob: job("running") })]), + polling.promise, + ); + const { unmount } = renderPanel(); + await flushPromises(); + await advance(1_000); + const pollingSignal = fetchMock.mock.calls[1]?.[1]?.signal; + + unmount(); + polling.reject(new Error("Late polling rejection")); + await flushPromises(); + + expect(pollingSignal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("clears a pending recursive poll timer on unmount", async () => { + fetchSequence(listResponse([asset({ latestJob: job("queued") })])); + const { unmount } = renderPanel(); + await flushPromises(); + + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("preserves state, retries a transient failure, recovers, and handles the rejection", async () => { + const unhandled = vi.fn(); + const failedPoll = deferred(); + window.addEventListener("unhandledrejection", unhandled); + try { + const fetchMock = fetchSequence( + listResponse([ + asset({ status: "transcribing", latestJob: job("running") }), + ]), + failedPoll.promise, + listResponse([ + asset({ status: "completed", latestJob: job("completed") }), + ]), + ); + renderPanel(); + await flushPromises(); + await advance(1_000); + failedPoll.reject(new Error("Temporary polling failure")); + await flushPromises(); + + expect(screen.getByTestId("uploaded-audio-status")).toHaveTextContent( + "Transcribing", + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Temporary polling failure", + ); + expect(vi.getTimerCount()).toBe(1); + + await advance(1_000); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(screen.getByTestId("uploaded-audio-status")).toHaveTextContent( + "Completed", + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(vi.getTimerCount()).toBe(0); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + window.removeEventListener("unhandledrejection", unhandled); + } + }); + + it("avoids overlap while hidden and safely resumes active polling when visible", async () => { + const polling = deferred(); + const fetchMock = fetchSequence( + listResponse([asset({ latestJob: job("queued") })]), + polling.promise, + listResponse([asset({ latestJob: job("running") })]), + ); + renderPanel(); + await flushPromises(); + + setVisibility("hidden"); + await advance(1_999); + expect(fetchMock).toHaveBeenCalledOnce(); + await advance(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + + setVisibility("visible"); + await advance(1_000); + expect(fetchMock).toHaveBeenCalledTimes(2); + polling.resolve(listResponse([asset({ latestJob: job("queued") })])); + await flushPromises(); + await advance(1_000); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("does not resume a terminal job on visibility changes and removes its listener", async () => { + const add = vi.spyOn(document, "addEventListener"); + const remove = vi.spyOn(document, "removeEventListener"); + const fetchMock = fetchSequence( + listResponse([ + asset({ status: "completed", latestJob: job("completed") }), + ]), + ); + const { unmount } = renderPanel(); + await flushPromises(); + + setVisibility("hidden"); + setVisibility("visible"); + await advance(5_000); + expect(fetchMock).toHaveBeenCalledOnce(); + const listener = add.mock.calls.find( + ([type]) => type === "visibilitychange", + )?.[1]; + expect(listener).toBeDefined(); + + unmount(); + expect(remove).toHaveBeenCalledWith("visibilitychange", listener); + }); + + it("keeps keyboard focus during a polling refresh", async () => { + fetchSequence( + listResponse([ + asset({ status: "transcribing", latestJob: job("running") }), + ]), + listResponse([ + asset({ + status: "transcribing", + latestJob: job("running", { attemptCount: 2, updatedAt: 12_000 }), + }), + ]), + ); + renderPanel(); + await flushPromises(); + const deleteButton = screen.getByRole("button", { + name: /Delete uploaded audio practice\.wav/, + }); + deleteButton.focus(); + + await advance(1_000); + expect(deleteButton).toHaveFocus(); + }); +}); + +describe("UploadedAudioPanel actions and accessibility", () => { + it("posts a fresh action ID only after explicit Transcribe and renders the queued state", async () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(firstActionId); + const fetchMock = fetchSequence( + listResponse([asset()]), + jsonResponse({ job: { status: "queued" } }, 202), + listResponse([asset({ latestJob: job("queued") })]), + ); + const callback = vi.fn(async () => undefined); + const { rerender } = renderPanel(callback); + await flushPromises(); + rerender( + , + ); + expect( + fetchMock.mock.calls.filter((call) => requestMethod(call) === "POST"), + ).toHaveLength(0); + + fireEvent.click( + screen.getByRole("button", { name: /Transcribe practice\.wav/ }), + ); + await flushPromises(); + + const post = fetchMock.mock.calls.find( + (call) => requestMethod(call) === "POST", + ); + expect(post?.[0]).toContain("/asset-one/transcribe"); + expect(JSON.parse(String(post?.[1]?.body))).toEqual({ + actionId: firstActionId, + }); + expect(screen.getByTestId("uploaded-audio-status")).toHaveTextContent( + "Queued", + ); + expect(screen.getByTestId("uploaded-audio-asset")).toHaveAttribute( + "aria-busy", + "true", + ); + }); + + it("never posts from mount, rerender, polling, or visibility changes", async () => { + const fetchMock = fetchSequence( + listResponse([asset({ latestJob: job("queued") })]), + listResponse([asset({ latestJob: job("running") })]), + ); + const { rerender } = renderPanel(); + await flushPromises(); + + rerender( + undefined)} + />, + ); + setVisibility("hidden"); + setVisibility("visible"); + await advance(1_000); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect( + fetchMock.mock.calls.every((call) => requestMethod(call) === "GET"), + ).toBe(true); + expect(vi.getTimerCount()).toBe(1); + }); + + it("aborts an active Transcribe request and ignores its late success after unmount", async () => { + const transcribeRequest = deferred(); + const fetchMock = fetchSequence( + listResponse([asset()]), + transcribeRequest.promise, + ); + const { unmount } = renderPanel(); + await flushPromises(); + fireEvent.click( + screen.getByRole("button", { name: /Transcribe practice\.wav/ }), + ); + await flushPromises(); + const actionSignal = fetchMock.mock.calls[1]?.[1]?.signal; + + unmount(); + expect(actionSignal?.aborted).toBe(true); + transcribeRequest.resolve(jsonResponse({ job: { status: "queued" } }, 202)); + await flushPromises(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it("uses a different action ID for each retry after terminal failure", async () => { + vi.spyOn(globalThis.crypto, "randomUUID") + .mockReturnValueOnce(firstActionId) + .mockReturnValueOnce(secondActionId); + const failed = asset({ status: "failed", latestJob: job("failed") }); + fetchSequence( + listResponse([failed]), + jsonResponse({ job: { status: "queued" } }, 202), + listResponse([ + asset({ + status: "failed", + latestJob: job("failed", { id: "failed-again" }), + }), + ]), + jsonResponse({ job: { status: "queued" } }, 202), + listResponse([ + asset({ latestJob: job("queued", { id: "second-retry-job" }) }), + ]), + ); + const fetchMock = globalThis.fetch as ReturnType; + renderPanel(); + await flushPromises(); + + fireEvent.click( + screen.getByRole("button", { name: /Retry transcription/ }), + ); + await flushPromises(); + fireEvent.click( + screen.getByRole("button", { name: /Retry transcription/ }), + ); + await flushPromises(); + + const calls = fetchMock.mock.calls as Array>; + const bodies = calls + .filter((call) => requestMethod(call) === "POST") + .map((call) => JSON.parse(String(call[1]?.body)).actionId); + expect(bodies).toEqual([firstActionId, secondActionId]); + }); + + it("respects delete confirmation, exposes deletion as busy, and sends no transcript mutation", async () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(firstActionId); + const confirmation = vi.spyOn(window, "confirm"); + confirmation.mockReturnValueOnce(false).mockReturnValueOnce(true); + const deletion = deferred(); + const fetchMock = fetchSequence( + listResponse([asset({ latestJob: job("queued") })]), + deletion.promise, + listResponse([]), + ); + renderPanel(); + await flushPromises(); + const deleteButton = screen.getByRole("button", { + name: /Delete uploaded audio practice\.wav/, + }); + + fireEvent.click(deleteButton); + expect(fetchMock).toHaveBeenCalledOnce(); + fireEvent.click(deleteButton); + await flushPromises(); + + expect(screen.getByTestId("uploaded-audio-status")).toHaveTextContent( + "Cancelling/deleting", + ); + expect(screen.getByTestId("uploaded-audio-asset")).toHaveAttribute( + "aria-busy", + "true", + ); + expect( + screen.getByRole("button", { + name: /Cancelling\/deleting uploaded audio/, + }), + ).toBeDisabled(); + const request = fetchMock.mock.calls[1]; + expect(requestMethod(request)).toBe("DELETE"); + expect(JSON.parse(String(request[1]?.body))).toEqual({ + actionId: firstActionId, + }); + + deletion.resolve(jsonResponse({ deleted: true })); + await flushPromises(); + expect(screen.getByText("No uploaded audio assets.")).toBeVisible(); + expect( + fetchMock.mock.calls.some(([url]) => + String(url).includes("transcript-segments"), + ), + ).toBe(false); + expect( + fetchMock.mock.calls.some((call) => requestMethod(call) === "POST"), + ).toBe(false); + }); + + it("does not let an older polling response restore a deleted asset", async () => { + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(firstActionId); + vi.spyOn(window, "confirm").mockReturnValue(true); + const stalePoll = deferred(); + const fetchMock = fetchSequence( + listResponse([asset({ latestJob: job("running") })]), + stalePoll.promise, + jsonResponse({ deleted: true }), + listResponse([]), + ); + renderPanel(); + await flushPromises(); + await advance(1_000); + + fireEvent.click( + screen.getByRole("button", { + name: /Delete uploaded audio practice\.wav/, + }), + ); + await flushPromises(); + expect(screen.getByText("No uploaded audio assets.")).toBeVisible(); + + stalePoll.resolve( + listResponse([ + asset({ status: "completed", latestJob: job("completed") }), + ]), + ); + await flushPromises(); + + expect(screen.getByText("No uploaded audio assets.")).toBeVisible(); + expect(screen.queryByTestId("uploaded-audio-asset")).not.toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(vi.getTimerCount()).toBe(0); + }); + + it("renders only a safe public request error and restores retry controls", async () => { + const failed = asset({ status: "failed", latestJob: job("failed") }); + const fetchMock = fetchSequence( + listResponse([failed]), + jsonResponse( + { + error: { + message: "Transcription is temporarily unavailable.", + stack: "secret stack /private/path database.sqlite SQL lease-token", + providerPayload: { transcript: "private transcript text" }, + }, + }, + 503, + ), + listResponse([failed]), + ); + renderPanel(); + await flushPromises(); + fireEvent.click( + screen.getByRole("button", { name: /Retry transcription/ }), + ); + await flushPromises(); + + expect( + screen + .getAllByRole("alert") + .some((alert) => + alert.textContent?.includes( + "Transcription is temporarily unavailable.", + ), + ), + ).toBe(true); + expect(document.body).not.toHaveTextContent("secret stack"); + expect(document.body).not.toHaveTextContent("/private/path"); + expect(document.body).not.toHaveTextContent("database.sqlite"); + expect(document.body).not.toHaveTextContent("lease-token"); + expect(document.body).not.toHaveTextContent("private transcript text"); + expect( + screen.getByRole("button", { name: /Retry transcription/ }), + ).toBeEnabled(); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("keeps labelled Upload, role, Transcribe, and Delete controls keyboard reachable", async () => { + fetchSequence(listResponse([asset()])); + renderPanel(); + await flushPromises(); + const fileInput = screen.getByLabelText("Audio file"); + const roleSelect = screen.getByLabelText( + "One speaker role for the whole file", + ); + const uploadButton = screen.getByRole("button", { + name: "Upload selected practice audio", + }); + const transcribeButton = screen.getByRole("button", { + name: /Transcribe practice\.wav/, + }); + const deleteButton = screen.getByRole("button", { + name: /Delete uploaded audio practice\.wav/, + }); + fireEvent.change(fileInput, { + target: { + files: [ + new File([new Uint8Array([1])], "new.wav", { type: "audio/wav" }), + ], + }, + }); + expect(uploadButton).toBeEnabled(); + + vi.clearAllTimers(); + vi.useRealTimers(); + const user = userEvent.setup(); + await user.tab(); + expect(fileInput).toHaveFocus(); + await user.tab(); + expect(roleSelect).toHaveFocus(); + await user.tab(); + expect(uploadButton).toHaveFocus(); + await user.tab(); + expect(transcribeButton).toHaveFocus(); + await user.tab(); + expect(deleteButton).toHaveFocus(); + }); +});