Files
ita-ito-horaires/lib/screen/viewmodel.test.ts
T
vliaudatandClaude Opus 5 063c5758a7 chore: settle the public domain and move off port 3000
The application is published at horaires.ita-ito.com. One origin serves
both the panel and the browser, so the OIDC callback, the image URL the
device is handed and the documentation all follow from this single name.

Port 3000 is already taken by the facture_ocr stack on the development
machine, so the app listens on 3010 there and the reason is written next
to the setting rather than left to be rediscovered.

The fixtures and the frozen payload snapshot move to the real domain too:
a contract snapshot carrying a hostname that never existed is a small
puzzle left for whoever reads it next.

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

382 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import type { ScheduleContext, Slot } from '@/lib/schedule/types';
import { MESSAGE_MAX_CHARS, SCREEN_SCHEMA_VERSION } from './contract';
import { buildScreenPayload, selectActiveMessage, type MessageCandidate } from './viewmodel';
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' },
];
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,
};
}
function input(overrides: Partial<Parameters<typeof buildScreenPayload>[0]> = {}) {
return {
// Tuesday 2026-09-22, 12:00 in Zurich: the shop is open.
now: new Date('2026-09-22T10:00:00Z'),
ctx: context(),
shopName: 'ITA ITO',
logoUrl: 'https://horaires.ita-ito.com/brand/logo-eink.png',
messages: [] as MessageCandidate[],
...overrides,
};
}
describe('selectActiveMessage', () => {
const now = new Date('2026-09-22T10:00:00Z');
const base: MessageCandidate = {
textFr: 'Bonjour',
textEn: 'Hello',
startsAt: null,
endsAt: null,
priority: 0,
isActive: true,
};
it('returns nothing when there is nothing to say', () => {
expect(selectActiveMessage([], now)).toBeNull();
});
it('returns a permanent active message', () => {
expect(selectActiveMessage([base], now)?.textFr).toBe('Bonjour');
});
it('ignores a deactivated message', () => {
expect(selectActiveMessage([{ ...base, isActive: false }], now)).toBeNull();
});
it('ignores a message whose window has not opened', () => {
const future = { ...base, startsAt: new Date('2026-09-23T00:00:00Z') };
expect(selectActiveMessage([future], now)).toBeNull();
});
it('ignores a message whose window has closed', () => {
const past = { ...base, endsAt: new Date('2026-09-21T00:00:00Z') };
expect(selectActiveMessage([past], now)).toBeNull();
});
it('keeps a message inside its window', () => {
const current = {
...base,
startsAt: new Date('2026-09-21T00:00:00Z'),
endsAt: new Date('2026-09-23T00:00:00Z'),
};
expect(selectActiveMessage([current], now)?.textFr).toBe('Bonjour');
});
it('prefers the highest priority', () => {
const chosen = selectActiveMessage(
[
{ ...base, textFr: 'Normale', priority: 1 },
{ ...base, textFr: 'Urgente', priority: 9 },
],
now,
);
expect(chosen?.textFr).toBe('Urgente');
});
});
describe('buildScreenPayload', () => {
it('stamps the schema version and the generation time with the shop offset', () => {
const payload = buildScreenPayload(input());
expect(payload.schema).toBe(SCREEN_SCHEMA_VERSION);
expect(payload.generated_at).toBe('2026-09-22T12:00:00+02:00');
});
it('describes an open day', () => {
const payload = buildScreenPayload(input());
expect(payload.today).toMatchObject({
date_fr: 'Mardi 22 septembre',
date_en: 'Tuesday 22 September',
status: 'OPEN',
status_fr: 'OUVERT',
status_en: 'OPEN',
hours_text: '10:00 – 18:30',
next_change_fr: 'Ferme à 18:30',
next_change_en: 'Closes at 6:30 pm',
is_exception: false,
});
});
it('still says OUVERT when closing is imminent', () => {
// 18:05 local: the big word must stay OUVERT, the detail line carries the warning.
const payload = buildScreenPayload(input({ now: new Date('2026-09-22T16:05:00Z') }));
expect(payload.today.status).toBe('CLOSING_SOON');
expect(payload.today.status_fr).toBe('OUVERT');
expect(payload.today.next_change_fr).toBe('Ferme à 18:30');
});
it('describes a closed day and points at the next opening', () => {
// Monday 2026-09-21, 12:00 local.
const payload = buildScreenPayload(input({ now: new Date('2026-09-21T10:00:00Z') }));
expect(payload.today.status).toBe('CLOSED');
expect(payload.today.status_fr).toBe('FERMÉ');
expect(payload.today.status_en).toBe('CLOSED');
expect(payload.today.hours_text).toBe('');
expect(payload.today.next_change_fr).toBe('Ouvre demain à 10:00');
expect(payload.today.next_change_en).toBe('Opens tomorrow at 10:00 am');
});
it('says nothing about the future when the shop never reopens', () => {
const payload = buildScreenPayload(
input({
ctx: context({
weekly: [0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => ({
dayOfWeek,
isClosed: true,
slots: [],
})),
}),
}),
);
expect(payload.today.next_change_fr).toBe('');
expect(payload.today.next_change_en).toBe('');
});
it('lays the week out Monday to Sunday and marks today', () => {
const payload = buildScreenPayload(input());
expect(payload.week).toHaveLength(7);
expect(payload.week.map((day) => day.label_fr)).toEqual([
'Lun',
'Mar',
'Mer',
'Jeu',
'Ven',
'Sam',
'Dim',
]);
expect(payload.week.map((day) => day.label_en)).toEqual([
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
]);
// Tuesday is index 1 of a Monday-first week.
expect(payload.week.map((day) => day.is_today)).toEqual([
false,
true,
false,
false,
false,
false,
false,
]);
expect(payload.week[0]?.is_closed).toBe(true);
expect(payload.week[2]?.hours_text).toBe('10:00 – 13:00 · 14:00 – 18:30');
});
it('marks exceptional days in the week strip', () => {
const payload = buildScreenPayload(
input({
ctx: context({
exceptions: [
{
date: '2026-09-24',
isClosed: true,
slots: null,
reason: 'TEMPORARY',
noteFr: 'Fermé',
noteEn: 'Closed',
source: 'MANUAL',
},
],
}),
}),
);
expect(payload.week[3]?.is_exception).toBe(true);
expect(payload.week[3]?.is_closed).toBe(true);
});
it('carries no banner when the shop has nothing to say', () => {
expect(buildScreenPayload(input()).banner).toBeNull();
});
it('raises the automatic banner when only today departs from the reference week', () => {
const payload = buildScreenPayload(
input({
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(payload.banner).toEqual({
kind: 'SCHEDULE_TODAY',
text_fr: "Changement d'horaire aujourd'hui",
text_en: 'Opening hours changed today',
});
});
it('raises the weekly banner when a later day departs', () => {
const payload = buildScreenPayload(
input({
ctx: context({
exceptions: [
{
date: '2026-09-24',
isClosed: true,
slots: null,
reason: 'TEMPORARY',
noteFr: null,
noteEn: null,
source: 'MANUAL',
},
],
}),
}),
);
expect(payload.banner?.kind).toBe('SCHEDULE_WEEK');
});
it('lets a free-text message win over the automatic banner', () => {
const payload = buildScreenPayload(
input({
ctx: context({
exceptions: [
{
date: '2026-09-24',
isClosed: true,
slots: null,
reason: 'TEMPORARY',
noteFr: null,
noteEn: null,
source: 'MANUAL',
},
],
}),
messages: [
{
textFr: 'Nouvelle collection en boutique',
textEn: 'New collection in store',
startsAt: null,
endsAt: null,
priority: 0,
isActive: true,
},
],
}),
);
expect(payload.banner).toEqual({
kind: 'MESSAGE',
text_fr: 'Nouvelle collection en boutique',
text_en: 'New collection in store',
});
});
it('shows the French line alone when the translation is missing', () => {
const payload = buildScreenPayload(
input({
messages: [
{
textFr: 'Fermeture exceptionnelle',
textEn: null,
startsAt: null,
endsAt: null,
priority: 0,
isActive: true,
},
],
}),
);
expect(payload.banner?.text_fr).toBe('Fermeture exceptionnelle');
expect(payload.banner?.text_en).toBe('');
});
it('truncates a message that would overflow the banner', () => {
const tooLong = 'a'.repeat(MESSAGE_MAX_CHARS + 40);
const payload = buildScreenPayload(
input({
messages: [
{
textFr: tooLong,
textEn: tooLong,
startsAt: null,
endsAt: null,
priority: 0,
isActive: true,
},
],
}),
);
expect(payload.banner?.text_fr.length).toBeLessThanOrEqual(MESSAGE_MAX_CHARS);
expect(payload.banner?.text_fr.endsWith('…')).toBe(true);
});
it('matches the frozen payload contract', () => {
// This snapshot is the regression guard for the whole display pipeline:
// any change to the shape or to a computed string has to be acknowledged
// here before it can reach the panel.
expect(
buildScreenPayload(
input({
ctx: context({
exceptions: [
{
date: '2026-09-24',
isClosed: false,
slots: [{ open: '14:00', close: '18:30' }],
reason: 'TEMPORARY',
noteFr: 'Ouverture retardée',
noteEn: 'Late opening',
source: 'MANUAL',
},
],
holidays: [
{
date: '2026-09-25',
nameFr: 'Jeûne genevois',
nameEn: 'Geneva Fast',
isAutoClosed: true,
},
],
}),
messages: [
{
textFr: 'Fermeture exceptionnelle jeudi après-midi',
textEn: 'Exceptionally closed on Thursday afternoon',
startsAt: null,
endsAt: null,
priority: 0,
isActive: true,
},
],
}),
),
).toMatchSnapshot();
});
});