Add getDecisionWithOverrides() function that checks manual overrides before algorithmic rules. Overrides are applied in priority order: flare > stress > sleep > pms, and all force REST status. Includes comprehensive test suite with 24 tests covering: - All 8 algorithmic priority rules - Override type behaviors - Override priority enforcement - Empty override fallthrough to algorithmic rules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
243 lines
8.5 KiB
TypeScript
243 lines
8.5 KiB
TypeScript
// ABOUTME: Unit tests for the training decision engine.
|
|
// ABOUTME: Tests all 8 priority rules and 4 override scenarios.
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import type { DailyData, OverrideType } from "@/types";
|
|
import {
|
|
getDecisionWithOverrides,
|
|
getTrainingDecision,
|
|
} from "./decision-engine";
|
|
|
|
// Helper to create baseline "healthy" data where TRAIN would be the decision
|
|
function createHealthyData(): DailyData {
|
|
return {
|
|
hrvStatus: "Balanced",
|
|
bbYesterdayLow: 50, // above 30
|
|
phase: "FOLLICULAR", // not LATE_LUTEAL or MENSTRUAL
|
|
weekIntensity: 0, // well below limit
|
|
phaseLimit: 120,
|
|
bbCurrent: 90, // above 85
|
|
};
|
|
}
|
|
|
|
describe("getTrainingDecision (algorithmic rules)", () => {
|
|
describe("Priority 1: HRV Unbalanced", () => {
|
|
it("forces REST when HRV is Unbalanced", () => {
|
|
const data = createHealthyData();
|
|
data.hrvStatus = "Unbalanced";
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("REST");
|
|
expect(result.reason).toContain("HRV");
|
|
});
|
|
});
|
|
|
|
describe("Priority 2: Body Battery Yesterday Low", () => {
|
|
it("forces REST when BB yesterday low is below 30", () => {
|
|
const data = createHealthyData();
|
|
data.bbYesterdayLow = 25;
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("REST");
|
|
expect(result.reason).toContain("BB");
|
|
});
|
|
});
|
|
|
|
describe("Priority 3: Late Luteal Phase", () => {
|
|
it("forces GENTLE during late luteal phase", () => {
|
|
const data = createHealthyData();
|
|
data.phase = "LATE_LUTEAL";
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("GENTLE");
|
|
expect(result.reason).toContain("rebounding");
|
|
});
|
|
});
|
|
|
|
describe("Priority 4: Menstrual Phase", () => {
|
|
it("forces GENTLE during menstrual phase", () => {
|
|
const data = createHealthyData();
|
|
data.phase = "MENSTRUAL";
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("GENTLE");
|
|
expect(result.reason).toContain("rebounding");
|
|
});
|
|
});
|
|
|
|
describe("Priority 5: Weekly Intensity Limit", () => {
|
|
it("forces REST when weekly intensity meets limit", () => {
|
|
const data = createHealthyData();
|
|
data.weekIntensity = 120; // equals phaseLimit
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("REST");
|
|
expect(result.reason).toContain("LIMIT");
|
|
});
|
|
|
|
it("forces REST when weekly intensity exceeds limit", () => {
|
|
const data = createHealthyData();
|
|
data.weekIntensity = 150; // exceeds phaseLimit
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("REST");
|
|
});
|
|
});
|
|
|
|
describe("Priority 6: Body Battery Current Low", () => {
|
|
it("forces LIGHT when current BB is below 75", () => {
|
|
const data = createHealthyData();
|
|
data.bbCurrent = 70;
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("LIGHT");
|
|
expect(result.reason).toContain("BB");
|
|
});
|
|
});
|
|
|
|
describe("Priority 7: Body Battery Current Medium", () => {
|
|
it("forces REDUCED when current BB is below 85 but above 74", () => {
|
|
const data = createHealthyData();
|
|
data.bbCurrent = 80;
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("REDUCED");
|
|
expect(result.reason).toContain("25%");
|
|
});
|
|
});
|
|
|
|
describe("Priority 8: Default", () => {
|
|
it("returns TRAIN when all conditions are favorable", () => {
|
|
const data = createHealthyData();
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("TRAIN");
|
|
expect(result.reason).toContain("OK to train");
|
|
});
|
|
});
|
|
|
|
describe("Priority enforcement", () => {
|
|
it("HRV Unbalanced takes precedence over low BB yesterday", () => {
|
|
const data = createHealthyData();
|
|
data.hrvStatus = "Unbalanced";
|
|
data.bbYesterdayLow = 25;
|
|
const result = getTrainingDecision(data);
|
|
expect(result.reason).toContain("HRV");
|
|
});
|
|
|
|
it("low BB yesterday takes precedence over phase-based rules", () => {
|
|
const data = createHealthyData();
|
|
data.bbYesterdayLow = 25;
|
|
data.phase = "LATE_LUTEAL";
|
|
const result = getTrainingDecision(data);
|
|
expect(result.status).toBe("REST");
|
|
expect(result.reason).toContain("BB");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getDecisionWithOverrides", () => {
|
|
describe("override types force REST", () => {
|
|
it("flare override forces REST", () => {
|
|
const data = createHealthyData();
|
|
const overrides: OverrideType[] = ["flare"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.status).toBe("REST");
|
|
expect(result.reason).toContain("flare");
|
|
});
|
|
|
|
it("stress override forces REST", () => {
|
|
const data = createHealthyData();
|
|
const overrides: OverrideType[] = ["stress"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.status).toBe("REST");
|
|
expect(result.reason).toContain("stress");
|
|
});
|
|
|
|
it("sleep override forces REST", () => {
|
|
const data = createHealthyData();
|
|
const overrides: OverrideType[] = ["sleep"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.status).toBe("REST");
|
|
expect(result.reason).toContain("sleep");
|
|
});
|
|
|
|
it("pms override forces REST", () => {
|
|
const data = createHealthyData();
|
|
const overrides: OverrideType[] = ["pms"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.status).toBe("REST");
|
|
expect(result.reason).toContain("pms");
|
|
});
|
|
});
|
|
|
|
describe("override priority (flare > stress > sleep > pms)", () => {
|
|
it("flare takes precedence over stress", () => {
|
|
const data = createHealthyData();
|
|
const overrides: OverrideType[] = ["stress", "flare"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.reason).toContain("flare");
|
|
expect(result.reason).not.toContain("stress");
|
|
});
|
|
|
|
it("stress takes precedence over sleep", () => {
|
|
const data = createHealthyData();
|
|
const overrides: OverrideType[] = ["sleep", "stress"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.reason).toContain("stress");
|
|
expect(result.reason).not.toContain("sleep");
|
|
});
|
|
|
|
it("sleep takes precedence over pms", () => {
|
|
const data = createHealthyData();
|
|
const overrides: OverrideType[] = ["pms", "sleep"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.reason).toContain("sleep");
|
|
expect(result.reason).not.toContain("pms");
|
|
});
|
|
|
|
it("respects full priority chain with all overrides", () => {
|
|
const data = createHealthyData();
|
|
const overrides: OverrideType[] = ["pms", "sleep", "stress", "flare"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.reason).toContain("flare");
|
|
});
|
|
});
|
|
|
|
describe("overrides bypass algorithmic rules", () => {
|
|
it("flare override bypasses favorable conditions", () => {
|
|
const data = createHealthyData();
|
|
// Normally this would return TRAIN
|
|
const overrides: OverrideType[] = ["flare"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.status).toBe("REST");
|
|
});
|
|
|
|
it("stress override works even with perfect biometrics", () => {
|
|
const data = createHealthyData();
|
|
data.bbCurrent = 100;
|
|
data.bbYesterdayLow = 80;
|
|
const overrides: OverrideType[] = ["stress"];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.status).toBe("REST");
|
|
});
|
|
});
|
|
|
|
describe("empty overrides fall through to algorithmic rules", () => {
|
|
it("returns TRAIN with no overrides and healthy data", () => {
|
|
const data = createHealthyData();
|
|
const overrides: OverrideType[] = [];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.status).toBe("TRAIN");
|
|
});
|
|
|
|
it("returns REST for HRV Unbalanced with no overrides", () => {
|
|
const data = createHealthyData();
|
|
data.hrvStatus = "Unbalanced";
|
|
const overrides: OverrideType[] = [];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.status).toBe("REST");
|
|
expect(result.reason).toContain("HRV");
|
|
});
|
|
|
|
it("algorithmic rules apply when overrides array is empty", () => {
|
|
const data = createHealthyData();
|
|
data.bbCurrent = 70;
|
|
const overrides: OverrideType[] = [];
|
|
const result = getDecisionWithOverrides(data, overrides);
|
|
expect(result.status).toBe("LIGHT");
|
|
});
|
|
});
|
|
});
|