feat: render the panel image as a 1-bit 800x480 BMP or PNG
With the move to BYOS there is no TRMNL cloud to interpret a Liquid template, so the application draws the screen itself. The pipeline is satori (flexbox to SVG) then resvg (SVG to pixels) then a hand-written 1-bit encoder. Chromium was the alternative and was rejected: half a gigabyte and a real memory appetite in the runtime image, for a screen of eight blocks. Playwright still handles the E2E tests, in its own pinned image, never in the application one. The payoff is testability. The layout is asserted on the element tree and the geometry on the SVG text, so a rendering regression shows up as a readable diff instead of a pixel comparison. The BMP and PNG encoders are verified field by field against their specifications, including an independent CRC-32 for the PNG: a device rejecting a malformed image is expensive to debug from a shop window. Two properties are pinned because the battery depends on them: the same payload must produce byte-identical output, and changed hours must produce different output. The filename handed to the device is a hash of these bytes, and the firmware skips the redraw when it is unchanged. The fonts are vendored into public/fonts and the logo into public/brand, both committed. Rendering must not depend on an install tree, a CDN or the network, or the bytes drift and the panel wakes for nothing. `npm run screen:preview` writes a real 1-bit image plus a magnified view, which is where clipping and thin strokes give themselves up. That is how the week strip was caught clipping and condensed. `npm run brand` rebuilds the assets: it locates the wordmark band in the shop logo rather than hard-coding offsets a future revision would break, and thresholds it with the panel's own encoder. sharp is a devDependency used only there; nothing at runtime needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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://trmnl.loxi.ch/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 = '<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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user