Files
ita-ito-horaires/lib/screen/viewmodel.test.ts
T
vliaudatandClaude Opus 5 72aff310d6 feat: build the frozen screen payload from resolved schedule data
Every string the panel shows is computed here, server-side and in both
languages: the status word, the formatted hours, the next-change line,
the week strip and the banner. The renderer downstream only draws, so it
has no access to the schedule and cannot disagree with this file about
what the shop's hours are.

Closing soon is still open and opening soon is still closed: the large
word states the fact and the line under it carries the nuance, which is
the only thing a passer-by can read from the pavement.

A message someone took the trouble to write always wins the banner over
the automatic notice. When its English translation has not landed yet,
the French line shows alone rather than a placeholder.

The snapshot test is the regression guard for the whole display
pipeline: any change to the shape or to a computed string has to be
acknowledged there before it can reach the panel. lib/screen is at 100%
statements and 96% branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 17:42:15 +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://trmnl.loxi.ch/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();
});
});