diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b66c7cde7..d22e22907 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -491,7 +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. 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 (ADR 0020). 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.0-related-nodes-team-org-walk.md b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md index 4989a054f..83e585b3f 100644 --- a/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md +++ b/CHANGELOG.d/0.86.0-related-nodes-team-org-walk.md @@ -1,4 +1,5 @@ Related-node walks include team and organization mention edges. Click an R&R team to open sibling posts. Thread-group run lists honor knowledge_cutoff. Failed period-report rows rebuild the report; a pending TEPP corpus -does not claim a calibrated measurement. +does not claim a calibrated measurement. Pending list copy is +kind-specific (ADR 0020). diff --git a/CHANGELOG.d/0.86.3-kind-specific-pending-next-actions.md b/CHANGELOG.d/0.86.3-kind-specific-pending-next-actions.md new file mode 100644 index 000000000..a79df878f --- /dev/null +++ b/CHANGELOG.d/0.86.3-kind-specific-pending-next-actions.md @@ -0,0 +1,3 @@ +Pending TEPP next-action copy does not say reconstruction. List +accessible names include the next-action sentence. Detail repeats it +(ADR 0020). diff --git a/CHANGELOG.md b/CHANGELOG.md index 13aeb02f9..96c308198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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 + +- Pending analysis-run next actions are kind-specific. A pending TEPP + row tells the operator measurement has not started — it does not say + reconstruction. The list button accessible name includes that + sentence (WCAG 2.2 SC 4.1.2). Open the row: detail repeats it + (ADR 0020). + ## [0.86.2] - 2026-08-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index a11127584..36735f91a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,9 @@ 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. +pending TEPP row does not claim a calibrated measurement and does +not say reconstruction (ADR 0020). 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 29c074c8f..b1626993a 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -48,7 +48,8 @@ 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. The detail now shows the legal +measurement and must not say reconstruction (ADR 0020). The list +button accessible name includes the next-action sentence. 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 diff --git a/docs/adr/0020-kind-specific-pending-next-actions.md b/docs/adr/0020-kind-specific-pending-next-actions.md new file mode 100644 index 000000000..79257de6c --- /dev/null +++ b/docs/adr/0020-kind-specific-pending-next-actions.md @@ -0,0 +1,52 @@ +# ADR 0020 — Pending next actions stay kind-specific and audible + +**Decision status:** Accepted on this active PR; not protected-main truth until merge +**Date:** 2026-08-16 +**Depends on:** ADR 0014 authorized analysis-run read; ADR 0017 authorized +analysis-run create +**Refs:** Merged #128 left pending copy kind-blind; #138 is the same +slice on `main` and must not be duplicated onto this #74 stack + +## Context + +#128 pinned Failed next-action copy to the registered kinds +(`analysis_run_lineage`, `analysis_run_tepp`, `analysis_run_report`). +Pending rows still used one sentence: "Reconstruction has not started +yet." A Pending TEPP row therefore told the operator to wait for +reconstruction. The list button's `aria-label` also replaced the +visible next-action, so assistive technology only heard the caption +(W3C, 2018, 2023). + +#142 owns in-process lineage start. This decision does not start a +run, invent a theta, or add Storybook. + +## Decision + +- `analysisRunNextAction` switches on both `status_code` and + `run_kind_code`. Pending TEPP says measurement has not started and + is not a calibrated result. Pending report says the report has not + been built. Pending lineage keeps the reconstruction sentence. +- The list button accessible name is + `Open analysis run: {caption}. {nextAction}` when a next action + exists, otherwise the caption alone. +- Detail repeats the same next-action sentence after the title so + opening a Failed or Pending row still tells the operator what to do. +- Running, succeeded, and cancelled rows keep list next-action empty. + TEPP corpus copy already covers those states on detail. + +## Consequences + +After `make seed`, open the Failed TEPP row: the list name includes +"connect the measurement service." Request a lineage reconstruction: +the Pending lineage row still says reconstruction has not started. +A Pending TEPP fixture must not say Reconstruction. + +## References — APA 7th + +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..18df0f749 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 0020; 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 65d25259b..53bb483f2 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,89 +222,136 @@ 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"; - 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 +364,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 +396,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 +452,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 +476,8 @@ describe("App, authenticated", () => { }, ], }, - ...(options?.failedReportRun + ...(createdPendingLineage ? [pendingLineageRun] : []), + ...(options?.failedReportRun || options?.pendingReportRun ? [ { analysis_run_id: "run-demo-report", @@ -417,8 +486,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 +1775,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 +1793,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 +1806,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 +1825,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,22 +1841,78 @@ 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( + 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 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(); }); + 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 () => { stubBackend({ succeededTeppRun: true }); render(); @@ -1799,7 +1938,18 @@ describe("App, authenticated", () => { expect( await screen.findByRole("heading", { name: "Lineage reconstruction · Pending · Demo Corp" }), ).toBeInTheDocument(); - expect(screen.getByText(/has not started yet/)).toBeInTheDocument(); + expect( + 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 a511f713d..b2e578c72 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1451,33 +1451,68 @@ function analysisRunCaption(run: AnalysisRun): string { } /** - * Next action for a failed run on the home list. + * Next action for a pending or failed run on the home list (ADR 0020). * * The machine `failure_code` stays on detail history (ADR 0014). Copy - * is kind-specific so a failed lineage reconstruction is not mistaken - * for a missing TEPP transport. + * is kind-specific so a pending TEPP row is not mistaken for + * reconstruction, and a failed lineage row is not mistaken for a + * missing TEPP transport. */ function analysisRunNextAction(run: AnalysisRun): string | null { - if (run.status_code === "analysis_status_pending") { - return "Open this run to confirm which posts it will use. Reconstruction has not started yet."; - } - if (run.status_code !== "analysis_status_failed") { - return null; - } - switch (run.run_kind_code) { - case "analysis_run_tepp": - return "Open this run to see why it failed, then connect the measurement service and re-run."; - case "analysis_run_lineage": - return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; - case "analysis_run_report": - return "Open this run to see why it failed, then rebuild the period report from a current snapshot."; + switch (run.status_code) { + case "analysis_status_pending": + switch (run.run_kind_code) { + case "analysis_run_lineage": + return "Open this run to confirm which posts it will use. Reconstruction has not started yet."; + case "analysis_run_tepp": + return "Open this run to confirm which posts TEPP will measure. Measurement has not started yet — this is not a calibrated result."; + case "analysis_run_report": + 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; + 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": + switch (run.run_kind_code) { + case "analysis_run_tepp": + return "Open this run to see why it failed, then connect the measurement service and re-run."; + case "analysis_run_lineage": + return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; + case "analysis_run_report": + 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; + void unexpected; + return "Open this run to see why it failed, then retry from a current snapshot."; + } + } + case "analysis_status_running": + case "analysis_status_succeeded": + case "analysis_status_cancelled": + case null: + return null; default: { - const unexpected: never = run.run_kind_code; - return unexpected; + const unexpected: never = run.status_code; + 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. */ @@ -1500,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." + ); } } } @@ -1533,7 +1572,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."; } } } @@ -1648,6 +1688,7 @@ function AnalysisRunsPanel({ if (runs === null) return

Loading analysis runs...

; const corpusHint = selected ? analysisRunCorpusHint(selected) : null; + const selectedNextAction = selected ? analysisRunNextAction(selected) : null; return (
@@ -1680,7 +1721,7 @@ function AnalysisRunsPanel({