test(modules): hold every surface list against the surface map

Each family of ids must name a surface that exists (nav hrefs, sub-page
directories, section ids, widget ids, derived ids, Settings slugs,
correlation channels). Sub-pages, trend slots and correlation channels
must follow the owner of the types they plot, joined against the
dashboard's summary owners, and a pinned table covers the owners no
join can derive. The replaced per-surface keys and the redirecting page
guard are searched for in source, and the derived route's gate is
compared with the map entry by entry.

Broken on purpose before committing: dropping the sleep sub-page entry,
the mood trend entry or a nav entry, drifting a derived owner, and
reintroducing a per-destination key each fail by name.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:56:50 +02:00
1 parent 8ae0f75ecd
commit 2594045bef
3 files changed
+326 -7

No files matched your search

+1 -1
View File
@@ -84,7 +84,7 @@ export default async function SettingsLayoutModulePage({ params }: PageProps) {
>
<RecordSettingsSectionGate section="layout">
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleGate={layoutGroupModule(group.id)}>
<LayoutModuleGate moduleKey={layoutGroupModule(group.id)}>
<Body />
</LayoutModuleGate>
</section>
@@ -23,13 +23,13 @@ import type { ModuleKey } from "@/lib/modules/registry";
* diverges the initial render and cannot trigger a React #418 mismatch.
*
* Groups the surface map gives no owner (dashboard / insights / vorsorge)
* pass `moduleGate={undefined}` and always render.
* pass `moduleKey={undefined}` and always render.
*/
export function LayoutModuleGate({
moduleGate,
moduleKey,
children,
}: {
moduleGate?: ModuleKey;
moduleKey?: ModuleKey;
children: React.ReactNode;
}) {
const hydrated = useMounted();
@@ -37,9 +37,7 @@ export function LayoutModuleGate({
const router = useRouter();
const disabled =
hydrated &&
moduleGate !== undefined &&
user?.modules?.[moduleGate] === false;
hydrated && moduleKey !== undefined && user?.modules?.[moduleKey] === false;
React.useEffect(() => {
if (disabled) {
@@ -0,0 +1,321 @@
/**
* Structural guard on the surface map (`src/lib/modules/surface.ts`).
*
* The map is the one answer to "which module owns this surface". It only
* works if two things stay true, and each half of this file holds one:
*
* 1. Every id in the map names a surface that exists. A nav href, a slug, a
* section id, a widget id that was renamed would leave an entry that
* matches nothing, and the surface it meant would silently stop hiding.
* Each family is checked against the list the app actually renders from.
*
* 2. Every surface that shows a module's data is owned by that module. This
* half cannot come from the map itself, so it is derived from a second
* source: the measurement types each surface plots, joined against the
* summary owners the dashboard already gates on. A sleep page with no
* sleep entry, or a trends slot charting a recovery-owned series with no
* owner, fails here by name. A short table of owners that no data join
* can derive (nav pages, the add menu, overview blocks) is pinned below.
*
* Plus the retirement half: the per-surface maps and guard hooks this map
* replaced must not come back, so their names are searched for in source.
*
* Limits, stated so nobody reads more into a green run: the data join only
* sees surfaces that declare the types they plot, so a surface that reads a
* module's store without declaring a type would pass. The source search
* matches names, not behaviour; a copy under a new name passes. Every match
* set asserts a non-zero count so a matcher that stopped matching fails.
*/
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
// The capture picker and the Settings shell pull in forms and sections; the
// guard needs only their exported lists.
vi.mock("@/components/measurements/measurement-form", () => ({
MeasurementForm: () => null,
}));
vi.mock("@/components/mood/mood-form", () => ({ MoodForm: () => null }));
vi.mock("@/components/dashboard/medication-intake-quick-add", () => ({
MedicationIntakeQuickAdd: () => null,
}));
import {
walkSourceFiles,
stripComments,
} from "@/__tests__/helpers/source-files";
import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
import { CAPTURE_KIND_ORDER } from "@/components/layout/capture-picker";
import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups";
import { DASHBOARD_WIDGET_CATALOGUE_IDS } from "@/lib/dashboard-layout";
import { SUMMARY_TYPE_MODULE } from "@/lib/dashboard/widget-modules";
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
import {
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
FACTOR_CHANNEL_PREFIX,
} from "@/lib/insights/correlation-discovery";
import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry";
import { INSIGHTS_SECTION_IDS } from "@/lib/insights-layout";
import {
SUB_PAGE_SLUGS,
TYPE_TO_SUB_PAGE_SLUG,
} from "@/lib/insights/sub-page-metric";
import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select";
import {
SURFACE_KINDS,
SURFACE_MODULE,
surfaceModule,
surfaceModulesOfKind,
type SurfaceKind,
} from "@/lib/modules/surface";
const SRC = join(process.cwd(), "src");
function localIds(kind: SurfaceKind): string[] {
const ids = Object.keys(surfaceModulesOfKind(kind));
return ids;
}
describe("every surface id names a surface that exists", () => {
it("covers every kind with at least one entry", () => {
for (const kind of SURFACE_KINDS) {
expect(localIds(kind).length, kind).toBeGreaterThan(0);
}
});
it("nav: a destination href", () => {
const hrefs = new Set(NAV_DESTINATIONS.map((d) => d.href));
for (const id of localIds("nav")) expect(hrefs.has(id), id).toBe(true);
});
it("insights-page: a routed Insights sub-page", () => {
for (const slug of localIds("insights-page")) {
expect(
existsSync(join(SRC, "app/insights", slug)),
`src/app/insights/${slug}`,
).toBe(true);
}
});
it("overview: an overview section id, or the cycle ring inside the scores strip", () => {
const ids = new Set<string>([...INSIGHTS_SECTION_IDS, "cycle-ring"]);
for (const id of localIds("overview")) expect(ids.has(id), id).toBe(true);
});
it("trend: a trends-row slot that charts something", () => {
for (const id of localIds("trend")) {
expect(
TREND_CHART_CONFIG[id as keyof typeof TREND_CHART_CONFIG],
id,
).toBeTruthy();
}
});
it("capture: an add-menu kind", () => {
const kinds = new Set<string>(CAPTURE_KIND_ORDER);
for (const id of localIds("capture")) expect(kinds.has(id), id).toBe(true);
});
it("widget: a dashboard widget id", () => {
const ids = new Set<string>(DASHBOARD_WIDGET_CATALOGUE_IDS);
for (const id of localIds("widget")) expect(ids.has(id), id).toBe(true);
});
it("derived: a derived metric id", () => {
const ids = new Set<string>(DERIVED_METRIC_IDS);
for (const id of localIds("derived")) expect(ids.has(id), id).toBe(true);
});
it("settings: a Settings section slug", () => {
const slugs = new Set<string>(SETTINGS_SECTIONS.map((s) => s.slug));
for (const id of localIds("settings")) expect(slugs.has(id), id).toBe(true);
});
it("settings-layout: a Layout group id", () => {
const ids = new Set<string>(LAYOUT_GROUP_IDS);
for (const id of localIds("settings-layout")) {
expect(ids.has(id), id).toBe(true);
}
});
it("correlation: a discovery channel, a weather field or the lab pass", () => {
const channels = new Set<string>([
...DISCOVERY_BEHAVIOURS,
...DISCOVERY_OUTCOMES,
...ENVIRONMENT_FIELDS.map((f) => f.key),
"LAB_DRAWS",
]);
for (const id of localIds("correlation")) {
expect(channels.has(id), id).toBe(true);
}
// `correlationChannelSurfaceId` folds rated factors onto mood by this
// prefix; it has to be the prefix the engine actually writes.
expect(FACTOR_CHANNEL_PREFIX).toBe("FACTOR:");
});
});
describe("every surface that shows a module's data is owned by it", () => {
/** Summary owners are the dashboard's own gate; the join source of truth. */
const ownerOfType = (type: string) => SUMMARY_TYPE_MODULE[type];
/**
* Sub-pages a module-owned type routes to but that are not that module's.
* Each carries its reason; an entry here is a decision, not a silence.
*/
const NOT_OWNED_DESPITE_TYPE: Record<string, string> = {
// The HRV page leads with SDNN, a plain vital. RMSSD is recovery-owned
// and routes here only as the multi-metric fallback.
hrv: "SDNN page; RMSSD is only its fallback series",
};
it("Insights sub-pages follow the owner of the types they plot", () => {
let joined = 0;
for (const [type, slug] of Object.entries(TYPE_TO_SUB_PAGE_SLUG)) {
const owner = ownerOfType(type);
if (!owner || slug in NOT_OWNED_DESPITE_TYPE) continue;
joined += 1;
expect(
surfaceModule(`insights-page:${slug}`),
`${slug} plots ${type}, which ${owner} owns`,
).toBe(owner);
}
expect(joined).toBeGreaterThan(0);
});
it("trends-row slots follow the owner of the series they chart", () => {
let joined = 0;
for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) {
if (!config) continue;
const owners = new Set(
config.types.map(ownerOfType).filter((o) => o !== undefined),
);
if (config.kind === "mood") owners.add("mood");
if (owners.size === 0) {
expect(surfaceModule(`trend:${metric}`), metric).toBeUndefined();
continue;
}
joined += 1;
expect(owners.size, metric).toBe(1);
expect(surfaceModule(`trend:${metric}`), metric).toBe([...owners][0]);
}
expect(joined).toBeGreaterThan(0);
});
it("correlation channels that are summary types follow the summary owner", () => {
let joined = 0;
for (const key of [...DISCOVERY_BEHAVIOURS, ...DISCOVERY_OUTCOMES]) {
const owner = ownerOfType(key);
if (!owner) continue;
joined += 1;
expect(surfaceModule(`correlation:${key}`), key).toBe(owner);
}
expect(joined).toBeGreaterThan(0);
});
it("every weather channel belongs to the environment module", () => {
for (const field of ENVIRONMENT_FIELDS) {
expect(surfaceModule(`correlation:${field.key}`), field.key).toBe(
"environment",
);
}
});
// Owners no data join can derive. Pinned by hand; each is a surface the
// audit found leaking when its module was off.
it.each([
["nav:/mood", "mood"],
["nav:/cycle", "cycle"],
["nav:/medications", "medications"],
["nav:/labs", "labs"],
["nav:/coach", "coach"],
["insights-page:mood", "mood"],
["insights-page:medications", "medications"],
["insights-page:workouts", "workouts"],
["insights-page:recovery", "recovery"],
["insights-page:nutrients", "nutrients"],
["overview:breathing", "sleep"],
["overview:labs-changes", "labs"],
["overview:cycle-summary", "cycle"],
["overview:cycle-ring", "cycle"],
["capture:mood", "mood"],
["capture:medication", "medications"],
["trend:mood", "mood"],
["widget:medications", "medications"],
["correlation:MEDICATION_COMPLIANCE", "medications"],
["correlation:SYMPTOM_SEVERITY", "illness"],
["correlation:LAB_DRAWS", "labs"],
])("%s is owned by %s", (id, owner) => {
expect(surfaceModule(id)).toBe(owner);
});
it("the insights key owns nothing (it is AI analysis, not an area)", () => {
expect(Object.values(SURFACE_MODULE)).not.toContain("insights");
});
it("the tab strip covers every sub-page slug it can render", () => {
// Every owned sub-page that is a strip slug is reachable by the strip's
// lookup; recovery is the one composite pill without a slug.
const slugs = new Set<string>(SUB_PAGE_SLUGS);
for (const id of localIds("insights-page")) {
expect(slugs.has(id) || id === "recovery", id).toBe(true);
}
});
});
describe("the replaced maps and guards stay gone", () => {
const files = walkSourceFiles(SRC, { floor: 1000 }).filter(
(rel) =>
!rel.startsWith("generated/") &&
!rel.includes("__tests__/") &&
!/\.test\.tsx?$/.test(rel),
);
const sources = files.map((rel) => ({
rel,
code: stripComments(readFileSync(join(SRC, rel), "utf8")),
}));
it.each([
["a per-destination module key", /\brequiresModule\s*[?:]/],
["a per-section module key", /\bmoduleGate\s*\?\s*:|\bmoduleGate\s*:\s*"/],
["the tab strip's own slug map", /\bSUB_PAGE_MODULE\b/],
["the redirecting page guard", /\buseModulePageGuard\b/],
])("no source declares %s", (_label, pattern) => {
const hits = sources.filter((s) => pattern.test(s.code)).map((s) => s.rel);
expect(hits).toEqual([]);
});
it("the dashboard maps are views, built from the surface map and nowhere else", () => {
const widgetModules = sources.find(
(s) => s.rel === "lib/dashboard/widget-modules.ts",
);
expect(widgetModules).toBeDefined();
expect(widgetModules!.code).toMatch(
/WIDGET_MODULE_BY_ID[^=]*=\s*surfaceModulesOfKind\(\s*"widget"\s*\)/,
);
expect(widgetModules!.code).toMatch(
/SUMMARY_TYPE_MODULE[^=]*=\s*surfaceModulesOfKind\(\s*"summary"\s*\)/,
);
});
it("the derived-score gate agrees with the surface map", () => {
// `DERIVED_MODULE` lives in the derived route, which this guard reads as
// text rather than importing (it pulls the whole server stack). Parsed
// entry by entry and compared, so a drift in either direction fails.
const route = readFileSync(
join(SRC, "app/api/insights/derived/route.ts"),
"utf8",
);
const body = /DERIVED_MODULE[^=]*=\s*\{([\s\S]*?)\}/.exec(route)?.[1];
expect(body).toBeDefined();
const entries = Object.fromEntries(
[...body!.matchAll(/([A-Z_]+)\s*:\s*"([A-Za-z]+)"/g)].map((m) => [
m[1],
m[2],
]),
);
expect(Object.keys(entries).length).toBeGreaterThan(0);
expect(entries).toEqual(surfaceModulesOfKind("derived"));
});
});