mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
8ae0f75ecd
commit
2594045bef
3 files changed
+326
-7
No files matched your search
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user