Repository Public
1import { describe, expect, it } from "vitest";2import { checkSlot, collides, computeSlots, mergeIntervals, openWindows, subtractIntervals, type EngineInput, type SlotParams } from "@/lib/engine/slots";34const TZ = "America/Toronto";5const ms = (iso: string) => Temporal.Instant.from(iso).epochMilliseconds;6const local = (tz: string, t: number) => Temporal.Instant.fromEpochMilliseconds(t).toZonedDateTimeISO(tz).toPlainDateTime().toString({ smallestUnit: "minute" });78const base: SlotParams = { timezone: TZ, durationMin: 30, slotStepMin: 30, bufferBeforeMin: 0, bufferAfterMin: 0, minNoticeMin: 0, horizonDays: 365 };9function input(over: Omit<Partial<EngineInput>, "params"> & { params?: Partial<SlotParams> } = {}): EngineInput {10 return {11 rules: [], exceptions: [], taken: [], counted: [],12 now: ms("2026-11-01T12:00:00Z"), fromDate: "2026-11-16", toDate: "2026-11-16",13 ...over, params: { ...base, ...(over.params ?? {}) },14 };15}16const monday9to12 = [{ weekday: 1, startMinute: 540, endMinute: 720 }];1718describe("interval algebra", () => {19 it("merges and subtracts", () => {20 expect(mergeIntervals([{ start: 5, end: 9 }, { start: 0, end: 5 }, { start: 20, end: 30 }])).toEqual([{ start: 0, end: 9 }, { start: 20, end: 30 }]);21 expect(subtractIntervals([{ start: 0, end: 100 }], [{ start: 10, end: 20 }, { start: 90, end: 120 }])).toEqual([{ start: 0, end: 10 }, { start: 20, end: 90 }]);22 });23});2425describe("acceptance 1 — Monday 9–12 with 10–11 blocked", () => {26 it("offers only 9:00, 9:30, 11:00, 11:30", () => {27 const slots = computeSlots(input({ rules: monday9to12, taken: [{ start: ms("2026-11-16T15:00:00Z"), end: ms("2026-11-16T16:00:00Z") }] }));28 expect(slots.map((s) => local(TZ, s.start))).toEqual(["2026-11-16T09:00", "2026-11-16T09:30", "2026-11-16T11:00", "2026-11-16T11:30"]);29 });30 it("same result when the block is a wall-clock closure", () => {31 const slots = computeSlots(input({ rules: monday9to12, exceptions: [{ kind: "closed", mode: "range", startsLocal: "2026-11-16T10:00", endsLocal: "2026-11-16T11:00" }] }));32 expect(slots.map((s) => local(TZ, s.start))).toEqual(["2026-11-16T09:00", "2026-11-16T09:30", "2026-11-16T11:00", "2026-11-16T11:30"]);33 });34 it("a slot never overhangs the window: 45 min in 9–12 stepping 30 ends at 11:00", () => {35 const slots = computeSlots(input({ rules: monday9to12, params: { durationMin: 45 } }));36 expect(local(TZ, slots[slots.length - 1].start)).toBe("2026-11-16T11:00");37 });38 it("no rules, no slots; other weekdays stay closed", () => {39 expect(computeSlots(input())).toEqual([]);40 expect(computeSlots(input({ rules: monday9to12, fromDate: "2026-11-17", toDate: "2026-11-22" }))).toEqual([]);41 });42});4344describe("acceptance 2 — a confirmed appointment removes its slot", () => {45 it("removes exactly the taken slot", () => {46 const slots = computeSlots(input({ rules: monday9to12, taken: [{ start: ms("2026-11-16T14:30:00Z"), end: ms("2026-11-16T15:00:00Z") }] }));47 expect(slots.map((s) => local(TZ, s.start))).not.toContain("2026-11-16T09:30");48 expect(slots).toHaveLength(5);49 });50 it("checkSlot refuses it as taken", () => {51 const i = input({ rules: monday9to12, taken: [{ start: ms("2026-11-16T14:30:00Z"), end: ms("2026-11-16T15:00:00Z") }] });52 expect(checkSlot(i, ms("2026-11-16T14:30:00Z"))).toBe("taken");53 expect(checkSlot(i, ms("2026-11-16T15:00:00Z"))).toBeNull();54 });55});5657describe("buffers", () => {58 const appt = { start: ms("2026-11-16T15:00:00Z"), end: ms("2026-11-16T15:30:00Z"), bufferBeforeMin: 0, bufferAfterMin: 15 };59 it("existing appointment's after-buffer pushes the next slot", () => {60 const slots = computeSlots(input({ rules: monday9to12, params: { slotStepMin: 15 }, taken: [appt] }));61 const starts = slots.map((s) => local(TZ, s.start));62 expect(starts).not.toContain("2026-11-16T10:30");63 expect(starts).toContain("2026-11-16T10:45");64 expect(starts).toContain("2026-11-16T09:30"); // ends 10:00, nothing required before the existing one65 });66 it("buffers do not stack (max, not sum)", () => {67 expect(collides(ms("2026-11-16T15:45:00Z"), ms("2026-11-16T16:15:00Z"), 15, 0, [appt])).toBe(false);68 expect(collides(ms("2026-11-16T15:40:00Z"), ms("2026-11-16T16:10:00Z"), 15, 0, [appt])).toBe(true);69 });70 it("plain busy blocks have no buffer, but the new appointment's own buffers apply", () => {71 const block = { start: ms("2026-11-16T15:00:00Z"), end: ms("2026-11-16T16:00:00Z") };72 expect(collides(ms("2026-11-16T16:00:00Z"), ms("2026-11-16T16:30:00Z"), 0, 0, [block])).toBe(false);73 expect(collides(ms("2026-11-16T16:00:00Z"), ms("2026-11-16T16:30:00Z"), 10, 0, [block])).toBe(true);74 expect(collides(ms("2026-11-16T14:30:00Z"), ms("2026-11-16T15:00:00Z"), 0, 10, [block])).toBe(true);75 });76});7778describe("notice, horizon, past", () => {79 it("refuses the past and respects minimum notice and horizon", () => {80 const now = ms("2026-11-16T14:10:00Z"); // 9:10 local81 const i = input({ rules: monday9to12, now, params: { minNoticeMin: 60, horizonDays: 7 } });82 expect(computeSlots(i).map((s) => local(TZ, s.start))).toEqual(["2026-11-16T10:30", "2026-11-16T11:00", "2026-11-16T11:30"]);83 expect(checkSlot(i, ms("2026-11-16T14:00:00Z"))).toBe("past");84 expect(checkSlot(i, ms("2026-11-16T15:00:00Z"))).toBe("too_soon");85 expect(checkSlot(i, ms("2026-11-30T14:00:00Z"))).toBe("too_far");86 // The owner may confirm inside the notice period, never in the past.87 expect(checkSlot(i, ms("2026-11-16T15:00:00Z"), { enforceNotice: false })).toBeNull();88 expect(checkSlot(i, ms("2026-11-16T14:00:00Z"), { enforceNotice: false })).toBe("past");89 });90 it("refuses a time outside opening hours and a misaligned start", () => {91 const i = input({ rules: monday9to12 });92 expect(checkSlot(i, ms("2026-11-16T17:00:00Z"))).toBe("closed");93 expect(checkSlot(i, ms("2026-11-16T14:10:00Z"))).toBe("misaligned");94 expect(checkSlot(i, ms("2026-11-16T16:45:00Z"))).toBe("closed"); // 11:45 + 30 overhangs95 });96});9798describe("limits", () => {99 it("daily limit closes the day, weekly limit closes the ISO week", () => {100 const rules = [1, 2, 3, 4, 5].map((weekday) => ({ weekday, startMinute: 540, endMinute: 600 }));101 const counted = [{ start: ms("2026-11-16T14:00:00Z") }];102 const daily = computeSlots(input({ rules, counted, fromDate: "2026-11-16", toDate: "2026-11-20", params: { dailyLimit: 1 } }));103 expect(new Set(daily.map((s) => local(TZ, s.start).slice(0, 10)))).toEqual(new Set(["2026-11-17", "2026-11-18", "2026-11-19", "2026-11-20"]));104 const weekly = computeSlots(input({ rules, counted, fromDate: "2026-11-16", toDate: "2026-11-27", params: { weeklyLimit: 1 } }));105 expect(weekly.every((s) => local(TZ, s.start) >= "2026-11-23")).toBe(true);106 expect(weekly.length).toBe(10);107 });108 it("local day, not UTC day: 21:00 Toronto counts for that local date", () => {109 const rules = [{ weekday: 1, startMinute: 1200, endMinute: 1320 }]; // 20:00–22:00110 const counted = [{ start: ms("2026-11-17T02:00:00Z") }]; // Mon 21:00 local = Tue UTC111 expect(computeSlots(input({ rules, counted, params: { dailyLimit: 1 } }))).toEqual([]);112 });113});114115describe("acceptance 7 — DST", () => {116 // Toronto 2026: spring forward 8 March (02:00 → 03:00), fall back 1 November (02:00 → 01:00).117 const allWeek = (a: number, b: number) => [1, 2, 3, 4, 5, 6, 7].map((weekday) => ({ weekday, startMinute: a, endMinute: b }));118 it("spring forward: the skipped hour offers nothing, slots stay real 60 minutes", () => {119 const slots = computeSlots(input({ rules: allWeek(60, 240), now: ms("2026-03-01T00:00:00Z"), fromDate: "2026-03-08", toDate: "2026-03-08", params: { durationMin: 60, slotStepMin: 60 } }));120 expect(slots.map((s) => local(TZ, s.start))).toEqual(["2026-03-08T01:00", "2026-03-08T03:00"]);121 for (const s of slots) expect(s.end - s.start).toBe(3_600_000);122 expect(slots[1].start - slots[0].start).toBe(3_600_000);123 });124 it("fall back: the repeated hour is real time, no overlapping slots", () => {125 const slots = computeSlots(input({ rules: allWeek(0, 180), now: ms("2026-10-25T00:00:00Z"), fromDate: "2026-11-01", toDate: "2026-11-01", params: { durationMin: 60, slotStepMin: 60 } }));126 expect(slots).toHaveLength(4); // 00:00, 01:00 EDT, 01:00 EST, 02:00127 for (let k = 1; k < slots.length; k++) expect(slots[k].start).toBe(slots[k - 1].end);128 expect(slots.map((s) => local(TZ, s.start))).toEqual(["2026-11-01T00:00", "2026-11-01T01:00", "2026-11-01T01:00", "2026-11-01T02:00"]);129 });130 it("9:00 local is 14:00Z in winter and 13:00Z in summer", () => {131 const winter = computeSlots(input({ rules: monday9to12 }));132 const summer = computeSlots(input({ rules: monday9to12, now: ms("2026-06-01T00:00:00Z"), fromDate: "2026-06-15", toDate: "2026-06-15" }));133 expect(new Date(winter[0].start).toISOString()).toBe("2026-11-16T14:00:00.000Z");134 expect(new Date(summer[0].start).toISOString()).toBe("2026-06-15T13:00:00.000Z");135 });136 it("another owner zone: Paris 9:00 is 08:00Z in winter", () => {137 const slots = computeSlots(input({ rules: monday9to12, params: { timezone: "Europe/Paris" } }));138 expect(new Date(slots[0].start).toISOString()).toBe("2026-11-16T08:00:00.000Z");139 });140 it("a visitor in Tokyo sees the same instants (conversion is display only)", () => {141 const slots = computeSlots(input({ rules: monday9to12 }));142 expect(local("Asia/Tokyo", slots[0].start)).toBe("2026-11-16T23:00");143 });144});145146describe("exceptions", () => {147 it("all-day closure over several days (vacation)", () => {148 const rules = [1, 2, 3, 4, 5].map((weekday) => ({ weekday, startMinute: 540, endMinute: 600 }));149 const slots = computeSlots(input({ rules, fromDate: "2026-11-16", toDate: "2026-11-20", exceptions: [{ kind: "closed", mode: "range", allDay: true, startsLocal: "2026-11-17T00:00", endsLocal: "2026-11-20T00:00" }] }));150 expect(new Set(slots.map((s) => local(TZ, s.start).slice(0, 10)))).toEqual(new Set(["2026-11-16", "2026-11-20"]));151 });152 it("closure crossing midnight cuts both days", () => {153 const rules = [{ weekday: 1, startMinute: 1320, endMinute: 1440 }, { weekday: 2, startMinute: 0, endMinute: 120 }];154 const ex = [{ kind: "closed" as const, mode: "range" as const, startsLocal: "2026-11-16T23:00", endsLocal: "2026-11-17T01:00" }];155 const slots = computeSlots(input({ rules, exceptions: ex, fromDate: "2026-11-16", toDate: "2026-11-17", params: { durationMin: 60, slotStepMin: 60 } }));156 expect(slots.map((s) => local(TZ, s.start))).toEqual(["2026-11-16T22:00", "2026-11-17T01:00"]);157 });158 it("opening hours that touch at midnight form one window: a slot may cross midnight", () => {159 const rules = [{ weekday: 1, startMinute: 1380, endMinute: 1440 }, { weekday: 2, startMinute: 0, endMinute: 60 }];160 const slots = computeSlots(input({ rules, fromDate: "2026-11-16", toDate: "2026-11-17", params: { durationMin: 90, slotStepMin: 30 } }));161 expect(slots.map((s) => local(TZ, s.start))).toEqual(["2026-11-16T23:00", "2026-11-16T23:30"]);162 });163 it("recurring weekly absence, with validity dates, may cross midnight", () => {164 const rules = [1, 2].map((weekday) => ({ weekday, startMinute: 540, endMinute: 720 }));165 const ex = [{ kind: "closed" as const, mode: "weekly" as const, weekdays: [1], startMinute: 600, durationMin: 60, validFrom: "2026-11-01", validUntil: "2026-11-20" }];166 const a = computeSlots(input({ rules, exceptions: ex, fromDate: "2026-11-16", toDate: "2026-11-17" })).map((s) => local(TZ, s.start));167 expect(a).not.toContain("2026-11-16T10:00");168 expect(a).toContain("2026-11-17T10:00");169 const b = computeSlots(input({ rules, exceptions: ex, fromDate: "2026-11-23", toDate: "2026-11-23" })).map((s) => local(TZ, s.start));170 expect(b).toContain("2026-11-23T10:00"); // past validUntil171 const night = [{ kind: "closed" as const, mode: "weekly" as const, weekdays: [7], startMinute: 1380, durationMin: 660, validFrom: "2026-01-01" }]; // Sun 23:00 → Mon 10:00172 const c = computeSlots(input({ rules, exceptions: night })).map((s) => local(TZ, s.start));173 expect(c).toEqual(["2026-11-16T10:00", "2026-11-16T10:30", "2026-11-16T11:00", "2026-11-16T11:30"]);174 });175 it("an extra opening adds time on a closed day; a closure wins over an opening", () => {176 const open = [{ kind: "open" as const, mode: "range" as const, startsLocal: "2026-11-21T13:00", endsLocal: "2026-11-21T15:00" }];177 const s1 = computeSlots(input({ rules: monday9to12, exceptions: open, fromDate: "2026-11-21", toDate: "2026-11-21" }));178 expect(s1.map((s) => local(TZ, s.start))).toEqual(["2026-11-21T13:00", "2026-11-21T13:30", "2026-11-21T14:00", "2026-11-21T14:30"]);179 const both = [...open, { kind: "closed" as const, mode: "range" as const, startsLocal: "2026-11-21T14:00", endsLocal: "2026-11-21T16:00" }];180 expect(computeSlots(input({ rules: monday9to12, exceptions: both, fromDate: "2026-11-21", toDate: "2026-11-21" }))).toHaveLength(2);181 });182 it("openWindows merges overlapping rules", () => {183 const w = openWindows(TZ, [{ weekday: 1, startMinute: 540, endMinute: 660 }, { weekday: 1, startMinute: 600, endMinute: 720 }], [], Temporal.PlainDate.from("2026-11-16"), Temporal.PlainDate.from("2026-11-16"));184 expect(w).toHaveLength(1);185 expect(w[0].end - w[0].start).toBe(3 * 3_600_000);186 });187});188