diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 1cd386d203..63e3debb58 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -2901,6 +2901,11 @@ "section": "organisation", "catalogued": true }, + { + "path": "docs/decisions/2026-10-27-signed-out-examples-on-every-work-page.md", + "section": "decisions", + "catalogued": true + }, { "path": "docs/decisions/2026-10-19-rag-ts-split-deferred-until-after-launch.md", "section": "decisions", diff --git a/docs/decisions/2026-10-27-signed-out-examples-on-every-work-page.md b/docs/decisions/2026-10-27-signed-out-examples-on-every-work-page.md new file mode 100644 index 0000000000..c532af73b7 --- /dev/null +++ b/docs/decisions/2026-10-27-signed-out-examples-on-every-work-page.md @@ -0,0 +1,32 @@ +# Decision: example content on every Work mode page when signed out + +- **Status:** decided 2026-10-27 +- **Source:** the owner, in chat + +On 27 Oct 2026 the owner asked: "Please ensure there is sample content on all modes for me in +logged out view", "And all pages in work mode", "To demonstrate what it looks like". + +Every Work mode page was opened signed out. Most already showed example records. These pages +showed only a sign-in card, and now show clearly marked example content while example data is +on (the default for a signed-out visitor; turning example data off still gives the plain sign-in +state): + +- CPD: AMA CPD Home, Evidence, Export, Customise and Learning. The old CPD Job applications links + follow on to Admin. +- Admin: Job applications and its CV, the Credential pack (built from the example records with + made-up all-zero numbers, never the numbers saved on the device), and Contract end (an example + contract ending five months out). The example contract is only in the signed-out example set, + never in "Load example content", so a real account never gets an end date it did not enter. +- My Day: Alerts settings, shown as a look-only example while signed out. Signed in, they are + always the reader's own. +- On Call: Who's on from your team roster. +- Roster: Manage team. +- Open shifts: the week board, Post, and Post a shift. + +Left as they were, because they are forms or flows rather than records: Open shifts Alerts (the +same switches as Roster settings, which have nothing to show without phone alerts set up), Log a +shift, My Day Set up and Favourites, the On Call device tools, and Teaching check-in. + +Nothing here can save: example screens refuse writes, as every example screen already did. + +If this summary and the source ever differ, the source wins. diff --git a/src/app/(search-app)/admin/applications/cv/page.tsx b/src/app/(search-app)/admin/applications/cv/page.tsx index 094db88e03..62ea49255e 100644 --- a/src/app/(search-app)/admin/applications/cv/page.tsx +++ b/src/app/(search-app)/admin/applications/cv/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { ApplicationsCvPage } from "@/components/cme/applications/applications-cv-page"; import { CmeStateNotice } from "@/components/cme/cme-state-notice"; +import { SignedOutExampleSwitch } from "@/components/mode-kit/signed-out-example-switch"; +import { DEMO_CME_ENTRIES, DEMO_CME_INSTANT } from "@/lib/cme/demo-year"; import { loadCmePageData } from "@/lib/cme/load-cme-page-data"; export const metadata: Metadata = { @@ -14,9 +16,15 @@ export default async function CmeApplicationsCvRoute() { const data = await loadCmePageData(undefined, { allYears: true }); if (data.state === "signed-out") { return ( -
- -
+ } + notice={ +
+ +
+ } + /> ); } // A year with no CPD set-up still has other years' activities, read through allYears. diff --git a/src/app/(search-app)/admin/applications/page.tsx b/src/app/(search-app)/admin/applications/page.tsx index 0b613b5026..dff16f7957 100644 --- a/src/app/(search-app)/admin/applications/page.tsx +++ b/src/app/(search-app)/admin/applications/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { ApplicationsPage } from "@/components/cme/applications/applications-page"; import { CmeStateNotice } from "@/components/cme/cme-state-notice"; +import { SignedOutExampleSwitch } from "@/components/mode-kit/signed-out-example-switch"; import { cpdYearOf } from "@/lib/cme/cpd-year"; import { DEMO_CME_INSTANT } from "@/lib/cme/demo-year"; import { isDemoMode } from "@/lib/env"; @@ -32,9 +33,15 @@ export default async function CmeApplicationsRoute() { const now = demoMode ? DEMO_CME_INSTANT : new Date(); if (!demoMode && (await signedOut())) { return ( -
- -
+ } + notice={ +
+ +
+ } + /> ); } return ; diff --git a/src/app/(search-app)/admin/contract/page.tsx b/src/app/(search-app)/admin/contract/page.tsx index 30ce10efca..dbcf4a6a9d 100644 --- a/src/app/(search-app)/admin/contract/page.tsx +++ b/src/app/(search-app)/admin/contract/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react"; import { ContractEndPage } from "@/components/admin/contract/contract-end-page"; import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; +import { RosterSampleGate } from "@/components/roster/roster-sample-gate"; export const metadata: Metadata = { title: "Contract end | Admin | PsychSift", @@ -15,7 +16,10 @@ export default function AdminContractRoute() { // Suspense boundary in the App Router. return ( }> - + {/* The leave-before-the-end list reads Roster, so signed out it reads Roster's example leave. */} + + + ); } diff --git a/src/app/(search-app)/on-call/whos-on/roster/page.tsx b/src/app/(search-app)/on-call/whos-on/roster/page.tsx index 1e7745cb48..b6277c651f 100644 --- a/src/app/(search-app)/on-call/whos-on/roster/page.tsx +++ b/src/app/(search-app)/on-call/whos-on/roster/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { OnCallRosterWhosOnPage } from "@/components/on-call/roster-whos-on/on-call-roster-whos-on-page"; +import { RosterSampleGate } from "@/components/roster/roster-sample-gate"; export const metadata: Metadata = { title: "From your team roster | On Call | PsychSift", @@ -9,5 +10,10 @@ export const metadata: Metadata = { }; export default function OnCallRosterWhosOnRoute() { - return ; + // Who is on reads Roster, so signed out it shows Roster's example team. + return ( + + + + ); } diff --git a/src/app/(search-app)/open-shifts/board/page.tsx b/src/app/(search-app)/open-shifts/board/page.tsx index bfc893eaad..677aacede2 100644 --- a/src/app/(search-app)/open-shifts/board/page.tsx +++ b/src/app/(search-app)/open-shifts/board/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { OpenShiftsBoardPage } from "@/components/open-shifts/open-shifts-board-page"; +import { RosterSampleGate } from "@/components/roster/roster-sample-gate"; export const metadata: Metadata = { title: "Board | Open shifts | PsychSift", @@ -8,5 +9,9 @@ export const metadata: Metadata = { }; export default function OpenShiftsBoardRoute() { - return ; + return ( + + + + ); } diff --git a/src/app/(search-app)/open-shifts/post/new/page.tsx b/src/app/(search-app)/open-shifts/post/new/page.tsx index d6e0ee583b..f514c7196f 100644 --- a/src/app/(search-app)/open-shifts/post/new/page.tsx +++ b/src/app/(search-app)/open-shifts/post/new/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { OpenShiftsPostPage } from "@/components/open-shifts/open-shifts-post-page"; +import { RosterSampleGate } from "@/components/roster/roster-sample-gate"; export const metadata: Metadata = { title: "Post a shift | Open shifts | PsychSift", @@ -8,5 +9,9 @@ export const metadata: Metadata = { }; export default function OpenShiftsPostRoute() { - return ; + return ( + + + + ); } diff --git a/src/app/(search-app)/open-shifts/post/page.tsx b/src/app/(search-app)/open-shifts/post/page.tsx index 5d3651660b..4d2c53a642 100644 --- a/src/app/(search-app)/open-shifts/post/page.tsx +++ b/src/app/(search-app)/open-shifts/post/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { OpenShiftsPostedPage } from "@/components/open-shifts/open-shifts-posted-page"; +import { RosterSampleGate } from "@/components/roster/roster-sample-gate"; export const metadata: Metadata = { title: "Post | Open shifts | PsychSift", @@ -8,5 +9,9 @@ export const metadata: Metadata = { }; export default function OpenShiftsPostedRoute() { - return ; + return ( + + + + ); } diff --git a/src/app/(search-app)/roster/manage/page.tsx b/src/app/(search-app)/roster/manage/page.tsx index 6f720cd443..a60b672a66 100644 --- a/src/app/(search-app)/roster/manage/page.tsx +++ b/src/app/(search-app)/roster/manage/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { RosterManagePage } from "@/components/roster/manage/roster-manage-page"; +import { RosterSampleGate } from "@/components/roster/roster-sample-gate"; export const metadata: Metadata = { title: "Manage team | Roster | PsychSift", @@ -8,5 +9,9 @@ export const metadata: Metadata = { }; export default function Page() { - return ; + return ( + + + + ); } diff --git a/src/components/admin/new-job/admin-credential-pack-page.tsx b/src/components/admin/new-job/admin-credential-pack-page.tsx index a3fa482577..d60f32d70c 100644 --- a/src/components/admin/new-job/admin-credential-pack-page.tsx +++ b/src/components/admin/new-job/admin-credential-pack-page.tsx @@ -43,6 +43,14 @@ import { guardExampleAction } from "@/lib/example-data/guards"; * The wallet is device-only, so the pack is offered only when signed in and * not in the example corpus, the same rule Admin Today uses for the wallet. */ +/** Made-up numbers for the signed-out example, shaped like the real ones but all zeros. */ +const EXAMPLE_PACK_NUMBERS: CredentialPackNumbers = { + ahpraNumber: "MED0000000000", + prescriberNumber: "0000000", + wwccNumber: "0000000", + providerNumbers: [{ id: "example-provider", site: "Example Hospital", number: "000000AB" }], +}; + export function AdminCredentialPackPage({ now: nowProp }: { now?: Date } = {}) { const state = useOnCallEntries(); const { isAuthenticated } = useAccountData(); @@ -55,7 +63,11 @@ export function AdminCredentialPackPage({ now: nowProp }: { now?: Date } = {}) { const [numbers, setNumbers] = useState(() => loadDoctorCredentials()); useEffect(() => subscribeAccountTransition(() => setNumbers({})), []); - const sections = useMemo(() => buildCredentialPack({ numbers, ownEntries: own }), [numbers, own]); + // Signed out with example data showing, the pack is built from the example records and made-up + // numbers, so the page can be seen working. Copy, share and save stay off for examples. + const signedOutExample = !isAuthenticated && state.sample; + const packNumbers = signedOutExample ? EXAMPLE_PACK_NUMBERS : numbers; + const sections = useMemo(() => buildCredentialPack({ numbers: packNumbers, ownEntries: own }), [packNumbers, own]); const [excluded, setExcluded] = useState>(() => new Set()); const included = useMemo(() => includedCredentialPack(sections, excluded), [sections, excluded]); const text = useMemo(() => credentialPackText(included, now), [included, now]); @@ -92,7 +104,7 @@ export function AdminCredentialPackPage({ now: nowProp }: { now?: Date } = {}) { ); } - const available = isAuthenticated && !state.demoMode; + const available = (isAuthenticated && !state.demoMode) || signedOutExample; const ready = loadState === "ready" && available; const nothingIncluded = included.length === 0; @@ -159,6 +171,11 @@ export function AdminCredentialPackPage({ now: nowProp }: { now?: Date } = {}) { ) : ready ? ( <> + {signedOutExample ? ( + + These are example records with made-up numbers. Sign in to make a pack from your own. + + ) : null}
Choose what goes in
    @@ -189,12 +206,13 @@ export function AdminCredentialPackPage({ now: nowProp }: { now?: Date } = {}) {
- {/* Only this section prints: the shared print rule hides the app around `data-print-output`. */} + {/* Only this section prints: the shared print rule hides the app around `data-print-output`. + The example pack never prints: a browser Print would otherwise skip the Save as PDF guard. */}
Preview diff --git a/src/components/alerts/alerts-page.tsx b/src/components/alerts/alerts-page.tsx index 5e089406ee..51fdec08ab 100644 --- a/src/components/alerts/alerts-page.tsx +++ b/src/components/alerts/alerts-page.tsx @@ -17,8 +17,11 @@ import { usePhoneAlerts } from "@/components/alerts/use-phone-alerts"; import { useRemindMe } from "@/components/alerts/use-remind-me"; import { useAppPreferences } from "@/components/clinical-dashboard/use-app-preferences"; import { ModeGroupedList, ModeRow } from "@/components/mode-kit/grouped-list"; +import { SignedOutSampleNotice } from "@/components/mode-kit/signed-out-sample"; +import { useSignedOut } from "@/components/mode-kit/use-signed-out-sample"; import { WorkTag } from "@/components/mode-kit/work"; import { MyDayFrame } from "@/components/my-day/my-day-frame"; +import { RosterSampleGate } from "@/components/roster/roster-sample-gate"; import { useRosterSettings } from "@/components/roster/use-roster-settings"; import { useWorkModeRouteVisible } from "@/components/work-mode-launch/work-mode-launch-provider"; import { ALERT_AREA_IDS, ALERT_AREAS, areaSummary, type AlertAreaId } from "@/lib/alerts/areas"; @@ -47,6 +50,9 @@ export function AlertsPage({ */ inFrame?: boolean; } = {}) { + // Signed out, the settings show as an example to look at (the frame adds it only while My Day shows + // example data). Signed in, they are always the reader's own, never an example. + const signedOut = useSignedOut(); return ( "What reaches your phone, and when"} + signedOutSample={signedOut ? { render: (at) => } : undefined} signedOut={{ title: "Sign in to get alerts", body: "Alerts come from your own roster and records, so they need your account. Nothing is sent to this device while you're signed out.", @@ -64,6 +71,25 @@ export function AlertsPage({ ); } +/** + * The settings as a signed-in doctor sees them, to look at only: `inert` keeps every switch and sheet + * shut, so nothing is changed on this device or sent. Roster's switches read Roster's example data. + */ +function AlertsSignedOutSample({ now, inFrame }: { now: Date; inFrame: boolean }) { + return ( + <> + + This is how the settings look. Signed in, you choose what reaches your phone, and when. + + +
+ +
+
+ + ); +} + /** Each area's icon, as the notification centre draws it. */ const AREA_ICONS: Readonly> = { roster: CalendarDays, diff --git a/src/components/cme/cme-signed-out-sample.tsx b/src/components/cme/cme-signed-out-sample.tsx index 4a7cbe9b46..dfe257f0c8 100644 --- a/src/components/cme/cme-signed-out-sample.tsx +++ b/src/components/cme/cme-signed-out-sample.tsx @@ -2,12 +2,14 @@ import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { Suspense } from "react"; +import { Suspense, useEffect } from "react"; import { CmeAnnualSummary } from "@/components/cme/cme-annual-summary"; import { CmeCalendarPage } from "@/components/cme/cme-calendar-page"; +import { CmeCustomisePage } from "@/components/cme/cme-customise-page"; import { CmeDashboard } from "@/components/cme/cme-dashboard"; import { CmeEntryRouteClient } from "@/components/cme/cme-entry-route-client"; +import { CmeLearningPage } from "@/components/cme/cme-learning-page"; import { CmeLogPage, type CmeLogAttention } from "@/components/cme/cme-log-page"; import { CmeNewEntryRoute } from "@/components/cme/cme-new-entry-route"; import { CmePageTabs } from "@/components/cme/cme-page-tabs"; @@ -19,6 +21,9 @@ import { CmeSampleContext } from "@/components/cme/cme-sample-context"; import { CmeSetupRoute } from "@/components/cme/cme-setup-route"; import { CmeTrainingPage } from "@/components/cme/cme-training-page"; import { CmeYearCheckPage } from "@/components/cme/cme-year-check-page"; +import { CpdHomeSendPage } from "@/components/cme/cpd-home/cpd-home-send-page"; +import { CpdEvidencePage } from "@/components/work-screens/cpd/cpd-evidence-page"; +import { CpdExportPage } from "@/components/work-screens/cpd/cpd-export-page"; import { perthCalendarDate } from "@/lib/cme/cpd-year"; import { DEMO_CME_ENTRIES, @@ -27,6 +32,7 @@ import { DEMO_CME_ROUTINES, DEMO_CME_YEAR, } from "@/lib/cme/demo-year"; +import { loadLearningDirectory } from "@/lib/cme/learning-directory"; import { parseCmeLearningPrefill } from "@/lib/cme/learning-source"; import { trainingExampleView } from "@/lib/cme/training-assessments"; import { @@ -36,6 +42,7 @@ import { sampleTrainingAssessments, } from "@/lib/cme/training-assessments-sample"; import { cmeCategories, type CmeCategory } from "@/lib/cme/types"; +import { parseEvidenceCategory, parseEvidenceOrder, parseEvidenceStatus } from "@/lib/work-screens/cpd/evidence"; /** * The signed-out CPD sample: the real screens filled with the invented demo year @@ -53,6 +60,18 @@ const ROUTINES = DEMO_CME_ROUTINES; const GOALS = DEMO_CME_PLAN_GOALS; const SAMPLE_YEARS = [DEMO_CME_YEAR.year] as const; +/** Pages that moved to Admin: the server redirect does not run here, so follow it in the browser. */ +const MOVED_TO_ADMIN: Readonly> = { + "/cme/applications": "/admin/applications", + "/cme/applications/cv": "/admin/applications/cv", +}; + +function MovedPage({ href }: { readonly href: string }) { + const router = useRouter(); + useEffect(() => router.replace(href), [href, router]); + return
; +} + function SampleBody({ pathname, query }: { readonly pathname: string; readonly query: URLSearchParams }) { const router = useRouter(); if (pathname === "/cme") { @@ -160,6 +179,65 @@ function SampleBody({ pathname, query }: { readonly pathname: string; readonly q ); } + if (pathname === "/cme/cpd-home") { + return ( + + ); + } + if (pathname === "/cme/evidence") { + const category = parseEvidenceCategory(query.get("category")); + const status = parseEvidenceStatus(query.get("show")); + const order = parseEvidenceOrder(query.get("order")); + return ( + + ); + } + if (pathname === "/cme/export") { + return ( + + ); + } + if (pathname === "/cme/customise") { + // Look only: the layout is kept on this device, so the sample must not change it for whoever signs in next. + return ( +
+ +
+ ); + } + if (pathname === "/cme/learning") { + // The public WA course list, the same for everyone; the home label is the sample year's. + const directory = loadLearningDirectory(); + return ( + + ); + } + const moved = MOVED_TO_ADMIN[pathname]; + if (moved) return ; return (

This page is not part of the example data. It works on your own CPD record, so it needs you to sign in.

diff --git a/src/components/mode-kit/signed-out-example-switch.tsx b/src/components/mode-kit/signed-out-example-switch.tsx new file mode 100644 index 0000000000..18746f82e9 --- /dev/null +++ b/src/components/mode-kit/signed-out-example-switch.tsx @@ -0,0 +1,23 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { useSignedOutSample } from "@/components/mode-kit/use-signed-out-sample"; +import type { WorkAreaId } from "@/lib/work-frame/areas"; + +/** + * For a server page that already knows the visitor is signed out: the page's + * example while the example data switch shows examples in `area`, else the + * page's own sign-in notice. Only one of the two is ever mounted. + */ +export function SignedOutExampleSwitch({ + area, + example, + notice, +}: { + readonly area: WorkAreaId; + readonly example: ReactNode; + readonly notice: ReactNode; +}) { + return useSignedOutSample(area) ? example : notice; +} diff --git a/src/lib/example-data/registry.ts b/src/lib/example-data/registry.ts index cb7ef0814f..4e606d249f 100644 --- a/src/lib/example-data/registry.ts +++ b/src/lib/example-data/registry.ts @@ -75,7 +75,10 @@ const LOADERS: { [K in ExampleDatasetKey]: Loader } = { "roster.rotations": async (now) => (await import("@/lib/example-data/datasets/roster-rotations")).exampleRotationRounds(now), "openShifts.listings": async (now) => (await import("@/lib/open-shifts/sample")).sampleListings(now), - "onCall.entries": async () => (await import("@/lib/on-call/demo-entries")).DEMO_ON_CALL_ENTRIES, + "onCall.entries": async () => { + const { DEMO_ON_CALL_ENTRIES, EXAMPLE_CONTRACT_END_ENTRY } = await import("@/lib/on-call/demo-entries"); + return [...DEMO_ON_CALL_ENTRIES, EXAMPLE_CONTRACT_END_ENTRY]; + }, "teaching.sessions": async (now, zone) => { const today = zonedDateOf(now, zone); return (await import("@/lib/teaching/demo-programme")).demoTeachingSessions( diff --git a/src/lib/on-call/demo-entries.ts b/src/lib/on-call/demo-entries.ts index eb66b5cb17..77b64a909c 100644 --- a/src/lib/on-call/demo-entries.ts +++ b/src/lib/on-call/demo-entries.ts @@ -2257,3 +2257,32 @@ export const DEMO_ON_CALL_ENTRIES: readonly OnCallEntry[] = [ lastVerifiedAt: NEVER_VERIFIED, }), ]; + +/** + * The example contract, five months out, so Contract end shows its dates, reminders + * and questions to a signed-out visitor. It is only in the example data set + * (`onCall.entries`), never in the corpus above: "Load example content" must + * not give a real account a contract end date it did not enter. + */ +export const EXAMPLE_CONTRACT_END_ENTRY: OnCallEntry = entry({ + id: id(141), + section: "logistics", + slug: "contract-end-example", + title: "Contract end", + subtitle: null, + body: null, + details: { + category: "Personal", + kind: "compliance", + expiresOn: demoDateKey(150), + leadTimeDays: 91, + issuingBody: "Example Health Service", + provenance: "typed", + }, + linkedDocumentIds: [], + tags: [], + isPersonal: true, + includeOnCard: false, + sortOrder: 19, + lastVerifiedAt: VERIFIED_RECENTLY, +}); diff --git a/tests/admin-credential-pack-page.dom.test.tsx b/tests/admin-credential-pack-page.dom.test.tsx index 74060a3b25..704add6413 100644 --- a/tests/admin-credential-pack-page.dom.test.tsx +++ b/tests/admin-credential-pack-page.dom.test.tsx @@ -27,6 +27,7 @@ const entryState = vi.hoisted(() => ({ loadError: null as "offline" | "failed" | null, signedOut: false, demoMode: false, + sample: false, cachedAt: null as string | null, retry: vi.fn(), })); @@ -52,6 +53,7 @@ beforeEach(() => { loadError: null, signedOut: false, demoMode: false, + sample: false, cachedAt: null, }); }); @@ -70,6 +72,22 @@ describe("AdminCredentialPackPage", () => { expect(screen.queryByTestId("admin-credential-pack-preview")).toBeNull(); }); + it("shows a pack built from the example records and made-up numbers when signed out with examples showing", () => { + accountState.isAuthenticated = false; + seedWallet({ ahpraNumber: "MED1111111111" }); + Object.assign(entryState, { demoMode: true, sample: true, entries: [renewal] }); + render(); + expect(screen.getByTestId("admin-credential-pack-example").textContent).toContain("made-up numbers"); + const preview = screen.getByTestId("admin-credential-pack-preview").textContent ?? ""; + expect(preview).toContain("MED0000000000"); + expect(preview).toContain("Medical registration renewal"); + // Whatever this device holds is never shown in the example. + expect(preview).not.toContain("MED1111111111"); + // A browser Print must not output the example pack. + expect(screen.getByTestId("admin-credential-pack-preview").hasAttribute("data-print-output")).toBe(false); + expect(screen.queryByTestId("admin-credential-pack-signed-out")).toBeNull(); + }); + it("shows the same message in demo mode", () => { Object.assign(entryState, { demoMode: true }); render(); diff --git a/tests/cme-signed-out-sample.dom.test.tsx b/tests/cme-signed-out-sample.dom.test.tsx index 8c8000b1c5..3c52f18aa6 100644 --- a/tests/cme-signed-out-sample.dom.test.tsx +++ b/tests/cme-signed-out-sample.dom.test.tsx @@ -121,14 +121,38 @@ describe("CPD signed-out sample", () => { }); it("says plainly that a page outside the sample needs sign-in", async () => { - nav.pathname = "/cme/customise"; + nav.pathname = "/cme/not-a-sample-page"; renderBoundary(); await sampleShown(); expect(screen.getByTestId("cme-sample-unavailable").textContent).toContain("not part of the example data"); }); + it("shows Customise look-only, so the sample cannot change this device's layout", async () => { + nav.pathname = "/cme/customise"; + renderBoundary(); + await sampleShown(); + expect(screen.getByTestId("cme-sample-customise").hasAttribute("inert")).toBe(true); + }); + + it("follows Job applications to Admin, where it moved", async () => { + nav.pathname = "/cme/applications/cv"; + renderBoundary(); + await waitFor(() => expect(router.replace).toHaveBeenCalledWith("/admin/applications/cv")); + }); + it("makes no network call and no browser-storage write on any sample page", async () => { - for (const pathname of ["/cme", "/cme/log", "/cme/routines", "/cme/summary", "/cme/new", "/cme/training"]) { + for (const pathname of [ + "/cme", + "/cme/log", + "/cme/routines", + "/cme/summary", + "/cme/new", + "/cme/training", + "/cme/cpd-home", + "/cme/evidence", + "/cme/export", + "/cme/learning", + ]) { nav.pathname = pathname; const view = renderBoundary(); await sampleShown(); diff --git a/tests/on-call-entry-store-example-switch.dom.test.tsx b/tests/on-call-entry-store-example-switch.dom.test.tsx index c05fecbba8..2b806241df 100644 --- a/tests/on-call-entry-store-example-switch.dom.test.tsx +++ b/tests/on-call-entry-store-example-switch.dom.test.tsx @@ -10,7 +10,7 @@ import { cleanup, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { DEMO_ON_CALL_ENTRIES } from "@/lib/on-call/demo-entries"; +import { DEMO_ON_CALL_ENTRIES, EXAMPLE_CONTRACT_END_ENTRY } from "@/lib/on-call/demo-entries"; import { OnCallExampleDataScope, cacheOnCallEntries, @@ -21,6 +21,9 @@ import { } from "@/lib/on-call/entry-store"; import { areaDataState, resetExampleDataForTests, setExampleDataOn } from "@/lib/example-data/store"; +/** The example set is the corpus plus the example contract, which "Load example content" never writes. */ +const EXAMPLE_SET_SIZE = [...DEMO_ON_CALL_ENTRIES, EXAMPLE_CONTRACT_END_ENTRY].length; + const auth = vi.hoisted(() => ({ status: "authenticated" as string, authEpoch: 1, @@ -64,7 +67,7 @@ describe("useOnCallEntries and the example data switch", () => { await waitFor(() => expect(result.current.sample).toBe(true)); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.demoMode).toBe(true); - expect(result.current.entries).toHaveLength(DEMO_ON_CALL_ENTRIES.length); + expect(result.current.entries).toHaveLength(EXAMPLE_SET_SIZE); expect(result.current.entries.every(isOnCallExampleEntry)).toBe(true); expect(result.current.cachedAt).toBeNull(); @@ -111,7 +114,7 @@ describe("useOnCallEntries and the example data switch", () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(areaDataState("call")).toBe("empty"); expect(result.current.demoMode).toBe(true); - expect(result.current.entries).toHaveLength(DEMO_ON_CALL_ENTRIES.length); + expect(result.current.entries).toHaveLength(EXAMPLE_SET_SIZE); }); it("honours an explicit off for a signed-out visitor: no sample, the normal signed-out state", async () => { @@ -134,7 +137,7 @@ describe("useOnCallEntries and the example data switch", () => { const { result } = renderHook(() => useOnCallEntries(), { wrapper: scope }); await waitFor(() => expect(result.current.sample).toBe(true)); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.entries).toHaveLength(DEMO_ON_CALL_ENTRIES.length); + expect(result.current.entries).toHaveLength(EXAMPLE_SET_SIZE); }); it("changes nothing outside the frame's scope", async () => { diff --git a/tests/on-call-signed-out-sample.dom.test.tsx b/tests/on-call-signed-out-sample.dom.test.tsx index 1cad0fff1b..f87bba04af 100644 --- a/tests/on-call-signed-out-sample.dom.test.tsx +++ b/tests/on-call-signed-out-sample.dom.test.tsx @@ -7,11 +7,14 @@ import { cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { DEMO_ON_CALL_ENTRIES } from "@/lib/on-call/demo-entries"; +import { DEMO_ON_CALL_ENTRIES, EXAMPLE_CONTRACT_END_ENTRY } from "@/lib/on-call/demo-entries"; import { clearOnCallEntryCache, onCallEntryCacheStorageKey, useOnCallEntries } from "@/lib/on-call/entry-store"; import { sampleServiceDetail } from "@/lib/on-call/service-demo"; import { isOnCallPlaceholderNumber, onCallTelHref, resolveHandbookPhone } from "@/lib/on-call/number-resolver"; +/** The example set is the corpus plus the example contract, which "Load example content" never writes. */ +const EXAMPLE_SET_SIZE = [...DEMO_ON_CALL_ENTRIES, EXAMPLE_CONTRACT_END_ENTRY].length; + const auth = vi.hoisted(() => ({ status: "signed_out", authEpoch: 1 })); vi.mock("@/lib/supabase/client", () => ({ useAuthSession: () => auth })); vi.mock("@/components/clinical-dashboard/account-setup-dialog", () => ({ @@ -40,7 +43,7 @@ describe("On Call signed-out sample", () => { await waitFor(() => expect(result.current.sample).toBe(true)); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.entries.length).toBe(DEMO_ON_CALL_ENTRIES.length); + expect(result.current.entries.length).toBe(EXAMPLE_SET_SIZE); expect(result.current.demoMode).toBe(true); expect(result.current.signedOut).toBe(false); expect(result.current.cachedAt).toBeNull(); diff --git a/tests/signed-out-example-switch.dom.test.tsx b/tests/signed-out-example-switch.dom.test.tsx new file mode 100644 index 0000000000..34efbea9cb --- /dev/null +++ b/tests/signed-out-example-switch.dom.test.tsx @@ -0,0 +1,41 @@ +/** @vitest-environment jsdom */ + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SignedOutExampleSwitch } from "@/components/mode-kit/signed-out-example-switch"; + +const sample = vi.hoisted(() => ({ active: true })); +vi.mock("@/components/mode-kit/use-signed-out-sample", () => ({ + useSignedOutSample: () => sample.active, +})); + +afterEach(() => { + cleanup(); + sample.active = true; +}); + +describe("SignedOutExampleSwitch", () => { + function renderSwitch() { + render( + Example

} + notice={

Sign in

} + />, + ); + } + + it("shows the example while example data shows in the area", () => { + renderSwitch(); + expect(screen.getByTestId("example")).toBeTruthy(); + expect(screen.queryByTestId("notice")).toBeNull(); + }); + + it("shows the sign-in notice once example data is off", () => { + sample.active = false; + renderSwitch(); + expect(screen.getByTestId("notice")).toBeTruthy(); + expect(screen.queryByTestId("example")).toBeNull(); + }); +});