Files
HealthLog/src/lib/insights/__tests__/trend-chart-select.test.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

217 lines
8.1 KiB
TypeScript

import { describe, it, expect } from "vitest";
import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema";
import { isSurfaceVisible, surfaceModule } from "@/lib/modules/surface";
import {
selectTrendCharts,
DEFAULT_TREND_CHART_CAP,
TREND_CHART_CONFIG,
} from "../trend-chart-select";
/**
* v1.8.5 — the Trends row charts the metrics the daily briefing flags,
* in briefing order, deduped, capped, with the legacy BP / weight /
* mood triple as the fallback. These tests pin the selection contract;
* the renderer is a thin consumer.
*/
function finding(
sourceMetric: DailyBriefingKeyFinding["sourceMetric"],
): DailyBriefingKeyFinding {
return {
tone: "watch",
headline: `${sourceMetric} headline`,
detail: `${sourceMetric} detail`,
delta: null,
sourceWindow: "30d",
sourceMetric,
};
}
function briefing(
metrics: DailyBriefingKeyFinding["sourceMetric"][],
): DailyBriefing {
return {
paragraph: "Synthesised briefing paragraph.",
keyFindings: metrics.map(finding),
};
}
describe("selectTrendCharts", () => {
it("falls back to the BP / weight / mood triple when the briefing is null", () => {
const charts = selectTrendCharts(null);
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("falls back to the default triple when the briefing is undefined", () => {
const charts = selectTrendCharts(undefined);
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("falls back when the briefing carries no key findings", () => {
const charts = selectTrendCharts(briefing([]));
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("charts exactly the metrics the briefing flags, in briefing order", () => {
const charts = selectTrendCharts(briefing(["weight", "pulse", "sleep"]));
expect(charts.map((c) => c.metric)).toEqual(["weight", "pulse", "sleep"]);
});
it("dedupes repeated metrics, keeping first-seen order", () => {
const charts = selectTrendCharts(briefing(["bp", "weight", "bp", "pulse"]));
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "pulse"]);
});
it("caps at the default of three", () => {
const charts = selectTrendCharts(
briefing(["weight", "pulse", "sleep", "hrv", "steps"]),
);
expect(charts).toHaveLength(DEFAULT_TREND_CHART_CAP);
expect(charts.map((c) => c.metric)).toEqual(["weight", "pulse", "sleep"]);
});
it("honours a custom cap", () => {
const charts = selectTrendCharts(briefing(["weight", "pulse", "sleep"]), {
cap: 2,
});
expect(charts.map((c) => c.metric)).toEqual(["weight", "pulse"]);
});
it("clamps a zero/negative cap to at least one", () => {
const charts = selectTrendCharts(briefing(["weight", "pulse"]), { cap: 0 });
expect(charts).toHaveLength(1);
expect(charts[0].metric).toBe("weight");
});
it("skips metrics with no standalone trend chart (compliance, glp1_plateau)", () => {
const charts = selectTrendCharts(
briefing(["compliance", "weight", "glp1_plateau", "pulse"]),
);
expect(charts.map((c) => c.metric)).toEqual(["weight", "pulse"]);
});
it("falls back to the default triple when every finding is chartless", () => {
const charts = selectTrendCharts(briefing(["compliance", "glp1_plateau"]));
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("resolves additive HealthKit metrics to a chart config", () => {
const charts = selectTrendCharts(briefing(["hrv", "active_energy"]));
expect(charts.map((c) => c.metric)).toEqual(["hrv", "active_energy"]);
expect(charts[0].types).toEqual(["HEART_RATE_VARIABILITY"]);
expect(charts[1].types).toEqual(["ACTIVE_ENERGY_BURNED"]);
});
it("carries an annotation key only for the legacy triple", () => {
const charts = selectTrendCharts(briefing(["bp", "weight", "mood"]));
expect(charts.map((c) => c.annotationKey)).toEqual([
"bp",
"weight",
"mood",
]);
const pulseCharts = selectTrendCharts(briefing(["pulse"]));
expect(pulseCharts[0].annotationKey).toBeUndefined();
});
it("maps the mood slot to the bespoke mood chart kind", () => {
const charts = selectTrendCharts(briefing(["mood"]));
expect(charts[0].kind).toBe("mood");
expect(charts[0].types).toEqual([]);
});
it("every chart-bearing sourceMetric carries a standard caption key", () => {
// v1.8.6 — a tile without an advisor annotation used to paint
// empty space below the chart. Every chart config now carries a
// `captionKey` so the renderer can always surface a caption. This
// guard keeps a future metric addition from re-opening the gap.
for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) {
if (!config) continue;
expect(config.captionKey, `${metric} captionKey`).toMatch(
/^insights\.trendsRow\.caption\./,
);
}
});
it("every chart-bearing sourceMetric resolves to a non-empty health-chart series", () => {
for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) {
if (!config) continue;
if (config.kind === "health-chart") {
expect(config.types.length, `${metric} types`).toBeGreaterThan(0);
expect(config.colors.length, `${metric} colors`).toBeGreaterThanOrEqual(
config.types.length,
);
}
}
});
it("every chart-bearing sourceMetric carries a routed detailHref", () => {
// v1.18.6 — the overview Trends row drills into the metric's detail page.
// Each chartable slot must resolve to a `/insights/<slug>` target so the
// cross-link never lands on a non-route — steps in particular.
for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) {
if (!config) continue;
expect(config.detailHref, `${metric} detailHref`).toMatch(
/^\/insights\//,
);
}
expect(TREND_CHART_CONFIG.steps?.detailHref).toBe("/insights/steps");
});
});
describe("selectTrendCharts — switched-off modules", () => {
it("fills the fallback row from the next metric when mood is off", () => {
const charts = selectTrendCharts(null, { modules: { mood: false } });
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "pulse"]);
});
it("keeps the familiar blood pressure, weight, mood row with every module on", () => {
const charts = selectTrendCharts(null, { modules: {} });
expect(charts.map((c) => c.metric)).toEqual(["bp", "weight", "mood"]);
});
it("skips a flagged metric whose module is off and lets the next finding take the slot", () => {
const charts = selectTrendCharts(briefing(["sleep", "mood", "weight"]), {
modules: { sleep: false, mood: false },
});
expect(charts.map((c) => c.metric)).toEqual(["weight"]);
});
it("still honours an explicit hide list", () => {
const charts = selectTrendCharts(briefing(["mood", "weight"]), {
hiddenMetrics: ["mood"],
});
expect(charts.map((c) => c.metric)).toEqual(["weight"]);
});
it("never returns a slot whose module is off, for any module map and briefing", () => {
// Exhaustive over every on/off combination of the owning modules, with
// every finding a briefing can carry, in both directions.
const owners = [
...new Set(
Object.keys(TREND_CHART_CONFIG)
.map((m) => surfaceModule(`trend:${m}`))
.filter((k): k is NonNullable<typeof k> => k !== undefined),
),
];
expect(owners.length).toBeGreaterThan(0);
const all = Object.keys(TREND_CHART_CONFIG) as Array<
keyof typeof TREND_CHART_CONFIG
>;
const briefings = [null, briefing(all), briefing([...all].reverse())];
for (let mask = 0; mask < 1 << owners.length; mask += 1) {
const modules = Object.fromEntries(
owners.map((key, i) => [key, (mask & (1 << i)) === 0]),
);
for (const b of briefings) {
for (const chart of selectTrendCharts(b, { modules })) {
expect(
isSurfaceVisible(`trend:${chart.metric}`, modules),
`${chart.metric} under ${JSON.stringify(modules)}`,
).toBe(true);
}
}
}
});
});