import { describe, expect, it } from 'vitest'; import { SCREEN_HEIGHT, SCREEN_WIDTH } from './layout'; import { encodeScreen } from './encode'; import { renderScreenSvg, screenTree } from './render'; import type { ScreenPayload } from './contract'; const PAYLOAD: ScreenPayload = { schema: 1, generated_at: '2026-09-22T12:00:00+02:00', shop_name: 'ITA ITO', logo_url: 'https://horaires.ita-ito.com/brand/logo-eink.png', today: { date_fr: 'Mardi 22 septembre', date_en: 'Tuesday 22 September', status: 'OPEN', status_fr: 'OUVERT', status_en: 'OPEN', hours_text: '10:00 – 13:00 · 14:00 – 18:30', next_change_fr: 'Ferme à 13:00', next_change_en: 'Closes at 1:00 pm', is_exception: false, }, week: [ { label_fr: 'Lun', label_en: 'Mon', hours_text: '', is_today: false, is_closed: true, is_exception: false }, { label_fr: 'Mar', label_en: 'Tue', hours_text: '10:00 – 18:30', is_today: true, is_closed: false, is_exception: false }, { label_fr: 'Mer', label_en: 'Wed', hours_text: '10:00 – 13:00 · 14:00 – 18:30', is_today: false, is_closed: false, is_exception: false }, { label_fr: 'Jeu', label_en: 'Thu', hours_text: '14:00 – 18:30', is_today: false, is_closed: false, is_exception: true }, { label_fr: 'Ven', label_en: 'Fri', hours_text: '', is_today: false, is_closed: true, is_exception: true }, { label_fr: 'Sam', label_en: 'Sat', hours_text: '10:00 – 18:30', is_today: false, is_closed: false, is_exception: false }, { label_fr: 'Dim', label_en: 'Sun', hours_text: '', is_today: false, is_closed: true, is_exception: false }, ], banner: { kind: 'MESSAGE', text_fr: 'Fermeture exceptionnelle jeudi après-midi', text_en: 'Exceptionally closed on Thursday afternoon', }, }; describe('screenTree', () => { it('drops the banner block entirely when there is nothing to say', () => { const withBanner = JSON.stringify(screenTree(PAYLOAD)); const without = JSON.stringify(screenTree({ ...PAYLOAD, banner: null })); expect(withBanner).toContain('Fermeture exceptionnelle jeudi après-midi'); expect(without).not.toContain('Fermeture'); // The week block takes the freed room rather than leaving a white gap. expect(without.length).toBeLessThan(withBanner.length); }); it('places every key label in the drawing', () => { // satori vectorises text into paths, so the strings are only observable // here, on the element tree it is handed. const tree = JSON.stringify(screenTree(PAYLOAD)); for (const label of [ 'ITA ITO', 'Mardi 22 septembre', 'OUVERT', '10:00 – 13:00 · 14:00 – 18:30', 'Ferme à 13:00', 'Closes at 1:00 pm', 'Lun', 'Jeu*', 'Dim', ]) { expect(tree).toContain(label); } }); it('leaves no value unrendered', () => { // A dropped or mistyped field surfaces as one of these in the drawing // rather than as a crash, so they are worth failing the build over. // (Liquid-style {{ }} holes are not checked: there is no template engine // left in this pipeline, and JSON braces would match them anyway.) expect(JSON.stringify(screenTree(PAYLOAD))).not.toMatch( /undefined|\[object Object\]|NaN|null,null/, ); }); it('drops the separator when only one language has something to say', () => { const frenchOnly = JSON.stringify( screenTree({ ...PAYLOAD, today: { ...PAYLOAD.today, next_change_en: '' } }), ); expect(frenchOnly).toContain('Ferme à 13:00'); expect(frenchOnly).not.toContain('"·"'); const englishOnly = JSON.stringify( screenTree({ ...PAYLOAD, today: { ...PAYLOAD.today, next_change_fr: '' } }), ); expect(englishOnly).toContain('Closes at 1:00 pm'); expect(englishOnly).not.toContain('"·"'); }); it('drops the whole next-change line when there is nothing ahead', () => { const tree = JSON.stringify( screenTree({ ...PAYLOAD, today: { ...PAYLOAD.today, next_change_fr: '', next_change_en: '' }, }), ); // Checked against the exact strings: the banner legitimately starts with // "Fermeture", which a looser assertion would trip over. expect(tree).not.toContain('Ferme à 13:00'); expect(tree).not.toContain('Closes at 1:00 pm'); }); it('drops the English banner line when the translation is missing', () => { const tree = JSON.stringify( screenTree({ ...PAYLOAD, banner: { ...PAYLOAD.banner!, text_en: '' } }), ); expect(tree).toContain('Fermeture exceptionnelle jeudi après-midi'); expect(tree).not.toContain('Exceptionally'); }); it('shows the wordmark when one is available, and the shop name otherwise', () => { const withLogo = JSON.stringify(screenTree(PAYLOAD, 'data:image/png;base64,AAAA')); expect(withLogo).toContain('"img"'); expect(withLogo).toContain('data:image/png;base64,AAAA'); // The fallback keeps the panel working if the asset ever goes missing. const withoutLogo = JSON.stringify(screenTree(PAYLOAD, null)); expect(withoutLogo).not.toContain('"img"'); expect(withoutLogo).toContain('ITA ITO'); }); it('omits the hours line on a day with no hours', () => { const tree = JSON.stringify( screenTree({ ...PAYLOAD, today: { ...PAYLOAD.today, hours_text: '' } }), ); expect(tree).not.toContain('10:00 – 13:00 · 14:00 – 18:30'); }); }); describe('renderScreenSvg', () => { it('renders exactly 800x480', async () => { const svg = await renderScreenSvg(PAYLOAD); expect(svg).toContain(`width="${SCREEN_WIDTH}"`); expect(svg).toContain(`height="${SCREEN_HEIGHT}"`); }); it('uses no grey, no gradient and no shadow', async () => { const svg = await renderScreenSvg(PAYLOAD); expect(svg).not.toMatch(/linearGradient|radialGradient|feGaussianBlur|filter=/); // Only pure black and pure white may appear, in either hex spelling. const normalise = (colour: string): string => /^#[0-9a-f]{3}$/i.test(colour) ? `#${[...colour.slice(1)].map((digit) => digit + digit).join('')}`.toLowerCase() : colour.toLowerCase(); const paints = [...svg.matchAll(/(?:fill|stroke)="([^"]+)"/g)].map((match) => normalise(match[1] ?? ''), ); const unexpected = paints.filter( (paint) => !['#000000', '#ffffff', 'none', 'transparent'].includes(paint), ); expect(unexpected).toEqual([]); }); }); describe('encodeScreen', () => { it('produces a 1-bit BMP of the exact panel size', async () => { const bmp = encodeScreen(await renderScreenSvg(PAYLOAD), 'bmp'); expect(bmp.subarray(0, 2).toString('ascii')).toBe('BM'); expect(bmp.readInt32LE(18)).toBe(SCREEN_WIDTH); expect(bmp.readInt32LE(22)).toBe(SCREEN_HEIGHT); expect(bmp.readUInt16LE(28)).toBe(1); // 800x480 at one bit, plus headers: comfortably inside any firmware limit. expect(bmp.length).toBe(62 + 100 * 480); }); it('produces a 1-bit PNG of the exact panel size', async () => { const png = encodeScreen(await renderScreenSvg(PAYLOAD), 'png'); expect(png.readUInt32BE(16)).toBe(SCREEN_WIDTH); expect(png.readUInt32BE(20)).toBe(SCREEN_HEIGHT); expect(png.readUInt8(24)).toBe(1); // Compressed, so it must come out far smaller than the raw bitmap. expect(png.length).toBeLessThan(62 + 100 * 480); }); it('is byte-stable for the same payload', async () => { // The device skips the redraw when the filename is unchanged, and the // filename is a hash of these bytes. Instability would wake the panel for // nothing, every single cycle. const first = encodeScreen(await renderScreenSvg(PAYLOAD), 'bmp'); const second = encodeScreen(await renderScreenSvg(PAYLOAD), 'bmp'); expect(first.equals(second)).toBe(true); }); it('refuses to hand the device an image of the wrong size', () => { // Better a loud failure here than a panel that silently draws nothing. const square = ''; expect(() => encodeScreen(square, 'bmp')).toThrow(/expected 800x480/); }); it('passes the invert option through to the bit packing', async () => { const svg = await renderScreenSvg(PAYLOAD); const normal = encodeScreen(svg, 'bmp'); const inverted = encodeScreen(svg, 'bmp', { invert: true }); expect(normal.equals(inverted)).toBe(false); // Only the pixels differ; the headers are identical. expect(normal.subarray(0, 62).equals(inverted.subarray(0, 62))).toBe(true); }); it('changes when the hours change', async () => { const before = encodeScreen(await renderScreenSvg(PAYLOAD), 'bmp'); const after = encodeScreen( await renderScreenSvg({ ...PAYLOAD, today: { ...PAYLOAD.today, hours_text: '14:00 – 18:30' }, }), 'bmp', ); expect(before.equals(after)).toBe(false); }); });