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
102 lines
3.5 KiB
TypeScript
102 lines
3.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
addCivilDays,
|
|
civilDayOfWeek,
|
|
compareCivil,
|
|
minutesOfTime,
|
|
toCivilDate,
|
|
toCivilTime,
|
|
} from './civil';
|
|
|
|
const ZURICH = 'Europe/Zurich';
|
|
|
|
describe('toCivilDate / toCivilTime', () => {
|
|
it('converts an instant to the wall clock of the shop', () => {
|
|
// 2026-09-20T08:15:00Z is 10:15 in Zurich (CEST, UTC+2).
|
|
const instant = new Date('2026-09-20T08:15:00Z');
|
|
expect(toCivilDate(instant, ZURICH)).toBe('2026-09-20');
|
|
expect(toCivilTime(instant, ZURICH)).toBe('10:15');
|
|
});
|
|
|
|
it('keeps the local day when UTC has already rolled over', () => {
|
|
// 22:30 UTC is 00:30 the next day in Zurich: the civil date must follow
|
|
// the shop, not the server.
|
|
const instant = new Date('2026-09-20T22:30:00Z');
|
|
expect(toCivilDate(instant, ZURICH)).toBe('2026-09-21');
|
|
expect(toCivilTime(instant, ZURICH)).toBe('00:30');
|
|
});
|
|
|
|
it('reads the same instant differently in winter and in summer', () => {
|
|
// CET (UTC+1) in January, CEST (UTC+2) in July.
|
|
expect(toCivilTime(new Date('2026-01-15T12:00:00Z'), ZURICH)).toBe('13:00');
|
|
expect(toCivilTime(new Date('2026-07-15T12:00:00Z'), ZURICH)).toBe('14:00');
|
|
});
|
|
});
|
|
|
|
describe('addCivilDays', () => {
|
|
it('walks forward across a month boundary', () => {
|
|
expect(addCivilDays('2026-09-30', 1)).toBe('2026-10-01');
|
|
});
|
|
|
|
it('walks forward across a year boundary', () => {
|
|
expect(addCivilDays('2026-12-31', 1)).toBe('2027-01-01');
|
|
});
|
|
|
|
it('handles a leap day', () => {
|
|
expect(addCivilDays('2028-02-28', 1)).toBe('2028-02-29');
|
|
expect(addCivilDays('2028-02-29', 1)).toBe('2028-03-01');
|
|
});
|
|
|
|
it('is unaffected by the spring daylight-saving switch', () => {
|
|
// 2026-03-29 is the last Sunday of March: Zurich loses an hour that night.
|
|
// Civil dates must still advance by exactly one day.
|
|
expect(addCivilDays('2026-03-28', 1)).toBe('2026-03-29');
|
|
expect(addCivilDays('2026-03-29', 1)).toBe('2026-03-30');
|
|
});
|
|
|
|
it('is unaffected by the autumn daylight-saving switch', () => {
|
|
// 2026-10-25 is the last Sunday of October: that day has 25 hours.
|
|
expect(addCivilDays('2026-10-24', 1)).toBe('2026-10-25');
|
|
expect(addCivilDays('2026-10-25', 1)).toBe('2026-10-26');
|
|
});
|
|
|
|
it('walks backwards', () => {
|
|
expect(addCivilDays('2026-01-01', -1)).toBe('2025-12-31');
|
|
});
|
|
});
|
|
|
|
describe('civilDayOfWeek', () => {
|
|
it('matches JS getDay(), Sunday first', () => {
|
|
expect(civilDayOfWeek('2026-09-20')).toBe(0); // Sunday
|
|
expect(civilDayOfWeek('2026-09-21')).toBe(1); // Monday
|
|
expect(civilDayOfWeek('2026-09-26')).toBe(6); // Saturday
|
|
});
|
|
|
|
it('is stable across the daylight-saving switches', () => {
|
|
expect(civilDayOfWeek('2026-03-29')).toBe(0);
|
|
expect(civilDayOfWeek('2026-10-25')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('compareCivil', () => {
|
|
it('orders dates chronologically', () => {
|
|
expect(compareCivil('2026-09-20', '2026-09-21')).toBeLessThan(0);
|
|
expect(compareCivil('2026-09-21', '2026-09-20')).toBeGreaterThan(0);
|
|
expect(compareCivil('2026-09-20', '2026-09-20')).toBe(0);
|
|
});
|
|
|
|
it('orders across year boundaries', () => {
|
|
expect(compareCivil('2026-12-31', '2027-01-01')).toBeLessThan(0);
|
|
});
|
|
});
|
|
|
|
describe('minutesOfTime', () => {
|
|
it('converts a wall-clock time to minutes since midnight', () => {
|
|
expect(minutesOfTime('00:00')).toBe(0);
|
|
expect(minutesOfTime('10:00')).toBe(600);
|
|
expect(minutesOfTime('18:30')).toBe(1110);
|
|
expect(minutesOfTime('23:59')).toBe(1439);
|
|
});
|
|
});
|