A bottom strip gave each day a 114px column, and that width was what capped the type size. A side band gives 142px of usable width and a 68px row, so the hours go from 19pt to 22pt, the day labels from 20 to 23, and each day fits on one line — "Mar 11:00–18:00" reads at a glance where a column had to be read downwards. Right rather than left, for two reasons the renders make plain: on the left the logo is pushed into the middle and loses its place as a signature, and the eye meets the week before the day's status. OUVERT is what has to be read first from the pavement. The times stay whole. Only the spaces around the dash go, which is enough to hold 22pt without abbreviating anything: a shop's opening hour is not a detail to shorten. The idea came from the shop owner, and testing it surfaced a defect that had been in production all along. Rendering the worst case the data model allows — three periods in a day, none on a round hour — showed the day's hours wrapping, pushing the layout down, and the English line of the banner falling off the bottom edge. A panel does not scroll and does not reflow: what does not fit is lost. Type size is now fitted to the space available, with a floor below which it will not go, and nothing may leave its box. The width estimate is deliberately crude: measuring glyphs would mean loading the font in that module and would still be an estimate, since the renderer applies its own kerning. Erring small costs a point of type, erring large costs a broken screen. Three periods a day will reportedly never happen. The protection stays anyway — it costs nothing and removes the need to remember. `npm run screen:fonts` renders every arrangement and both alternative typefaces, including the worst case, because choosing type for a 1-bit panel is done by looking at it thresholded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
234 lines
9.6 KiB
TypeScript
234 lines
9.6 KiB
TypeScript
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('keeps the hours whole in the week, tightening only the dash', () => {
|
||
const tree = JSON.stringify(screenTree(PAYLOAD));
|
||
// A shop's opening hour is not a detail to abbreviate: 11:00 stays 11:00.
|
||
expect(tree).toContain('10:00–13:00');
|
||
expect(tree).toContain('14:00–18:30');
|
||
expect(tree).not.toContain('10–13');
|
||
// The main block has the room for the spaced form, and it is the line
|
||
// someone actually reads for today.
|
||
expect(tree).toContain('10:00 – 13:00 · 14:00 – 18:30');
|
||
});
|
||
|
||
it('draws the week down the right-hand side by default', () => {
|
||
const tree = JSON.stringify(screenTree(PAYLOAD));
|
||
// The band is the default arrangement; the bottom strip remains available.
|
||
expect(tree).toContain('"width":232');
|
||
expect(JSON.stringify(screenTree(PAYLOAD, null, 'bottom'))).not.toContain('"width":232');
|
||
});
|
||
|
||
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 = '<svg width="10" height="10" xmlns="http://www.w3.org/2000/svg"></svg>';
|
||
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);
|
||
});
|
||
});
|