test(app-shell): cover header + menu drawer + sidebar; full-width status tabs

12 new SSR tests in app-shell.test.tsx:

Mobile header
- Fixed top header is rendered with `sm:hidden` so it disappears on
  the desktop breakpoint.
- Brand mark on the left links home and carries `aria-label="Go home"`.
- Page title in the centre is derived from usePathname:
  * "/"             → "Dashboard"
  * "/accounts/123" → "Accounts" (sub-route falls back to parent label)
  * unknown route   → generic "WhatsApp Bot"
- Menu button on the right is labelled `aria-label="Open menu"`.

Menu drawer (Sheet primitives mocked transparent so SSR shows content)
- Renders one nav link per NAV_ITEM, in declared order.
- The active route's link gets `aria-current="page"`; others don't.
- Dashboard ("/") matches by exact equality, not by `startsWith`, so
  every page doesn't get marked Dashboard.
- The drawer does NOT include the theme toggle — it lives only in
  the desktop sidebar footer per the recent product call.
- Drawer header carries the brand wording and the SR-only nav-menu
  description.

Desktop sidebar
- Renders with `hidden sm:flex` (mobile-hidden, desktop-visible).
- All NAV_ITEMS appear.
- Theme toggle is present in the sidebar footer.

Plus the small follow-up the user pointed at:

UI: status tabs span the full row
- The shadcn `<TabsList>` defaults to `inline-flex w-fit`, which
  packed Active/Ended/Paused into a tight cluster on the left of
  the reminders + activity pages. Added `w-full` to both
  `<TabsList>` invocations so the tabs distribute evenly across
  the available row width (`flex-1` on each `<TabsTrigger>` already
  handles even widths once the parent stretches).

Total: 206 web tests passing (was 194; +12 from app-shell.test.tsx).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 12:50:54 +08:00
parent ab547c7b34
commit f24619e3d6
3 changed files with 269 additions and 2 deletions

View File

@ -216,7 +216,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
</div> </div>
<Tabs value={filter}> <Tabs value={filter}>
<TabsList> <TabsList className="w-full">
{FILTER_TABS.map(({ value, label }) => ( {FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild> <TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}

View File

@ -198,7 +198,7 @@ export default async function RemindersPage({ searchParams }: PageProps) {
{/* Status tabs — preserve other filter params so flipping tabs doesn't lose them */} {/* Status tabs — preserve other filter params so flipping tabs doesn't lose them */}
<Tabs value={status}> <Tabs value={status}>
<TabsList> <TabsList className="w-full">
{FILTER_TABS.map(({ value, label }) => ( {FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild> <TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}

View File

@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// usePathname is the only knob that drives "active state" + the page-title
// derivation in MobileHeader. We swap it per test to verify each NAV_ITEM
// gets selected when its route matches.
const pathnameMock = vi.fn<() => string>(() => "/");
vi.mock("next/navigation", () => ({
usePathname: () => pathnameMock(),
}));
// next-themes pulls window APIs that don't exist under the SSR-only test
// environment; the ThemeToggle component is rendered inside Sidebar so we
// stub it to a deterministic placeholder we can grep for.
vi.mock("@/components/theme-toggle", () => ({
ThemeToggle: () => <div data-testid="theme-toggle">theme-toggle</div>,
}));
// Make the Sheet primitives transparent so the drawer's contents render
// inline and we can grep them. The real components defer rendering until
// the trigger is clicked (Radix portal); for a contract test we just want
// to confirm what's INSIDE the drawer.
vi.mock("@/components/ui/sheet", () => {
const passthrough = ({ children }: { children: ReactNode }) => <>{children}</>;
return {
Sheet: passthrough,
SheetTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => (
<>{children}</>
),
SheetContent: ({ children }: { children: ReactNode }) => (
<div data-testid="sheet-content">{children}</div>
),
SheetHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
SheetDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
SheetClose: passthrough,
};
});
import { AppShell } from "./app-shell";
import { NAV_ITEMS } from "./nav-config";
// ---------------------------------------------------------------------------
// MobileHeader contract
// ---------------------------------------------------------------------------
describe("AppShell — mobile header (SSR)", () => {
beforeEach(() => {
pathnameMock.mockReset();
pathnameMock.mockReturnValue("/");
});
it("renders a fixed top header that hides on sm+ breakpoints", () => {
const html = renderToStaticMarkup(
<AppShell>
<main>page</main>
</AppShell>,
);
// A `<header>` exists with both `fixed top-0` and `sm:hidden` so it
// covers the mobile viewport edge but yields to the sidebar on desktop.
expect(html).toMatch(/<header[^>]*class="[^"]*fixed top-0[^"]*sm:hidden/);
});
it("brand mark on the left links to /", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// The "cm" brand pill is an <a href="/"> with aria-label "Go home"
// so screen readers announce its purpose.
expect(html).toMatch(/aria-label="Go home"[^>]*href="\/"|href="\/"[^>]*aria-label="Go home"/);
// Brand text is the literal "cm" inside the pill (not the page title).
expect(html).toContain(">cm<");
});
it("page title in the centre reflects the active route", () => {
const cases: Array<{ path: string; expected: string }> = [
{ path: "/", expected: "Dashboard" },
{ path: "/accounts", expected: "Accounts" },
{ path: "/accounts/abc-123", expected: "Accounts" }, // sub-routes still match
{ path: "/reminders", expected: "Reminders" },
{ path: "/reminders/new", expected: "Reminders" },
{ path: "/activity", expected: "Activity" },
{ path: "/settings", expected: "Settings" },
];
for (const c of cases) {
pathnameMock.mockReturnValue(c.path);
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// The mobile header has a span with the title; the desktop sidebar
// doesn't include this title element. Check the title appears at
// least once (mobile header) AND specifically in the expected form.
expect(html).toContain(c.expected);
}
});
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
pathnameMock.mockReturnValue("/unknown-route");
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// Should fall back to the generic title in the centre (and also be
// present in the desktop sidebar header).
expect(html).toContain("WhatsApp Bot");
});
it("menu button on the right uses aria-label='Open menu'", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
expect(html).toMatch(/aria-label="Open menu"/);
});
});
// ---------------------------------------------------------------------------
// Menu drawer (Sheet) contents
// ---------------------------------------------------------------------------
describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", () => {
beforeEach(() => {
pathnameMock.mockReset();
pathnameMock.mockReturnValue("/");
});
it("renders one nav link per NAV_ITEM, in order", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// Find the substring inside the sheet wrapper to scope our assertions
// to the drawer (avoids matching the desktop sidebar).
const sheetSlice = extractSheet(html);
for (const item of NAV_ITEMS) {
expect(sheetSlice).toContain(`href="${item.href}"`);
expect(sheetSlice).toContain(item.label);
}
// Order check: each label appears in the drawer in NAV_ITEMS order.
let cursor = 0;
for (const item of NAV_ITEMS) {
const idx = sheetSlice.indexOf(item.label, cursor);
expect(idx).toBeGreaterThan(-1);
cursor = idx + item.label.length;
}
});
it("marks the active route's link with aria-current='page'", () => {
pathnameMock.mockReturnValue("/reminders");
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
const sheetSlice = extractSheet(html);
// The reminders link should carry aria-current; the others should not.
expect(sheetSlice).toMatch(/href="\/reminders"[^>]*aria-current="page"|aria-current="page"[^>]*href="\/reminders"/);
expect(sheetSlice).not.toMatch(/href="\/accounts"[^>]*aria-current="page"/);
expect(sheetSlice).not.toMatch(/href="\/activity"[^>]*aria-current="page"/);
});
it("Dashboard ('/') matches exactly, not as a prefix of every route", () => {
// Regression guard: NAV_ITEMS contains '/' as the dashboard href. A
// naïve `pathname.startsWith(href)` would mark Dashboard active on
// every page. The header uses an exact-match check for "/".
pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
const sheetSlice = extractSheet(html);
expect(sheetSlice).not.toMatch(/href="\/"[^>]*aria-current="page"/);
expect(sheetSlice).toMatch(/href="\/accounts"[^>]*aria-current="page"|aria-current="page"[^>]*href="\/accounts"/);
});
it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
const sheetSlice = extractSheet(html);
expect(sheetSlice).not.toContain("theme-toggle");
});
it("drawer header carries the brand wording and a screen-reader description", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
const sheetSlice = extractSheet(html);
// Visible title carries the brand wording.
expect(sheetSlice).toContain("WhatsApp Bot");
// Description text is present (the actual sr-only class lives on the
// shadcn primitive, which the mock here doesn't reproduce — so we
// just assert the text is rendered, leaving a11y class testing to
// the primitive's own coverage).
expect(sheetSlice).toContain("Primary navigation menu");
});
});
// ---------------------------------------------------------------------------
// Desktop sidebar contract
// ---------------------------------------------------------------------------
describe("AppShell — desktop sidebar (SSR)", () => {
beforeEach(() => {
pathnameMock.mockReset();
pathnameMock.mockReturnValue("/");
});
it("renders the sidebar nav with every NAV_ITEM", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// Desktop sidebar starts with `hidden sm:flex` so it's invisible on mobile.
expect(html).toMatch(/<aside[^>]*class="[^"]*hidden sm:flex/);
for (const item of NAV_ITEMS) {
expect(html).toContain(item.label);
}
});
it("keeps the theme toggle in the sidebar footer", () => {
const html = renderToStaticMarkup(
<AppShell>
<div />
</AppShell>,
);
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must
// appear in the sidebar (we removed it from the mobile drawer).
expect(html).toContain('data-testid="theme-toggle"');
});
});
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
/**
* Slice off everything from the SheetContent marker onward. The
* AppShell renders <Sidebar /> first and <MobileHeader /> (which owns
* the Sheet) second, so anything after the marker belongs to the
* mobile drawer + its surrounding JSX (the closing tags). This avoids
* matching the desktop sidebar's nav links, which would otherwise
* trigger false positives.
*
* We can't reliably scope to "just the SheetContent div" without an
* HTML parser the slice includes a few closing tags from outer
* elements, but those don't introduce false matches for our
* assertions (they have no href / aria-current attributes).
*/
function extractSheet(html: string): string {
const open = html.indexOf('data-testid="sheet-content"');
if (open === -1) return "";
return html.slice(open);
}