Files
HealthLog/src/lib/cycle/__tests__/prediction.test.ts
T
Marc-André Bombeck 47d41a13f4 docs(cycle): say why the fertile window is seven days, not six
The constants make ovulation minus five through ovulation plus one, and
the comment above them claimed a six-day window. Wilcox's six days are
the five before ovulation plus the day itself, measured against a KNOWN
ovulation day. Ours is estimated, usually by back-calculating a luteal
length from a median cycle, so a window drawn tight to the estimate
loses its last day whenever the estimate lands early, and the ovum stays
viable for most of a day after release besides.

The direction is decided by who reads the window. It is surfaced to
people trying to conceive and to people avoiding it, and for the second
group a window one day short is a window that misses. The constants keep
the conservative reading and the comment now states it, with the note
that changing either one is a coordination matter because the iOS client
reimplements the same spec.

The width is pinned by a test rather than left to be re-read off the
numbers the code already uses.
2026-08-07 15:53:55 +02:00

1125 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { addDays } from "../day-math";
import { FERTILE_POST, FERTILE_PRE } from "../types";
import {
clampLuteal,
confirmSymptothermal,
detectCervixPeak,
detectLhSurgeOvulation,
detectMucusPeak,
detectTempShift,
detectTemperatureTrend,
estimateCycleLength,
estimatePeriodLength,
median,
observedPeriodLength,
predictCycle,
resolveLuteal,
} from "../prediction";
import { toCyclePredictionDTO } from "../dto";
import type {
CycleInput,
CycleProfileInput,
CyclePredictionResult,
DayLogInput,
NightlyTempInput,
} from "../types";
/**
* These fixtures are the parity contract iOS reuses. Every expected date +
* confidence is computed by hand from algorithm.md's pinned constants and
* asserted exactly. The fixtures are constructed from a fixed seed date so the
* assertions are timezone-independent (all day math is noon-UTC anchored).
*/
const BASE_PROFILE: CycleProfileInput = {
goal: "TRYING_TO_CONCEIVE",
typicalCycleLength: null,
typicalPeriodLength: null,
lutealPhaseLength: null,
predictionEnabled: true,
rawChartMode: false,
};
/** Build a chain of cycles from an anchor start, each `gap[i]` days apart. */
function cyclesFromGaps(anchor: string, gaps: number[]): CycleInput[] {
const starts: string[] = [anchor];
for (const g of gaps) starts.push(addDays(starts[starts.length - 1], g));
return starts.map((startDate, i) => ({
startDate,
endDate: i < starts.length - 1 ? starts[i + 1] : null,
periodEndDate: null,
ovulationDate: null,
ovulationConfirmed: false,
}));
}
describe("cycle/prediction — robust statistics", () => {
it("median: odd + even counts (even = mean of two central, unrounded)", () => {
expect(median([28, 30, 29])).toBe(29);
expect(median([28, 30])).toBe(29);
expect(median([28, 31])).toBe(29.5);
expect(median([])).toBeNaN();
});
});
describe("cycle/prediction — cycle-length estimator (§1, §5)", () => {
it("regular 28-day user: median 28, MAD 0 → sigma floor 1.0", () => {
const est = estimateCycleLength(
[28, 28, 28, 28, 28, 28],
"TRYING_TO_CONCEIVE",
);
expect(est.lengthRounded).toBe(28);
expect(est.sigma).toBe(1.0); // SIGMA_FLOOR (MAD = 0)
expect(est.cyclesObserved).toBe(6);
expect(est.cv).toBeCloseTo(1 / 28, 6);
});
it("irregular user: high MAD widens sigma", () => {
// lengths 24,28,32,26,30,28 → median 28, deviations 4,0,4,2,2,0 → MAD 2
const est = estimateCycleLength(
[24, 28, 32, 26, 30, 28],
"TRYING_TO_CONCEIVE",
);
expect(est.lengthRounded).toBe(28);
expect(est.sigma).toBeCloseTo(1.4826 * 2, 6); // 2.9652
});
it("excludes a 3-MAD outlier from the point estimate but counts it against confidence", () => {
// 28,28,29,28,28 + one wild 60-day (missed-log: >= 1.75*28 = 49).
const est = estimateCycleLength(
[28, 28, 29, 28, 28, 60],
"TRYING_TO_CONCEIVE",
);
// 60 excluded → median over kept = 28, kept count = 5.
expect(est.lengthRounded).toBe(28);
expect(est.cyclesObserved).toBe(5);
});
it("hard-bounds: a 14-day length is always an outlier candidate", () => {
const est = estimateCycleLength([28, 28, 14, 28, 29], "TRYING_TO_CONCEIVE");
expect(est.cyclesObserved).toBe(4); // 14 excluded (< HARD_CYCLE_MIN 21)
expect(est.lengthRounded).toBe(28);
});
it("keeps a legitimate long (oligomenorrhea) cycle inside the hard bound", () => {
// A run of long-but-real cycles (ACOG: > 35 d is oligomenorrhea, still a
// real cycle; perimenopause routinely runs longer). A 52-day length must
// not be force-excluded by the hard ceiling — only the MAD fence / missed-
// log heuristic should ever drop it. Here the whole run is long, so the
// robust median sits near 50 and 52 is well inside the fence.
const est = estimateCycleLength([48, 50, 52, 49, 51], "PERIMENOPAUSE");
expect(est.cyclesObserved).toBe(5); // none clipped by HARD_CYCLE_MAX
expect(est.lengthRounded).toBe(50);
});
it("perimenopause widens the fence (OUTLIER_K 4 keeps more)", () => {
const lengths = [28, 30, 26, 40, 24, 32];
const normal = estimateCycleLength(lengths, "TRYING_TO_CONCEIVE");
const peri = estimateCycleLength(lengths, "PERIMENOPAUSE");
expect(peri.cyclesObserved).toBeGreaterThanOrEqual(normal.cyclesObserved);
});
});
describe("cycle/prediction — period-length estimator (§2)", () => {
const cyc: CycleInput = {
startDate: "2024-01-01",
endDate: "2024-01-29",
periodEndDate: null,
ovulationDate: null,
ovulationConfirmed: false,
};
function flowDay(date: string, flow: DayLogInput["flow"]): DayLogInput {
return {
date,
flow,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
};
}
it("counts a contiguous 5-day bleeding run", () => {
const logs = [
flowDay("2024-01-01", "MEDIUM"),
flowDay("2024-01-02", "HEAVY"),
flowDay("2024-01-03", "MEDIUM"),
flowDay("2024-01-04", "LIGHT"),
flowDay("2024-01-05", "SPOTTING"),
];
expect(observedPeriodLength("2024-01-01", logs)).toBe(5);
});
it("tolerates a single dry day inside the run but breaks on a 2-day gap", () => {
const logs = [
flowDay("2024-01-01", "MEDIUM"),
flowDay("2024-01-02", "LIGHT"),
// 01-03 dry (single gap, tolerated)
flowDay("2024-01-04", "LIGHT"),
// 01-05, 01-06 dry (2-day gap → break)
flowDay("2024-01-07", "SPOTTING"),
];
expect(observedPeriodLength("2024-01-01", logs)).toBe(4); // through 01-04
});
it("returns 0 when startDate itself has no bleeding", () => {
expect(
observedPeriodLength("2024-01-01", [flowDay("2024-01-02", "MEDIUM")]),
).toBe(0);
});
it("estimatePeriodLength prefers explicit periodEndDate", () => {
const c = { ...cyc, periodEndDate: "2024-01-04" }; // 4-day period
expect(estimatePeriodLength([c], [], BASE_PROFILE)).toBe(4);
});
it("falls back to population default (5) with no data and no prior", () => {
expect(estimatePeriodLength([], [], BASE_PROFILE)).toBe(5);
});
});
describe("cycle/prediction — predictCycle calendar path (§1–§3)", () => {
it("regular 28-day user (≥6 cycles, no logs): exact band + medium confidence", () => {
// 7 starts → 6 completed lengths of 28. Last start 2024-06-24.
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28, 28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
// 7 starts = anchor + 6*28 = 2024-01-01 + 168 days.
expect(lastStart).toBe("2024-06-17");
const result = predictCycle(cycles, [], BASE_PROFILE, "2024-06-24");
expect(result.method).toBe("CALENDAR");
expect(result.cyclesObserved).toBe(6);
expect(result.stillLearning).toBe(false);
expect(result.estimatedCycleLength).toBe(28);
expect(result.estimatedCycleSd).toBe(1.0);
// nextStart = 2024-06-17 + 28 = 2024-07-15.
expect(result.nextPeriodStart).toBe("2024-07-15");
// halfWidth: density 0 (no logs) → logSparsity 0.5 → penalty 1.5.
// Z_BAND(1) * sigma(1) * 1.5 = 1.5 → round 2 → clamp [1,14] = 2.
expect(result.nextPeriodStartLow).toBe("2024-07-13");
expect(result.nextPeriodStartHigh).toBe("2024-07-17");
// Ovulation = nextStart - luteal(14) = 2024-07-01.
expect(result.predictedOvulation).toBe("2024-07-01");
// Fertile window ov-5 … ov+1.
expect(result.fertileWindowStart).toBe("2024-06-26");
expect(result.fertileWindowEnd).toBe("2024-07-02");
// confidence = cCount(1.0) * cVariance(1.0, cv≈0.036≤0.05) * cAdherence(0.4) = 0.40.
expect(result.confidence).toBe(0.4);
expect(result.confidenceLabel).toBe("medium");
});
it("irregular user: wider band, lower confidence", () => {
// lengths 24,32,26,30,28,28 → median 28, MAD 2 → sigma 2.9652.
const cycles = cyclesFromGaps("2024-01-01", [24, 32, 26, 30, 28, 28]);
const result = predictCycle(cycles, [], BASE_PROFILE, "2024-07-10");
expect(result.estimatedCycleLength).toBe(28);
expect(result.estimatedCycleSd).toBeCloseTo(2.97, 2);
// halfWidth = round(1 * 2.9652 * 1.5) = round(4.4478) = 4.
const low = result.nextPeriodStartLow;
const high = result.nextPeriodStartHigh;
// band half-width is 4 days each side of nextStart.
expect(low).toBe(addDays(result.nextPeriodStart, -4));
expect(high).toBe(addDays(result.nextPeriodStart, 4));
// cv = 2.9652/28 ≈ 0.1059 → cVariance 0.65; cCount 1.0; cAdherence 0.4.
// confidence = 1.0 * 0.65 * 0.4 = 0.26 → low.
expect(result.confidence).toBe(0.26);
expect(result.confidenceLabel).toBe("low");
});
it("<2 cycles cold start (1 length): priors-flavoured, stillLearning, band bonus", () => {
// 2 starts → 1 completed length of 30.
const cycles = cyclesFromGaps("2024-01-01", [30]);
const result = predictCycle(cycles, [], BASE_PROFILE, "2024-02-05");
expect(result.cyclesObserved).toBe(1);
expect(result.stillLearning).toBe(true);
expect(result.estimatedCycleLength).toBe(30);
// sigma falls back to SIGMA_FLOOR 1.0 (no MAD); +COLD_START_BAND_BONUS 3.
// halfWidth = round(1*1*1.5)=2, +3 = 5 → clamp 5.
expect(result.nextPeriodStartLow).toBe(addDays(result.nextPeriodStart, -5));
expect(result.nextPeriodStartHigh).toBe(addDays(result.nextPeriodStart, 5));
// cCount(1)=0.35, cVariance(cv=1/30≈0.033≤0.05)=1.0, cAdherence=0.4.
// confidence = 0.35 * 1.0 * 0.4 = 0.14 → low.
expect(result.confidence).toBe(0.14);
});
it("0 cycles priors-only: population default 28, confidence 0.20, fixed band 4", () => {
const onlyStart: CycleInput[] = [
{
startDate: "2024-05-01",
endDate: null,
periodEndDate: null,
ovulationDate: null,
ovulationConfirmed: false,
},
];
const result = predictCycle(onlyStart, [], BASE_PROFILE, "2024-05-10");
expect(result.method).toBe("CALENDAR");
expect(result.cyclesObserved).toBe(0);
expect(result.stillLearning).toBe(true);
expect(result.estimatedCycleLength).toBe(28);
expect(result.confidence).toBe(0.2);
// nextStart = 2024-05-01 + 28 = 2024-05-29, fixed half-width 4.
expect(result.nextPeriodStart).toBe("2024-05-29");
expect(result.nextPeriodStartLow).toBe("2024-05-25");
expect(result.nextPeriodStartHigh).toBe("2024-06-02");
});
it("respects a user-set typical-cycle prior on cold start", () => {
const onlyStart: CycleInput[] = [
{
startDate: "2024-05-01",
endDate: null,
periodEndDate: null,
ovulationDate: null,
ovulationConfirmed: false,
},
];
const profile = { ...BASE_PROFILE, typicalCycleLength: 30 };
const result = predictCycle(onlyStart, [], profile, "2024-05-10");
expect(result.estimatedCycleLength).toBe(30);
expect(result.nextPeriodStart).toBe("2024-05-31");
});
it("logging density lifts confidence and tightens the band", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28, 28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate; // 2024-06-24
// Log every day since the last start through 'today' = full density.
const logs: DayLogInput[] = [];
for (let i = 0; i <= 7; i++) {
logs.push({
date: addDays(lastStart, i),
flow: i < 4 ? "MEDIUM" : "NONE",
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
});
}
const result = predictCycle(
cycles,
logs,
BASE_PROFILE,
addDays(lastStart, 7),
);
// density 1 → logSparsity 0 → penalty 1 → halfWidth round(1*1*1)=1.
expect(result.nextPeriodStartLow).toBe(addDays(result.nextPeriodStart, -1));
// cAdherence = 0.4 + 0.6*1 = 1.0 → confidence 1*1*1.0 = 0.98 (clamped from 1.0).
expect(result.confidence).toBe(0.98);
expect(result.confidenceLabel).toBe("high");
});
});
describe("cycle/prediction — symptothermal confirmation (§4.2)", () => {
function bbtDay(
date: string,
t: number,
mucus: DayLogInput["cervicalMucus"] = null,
): DayLogInput {
return {
date,
flow: null,
basalBodyTempC: t,
ovulationTest: null,
cervicalMucus: mucus,
};
}
function cervixDay(
date: string,
cervixPosition: DayLogInput["cervixPosition"],
cervixFirmness: DayLogInput["cervixFirmness"],
cervixOpening: DayLogInput["cervixOpening"],
): DayLogInput {
return {
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
cervixPosition,
cervixFirmness,
cervixOpening,
};
}
it("detectTempShift: 3-over-6 with the 3rd reading >= 0.2°C over the 6-day max", () => {
// 6 low baseline ~36.40, then 3 elevated clearing the max by 0.2.
const logs: DayLogInput[] = [];
const start = "2024-01-01";
const baseline = [36.4, 36.42, 36.38, 36.41, 36.4, 36.39]; // max 36.42
baseline.forEach((t, i) => logs.push(bbtDay(addDays(start, i), t)));
// rise: each > 36.42; 3rd (index 8) must be >= 36.62.
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62));
const shift = detectTempShift(logs, 0.2);
expect(shift).not.toBeNull();
// ovulation = day before the first elevated reading (offset 6 → 2024-01-07).
expect(shift?.ovulationDate).toBe(addDays(start, 5));
});
it("detectTempShift: rejects a rise that does not clear the 0.2°C third-measurement bar", () => {
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
// 3rd reading only +0.1 over the 36.42 max.
logs.push(bbtDay(addDays(start, 6), 36.45));
logs.push(bbtDay(addDays(start, 7), 36.48));
logs.push(bbtDay(addDays(start, 8), 36.52));
expect(detectTempShift(logs, 0.2)).toBeNull();
});
it("detectTempShift rule 0: the regular rule reports rule:0 + evaluation on the 3rd day", () => {
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62)); // 3rd clears 0.2 over 36.42
const shift = detectTempShift(logs, 0.2);
expect(shift?.rule).toBe(0);
expect(shift?.ovulationDate).toBe(addDays(start, 5));
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 8));
});
it("detectTempShift 1. Ausnahmeregel: slow/staircase rise confirms on the 4th reading", () => {
// 1. Ausnahmeregel: 3rd high reading is above the line but NOT 0.2°C above;
// a 4th reading above the line confirms (need only be above the line).
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
// cover line = 36.42. Staircase: all above the line, 3rd only +0.1.
logs.push(bbtDay(addDays(start, 6), 36.45));
logs.push(bbtDay(addDays(start, 7), 36.49));
logs.push(bbtDay(addDays(start, 8), 36.52)); // +0.1 → regular rule fails
// Without a 4th reading the strict + 1. rule cannot confirm.
expect(detectTempShift([...logs], 0.2)).toBeNull();
// 4th reading merely above the line (no 0.2 requirement) → confirmed by rule 1.
logs.push(bbtDay(addDays(start, 9), 36.5));
const shift = detectTempShift(logs, 0.2);
expect(shift?.rule).toBe(1);
expect(shift?.ovulationDate).toBe(addDays(start, 5));
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9));
});
it("detectTempShift 2. Ausnahmeregel: a single fall-back day confirms on a 4th ≥0.2°C reading", () => {
// 2. Ausnahmeregel: one of the 3 readings falls back to/below the line; it
// is discounted and a 4th reading is required that again clears 0.2°C.
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
// cover line = 36.42. 1st + 2nd above, 3rd falls back below the line.
logs.push(bbtDay(addDays(start, 6), 36.6));
logs.push(bbtDay(addDays(start, 7), 36.62));
logs.push(bbtDay(addDays(start, 8), 36.4)); // falls back below 36.42
// Without a 4th reading nothing confirms.
expect(detectTempShift([...logs], 0.2)).toBeNull();
// 4th reading that does NOT clear 0.2 must not confirm.
const tooLow = [...logs, bbtDay(addDays(start, 9), 36.5)]; // +0.08 only
expect(detectTempShift(tooLow, 0.2)).toBeNull();
// 4th reading ≥0.2°C above the line → confirmed by rule 2.
logs.push(bbtDay(addDays(start, 9), 36.63)); // +0.21 over 36.42
const shift = detectTempShift(logs, 0.2);
expect(shift?.rule).toBe(2);
expect(shift?.ovulationDate).toBe(addDays(start, 5));
expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9));
});
it("detectTempShift: an excluded (disturbed) baseline spike no longer masks a true rise", () => {
const start = "2024-01-01";
// 6 baseline days; the LAST (immediately before the rise) is a fever spike,
// so it sits in the cover-line window. Unexcluded it raises the cover line
// and contaminates detection; excluded it is dropped and the remaining lows
// form the real (lower) cover line.
const baseline = [36.4, 36.42, 36.38, 36.41, 36.4, 37.5];
const extraLow = 36.39; // a 6th valid low so exclusion still leaves 6
const rise = [36.55, 36.6, 36.62];
const build = (excludeFever: boolean): DayLogInput[] => {
const logs: DayLogInput[] = [];
logs.push(bbtDay(addDays(start, 0), extraLow));
baseline.forEach((t, i) => {
const day = bbtDay(addDays(start, 1 + i), t);
if (i === 5 && excludeFever) day.temperatureExcluded = true;
logs.push(day);
});
rise.forEach((t, i) => logs.push(bbtDay(addDays(start, 7 + i), t)));
return logs;
};
// Fever NOT excluded: the 37.5 spike contaminates detection — it does NOT
// yield the correct shift (ovulation = day before the real rise).
const corrupted = detectTempShift(build(false), 0.2);
expect(corrupted?.ovulationDate).not.toBe(addDays(start, 6));
// Fever excluded: cover line falls to the real baseline max and the genuine
// rise confirms with ovulation the day before it (2024-01-07).
const shift = detectTempShift(build(true), 0.2);
expect(shift?.rule).toBe(0);
expect(shift?.ovulationDate).toBe(addDays(start, 6));
});
it("detectTempShift: an excluded fever spike inside the rise no longer fabricates a shift", () => {
const start = "2024-01-01";
const logs: DayLogInput[] = [];
[36.4, 36.4, 36.4, 36.4, 36.4, 36.4].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
const fever = bbtDay(addDays(start, 6), 38.0);
fever.temperatureExcluded = true;
logs.push(fever);
logs.push(bbtDay(addDays(start, 7), 36.41));
logs.push(bbtDay(addDays(start, 8), 36.4));
// The excluded fever is dropped; the remaining readings show no rise.
expect(detectTempShift(logs, 0.2)).toBeNull();
});
it("detectMucusPeak: confirms the last best-quality day followed by 3 drier days", () => {
const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: q,
});
const start = "2024-01-01";
const logs = [
m(addDays(start, 0), "STICKY"),
m(addDays(start, 1), "CREAMY"),
m(addDays(start, 2), "EGG_WHITE"), // peak candidate
m(addDays(start, 3), "CREAMY"),
m(addDays(start, 4), "STICKY"),
m(addDays(start, 5), "DRY"),
];
expect(detectMucusPeak(logs)).toBe(addDays(start, 2));
});
it("detectMucusPeak: returns null until 3 drier days follow the best-quality day", () => {
const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: q,
});
const start = "2024-01-01";
// egg-white then only 2 drier observed days → not yet confirmable.
const logs = [
m(addDays(start, 0), "EGG_WHITE"),
m(addDays(start, 1), "CREAMY"),
m(addDays(start, 2), "DRY"),
];
expect(detectMucusPeak(logs)).toBeNull();
});
it("detectMucusPeak: a stray late egg-white entry no longer moves a confirmed peak", () => {
const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: q,
});
const start = "2024-01-01";
const logs = [
m(addDays(start, 0), "EGG_WHITE"), // true peak
m(addDays(start, 1), "CREAMY"),
m(addDays(start, 2), "STICKY"),
m(addDays(start, 3), "DRY"),
// a stray late egg-white with NO 3 drier days after it (only 1 logged
// day follows) — must not be confirmed as a new peak.
m(addDays(start, 8), "EGG_WHITE"),
m(addDays(start, 9), "DRY"),
];
// The confirmed peak stays on the true (early) egg-white day, not the stray.
expect(detectMucusPeak(logs)).toBe(addDays(start, 0));
});
it("confirmSymptothermal requires temp-shift and mucus-peak within ±2 days", () => {
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62));
// temp ovulation = 2024-01-06. Add a mucus peak (EGG_WHITE) on 2024-01-06,
// confirmed by 3 drier days after it (Sensiplan post-peak count).
const mucusDay = (
date: string,
m: DayLogInput["cervicalMucus"],
): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: m,
});
logs.push(mucusDay("2024-01-06", "EGG_WHITE"));
logs.push(mucusDay("2024-01-07", "STICKY"));
logs.push(mucusDay("2024-01-08", "DRY"));
logs.push(mucusDay("2024-01-09", "DRY"));
expect(confirmSymptothermal(logs)).toBe("2024-01-06");
});
it("confirmSymptothermal defaults to the mucus secondary symptom (cervix ignored)", () => {
// A confirmed temp shift + a confirmed CERVIX peak but NO mucus must NOT
// confirm on the default (mucus) path — the default behaviour is unchanged.
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62));
// cervix peak on 2024-01-06, confirmed by 3 closed days — but no mucus.
logs.push(cervixDay("2024-01-06", "HIGH", "SOFT", "OPEN"));
logs.push(cervixDay("2024-01-07", "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay("2024-01-08", "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay("2024-01-09", "LOW", "FIRM", "CLOSED"));
// Default secondary = mucus → no mucus peak → no confirmation.
expect(confirmSymptothermal(logs)).toBeNull();
});
it("detectCervixPeak: confirms the last fertile-cervix day followed by 3 closed days", () => {
const start = "2024-01-01";
const logs = [
cervixDay(addDays(start, 0), "LOW", "FIRM", "CLOSED"),
cervixDay(addDays(start, 1), "HIGH", "SOFT", "OPEN"), // fertile peak
cervixDay(addDays(start, 2), "LOW", "FIRM", "CLOSED"),
cervixDay(addDays(start, 3), "LOW", "FIRM", "CLOSED"),
cervixDay(addDays(start, 4), "LOW", "FIRM", "CLOSED"),
];
expect(detectCervixPeak(logs)).toBe(addDays(start, 1));
});
it("detectCervixPeak: returns null until 3 closed days follow the fertile peak", () => {
const start = "2024-01-01";
const logs = [
cervixDay(addDays(start, 0), "HIGH", "SOFT", "OPEN"),
cervixDay(addDays(start, 1), "LOW", "FIRM", "CLOSED"),
cervixDay(addDays(start, 2), "LOW", "FIRM", "CLOSED"),
];
expect(detectCervixPeak(logs)).toBeNull();
});
it("confirmSymptothermal(CERVIX): a cervix peak + temp shift confirms like mucus does", () => {
const logs: DayLogInput[] = [];
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(bbtDay(addDays(start, 6), 36.55));
logs.push(bbtDay(addDays(start, 7), 36.6));
logs.push(bbtDay(addDays(start, 8), 36.62));
// temp ovulation = 2024-01-06. Cervix peak on 2024-01-06, confirmed by 3
// closed days after it (cervix-closure rule).
logs.push(cervixDay("2024-01-06", "HIGH", "SOFT", "OPEN"));
logs.push(cervixDay("2024-01-07", "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay("2024-01-08", "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay("2024-01-09", "LOW", "FIRM", "CLOSED"));
expect(confirmSymptothermal(logs, "CERVIX")).toBe("2024-01-06");
});
it("confirmSymptothermal(CERVIX): a cervix sign alone (no temp shift) never confirms", () => {
const start = "2024-01-01";
// A confirmed cervix peak but a FLAT temperature series (no rise).
const logs: DayLogInput[] = [];
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4, 36.41, 36.4, 36.39].forEach(
(t, i) => logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(cervixDay(addDays(start, 4), "HIGH", "SOFT", "OPEN"));
logs.push(cervixDay(addDays(start, 5), "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay(addDays(start, 6), "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay(addDays(start, 7), "LOW", "FIRM", "CLOSED"));
// Single sign (cervix) without the temperature double-check → no confirmation.
expect(confirmSymptothermal(logs, "CERVIX")).toBeNull();
});
it("predictCycle(CERVIX): a cervix-peak + temp shift confirms the same boundary as mucus", () => {
const cervixProfile: CycleProfileInput = {
...BASE_PROFILE,
secondarySymptom: "CERVIX",
};
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
const logs: DayLogInput[] = [];
const ovDay = addDays(lastStart, 13);
const riseStart = addDays(ovDay, 1);
const baselineStart = addDays(riseStart, -6);
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) =>
logs.push(bbtDay(addDays(baselineStart, i), t)),
);
logs.push(bbtDay(riseStart, 36.6));
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
// cervix peak agreeing with the temp ovulation, confirmed by 3 closed days.
logs.push(cervixDay(ovDay, "HIGH", "SOFT", "OPEN"));
logs.push(cervixDay(addDays(ovDay, 1), "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay(addDays(ovDay, 2), "LOW", "FIRM", "CLOSED"));
logs.push(cervixDay(addDays(ovDay, 3), "LOW", "FIRM", "CLOSED"));
const result = predictCycle(
cycles,
logs,
cervixProfile,
addDays(lastStart, 16),
);
expect(result.ovulationConfirmed).toBe(true);
expect(result.method).toBe("BLENDED");
expect(result.predictedOvulation).toBe(ovDay);
});
it("predictCycle: a confirmed symptothermal ovulation overrides next-start and tightens the band", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); // 3 lengths, last start 2024-03-25
const lastStart = cycles[cycles.length - 1].startDate;
expect(lastStart).toBe("2024-03-25");
// Build a temp shift in the current (in-progress) cycle with ovulation on lastStart+13.
const logs: DayLogInput[] = [];
const ovDay = addDays(lastStart, 13); // ovulation we want confirmed
// 6 baseline ending the day before the rise; rise starts ovDay+1.
const riseStart = addDays(ovDay, 1);
const baselineStart = addDays(riseStart, -6);
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) =>
logs.push(bbtDay(addDays(baselineStart, i), t)),
);
logs.push(bbtDay(riseStart, 36.6));
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
// mucus peak agreeing with the temp ovulation, confirmed by 3 drier days.
const mucusDay = (
date: string,
m: DayLogInput["cervicalMucus"],
): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: m,
});
logs.push(mucusDay(ovDay, "EGG_WHITE"));
logs.push(mucusDay(addDays(ovDay, 1), "STICKY"));
logs.push(mucusDay(addDays(ovDay, 2), "DRY"));
logs.push(mucusDay(addDays(ovDay, 3), "DRY"));
const result = predictCycle(
cycles,
logs,
BASE_PROFILE,
addDays(lastStart, 16),
);
expect(result.ovulationConfirmed).toBe(true);
expect(result.method).toBe("BLENDED");
expect(result.predictedOvulation).toBe(ovDay);
// nextStart = confirmed ovulation + luteal(14).
expect(result.nextPeriodStart).toBe(addDays(ovDay, 14));
});
});
describe("cycle/prediction — Marquette LH/OPK ovulation anchor (§4, C2)", () => {
function lhDay(date: string): DayLogInput {
return {
date,
flow: null,
basalBodyTempC: null,
ovulationTest: "POSITIVE_LH_SURGE",
cervicalMucus: null,
};
}
it("detectLhSurgeOvulation: anchors ovulation one day after the LAST positive LH", () => {
const logs = [lhDay("2024-01-10"), lhDay("2024-01-11")];
expect(detectLhSurgeOvulation(logs)).toBe("2024-01-12");
expect(detectLhSurgeOvulation([])).toBeNull();
});
it("predictCycle: a positive LH surge anchors ovulation on the surge, not nextStart−luteal", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate; // 2024-03-25
// The calendar back-calc would place ovulation at nextStart−14. A positive
// LH on lastStart+16 should anchor ovulation to lastStart+17.
const lhDate = addDays(lastStart, 16);
const logs = [lhDay(lhDate)];
const today = addDays(lastStart, 18);
const calendarOnly = predictCycle(cycles, [], BASE_PROFILE, today);
const withLh = predictCycle(cycles, logs, BASE_PROFILE, today);
// The LH anchor moves the ovulation estimate off the calendar back-calc.
expect(withLh.predictedOvulation).toBe(addDays(lhDate, 1));
expect(withLh.predictedOvulation).not.toBe(calendarOnly.predictedOvulation);
// next-start = anchored ovulation + luteal(14).
expect(withLh.nextPeriodStart).toBe(addDays(lhDate, 1 + 14));
expect(withLh.method).toBe("BLENDED");
// A single LH indicator never asserts CONFIRMED ovulation.
expect(withLh.ovulationConfirmed).toBe(false);
});
it("predictCycle: LH sharpens the estimate in the thin-data (1-cycle) case", () => {
const cycles = cyclesFromGaps("2024-01-01", [28]); // one completed length
const lastStart = cycles[cycles.length - 1].startDate; // 2024-01-29
const lhDate = addDays(lastStart, 15);
const today = addDays(lastStart, 17);
const result = predictCycle(cycles, [lhDay(lhDate)], BASE_PROFILE, today);
expect(result.predictedOvulation).toBe(addDays(lhDate, 1));
expect(result.ovulationConfirmed).toBe(false);
// The still-learning gate is preserved (1 cycle < 3).
expect(result.stillLearning).toBe(true);
});
it("predictCycle: a confirmed symptothermal ovulation wins over the LH anchor", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
const ovDay = addDays(lastStart, 13);
const riseStart = addDays(ovDay, 1);
const baselineStart = addDays(riseStart, -6);
const logs: DayLogInput[] = [];
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) =>
logs.push({
date: addDays(baselineStart, i),
flow: null,
basalBodyTempC: t,
ovulationTest: null,
cervicalMucus: null,
}),
);
const bbt = (date: string, t: number): DayLogInput => ({
date,
flow: null,
basalBodyTempC: t,
ovulationTest: null,
cervicalMucus: null,
});
logs.push(bbt(riseStart, 36.6));
logs.push(bbt(addDays(riseStart, 1), 36.62));
logs.push(bbt(addDays(riseStart, 2), 36.63));
const mucus = (
date: string,
m: DayLogInput["cervicalMucus"],
): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: m,
});
logs.push(mucus(ovDay, "EGG_WHITE"));
logs.push(mucus(addDays(ovDay, 1), "STICKY"));
logs.push(mucus(addDays(ovDay, 2), "DRY"));
logs.push(mucus(addDays(ovDay, 3), "DRY"));
// A contradicting LH surge far from the temp shift must NOT override it.
logs.push(lhDay(addDays(ovDay, 5)));
const result = predictCycle(
cycles,
logs,
BASE_PROFILE,
addDays(lastStart, 20),
);
expect(result.ovulationConfirmed).toBe(true);
expect(result.predictedOvulation).toBe(ovDay); // symptothermal wins
});
});
describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)", () => {
it("detectTemperatureTrend: 3-of-4 nights >= +0.15°C over the trailing 6-night mean", () => {
const start = "2024-02-01";
const nights: NightlyTempInput[] = [];
// 6 trailing nights at 36.30 → mean 36.30.
for (let i = 0; i < 6; i++)
nights.push({ date: addDays(start, i), valueC: 36.3 });
// 4-night window: 3 elevated (>= 36.45) + 1 not.
nights.push({ date: addDays(start, 6), valueC: 36.46 });
nights.push({ date: addDays(start, 7), valueC: 36.47 });
nights.push({ date: addDays(start, 8), valueC: 36.3 });
nights.push({ date: addDays(start, 9), valueC: 36.48 });
const ov = detectTemperatureTrend(nights);
// rise onset = first window night (offset 6 → 2024-02-07); ovulation = night before.
expect(ov).toBe(addDays(start, 5));
});
it("predictCycle uses temperature-trend when no manual symptothermal confirmation exists", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
const start = addDays(lastStart, 5);
const nights: NightlyTempInput[] = [];
for (let i = 0; i < 6; i++)
nights.push({ date: addDays(start, i), valueC: 36.3 });
nights.push({ date: addDays(start, 6), valueC: 36.46 });
nights.push({ date: addDays(start, 7), valueC: 36.47 });
nights.push({ date: addDays(start, 8), valueC: 36.48 });
nights.push({ date: addDays(start, 9), valueC: 36.49 });
const result = predictCycle(
cycles,
[],
BASE_PROFILE,
addDays(lastStart, 20),
nights,
);
expect(result.ovulationConfirmed).toBe(true);
expect(result.method).toBe("BLENDED");
expect(result.predictedOvulation).toBe(addDays(start, 5));
});
});
describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => {
function bbtDay(
date: string,
t: number,
mucus: DayLogInput["cervicalMucus"] = null,
): DayLogInput {
return {
date,
flow: null,
basalBodyTempC: t,
ovulationTest: null,
cervicalMucus: mucus,
};
}
/** Build a confirmable symptothermal shift (temp + agreeing mucus) on `ovDay`. */
function symptothermalAround(ovDay: string): DayLogInput[] {
const logs: DayLogInput[] = [];
const riseStart = addDays(ovDay, 1);
const baselineStart = addDays(riseStart, -6);
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) =>
logs.push(bbtDay(addDays(baselineStart, i), t)),
);
logs.push(bbtDay(riseStart, 36.6));
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
const mucusDay = (
date: string,
m: DayLogInput["cervicalMucus"],
): DayLogInput => ({
date,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: m,
});
// Peak on ovDay confirmed by 3 drier observed days (Sensiplan post-peak count).
logs.push(mucusDay(ovDay, "EGG_WHITE"));
logs.push(mucusDay(addDays(ovDay, 1), "STICKY"));
logs.push(mucusDay(addDays(ovDay, 2), "DRY"));
logs.push(mucusDay(addDays(ovDay, 3), "DRY"));
return logs;
}
it("does not confirm a STALE prior-cycle ovulation for a multi-cycle user", () => {
// Three confirmed cycles; last start 2024-03-25, today well into the
// current cycle. The ONLY BBT/mucus data is a complete shift back in the
// FIRST cycle (months ago). Pre-fix, detectTempShift returned that stale
// shift and nextPeriodStart landed in the past.
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
expect(lastStart).toBe("2024-03-25");
const staleOv = addDays("2024-01-01", 13); // 2024-01-14, two cycles back
const logs = symptothermalAround(staleOv);
const today = addDays(lastStart, 16);
const result = predictCycle(cycles, logs, BASE_PROFILE, today);
// Stale signal is outside [lastStart − BBT_WINDOW, today] → ignored.
expect(result.ovulationConfirmed).toBe(false);
expect(result.method).toBe("CALENDAR");
// Calendar next-start is in the future, never in the past.
expect(result.nextPeriodStart >= today).toBe(true);
});
it("confirms the CURRENT-cycle shift even when a prior-cycle shift also exists", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate; // 2024-03-25
const staleOv = addDays("2024-01-01", 13); // months ago
const currentOv = addDays(lastStart, 13); // current cycle
const logs = [
...symptothermalAround(staleOv),
...symptothermalAround(currentOv),
];
const result = predictCycle(
cycles,
logs,
BASE_PROFILE,
addDays(lastStart, 16),
);
expect(result.ovulationConfirmed).toBe(true);
expect(result.predictedOvulation).toBe(currentOv);
expect(result.nextPeriodStart).toBe(addDays(currentOv, 14));
});
it("detectTempShift returns the LATEST qualifying shift, not the earliest", () => {
const logs: DayLogInput[] = [];
// First shift around 2024-01-07.
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(bbtDay(addDays(start, 6), 36.6));
logs.push(bbtDay(addDays(start, 7), 36.61));
logs.push(bbtDay(addDays(start, 8), 36.62));
// Drop back to baseline, then a SECOND shift a month later.
const start2 = "2024-02-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start2, i), t)),
);
logs.push(bbtDay(addDays(start2, 6), 36.6));
logs.push(bbtDay(addDays(start2, 7), 36.61));
logs.push(bbtDay(addDays(start2, 8), 36.62));
const shift = detectTempShift(logs, 0.2);
// Latest match → day before the second rise onset (2024-02-07 → 2024-02-06).
expect(shift?.ovulationDate).toBe(addDays(start2, 5));
});
});
describe("cycle/prediction — luteal clamp single source of truth (QA HIGH)", () => {
it("clampLuteal pins a raw value to [10, 16]", () => {
expect(clampLuteal(8)).toBe(10);
expect(clampLuteal(20)).toBe(16);
expect(clampLuteal(13)).toBe(13);
});
it("resolveLuteal applies the default then the clamp", () => {
expect(resolveLuteal({ lutealPhaseLength: null })).toBe(14);
expect(resolveLuteal({ lutealPhaseLength: 8 })).toBe(10);
expect(resolveLuteal({ lutealPhaseLength: 19 })).toBe(16);
});
it("predictCycle ovulation uses the CLAMPED luteal for an out-of-clamp profile", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
// Profile asks for luteal 8 → engine clamps to 10.
const profile: CycleProfileInput = {
...BASE_PROFILE,
lutealPhaseLength: 8,
};
const result = predictCycle(cycles, [], profile, addDays(lastStart, 5));
// predictedOvulation = nextStart − clampedLuteal(10).
expect(result.predictedOvulation).toBe(
addDays(result.nextPeriodStart, -10),
);
});
});
describe("cycle/dto — toCyclePredictionDTO still-learning honesty gate (M-1)", () => {
/** A fully-populated engine result; `stillLearning` is overridden per case. */
function makeResult(
overrides: Partial<CyclePredictionResult> = {},
): CyclePredictionResult {
return {
method: "CALENDAR",
nextPeriodStart: "2024-02-01",
nextPeriodStartLow: "2024-01-30",
nextPeriodStartHigh: "2024-02-03",
predictedPeriodLength: 5,
fertileWindowStart: "2024-01-14",
fertileWindowEnd: "2024-01-20",
predictedOvulation: "2024-01-18",
ovulationConfirmed: true,
confidence: 0.6,
confidenceLabel: "medium",
cyclesObserved: 5,
stillLearning: false,
estimatedCycleLength: 28,
estimatedCycleSd: 1.5,
...overrides,
};
}
it("suppresses the fertile window + predicted ovulation on the wire while still learning (<3 cycles), even when the goal allows it", () => {
const result = makeResult({ stillLearning: true, cyclesObserved: 2 });
const dto = toCyclePredictionDTO(result, /* goalAllowsFertile */ true, "d");
expect(dto.fertileWindowStart).toBeNull();
expect(dto.fertileWindowEnd).toBeNull();
expect(dto.predictedOvulation).toBeNull();
// The honesty flag still reaches the client so it can paint the calm state.
expect(dto.stillLearning).toBe(true);
});
it("populates the fertile window + predicted ovulation once learning completes (>=3 cycles) and the goal allows it", () => {
const result = makeResult({ stillLearning: false, cyclesObserved: 3 });
const dto = toCyclePredictionDTO(result, /* goalAllowsFertile */ true, "d");
expect(dto.fertileWindowStart).toBe("2024-01-14");
expect(dto.fertileWindowEnd).toBe("2024-01-20");
expect(dto.predictedOvulation).toBe("2024-01-18");
expect(dto.stillLearning).toBe(false);
});
it("keeps ovulationConfirmed goal-gated only — unchanged by the still-learning gate", () => {
// Still learning but goal allows: a confirmed shift is observed data, not a
// prior, so it stays surfaced even while the predicted window is suppressed.
const learning = toCyclePredictionDTO(
makeResult({ stillLearning: true, ovulationConfirmed: true }),
true,
"d",
);
expect(learning.ovulationConfirmed).toBe(true);
// Goal forbids the fertile window → confirmation drops regardless of learning.
const goalGated = toCyclePredictionDTO(
makeResult({ stillLearning: false, ovulationConfirmed: true }),
false,
"d",
);
expect(goalGated.ovulationConfirmed).toBe(false);
expect(goalGated.fertileWindowStart).toBeNull();
expect(goalGated.predictedOvulation).toBeNull();
});
});
describe("cycle/prediction — the fertile window's width (A10)", () => {
it("spans seven days, ending the day after the ovulation estimate", () => {
// The comment on the constants used to say six days and the constants made
// seven. The width is now a decision with a reason written at the constant,
// so it is pinned here rather than left to be re-read off the same numbers
// the code uses: five days before the estimate, the estimate, and one after.
const cycles = cyclesFromGaps("2026-01-01", [28, 28, 28]);
const p = predictCycle(cycles, [], BASE_PROFILE, "2026-03-20", []);
expect(p.predictedOvulation).not.toBeNull();
expect(p.fertileWindowStart).toBe(addDays(p.predictedOvulation!, -5));
expect(p.fertileWindowEnd).toBe(addDays(p.predictedOvulation!, 1));
const span =
(Date.parse(`${p.fertileWindowEnd}T12:00:00Z`) -
Date.parse(`${p.fertileWindowStart}T12:00:00Z`)) /
86_400_000;
// Inclusive of both ends.
expect(span + 1).toBe(7);
});
it("keeps the extra day on the side that costs least to be wrong about", () => {
// The trailing day is estimator slack plus ovum viability. Dropping it
// would shorten the window for somebody avoiding pregnancy, which is the
// direction that misses.
expect(FERTILE_PRE).toBe(5);
expect(FERTILE_POST).toBe(1);
});
});