diff --git a/src/components/layout/__tests__/bottom-nav-module-gate.test.tsx b/src/components/layout/__tests__/bottom-nav-module-gate.test.tsx new file mode 100644 index 000000000..6662c5b7c --- /dev/null +++ b/src/components/layout/__tests__/bottom-nav-module-gate.test.tsx @@ -0,0 +1,73 @@ +/** + * Bottom-nav primary-slot module gate — the MOUNTED contract. + * + * `useMounted` is mocked true here, so the resolved module map applies + * (the sibling `bottom-nav.test.tsx` pins the fail-closed pre-mount + * markup). The Meds slot pins to `/medications`, whose page renders + * nothing when the medications module is off — a visible tab over a + * blank page — so the slot must honour the same per-user map the + * Insights slot and the More hub already do. + * + * Watched red: with the `requiresModule: "medications"` entry removed + * from PRIMARY_LEFT (the pre-fix ungated pin) the module-off test fails + * with the tab still present. Verified red against the pre-fix bar. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/", + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +// Mounted=true: the SSR render below behaves like the hydrated bar. +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +const mockUserRef = { + value: { id: "u1", modules: {} } as { + id: string; + modules: Record; + } | null, +}; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: mockUserRef.value, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@/lib/i18n/context"; +import { BottomNav } from "../bottom-nav"; + +function render() { + return renderToStaticMarkup( + + + + + , + ); +} + +describe(" mounted module gate", () => { + it("shows the Meds and Insights slots when their modules are on (default-on map)", () => { + mockUserRef.value = { id: "u1", modules: {} }; + const html = render(); + for (const href of ["/", "/medications", "/insights"]) { + expect(html).toContain(`href="${href}"`); + } + }); + + it("drops the Meds slot when the medications module is off", () => { + mockUserRef.value = { id: "u1", modules: { medications: false } }; + const html = render(); + expect(html).not.toContain('href="/medications"'); + // Siblings unaffected. + expect(html).toContain('href="/"'); + expect(html).toContain('href="/insights"'); + mockUserRef.value = { id: "u1", modules: {} }; + }); +}); diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index fba5ead13..b4828bd81 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -61,22 +61,21 @@ function render() { } describe(" iOS-parity layout", () => { - it("renders the always-on flanking primary anchors (Home, Meds)", () => { + it("renders the always-on Home anchor", () => { const html = render(); - // Home + Meds are not module-gated, so they anchor the strip on first - // paint. The Insights slot IS module-gated; v1.25.12 gates it behind - // hydration (fail-closed pre-mount) so a disabled module can't flicker - // in — its presence is asserted in the mounted/e2e path, not here. - for (const href of ["/", "/medications"]) { - expect(html).toContain(`href="${href}"`); - } + // Home is the one ungated primary slot, so it anchors the strip on + // first paint. Meds and Insights are BOTH module-gated now and follow + // the fail-closed-pre-mount contract; their mounted presence is pinned + // in bottom-nav-module-gate.test.tsx. + expect(html).toContain('href="/"'); }); - it("keeps the module-gated Insights slot OUT of the first-paint markup (v1.25.12)", () => { + it("keeps the module-gated Meds and Insights slots OUT of the first-paint markup (v1.25.12)", () => { const html = render(); - // Pre-hydration the module map isn't settled; the Insights primary slot - // therefore fails closed (absent) rather than flickering in. + // Pre-hydration the module map isn't settled; a module-gated primary + // slot therefore fails closed (absent) rather than flickering in. expect(html).not.toContain('href="/insights"'); + expect(html).not.toContain('href="/medications"'); }); it("renders the center capture action as a dialog-opening button, not a link", () => { @@ -111,13 +110,13 @@ describe(" iOS-parity layout", () => { it("each strip entry meets the 44 px tap-target floor (min-h-11 min-w-11)", () => { const html = render(); - // The flanking strip slots carry `min-h-11 min-w-11`. On first paint the - // module-gated Insights slot is fail-closed (v1.25.12), so the strip - // shows Home, Meds and More — three slots. We don't hard-pin the count - // because the mounted render adds Insights back and a future entry would - // also satisfy the contract. + // The flanking strip slots carry `min-h-11 min-w-11`. On first paint + // BOTH module-gated slots (Meds, Insights) are fail-closed (v1.25.12), + // so the strip shows Home and More — two slots. We don't hard-pin the + // count because the mounted render adds the gated slots back and a + // future entry would also satisfy the contract. const matches = html.match(/min-h-11 min-w-11/g) ?? []; - expect(matches.length).toBeGreaterThanOrEqual(3); + expect(matches.length).toBeGreaterThanOrEqual(2); }); it("the center capture FAB is a larger, elevated tap target", () => { @@ -171,9 +170,8 @@ describe(" iOS-parity layout", () => { mockUserRef.value = { id: "u1", modules: { insights: false } }; const html = render(); expect(html).not.toContain('href="/insights"'); - // Sibling primary slots are unaffected. + // The ungated sibling anchor is unaffected. expect(html).toContain('href="/"'); - expect(html).toContain('href="/medications"'); mockUserRef.value = { id: "u1", modules: {} }; }); diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index de0e12093..bb72d2117 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -18,7 +18,7 @@ import { mobileMoreHubDestinations, } from "@/components/layout/nav-model"; import type { ModuleKey } from "@/lib/modules/registry"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; @@ -55,9 +55,17 @@ interface NavLink { // slots, plus the shared utility tail) — so the two surfaces tell one story // instead of two hand-curated ones that drift, and the headline invariant // is a tested model function rather than inline bar logic. -const PRIMARY_LEFT: ReadonlyArray = [ +// The Meds slot is module-gated exactly like the Insights slot below: +// `/medications` renders nothing when the medications module is off, so a +// pinned tab would be a visible control over a blank page. +const PRIMARY_LEFT: ReadonlyArray = [ { href: "/", tKey: "nav.dashboard", icon: Home }, - { href: "/medications", tKey: "nav.medications", icon: Pill }, + { + href: "/medications", + tKey: "nav.medications", + icon: Pill, + requiresModule: "medications", + }, ]; // v1.18.0 — the Insights primary slot is module-gated. Insights is now a @@ -125,20 +133,35 @@ export function BottomNav() { // Insights never flickers in), then the real map applies: a missing key / // unloaded map keeps the slot, mirroring the gate + More-hub default-on // contract. - const primaryRight = useMemo( - () => - PRIMARY_RIGHT.filter( - (item) => - // v1.36.0 — the fixed slot asks the shared destination model - // whether sharing covers it, rather than carrying its own answer: - // Insights is an AI surface and drops out under a switch. - (!sharedRecord || isDestinationInSharedRecord(item.href, sections)) && - (!item.requiresModule || - (mounted && user?.modules?.[item.requiresModule] !== false)), - ), + const primarySlotVisible = useCallback( + (item: NavLink & { requiresModule?: ModuleKey }) => { + // An ungated slot (Home) is pinned unconditionally — the bar always + // has an anchor, whatever the module map or a record switch says. + if (!item.requiresModule) return true; + return ( + // v1.36.0 — the fixed slot asks the shared destination model + // whether sharing covers it, rather than carrying its own answer: + // Insights is an AI surface and drops out under a switch. + (!sharedRecord || isDestinationInSharedRecord(item.href, sections)) && + mounted && + user?.modules?.[item.requiresModule] !== false + ); + }, [user?.modules, mounted, sharedRecord, sections], ); + // Both fixed flanks run through the ONE slot filter, so a module-gated + // tab (Meds left, Insights right) is dropped — not relocated — when the + // account turned its module off, matching the More hub's exclusion. + const primaryLeft = useMemo( + () => PRIMARY_LEFT.filter(primarySlotVisible), + [primarySlotVisible], + ); + const primaryRight = useMemo( + () => PRIMARY_RIGHT.filter(primarySlotVisible), + [primarySlotVisible], + ); + function isActiveLink(href: string) { return isNavDestinationActive(href, pathname); } @@ -189,7 +212,7 @@ export function BottomNav() { className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden" >
- {PRIMARY_LEFT.map(renderPrimary)} + {primaryLeft.map(renderPrimary)} {/* Center capture action — a labeled button (NOT a link) that opens the capture picker. Elevated as a Floating-Action-