From 275dd3f38f7ac3206d8d65de6829c437fae3ef44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:31:53 +0000 Subject: [PATCH 1/5] fix(ui): include next-action in analysis-run accessible names List aria-label was replacing the visible next-action, so a Pending TEPP or Failed TEPP row only announced the caption. Pin the name to caption plus the kind-specific sentence and lock pending-report plus running/cancelled TEPP corpus copy. Co-authored-by: Seongho Bae --- frontend/src/App.test.tsx | 264 ++++++++++++++++++++++++++++---------- frontend/src/App.tsx | 37 ++++-- 2 files changed, 227 insertions(+), 74 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 60d06c8de..7871f428c 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -61,8 +61,11 @@ describe("App, authenticated", () => { verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; failedReportRun?: boolean; + pendingReportRun?: boolean; succeededTeppRun?: boolean; pendingTeppRun?: boolean; + runningTeppRun?: boolean; + cancelledTeppRun?: boolean; postBody?: string; }) { const statusLabel: Record = { @@ -175,6 +178,7 @@ describe("App, authenticated", () => { ); } if (url.endsWith("/api/analysis-runs/run-demo-report")) { + const reportPending = Boolean(options?.pendingReportRun); return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-report", @@ -183,8 +187,8 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: reportPending ? "analysis_status_pending" : "analysis_status_failed", + status_label: reportPending ? "Pending" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -195,21 +199,30 @@ describe("App, authenticated", () => { }, ], visible_posts: [], - status_history: [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:39:00Z", - }, - { - status_ordinal: 2, - status_code: "analysis_status_failed", - status_label: "Failed", - occurred_at: "2026-01-12T12:40:00Z", - failure_code: "period_report_rebuild_failed", - }, - ], + status_history: reportPending + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:39:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:40:00Z", + failure_code: "period_report_rebuild_failed", + }, + ], }), ); } @@ -218,66 +231,101 @@ describe("App, authenticated", () => { ? "analysis_status_succeeded" : options?.pendingTeppRun ? "analysis_status_pending" - : "analysis_status_failed"; + : options?.runningTeppRun + ? "analysis_status_running" + : options?.cancelledTeppRun + ? "analysis_status_cancelled" + : "analysis_status_failed"; const teppLabel = options?.succeededTeppRun ? "Succeeded" : options?.pendingTeppRun ? "Pending" - : "Failed"; - return Promise.resolve( - jsonResponse({ - analysis_run_id: "run-demo-tepp", - run_kind_code: "analysis_run_tepp", - run_kind_label: "TEPP measurement", - scope_kind_code: "analysis_scope_corporate_entity", - scope_kind_label: "Corporate entity", - scope_entity_name: "Demo Corp", - status_code: teppStatus, - status_label: teppLabel, - knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:34:00Z", - source_counts: [ + : options?.runningTeppRun + ? "Running" + : options?.cancelledTeppRun + ? "Cancelled" + : "Failed"; + const teppHistory = options?.pendingTeppRun + ? [ { - count_type_code: "analysis_count_document", - count_type_label: "Documents", - count_value: 3, + status_ordinal: 1, + status_code: "analysis_status_pending" as const, + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", }, - ], - visible_posts: [{ post_id: "post-1", post_title: "Public post" }], - status_history: options?.pendingTeppRun + ] + : options?.runningTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending" as const, + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running" as const, + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + ] + : options?.cancelledTeppRun ? [ { status_ordinal: 1, - status_code: "analysis_status_pending", + status_code: "analysis_status_pending" as const, status_label: "Pending", occurred_at: "2026-01-12T12:35:00Z", }, + { + status_ordinal: 2, + status_code: "analysis_status_cancelled" as const, + status_label: "Cancelled", + occurred_at: "2026-01-12T12:36:00Z", + }, ] : [ { status_ordinal: 1, - status_code: "analysis_status_pending", + status_code: "analysis_status_pending" as const, status_label: "Pending", occurred_at: "2026-01-12T12:35:00Z", }, { status_ordinal: 2, - status_code: "analysis_status_running", + status_code: "analysis_status_running" as const, status_label: "Running", occurred_at: "2026-01-12T12:36:00Z", }, { status_ordinal: 3, - status_code: options?.succeededTeppRun - ? "analysis_status_succeeded" - : "analysis_status_failed", - status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", + status_code: teppStatus, + status_label: teppLabel, occurred_at: "2026-01-12T12:37:00Z", - ...(options?.succeededTeppRun - ? {} - : { failure_code: "tepp_not_available" }), + ...(options?.succeededTeppRun ? {} : { failure_code: "tepp_not_available" }), }, - ], + ]; + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: teppStatus, + status_label: teppLabel, + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: teppHistory, }), ); } @@ -290,8 +338,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", source_counts: [ @@ -320,9 +370,14 @@ describe("App, authenticated", () => { }, { status_ordinal: 3, - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", occurred_at: "2026-01-12T12:33:00Z", + ...(options?.failedLineageRun + ? { failure_code: "lineage_reconstruction_failed" } + : {}), }, ], }), @@ -392,12 +447,20 @@ describe("App, authenticated", () => { ? "analysis_status_succeeded" : options?.pendingTeppRun ? "analysis_status_pending" - : "analysis_status_failed", + : options?.runningTeppRun + ? "analysis_status_running" + : options?.cancelledTeppRun + ? "analysis_status_cancelled" + : "analysis_status_failed", status_label: options?.succeededTeppRun ? "Succeeded" : options?.pendingTeppRun ? "Pending" - : "Failed", + : options?.runningTeppRun + ? "Running" + : options?.cancelledTeppRun + ? "Cancelled" + : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -408,7 +471,7 @@ describe("App, authenticated", () => { }, ], }, - ...(options?.failedReportRun + ...(options?.failedReportRun || options?.pendingReportRun ? [ { analysis_run_id: "run-demo-report", @@ -417,8 +480,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed" as const, - status_label: "Failed", + status_code: options?.pendingReportRun + ? ("analysis_status_pending" as const) + : ("analysis_status_failed" as const), + status_label: options?.pendingReportRun ? "Pending" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:38:00Z", source_counts: [ @@ -1704,7 +1769,7 @@ describe("App, authenticated", () => { await userEvent.click( screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: TEPP measurement · Failed · Demo Corp. Open this run to see why it failed, then connect the measurement service and re-run.", }), ); expect( @@ -1722,10 +1787,10 @@ describe("App, authenticated", () => { await screen.findByRole("list", { name: "Analysis runs" }); const lineageButton = screen.getByRole("button", { - name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp. Open this run to see why it failed, then retry reconstruction from a current snapshot.", }); const teppButton = screen.getByRole("button", { - name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + name: "Open analysis run: TEPP measurement · Failed · Demo Corp. Open this run to see why it failed, then connect the measurement service and re-run.", }); expect(lineageButton).toHaveTextContent( "Open this run to see why it failed, then retry reconstruction from a current snapshot.", @@ -1735,6 +1800,18 @@ describe("App, authenticated", () => { "Open this run to see why it failed, then connect the measurement service and re-run.", ); expect(teppButton).not.toHaveTextContent("reconstruction"); + + await userEvent.click(lineageButton); + expect( + await screen.findByRole("heading", { + name: "Lineage reconstruction · Failed · Demo Corp", + }), + ).toBeInTheDocument(); + expect( + screen.getAllByText( + "Open this run to see why it failed, then retry reconstruction from a current snapshot.", + ).length, + ).toBeGreaterThanOrEqual(2); }); it("does not tell a failed period report to connect the measurement service", async () => { @@ -1742,7 +1819,7 @@ describe("App, authenticated", () => { render(); const reportButton = await screen.findByRole("button", { - name: "Open analysis run: Period report · Failed · Demo Corp", + name: "Open analysis run: Period report · Failed · Demo Corp. Open this run to see why it failed, then rebuild the period report from a current snapshot.", }); expect(reportButton).toHaveTextContent( "Open this run to see why it failed, then rebuild the period report from a current snapshot.", @@ -1758,21 +1835,76 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); }); - it("does not tell a pending TEPP run that it already measured", async () => { + it("does not tell a pending TEPP run that reconstruction or measurement already finished", async () => { stubBackend({ pendingTeppRun: true }); render(); + const teppButton = await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Pending · Demo Corp. Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.", + }); + expect(teppButton).not.toHaveTextContent("Reconstruction has not started yet"); + expect(teppButton).not.toHaveTextContent("measured"); + + await userEvent.click(teppButton); + expect( + await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + }); + + it("does not tell a pending period report that reconstruction or measurement started", async () => { + stubBackend({ pendingReportRun: true }); + render(); + + const reportButton = await screen.findByRole("button", { + name: "Open analysis run: Period report · Pending · Demo Corp. Open this run to confirm which posts the period report will use. The report has not been built yet.", + }); + expect(reportButton).not.toHaveTextContent("Reconstruction"); + expect(reportButton).not.toHaveTextContent("measurement"); + + await userEvent.click(reportButton); + expect( + screen.getAllByText( + "Open this run to confirm which posts the period report will use. The report has not been built yet.", + ).length, + ).toBeGreaterThanOrEqual(2); + expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + }); + + it("does not tell a running TEPP run that it already measured", async () => { + stubBackend({ runningTeppRun: true }); + render(); + await userEvent.click( await screen.findByRole("button", { - name: "Open analysis run: TEPP measurement · Pending · Demo Corp", + name: "Open analysis run: TEPP measurement · Running · Demo Corp", }), ); expect( await screen.findByText("These posts are the cutoff corpus TEPP will measure once this run finishes."), ).toBeInTheDocument(); - expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Reconstruction has not started yet/)).not.toBeInTheDocument(); + }); + + it("does not tell a cancelled TEPP run that it produced a calibrated result", async () => { + stubBackend({ cancelledTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Cancelled · Demo Corp", + }), + ); + expect( + await screen.findByText( + "These posts are the cutoff corpus this TEPP run would have measured. The run was cancelled before a calibrated result.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/this TEPP run measured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); }); it("does not tell a succeeded TEPP run to replace Failed", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d589a3644..999a3ec64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1451,12 +1451,13 @@ function analysisRunCaption(run: AnalysisRun): string { } /** - * Next action for a pending or failed run on the home list and detail. + * Next action for a pending or failed run on the home list and detail + * (ADR 0021). * * The machine `failure_code` stays on detail history (ADR 0014). Copy * is pinned to registered kinds so a pending TEPP row is not mistaken * for reconstruction, and a failed lineage row is not mistaken for a - * missing TEPP transport. + * missing TEPP transport. Unknown wire codes stay off the sentence. */ function analysisRunNextAction(run: AnalysisRun): string | null { switch (run.status_code) { @@ -1470,7 +1471,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return "Open this run to confirm which posts the period report will use. The report has not been built yet."; default: { const unexpected: never = run.run_kind_code; - return unexpected; + void unexpected; + return "Open this run to confirm its next step. The registered kind is not lineage, TEPP, or a period report."; } } case "analysis_status_failed": @@ -1483,7 +1485,8 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; default: { const unexpected: never = run.run_kind_code; - return unexpected; + void unexpected; + return "Open this run to see why it failed, then retry from a current snapshot."; } } case "analysis_status_running": @@ -1493,11 +1496,24 @@ function analysisRunNextAction(run: AnalysisRun): string | null { return null; default: { const unexpected: never = run.status_code; - return unexpected; + void unexpected; + return "Open this run to confirm its current status before acting."; } } } +/** + * List-button accessible name (WCAG 2.2 SC 4.1.2 / AccName 1.1). + * + * `aria-label` replaces the button contents, so the next-action sentence + * must be in the name or a screen reader only hears the caption. + */ +function analysisRunAccessibleName(run: AnalysisRun): string { + const caption = analysisRunCaption(run); + const nextAction = analysisRunNextAction(run); + return nextAction ? `Open analysis run: ${caption}. ${nextAction}` : `Open analysis run: ${caption}`; +} + /** * Empty-corpus copy that tells the operator what to do next. */ @@ -1520,7 +1536,11 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { ); default: { const unexpected: never = run.run_kind_code; - return unexpected; + void unexpected; + return ( + "No posts were available at this cutoff. Open a later run, or ask an " + + "administrator to capture a newer snapshot." + ); } } } @@ -1553,7 +1573,8 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { return "These posts are the cutoff corpus attached to this TEPP run."; default: { const unexpected: never = run.status_code; - return unexpected; + void unexpected; + return "These posts are the cutoff corpus attached to this TEPP run."; } } } @@ -1701,7 +1722,7 @@ function AnalysisRunsPanel({