Files
HealthLog/src/lib/cycle/__tests__/engine-adapter.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

230 lines
6.7 KiB
TypeScript

/**
* v1.15.0 — calendar composition over the deterministic engine.
*/
import { describe, it, expect } from "vitest";
import { buildCalendar, type CalendarDayLogRow } from "../engine-adapter";
import type { CycleProfile, MenstrualCycle } from "@/generated/prisma/client";
function profile(overrides: Partial<CycleProfile> = {}): CycleProfile {
return {
id: "p1",
userId: "u1",
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
typicalCycleLength: null,
typicalPeriodLength: null,
lutealPhaseLength: null,
predictionEnabled: true,
rawChartMode: false,
discreetNotifications: false,
sensitiveCategoryEncryption: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as CycleProfile;
}
function cycle(
startDate: string,
overrides: Partial<MenstrualCycle> = {},
): MenstrualCycle {
return {
id: `c-${startDate}`,
userId: "u1",
startDate,
endDate: null,
periodEndDate: null,
lengthDays: null,
ovulationDate: null,
ovulationConfirmed: false,
isPredicted: false,
tz: null,
syncVersion: 0,
deletedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as MenstrualCycle;
}
describe("buildCalendar", () => {
// Three ~28-day cycles → a real prediction.
const cycles = [
cycle("2026-01-01", {
endDate: "2026-01-28",
lengthDays: 28,
periodEndDate: "2026-01-05",
}),
cycle("2026-01-29", {
endDate: "2026-02-25",
lengthDays: 28,
periodEndDate: "2026-02-02",
}),
cycle("2026-02-26", { periodEndDate: "2026-03-02" }),
];
it("labels logged-flow days and emits a forecast for ≥2 cycles", () => {
const dayLogs: CalendarDayLogRow[] = [
{
date: "2026-02-26",
flow: "MEDIUM",
basalBodyTempC: null,
temperatureExcluded: false,
ovulationTest: null,
cervicalMucus: null,
cervixPosition: null,
cervixFirmness: null,
cervixOpening: null,
hasSymptoms: true,
},
];
const { prediction, days } = buildCalendar(
profile(),
cycles,
dayLogs,
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
false,
);
expect(prediction).not.toBeNull();
expect(prediction!.cyclesObserved).toBeGreaterThanOrEqual(2);
const logged = days.find((d) => d.date === "2026-02-26")!;
expect(logged.isPeriodLogged).toBe(true);
expect(logged.flow).toBe("MEDIUM");
expect(logged.hasSymptoms).toBe(true);
// The forecast next-period start falls inside a predicted-period day.
const predStart = prediction!.nextPeriodStart;
const predDay = days.find((d) => d.date === predStart);
expect(predDay?.isPredictedPeriod).toBe(true);
});
it("suppresses the fertile window at the grid when the goal disallows it", () => {
const { days } = buildCalendar(
profile({ goal: "GENERAL_HEALTH" }),
cycles,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
false,
);
expect(days.every((d) => !d.isFertileWindow)).toBe(true);
expect(days.every((d) => !d.isPredictedOvulation)).toBe(true);
});
it("surfaces the fertile window for the conception goal once past cold start", () => {
// Four starts → three completed cycles → not still-learning.
const fourCycles = [
cycle("2026-01-01", {
endDate: "2026-01-29",
periodEndDate: "2026-01-05",
}),
cycle("2026-01-29", {
endDate: "2026-02-26",
periodEndDate: "2026-02-02",
}),
cycle("2026-02-26", {
endDate: "2026-03-26",
periodEndDate: "2026-03-02",
}),
cycle("2026-03-26", { periodEndDate: "2026-03-30" }),
];
const { days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
fourCycles,
[],
[],
"2026-03-20",
"2026-05-15",
"2026-04-10",
true,
);
expect(days.some((d) => d.isFertileWindow)).toBe(true);
});
it("emits no prediction in raw-chart mode", () => {
const { prediction } = buildCalendar(
profile({ rawChartMode: true }),
cycles,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
false,
);
expect(prediction).toBeNull();
});
describe("still-learning gate (cold start)", () => {
// One logged period — zero completed cycles → priors-only forecast.
const oneCycle = [cycle("2026-02-26", { periodEndDate: "2026-03-02" })];
it("flags stillLearning and asserts no fertile/ovulation grid for ≤1 cycle (TTC)", () => {
const { prediction, stillLearning, days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
oneCycle,
[],
[],
"2026-02-20",
"2026-04-15",
"2026-03-10",
true, // goal allows fertile — only the learning gate must suppress it
);
expect(prediction).not.toBeNull();
expect(prediction!.cyclesObserved).toBeLessThan(3);
expect(prediction!.stillLearning).toBe(true);
expect(stillLearning).toBe(true);
// No confident fertile shading or ovulation dot from a population guess.
expect(days.every((d) => !d.isFertileWindow)).toBe(true);
expect(days.every((d) => !d.isPredictedOvulation)).toBe(true);
// No asserted phase band either (population-28 frame is not yet earned).
expect(days.every((d) => d.phase === null)).toBe(true);
// The predicted next-period bar still shows (the panel shows it too).
expect(days.some((d) => d.isPredictedPeriod)).toBe(true);
});
it("emits normal fertile/ovulation/phase output once ≥3 cycles are observed", () => {
// Four ~28-day starts → three COMPLETED cycle lengths → cyclesObserved=3.
const fourCycles = [
cycle("2026-01-01", {
endDate: "2026-01-29",
periodEndDate: "2026-01-05",
}),
cycle("2026-01-29", {
endDate: "2026-02-26",
periodEndDate: "2026-02-02",
}),
cycle("2026-02-26", {
endDate: "2026-03-26",
periodEndDate: "2026-03-02",
}),
cycle("2026-03-26", { periodEndDate: "2026-03-30" }),
];
const { prediction, stillLearning, days } = buildCalendar(
profile({ goal: "TRYING_TO_CONCEIVE" }),
fourCycles,
[],
[],
"2026-03-20",
"2026-05-15",
"2026-04-10",
true,
);
expect(prediction!.cyclesObserved).toBeGreaterThanOrEqual(3);
expect(prediction!.stillLearning).toBe(false);
expect(stillLearning).toBe(false);
expect(days.some((d) => d.isFertileWindow)).toBe(true);
expect(days.some((d) => d.isPredictedOvulation)).toBe(true);
expect(days.some((d) => d.phase !== null)).toBe(true);
});
});
});