mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Cases leaned on what an earlier case set up or left behind: a once-value the code under test never consumed, an implementation swapped in and kept by clearAllMocks, a payload only another block's setup provided, the zone resolver's per-user cache, module state in a hoisted object, and a scope set one case discovered for the next. Each now resets or states what it reads.
255 lines
8.2 KiB
TypeScript
255 lines
8.2 KiB
TypeScript
/**
|
|
* `<DashboardHeader>` greeting contract.
|
|
*
|
|
* The greeting line sits under the title on every dashboard mount — the
|
|
* promoted Today hero renders separately above the tile strip, so the
|
|
* header greeting is unconditional (the legacy opt-in hero that once
|
|
* owned it was retired). Pinned here:
|
|
*
|
|
* 1. the greeting line renders (with the `min-h-5` line-box
|
|
* reservation that keeps the header height stable through the
|
|
* post-hydration name personalisation);
|
|
* 2. the SSR pass renders the name-less fallback (hydration-safe).
|
|
*/
|
|
import { beforeEach, describe, it, expect, vi } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import { I18nProvider } from "@/lib/i18n/context";
|
|
|
|
vi.mock("@/components/ui/dropdown-menu", () => ({
|
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
|
<>{children}</>
|
|
),
|
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DropdownMenuItem: ({ children }: { children: React.ReactNode }) => (
|
|
<div>{children}</div>
|
|
),
|
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
|
<>{children}</>
|
|
),
|
|
}));
|
|
const modulesRef: { value: Record<string, boolean> | undefined } = {
|
|
value: undefined,
|
|
};
|
|
vi.mock("@/hooks/use-auth", () => ({
|
|
useAuth: () => ({
|
|
user: {
|
|
username: "tester",
|
|
timezone: "Europe/Berlin",
|
|
modules: modulesRef.value,
|
|
},
|
|
}),
|
|
}));
|
|
|
|
import { DashboardHeader, greetingLine } from "../dashboard-header";
|
|
import {
|
|
CAPTURE_KIND_ORDER,
|
|
visibleCaptureKinds,
|
|
} from "@/components/layout/capture-picker";
|
|
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
|
|
|
primeLocales("de");
|
|
|
|
// Every case starts on an account that never set a module, whichever case
|
|
// ran before it and left its own modules behind.
|
|
beforeEach(() => {
|
|
modulesRef.value = undefined;
|
|
});
|
|
|
|
function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
|
|
return renderToStaticMarkup(
|
|
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
describe("greetingLine", () => {
|
|
const now = Date.parse("2026-10-29T09:00:00Z");
|
|
const base = { greeting: "Hello", nowMs: now };
|
|
|
|
it("welcomes an account in its first day, without a 'back'", () => {
|
|
expect(
|
|
greetingLine({
|
|
...base,
|
|
known: true,
|
|
name: "tester",
|
|
createdAt: "2026-10-29T08:50:00Z",
|
|
}),
|
|
).toEqual({
|
|
key: "dashboard.welcomeNewWithName",
|
|
values: { name: "tester" },
|
|
});
|
|
expect(
|
|
greetingLine({
|
|
...base,
|
|
known: true,
|
|
name: null,
|
|
createdAt: "2026-10-28T10:00:00Z",
|
|
}).key,
|
|
).toBe("dashboard.welcomeNew");
|
|
});
|
|
|
|
it("welcomes back an account older than a day", () => {
|
|
expect(
|
|
greetingLine({
|
|
...base,
|
|
known: true,
|
|
name: "tester",
|
|
createdAt: "2026-10-20T08:00:00Z",
|
|
}),
|
|
).toEqual({
|
|
key: "dashboard.welcomeBackWithName",
|
|
values: { greeting: "Hello", name: "tester" },
|
|
});
|
|
});
|
|
|
|
it("welcomes back when an older server does not say when the account began", () => {
|
|
expect(
|
|
greetingLine({ ...base, known: true, name: null, createdAt: undefined })
|
|
.key,
|
|
).toBe("dashboard.welcomeBack");
|
|
});
|
|
|
|
it("says only the greeting while the account is not known", () => {
|
|
expect(
|
|
greetingLine({
|
|
...base,
|
|
known: false,
|
|
name: "tester",
|
|
createdAt: "2026-10-29T08:50:00Z",
|
|
}),
|
|
).toEqual({ key: "dashboard.greetingOnly", values: { greeting: "Hello" } });
|
|
});
|
|
});
|
|
|
|
describe("<DashboardHeader> — greeting", () => {
|
|
it("renders the greeting line under the title", () => {
|
|
const html = renderSSR(<DashboardHeader onQuickEntry={() => undefined} />);
|
|
expect(html).toContain('data-slot="dashboard-header-greeting"');
|
|
// SSR pass: the time-of-day greeting alone. Nothing says "welcome
|
|
// back" before the client knows the account is not new.
|
|
expect(html).toContain("Hallo.");
|
|
expect(html).not.toContain("willkommen zurück");
|
|
// Reserved line box keeps the header height stable through the
|
|
// post-hydration personalisation swap.
|
|
expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/);
|
|
expect(html).toContain("min-h-5");
|
|
// The title itself stays.
|
|
expect(html).toContain("Dashboard");
|
|
});
|
|
|
|
// The dashboard is the app's landing surface
|
|
// and must expose a page-level `<h1>`. It comes from the shared
|
|
// `PageHeader` the header renders, so a screen-reader user navigating by
|
|
// heading lands on a real page anchor (not the Today hero's muted `<h2>`
|
|
// micro-label). No separate sr-only heading is added — that would double
|
|
// the `h1`.
|
|
it("renders a real page-level h1 with the dashboard title", () => {
|
|
const html = renderSSR(<DashboardHeader onQuickEntry={() => undefined} />);
|
|
expect(html).toMatch(/<h1[^>]*>Dashboard<\/h1>/);
|
|
});
|
|
});
|
|
|
|
describe("<DashboardHeader> — quick-add menu", () => {
|
|
it("offers measurement, mood and medication, and no water entry", () => {
|
|
const html = renderSSR(
|
|
<DashboardHeader onQuickEntry={() => undefined} />,
|
|
"en",
|
|
);
|
|
|
|
expect(html).toContain("Log measurement");
|
|
expect(html).toContain("Log mood");
|
|
expect(html).toContain("Log medication intake");
|
|
// Water logging was removed from the app; the dashboard quick-add offers
|
|
// no water entry.
|
|
expect(html).not.toContain("Log water");
|
|
});
|
|
});
|
|
|
|
describe("<DashboardHeader> — Log workout", () => {
|
|
it("offers it beside the other capture entries", () => {
|
|
modulesRef.value = { workouts: true };
|
|
const html = renderSSR(
|
|
<DashboardHeader onQuickEntry={() => undefined} />,
|
|
"en",
|
|
);
|
|
expect(html).toContain("Log workout");
|
|
});
|
|
|
|
it("withholds it when the workouts module is off, and keeps the rest", () => {
|
|
modulesRef.value = { workouts: false };
|
|
const html = renderSSR(
|
|
<DashboardHeader onQuickEntry={() => undefined} />,
|
|
"en",
|
|
);
|
|
expect(html).not.toContain("Log workout");
|
|
expect(html).toContain("Log measurement");
|
|
});
|
|
});
|
|
|
|
describe("<DashboardHeader> — every entry follows its module, as the capture picker does", () => {
|
|
it("withholds Log mood when the mood module is off", () => {
|
|
modulesRef.value = { mood: false };
|
|
const html = renderSSR(
|
|
<DashboardHeader onQuickEntry={() => undefined} />,
|
|
"en",
|
|
);
|
|
expect(html).not.toContain("Log mood");
|
|
expect(html).toContain("Log medication intake");
|
|
});
|
|
|
|
it("withholds Log medication intake when the medications module is off", () => {
|
|
modulesRef.value = { medications: false };
|
|
const html = renderSSR(
|
|
<DashboardHeader onQuickEntry={() => undefined} />,
|
|
"en",
|
|
);
|
|
expect(html).not.toContain("Log medication intake");
|
|
expect(html).toContain("Log mood");
|
|
});
|
|
|
|
it("offers exactly what the capture picker offers for the same modules", () => {
|
|
const cases: Array<Record<string, boolean> | undefined> = [
|
|
undefined,
|
|
{ mood: false },
|
|
{ medications: false },
|
|
{ workouts: false },
|
|
{ illness: false },
|
|
{ mood: false, medications: false, workouts: false, illness: false },
|
|
{ timeline: false },
|
|
{ timeline: true },
|
|
];
|
|
for (const modules of cases) {
|
|
modulesRef.value = modules;
|
|
const html = renderSSR(
|
|
<DashboardHeader onQuickEntry={() => undefined} />,
|
|
"en",
|
|
);
|
|
const picker = visibleCaptureKinds(
|
|
{ inSharedRecord: false, canWriteDomain: () => true },
|
|
CAPTURE_KIND_ORDER,
|
|
modules,
|
|
);
|
|
const label = {
|
|
measurement: "Log measurement",
|
|
medication: "Log medication intake",
|
|
mood: "Log mood",
|
|
symptom: "Log symptom",
|
|
workout: "Log workout",
|
|
lifeEvent: "Log life event",
|
|
} as const;
|
|
for (const kind of CAPTURE_KIND_ORDER) {
|
|
expect(
|
|
html.includes(label[kind]),
|
|
`${kind} ${JSON.stringify(modules)}`,
|
|
).toBe(picker.includes(kind));
|
|
}
|
|
// Same order as the picker, so the two ways in never disagree on
|
|
// where an entry sits.
|
|
const positions = picker.map((kind) => html.indexOf(label[kind]));
|
|
expect(positions).toEqual([...positions].sort((x, y) => x - y));
|
|
}
|
|
});
|
|
});
|