SPB Git

Repository Public

engine.test.ts 13.1 KB 188 lines typescript

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
Files main
Menu

Theme