Files
HealthLog/src/lib/modules/__tests__/onboarding-needs-map.test.ts
T
Marc-André Bombeck 2d2350252f fix(onboarding): never switch a module off on a record that already uses it
The derivation wrote an explicit off for every module the answers did not
name, which is right for a record being set up and wrong for one with years of
rows behind it. An established self-hoster has, for most modules, no stored
preference at all — they were default-on and nobody ever touched the toggle —
so the first confirm took away the navigation entry, the dashboard widget and
the settings entry for domains full of content. The rows survived; the
surfaces did not.

A derived off now asks first, and only for the modules it would actually
retract. Ownership is not re-answered: the measurement domains come from the
same map the MCP reads and the coach snapshot gates off, and each remaining
module names the one table its content lives in, exhaustively, so a module
added later fails to compile until somebody says where its content is. A
module whose domain holds rows keeps whatever the record already had, because
absence is what "on" looks like in the stored map.
2026-09-10 11:48:27 +02:00

269 lines
9.1 KiB
TypeScript

/**
* v1.39 (C1) — the needs → modules mapping, and the merge that protects a
* decision somebody already made.
*
* The mapping is the one piece of the flow with a consequence that outlives
* the screens: a module switched off here disappears from the navigation until
* somebody finds it again in Settings. So the properties below are stated as
* properties over the whole registry rather than as a table of examples — a
* module key added to `MODULE_KEYS` next year is covered by them on the day it
* lands, and an area that stops naming anything fails here rather than
* shipping as a chip with no effect.
*/
import { describe, expect, it } from "vitest";
import {
deriveOnboardingModuleDefaults,
isModuleKey,
mergeDerivedModulePreferences,
MODULE_REGISTRY,
ONBOARDING_ALWAYS_ON_MODULES,
ONBOARDING_AREA_KEYS,
ONBOARDING_AREA_MODULES,
OWNED_MODULE_KEYS,
type OnboardingModuleNeeds,
} from "../registry";
/** A record with nothing in it — the state a first setup describes. */
const NO_DATA: ReadonlySet<string> = new Set();
function needs(
overrides: Partial<OnboardingModuleNeeds> = {},
): OnboardingModuleNeeds {
return {
recordTarget: "me",
areas: [],
medication: "no",
visit: "no",
...overrides,
};
}
describe("the area → module map", () => {
it("gives every area at least one module", () => {
expect(ONBOARDING_AREA_KEYS.length).toBe(9);
for (const area of ONBOARDING_AREA_KEYS) {
expect(
ONBOARDING_AREA_MODULES[area]?.length ?? 0,
`area \`${area}\` names no module, so ticking it changes nothing`,
).toBeGreaterThan(0);
}
});
it("names only real module keys", () => {
for (const area of ONBOARDING_AREA_KEYS) {
for (const key of ONBOARDING_AREA_MODULES[area]) {
expect(isModuleKey(key), `\`${key}\` is not a module key`).toBe(true);
}
}
});
it("never names the Coach, which the flow deliberately does not offer", () => {
const named = ONBOARDING_AREA_KEYS.flatMap(
(area) => ONBOARDING_AREA_MODULES[area],
);
expect(named).not.toContain("coach");
});
});
describe("deriveOnboardingModuleDefaults", () => {
it("answers for every directly-owned module and for none of the delegated ones", () => {
const derived = deriveOnboardingModuleDefaults(needs());
expect(Object.keys(derived.preferences).sort()).toEqual(
[...OWNED_MODULE_KEYS].sort(),
);
expect(derived.preferences).not.toHaveProperty("cycle");
expect(derived.preferences).not.toHaveProperty("coach");
for (const key of OWNED_MODULE_KEYS) {
expect(MODULE_REGISTRY[key].delegatesTo).toBeUndefined();
}
});
it("keeps the always-on modules on however the questions are answered", () => {
const answers: OnboardingModuleNeeds[] = [
needs(),
needs({ areas: [...ONBOARDING_AREA_KEYS], medication: "yes" }),
needs({ recordTarget: "someone-else", visit: "within-a-month" }),
needs({ recordTarget: "both", medication: "sometimes", visit: "later" }),
];
for (const answer of answers) {
const { preferences } = deriveOnboardingModuleDefaults(answer);
for (const key of ONBOARDING_ALWAYS_ON_MODULES) {
expect(
preferences[key as keyof typeof preferences],
`always-on module \`${key}\` was derived off`,
).toBe(true);
}
}
});
it("switches on exactly what the chosen areas name", () => {
const { preferences, cycleTracking } = deriveOnboardingModuleDefaults(
needs({ areas: ["glucose", "sleep", "mood"] }),
);
expect(preferences.glucose).toBe(true);
expect(preferences.sleep).toBe(true);
expect(preferences.recovery).toBe(true);
expect(preferences.mood).toBe(true);
expect(preferences.mentalHealth).toBe(true);
expect(preferences.labs).toBe(false);
expect(preferences.workouts).toBe(false);
expect(preferences.illness).toBe(false);
expect(cycleTracking).toBe(false);
});
it("reports the cycle area separately, because the module blob does not own it", () => {
expect(deriveOnboardingModuleDefaults(needs({ areas: ["cycle"] }))).toEqual(
expect.objectContaining({ cycleTracking: true }),
);
expect(deriveOnboardingModuleDefaults(needs()).cycleTracking).toBe(false);
});
it("follows the medication answer", () => {
for (const answer of ["yes", "sometimes"] as const) {
expect(
deriveOnboardingModuleDefaults(needs({ medication: answer }))
.preferences.medications,
).toBe(true);
}
expect(
deriveOnboardingModuleDefaults(needs({ medication: "no" })).preferences
.medications,
).toBe(false);
});
it("switches the doctor report on for any visit that is actually coming", () => {
for (const visit of ["within-a-month", "later"] as const) {
expect(
deriveOnboardingModuleDefaults(needs({ visit })).preferences
.doctorReport,
).toBe(true);
}
expect(
deriveOnboardingModuleDefaults(needs({ visit: "no" })).preferences
.doctorReport,
).toBe(false);
});
it("switches the immunization log on for a record somebody else runs", () => {
for (const recordTarget of ["someone-else", "both"] as const) {
expect(
deriveOnboardingModuleDefaults(needs({ recordTarget })).preferences
.vaccinations,
).toBe(true);
}
expect(
deriveOnboardingModuleDefaults(needs({ recordTarget: "me" })).preferences
.vaccinations,
).toBe(false);
});
it("leaves the opt-in surfaces off unless something asks for them", () => {
const { preferences } = deriveOnboardingModuleDefaults(
needs({ areas: [...ONBOARDING_AREA_KEYS] }),
);
expect(preferences.mcp).toBe(false);
expect(preferences.nutrients).toBe(false);
expect(preferences.environment).toBe(false);
});
});
describe("mergeDerivedModulePreferences", () => {
it("never turns off a module the person switched on by hand", () => {
const { preferences } = deriveOnboardingModuleDefaults(needs());
expect(preferences.mcp).toBe(false);
expect(preferences.labs).toBe(false);
const merged = mergeDerivedModulePreferences(
{ mcp: true, labs: true },
preferences,
NO_DATA,
);
expect(merged.mcp).toBe(true);
expect(merged.labs).toBe(true);
});
it("does turn off a module the record only had by default", () => {
const { preferences } = deriveOnboardingModuleDefaults(needs());
const merged = mergeDerivedModulePreferences({}, preferences, NO_DATA);
expect(merged.labs).toBe(false);
expect(merged.workouts).toBe(false);
});
it("switches on what the answers named even where the record had it off", () => {
const { preferences } = deriveOnboardingModuleDefaults(
needs({ areas: ["labs"] }),
);
const merged = mergeDerivedModulePreferences(
{ labs: false },
preferences,
NO_DATA,
);
expect(merged.labs).toBe(true);
});
it("drops a stored key that is not a module", () => {
const { preferences } = deriveOnboardingModuleDefaults(needs());
const merged = mergeDerivedModulePreferences(
{ weight: false, nonsense: true },
preferences,
NO_DATA,
);
expect(merged).not.toHaveProperty("weight");
expect(merged).not.toHaveProperty("nonsense");
});
it("never switches off a domain the record already holds rows in", () => {
const { preferences } = deriveOnboardingModuleDefaults(needs());
expect(preferences.labs).toBe(false);
expect(preferences.mood).toBe(false);
// An established record with no stored preference at all: default-on, and
// nobody ever touched the toggle. A derived `false` would take the nav
// entry, the dashboard widget and the settings entry away from a domain
// holding years of rows, so the key is left exactly as it was — and
// absence means on.
const merged = mergeDerivedModulePreferences(
{},
preferences,
new Set(["labs", "mood"]),
);
expect(merged).not.toHaveProperty("labs");
expect(merged).not.toHaveProperty("mood");
expect(merged.workouts).toBe(false);
});
it("keeps a stored explicit off where the domain holds rows", () => {
const { preferences } = deriveOnboardingModuleDefaults(needs());
// Somebody switched labs off by hand and still has old results. The flow
// must not switch it back on, and must not restate the decision either.
const merged = mergeDerivedModulePreferences(
{ labs: false },
preferences,
new Set(["labs"]),
);
expect(merged.labs).toBe(false);
});
it("still switches a domain ON when the answers name it, rows or not", () => {
const { preferences } = deriveOnboardingModuleDefaults(
needs({ areas: ["labs"] }),
);
const merged = mergeDerivedModulePreferences(
{},
preferences,
new Set(["labs"]),
);
expect(merged.labs).toBe(true);
});
it("leaves the two delegated keys alone in both directions", () => {
const { preferences } = deriveOnboardingModuleDefaults(
needs({ areas: ["cycle"] }),
);
const merged = mergeDerivedModulePreferences({}, preferences, NO_DATA);
expect(merged).not.toHaveProperty("cycle");
expect(merged).not.toHaveProperty("coach");
});
});