Files
HealthLog/src/components/settings/__tests__/dashboard-layout-section.test.tsx
T
Marc-André Bombeck 86a3856ae9 fix(i18n): drop spaced dashes as separators from every visible string
A dash between two parts of a sentence ("{what} — tomorrow", "Could not
record {name} — try again", "End {name} — …") stands in for a comma, a
colon or a full stop the sentence should say. Every such string in the
seven bundles now says it in plain words. A range keeps its unspaced en
dash ("70–100 mmHg", "{start}–{end}"); that is how a range is written,
not a pause, and the spaced range forms became unspaced ones.

Labels the components put together themselves follow the same rule:
aria-labels read "Move up: Ramipril", a medication and its dose read
"Ramipril 5 mg", page titles use "|", Coach prefills are two sentences,
and the API's user-facing error messages lost their dashes too.

The bundle guard now refuses " — " and " – " beside " · ", with an empty
allowlist, and a sibling guard walks src/components and src/app for a
string or template literal that joins its parts with a spaced dash.
2026-10-09 16:05:32 +02:00

624 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
DASHBOARD_IOS_ONLY_WIDGET_IDS,
DASHBOARD_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item";
import { NATIVE_ONLY_WIDGET_LABEL_KEYS } from "@/lib/dashboard/widget-modules";
// v1.11.2 HIGH-1 — the web Settings list renders one row per WRITABLE id
// (`DASHBOARD_WIDGET_IDS`) MINUS the `IOS_PIN_ONLY_WIDGET_IDS` (writable so
// the iOS pin PUT validates, but with no web render path). The default
// layout still carries all 24 writable widgets; only this subset renders.
const WEB_RENDERABLE_ROW_COUNT =
DASHBOARD_WIDGET_IDS.length - IOS_PIN_ONLY_WIDGET_IDS.length;
// Mutable holder so individual tests can inject a layout (e.g. one that
// carries iOS-only ids) into the mocked `useQuery` without re-mocking
// the module. Defaults to the 16-tile web default layout.
const queryState: { layout: DashboardLayout } = {
layout: DEFAULT_DASHBOARD_LAYOUT,
};
/**
* v1.4.15 Fix 5 — independent strip-tile + chart toggles.
*
* The settings section grew a SECOND switch column for the upper-row
* tile, distinct from the existing chart switch. SSR smoke tests are
* sufficient here; full state-mutation testing would require a DOM
* runtime which the rest of the settings suite doesn't pull in.
*
* v1.4.47 W4 — drag-and-drop reorder via @dnd-kit. The SSR markup picks
* up the new drag handle, the shared describedby hint paragraph, and
* keeps the legacy arrow buttons + switches. The reorder contract is
* exercised via the exported `reorderWidgets` helper so we pin the
* mutation payload shape without a DOM runtime.
*/
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: queryState.layout, isLoading: false }),
useQueryClient: () => ({ setQueryData: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
}),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
// v1.18.0 — mutable module map so the widget-toggle gating tests can flip a
// module off without re-mocking. Defaults to `undefined` (no map → fail-open,
// every web-renderable row shown), preserving the pre-existing row counts.
const authState: {
modules: Partial<Record<string, boolean>> | undefined;
} = { modules: undefined };
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { modules: authState.modules } }),
}));
// The hero fieldset gates its presence via `useMounted()` (v1.34, hydration
// fix). Under SSR (`renderToStaticMarkup`) `useMounted()` returns `false`,
// which would hide the fieldset from every test below regardless of
// `layout`. Force it to the hydrated (`true`) snapshot so these tests
// exercise the real post-mount content — same convention as
// layout-section.test.tsx for the identical `LayoutModuleGate` pattern.
vi.mock("@/hooks/use-mounted", () => ({
useMounted: () => true,
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
DashboardLayoutSection,
reorderWidgets,
setHeroItemKindEnabled,
} from "../dashboard-layout-section";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
beforeEach(() => {
queryState.layout = DEFAULT_DASHBOARD_LAYOUT;
authState.modules = undefined;
});
describe("<DashboardLayoutSection> — tile + chart split", () => {
it("renders both Tile and Chart column headers in English", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// Column headers — the v1.4.15 split.
expect(html).toContain("Tile");
expect(html).toContain("Chart");
});
it("renders both column headers in German", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(html).toContain("Kachel");
expect(html).toContain("Chart");
});
it("paints both switch slots per widget", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The two new data-slots distinguish tile vs chart switches in
// visual-verify and other consumers.
expect(html).toContain('data-slot="widget-tile-switch"');
expect(html).toContain('data-slot="widget-chart-switch"');
});
it("each widget paints exactly one tile-switch and one chart-switch", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
const chartSwitches = html.match(/data-slot="widget-chart-switch"/g) ?? [];
// One tile + one chart switch per row: the WEB-renderable widgets in
// the sortable list, plus the native-only rows below it (issue #581 —
// the iOS-pin-only ids the default layout carries).
const nativeRows = (html.match(/data-slot="native-widget-row"/g) ?? [])
.length;
expect(nativeRows).toBe(IOS_PIN_ONLY_WIDGET_IDS.length);
expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT + nativeRows);
expect(chartSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT + nativeRows);
});
});
/**
* v1.4.47 W4 — drag-and-drop reorder surface.
*
* The audit (v1.4.43 QoL M1) asked for drag-to-reorder while keeping the
* arrow-button keyboard fallback. Tests pin (a) the visual surface
* (drag handle + describedby hint), (b) the keyboard fallback survived,
* (c) the reorder contract — same `widgets[]` shape with `order: 0..n-1`
* the existing PUT already accepts.
*/
describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
it("paints a drag handle for every widget row", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
});
it("each drag handle has an aria-describedby pointing to a shared hint", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// Split the markup into per-handle button fragments so we don't need
// to care about attribute ordering inside the rendered <button>.
const handleButtons =
html.match(/<button[^>]*data-slot="widget-drag-handle"[^>]*>/g) ?? [];
expect(handleButtons).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// Every handle declares aria-describedby and they all share the
// same id (one hint paragraph below the list — single source of
// truth for screen readers).
const describedByIds = handleButtons.map((button) => {
const m = button.match(/aria-describedby="([^"]+)"/);
expect(m).not.toBeNull();
return m![1];
});
const unique = new Set(describedByIds);
expect(unique.size).toBe(1);
const hintId = describedByIds[0];
// The matching hint paragraph is rendered exactly once.
expect(html).toMatch(new RegExp(`id="${hintId}"`));
});
it("keeps the arrow buttons present after the drag handle lands (a11y fallback)", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// Move up + Move down translation keys resolve once per widget row.
const expected = WEB_RENDERABLE_ROW_COUNT;
const moveUpCount = (html.match(/aria-label="Move up"/g) ?? []).length;
const moveDownCount = (html.match(/aria-label="Move down"/g) ?? []).length;
expect(moveUpCount).toBe(expected);
expect(moveDownCount).toBe(expected);
});
it("hint string localises to German", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
// v1.4.48 L9 — copy trimmed to ~12 words so screen readers do
// not spend ~6 s reading the hint on focus. Anchor on the
// shortened phrase.
expect(html).toContain("Pfeiltasten Hoch/Runter");
});
});
describe("reorderWidgets — pure mutation contract", () => {
// Synthetic small list so the assertions are readable; the helper is
// shape-only and doesn't care which ids are dashboard widgets.
const initial = [
{ id: "a", order: 0 },
{ id: "b", order: 1 },
{ id: "c", order: 2 },
{ id: "d", order: 3 },
];
it("moves an item down and rewrites order to 0..n-1", () => {
const out = reorderWidgets(initial, "a", "c");
expect(out.map((w) => w.id)).toEqual(["b", "c", "a", "d"]);
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
});
it("moves an item up and rewrites order to 0..n-1", () => {
const out = reorderWidgets(initial, "d", "a");
expect(out.map((w) => w.id)).toEqual(["d", "a", "b", "c"]);
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
});
it("no-op when source equals target", () => {
const out = reorderWidgets(initial, "b", "b");
expect(out.map((w) => w.id)).toEqual(["a", "b", "c", "d"]);
});
it("no-op when an id is missing — still normalises order", () => {
const out = reorderWidgets(initial, "missing", "a");
expect(out.map((w) => w.id)).toEqual(["a", "b", "c", "d"]);
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
});
it("preserves stable sort by `order` before reordering", () => {
const shuffled = [
{ id: "c", order: 2 },
{ id: "a", order: 0 },
{ id: "d", order: 3 },
{ id: "b", order: 1 },
];
const out = reorderWidgets(shuffled, "a", "d");
// Sort first → [a, b, c, d]; move a → d position → [b, c, d, a].
expect(out.map((w) => w.id)).toEqual(["b", "c", "d", "a"]);
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
});
it("returns a fresh array — does not mutate the input", () => {
const snapshot = JSON.parse(JSON.stringify(initial));
reorderWidgets(initial, "a", "c");
expect(initial).toEqual(snapshot);
});
});
/**
* v1.7.0 W1 — the stored layout round-trips the iOS-only widget ids. The
* web tile/chart LIST still has no surface for them (no drag handle, no
* reorder, no web render path), so the sortable list must skip them.
*
* issue #581 — but skipping them everywhere left the account holding rows
* it never set and could not change from any surface, so they now appear in
* a separate, clearly-labelled group with their visibility flags.
*/
describe("<DashboardLayoutSection> — iOS-only ids stay out of the web list (v1.7.0)", () => {
it("keeps iOS-only ids out of the sortable web list", () => {
// Inject a layout = the web defaults + every catalogue-only iOS id.
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
...DASHBOARD_IOS_ONLY_WIDGET_IDS.map((id, i) => ({
id,
visible: true,
tileVisible: true,
order: DEFAULT_DASHBOARD_LAYOUT.widgets.length + i,
})),
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The sortable list is exactly the web-renderable widgets: one drag
// handle each, and no more.
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// No iOS-only raw id leaks into the markup as a row label — every one
// of them resolves through a translation key.
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
expect(html).not.toContain(`>${iosId}<`);
}
});
it("does not crash when the layout is ENTIRELY iOS-only ids", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: DASHBOARD_IOS_ONLY_WIDGET_IDS.map((id, i) => ({
id,
visible: true,
tileVisible: true,
order: i,
})),
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// No web-renderable rows → no sortable list at all, but the section
// still renders and every id lands in the native-only group.
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(0);
const nativeRows = html.match(/data-slot="native-widget-row"/g) ?? [];
expect(nativeRows).toHaveLength(DASHBOARD_IOS_ONLY_WIDGET_IDS.length);
expect(html).toContain("dashboard-layout");
});
});
/**
* issue #581 — BMI rendered on the native dashboard and was configurable
* from nowhere: the widgets PUT accepts and persists it, the native client
* writes it, and the web Settings list filtered it out because the web has
* no render path for it. The account was left holding
* `{"id":"bmi","visible":false,…}` — a value it never set and could not
* change. The native-only group is where those flags become reachable.
*/
describe("<DashboardLayoutSection> — native-only widget group (issue #581)", () => {
const NATIVE_ONLY_IDS = [
...IOS_PIN_ONLY_WIDGET_IDS,
...DASHBOARD_IOS_ONLY_WIDGET_IDS,
];
it("offers a tile and a chart switch for BMI when the layout carries it", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
{
id: "bmi",
visible: false,
tileVisible: true,
order: DEFAULT_DASHBOARD_LAYOUT.widgets.length,
},
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-widget-id="bmi"');
expect(html).toContain('aria-label="BMI: Tile"');
expect(html).toContain('aria-label="BMI: Chart"');
// Reflects the stored flags rather than inventing a default.
const row = html.match(
/data-widget-id="bmi"[\s\S]*?(?=data-slot="native-widget-row"|$)/,
)![0];
expect(row).toContain('data-slot="widget-tile-switch"');
expect(row).toContain('data-slot="widget-chart-switch"');
// ...and it is NOT in the sortable web list (no drag handle for it).
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
});
it("labels the group and says which client draws these widgets", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
{ id: "bmi", visible: false, tileVisible: true, order: 99 },
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-slot="native-only-widgets"');
expect(html).toContain("Shown in the mobile app");
expect(html).toContain("The mobile app draws these");
});
it("shows nothing when the layout holds no native-only id", () => {
// The stock web default layout carries the pin-only writable ids but
// no catalogue-only ones; strip both so a pure web account is modelled.
const nativeOnly = new Set<string>(NATIVE_ONLY_IDS);
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
(w) => !nativeOnly.has(w.id),
),
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).not.toContain('data-slot="native-only-widgets"');
expect(html).not.toContain("Shown in the mobile app");
});
it("every native-only id has a label key, so no row can render raw", () => {
for (const id of NATIVE_ONLY_IDS) {
expect(
NATIVE_ONLY_WIDGET_LABEL_KEYS[id],
`${id} has no label key — its row would render its raw id`,
).toBeTruthy();
}
});
it("hides a native-only row whose owning module is off", () => {
authState.modules = { recovery: false };
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
widgets: [
...DEFAULT_DASHBOARD_LAYOUT.widgets,
{ id: "bmi", visible: false, tileVisible: true, order: 99 },
],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// `cardioRecovery` belongs to the recovery module and drops out; BMI
// belongs to none and stays.
expect(html).not.toContain('data-widget-id="cardioRecovery"');
expect(html).toContain('data-widget-id="bmi"');
authState.modules = undefined;
});
});
/**
* v1.11.2 HIGH-1 — the 8 B5 ids are WRITABLE (in DASHBOARD_WIDGET_IDS so the
* iOS pin PUT validates them) but have NO web render path, so the web
* Settings list must NOT offer a dead toggle for them.
*/
describe("<DashboardLayoutSection> — iOS-pin-only ids hidden from web (v1.11.2)", () => {
it("renders one fewer sortable row per iOS-pin-only id than the writable id count", () => {
// Default layout carries all writable widgets incl. the pin-only ones.
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
// Sanity: WEB_RENDERABLE_ROW_COUNT == writable − pin-only.
expect(WEB_RENDERABLE_ROW_COUNT).toBe(
DASHBOARD_WIDGET_IDS.length - IOS_PIN_ONLY_WIDGET_IDS.length,
);
});
it("paints the pin-only labels ONLY in the native-only group", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The pin-only ids reuse `measurements.type*` labels that resolve to
// distinctive English strings; none should appear as a toggle aria-label.
const pinOnlyLabels: Record<
(typeof IOS_PIN_ONLY_WIDGET_IDS)[number],
string
> = {
cardioRecovery: "Cardio recovery",
sixMinuteWalk: "Six-minute walk distance",
stairAscentSpeed: "Stair ascent speed",
stairDescentSpeed: "Stair descent speed",
breathingDisturbances: "Breathing disturbances",
// v1.28.52 — `wristTemperature` is no longer pin-only; it now renders
// a web row, so it left both IOS_PIN_ONLY_WIDGET_IDS and this map.
falls: "Falls",
walkingSteadiness: "Walking steadiness",
};
// The sortable web list must not offer them (issue #581 note: the
// native-only group below it does, which is where the markup match
// now comes from — so anchor on the sortable rows themselves).
const sortableList = html.slice(
0,
html.indexOf('data-slot="native-only-widgets"'),
);
for (const id of IOS_PIN_ONLY_WIDGET_IDS) {
expect(sortableList).not.toContain(`${pinOnlyLabels[id]}: `);
// ...and each is reachable exactly once, in the native-only group.
expect(html).toContain(`data-widget-id="${id}"`);
}
});
});
/**
* v1.18.0 — a dashboard widget toggle whose owning module is disabled is a
* dead control: the snapshot gates the tile/chart out server-side no matter
* what the switch says. The Settings list must hide those rows. Core widgets
* (no module entry in `WIDGET_MODULE_BY_ID`) always show; the gate fails open
* when the module map is absent or the key is unset.
*/
describe("<DashboardLayoutSection> — disabled-module widget toggles", () => {
// The achievements widget label drives its switch aria-label; it is the
// canonical disabled-module probe (label resolves to "Achievements").
const ACHIEVEMENTS_ARIA = "Achievements: ";
// Mood is a module-owned widget too; weight is a core widget with NO module
// entry, so it must survive any module-off state.
const MOOD_ARIA = "Mood: ";
const WEIGHT_ARIA = "Weight: ";
it("hides a widget toggle whose owning module is disabled", () => {
authState.modules = { achievements: false };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// The achievements row (and its switches) are gone…
expect(html).not.toContain(ACHIEVEMENTS_ARIA);
// …and exactly one fewer sortable row renders.
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT - 1);
});
it("keeps an enabled-module widget and core (no-module) widgets shown", () => {
authState.modules = { achievements: false };
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
// Enabled module (mood) still renders…
expect(html).toContain(MOOD_ARIA);
// …and a core widget with no module entry (weight) always shows.
expect(html).toContain(WEIGHT_ARIA);
});
it("shows every row when the module map is absent (fail-open)", () => {
authState.modules = undefined;
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
expect(html).toContain(ACHIEVEMENTS_ARIA);
});
});
describe("<DashboardLayoutSection> — hero content", () => {
it("keeps the removed score and comparison controls out of the UI", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
for (const removedLabel of [
"Compare to",
"Last month",
"Last year",
"Readiness",
"Recovery score",
"Sleep score",
]) {
expect(html).not.toContain(removedLabel);
}
});
it("renders one switch for every current priority-item kind", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain('data-slot="hero-content-settings"');
const switches = html.match(/data-slot="hero-item-switch"/g) ?? [];
expect(switches).toHaveLength(PRIORITY_ITEM_KINDS.length);
for (const kind of PRIORITY_ITEM_KINDS) {
const control = html.match(
new RegExp(
`<button[^>]*data-slot="hero-item-switch"[^>]*data-kind="${kind}"[^>]*>`,
),
);
expect(control, kind).not.toBeNull();
expect(control![0]).toContain('data-state="checked"');
}
});
it("treats a missing API field as every current kind enabled", () => {
const layoutWithoutHeroKinds: DashboardLayout = {
...DEFAULT_DASHBOARD_LAYOUT,
};
delete layoutWithoutHeroKinds.enabledHeroItemKinds;
queryState.layout = layoutWithoutHeroKinds;
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches =
html.match(/<button[^>]*data-slot="hero-item-switch"[^>]*>/g) ?? [];
expect(switches).toHaveLength(PRIORITY_ITEM_KINDS.length);
for (const control of switches) {
expect(control).toContain('data-state="checked"');
}
});
it("permits an explicit all-off layout", () => {
queryState.layout = {
...DEFAULT_DASHBOARD_LAYOUT,
enabledHeroItemKinds: [],
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(
/<button[^>]*data-slot="hero-item-switch"[^>]*>/g,
);
expect(switches).toHaveLength(PRIORITY_ITEM_KINDS.length);
for (const control of switches ?? []) {
expect(control).toContain('data-state="unchecked"');
expect(control).not.toContain('disabled=""');
}
expect(html).toContain("Custom layout active");
});
it("switches off the last enabled kind and persists an explicit empty array", () => {
const lastEnabled = ["coach_checkin"] as const;
const enabledHeroItemKinds = setHeroItemKindEnabled(
lastEnabled,
"coach_checkin",
false,
);
expect(enabledHeroItemKinds).toEqual([]);
expect(lastEnabled).toEqual(["coach_checkin"]);
const serialized = serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
enabledHeroItemKinds,
});
expect(serialized.enabledHeroItemKinds).toEqual([]);
});
it("labels the switch collection and associates both explanatory texts", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const fieldset = html.match(
/<fieldset[^>]*data-slot="hero-content-settings"[^>]*>/,
);
expect(fieldset).not.toBeNull();
expect(fieldset![0]).toContain("rounded-lg");
const describedBy = fieldset![0].match(/aria-describedby="([^"]+)"/);
expect(describedBy).not.toBeNull();
const descriptionIds = describedBy![1].split(" ");
expect(descriptionIds).toHaveLength(2);
for (const descriptionId of descriptionIds) {
expect(html).toContain(`id="${descriptionId}"`);
}
expect(html).toContain("<legend");
expect(html).toContain("Today highlights");
});
it("keeps every kind visible when unrelated modules are disabled", () => {
authState.modules = {
coach: false,
medications: false,
insights: false,
};
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
const switches = html.match(/data-slot="hero-item-switch"/g) ?? [];
expect(switches).toHaveLength(PRIORITY_ITEM_KINDS.length);
});
it("renders localized Today-highlight labels", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
expect(html).toContain("Highlights für heute");
expect(html).toContain("Medikamentendosen");
expect(html).toContain("Neue EKG-Aufzeichnungen");
});
it("states that notification settings are managed separately", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
expect(html).toContain("Notification settings are managed separately.");
});
});