flowtrack/tests/test_cycle.js

155 lines
6.9 KiB
JavaScript
Raw Permalink Normal View History

const assert = require("node:assert/strict");
const cycle = require("../static/cycle.js");
const { toDay, toIso } = cycle;
// Logs `length` period days starting at each date.
function logPeriods(starts, length = 5, flow = "medium") {
const days = {};
for (const start of starts) {
for (let offset = 0; offset < length; offset += 1) days[toIso(toDay(start) + offset)] = { flow, symptoms: [] };
}
return days;
}
// Date helpers round-trip across month, year and leap-day boundaries.
for (const iso of ["2024-02-29", "2025-12-31", "2026-03-29", "2026-10-25"]) assert.equal(toIso(toDay(iso)), iso);
assert.equal(toDay("2026-03-01") - toDay("2026-02-28"), 1);
// Period days join into periods; one unlogged day does not split a period,
// spotting never counts, and two missing days do split it.
{
const days = logPeriods(["2026-01-01"], 3);
days["2026-01-05"] = { flow: "light", symptoms: [] };
days["2026-01-10"] = { flow: "spotting", symptoms: [] };
days["2026-01-13"] = { flow: "period", symptoms: [] };
const periods = cycle.periods(days);
assert.deepEqual(periods.map(period => [toIso(period.start), period.length]), [["2026-01-01", 5], ["2026-01-13", 1]]);
}
// Nothing logged: no predictions at all.
{
const summary = cycle.summarize({}, "2026-10-18");
assert.equal(summary.current, null);
assert.equal(summary.confidence, "none");
assert.deepEqual(summary.predictions, []);
}
// One period: falls back to 28 days with low confidence and a wide range.
{
const summary = cycle.summarize(logPeriods(["2026-10-01"]), "2026-10-10");
assert.equal(summary.confidence, "none");
assert.equal(summary.cycleLength, 28);
assert.equal(toIso(summary.predictions[0].start), "2026-10-29");
assert.equal(summary.predictions[0].latest - summary.predictions[0].start, 4);
assert.equal(summary.current.cycleDay, 10);
}
// Regular 30-day cycles predict 30 days ahead, with the minimum ±2 range
// that widens for later cycles, and ovulation 14 days before each period.
{
const starts = ["2026-04-01", "2026-05-01", "2026-05-31", "2026-06-30", "2026-07-30", "2026-08-29", "2026-09-28"];
const summary = cycle.summarize(logPeriods(starts), "2026-10-10");
assert.equal(summary.confidence, "normal");
assert.equal(summary.sampleSize, 6);
assert.equal(summary.cycleLength, 30);
assert.equal(summary.periodLength, 5);
const [next, second] = summary.predictions;
assert.equal(toIso(next.start), "2026-10-28");
assert.equal(toIso(next.end), "2026-11-01");
assert.equal(next.start - next.earliest, 2);
assert.equal(toIso(second.start), "2026-11-27");
assert.equal(second.start - second.earliest, 3);
assert.equal(summary.predictions.length, 6);
const window = summary.fertile[0];
assert.equal(toIso(window.ovulation), "2026-10-14");
assert.equal(toIso(window.start), "2026-10-09");
assert.equal(toIso(window.end), "2026-10-15");
}
// Recent cycles weigh more: a shift from 26 to 32 days pulls the mean
// above the plain average of 29.
{
const starts = ["2026-01-01", "2026-01-27", "2026-02-22", "2026-03-20", "2026-04-21", "2026-05-23", "2026-06-24"];
const summary = cycle.summarize(logPeriods(starts), "2026-07-01");
assert.ok(summary.cycleLength >= 30, `expected recent cycles to dominate, got ${summary.cycleLength}`);
}
// A gap from a forgotten period (two cycles' worth) is excluded from the average.
{
const starts = ["2026-01-01", "2026-01-29", "2026-04-23", "2026-05-21", "2026-06-18"];
const summary = cycle.summarize(logPeriods(starts), "2026-06-20");
assert.deepEqual(summary.cycles.map(item => item.used), [true, false, true, true]);
assert.equal(summary.cycleLength, 28);
}
// Late: once the expected day has passed, the next period is expected from
// tomorrow, while this cycle's fertile window stays where it was.
{
const starts = ["2026-07-01", "2026-07-29", "2026-08-26", "2026-09-23"];
const summary = cycle.summarize(logPeriods(starts), "2026-10-24");
assert.equal(summary.late, 3);
assert.equal(toIso(summary.predictions[0].start), "2026-10-25");
assert.equal(toIso(summary.fertile[0].ovulation), "2026-10-07");
}
// Due today is not late yet.
{
const summary = cycle.summarize(logPeriods(["2026-08-26", "2026-09-23"]), "2026-10-21");
assert.equal(summary.late, 0);
assert.equal(toIso(summary.predictions[0].start), "2026-10-21");
}
// An ongoing period is flagged and does not count towards period length yet.
{
const days = logPeriods(["2026-08-01", "2026-08-29"], 6);
Object.assign(days, logPeriods(["2026-09-26"], 2));
const summary = cycle.summarize(days, "2026-09-27");
assert.equal(summary.current.onPeriod, true);
assert.equal(summary.periodLength, 6);
}
// Days logged after "today" (a phone ahead of the server) are ignored.
{
const summary = cycle.summarize(logPeriods(["2026-09-01", "2026-10-19"]), "2026-10-18");
assert.equal(toIso(summary.current.start), "2026-09-01");
}
// Very short cycles never place the fertile window inside the period.
{
const summary = cycle.summarize(logPeriods(["2026-08-01", "2026-08-19", "2026-09-06", "2026-09-24"], 5), "2026-09-30");
for (const window of summary.fertile) assert.ok(window.start > toDay("2026-09-28"));
}
// The forecast has no horizon: it runs to any date, keeps cycles evenly
// spaced, fades predictions beyond six weeks and caps the range at ±14.
{
const starts = ["2026-04-01", "2026-05-01", "2026-05-31", "2026-06-30", "2026-07-30", "2026-08-29", "2026-09-28"];
const summary = cycle.summarize(logPeriods(starts), "2026-10-10");
const { predictions, fertile } = cycle.forecast(summary, toDay("2028-12-31"));
assert.ok(predictions.length > 25);
assert.ok(predictions[predictions.length - 1].start <= toDay("2028-12-31"));
assert.deepEqual(predictions.slice(0, 6), summary.predictions);
for (let index = 1; index < predictions.length; index += 1) assert.equal(predictions[index].start - predictions[index - 1].start, 30);
assert.deepEqual(predictions.slice(0, 3).map(item => item.faded), [false, true, true], "28 Oct is within six weeks, 27 Nov is not");
assert.ok(predictions.every(item => item.latest - item.start <= 14));
assert.equal(fertile.length, predictions.length);
assert.equal(fertile[0].faded, false);
assert.equal(cycle.forecast(cycle.summarize({}, "2026-10-10"), toDay("2030-01-01")).predictions.length, 0);
}
// The usual strength per period day averages rated days only: unrated
// ("period") days and days nobody logged that far into a period stay null.
{
const days = {};
const rate = (start, flows) => flows.forEach((flow, offset) => { days[toIso(toDay(start) + offset)] = { flow, symptoms: [] }; });
rate("2026-08-01", ["heavy", "heavy", "medium", "light"]);
rate("2026-08-29", ["medium", "heavy", "period", "light", "light"]);
const summary = cycle.summarize(days, "2026-09-20");
assert.deepEqual(summary.flowProfile, [3, 3, 2, 1, 1]);
assert.equal(summary.periodSamples, 2);
assert.equal(summary.periodLength, 5);
}
console.log("cycle tests passed");