This is the business core, written before any UI as the spec requires. It is pure: no I/O, no database, no clock of its own. Everything arrives in a ScheduleContext, so every rule below is exhaustively testable. The engine works on civil (wall-clock) values rather than instants. A slot that runs 10:00-18:30 runs 10:00-18:30 on the nights the clocks change too, and day arithmetic goes through UTC, which has no daylight saving. That turns the March and October switches from edge cases into non-events, and the tests pin both of them. Priority order, highest first: a dated exception, a vacation period, a public holiday the shop closes for, the reference week, then closed. Exceptions are badged by their stated reason rather than by who created them, so a holiday imported by the sync shows as a holiday in the UI. The search for the next opening is bounded to fourteen days. A shop that is closed forever must not make the server spin; past the horizon the screen simply says nothing about reopening. Both the never-open week and the beyond-the-horizon reopening are covered. "Soon" is strictly under thirty minutes, so a change exactly half an hour away still reads as plain OPEN or CLOSED. 55 tests; lib/schedule sits at 97% statements and 93% branches against an 85% floor. The thresholds were verified to actually fail the build before being committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
495 lines
16 KiB
TypeScript
495 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { detectDivergence, getCurrentStatus, resolveDay, resolveWeek } from './resolver';
|
|
import type { ScheduleContext, Slot } from './types';
|
|
|
|
const ZURICH = 'Europe/Zurich';
|
|
|
|
const FULL_DAY: Slot[] = [{ open: '10:00', close: '18:30' }];
|
|
const SPLIT_DAY: Slot[] = [
|
|
{ open: '10:00', close: '13:00' },
|
|
{ open: '14:00', close: '18:30' },
|
|
];
|
|
|
|
/**
|
|
* The seeded reference week: closed Sunday and Monday, 10:00-18:30 Tuesday to
|
|
* Saturday, with a lunch break on Wednesday.
|
|
*
|
|
* Anchor dates used throughout: 2026-09-20 is a Sunday, so 09-21 is Monday,
|
|
* 09-22 Tuesday, 09-23 Wednesday, 09-26 Saturday.
|
|
*/
|
|
function context(overrides: Partial<ScheduleContext> = {}): ScheduleContext {
|
|
return {
|
|
timezone: ZURICH,
|
|
weekly: [
|
|
{ dayOfWeek: 0, isClosed: true, slots: [] },
|
|
{ dayOfWeek: 1, isClosed: true, slots: [] },
|
|
{ dayOfWeek: 2, isClosed: false, slots: FULL_DAY },
|
|
{ dayOfWeek: 3, isClosed: false, slots: SPLIT_DAY },
|
|
{ dayOfWeek: 4, isClosed: false, slots: FULL_DAY },
|
|
{ dayOfWeek: 5, isClosed: false, slots: FULL_DAY },
|
|
{ dayOfWeek: 6, isClosed: false, slots: FULL_DAY },
|
|
],
|
|
exceptions: [],
|
|
vacations: [],
|
|
holidays: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** An instant that reads as the given wall clock in Zurich. */
|
|
function at(date: string, time: string, offset = '+02:00'): Date {
|
|
return new Date(`${date}T${time}:00${offset}`);
|
|
}
|
|
|
|
describe('resolveDay — the reference week', () => {
|
|
it('opens a normal trading day', () => {
|
|
const day = resolveDay(at('2026-09-22', '09:00'), context());
|
|
expect(day).toMatchObject({
|
|
date: '2026-09-22',
|
|
dayOfWeek: 2,
|
|
isOpen: true,
|
|
slots: FULL_DAY,
|
|
isException: false,
|
|
exceptionKind: null,
|
|
});
|
|
});
|
|
|
|
it('closes a normal closed day', () => {
|
|
const day = resolveDay(at('2026-09-21', '09:00'), context());
|
|
expect(day.isOpen).toBe(false);
|
|
expect(day.slots).toEqual([]);
|
|
expect(day.isException).toBe(false);
|
|
});
|
|
|
|
it('keeps both slots of a day with a lunch break', () => {
|
|
const day = resolveDay(at('2026-09-23', '09:00'), context());
|
|
expect(day.slots).toEqual(SPLIT_DAY);
|
|
});
|
|
|
|
it('falls back to closed when the week has no entry for that day', () => {
|
|
const day = resolveDay(at('2026-09-22', '09:00'), context({ weekly: [] }));
|
|
expect(day.isOpen).toBe(false);
|
|
expect(day.slots).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('resolveDay — priority order', () => {
|
|
const holiday = {
|
|
date: '2026-09-22',
|
|
nameFr: 'Jeûne genevois',
|
|
nameEn: 'Geneva Fast',
|
|
isAutoClosed: true,
|
|
};
|
|
|
|
it('closes on a public holiday marked as closing', () => {
|
|
const day = resolveDay(at('2026-09-22', '09:00'), context({ holidays: [holiday] }));
|
|
expect(day.isOpen).toBe(false);
|
|
expect(day.exceptionKind).toBe('HOLIDAY');
|
|
expect(day.noteFr).toBe('Jeûne genevois');
|
|
expect(day.noteEn).toBe('Geneva Fast');
|
|
});
|
|
|
|
it('keeps normal hours on a public holiday the shop trades through', () => {
|
|
const day = resolveDay(
|
|
at('2026-09-22', '09:00'),
|
|
context({ holidays: [{ ...holiday, isAutoClosed: false }] }),
|
|
);
|
|
expect(day.isOpen).toBe(true);
|
|
expect(day.slots).toEqual(FULL_DAY);
|
|
expect(day.isException).toBe(false);
|
|
});
|
|
|
|
it('lets a vacation period beat the reference week', () => {
|
|
const day = resolveDay(
|
|
at('2026-09-22', '09:00'),
|
|
context({
|
|
vacations: [
|
|
{
|
|
startDate: '2026-09-21',
|
|
endDate: '2026-09-27',
|
|
labelFr: 'Congés annuels',
|
|
labelEn: 'Annual leave',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(day.isOpen).toBe(false);
|
|
expect(day.exceptionKind).toBe('VACATION');
|
|
expect(day.noteFr).toBe('Congés annuels');
|
|
});
|
|
|
|
it('includes the first and last day of a vacation period', () => {
|
|
const vacations = [
|
|
{ startDate: '2026-09-22', endDate: '2026-09-24', labelFr: 'Congés', labelEn: 'Leave' },
|
|
];
|
|
expect(resolveDay(at('2026-09-22', '09:00'), context({ vacations })).isOpen).toBe(false);
|
|
expect(resolveDay(at('2026-09-24', '09:00'), context({ vacations })).isOpen).toBe(false);
|
|
// The day after the period ends is a normal Friday again.
|
|
expect(resolveDay(at('2026-09-25', '09:00'), context({ vacations })).isOpen).toBe(true);
|
|
});
|
|
|
|
it('lets a manual exception beat a public holiday', () => {
|
|
const day = resolveDay(
|
|
at('2026-09-22', '09:00'),
|
|
context({
|
|
holidays: [holiday],
|
|
exceptions: [
|
|
{
|
|
date: '2026-09-22',
|
|
isClosed: false,
|
|
slots: [{ open: '14:00', close: '18:00' }],
|
|
reason: 'SPECIAL_EVENT',
|
|
noteFr: 'Ouverture spéciale',
|
|
noteEn: 'Special opening',
|
|
source: 'MANUAL',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(day.isOpen).toBe(true);
|
|
expect(day.slots).toEqual([{ open: '14:00', close: '18:00' }]);
|
|
expect(day.exceptionKind).toBe('MANUAL');
|
|
expect(day.noteFr).toBe('Ouverture spéciale');
|
|
});
|
|
|
|
it('lets a manual exception beat a vacation period', () => {
|
|
const day = resolveDay(
|
|
at('2026-09-22', '09:00'),
|
|
context({
|
|
vacations: [
|
|
{ startDate: '2026-09-21', endDate: '2026-09-27', labelFr: 'Congés', labelEn: 'Leave' },
|
|
],
|
|
exceptions: [
|
|
{
|
|
date: '2026-09-22',
|
|
isClosed: false,
|
|
slots: FULL_DAY,
|
|
reason: 'SPECIAL_EVENT',
|
|
noteFr: null,
|
|
noteEn: null,
|
|
source: 'MANUAL',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(day.isOpen).toBe(true);
|
|
expect(day.exceptionKind).toBe('MANUAL');
|
|
});
|
|
|
|
it('treats an exception with no slots as a full-day closure', () => {
|
|
const day = resolveDay(
|
|
at('2026-09-22', '09:00'),
|
|
context({
|
|
exceptions: [
|
|
{
|
|
date: '2026-09-22',
|
|
isClosed: true,
|
|
slots: null,
|
|
reason: 'TEMPORARY',
|
|
noteFr: 'Fermeture exceptionnelle',
|
|
noteEn: 'Exceptionally closed',
|
|
source: 'MANUAL',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(day.isOpen).toBe(false);
|
|
expect(day.slots).toEqual([]);
|
|
expect(day.exceptionKind).toBe('MANUAL');
|
|
});
|
|
|
|
it('badges an imported holiday exception by its reason, not by its author', () => {
|
|
const day = resolveDay(
|
|
at('2026-09-22', '09:00'),
|
|
context({
|
|
exceptions: [
|
|
{
|
|
date: '2026-09-22',
|
|
isClosed: true,
|
|
slots: null,
|
|
reason: 'HOLIDAY',
|
|
noteFr: 'Férié',
|
|
noteEn: 'Holiday',
|
|
source: 'HOLIDAY_API',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(day.isOpen).toBe(false);
|
|
// The row wins the same way a hand-typed one would, but the UI badge has
|
|
// to say where it came from.
|
|
expect(day.exceptionKind).toBe('HOLIDAY');
|
|
});
|
|
});
|
|
|
|
describe('resolveDay — daylight saving and calendar boundaries', () => {
|
|
it('keeps the usual hours on the spring forward day', () => {
|
|
// 2026-03-29, last Sunday of March. Sunday is closed, so check the Monday
|
|
// and the Tuesday around it keep their normal shape.
|
|
const ctx = context();
|
|
expect(resolveDay(at('2026-03-29', '12:00', '+02:00'), ctx).isOpen).toBe(false);
|
|
expect(resolveDay(at('2026-03-31', '12:00', '+02:00'), ctx).slots).toEqual(FULL_DAY);
|
|
});
|
|
|
|
it('keeps the usual hours on the autumn fall back day', () => {
|
|
// 2026-10-25, last Sunday of October: a 25-hour day.
|
|
const ctx = context();
|
|
expect(resolveDay(at('2026-10-25', '12:00', '+01:00'), ctx).date).toBe('2026-10-25');
|
|
expect(resolveDay(at('2026-10-27', '12:00', '+01:00'), ctx).slots).toEqual(FULL_DAY);
|
|
});
|
|
|
|
it('resolves the ambiguous hour of the fall back night to the right day', () => {
|
|
// 02:30 happens twice on 2026-10-25. Both readings are still that Sunday.
|
|
expect(resolveDay(new Date('2026-10-25T00:30:00Z'), context()).date).toBe('2026-10-25');
|
|
expect(resolveDay(new Date('2026-10-25T01:30:00Z'), context()).date).toBe('2026-10-25');
|
|
});
|
|
|
|
it('resolves the last minute and the first minute of a day', () => {
|
|
expect(resolveDay(at('2026-09-22', '23:59'), context()).date).toBe('2026-09-22');
|
|
expect(resolveDay(at('2026-09-23', '00:00'), context()).date).toBe('2026-09-23');
|
|
});
|
|
|
|
it('crosses the year boundary', () => {
|
|
// 2026-12-31 is a Thursday, 2027-01-01 a Friday: both trading days here.
|
|
expect(resolveDay(at('2026-12-31', '12:00', '+01:00'), context()).dayOfWeek).toBe(4);
|
|
expect(resolveDay(at('2027-01-01', '12:00', '+01:00'), context()).dayOfWeek).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('resolveWeek', () => {
|
|
it('returns seven consecutive days starting on the given date', () => {
|
|
const week = resolveWeek(at('2026-09-21', '09:00'), context());
|
|
expect(week).toHaveLength(7);
|
|
expect(week.map((day) => day.date)).toEqual([
|
|
'2026-09-21',
|
|
'2026-09-22',
|
|
'2026-09-23',
|
|
'2026-09-24',
|
|
'2026-09-25',
|
|
'2026-09-26',
|
|
'2026-09-27',
|
|
]);
|
|
});
|
|
|
|
it('carries exceptions through into the week', () => {
|
|
const week = resolveWeek(
|
|
at('2026-09-21', '09:00'),
|
|
context({
|
|
exceptions: [
|
|
{
|
|
date: '2026-09-24',
|
|
isClosed: true,
|
|
slots: null,
|
|
reason: 'TEMPORARY',
|
|
noteFr: 'Fermé',
|
|
noteEn: 'Closed',
|
|
source: 'MANUAL',
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(week[3]?.isException).toBe(true);
|
|
expect(week[3]?.isOpen).toBe(false);
|
|
});
|
|
|
|
it('crosses a month boundary', () => {
|
|
const week = resolveWeek(at('2026-09-28', '09:00'), context());
|
|
expect(week.map((day) => day.date)).toContain('2026-10-01');
|
|
});
|
|
});
|
|
|
|
describe('getCurrentStatus', () => {
|
|
it('reports OPEN in the middle of a slot', () => {
|
|
const status = getCurrentStatus(at('2026-09-22', '12:00'), context());
|
|
expect(status.status).toBe('OPEN');
|
|
expect(status.nextChangeAt).toEqual({ date: '2026-09-22', time: '18:30' });
|
|
});
|
|
|
|
it('reports OPEN exactly at opening time', () => {
|
|
expect(getCurrentStatus(at('2026-09-22', '10:00'), context()).status).toBe('OPEN');
|
|
});
|
|
|
|
it('reports CLOSED exactly at closing time', () => {
|
|
expect(getCurrentStatus(at('2026-09-22', '18:30'), context()).status).toBe('CLOSED');
|
|
});
|
|
|
|
it('reports CLOSING_SOON within thirty minutes of closing', () => {
|
|
const status = getCurrentStatus(at('2026-09-22', '18:05'), context());
|
|
expect(status.status).toBe('CLOSING_SOON');
|
|
expect(status.nextChangeAt).toEqual({ date: '2026-09-22', time: '18:30' });
|
|
});
|
|
|
|
it('still reports OPEN thirty-one minutes before closing', () => {
|
|
expect(getCurrentStatus(at('2026-09-22', '17:59'), context()).status).toBe('OPEN');
|
|
});
|
|
|
|
it('reports OPENING_SOON within thirty minutes of opening', () => {
|
|
const status = getCurrentStatus(at('2026-09-22', '09:45'), context());
|
|
expect(status.status).toBe('OPENING_SOON');
|
|
expect(status.nextChangeAt).toEqual({ date: '2026-09-22', time: '10:00' });
|
|
expect(status.nextOpening).toEqual({ date: '2026-09-22', time: '10:00' });
|
|
});
|
|
|
|
it('reports CLOSED during the lunch break and points at the afternoon slot', () => {
|
|
const status = getCurrentStatus(at('2026-09-23', '13:30'), context());
|
|
expect(status.status).toBe('CLOSED');
|
|
expect(status.nextChangeAt).toEqual({ date: '2026-09-23', time: '14:00' });
|
|
expect(status.nextOpening).toEqual({ date: '2026-09-23', time: '14:00' });
|
|
});
|
|
|
|
it('reports OPENING_SOON near the end of the lunch break', () => {
|
|
expect(getCurrentStatus(at('2026-09-23', '13:45'), context()).status).toBe('OPENING_SOON');
|
|
});
|
|
|
|
it('skips closed days when looking for the next opening', () => {
|
|
// Saturday evening, after closing: the next opening is Tuesday, because
|
|
// Sunday and Monday are closed.
|
|
const status = getCurrentStatus(at('2026-09-26', '19:00'), context());
|
|
expect(status.status).toBe('CLOSED');
|
|
expect(status.nextOpening).toEqual({ date: '2026-09-29', time: '10:00' });
|
|
expect(status.nextChangeAt).toEqual({ date: '2026-09-29', time: '10:00' });
|
|
});
|
|
|
|
it('skips a vacation period when looking for the next opening', () => {
|
|
const status = getCurrentStatus(
|
|
at('2026-09-22', '19:00'),
|
|
context({
|
|
vacations: [
|
|
{ startDate: '2026-09-23', endDate: '2026-09-25', labelFr: 'Congés', labelEn: 'Leave' },
|
|
],
|
|
}),
|
|
);
|
|
expect(status.nextOpening).toEqual({ date: '2026-09-26', time: '10:00' });
|
|
});
|
|
|
|
it('gives up rather than looping when the shop is never open', () => {
|
|
const alwaysClosed = context({
|
|
weekly: [0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => ({ dayOfWeek, isClosed: true, slots: [] })),
|
|
});
|
|
const status = getCurrentStatus(at('2026-09-22', '12:00'), alwaysClosed);
|
|
expect(status.status).toBe('CLOSED');
|
|
expect(status.nextOpening).toBeNull();
|
|
expect(status.nextChangeAt).toBeNull();
|
|
});
|
|
|
|
it('gives up when the only opening is beyond the search horizon', () => {
|
|
// A vacation covering the next three weeks pushes the reopening out of the
|
|
// fourteen-day window the resolver is willing to scan.
|
|
const status = getCurrentStatus(
|
|
at('2026-09-22', '19:00'),
|
|
context({
|
|
vacations: [
|
|
{ startDate: '2026-09-23', endDate: '2026-10-20', labelFr: 'Congés', labelEn: 'Leave' },
|
|
],
|
|
}),
|
|
);
|
|
expect(status.nextOpening).toBeNull();
|
|
});
|
|
|
|
it('reports the resolved day alongside the status', () => {
|
|
const status = getCurrentStatus(at('2026-09-23', '12:00'), context());
|
|
expect(status.today.date).toBe('2026-09-23');
|
|
expect(status.today.slots).toEqual(SPLIT_DAY);
|
|
});
|
|
|
|
it('finds the next opening on a closed day', () => {
|
|
// Monday: closed all day, so the next opening is Tuesday morning.
|
|
const status = getCurrentStatus(at('2026-09-21', '12:00'), context());
|
|
expect(status.status).toBe('CLOSED');
|
|
expect(status.nextOpening).toEqual({ date: '2026-09-22', time: '10:00' });
|
|
});
|
|
});
|
|
|
|
describe('detectDivergence', () => {
|
|
it('reports nothing when the week matches the reference', () => {
|
|
expect(detectDivergence(at('2026-09-21', '09:00'), context())).toBe('NONE');
|
|
});
|
|
|
|
it('reports TODAY when only today departs from the reference week', () => {
|
|
const ctx = context({
|
|
exceptions: [
|
|
{
|
|
date: '2026-09-22',
|
|
isClosed: true,
|
|
slots: null,
|
|
reason: 'TEMPORARY',
|
|
noteFr: 'Fermé',
|
|
noteEn: 'Closed',
|
|
source: 'MANUAL',
|
|
},
|
|
],
|
|
});
|
|
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('TODAY');
|
|
});
|
|
|
|
it('reports WEEK when a later day departs from the reference week', () => {
|
|
const ctx = context({
|
|
exceptions: [
|
|
{
|
|
date: '2026-09-24',
|
|
isClosed: true,
|
|
slots: null,
|
|
reason: 'TEMPORARY',
|
|
noteFr: 'Fermé',
|
|
noteEn: 'Closed',
|
|
source: 'MANUAL',
|
|
},
|
|
],
|
|
});
|
|
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('WEEK');
|
|
});
|
|
|
|
it('reports WEEK when today and a later day both depart', () => {
|
|
const ctx = context({
|
|
exceptions: [
|
|
{
|
|
date: '2026-09-22',
|
|
isClosed: true,
|
|
slots: null,
|
|
reason: 'TEMPORARY',
|
|
noteFr: null,
|
|
noteEn: null,
|
|
source: 'MANUAL',
|
|
},
|
|
{
|
|
date: '2026-09-24',
|
|
isClosed: true,
|
|
slots: null,
|
|
reason: 'TEMPORARY',
|
|
noteFr: null,
|
|
noteEn: null,
|
|
source: 'MANUAL',
|
|
},
|
|
],
|
|
});
|
|
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('WEEK');
|
|
});
|
|
|
|
it('notices a change of hours, not just a closure', () => {
|
|
const ctx = context({
|
|
exceptions: [
|
|
{
|
|
date: '2026-09-22',
|
|
isClosed: false,
|
|
slots: [{ open: '14:00', close: '18:30' }],
|
|
reason: 'TEMPORARY',
|
|
noteFr: 'Ouverture retardée',
|
|
noteEn: 'Late opening',
|
|
source: 'MANUAL',
|
|
},
|
|
],
|
|
});
|
|
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('TODAY');
|
|
});
|
|
|
|
it('ignores a public holiday the shop trades through', () => {
|
|
const ctx = context({
|
|
holidays: [
|
|
{ date: '2026-09-24', nameFr: 'Férié', nameEn: 'Holiday', isAutoClosed: false },
|
|
],
|
|
});
|
|
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('NONE');
|
|
});
|
|
});
|