Er is een vierde binnenmodule bijgekomen. De kaart verscheen al vanzelf (StationMapper leest alle NAModule4-modules uit), maar de kolom was op drie kaarten ontworpen: vier kaarten van 120px duwden de rij op 481px en daarmee de onderste strook 39px voorbij de onderrand van het canvas. De kolom heeft nu een vaste hoogte van 412px met overflow verborgen, en fitRooms() verkleint de inhoud met een schaal zodra die niet past. Bij drie modules verandert er niets (schaal 1), bij vier wordt het 0,86 en met een vastgezette module 0,78. Zo blijft het scherm kloppen, ongeacht hoeveel modules erbij komen. De dev-fixture heeft er een vierde module bij, zodat dit lokaal te zien is zonder Netatmo-koppeling. Servicewerker naar v12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
10 KiB
JavaScript
260 lines
10 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
|
||
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
|
||
} from '../../public/format.js';
|
||
|
||
const TZ = 'Europe/Berlin';
|
||
|
||
test('f1 uses a decimal comma and one decimal', () => {
|
||
assert.equal(f1(16.4), '16,4');
|
||
assert.equal(f1(0), '0,0');
|
||
assert.equal(f1(19.75), '19,8');
|
||
assert.equal(f1(null), '–');
|
||
assert.equal(f1(undefined), '–');
|
||
});
|
||
|
||
test('fInt rounds to whole degrees', () => {
|
||
assert.equal(fInt(18.3), '18');
|
||
assert.equal(fInt(9.5), '10');
|
||
assert.equal(fInt(null), '–');
|
||
});
|
||
|
||
test('fmm shows "0 mm" for zero', () => {
|
||
assert.equal(fmm(0), '0 mm');
|
||
assert.equal(fmm(0.4), '0,4 mm');
|
||
assert.equal(fmm(8.25), '8,3 mm');
|
||
assert.equal(fmm(null), '–');
|
||
});
|
||
|
||
test('markerPos interpolates and clamps', () => {
|
||
assert.ok(Math.abs(markerPos(16.4, 9.2, 19.8) - 67.92) < 0.01);
|
||
assert.equal(markerPos(5, 9.2, 19.8), 0);
|
||
assert.equal(markerPos(25, 9.2, 19.8), 100);
|
||
assert.equal(markerPos(10, 10, 10), 50);
|
||
assert.equal(markerPos(null, 1, 2), 50);
|
||
});
|
||
|
||
test('barHeights scales to the tallest bar with a 2px floor', () => {
|
||
const h = barHeights([1.2, 6.8, 0, 2.4]);
|
||
assert.ok(Math.abs(h[0] - 12.35) < 0.01);
|
||
assert.equal(h[1], 70);
|
||
assert.equal(h[2], 2);
|
||
assert.ok(Math.abs(h[3] - 24.7) < 0.01);
|
||
assert.deepEqual(barHeights([0, 0, 0, 0]), [2, 2, 2, 2]);
|
||
assert.deepEqual(barHeights([null, 0, undefined, 0]), [2, 2, 2, 2]);
|
||
assert.deepEqual(barHeights([null, 5]), [2, 70]);
|
||
});
|
||
|
||
test('wmoIcon maps every documented code', () => {
|
||
assert.equal(wmoIcon(0), 'sun');
|
||
assert.equal(wmoIcon(1), 'partly');
|
||
assert.equal(wmoIcon(2), 'partly');
|
||
assert.equal(wmoIcon(3), 'cloud');
|
||
assert.equal(wmoIcon(45), 'fog');
|
||
assert.equal(wmoIcon(48), 'fog');
|
||
assert.equal(wmoIcon(51), 'drizzle');
|
||
assert.equal(wmoIcon(57), 'drizzle');
|
||
assert.equal(wmoIcon(61), 'rain');
|
||
assert.equal(wmoIcon(67), 'rain');
|
||
assert.equal(wmoIcon(80), 'showers');
|
||
assert.equal(wmoIcon(82), 'showers');
|
||
assert.equal(wmoIcon(71), 'snow');
|
||
assert.equal(wmoIcon(77), 'snow');
|
||
assert.equal(wmoIcon(85), 'snow');
|
||
assert.equal(wmoIcon(86), 'snow');
|
||
assert.equal(wmoIcon(95), 'thunder');
|
||
assert.equal(wmoIcon(99), 'thunder');
|
||
assert.equal(wmoIcon(42), 'cloud');
|
||
assert.equal(wmoIcon(null), 'cloud');
|
||
});
|
||
|
||
test('trendLabel', () => {
|
||
assert.equal(trendLabel('up'), '↗ stijgend');
|
||
assert.equal(trendLabel('down'), '↘ dalend');
|
||
assert.equal(trendLabel('stable'), '→ stabiel');
|
||
assert.equal(trendLabel(null), '');
|
||
});
|
||
|
||
test('formatDate gives a capitalised Dutch date in the station timezone', () => {
|
||
assert.equal(formatDate(new Date('2026-09-11T12:32:05Z'), TZ), 'Vrijdag 11 september');
|
||
// 23:30 UTC is al de volgende dag in Berlijn
|
||
assert.equal(formatDate(new Date('2026-09-11T23:30:00Z'), TZ), 'Zaterdag 12 september');
|
||
});
|
||
|
||
test('formatTime uses HH:MM in the station timezone', () => {
|
||
assert.equal(formatTime('2026-09-11T06:10:00+02:00', TZ), '06:10');
|
||
assert.equal(formatTime('2026-09-11T22:05:00Z', TZ), '00:05');
|
||
assert.equal(formatTime(null, TZ), '–');
|
||
assert.equal(formatTime('kapot', TZ), '–');
|
||
});
|
||
|
||
test('dayAbbr gives two-letter Dutch weekday', () => {
|
||
assert.equal(dayAbbr('2026-09-12', TZ), 'Za');
|
||
assert.equal(dayAbbr('2026-09-13', TZ), 'Zo');
|
||
assert.equal(dayAbbr('2026-09-14', TZ), 'Ma');
|
||
assert.equal(dayAbbr('2026-09-15', TZ), 'Di');
|
||
assert.equal(dayAbbr('2026-09-16', TZ), 'Wo');
|
||
assert.equal(dayAbbr('2026-09-17', TZ), 'Do');
|
||
assert.equal(dayAbbr('2026-09-18', TZ), 'Vr');
|
||
});
|
||
|
||
test('monthName', () => {
|
||
assert.equal(monthName(new Date('2026-09-11T12:00:00Z'), TZ), 'September');
|
||
});
|
||
|
||
test('isOlderThan', () => {
|
||
const now = new Date('2026-09-11T12:32:00Z');
|
||
assert.equal(isOlderThan('2026-09-11T12:10:00Z', now, 30), false);
|
||
assert.equal(isOlderThan('2026-09-11T11:50:00Z', now, 30), true);
|
||
assert.equal(isOlderThan(null, now, 30), true);
|
||
});
|
||
|
||
test('msUntil computes the wait until the next local time', () => {
|
||
const now = new Date('2026-09-11T12:32:05.500Z'); // 14:32:05 in Berlijn
|
||
assert.equal(msUntil(now, TZ, 0), ((24 - 14) * 3600 - 32 * 60 - 5) * 1000 - 500);
|
||
assert.equal(msUntil(now, TZ, 4), ((24 - 14 + 4) * 3600 - 32 * 60 - 5) * 1000 - 500);
|
||
assert.equal(msUntil(now, TZ, 15), (28 * 60 - 5) * 1000 - 500);
|
||
assert.equal(msUntil(new Date('2026-09-11T22:00:00.000Z'), TZ, 0), 86400000, 'precies op het moment zelf: pas morgen weer');
|
||
});
|
||
|
||
import {
|
||
co2Level, fPct, fHpa, seriesRange, sparklinePath, sparklinePoints,
|
||
windArrowDeg, fKmh, formatHour, formatFullDate, pinRoom, hourTicks, scaleToFit,
|
||
} from '../../public/format.js';
|
||
|
||
test('co2Level uses the Netatmo thresholds', () => {
|
||
assert.deepEqual(co2Level(612), { level: 'good', label: 'goed' });
|
||
assert.deepEqual(co2Level(1000), { level: 'good', label: 'goed' });
|
||
assert.deepEqual(co2Level(1001), { level: 'medium', label: 'matig' });
|
||
assert.deepEqual(co2Level(1500), { level: 'medium', label: 'matig' });
|
||
assert.deepEqual(co2Level(1501), { level: 'bad', label: 'slecht' });
|
||
assert.deepEqual(co2Level(null), { level: 'unknown', label: '–' });
|
||
});
|
||
|
||
test('fPct and fHpa', () => {
|
||
assert.equal(fPct(48), '48%');
|
||
assert.equal(fPct(47.6), '48%');
|
||
assert.equal(fPct(null), '–');
|
||
assert.equal(fHpa(1014.2), '1014 hPa');
|
||
assert.equal(fHpa(null), '–');
|
||
});
|
||
|
||
test('seriesRange ignores nulls', () => {
|
||
assert.deepEqual(seriesRange([1010, null, 1015.5, 1008]), { min: 1008, max: 1015.5 });
|
||
assert.equal(seriesRange([null, undefined]), null);
|
||
});
|
||
|
||
test('sparklinePath scales points into the box', () => {
|
||
assert.equal(sparklinePath([1000, 1010, 1005], 100, 50, 0), 'M0.0,50.0 L50.0,0.0 L100.0,25.0');
|
||
assert.equal(sparklinePath([1000, 1010], 100, 50, 5), 'M5.0,45.0 L95.0,5.0');
|
||
assert.equal(sparklinePath([1010, 1010, 1010], 100, 50, 0), 'M0.0,25.0 L50.0,25.0 L100.0,25.0', 'vlakke reeks in het midden');
|
||
assert.equal(sparklinePath([1000, null, 1010], 100, 50, 0), 'M0.0,50.0 L100.0,0.0', 'null-punt overgeslagen');
|
||
assert.equal(sparklinePath([1000], 100, 50), '');
|
||
assert.equal(sparklinePath([], 100, 50), '');
|
||
});
|
||
|
||
test('sparklinePoints gives x for every index and null y for unknown values', () => {
|
||
const points = sparklinePoints([1000, null, 1010], 100, 50, 0);
|
||
assert.deepEqual(points.map((p) => p.x), [0, 50, 100]);
|
||
assert.equal(points[0].y, 50);
|
||
assert.equal(points[1].y, null);
|
||
assert.equal(points[2].y, 0);
|
||
});
|
||
|
||
test('windArrowDeg rotates 180 degrees so the arrow points where the wind blows to', () => {
|
||
assert.equal(windArrowDeg(0), 180);
|
||
assert.equal(windArrowDeg(270), 90);
|
||
assert.equal(windArrowDeg(200), 20);
|
||
assert.equal(windArrowDeg(null), null);
|
||
});
|
||
|
||
test('fKmh rounds to whole km/u', () => {
|
||
assert.equal(fKmh(8.4), '8 km/u');
|
||
assert.equal(fKmh(8.6), '9 km/u');
|
||
assert.equal(fKmh(null), '–');
|
||
});
|
||
|
||
test('formatHour gives a two-digit hour in the station timezone', () => {
|
||
assert.equal(formatHour('2026-09-14T00:00:00+02:00', TZ), '00');
|
||
assert.equal(formatHour('2026-09-14T14:00:00+02:00', TZ), '14');
|
||
assert.equal(formatHour('2026-09-13T23:00:00Z', TZ), '01', '23:00 UTC is 01:00 de volgende dag in Berlijn');
|
||
assert.equal(formatHour(null, TZ), '–');
|
||
});
|
||
|
||
test('formatFullDate matches formatDate for the same calendar date', () => {
|
||
assert.equal(formatFullDate('2026-09-14', TZ), 'Maandag 14 september');
|
||
assert.equal(formatFullDate('kapot', TZ), '–');
|
||
});
|
||
|
||
test('pinRoom moves the matching room to the front, keeping the others in order', () => {
|
||
const rooms = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||
assert.deepEqual(pinRoom(rooms, 'b').map((r) => r.id), ['b', 'a', 'c']);
|
||
assert.deepEqual(pinRoom(rooms, 'c').map((r) => r.id), ['c', 'a', 'b']);
|
||
});
|
||
|
||
test('pinRoom returns the original array unchanged without a match, id, or when already first', () => {
|
||
const rooms = [{ id: 'a' }, { id: 'b' }];
|
||
assert.equal(pinRoom(rooms, null), rooms);
|
||
assert.equal(pinRoom(rooms, undefined), rooms);
|
||
assert.equal(pinRoom(rooms, 'x'), rooms, 'onbekend id: geen match, ongewijzigd terug');
|
||
assert.equal(pinRoom(rooms, 'a'), rooms, 'al vooraan: geen kopie nodig');
|
||
});
|
||
|
||
test('pinRoom does not mutate the input array', () => {
|
||
const rooms = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||
const original = rooms.slice();
|
||
pinRoom(rooms, 'c');
|
||
assert.deepEqual(rooms, original);
|
||
});
|
||
|
||
|
||
test('hourTicks puts marks on clock-aligned whole hours inside the window', () => {
|
||
const ticks = hourTicks('2026-09-12T11:41:00+02:00', '2026-09-13T11:41:00+02:00', TZ);
|
||
|
||
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '18:00', '00:00', '06:00']);
|
||
assert.deepEqual(ticks.map((t) => Number(t.percent.toFixed(2))), [1.32, 26.32, 51.32, 76.32]);
|
||
});
|
||
|
||
test('hourTicks drops a mark that would collide with the "nu" label', () => {
|
||
// Venster eindigt om 12:05, dus de markering van 12:00 zit op 99,7% en valt weg.
|
||
const ticks = hourTicks('2026-09-12T12:05:00+02:00', '2026-09-13T12:05:00+02:00', TZ);
|
||
|
||
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['18:00', '00:00', '06:00']);
|
||
});
|
||
|
||
test('hourTicks stays on local whole hours across the daylight saving change', () => {
|
||
// 25 oktober 2026: de klok gaat om 03:00 terug naar 02:00, dus dat venster duurt 25 uur.
|
||
// Zonder de lokale klok te raadplegen zou 00:00 hier op 01:00 uitkomen.
|
||
const ticks = hourTicks('2026-10-24T12:00:00+02:00', '2026-10-25T12:00:00+01:00', TZ);
|
||
|
||
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '18:00', '00:00', '06:00']);
|
||
assert.equal(ticks[0].percent, 0, 'een venster dat precies op een heel uur begint krijgt daar een markering');
|
||
});
|
||
|
||
test('hourTicks honours a different step', () => {
|
||
const ticks = hourTicks('2026-09-13T09:30:00+02:00', '2026-09-13T21:30:00+02:00', TZ, 3);
|
||
|
||
assert.deepEqual(ticks.map((t) => formatTime(t.at, TZ)), ['12:00', '15:00', '18:00']);
|
||
});
|
||
|
||
test('hourTicks gives nothing for an empty or invalid window', () => {
|
||
assert.deepEqual(hourTicks('2026-09-13T10:00:00+02:00', '2026-09-13T10:00:00+02:00', TZ), []);
|
||
assert.deepEqual(hourTicks('kapot', '2026-09-13T10:00:00+02:00', TZ), []);
|
||
assert.deepEqual(hourTicks(undefined, undefined, TZ), []);
|
||
});
|
||
|
||
test('scaleToFit shrinks to fit but never enlarges', () => {
|
||
assert.equal(scaleToFit(481, 412), 412 / 481);
|
||
assert.equal(scaleToFit(361, 412), 1, 'past al: niet vergroten');
|
||
assert.equal(scaleToFit(412, 412), 1);
|
||
});
|
||
|
||
test('scaleToFit is safe with nonsense input', () => {
|
||
assert.equal(scaleToFit(0, 412), 1);
|
||
assert.equal(scaleToFit(-10, 412), 1);
|
||
assert.equal(scaleToFit(481, 0), 1);
|
||
assert.equal(scaleToFit(undefined, 412), 1);
|
||
});
|