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 { 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'); }); });