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");