import { describe, it, expect } from "vitest"; import { minutesOutsideWindow, outOfWindowGap, type PlanTimeframes } from "./index.js"; // The "tariff bridge" gap for a subscriber scan outside their allowed window. UTC tz // keeps the wall-clock arithmetic obvious in the tests. See wiki/entities/subscription.md. // Night plan: window 20:00→08:00 (wraps midnight) on weekdays (Mon..Fri). Days not in the // set (Sat/Sun) are unrestricted — no charge. const night: PlanTimeframes = { days: [1, 2, 3, 4, 5], // Mon..Fri fromMin: 20 * 60, toMin: 8 * 60, // 1200 → 480 graceMin: 0, tz: "UTC", }; // 2026-06-22 is a Monday; 2026-06-20 is a Saturday. const monday = (hhmm: string) => `2026-06-22T${hhmm}:00.000Z`; const saturday = (hhmm: string) => `2026-06-20T${hhmm}:00.000Z`; describe("outOfWindowGap — entry edge (early arrival)", () => { it("19:30 arrival to a 20:00 window owes 30 min", () => { const g = outOfWindowGap(night, "UTC", monday("19:30"), "entry"); expect(g).not.toBeNull(); expect(g!.minutes).toBe(30); expect(g!.start).toBe(monday("19:30")); expect(g!.end).toBe(monday("20:00")); }); it("09:00 daytime arrival owes the whole gap to 20:00 (11h)", () => { const g = outOfWindowGap(night, "UTC", monday("09:00"), "entry"); expect(g!.minutes).toBe(11 * 60); expect(g!.end).toBe(monday("20:00")); }); it("in-window arrival (22:00) owes nothing", () => { expect(outOfWindowGap(night, "UTC", monday("22:00"), "entry")).toBeNull(); }); it("after-midnight in-window arrival (02:00) owes nothing", () => { expect(outOfWindowGap(night, "UTC", monday("02:00"), "entry")).toBeNull(); }); }); describe("outOfWindowGap — exit edge (late departure)", () => { it("08:45 exit after an 08:00 window close owes 45 min", () => { const g = outOfWindowGap(night, "UTC", monday("08:45"), "exit"); expect(g!.minutes).toBe(45); expect(g!.start).toBe(monday("08:00")); expect(g!.end).toBe(monday("08:45")); }); it("in-window exit (07:00) owes nothing", () => { expect(outOfWindowGap(night, "UTC", monday("07:00"), "exit")).toBeNull(); }); }); describe("outOfWindowGap — days the window doesn't apply", () => { it("a Saturday scan is free (window only Mon..Fri)", () => { expect(outOfWindowGap(night, "UTC", saturday("09:00"), "entry")).toBeNull(); expect(outOfWindowGap(night, "UTC", saturday("23:30"), "exit")).toBeNull(); }); it("a window with no days (every day) DOES apply on Saturday", () => { const everyDay: PlanTimeframes = { fromMin: 1200, toMin: 480, tz: "UTC" }; expect(outOfWindowGap(everyDay, "UTC", saturday("09:00"), "entry")).not.toBeNull(); }); it("an arbitrary day set (e.g. only Saturday) applies just then", () => { const satOnly: PlanTimeframes = { days: [6], fromMin: 1200, toMin: 480, tz: "UTC" }; expect(outOfWindowGap(satOnly, "UTC", saturday("09:00"), "entry")).not.toBeNull(); expect(outOfWindowGap(satOnly, "UTC", monday("09:00"), "entry")).toBeNull(); }); }); describe("outOfWindowGap — grace tolerance", () => { const withGrace: PlanTimeframes = { ...night, graceMin: 15 }; it("19:50 entry (10 min before open) is within a 15-min grace → no charge", () => { expect(outOfWindowGap(withGrace, "UTC", monday("19:50"), "entry")).toBeNull(); }); it("19:30 entry (30 min before) still charged, minus 15 grace = 15 min", () => { const g = outOfWindowGap(withGrace, "UTC", monday("19:30"), "entry"); expect(g!.minutes).toBe(15); }); it("08:10 exit within a 15-min grace of the 08:00 close → no charge", () => { expect(outOfWindowGap(withGrace, "UTC", monday("08:10"), "exit")).toBeNull(); }); }); describe("outOfWindowGap — unrestricted", () => { it("null timeframes → never a charge", () => { expect(outOfWindowGap(null, "UTC", monday("09:00"), "entry")).toBeNull(); }); }); describe("minutesOutsideWindow — the charge basis (regression: no phantom span)", () => { // entered 30 min before the 20:00 window; STILL inside, only a few minutes elapsed. it("early entry, barely elapsed → only the elapsed pre-window minutes (NOT back to a prior close)", () => { // 19:30 entry, now 19:33 → 3 minutes outside (the bug charged ~12h here). expect(minutesOutsideWindow(night, "UTC", monday("19:30"), monday("19:33"))).toBe(3); }); it("early entry until the window opens = the full pre-window gap, then 0 inside", () => { // 19:30 → 22:00: 30 min outside (19:30→20:00), the rest in-window. expect(minutesOutsideWindow(night, "UTC", monday("19:30"), monday("22:00"))).toBe(30); }); it("in-window the whole stay → 0", () => { expect(minutesOutsideWindow(night, "UTC", monday("22:00"), monday("23:30"))).toBe(90 - 90); // fully inside }); it("late exit after close adds the post-close minutes", () => { // enter 22:00 (in window), exit 08:30 → 30 min outside (08:00→08:30). expect(minutesOutsideWindow(night, "UTC", monday("22:00"), `2026-06-23T08:30:00.000Z`)).toBe(30); }); it("a day the window doesn't apply contributes 0 (weekend free)", () => { expect(minutesOutsideWindow(night, "UTC", saturday("09:00"), saturday("18:00"))).toBe(0); }); it("unrestricted plan → 0", () => { expect(minutesOutsideWindow(null, "UTC", monday("09:00"), monday("23:00"))).toBe(0); }); });