From 6900bfe6f0db057fc981cfd2de073f7be6db2574 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:36:20 +0000 Subject: [PATCH 1/2] fix(ui): keep analysis-run next actions in the list name (v0.86.3) #148 landed kind-specific pending copy but left aria-label caption-only. Include the next-action in the accessible name, split pending vs running TEPP corpus copy, and stop echoing raw wire codes from never defaults. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 5 +- .../0.86.3-audible-list-next-actions.md | 2 + CHANGELOG.md | 9 + CLAUDE.md | 6 +- docs/adr/0014-authorized-analysis-run-read.md | 15 +- .../ANALYSIS_RUN_REGISTRY_REFERENCES.md | 5 +- frontend/package.json | 2 +- frontend/src/App.test.tsx | 327 +++++++++++++----- frontend/src/App.tsx | 36 +- lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 12 files changed, 309 insertions(+), 104 deletions(-) create mode 100644 CHANGELOG.d/0.86.3-audible-list-next-actions.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a4d0ac4a..4ac90ee56 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -491,8 +491,9 @@ measurement service) so `tepp_not_available` is not mistaken for a calibrated negative result. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report. A pending TEPP row -does not claim a calibrated measurement. A pending lineage row -says reconstruction has not started yet. The +does not claim a calibrated measurement and does not say +reconstruction. The list button accessible name includes the +next-action sentence; detail repeats it. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.86.3-audible-list-next-actions.md b/CHANGELOG.d/0.86.3-audible-list-next-actions.md new file mode 100644 index 000000000..94862a886 --- /dev/null +++ b/CHANGELOG.d/0.86.3-audible-list-next-actions.md @@ -0,0 +1,2 @@ +List-button accessible names include the next-action sentence. +Pending TEPP corpus copy does not say the run has already started. diff --git a/CHANGELOG.md b/CHANGELOG.md index d30ae14aa..91ffd7144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.86.3] - 2026-08-16 + +### Fixed + +- Analysis-run list buttons keep the next-action sentence in the + accessible name (WCAG 2.2 SC 4.1.2). Open a Failed TEPP row: the + name includes “connect the measurement service.” A Pending TEPP + corpus no longer says the run has already started finishing. + ## [0.86.2] - 2026-08-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c5a1828f2..56978e4e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,10 @@ theta or a local psychometric substitute. The home list caption stays (ADR 0014). Open a Failed TEPP row, then connect a live TEPP transport. A failed lineage row retries reconstruction -- it does not mention TEPP. A failed period-report row rebuilds the report. A -pending TEPP row does not claim a calibrated measurement. A pending -lineage row says reconstruction has not started yet. +pending TEPP row does not claim a calibrated measurement and does +not say reconstruction. A pending lineage row still says +reconstruction has not started. The list button name includes the +next-action sentence. Digest prefixes stay audible; hover a prefix to read the full digest. Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index 500c2bc2a..17d9b6b31 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -48,8 +48,11 @@ service. A failed lineage row tells the operator to retry reconstruction, not to connect TEPP. A failed period-report row tells the operator to rebuild the report from a current snapshot. A pending or running TEPP row must not claim a calibrated -measurement. A pending lineage row says reconstruction has not -started yet. The detail now shows the legal +measurement and must not say reconstruction. A pending lineage row +says reconstruction has not started yet. The list button accessible +name includes the next-action sentence (WCAG 2.2 SC 4.1.2); detail +repeats it so opening a Failed or Pending row still tells the +operator what to do. The detail now shows the legal lifecycle the registry already stored. `POST /api/analysis-runs` now records a Pending run on an authorized cutoff capture (ADR 0017). Reconstruction, a live TEPP transport, and a fuller Analysis Run @@ -65,3 +68,11 @@ Educational Research Association. Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/2013/REC-prov-o-20130430/ + +World Wide Web Consortium. (2018). *Accessible name and description +computation 1.1* (W3C Recommendation). +https://www.w3.org/TR/accname-1.1/ + +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). +https://www.w3.org/TR/WCAG22/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index b41b31c17..bee3d2571 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -9,7 +9,7 @@ |---|---|---| | W3C PROV-DM and PROV-O | Preserve identifiable entities, activities, agents, generation/use, and derivation without flattening provenance into display-only edges. | `analysis_source_snapshot`, `analysis_run`, authenticated requester, append-only status events, immutable digests; later product bindings continue to use the separate `provenance_*` layer from ADR 0011. | | W3C Time Ontology in OWL | Keep temporal concepts explicit and avoid collapsing distinct clocks. | Evidence availability and snapshot capture remain on `analysis_source_snapshot`; analysis knowledge cutoff and request time remain on `analysis_run`; status occurrence and database record time remain distinct. `GET /api/analysis-runs/{id}` visible posts apply `created_at <= knowledge_cutoff` (ADR 0016). Opening a listed title warns that the live body may have changed after that cutoff. | -| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. | +| W3C Accessible Name and Description Computation 1.1 | Do not let `aria-label` replace visible text the operator must hear. | Analysis-run digest prefixes live in a labeled group; the prefixes remain the accessible contents and the full digest is on `title` for hover verification. List-button names include the kind-specific next-action sentence (ADR 0014; WCAG 2.2 SC 4.1.2). | | ISO 8601-1:2019 | Use unambiguous timestamp representation and timezone-aware persistence. | PostgreSQL `timestamptz` for availability, capture, cutoff, request, occurrence, and record clocks; tests use explicit `Z` offsets. | | PostgreSQL 18 constraints and trigger contracts | Put integrity close to durable truth and use constraints for row shape while triggers enforce cross-row state and serialization. | Digest/check constraints, category allowlists, account-scoped uniqueness, shape constraints, immutable-row triggers, shared snapshot-row locking, and serialized status transitions. | | NIST SP 800-92 | Treat audit records as bounded, protected operational evidence rather than unstructured application logging. | Append-only status events, machine failure codes, actor identity, occurrence/record clocks, fail-closed rollback, and exclusion of raw source/provider payloads. | @@ -102,5 +102,8 @@ Recommendation). https://www.w3.org/TR/prov-o/ World Wide Web Consortium. (2018). *Accessible name and description computation 1.1* (W3C Recommendation). https://www.w3.org/TR/accname-1.1/ +World Wide Web Consortium. (2023). *Web content accessibility +guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ diff --git a/frontend/package.json b/frontend/package.json index fb52f7948..b5209226e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.2", + "version": "0.86.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 60d06c8de..c547068db 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 = { @@ -85,6 +88,29 @@ describe("App, authenticated", () => { let nextTicketId = 1; const events: { event_id: string; event_type: string; actor_account_id: string; summary: string }[] = []; let nextEventId = 1; + let createdPendingLineage = false; + const pendingLineageRun = { + analysis_run_id: "run-demo-lineage-pending", + run_kind_code: "analysis_run_lineage" as const, + run_kind_label: "Lineage reconstruction", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_pending" as const, + status_label: "Pending", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:35:00Z", + source_counts: [] as { count_type_code: string; count_type_label: string; count_value: number }[], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending" as const, + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + ], + }; const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -175,6 +201,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 +210,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,35 +222,55 @@ 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", + }, + ], }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-lineage-pending")) { + return Promise.resolve(jsonResponse(pendingLineageRun)); + } if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { const teppStatus = options?.succeededTeppRun ? "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"; + : options?.runningTeppRun + ? "Running" + : options?.cancelledTeppRun + ? "Cancelled" + : "Failed"; return Promise.resolve( jsonResponse({ analysis_run_id: "run-demo-tepp", @@ -253,31 +300,57 @@ describe("App, authenticated", () => { occurred_at: "2026-01-12T12:35:00Z", }, ] - : [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:35:00Z", - }, - { - status_ordinal: 2, - status_code: "analysis_status_running", - 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", - occurred_at: "2026-01-12T12:37:00Z", - ...(options?.succeededTeppRun - ? {} - : { failure_code: "tepp_not_available" }), - }, - ], + : options?.runningTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + ] + : options?.cancelledTeppRun + ? [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_cancelled", + status_label: "Cancelled", + occurred_at: "2026-01-12T12:36:00Z", + }, + ] + : [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: teppStatus, + status_label: teppLabel, + occurred_at: "2026-01-12T12:37:00Z", + ...(options?.succeededTeppRun ? {} : { failure_code: "tepp_not_available" }), + }, + ], }), ); } @@ -290,8 +363,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,38 +395,22 @@ 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" } + : {}), }, ], }), ); } if (url.endsWith("/api/analysis-runs") && method === "POST") { - const created = { - analysis_run_id: "run-demo-lineage-pending", - run_kind_code: "analysis_run_lineage", - run_kind_label: "Lineage reconstruction", - scope_kind_code: "analysis_scope_corporate_entity", - scope_kind_label: "Corporate entity", - scope_entity_name: "Demo Corp", - status_code: "analysis_status_pending", - status_label: "Pending", - knowledge_cutoff: "2026-01-12T12:00:00Z", - requested_at: "2026-01-12T12:35:00Z", - source_counts: [], - visible_posts: [{ post_id: "post-1", post_title: "Public post" }], - status_history: [ - { - status_ordinal: 1, - status_code: "analysis_status_pending", - status_label: "Pending", - occurred_at: "2026-01-12T12:35:00Z", - }, - ], - }; - return Promise.resolve(new Response(JSON.stringify(created), { status: 201 })); + createdPendingLineage = true; + return Promise.resolve(new Response(JSON.stringify(pendingLineageRun), { status: 201 })); } if (url.endsWith("/api/analysis-runs")) { return Promise.resolve( @@ -392,12 +451,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 +475,8 @@ describe("App, authenticated", () => { }, ], }, - ...(options?.failedReportRun + ...(createdPendingLineage ? [pendingLineageRun] : []), + ...(options?.failedReportRun || options?.pendingReportRun ? [ { analysis_run_id: "run-demo-report", @@ -417,8 +485,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 +1774,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 +1792,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 +1805,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 +1824,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 +1840,85 @@ describe("App, authenticated", () => { ).toBeInTheDocument(); }); - it("does not tell a pending TEPP run that it already measured", async () => { + it("does not tell a pending period report that reconstruction or measurement has 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 has not started yet"); + 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(/TEPP will measure/)).not.toBeInTheDocument(); + }); + + 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( + screen.getAllByText( + "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result.", + ).length, + ).toBeGreaterThanOrEqual(2); + expect( + await screen.findByText( + "These posts are the cutoff corpus TEPP will measure. Measurement has not started — this is not a calibrated result.", + ), + ).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(); + expect(screen.queryByText(/once this run finishes/)).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(); + expect(screen.queryByText(/Measurement has not started/)).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 () => { @@ -1801,10 +1947,17 @@ describe("App, authenticated", () => { await screen.findByRole("heading", { name: "Lineage reconstruction · Pending · Demo Corp" }), ).toBeInTheDocument(); expect( - screen.getByText( - "Open this run to confirm which posts it will use. Reconstruction has not started yet.", - ), + screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Pending · Demo Corp. Open this run to confirm which posts it will use. Reconstruction has not started yet.", + }), ).toBeInTheDocument(); + expect( + screen.getAllByText( + "Open this run to confirm which posts it will use. Reconstruction has not started yet.", + ).length, + ).toBeGreaterThanOrEqual(2); + expect(screen.queryByText(/Measurement has not started/)).not.toBeInTheDocument(); + expect(screen.queryByText(/The report has not been built/)).not.toBeInTheDocument(); const postCall = fetchMock.mock.calls.find( (call) => String(call[0]).endsWith("/api/analysis-runs") && call[1]?.method === "POST", ); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d589a3644..16c81ee14 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1470,7 +1470,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 +1484,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 +1495,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 +1535,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." + ); } } } @@ -1542,6 +1561,10 @@ function analysisRunCorpusHint(run: AnalysisRun): string | null { case "analysis_status_succeeded": return "These posts are the cutoff corpus this TEPP run measured."; case "analysis_status_pending": + return ( + "These posts are the cutoff corpus TEPP will measure. Measurement has " + + "not started — this is not a calibrated result." + ); case "analysis_status_running": return "These posts are the cutoff corpus TEPP will measure once this run finishes."; case "analysis_status_cancelled": @@ -1553,7 +1576,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 +1725,7 @@ function AnalysisRunsPanel({