diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3efd0154..a2168aade 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1558,7 +1558,32 @@ jobs: (needs.ui-critical.result == 'success' || needs.ui-critical.result == 'skipped') needs: [changes, static-pr, build, ui-critical] runs-on: ubuntu-24.04 - timeout-minutes: 70 + # Per ENGINE, not for the whole matrix. Before 2026-09-07 one job ran + # chromium-mockups + firefox + webkit sequentially under `workers: 1` / + # `fullyParallel: false`, and it stopped finishing: measured 70m23s + # (run 34100540973) and 70m20s (run 34104496596), both landing exactly on the + # old 70-minute cap. GitHub reports a timed-out job as `cancelled`, so every + # main run concluded cancelled while every other job passed — and the matrix + # produced NO verdict, which is worse than a red one. `main` had zero + # Firefox/WebKit coverage rather than failing coverage. + # + # A single-worker suite is not made faster by a longer cap, so the cap was + # NOT raised. The engines run as sibling jobs instead, the same answer + # `Production UI` already uses for Chromium (see its shard strategy above), + # which keeps `fullyParallel: false` / `retries: 0` determinism inside each + # job while cutting wall-clock to the slowest engine rather than their sum. + timeout-minutes: 45 + strategy: + # One engine's failure must not cancel its siblings: a cancelled sibling + # reports no verdict, which is the exact failure mode this change exists to + # remove. + fail-fast: false + matrix: + # Groups, not bare project names, because the fail-safe path below runs + # the COMPLETE project set and every project must land in exactly one + # group. `tests/ci-browser-matrix-coverage.test.ts` proves that union + # against playwright.config.ts and fails closed when a project is added. + engine: [chromium, firefox, webkit] steps: - name: Checkout @@ -1610,21 +1635,47 @@ jobs: - name: Full browser UI matrix id: e2e-matrix + env: + ENGINE: ${{ matrix.engine }} run: | + # Both paths below are exhaustive across playwright.config.ts projects + # for their own path, and disjoint between engines. The fail-safe union + # must equal the COMPLETE project set — a project omitted here would + # vanish from CI silently, so + # `tests/ci-browser-matrix-coverage.test.ts` re-derives both unions from + # this file plus playwright.config.ts and fails closed on any drift. if [ "${{ needs.changes.outputs.ui_changed }}" = "true" ] && [ "${{ needs.ui-critical.result }}" = "success" ]; then # Production Chromium already passed in this run. Keep mockup # Chromium plus the cross-browser backstop without repeating it. - npm run test:e2e -- --project=chromium-mockups --project=firefox --project=webkit + case "$ENGINE" in + chromium) PROJECTS="--project=chromium-mockups" ;; + firefox) PROJECTS="--project=firefox" ;; + webkit) PROJECTS="--project=webkit" ;; + *) echo "::error::unknown engine '$ENGINE'" >&2; exit 1 ;; + esac else - # Fail-safe path for perf/lockfile-only or otherwise skipped UI proof. - npm run test:e2e + # Fail-safe path for perf/lockfile-only or otherwise skipped UI proof: + # this is where the complete project set has to be accounted for. + case "$ENGINE" in + chromium) PROJECTS="--project=chromium --project=chromium-mockups --project=chromium-caring-contacts-seeded" ;; + firefox) PROJECTS="--project=firefox" ;; + webkit) PROJECTS="--project=webkit --project=mobile-webkit --project=mobile-pwa-standalone" ;; + *) echo "::error::unknown engine '$ENGINE'" >&2; exit 1 ;; + esac fi + echo "Running $ENGINE projects: $PROJECTS" + # Word-splitting is intended: $PROJECTS is a list of --project= flags + # built above, never user input. + # shellcheck disable=SC2086 + npm run test:e2e -- $PROJECTS - name: Upload UI diagnostics if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: release-ui-diagnostics-${{ github.run_id }} + # Per engine: a shared name makes the second failing engine fail on + # upload, the same trap already documented on the Production UI shards. + name: release-ui-diagnostics-${{ github.run_id }}-${{ matrix.engine }} path: | test-results/ playwright-report/ diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index bf47a706d..af85eabf9 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -909,6 +909,11 @@ "file": "src/app/mockups/document-search/page.tsx", "area": "mockup" }, + { + "path": "/mockups/dsm-favourites", + "file": "src/app/mockups/dsm-favourites/page.tsx", + "area": "mockup" + }, { "path": "/mockups/therapy-navigation-rail", "file": "src/app/mockups/therapy-navigation-rail/page.tsx", @@ -6424,6 +6429,14 @@ "outcome": "CHANGES REQUESTED / findings. No P0. Two confirmed P2 UX defects: (1) mobile presentation Compare tab is a self-link that drops `ids`/`q` and resets selection (1→default 4) + clears Query chip; (2) document “Browse by tag” / `searchByTag` navigates to `/?mode=documents&q=…` without `run=1`, so results do not run. P3: comparison disabled dropdown/filter controls lack `aria-describedby` placeholder contract; bare `/documents` 404 (no inbound links; `/documents/search` is canonical); phone mode-switcher truncates “Differenti…”. Deduped: `/tools` vs `/?mode=tools` remains #007; coming-soon density/favourites/forms remain #010. Phone docks flush `bottom:0` on sampled result routes; no document horizontal overflow at 390/768/1280 across 30 routes.", "checks": "`npm run workflow:design-sweep -- --write-evidence`; `npm run ensure` → http://localhost:4461 identity Clinical KB; Playwright HTTP+overflow matrix 30/30 no overflow; live Compare/tag proofs + phone route matrix; `npm run test:e2e:accessibility` 12/12; screenshots under `/opt/cursor/artifacts/screenshots/design-review-2026-07-24/`. No OpenAI/Supabase/GitHub/hosted CI/provider mutations. Added project subagent `.cursor/agents/design-review.md`." }, + { + "date": "2026-09-07", + "ref": "PR-2601", + "head": "01cfdbd9f314291640e5d39bc698a7fcc418b3b4", + "scope": "PR CI and review repair", + "outcome": "Fixed three unresolved governance findings and the deterministic forms sorting CI failure; focused calculator checks, lint and typecheck passed.", + "checks": "vitest calculators-governance-hardening (8); check:calculator-content; lint; typecheck; verify:pr-local offline stages through unit suite start" + }, { "date": "2026-07-14", "ref": "codex/global-answer-reliability", @@ -17440,6 +17453,14 @@ "outcome": "FIXED. Supersedes prior reviews after merging current-main PR #1469. No remaining P0-P2 findings; #107 is archived with executing jsdom state-matrix coverage, and the branch's existing changes remain intact.", "checks": "focused current-main state-matrix suite 2 files / 10 tests PASS; outstanding ledger 146 rows / 49 open / 97 archived PASS; branch-review ledger PASS; prior combined-tree verify:cheap 32 gates PASS" }, + { + "date": "2026-09-07", + "ref": "PR-2693", + "head": "85aad321b9a67eefa4dac2acb22552b8086df9c5", + "scope": "PR CI and review repair", + "outcome": "Verified complete 24-request reconciliation and fixed deterministic forms sorting CI expectation.", + "checks": "check:outstanding-issues; check:ledger-write-discipline; installed-lock parity; merge-tree clean" + }, { "date": "2026-07-28", "ref": "PR #1295 / `fix/audit-remediation-from-main`", @@ -21408,6 +21429,14 @@ "outcome": "Redundant: no patch-unique non-merge commits remain against `origin/main`; eligible for deletion when unreferenced.", "checks": "`git log --right-only --cherry-pick --no-merges origin/main...claude/mobile-search-bar-fix` returned empty." }, + { + "date": "2026-09-05", + "ref": "codex/calculators-governance-hardening (PR #2601)", + "head": "b755976a79bb8e0b203fd0ede882685f6804f1eb", + "scope": "Run PR sweep: CI fix + threads + drift", + "outcome": "before: PR required green but BEHIND main, 1 unresolved P2 thread (governance checker wiring test never exercised failure path). after: merged origin/main (clean, no conflicts), added test that runs the real checker script against corrupted fixture data and asserts nonzero exit + diagnostic, thread replied and resolved.", + "checks": "npx vitest run tests/calculators-governance-hardening.test.ts (6 passed); npm run typecheck (clean, recorded pass); npx prettier --write (unchanged); git merge-tree confirmed clean before merging origin/main. No provider-backed checks run." + }, { "date": "2026-08-30", "ref": "codex/smart-natural-search-current-main", diff --git a/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json b/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json new file mode 100644 index 000000000..98ec40f94 --- /dev/null +++ b/docs/outstanding-issues-inbox/3c0b6354-a44c-4003-aa08-a1277f3f7d22.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "3c0b6354-a44c-4003-aa08-a1277f3f7d22", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "issue", + "summary": "Every main CI run concludes cancelled because release-browser-matrix hits its own 70 minute timeout, so main has had no browser coverage at all", + "detail": "MECHANISM IDENTIFIED 2026-09-07 by job-level timing, and it is NOT the spending or concurrency cap this row originally hypothesised. That hypothesis is withdrawn, and the per-run concurrency group is not implicated. release-browser-matrix carries timeout-minutes: 70 (.github/workflows/ci.yml). Measured on two consecutive main runs: run 34100540973 (f3ea7cb), job 101675121681, started 08:30:56Z and completed 09:41:19Z = 70m23s, with its 'Full browser UI matrix' step ending in conclusion cancelled. Run 34104496596 (c8cc72f), job 101689976803, started 09:22:47Z and completed 10:33:07Z = 70m20s, identical shape. Both land exactly on the configured timeout. GitHub reports a timed-out job as cancelled, and one cancelled job makes the whole run conclusion cancelled. That is the entire explanation for main's run-level redness, and it also explains the earlier temporal pattern (runs stopped concluding cancelled when the merge queue quietened and the matrix presumably completed inside 70 minutes). In both runs every other job succeeded, PR required included: Change scope, Static PR checks, Unit coverage, Build, Safety and config, Caring Contacts database, Production UI (1)(2)(3), Visual baselines, Lighthouse budget. CONSEQUENCE, and it is worse than the original row implied: the matrix now produces NO result rather than a red one. Main's Firefox and WebKit coverage is currently zero, not failing. The sibling row recording 15 Firefox/WebKit failures describes the last state in which the matrix still finished, at 48.3m on edbd29f. It has since crossed 70 minutes, so those 15 failures are no longer even being reported. NEXT ACTION: find why the matrix went from roughly 48 minutes to over 70. The first candidate is the 15 failing tests themselves, because a failing Playwright test spends its full timeout and then pays for trace and video capture, so failures are disproportionately expensive and a growing failure set is self-accelerating. Fix the failures first and re-measure. Do NOT simply raise timeout-minutes: that buys a longer run without restoring a verdict, and it hides the regression that made the suite slower. If the suite is genuinely too long after the failures are fixed, shard it the way Production UI is already sharded into three jobs rather than extending one 70 minute job. ORIGINAL EVIDENCE, retained: of the 8 completed CI runs on main sampled 2026-09-06, 6 concluded cancelled and 2 failed, none succeeded. Volume context: 132 merge commits to main in 24 hours, 19 in one 3-hour window. The 2026-08-20 queue-eviction mechanism was ruled out and stays ruled out: commit 19ee948 carries group: CI-${{ github.run_id }} for push events plus cancel-in-progress: ${{ github.event_name != 'push' }}, so a main push run has a unique group and cannot be superseded.", + "source": "Session 2026-09-07. Original hypothesis from run-level reads on 2026-09-06; corrected the same day by job-level timing on runs 34100540973 and 34104496596, which both show release-browser-matrix ending at its 70 minute timeout. Offline repo read of ci.yml plus GitHub Actions job reads. No provider mutation.", + "issueUlid": "01M1XFW8TMYSKD8JMVH02ZTV0C" + } +} diff --git a/docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json b/docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json new file mode 100644 index 000000000..6a1f5ad25 --- /dev/null +++ b/docs/outstanding-issues-inbox/6b90cb8e-eec5-4bb1-8406-d00e123c8527.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "6b90cb8e-eec5-4bb1-8406-d00e123c8527", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "rec", + "summary": "Do not refresh the Lighthouse performance baseline until main has one genuinely green run", + "detail": "The Lighthouse budget cells on the release browser matrix have drifted from their recorded baseline, and the obvious remedy (re-record the baseline from a recent main run) is unsafe right now. All four cells drifted together, which points at an environment/runner-level shift rather than any one PR, and main is not currently in a known-good state because release-browser-matrix fails on every run with 15 Firefox/WebKit failures. Refreshing the baseline from a red main would stamp unattributable drift as 'the new normal' and permanently lose the ability to attribute it. NEXT ACTION, in this order: (1) fix or triage the release-browser-matrix Firefox/WebKit failures, (2) obtain one main run where the whole matrix is genuinely green, (3) only then re-record the Lighthouse baseline from that SHA and note the SHA in the commit message. STOP RULE: if step 2 cannot be reached, do not proceed to step 3 - raise it with the owner instead. Related but distinct: #QSHHGK covers the bundle-budget baseline refresh, which is a different artefact.", + "source": "Session 2026-09-07 (DSM diagnosis page work). Reversal of my own earlier advice in the same session: I first suggested refreshing the baseline, then withdrew that after establishing main is red. Offline analysis plus GitHub Actions run reads; no baseline was changed.", + "issueUlid": "01M1XFZZ550H0S89B5N5XKHAVT" + } +} diff --git a/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json new file mode 100644 index 000000000..39111df38 --- /dev/null +++ b/docs/outstanding-issues-inbox/9fcfa105-cf74-462b-9a10-3601894600e0.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "id": "9fcfa105-cf74-462b-9a10-3601894600e0", + "createdOn": "2026-09-07", + "action": "add", + "payload": { + "pri": "P2", + "type": "issue", + "summary": "release-browser-matrix fails on every main run with 15 Firefox/WebKit failures, and it is not in PR required so it never blocks a merge", + "detail": "Evidence, main run 34065301954 on edbd29f (#2690): every other job passed - Static PR checks, Unit coverage, Safety and config, Build, Visual baselines, Caring Contacts, Production UI (1)(2)(3), Lighthouse budget, and PR required itself. The sole failure was release-browser-matrix: '15 failed, 80 skipped, 1397 passed (48.3m)'. The identical single-job failure occurred on the preceding main commit cae0655 (#2689), so it is persistent and independent of the change under test. ALL 15 failures are Firefox (5) or WebKit (10); Chromium had zero. They are viewport and scroll-geometry assertions, e.g. tests/ui-tools.spec.ts:3172 'long mobile service details paint to the viewport edge with no bottom search dock' expected footerBottom <= 820 but received 838.546875 on webkit. Others: ui-formulation-result-cards.spec.ts:35 (both engines), ui-smoke.spec.ts:1202/2939/1648/5171/5961, ui-stress.spec.ts:436, ui-tools.spec.ts:2927, ui-phone-scroll-page-owned.spec.ts:98 (x2), ui-phone-scroll.spec.ts:236, ui-route-coverage.spec.ts:554, ui-specifiers.spec.ts:181 (axe violation). WHY IT IS INVISIBLE: release-browser-matrix is not part of the PR required aggregate - PR required concluded success on edbd29f while the run overall went red - so this paints every main run red without blocking any merge, and trains readers to ignore main's status. Distinct from #055, which is a process task to run the release gate before a handoff; this row is that the matrix is currently and persistently red. NEXT ACTION: triage the 15 as one focused piece of work (they cluster into phone-chrome scroll geometry, document viewer composer, service detail footer clearance, and one specifiers axe violation). VISIBILITY REMEDY, corrected 2026-09-07 after review: do NOT simply add release-browser-matrix to the pr-required needs list. ci.yml gates the job on workflow_dispatch, schedule, refs/heads/release/*, or refs/heads/main only, so it never runs on a pull_request event at all, and the comment above it records that it must deliberately not wait on pr-required (#023 - a blocking weekly dependency audit once skipped the matrix entirely). A needs edit alone would aggregate a skipped job and change nothing. Fix the post-merge signal instead so a red matrix on main is visible and owned rather than ambient. Making it genuinely merge-blocking is a separate and much larger change - a pull_request trigger plus an aggregate redesign that runs a roughly 50 minute matrix before every merge - and must be costed and decided on its own rather than assumed here. SUPERSEDED IN PART 2026-09-07: the matrix no longer reports these 15 failures at all. It now exceeds its 70 minute timeout on every main run and is recorded as cancelled, so main's Firefox and WebKit coverage is currently zero rather than red. The 48.3m/15-failure evidence above is the last state in which the job still finished. Fix the timeout cause first (see the sibling row on release-browser-matrix timing out), because the 15 failures cannot be re-measured until the job completes again.", + "source": "session 2026-09-06 diagnosis of main CI; runs 34065301954 and 34064564709", + "issueUlid": "01M1XFVVAZ9Z197J7AAJJEP4C7" + } +} diff --git a/docs/site-map.md b/docs/site-map.md index 8d305892b..34df1cdd7 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1241,6 +1241,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/document-search/source-overlays` - Route discovered from app directory Source: `src/app/mockups/document-search/source-overlays/page.tsx`. - `/mockups/document-search/source/evidence` - Route discovered from app directory Source: `src/app/mockups/document-search/source/evidence/page.tsx`. - `/mockups/document-top-navigation` - Route discovered from app directory Source: `src/app/mockups/document-top-navigation/page.tsx`. +- `/mockups/dsm-favourites` - Route discovered from app directory Source: `src/app/mockups/dsm-favourites/page.tsx`. - `/mockups/factsheets-compact-view` - Route discovered from app directory Source: `src/app/mockups/factsheets-compact-view/page.tsx`. - `/mockups/factsheets-topics-phone` - Route discovered from app directory Source: `src/app/mockups/factsheets-topics-phone/page.tsx`. - `/mockups/favourites-hub` - Route discovered from app directory Source: `src/app/mockups/favourites-hub/page.tsx`. diff --git a/mockups/README.md b/mockups/README.md index 28cd6cb83..be68cf66f 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -49,9 +49,10 @@ picked — kept as-is rather than guessed at. ### Favourites page -| Route | Status | -| ---------------------------- | ---------------------------------------------------- | -| `favourites-phone-perfected` | Chosen design (2026-08-27) — see the write-up above. | +| Route | Status | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `favourites-phone-perfected` | Chosen design (2026-08-27) — see the write-up above. | +| `dsm-favourites` | Active study (2026-09-07) — save a DSM diagnosis to favourites. Open decisions only; nothing wired. Blocks on a check-constraint migration. | Six earlier studies (`favourites-command-console`, `favourites-command-desk`, `favourites-library-view`, `favourites-review-console`, `favourites-set-board`, diff --git a/package.json b/package.json index d4bdafffe..7a4ea06bf 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "test:coverage": "node scripts/run-vitest.mjs run --coverage", "test:coverage:node": "node scripts/run-vitest.mjs run --project=node --coverage", "test:coverage:ui": "node scripts/run-vitest.mjs run --project=jsdom --coverage", - "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/ci-audit-contracts.test.ts tests/branch-review-index.test.ts tests/authenticated-live-workflow.test.ts tests/browser-test-plan.test.ts tests/chain-mirror-parity.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/live-domain-monitor-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/reindex-reaper-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts tests/bundle-budget-refresh-workflow.test.ts", + "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/ci-audit-contracts.test.ts tests/ci-browser-matrix-coverage.test.ts tests/branch-review-index.test.ts tests/authenticated-live-workflow.test.ts tests/browser-test-plan.test.ts tests/chain-mirror-parity.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/live-domain-monitor-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/reindex-reaper-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts tests/bundle-budget-refresh-workflow.test.ts", "test:cc-guards": "node scripts/run-vitest.mjs run --reporter=dot tests/caring-contacts-plan-draft.dom.test.tsx tests/caring-contacts-plan-patient-detail.test.ts tests/caring-contacts-plan-activation.test.ts tests/caring-contacts-plan-wizard.dom.test.tsx tests/caring-contacts-schedule.test.ts tests/caring-contacts-schedule-view.test.ts tests/caring-contacts-schedule-route.test.ts tests/caring-contacts-schedule-screen.dom.test.tsx tests/caring-contacts-schedule-page.dom.test.tsx tests/caring-contacts-clock.test.ts tests/caring-contacts-new-plan-page.dom.test.tsx tests/caring-contacts-explained-automation.dom.test.tsx tests/caring-contacts-workspace-shell.dom.test.tsx tests/caring-contacts-patients-directory.dom.test.tsx tests/caring-contacts-patient-overview.dom.test.tsx tests/caring-contacts-patients-page.dom.test.tsx tests/caring-contacts-domain-isolation.test.ts tests/caring-contacts-interface-vocabulary.test.ts tests/caring-contacts-retention.test.ts tests/caring-contacts-repository.test.ts tests/caring-contacts-overlay-definitions.test.ts tests/caring-contacts-overlay-trigger-inventory.test.ts tests/caring-contacts-workspace-screens.test.ts tests/route-reachability.test.ts tests/design-system-adoption.test.ts tests/caring-contacts-contact-time-adjustment.dom.test.tsx tests/caring-contacts-contact-route.test.ts tests/caring-contacts-overlay-trigger.dom.test.tsx tests/caring-contacts-overlay-host.dom.test.tsx tests/source-control-bytes.test.ts tests/caring-contacts-demo-seed.test.ts tests/caring-contacts-pathway-versions.test.ts tests/caring-contacts-templates-library.dom.test.tsx tests/caring-contacts-templates-page.dom.test.tsx tests/caring-contacts-template-detail.dom.test.tsx tests/caring-contacts-template-detail-page.dom.test.tsx tests/caring-contacts-reporting.test.ts tests/caring-contacts-guidance-reports-pages.dom.test.tsx tests/caring-contacts-team-workload.test.ts tests/caring-contacts-team-route.test.ts tests/caring-contacts-team-roster.dom.test.tsx tests/caring-contacts-team-page.dom.test.tsx", "test:e2e": "node scripts/run-playwright.mjs", "test:e2e:all": "node scripts/run-playwright.mjs", diff --git a/src/app/mockups/dsm-favourites/page.tsx b/src/app/mockups/dsm-favourites/page.tsx new file mode 100644 index 000000000..f28a409b5 --- /dev/null +++ b/src/app/mockups/dsm-favourites/page.tsx @@ -0,0 +1,5 @@ +import { DsmFavouritesMockupsPage } from "@/components/dsm-favourites-mockups/dsm-favourites-mockups-page"; + +export default function DsmFavouritesMockupRoute() { + return ; +} diff --git a/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx b/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx new file mode 100644 index 000000000..a63bde417 --- /dev/null +++ b/src/components/dsm-favourites-mockups/dsm-favourites-mockups-page.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { BookOpenCheck, Check, ChevronRight, Pin, Star, Tag } from "lucide-react"; +import { useState } from "react"; + +/** + * Design scratch for "save a DSM diagnosis to favourites". + * + * This is the ONE page-level capability the diagnosis-page review found that + * cannot be built without a schema change: `favouriteContentTypeSchema` and the + * `user_favourites_content_type_check` constraint both allow only + * service | form | differential | therapy. Adding `dsm` reaches the live clinical + * database on merge, so the shape wants deciding on a page before it is decided + * in a migration. + * + * There is a precedent to copy exactly: `20260814150000_add_therapy_favourites.sql` + * dropped and re-added the check constraint with `therapy`, and + * `20260814151000_validate_therapy_favourites_content_type.sql` validated it as a + * separate migration. Same two-step here. + * + * NOTHING BELOW IS WIRED. Every control is local state over fixed data, so the + * page can be read without a database, an account, or a decision having been + * made. The open questions are stated on the page itself rather than in a doc + * nobody opens next to the pictures. + */ + +type SaveState = "idle" | "saved"; + +const DIAGNOSIS = { + title: "Panic disorder", + icd: "F41.0", + category: "Anxiety disorders", + criteria: "4 criteria, A-D", +}; + +const SET_OPTIONS = ["Unsorted", "Clinical review", "Ward round", "On call", "Teaching", "Reference"] as const; + +/** What a saved DSM favourite would look like beside the types that already exist. */ +const LIBRARY_ROWS: ReadonlyArray<{ + type: string; + title: string; + meta: string; + set: string; + isNew?: boolean; +}> = [ + { + type: "DSM diagnosis", + title: "Panic disorder", + meta: "F41.0 · Anxiety disorders · 4 criteria", + set: "Clinical review", + isNew: true, + }, + { type: "Therapy", title: "CBT for panic", meta: "Structured protocol · 12 sessions", set: "Clinical review" }, + { type: "Differential", title: "Acute chest pain", meta: "Presentation · 9 candidates", set: "Ward round" }, + { type: "Form", title: "Form 1A — Referral", meta: "Mental Health Act 2014", set: "On call" }, +] as const; + +function Panel({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { + return ( +
+

+ + {n} + + {title} +

+ {children} +
+ ); +} + +function Question({ children }: { children: React.ReactNode }) { + return ( +

+ Decision: + {children} +

+ ); +} + +export function DsmFavouritesMockupsPage() { + const [headerSave, setHeaderSave] = useState("idle"); + const [chosenSet, setChosenSet] = useState<(typeof SET_OPTIONS)[number]>("Unsorted"); + const [pinned, setPinned] = useState(false); + + return ( +
+
+

Mockup · not wired

+

+ Save a DSM diagnosis to favourites +

+

+ Favourites currently accept services, forms, differentials and therapies. A diagnosis cannot be saved, because + the content type is constrained in two places at once: a Zod enum and a Postgres check constraint. Adding one + reaches the live clinical database the moment the migration merges, so the three decisions below are worth + settling here first. +

+
+ + +
+
+ + {DIAGNOSIS.title} + + {DIAGNOSIS.icd} + + + {DIAGNOSIS.category} + + + +
+ + {headerSave === "saved" ? ( +
+ + Add to set +
+ {SET_OPTIONS.map((option) => ( + + ))} +
+ +
+ ) : null} +
+ + + Save sits in the page header here, beside the code. The alternative is the ellipsis menu, where Compare used + to live. Header costs a permanent control on every diagnosis page; the menu costs a tap and is easy to miss. + My recommendation is the header, because saving is a one-tap action you do while reading, and the menu is + where Compare was when the review found nobody used it. + +
+ + +
    + {LIBRARY_ROWS.map((row) => ( +
  • + + {row.type} + + + {row.title} + {row.meta} + + {row.set} + +
  • + ))} +
+ + + The metadata line reads code · category · criteria count. Every other favourite type shows something + source-backed there. A diagnosis has no source to cite, so this is the closest equivalent — it is what tells + two saved anxiety disorders apart in a list. Confirm that is the right three facts, or name different ones. + +
+ + +
    +
  1. + 1. Two-step migration. Drop and + re-add + + user_favourites_content_type_check + + with dsm{" "} + added, then a separate validate migration. This is exactly the therapy precedent from 2026-08-14, which is + the reason this is a known-safe shape rather than a new one. +
  2. +
  3. + 2. Widen the enum in the favourites + contract, and add a diagnosis branch to the reference check so a saved favourite must point at a real + record. +
  4. +
  5. + 3. Wire the control and give the + library its label and icon. +
  6. +
+ +

+ Irreversible step: + merging the migration applies it to the live clinical database within seconds, with no deploy step in between. + It is additive and widens what is allowed rather than narrowing it, so nothing existing breaks — but it merges + inside an approved window, never on auto-merge. +

+
+ +
+ Static mockup. No account, no database, no network. The controls above change local state only, so nothing here + can save anything. +
+
+ ); +} diff --git a/src/components/dsm/dsm-code-copy.tsx b/src/components/dsm/dsm-code-copy.tsx new file mode 100644 index 000000000..315bc0bea --- /dev/null +++ b/src/components/dsm/dsm-code-copy.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useRef, useState } from "react"; + +import { cn, codeText, metadataPill } from "@/components/ui-primitives"; +import { copyTextToClipboard } from "@/lib/copy-to-clipboard"; + +/** + * The ICD-10 code chip, made copyable. + * + * The code is the one string on this page that gets retyped into a coding field + * or a discharge summary, and it was rendered as plain text, so it had to be + * selected by hand from a chip sized for reading rather than selecting. This + * keeps the chip's appearance and adds the one action it was missing. + * + * Deliberately a button rather than a click handler on the chip: it is an + * action, so it needs a role, a focusable target, and an accessible name that + * says what it does rather than just reading the code aloud. + */ + +const COPY_RESET_MS = 2000; + +export function DsmCodeCopy({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + const timer = useRef(null); + + async function copy() { + try { + await copyTextToClipboard(code); + setCopied(true); + } catch { + // A failed copy must not claim success. The code stays visible and + // selectable, which is exactly the fallback that existed before. + setCopied(false); + return; + } + if (timer.current) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), COPY_RESET_MS); + } + + return ( + + ); +} diff --git a/src/components/dsm/dsm-diagnosis-page.tsx b/src/components/dsm/dsm-diagnosis-page.tsx index b114ac718..dcee4d641 100644 --- a/src/components/dsm/dsm-diagnosis-page.tsx +++ b/src/components/dsm/dsm-diagnosis-page.tsx @@ -22,6 +22,7 @@ import { inPageActionRowClass, inPageAnchor } from "@/components/in-page-nav/in- import { cn, codeText, metadataPill, pageContainer } from "@/components/ui-primitives"; import { dsmCriteria, + dsmDifferentialParts, dsmSpecifierSplit, resolveDsmDifferential, type DsmDiagnosis, @@ -133,6 +134,7 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) { title={diagnosis.title} description="Core diagnostic criteria, specifiers, differential considerations, and documentation support in one open, scan-friendly view." code={diagnosis.icd_code} + copyCode category={diagnosis.category.label} breadcrumb={false} /> @@ -306,24 +308,63 @@ export function DsmDiagnosisPage({ diagnosis }: { diagnosis: DsmDiagnosis }) {
    {sidebarDifferentials.map((differential) => { const match = resolveDsmDifferential(differential); + const { name, discriminator } = dsmDifferentialParts(differential); + + /* + * Every row is actionable, which it was not before. Rows that + * resolve to a record open it; the rest — "Medical cause + * (cardiac, respiratory, endocrine)" and its kind, which name + * a category rather than a DSM record — search for the name. + * Previously those rendered as inert text beside linked + * siblings with nothing to explain the difference, so the + * list looked half-broken rather than deliberate. + */ + const href = match + ? `/dsm/diagnoses/${match.slug}` + : `/dsm/search?q=${encodeURIComponent(name)}&run=1`; + return (
  • - {match ? ( +
    - {differential} - + + {name} + {match ? ( + + ) : ( + + )} + + {discriminator ? ( + + {discriminator} + + ) : null} - ) : ( - - {differential} - - )} + {match ? ( + + + + ) : null} +
  • ); })} diff --git a/src/components/dsm/dsm-page-header.tsx b/src/components/dsm/dsm-page-header.tsx index 3b606cb0b..bf0f34ec9 100644 --- a/src/components/dsm/dsm-page-header.tsx +++ b/src/components/dsm/dsm-page-header.tsx @@ -1,6 +1,7 @@ import { BookOpenCheck, type LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; +import { DsmCodeCopy } from "@/components/dsm/dsm-code-copy"; import { InformationPageBreadcrumbs } from "@/components/information-page-shell"; import { PageHeader } from "@/components/ui/page-header"; import { cn, codeText, metadataPill, pageContainer } from "@/components/ui-primitives"; @@ -11,6 +12,7 @@ export function DsmPageHeader({ title, description, code, + copyCode = false, category, actions, className, @@ -22,6 +24,11 @@ export function DsmPageHeader({ title: string; description?: string; code?: string; + /** + * Render the code as a copy control. Only true where the code belongs to one + * diagnosis; a page showing a summary across records must leave it as text. + */ + copyCode?: boolean; category?: string; actions?: ReactNode; className?: string; @@ -57,7 +64,19 @@ export function DsmPageHeader({ meta={ code || category ? ( <> - {code ? {code} : null} + {/* + Copyable only where a single record owns the code. The + comparison and differential-considerations pages pass a + summary string rather than one diagnosis's code, and copying + that would put the wrong thing on the clipboard. + */} + {code ? ( + copyCode ? ( + + ) : ( + {code} + ) + ) : null} {category ? {category} : null} Local clinical reference diff --git a/src/lib/dsm.ts b/src/lib/dsm.ts index 4cd3b2f5d..4ce95ea46 100644 --- a/src/lib/dsm.ts +++ b/src/lib/dsm.ts @@ -361,6 +361,54 @@ export function listDsmDiagnosisSummaries(options: { query?: string; category?: .map(dsmDiagnosisSummary); } +export type DsmDifferentialParts = { + /** The diagnosis name, with any trailing parenthetical removed. */ + name: string; + /** The authored discriminator from that parenthetical, or "" when there is none. */ + discriminator: string; +}; + +/** + * Split a differential entry into its name and the discriminator the record + * already carries for it. + * + * 534 of the 688 differential rows the sidebar shows (78%) end in a parenthetical + * that is the clinical reason the differential is being raised — "Social anxiety + * disorder (expected attacks in social situations)", "Bipolar I disorder (full + * manic episode present - reclassify)". Rendering the whole string on one line + * buried that behind the name, so the sidebar read as a list of labels rather + * than something that helps separate two candidates. + * + * DELIBERATELY NOT sourced from `cross-mode-differentials-index.json`, which was + * the obvious candidate and is wrong for this. Its `clinicalHinge` is per + * PRESENTATION GROUP, not per differential: 201 entries share just 31 distinct + * hinge strings, so `social-anxiety-disorder` carries "Abrupt peak over minutes, + * recurrent unexpected attacks, anticipatory anxiety or avoidance" — which + * describes panic disorder, the presentation, not social anxiety. Rendering that + * under a differential's name would state something clinically false about that + * diagnosis. The parenthetical here is authored on the record itself, against + * that exact differential, so it cannot be mismatched. + * + * Only a trailing parenthetical counts. An inline one is part of the name + * ("Premenstrual dysphoric disorder (PMDD)" is a name, not a discriminator) — + * those resolve to a diagnosis and are left whole by the guard below. + */ +export function dsmDifferentialParts(value: string): DsmDifferentialParts { + const trimmed = value.trim(); + const match = /^(.*?)\s*\(([^()]*)\)$/.exec(trimmed); + if (!match) return { name: trimmed, discriminator: "" }; + + const [, name, inside] = match; + const discriminator = inside.trim(); + + // An abbreviation or alternate label is part of the name, not a reason. Both + // are short and word-like; a discriminator is a clause. + const looksLikeLabel = !/\s/.test(discriminator) || /^[A-Z0-9\-/]+$/.test(discriminator); + if (!name || looksLikeLabel) return { name: trimmed, discriminator: "" }; + + return { name, discriminator }; +} + export function resolveDsmDifferential(value: string) { const title = value.replace(/\s*\([^)]*\)\s*$/, "").trim(); const normalized = normalizeSearchText(title); diff --git a/tests/ci-browser-matrix-coverage.test.ts b/tests/ci-browser-matrix-coverage.test.ts new file mode 100644 index 000000000..7f9251055 --- /dev/null +++ b/tests/ci-browser-matrix-coverage.test.ts @@ -0,0 +1,117 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +import { sourceFrom } from "./helpers/source-contract"; + +/* + * `release-browser-matrix` runs one job per browser engine. + * + * Why that shape needs a guard of its own: before 2026-09-07 the job ran + * chromium-mockups + firefox + webkit sequentially in ONE job under + * `workers: 1` / `fullyParallel: false`, and it stopped finishing — measured + * 70m23s (run 34100540973) and 70m20s (run 34104496596), both landing exactly on + * the then-70-minute cap. GitHub reports a timed-out job as `cancelled`, so + * every main run concluded cancelled while every other job passed, and the + * matrix produced NO verdict at all. `main` had zero Firefox/WebKit coverage + * rather than failing coverage, which is the strictly worse state and the one + * nobody notices. + * + * Splitting by engine fixes the wall-clock. It also introduces the one hazard + * that a single job did not have: the fail-safe path used to run bare + * `npm run test:e2e`, which means EVERY project in playwright.config.ts. Spread + * across engines, that set is now written out by hand, so a project added to the + * config later would simply never run — and nothing would go red to say so. + * + * These cases exist to make that impossible. They re-derive the engine groups + * from ci.yml and the project list from playwright.config.ts, and fail closed on + * any divergence. Do not relax them to accommodate a new project; add the + * project to the right engine group instead. + */ + +const workflow = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"); +const playwrightConfig = readFileSync(new URL("../playwright.config.ts", import.meta.url), "utf8"); + +const releaseJob = sourceFrom(workflow, " release-browser-matrix:", { + label: "release-browser-matrix job definition", +}); + +/** Every project name declared in playwright.config.ts. */ +function configuredProjects(): string[] { + const names = [...playwrightConfig.matchAll(/^\s*name:\s*"([^"]+)"/gm)].map((match) => match[1]); + expect(names.length, "playwright.config.ts declared no projects — the regex above has drifted").toBeGreaterThan(0); + return names; +} + +/** + * The `--project=` flags each engine runs, split by the two branches of the + * step's `if`. The first `case` block is the primary path (production Chromium + * already proven in this run); the second is the fail-safe path that must cover + * everything. + */ +function engineProjects(branch: "primary" | "failsafe"): Map { + const caseBlocks = [...releaseJob.matchAll(/case "\$ENGINE" in\n([\s\S]*?)\n\s*esac/g)].map((match) => match[1]); + expect(caseBlocks.length, 'expected exactly two `case "$ENGINE"` blocks in the matrix step').toBe(2); + + const block = caseBlocks[branch === "primary" ? 0 : 1]; + const byEngine = new Map(); + for (const line of block.split("\n")) { + const engine = /^\s*([a-z]+)\)\s+PROJECTS=/.exec(line); + if (!engine) continue; + byEngine.set( + engine[1], + [...line.matchAll(/--project=([\w-]+)/g)].map((match) => match[1]), + ); + } + return byEngine; +} + +describe("release-browser-matrix engine coverage", () => { + it("declares one job per engine and does not buy time with a longer timeout", () => { + expect(releaseJob).toContain("engine: [chromium, firefox, webkit]"); + expect(releaseJob).toContain("fail-fast: false"); + + // A single-worker suite is not made faster by a longer cap. Raising this + // back above the old 70 restores the exact defect: a job that runs for over + // an hour and then reports nothing. + const timeout = /timeout-minutes:\s*(\d+)/.exec(releaseJob); + expect(timeout, "release-browser-matrix must declare a timeout").not.toBeNull(); + expect(Number(timeout?.[1])).toBeLessThanOrEqual(45); + }); + + it("runs every configured Playwright project on the fail-safe path", () => { + const byEngine = engineProjects("failsafe"); + expect([...byEngine.keys()].sort()).toEqual(["chromium", "firefox", "webkit"]); + + const covered = [...byEngine.values()].flat(); + expect( + [...covered].sort(), + "a playwright.config.ts project is missing from the fail-safe engine groups, so it would never run in CI", + ).toEqual([...configuredProjects()].sort()); + }); + + it("never runs the same project on two engines", () => { + for (const branch of ["primary", "failsafe"] as const) { + const covered = [...engineProjects(branch).values()].flat(); + expect(new Set(covered).size, `${branch} path runs a project on more than one engine`).toBe(covered.length); + } + }); + + it("keeps the primary path skipping production Chromium already proven in the run", () => { + const byEngine = engineProjects("primary"); + expect([...byEngine.values()].flat().sort()).toEqual(["chromium-mockups", "firefox", "webkit"]); + + // `chromium` (the production project) is the one the ui-critical job already + // proved in this run. Re-running it here is the waste the primary path exists + // to avoid — but only Chromium, never the cross-engine backstop. + expect([...byEngine.values()].flat()).not.toContain("chromium"); + expect([...byEngine.values()].flat()).toContain("firefox"); + expect([...byEngine.values()].flat()).toContain("webkit"); + }); + + it("uploads diagnostics under a per-engine artifact name", () => { + // A shared name makes the second failing engine fail on upload, so the + // engine that failed second loses its trace exactly when it is needed. + expect(releaseJob).toContain("release-ui-diagnostics-${{ github.run_id }}-${{ matrix.engine }}"); + }); +}); diff --git a/tests/ci-cache-safety.test.ts b/tests/ci-cache-safety.test.ts index a5ae078bc..2d8b28c90 100644 --- a/tests/ci-cache-safety.test.ts +++ b/tests/ci-cache-safety.test.ts @@ -284,8 +284,21 @@ describe("CI cache safety", () => { }); expect(releaseJob).not.toContain("path: .next/cache"); expect(releaseJob).not.toContain("run: npm run build"); - expect(releaseJob).toContain("npm run test:e2e -- --project=chromium-mockups --project=firefox --project=webkit"); expect(releaseJob).toContain("npm run test:e2e"); + + // Until 2026-09-07 this pinned the single-job command + // `npm run test:e2e -- --project=chromium-mockups --project=firefox --project=webkit`. + // That job stopped finishing — 70m23s and 70m20s on two consecutive main + // runs, both exactly on the old 70-minute cap — so the engines now run as + // sibling matrix jobs and the flags are assembled per engine in the step. + // The property this case still owns is the one it always owned: the primary + // path does not re-run production Chromium that ui-critical already proved. + // Full engine/project coverage is proven in + // tests/ci-browser-matrix-coverage.test.ts, which fails closed when a + // playwright.config.ts project is not assigned to an engine. + expect(releaseJob).toContain('chromium) PROJECTS="--project=chromium-mockups"'); + expect(releaseJob).toContain('firefox) PROJECTS="--project=firefox"'); + expect(releaseJob).toContain('webkit) PROJECTS="--project=webkit"'); }); it("scopes the main-branch release backstop to UI, performance, or lockfile risk", () => { diff --git a/tests/dsm-differential-parts.test.ts b/tests/dsm-differential-parts.test.ts new file mode 100644 index 000000000..05592c9a5 --- /dev/null +++ b/tests/dsm-differential-parts.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { dsmDiagnoses, dsmDifferentialParts, resolveDsmDifferential } from "@/lib/dsm"; + +/* + * The discriminator shown under each differential is pasted-adjacent clinical + * text: a reader separating two candidate diagnoses acts on it. Wrong text here + * is worse than no text, so these cases pin the split against the real corpus + * rather than against invented strings. + */ + +describe("dsmDifferentialParts", () => { + it("splits the authored reason out of a trailing parenthetical", () => { + expect(dsmDifferentialParts("Social anxiety disorder (expected attacks in social situations)")).toEqual({ + name: "Social anxiety disorder", + discriminator: "expected attacks in social situations", + }); + expect(dsmDifferentialParts("Bipolar I disorder (full manic episode present - reclassify)")).toEqual({ + name: "Bipolar I disorder", + discriminator: "full manic episode present - reclassify", + }); + }); + + it("leaves a bare differential whole", () => { + expect(dsmDifferentialParts("Adjustment disorder")).toEqual({ + name: "Adjustment disorder", + discriminator: "", + }); + }); + + it("treats an abbreviation as part of the name, not a discriminator", () => { + // "Premenstrual dysphoric disorder (PMDD)" is a name. Splitting it would + // render "PMDD" as though it were the reason the differential is raised, and + // would also break the abbreviation lookup in resolveDsmDifferential. + expect(dsmDifferentialParts("Premenstrual dysphoric disorder (PMDD)")).toEqual({ + name: "Premenstrual dysphoric disorder (PMDD)", + discriminator: "", + }); + expect(dsmDifferentialParts("Attention-deficit/hyperactivity disorder (ADHD)").discriminator).toBe(""); + }); + + it("never invents, reorders, or drops words from the source string", () => { + // Every differential in the corpus must be reconstructible from its parts, + // which is what rules out a split that quietly rewrites clinical text. + for (const diagnosis of dsmDiagnoses) { + for (const differential of diagnosis.differentials) { + const { name, discriminator } = dsmDifferentialParts(differential); + const rebuilt = discriminator ? `${name} (${discriminator})` : name; + expect(rebuilt, `${diagnosis.title} -> ${differential}`).toBe(differential.trim()); + } + } + }); + + it("keeps the name resolvable to a diagnosis wherever the whole string was", () => { + // The sidebar links the NAME after this change. If splitting cost a link the + // whole string used to earn, the change would have removed navigation. + for (const diagnosis of dsmDiagnoses) { + for (const differential of diagnosis.differentials) { + if (!resolveDsmDifferential(differential)) continue; + expect( + resolveDsmDifferential(dsmDifferentialParts(differential).name), + `${diagnosis.title} -> ${differential} resolved whole but not by name`, + ).toBeDefined(); + } + } + }); + + it("covers a majority of the corpus, so the sidebar is not mostly empty subtitles", () => { + const rows = dsmDiagnoses.flatMap((diagnosis) => diagnosis.differentials.slice(0, 6)); + const withDiscriminator = rows.filter((row) => dsmDifferentialParts(row).discriminator).length; + // Measured 534/688 = 77.6% on 2026-09-07. A floor, not a target: a drop + // below this means the corpus changed shape and the feature stopped paying. + expect(withDiscriminator / rows.length).toBeGreaterThan(0.7); + }); +}); diff --git a/tests/dsm-differential-rows.dom.test.tsx b/tests/dsm-differential-rows.dom.test.tsx new file mode 100644 index 000000000..94de8cc2a --- /dev/null +++ b/tests/dsm-differential-rows.dom.test.tsx @@ -0,0 +1,91 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DsmDiagnosisPage } from "@/components/dsm/dsm-diagnosis-page"; +import { dsmDiagnoses, dsmDifferentialParts, resolveDsmDifferential } from "@/lib/dsm"; + +// This page pulls in the cross-mode links section, which reads auth session +// state it has no provider for under jsdom. Same stub the shared nav contract +// test uses. +vi.mock("@/components/clinical-dashboard/cross-mode-links", () => ({ + CrossModeLinksSection: () => null, +})); + +// The nav header's back control calls useRouter. Same stub as +// tests/dsm-comparison-page.dom.test.tsx. +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), + usePathname: () => "/dsm/diagnoses/panic-disorder", + useSearchParams: () => new URLSearchParams(), +})); + +afterEach(cleanup); + +const panic = dsmDiagnoses.find((diagnosis) => diagnosis.slug === "panic-disorder")!; + +function sidebar() { + return within(screen.getByLabelText("Diagnosis reference summary")); +} + +describe("DSM differential rows", () => { + it("shows the authored discriminator as its own line under the name", () => { + render(); + const list = sidebar(); + + // Before this the whole string rendered on one line, so the reason a + // differential was raised sat behind the name it belonged to. + expect(list.getByText("Social anxiety disorder")).toBeTruthy(); + expect(list.getByText("expected attacks in social situations")).toBeTruthy(); + expect(list.queryByText("Social anxiety disorder (expected attacks in social situations)")).toBeNull(); + }); + + it("gives every row somewhere to go, including rows with no DSM record", () => { + render(); + const list = sidebar(); + + // "Medical cause (cardiac, respiratory, endocrine)" names a category, not a + // record, so it resolved to nothing and used to render as inert text beside + // linked siblings with nothing to explain the difference. + const unresolved = panic.differentials.slice(0, 6).find((row) => !resolveDsmDifferential(row)); + expect(unresolved, "fixture no longer contains an unresolved differential").toBeDefined(); + + const { name } = dsmDifferentialParts(unresolved!); + const link = list.getByRole("link", { name: new RegExp(name, "i") }); + expect(link.getAttribute("href")).toBe(`/dsm/search?q=${encodeURIComponent(name)}&run=1`); + }); + + it("offers compare against the diagnosis being read, on each row that resolves", () => { + render(); + const list = sidebar(); + + const resolved = panic.differentials.slice(0, 6).find((row) => resolveDsmDifferential(row))!; + const match = resolveDsmDifferential(resolved)!; + const { name } = dsmDifferentialParts(resolved); + + const compare = list.getByRole("link", { name: `Compare ${panic.title} with ${name}` }); + // Both ids, in the order the reader is holding them: the page they are on, + // then the candidate. A compare link carrying only one id would drop the + // comparison the row exists to offer. + expect(compare.getAttribute("href")).toBe( + `/dsm/compare?ids=${encodeURIComponent(panic.slug)},${encodeURIComponent(match.slug)}`, + ); + }); + + it("offers no compare on a row with nothing to compare against", () => { + render(); + const list = sidebar(); + + const unresolved = panic.differentials.slice(0, 6).find((row) => !resolveDsmDifferential(row))!; + const { name } = dsmDifferentialParts(unresolved); + expect(list.queryByRole("link", { name: `Compare ${panic.title} with ${name}` })).toBeNull(); + }); + + it("renders the ICD-10 code as a copy control", () => { + render(); + const button = screen.getByTestId("dsm-icd-copy"); + expect(button.tagName).toBe("BUTTON"); + expect(button.getAttribute("aria-label")).toBe(`Copy ICD-10 code ${panic.icd_code}`); + // A control, not a label: it has to be pressable on a phone. + expect(button.className).toContain("min-h-tap"); + }); +});