Files
ita-ito-horaires/lib/schedule/exceptions.test.ts
T
vliaudatandClaude Opus 5 7d23b14ad8 feat: add the one-off schedule changes page
The page opens on the gesture the shop actually makes: changing today's
hours from a phone, behind the counter. "Closed today", "opens later at"
and "closes earlier at" are one tap plus a time, and each shows the
hours it would produce before it is applied.

The derivation is the delicate part and is pure and tested. Opening at
14:00 drops a 10:00-13:00 morning rather than keeping it, and trims the
slot the new time falls inside rather than dropping it. Closing early is
the mirror. Both are computed against what the day would normally be,
ignoring any exception already recorded, since that is what "late" is
late relative to.

The upcoming list is built by resolving each of the next sixty days, not
by reading the exception table. Vacations and public holidays are never
materialised as rows, so the table alone would quietly omit most of what
is actually in effect. Each entry is badged with the rule that produced
it, and only stored exceptions offer a delete.

Ranges are capped at 92 days and point at the holidays page beyond that:
a typo in a year field should produce an error, not thirty thousand rows.

Editing the French note clears the English one. A translation of text
that has changed is worse than no translation.

Three exports were removed from the actions module before committing:
every export in a 'use server' file becomes a publicly callable
endpoint, and those three had ended up unused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 19:11:28 +02:00

133 lines
4.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { checkRange, earlyClosing, expandRange, lateOpening, MAX_RANGE_DAYS } from './exceptions';
import type { Slot } from './types';
const FULL: Slot[] = [{ open: '10:00', close: '18:30' }];
const SPLIT: Slot[] = [
{ open: '10:00', close: '13:00' },
{ open: '14:00', close: '18:30' },
];
describe('lateOpening', () => {
it('trims the only slot', () => {
expect(lateOpening(FULL, '14:00')).toEqual([{ open: '14:00', close: '18:30' }]);
});
it('drops a morning that is entirely before the new opening', () => {
// A shop opening at 14:00 does not keep its 10:00-13:00 morning.
expect(lateOpening(SPLIT, '14:00')).toEqual([{ open: '14:00', close: '18:30' }]);
});
it('trims the slot the new opening falls inside', () => {
expect(lateOpening(SPLIT, '11:00')).toEqual([
{ open: '11:00', close: '13:00' },
{ open: '14:00', close: '18:30' },
]);
});
it('changes nothing when the shop already opens later', () => {
expect(lateOpening(FULL, '09:00')).toEqual(FULL);
});
it('closes the day when the new opening is past every slot', () => {
expect(lateOpening(FULL, '19:00')).toEqual([]);
expect(lateOpening(SPLIT, '18:30')).toEqual([]);
});
it('leaves a closed day closed', () => {
expect(lateOpening([], '14:00')).toEqual([]);
});
it('does not mutate its input', () => {
const slots = SPLIT.map((slot) => ({ ...slot }));
lateOpening(slots, '11:00');
expect(slots).toEqual(SPLIT);
});
});
describe('earlyClosing', () => {
it('trims the only slot', () => {
expect(earlyClosing(FULL, '15:00')).toEqual([{ open: '10:00', close: '15:00' }]);
});
it('drops an afternoon that starts after the new closing', () => {
expect(earlyClosing(SPLIT, '13:00')).toEqual([{ open: '10:00', close: '13:00' }]);
});
it('trims the slot the new closing falls inside', () => {
expect(earlyClosing(SPLIT, '15:00')).toEqual([
{ open: '10:00', close: '13:00' },
{ open: '14:00', close: '15:00' },
]);
});
it('changes nothing when the shop already closes earlier', () => {
expect(earlyClosing(FULL, '19:00')).toEqual(FULL);
});
it('closes the day when the new closing is before every slot', () => {
expect(earlyClosing(FULL, '09:00')).toEqual([]);
expect(earlyClosing(SPLIT, '10:00')).toEqual([]);
});
it('is the mirror of lateOpening on a split day', () => {
// Opening late then closing early must leave the middle of the day.
expect(earlyClosing(lateOpening(SPLIT, '11:00'), '15:00')).toEqual([
{ open: '11:00', close: '13:00' },
{ open: '14:00', close: '15:00' },
]);
});
});
describe('expandRange', () => {
it('includes both ends', () => {
expect(expandRange('2026-09-20', '2026-09-22')).toEqual([
'2026-09-20',
'2026-09-21',
'2026-09-22',
]);
});
it('handles a single day', () => {
expect(expandRange('2026-09-20', '2026-09-20')).toEqual(['2026-09-20']);
});
it('crosses a month and a year boundary', () => {
expect(expandRange('2026-12-30', '2027-01-02')).toEqual([
'2026-12-30',
'2026-12-31',
'2027-01-01',
'2027-01-02',
]);
});
it('returns nothing for a reversed range', () => {
expect(expandRange('2026-09-22', '2026-09-20')).toEqual([]);
});
it('refuses a range longer than the ceiling', () => {
// A typo in a year field should produce an error, not thirty thousand rows.
expect(expandRange('2026-01-01', '2030-01-01')).toEqual([]);
});
it('accepts a range exactly at the ceiling', () => {
const end = expandRange('2026-01-01', '2026-01-01');
expect(end).toHaveLength(1);
expect(expandRange('2026-01-01', '2026-04-01').length).toBeLessThanOrEqual(MAX_RANGE_DAYS + 1);
});
});
describe('checkRange', () => {
it('accepts a sound range', () => {
expect(checkRange('2026-09-20', '2026-09-22')).toBeNull();
});
it('names each problem', () => {
expect(checkRange('', '2026-09-22')).toBe('EMPTY');
expect(checkRange('2026-09-22', '')).toBe('EMPTY');
expect(checkRange('2026-09-22', '2026-09-20')).toBe('REVERSED');
expect(checkRange('2026-01-01', '2030-01-01')).toBe('TOO_LONG');
});
});