feat: tik op het regenblok voor de Buienradar-animatie

Het regenblok linksonder opent een overlay met de geanimeerde regenradar
van Buienradar (550x512, tijd in het beeld), naast de regencijfers die we
al hebben: vandaag, de 3 vorige dagen en het maandtotaal. Sluit na 20
seconden of bij een tik, net als het uuroverzicht.

Het beeld loopt via onze eigen /api/radar.php in plaats van een <img>
rechtstreeks naar Buienradar: de spec wil alles lokaal geserveerd, de
iPad praat zo nooit met een derde partij, we cachen 4 minuten (de radar
ververst elke 5) en de servicewerker kan het laatste beeld offline
bewaren. Bij een storing komt het laatst opgehaalde beeld terug; pas als
er niets is, een nette melding in plaats van een leeg vlak.
Bronvermelding staat in de overlay, zoals Buienradar vraagt.

Verder: de overlay-machinerie (20s-timer, balk, sluiten) staat nu een
keer in plaats van per overlay, Http kan optioneel redirects volgen (de
beeld-API leidt door naar een CDN), en alle /api/-paden zijn in de
servicewerker network-first met een cachesleutel zonder querystring.
Servicewerker naar v7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 11:13:28 +02:00
co-authored by Claude Opus 5
parent 2200a849f9
commit c542601d4e
15 changed files with 515 additions and 32 deletions
+1
View File
@@ -32,6 +32,7 @@ return [
'measure' => 900,
'pressure' => 900,
'forecast' => 3600,
'radar' => 240,
],
// Map voor tokens en cache. Moet schrijfbaar zijn voor de webserver en buiten de docroot liggen.
+7
View File
@@ -260,6 +260,12 @@ De iconen `sun`, `partly`, `rain` en `showers` staan in de mockup. Maak `cloud`,
- "Vandaag" groot, in mm met 1 decimaal.
- Daarnaast 4 staafjes: de 3 vorige dagen (labels "Di", "Wo", "Do") en vandaag, uitgelicht. De hoogte is `mm / max(4 waarden) × 70px`, met een minimum van 2px. Zijn alle waarden 0, dan zijn alle staafjes 2px.
- Daaronder "September 38,5 mm".
_Toegevoegd 2026-09-13: het regenblok is tikbaar en opent de regenradar van Buienradar (geanimeerde GIF,
550x512, met de tijd in het beeld), naast de regencijfers van vandaag, de 3 vorige dagen en het maandtotaal.
Sluit na 20 seconden of bij een tik. Het beeld loopt via `/api/radar.php` op onze eigen server (cache 4 min,
bij storing het laatst opgehaalde beeld), zodat de iPad niet zelf met een derde partij praat en het beeld
ook offline blijft staan. Bronvermelding "bron: Buienradar.nl" staat in de overlay; dat vraagt Buienradar
bij vrij gebruik. Zie `src/BuienradarClient.php` en `src/RadarService.php`._
- **Verwachting (rechtsonder):** 6 kolommen met dagafkorting ("Za"), icoon (50px), "max° min°" als hele getallen (min in `--muted`) en "x,x mm" plus neerslagkans in %. Geen neerslag toont "0 mm". Eerste kolom is vandaag.
_Toegevoegd 2026-09-13: elke dagkolom is tikbaar. Een tik opent een uuroverzicht voor die dag (temperatuur,
regen en wind, elk om de 2 uur — 12 punten), dat na 20 seconden vanzelf sluit of eerder bij een tik erop.
@@ -355,6 +361,7 @@ Hangt af van de iPadOS-versie in §0:
- [ ] Alle waarden komen overeen met de Netatmo-app: buiten, 3× binnen, min/max met tijdstippen, regen vandaag en de afgelopen 3 dagen, en het maandtotaal.
- [ ] De verwachting toont 6 dagen (vandaag t/m 5 dagen verder) met het juiste icoon per WMO-code.
- [ ] Tikken op een dag opent het uuroverzicht (temperatuur, regen, wind) en sluit na 20 seconden vanzelf of bij een tik erop.
- [ ] Tikken op het regenblok opent de Buienradar-animatie en sluit op dezelfde manier.
- [ ] De data ververst zichzelf. Na middernacht staat de nieuwe datum er binnen 5 minuten.
- [ ] Wifi uit: de laatste data blijft staan, met een melding "geen verbinding sinds". Wifi aan: herstelt vanzelf.
- [ ] De Netatmo-token ververst meerdere dagen achter elkaar zonder handmatig ingrijpen.
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Weerscherm\BuienradarClient;
use Weerscherm\Config;
use Weerscherm\Http;
use Weerscherm\RadarService;
$root = dirname(__DIR__, 2);
require $root . '/src/autoload.php';
header('X-Robots-Tag: noindex');
$configFile = $root . '/config.php';
if (!is_file($configFile)) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo "config.php ontbreekt\n";
exit;
}
try {
$config = Config::load($configFile);
$ttl = $config->get('cache_ttl')['radar'] ?? 240;
$service = new RadarService(
// Buienradar leidt door naar een CDN, dus deze client volgt redirects.
new BuienradarClient(new Http(followRedirects: true)),
rtrim((string) $config->get('storage_path'), '/') . '/cache/radar.gif',
(int) $ttl,
);
$radar = $service->image();
header('Content-Type: image/gif');
header('Content-Length: ' . strlen($radar['bytes']));
// De browser mag het beeld hergebruiken tot de radar zelf ververst is.
header('Cache-Control: public, max-age=' . max(30, (int) $ttl - $radar['age']));
header('X-Radar-Stale: ' . ($radar['stale'] ? '1' : '0'));
echo $radar['bytes'];
} catch (Throwable $e) {
error_log('weerscherm: radar: ' . $e->getMessage());
http_response_code(503);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo "Radarbeeld niet beschikbaar\n";
}
+72 -12
View File
@@ -14,7 +14,8 @@ const FETCH_TIMEOUT_MS = 10000;
const STALE_MINUTES = 30;
const RELOAD_HOUR = 4;
const FALLBACK_TZ = 'Europe/Berlin';
const HOURLY_OVERLAY_MS = 20000;
const OVERLAY_MS = 20000; // hoe lang een overlay blijft staan zonder tik
const RADAR_BUCKET_MS = 240000; // hergebruikvenster voor het radarbeeld, gelijk aan de servercache
const HOURLY_WIDTH = 1045; // = stage-breedte 1133 min 2x 44px padding, zelfde marge als de rest van het scherm
const $ = (id) => document.getElementById(id);
@@ -120,7 +121,8 @@ function renderForecast(days) {
/* ---------- uuroverzicht per dag ---------- */
let hourlyOverlayTimer = null;
let overlayTimer = null;
let openOverlayId = null;
function renderHourlyCharts(hourly) {
const temps = hourly.map((h) => h.temp);
@@ -147,25 +149,81 @@ function openHourOverlay(date) {
const day = (state.data?.forecast || []).find((d) => d.date === date);
if (!day) return;
clearTimeout(hourlyOverlayTimer);
setText('ho-date', formatFullDate(day.date, state.tz));
renderHourlyCharts(day.hourly || []);
showOverlay('hour-overlay', 'ho-bar-fill');
}
const fill = $('ho-bar-fill');
/* ---------- regenradar ---------- */
function renderRainDetails(rain) {
setText('ro-today', f1(rain.today));
$('ro-days').innerHTML = rain.previous_days
.map((p) => `<p><span>${dayAbbr(p.date, state.tz)}</span><b class="num">${fmm(p.mm)}</b></p>`)
.join('');
setText('ro-month', `${monthName(new Date(), state.tz)} ${f1(rain.month_total)} mm`);
}
/**
* Zet het radarbeeld klaar. De bucket in de URL verandert elke RADAR_BUCKET_MS, zodat de browser
* binnen dat venster het al geladen beeld hergebruikt (tik = meteen zichtbaar) en daarna vanzelf
* een verser beeld ophaalt. Er wordt niets op de achtergrond geladen zolang niemand kijkt.
*/
function loadRadarImage() {
const img = $('ro-img');
const status = $('ro-status');
const src = `api/radar.php?t=${Math.floor(Date.now() / RADAR_BUCKET_MS)}`;
if (img.getAttribute('src') === src && img.complete && img.naturalWidth > 0) {
return;
}
status.textContent = 'Radar laden…';
status.hidden = false;
img.hidden = true;
img.onload = () => {
status.hidden = true;
img.hidden = false;
};
img.onerror = () => {
status.textContent = 'Radar niet beschikbaar';
status.hidden = false;
img.hidden = true;
};
img.setAttribute('src', src);
}
function openRadarOverlay() {
if (!state.data) return;
renderRainDetails(state.data.rain);
loadRadarImage();
showOverlay('radar-overlay', 'ro-bar-fill');
}
/* ---------- gedeelde overlay-machinerie ---------- */
/** Toont een overlay, start de 20s-balk opnieuw en zet de sluittimer. */
function showOverlay(id, barFillId) {
closeOverlay();
const fill = $(barFillId);
fill.style.transition = 'none';
fill.style.transform = 'scaleX(1)';
fill.getBoundingClientRect(); // forceer reflow zodat de volgende transitie opnieuw start
fill.style.transition = `transform ${HOURLY_OVERLAY_MS}ms linear`;
fill.style.transition = `transform ${OVERLAY_MS}ms linear`;
fill.style.transform = 'scaleX(0)';
$('hour-overlay').hidden = false;
hourlyOverlayTimer = setTimeout(closeHourOverlay, HOURLY_OVERLAY_MS);
$(id).hidden = false;
openOverlayId = id;
overlayTimer = setTimeout(closeOverlay, OVERLAY_MS);
}
function closeHourOverlay() {
clearTimeout(hourlyOverlayTimer);
hourlyOverlayTimer = null;
$('hour-overlay').hidden = true;
function closeOverlay() {
clearTimeout(overlayTimer);
overlayTimer = null;
if (openOverlayId !== null) {
$(openOverlayId).hidden = true;
openOverlayId = null;
}
}
function renderBanner() {
@@ -319,7 +377,9 @@ $('forecast').addEventListener('click', (e) => {
const day = e.target.closest('.a-day');
if (day) openHourOverlay(day.dataset.date);
});
$('hour-overlay').addEventListener('click', closeHourOverlay);
$('hour-overlay').addEventListener('click', closeOverlay);
$('radar-overlay').addEventListener('click', closeOverlay);
$('rain-block').addEventListener('click', openRadarOverlay);
$('rooms').addEventListener('click', (e) => {
const room = e.target.closest('.a-room');
+23 -2
View File
@@ -47,7 +47,7 @@
<section class="a-inside" id="rooms"></section>
<section class="a-bottom">
<div>
<div id="rain-block">
<p class="a-sub">Regen</p>
<div class="a-rainrow">
<div>
@@ -63,7 +63,7 @@
<div class="banner" id="banner">Gegevens laden…</div>
<div class="hour-overlay" id="hour-overlay" hidden>
<div class="overlay" id="hour-overlay" hidden>
<div class="ho-bar"><i id="ho-bar-fill"></i></div>
<header class="ho-head">
<span class="ho-date" id="ho-date"></span>
@@ -79,6 +79,27 @@
<div class="ho-grid ho-wind" id="ho-wind"></div>
<div class="ho-grid ho-hours" id="ho-hours"></div>
</div>
<div class="overlay" id="radar-overlay" hidden>
<div class="ho-bar"><i id="ro-bar-fill"></i></div>
<header class="ho-head">
<span class="ho-date">Regen</span>
<span class="ho-hint">tik om te sluiten</span>
</header>
<div class="ro-body">
<div class="ro-map">
<p class="ro-status" id="ro-status">Radar laden…</p>
<img class="ro-img" id="ro-img" alt="Regenradar" hidden>
</div>
<div class="ro-side">
<p class="ro-big"><span class="num" id="ro-today">–</span> mm</p>
<p class="ro-sub">vandaag</p>
<div class="ro-days" id="ro-days"></div>
<p class="ro-month num" id="ro-month">–</p>
<p class="ro-source">bron: Buienradar.nl</p>
</div>
</div>
</div>
</main>
</div>
<div id="rotate">Draai de iPad een kwartslag</div>
+19 -3
View File
@@ -163,8 +163,9 @@ html, body {
}
.banner small { display: block; margin-top: 16px; font-size: 20px; color: var(--muted); }
/* Uuroverzicht per dag: tik op een dagkolom om te openen, tik erop of wacht 20s om te sluiten. */
.hour-overlay {
/* Overlays (uuroverzicht, regenradar): tik om te openen, tik erop of wacht 20s om te sluiten.
De .ho-*-chrome (balk, kop) wordt door beide overlays gedeeld. */
.overlay {
position: absolute;
left: 44px;
right: 44px;
@@ -180,7 +181,7 @@ html, body {
box-shadow: 0 12px 32px rgba(27, 42, 53, .12);
z-index: 5;
}
.hour-overlay[hidden] { display: none; }
.overlay[hidden] { display: none; }
.ho-bar { margin: 0 30px; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; }
.ho-bar i { display: block; width: 100%; height: 100%; background: var(--rain); transform-origin: left; }
@@ -207,6 +208,21 @@ html, body {
.ho-hours span { font-size: 16px; color: var(--muted); }
/* Regenradar */
.ro-body { display: flex; gap: 56px; margin: 0 30px; min-height: 0; }
.ro-map { display: flex; align-items: center; justify-content: center; width: 560px; flex: none; }
.ro-img { height: 512px; width: auto; display: block; border-radius: 10px; }
.ro-status { font-size: 22px; color: var(--muted); }
.ro-side { display: flex; flex-direction: column; justify-content: center; }
.ro-big { font-size: 26px; color: var(--rain); }
.ro-big span { font-size: 76px; font-weight: 300; line-height: 1; }
.ro-sub { font-size: 19px; color: var(--muted); margin-bottom: 26px; }
.ro-days p { display: flex; justify-content: space-between; width: 260px; font-size: 21px; color: var(--muted); padding: 7px 0; border-top: 1px solid var(--line); }
.ro-days b { color: var(--ink); font-weight: 600; }
.ro-month { width: 260px; font-size: 19px; color: var(--muted); margin-top: 18px; }
.ro-source { font-size: 15px; color: var(--muted); margin-top: auto; padding-top: 22px; }
#rotate {
display: none;
position: fixed;
+17 -9
View File
@@ -1,6 +1,6 @@
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
const VERSION = 'weerscherm-v6';
const VERSION = 'weerscherm-v7';
const SHELL = [
'./',
'./index.html',
@@ -14,7 +14,8 @@ const SHELL = [
'./icons/icon-192.png',
'./icons/icon-512.png',
];
const API_PATH = '/api/data.php';
const API_PREFIX = '/api/';
const DATA_PATH = '/api/data.php';
const API_TIMEOUT_MS = 8000;
self.addEventListener('install', (event) => {
@@ -37,7 +38,7 @@ self.addEventListener('fetch', (event) => {
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.endsWith(API_PATH)) {
if (url.pathname.includes(API_PREFIX)) {
event.respondWith(networkFirst(req));
} else {
event.respondWith(cacheFirst(req));
@@ -46,25 +47,32 @@ self.addEventListener('fetch', (event) => {
async function networkFirst(req) {
const cache = await caches.open(VERSION);
const url = new URL(req.url);
// Sleutel zonder querystring: het radarbeeld krijgt elke paar minuten een andere ?t=, en zonder
// normaliseren zou de cache vollopen met varianten en zou offline nooit een treffer geven.
const key = url.origin + url.pathname;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const res = await fetch(req, { signal: controller.signal, cache: 'no-store' });
if (res.ok) {
cache.put(req, res.clone());
cache.put(key, res.clone());
}
return res;
} catch (err) {
const cached = await cache.match(req);
const cached = await cache.match(key);
if (cached) {
const headers = new Headers(cached.headers);
headers.set('X-From-Cache', '1');
return new Response(await cached.arrayBuffer(), { status: 200, statusText: 'OK', headers });
}
return new Response(JSON.stringify({ errors: ['offline'] }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
});
if (url.pathname.endsWith(DATA_PATH)) {
return new Response(JSON.stringify({ errors: ['offline'] }), {
status: 503,
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' },
});
}
return new Response('offline', { status: 503, headers: { 'Cache-Control': 'no-store' } });
} finally {
clearTimeout(timer);
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Haalt de geanimeerde regenradar op bij Buienradar. Vrij te gebruiken met bronvermelding;
* de frontend toont daarom "bron: Buienradar.nl" bij het beeld.
*
* De endpoint leidt door naar een CDN, dus de meegegeven HttpClient moet redirects volgen.
* Het beeld is 550 x 512 met de tijd erin gebrand; opgegeven afmetingen worden genegeerd.
*/
final class BuienradarClient
{
private const URL = 'https://image.buienradar.nl/2.0/image/animation/RadarmapRainNL';
public function __construct(private readonly HttpClient $http)
{
}
/** @return string de ruwe bytes van een geanimeerde GIF */
public function rainRadar(): string
{
$res = $this->http->get(self::URL, ['w' => 550, 'h' => 512], ['Accept: image/gif,image/*']);
if ($res->status !== 200) {
throw new UpstreamException("Buienradar: HTTP {$res->status}");
}
if (!str_starts_with($res->body, 'GIF8')) {
throw new UpstreamException('Buienradar: antwoord is geen GIF');
}
return $res->body;
}
}
+1
View File
@@ -20,6 +20,7 @@ final class Config
'measure' => 900,
'pressure' => 900,
'forecast' => 3600,
'radar' => 240,
],
'storage_path' => null,
];
+12 -3
View File
@@ -7,8 +7,16 @@ namespace Weerscherm;
/** Dunne curl-wrapper met korte timeouts. Netwerkfouten worden UpstreamException. */
final class Http implements HttpClient
{
public function __construct(private readonly int $timeout = 8, private readonly int $connectTimeout = 5)
{
/**
* @param bool $followRedirects standaard uit: de API's die we aanroepen horen direct te antwoorden, en een
* stille omleiding naar een andere host wil je niet. De beeld-API van Buienradar
* leidt bewust door naar een CDN, dus die client zet dit expliciet aan.
*/
public function __construct(
private readonly int $timeout = 8,
private readonly int $connectTimeout = 5,
private readonly bool $followRedirects = false,
) {
}
public function get(string $url, array $query = [], array $headers = []): HttpResponse
@@ -33,7 +41,8 @@ final class Http implements HttpClient
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_FOLLOWLOCATION => $this->followRedirects,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json', 'User-Agent: weerscherm/1.0'], $headers),
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/**
* Serveert het radarbeeld met een eigen bestandscache. De radar ververst ongeveer elke 5 minuten,
* dus een TTL van 4 minuten houdt het beeld actueel zonder het CDN van Buienradar onnodig te belasten.
*
* Bij een storing komt het laatst opgehaalde beeld terug, gemarkeerd als verouderd. Alleen als er
* helemaal niets in de cache staat, faalt de aanroep. Zo blijft het scherm ook zonder verbinding iets tonen.
*/
final class RadarService
{
/** @var \Closure(): int */
private readonly \Closure $now;
public function __construct(
private readonly BuienradarClient $client,
private readonly string $file,
private readonly int $ttl = 240,
?\Closure $now = null,
) {
$this->now = $now ?? static fn(): int => time();
}
/** @return array{bytes: string, stale: bool, age: int} */
public function image(): array
{
$cached = $this->readCache();
if ($cached !== null && $cached['age'] < $this->ttl) {
return ['bytes' => $cached['bytes'], 'stale' => false, 'age' => $cached['age']];
}
try {
$bytes = $this->client->rainRadar();
$this->writeCache($bytes);
return ['bytes' => $bytes, 'stale' => false, 'age' => 0];
} catch (\Throwable $e) {
error_log('weerscherm: radar: ' . $e->getMessage());
if ($cached !== null) {
return ['bytes' => $cached['bytes'], 'stale' => true, 'age' => $cached['age']];
}
throw new UpstreamException('Radarbeeld niet beschikbaar en niets in de cache', 0, $e);
}
}
/** @return array{bytes: string, age: int}|null */
private function readCache(): ?array
{
if (!is_file($this->file)) {
return null;
}
$bytes = file_get_contents($this->file);
$mtime = filemtime($this->file);
if ($bytes === false || $bytes === '' || $mtime === false) {
return null;
}
return ['bytes' => $bytes, 'age' => max(0, ($this->now)() - $mtime)];
}
private function writeCache(string $bytes): void
{
$dir = dirname($this->file);
if (!is_dir($dir) && !mkdir($dir, 0750, true) && !is_dir($dir)) {
throw new \RuntimeException("Cachemap kan niet worden aangemaakt: $dir");
}
$tmp = $this->file . '.tmp.' . uniqid('', true);
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $this->file)) {
@unlink($tmp);
throw new \RuntimeException("Radarbeeld kan niet worden opgeslagen: {$this->file}");
}
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\BuienradarClient;
use Weerscherm\UpstreamException;
final class BuienradarClientTest extends TestCase
{
private const GIF = "GIF89a\x01\x00\x01\x00fake-animatie";
public function testFetchesTheAnimatedRadarImage(): void
{
$http = (new FakeHttp())->queue(200, self::GIF);
$bytes = (new BuienradarClient($http))->rainRadar();
self::assertSame(self::GIF, $bytes);
self::assertSame('https://image.buienradar.nl/2.0/image/animation/RadarmapRainNL', $http->requests[0]['url']);
self::assertSame(['w' => 550, 'h' => 512], $http->requests[0]['params']);
}
public function testErrorStatusIsUpstreamException(): void
{
$http = (new FakeHttp())->queue(503, '');
$this->expectException(UpstreamException::class);
$this->expectExceptionMessage('HTTP 503');
(new BuienradarClient($http))->rainRadar();
}
public function testNonGifResponseIsUpstreamException(): void
{
// Bijvoorbeeld een foutpagina of een redirect die als HTML binnenkomt.
$http = (new FakeHttp())->queue(200, '<!doctype html><title>Oeps</title>');
$this->expectException(UpstreamException::class);
$this->expectExceptionMessage('geen GIF');
(new BuienradarClient($http))->rainRadar();
}
}
+2 -2
View File
@@ -28,7 +28,7 @@ final class ConfigTest extends TestCase
self::assertSame('Europe/Berlin', $config->get('timezone'));
self::assertSame('fixture', $config->get('data_source'));
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
self::assertSame(['stations' => 300, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
self::assertSame([], $config->get('indoor_modules'));
self::assertNull($config->get('netatmo_device_id'));
}
@@ -39,7 +39,7 @@ final class ConfigTest extends TestCase
$config = Config::load($this->file);
self::assertSame('Europe/Amsterdam', $config->get('timezone'));
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600], $config->get('cache_ttl'));
self::assertSame(['stations' => 60, 'measure' => 900, 'pressure' => 900, 'forecast' => 3600, 'radar' => 240], $config->get('cache_ttl'));
}
public function testUnknownKeyReturnsGivenDefault(): void
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\BuienradarClient;
use Weerscherm\RadarService;
use Weerscherm\UpstreamException;
final class RadarServiceTest extends TestCase
{
private const NOW = 1_800_000_000;
private const GIF = "GIF89a\x01\x00\x01\x00vers";
private const OLD_GIF = "GIF89a\x01\x00\x01\x00oud";
private string $dir;
private string $file;
protected function setUp(): void
{
$this->dir = sys_get_temp_dir() . '/weerscherm-radar-' . uniqid();
$this->file = $this->dir . '/radar.gif';
}
protected function tearDown(): void
{
array_map('unlink', glob($this->dir . '/*') ?: []);
@rmdir($this->dir);
}
private function service(FakeHttp $http, int $ttl = 240): RadarService
{
return new RadarService(
new BuienradarClient($http),
$this->file,
$ttl,
static fn(): int => self::NOW,
);
}
/** Legt een cachebestand neer met een gegeven leeftijd in seconden. */
private function seedCache(string $bytes, int $ageSeconds): void
{
mkdir($this->dir, 0700, true);
file_put_contents($this->file, $bytes);
touch($this->file, self::NOW - $ageSeconds);
}
public function testFetchesAndCachesWhenNothingIsStored(): void
{
$http = (new FakeHttp())->queue(200, self::GIF);
$result = $this->service($http)->image();
self::assertSame(['bytes' => self::GIF, 'stale' => false, 'age' => 0], $result);
self::assertStringEqualsFile($this->file, self::GIF);
self::assertSame([], glob($this->dir . '/*.tmp.*') ?: [], 'geen tijdelijke bestanden achtergelaten');
}
public function testFreshCacheIsServedWithoutFetching(): void
{
$this->seedCache(self::OLD_GIF, 100);
$http = new FakeHttp(); // geen response gepland: een fetch zou de test laten falen
$result = $this->service($http)->image();
self::assertSame(self::OLD_GIF, $result['bytes']);
self::assertFalse($result['stale']);
self::assertSame(100, $result['age']);
self::assertSame([], $http->requests);
}
public function testExpiredCacheIsRefetched(): void
{
$this->seedCache(self::OLD_GIF, 300);
$http = (new FakeHttp())->queue(200, self::GIF);
$result = $this->service($http)->image();
self::assertSame(self::GIF, $result['bytes']);
self::assertFalse($result['stale']);
self::assertCount(1, $http->requests);
self::assertStringEqualsFile($this->file, self::GIF);
}
public function testUpstreamFailureFallsBackToTheCachedImageMarkedStale(): void
{
$this->seedCache(self::OLD_GIF, 3600);
$http = (new FakeHttp())->queue(500, '');
$result = $this->service($http)->image();
self::assertSame(self::OLD_GIF, $result['bytes'], 'ongeacht de leeftijd: liever een oud beeld dan niets');
self::assertTrue($result['stale']);
self::assertSame(3600, $result['age']);
}
public function testUpstreamFailureWithoutCacheThrows(): void
{
$http = (new FakeHttp())->queue(500, '');
$this->expectException(UpstreamException::class);
$this->service($http)->image();
}
public function testEmptyCacheFileIsIgnored(): void
{
$this->seedCache('', 10);
$http = (new FakeHttp())->queue(200, self::GIF);
$result = $this->service($http)->image();
self::assertSame(self::GIF, $result['bytes']);
self::assertCount(1, $http->requests, 'een leeg cachebestand telt niet als geldige cache');
}
}
+41 -1
View File
@@ -9,7 +9,8 @@ function loadServiceWorker({ fetchImpl }) {
const store = new Map();
const cache = {
async addAll(urls) { urls.forEach((u) => store.set(new URL(u, 'http://x/').href, new Response('shell'))); },
async put(req, res) { store.set(req.url, res); },
// De echte Cache API accepteert zowel een Request als een URL-string als sleutel.
async put(req, res) { store.set(typeof req === 'string' ? req : req.url, res); },
async match(req) { return store.get(typeof req === 'string' ? req : req.url) || undefined; },
async keys() { return [...store.keys()].map((u) => new Request(u)); },
};
@@ -96,3 +97,42 @@ test('cross-origin and non-GET requests are left alone', () => {
listeners.fetch({ request: new Request('http://x/api/data.php', { method: 'POST' }), respondWith: () => { handled = true; } });
assert.equal(handled, false);
});
test('the radar image is network-first and cached under a key without querystring', async () => {
const { listeners, store } = loadServiceWorker({
fetchImpl: async () => new Response('GIF89a', { status: 200, headers: { 'Content-Type': 'image/gif' } }),
});
const res = await dispatchFetch(listeners, 'http://x/api/radar.php?t=123');
assert.equal(res.status, 200);
assert.ok(store.has('http://x/api/radar.php'), 'sleutel zonder ?t=, anders loopt de cache vol met varianten');
assert.equal(store.has('http://x/api/radar.php?t=123'), false);
});
test('an offline radar request falls back to the cached image, even with a different querystring', async () => {
let online = true;
const { listeners } = loadServiceWorker({
fetchImpl: async () => {
if (!online) throw new TypeError('Failed to fetch');
return new Response('GIF89a', { status: 200, headers: { 'Content-Type': 'image/gif' } });
},
});
await dispatchFetch(listeners, 'http://x/api/radar.php?t=1');
online = false;
const res = await dispatchFetch(listeners, 'http://x/api/radar.php?t=2');
assert.equal(res.status, 200);
assert.equal(res.headers.get('X-From-Cache'), '1');
assert.equal(await res.text(), 'GIF89a');
});
test('an offline radar request without any cache gives a plain 503, not JSON', async () => {
const { listeners } = loadServiceWorker({ fetchImpl: async () => { throw new TypeError('offline'); } });
const res = await dispatchFetch(listeners, 'http://x/api/radar.php?t=1');
assert.equal(res.status, 503);
assert.equal(await res.text(), 'offline');
});