148 lines
5.4 KiB
TypeScript
148 lines
5.4 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { estimate, totalActiveDays, activeDaysConsumed } from "./engine";
|
|
import type { Config, Selections } from "./types";
|
|
import { parseISODate } from "./dates";
|
|
|
|
function d(iso: string): Date {
|
|
const parsed = parseISODate(iso);
|
|
if (!parsed) throw new Error(`bad test date ${iso}`);
|
|
return parsed;
|
|
}
|
|
|
|
// A simple 100-day window (Jan 1 .. Apr 10, 2026 is 100 inclusive days).
|
|
const config: Config = {
|
|
mealStartDate: d("2026-01-01"),
|
|
mealEndDate: d("2026-04-10"),
|
|
plans: [
|
|
{ id: "green", displayName: "Green", totalMealBlocks: 200, totalFlexDollars: 500 },
|
|
],
|
|
missingPeriods: [
|
|
{ id: "break", displayName: "Break", startDate: d("2026-02-01"), endDate: d("2026-02-10") }, // 10 days
|
|
],
|
|
};
|
|
|
|
function sel(overrides: Partial<Selections> = {}): Selections {
|
|
return {
|
|
selectedPlanId: "green",
|
|
targetDate: d("2026-04-10"),
|
|
activeMissingPeriodIds: new Set(),
|
|
extraExcludedDays: 0,
|
|
actualMealBlocks: null,
|
|
actualFlexDollars: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("totalActiveDays", () => {
|
|
it("counts the full inclusive window with no missing periods", () => {
|
|
expect(totalActiveDays(config, new Set())).toBe(100);
|
|
});
|
|
|
|
it("subtracts an active missing period", () => {
|
|
expect(totalActiveDays(config, new Set(["break"]))).toBe(90);
|
|
});
|
|
});
|
|
|
|
describe("activeDaysConsumed", () => {
|
|
it("is the full window at the end date", () => {
|
|
expect(activeDaysConsumed(config, sel())).toBe(100);
|
|
});
|
|
|
|
it("clamps a target date before the start to zero", () => {
|
|
expect(activeDaysConsumed(config, sel({ targetDate: d("2025-12-01") }))).toBe(0);
|
|
});
|
|
|
|
it("subtracts extra excluded days", () => {
|
|
expect(activeDaysConsumed(config, sel({ extraExcludedDays: 5 }))).toBe(95);
|
|
});
|
|
});
|
|
|
|
describe("estimate", () => {
|
|
it("conserves the budget at the end date with no exclusions", () => {
|
|
const r = estimate(config, sel());
|
|
expect(r.consumedMealBlocks).toBeCloseTo(200);
|
|
expect(r.remainingMealBlocks).toBeCloseTo(0);
|
|
expect(r.consumedFlexDollars).toBeCloseTo(500);
|
|
expect(r.remainingFlexDollars).toBeCloseTo(0);
|
|
});
|
|
|
|
it("consumed + remaining always equals the plan total", () => {
|
|
const r = estimate(config, sel({ targetDate: d("2026-02-15"), activeMissingPeriodIds: new Set(["break"]) }));
|
|
expect(r.consumedMealBlocks + r.remainingMealBlocks).toBeCloseTo(200);
|
|
expect(r.consumedFlexDollars + r.remainingFlexDollars).toBeCloseTo(500);
|
|
});
|
|
|
|
it("computes a per-week rate of dailyRate * 7", () => {
|
|
const r = estimate(config, sel());
|
|
// 200 blocks / 100 days = 2/day => 14/week
|
|
expect(r.perWeekMealBlocks).toBeCloseTo(14);
|
|
expect(r.perWeekFlexDollars).toBeCloseTo(35); // 500/100*7
|
|
});
|
|
|
|
it("never lets excluding days increase consumption (monotonicity)", () => {
|
|
const base = estimate(config, sel({ targetDate: d("2026-03-01") }));
|
|
const more = estimate(config, sel({ targetDate: d("2026-03-01"), extraExcludedDays: 10 }));
|
|
expect(more.consumedMealBlocks).toBeLessThanOrEqual(base.consumedMealBlocks);
|
|
});
|
|
|
|
it("handles zero active days without dividing by zero", () => {
|
|
const allExcluded: Config = {
|
|
...config,
|
|
missingPeriods: [
|
|
{ id: "all", displayName: "All", startDate: d("2026-01-01"), endDate: d("2026-04-10") },
|
|
],
|
|
};
|
|
const r = estimate(allExcluded, sel({ activeMissingPeriodIds: new Set(["all"]) }));
|
|
expect(r.perWeekMealBlocks).toBe(0);
|
|
expect(r.consumedMealBlocks).toBe(0);
|
|
expect(Number.isFinite(r.remainingMealBlocks)).toBe(true);
|
|
});
|
|
|
|
it("reports activeDaysRemaining as total minus consumed", () => {
|
|
// Halfway: Jan 1 .. Feb 19 is 50 inclusive days of a 100-day window.
|
|
const r = estimate(config, sel({ targetDate: d("2026-02-19") }));
|
|
expect(r.activeDaysConsumed).toBe(50);
|
|
expect(r.activeDaysRemaining).toBe(50);
|
|
});
|
|
|
|
it("returns null comparisons when no actual balance is entered", () => {
|
|
const r = estimate(config, sel());
|
|
expect(r.blockComparison).toBeNull();
|
|
expect(r.flexComparison).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("balance comparison", () => {
|
|
// Halfway through the 100-day window: expected remaining = 100 blocks / 250 flex.
|
|
const halfway = () => sel({ targetDate: d("2026-02-19") });
|
|
|
|
it("flags a higher-than-expected balance as ahead", () => {
|
|
const r = estimate(config, { ...halfway(), actualMealBlocks: 130 });
|
|
expect(r.blockComparison?.status).toBe("ahead");
|
|
expect(r.blockComparison?.difference).toBeCloseTo(30);
|
|
});
|
|
|
|
it("flags a lower-than-expected balance as behind", () => {
|
|
const r = estimate(config, { ...halfway(), actualMealBlocks: 70 });
|
|
expect(r.blockComparison?.status).toBe("behind");
|
|
expect(r.blockComparison?.difference).toBeCloseTo(-30);
|
|
});
|
|
|
|
it("flags a matching balance as on track", () => {
|
|
const r = estimate(config, { ...halfway(), actualMealBlocks: 100 });
|
|
expect(r.blockComparison?.status).toBe("onTrack");
|
|
});
|
|
|
|
it("suggests a go-forward per-week rate from the actual balance", () => {
|
|
// 130 blocks over 50 remaining days => 2.6/day => 18.2/week.
|
|
const r = estimate(config, { ...halfway(), actualMealBlocks: 130 });
|
|
expect(r.blockComparison?.suggestedPerWeek).toBeCloseTo(18.2, 1);
|
|
});
|
|
|
|
it("suggests null per-week when no active days remain", () => {
|
|
const r = estimate(config, { ...sel(), actualMealBlocks: 0 });
|
|
expect(r.activeDaysRemaining).toBe(0);
|
|
expect(r.blockComparison?.suggestedPerWeek).toBeNull();
|
|
});
|
|
});
|