De labels toonden de werkelijke meettijdstippen van Netatmo (11:41, 17:41, 23:41), wat onrustig leest. Nu markeringen op hele uren die op de klok zijn uitgelijnd: elke 6 uur vanaf middernacht, dus 00:00, 06:00, 12:00, 18:00, elk op zijn echte tijdspositie in het venster. "nu" blijft op de rechterrand; een markering die daar te dicht bij komt valt weg. axisMarks() is vervangen door hourTicks(), die uur voor uur door het venster loopt en telkens de lokale klok raadpleegt. Daardoor blijven de labels ook op de zomertijdwissel hele lokale uren, waar optellen in vaste stappen er een uur naast zou zitten; daar is een test voor. Een label vlak op de linkerrand wordt niet gecentreerd, zodat het binnen de grafiek blijft. Servicewerker naar v9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
10 KiB
JavaScript
247 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,
|
||
} 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), []);
|
||
});
|