Files
HealthLog/src/lib/mcp/__tests__/nutrients-read.test.ts
T
Marc-André Bombeck bec4cb3dfb fix(mcp): let five of the six shipped languages name a metric
Three resolvers turned a user-authored word into an internal code by
comparing it against a hand-typed English list. The app ships six
languages, so five of them resolved nothing: `Fer` and `Żelazo` came back
as `unknown_nutrient` while the settings card rendered exactly those
words, and `poids` and `sommeil` reached no series at all.

Stop transcribing. `nutrients.names.*` and `measurements.type*` already
label these concepts in every bundle, so the match index is derived from
`locales` instead — a seventh language works the day its bundle lands,
with no edit at any call site. The nutrient tool's English labels now come
out of `messages/en.json` too, so the wire wording cannot drift from the
screen.

The MCP contract is unchanged in shape and stays protocol-level English.
What moves is which words are understood: `unknown_nutrient` /
`unknown_metric` still mean "could not place this word", distinct from a
placed name answering `no_data`. Nothing turns absence into data.

Two findings the sweep did not predict:

- `Puls` resolved to pulse-wave velocity, because the English
  display-name pass matches substrings and "pulse-wave velocity" contains
  it. An exact name now beats a loose English fragment.
- French and Italian use one phrase for body fat and fat mass, and three
  bundles use one for fat-free mass and lean body mass. A word two
  metrics claim resolves to neither; answering kilograms to someone who
  asked for a percentage is worse than saying the word was not
  recognised. The known set is pinned so a new collision surfaces.

The medication needles are the exception: they are drug names, with no
bundle to derive from. They fold to a normalised INN stem rather than to
a language, so `Metformin`, `Metformine`, `Metformina` and `Metforminum`
are one entry, and `Semaglutyd` and `Atorwastatyna` cost nothing to add.
A guard asserts no two molecules collapse onto one stem. Iron is the one
class with no stem to fold to — it is named for the element — so its word
list is hand-tabulated and says so.

Every English resolution that worked before returns the same answer.
2026-08-23 14:46:10 +02:00

258 lines
9.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
const { nutrientIntakeDay, user } = vi.hoisted(() => ({
nutrientIntakeDay: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
}));
vi.mock("@/lib/db", () => ({ prisma: { nutrientIntakeDay, user } }));
import {
getNutrients,
resolveNutrientCode,
NUTRIENT_LABELS,
} from "../nutrients-read";
import type {
NutrientsOverviewResult,
NutrientsDailyResult,
} from "../nutrients-read";
import { isModuleEnabled } from "@/lib/modules/gate";
import { locales } from "@/lib/i18n/config";
import { labelIn } from "@/lib/i18n/localised-label-index";
import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
/** Narrow the union to the overview shape for a test's own assertions. */
function overview(
r: NutrientsOverviewResult | NutrientsDailyResult,
): NutrientsOverviewResult {
return r as NutrientsOverviewResult;
}
/** Narrow the union to the per-nutrient shape for a test's own assertions. */
function daily(
r: NutrientsOverviewResult | NutrientsDailyResult,
): NutrientsDailyResult {
return r as NutrientsDailyResult;
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(user.findUnique).mockResolvedValue({
timezone: "UTC",
gender: null,
} as never);
vi.mocked(nutrientIntakeDay.findMany).mockResolvedValue([] as never);
});
describe("resolveNutrientCode", () => {
it("resolves the exact catalog code", () => {
expect(resolveNutrientCode("water")).toBe("water");
expect(resolveNutrientCode("vitamin_d")).toBe("vitamin_d");
});
it("folds spaces/hyphens and case", () => {
expect(resolveNutrientCode("Vitamin D")).toBe("vitamin_d");
expect(resolveNutrientCode("vitamin-d")).toBe("vitamin_d");
expect(resolveNutrientCode("MAGNESIUM")).toBe("magnesium");
});
it("matches a display label (e.g. 'Vitamin B12')", () => {
expect(resolveNutrientCode("Vitamin B12")).toBe("vitamin_b12");
});
it("returns null for an unknown name (never invents a code)", () => {
expect(resolveNutrientCode("bananas")).toBeNull();
expect(resolveNutrientCode("")).toBeNull();
});
});
describe("resolveNutrientCode — every shipped locale, not just English", () => {
// `nutrients.names.*` already ships in all six bundles and is what the
// settings card renders. The resolver used to compare against a hand-typed
// English copy of that bundle, so an account whose language is French or
// Polish got `unknown_nutrient` for the word its own screen shows.
it.each([
["de", "Eisen", "iron"],
["es", "Hierro", "iron"],
["fr", "Fer", "iron"],
["it", "Ferro", "iron"],
["pl", "Żelazo", "iron"],
["de", "Wasser", "water"],
["es", "Agua", "water"],
["fr", "Eau", "water"],
["it", "Acqua", "water"],
["pl", "Woda", "water"],
["fr", "Magnésium", "magnesium"],
["pl", "Wapń", "calcium"],
["de", "Koffein", "caffeine"],
["it", "Iodio", "iodine"],
["pl", "Kwas pantotenowy (B5)", "pantothenic_acid"],
["fr", "Acide pantothénique (B5)", "pantothenic_acid"],
] as const)("resolves the %s name %s", (_locale, name, code) => {
expect(resolveNutrientCode(name)).toBe(code);
});
it("folds case, accents and separators on the localised names too", () => {
expect(resolveNutrientCode(" magnésium ")).toBe("magnesium");
expect(resolveNutrientCode("MAGNESIO")).toBe("magnesium");
expect(resolveNutrientCode("zelazo")).toBe("iron");
});
it("keeps every English spelling that resolved before resolving the same", () => {
// The pre-fix English arms: exact code, space/hyphen folding, and a
// display-label match. None of them may move.
expect(resolveNutrientCode("water")).toBe("water");
expect(resolveNutrientCode("vitamin_d")).toBe("vitamin_d");
expect(resolveNutrientCode("Vitamin D")).toBe("vitamin_d");
expect(resolveNutrientCode("vitamin-d")).toBe("vitamin_d");
expect(resolveNutrientCode("MAGNESIUM")).toBe("magnesium");
expect(resolveNutrientCode("Vitamin B12")).toBe("vitamin_b12");
expect(resolveNutrientCode("Pantothenic acid B5")).toBe("pantothenic_acid");
expect(resolveNutrientCode("Caffeine")).toBe("caffeine");
});
it("still refuses a word that is in no bundle — a parse miss stays a miss", () => {
expect(resolveNutrientCode("bananas")).toBeNull();
expect(resolveNutrientCode("Bananen")).toBeNull();
expect(resolveNutrientCode("protein")).toBeNull();
});
// The property, not a sample: EVERY code's name in EVERY shipped locale
// round-trips. A seventh locale added to `locales` is covered by this test
// the moment its bundle lands — which is the point of deriving the index
// instead of transcribing it.
it("round-trips every catalog code from every shipped locale's bundle", () => {
for (const locale of locales) {
for (const code of NUTRIENT_CODES) {
const label = labelIn(locale, `nutrients.names.${code}`);
expect(label, `${locale} is missing nutrients.names.${code}`).not.toBe(
null,
);
expect(resolveNutrientCode(label!), `${locale}/${code}`).toBe(code);
}
}
});
it("exposes the same English label the bundle carries, not a copy of it", () => {
for (const code of NUTRIENT_CODES) {
expect(NUTRIENT_LABELS[code]).toBe(
labelIn("en", `nutrients.names.${code}`),
);
}
});
});
describe("getNutrients — module gate", () => {
it("returns { present: false, reason: module_disabled } when the opt-in module is off", async () => {
vi.mocked(isModuleEnabled).mockResolvedValue(false);
const result = await getNutrients("user-1", {});
expect(result).toEqual({ present: false, reason: "module_disabled" });
expect(nutrientIntakeDay.findMany).not.toHaveBeenCalled();
});
});
describe("getNutrients — overview mode (no `nutrient` arg)", () => {
it("sums a day across sources and reports latest day / total / days-with-data", async () => {
vi.mocked(nutrientIntakeDay.findMany).mockResolvedValue([
// Two sources on the SAME latest day — must sum, not double-count the day.
{ nutrient: "water", unit: "ml", day: "2026-07-10", amount: 1200 },
{ nutrient: "water", unit: "ml", day: "2026-07-10", amount: 500 },
{ nutrient: "water", unit: "ml", day: "2026-07-09", amount: 2000 },
] as never);
const result = overview(await getNutrients("user-1", {}));
expect(result.present).toBe(true);
expect(result.nutrients).toEqual([
{
nutrient: "water",
label: "Water",
unit: "ml",
latestDay: "2026-07-10",
latestAmount: 1700,
daysWithData: 2,
},
]);
});
it("returns { present: false, reason: no_data } when nothing is logged", async () => {
const result = await getNutrients("user-1", {});
expect(result).toMatchObject({ present: false, reason: "no_data" });
});
it("clamps an out-of-range `days` to the overview ceiling (365)", async () => {
const result = overview(await getNutrients("user-1", { days: 10000 }));
expect(result.windowDays).toBe(365);
});
});
describe("getNutrients — per-nutrient mode", () => {
it("returns a dense day series + the resolved reference for a known-sex profile", async () => {
vi.mocked(user.findUnique).mockResolvedValue({
timezone: "UTC",
gender: "FEMALE",
} as never);
vi.mocked(nutrientIntakeDay.findMany).mockResolvedValue([
{ day: "2026-07-10", amount: 300 },
] as never);
const result = daily(
await getNutrients("user-1", { nutrient: "magnesium", days: 3 }),
);
expect(result.present).toBe(true);
expect(result.nutrient).toBe("magnesium");
expect(result.unit).toBe("mg");
expect(result.days).toHaveLength(3);
// Female reference for magnesium is 300 mg (EFSA DRV 2015).
expect(result.reference).toEqual({
kind: "AI",
direction: "target",
value: 300,
source: "EFSA DRV 2015 (adults)",
});
});
it("omits the reference (never guesses) when the profile has no sex on file", async () => {
vi.mocked(user.findUnique).mockResolvedValue({
timezone: "UTC",
gender: null,
} as never);
const result = daily(
await getNutrients("user-1", { nutrient: "magnesium" }),
);
expect(result.reference).toBeNull();
});
it("resolves a free-text nutrient name (display label)", async () => {
const result = daily(
await getNutrients("user-1", { nutrient: "Vitamin D" }),
);
expect(result.nutrient).toBe("vitamin_d");
});
it("returns { present: false, reason: unknown_nutrient } for an unresolvable name", async () => {
const result = await getNutrients("user-1", { nutrient: "bananas" });
expect(result).toEqual({ present: false, reason: "unknown_nutrient" });
expect(nutrientIntakeDay.findMany).not.toHaveBeenCalled();
});
it("clamps an out-of-range `days` to the per-nutrient ceiling (90)", async () => {
const result = daily(
await getNutrients("user-1", { nutrient: "water", days: 9000 }),
);
expect(result.windowDays).toBe(90);
expect(result.days).toHaveLength(90);
});
it("resolves caffeine's reference as an upper-guidance ceiling, not a target", async () => {
const result = daily(
await getNutrients("user-1", { nutrient: "caffeine" }),
);
expect(result.reference).toMatchObject({
kind: "safeLevel",
direction: "upperGuidance",
value: 400,
});
});
});