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