From c542601d4eb6e469c82f39dd90f49015e85e6908 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Sun, 13 Sep 2026 11:13:28 +0200 Subject: [PATCH] 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 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 --- config.example.php | 1 + docs/weerscherm-spec.md | 7 ++ public/api/radar.php | 48 ++++++++++++++ public/app.js | 84 +++++++++++++++++++---- public/index.html | 25 ++++++- public/styles.css | 22 +++++- public/sw.js | 26 +++++--- src/BuienradarClient.php | 35 ++++++++++ src/Config.php | 1 + src/Http.php | 15 ++++- src/RadarService.php | 75 +++++++++++++++++++++ tests/BuienradarClientTest.php | 44 ++++++++++++ tests/ConfigTest.php | 4 +- tests/RadarServiceTest.php | 118 +++++++++++++++++++++++++++++++++ tests/js/sw.test.js | 42 +++++++++++- 15 files changed, 515 insertions(+), 32 deletions(-) create mode 100644 public/api/radar.php create mode 100644 src/BuienradarClient.php create mode 100644 src/RadarService.php create mode 100644 tests/BuienradarClientTest.php create mode 100644 tests/RadarServiceTest.php diff --git a/config.example.php b/config.example.php index c117d55..2ac2ee9 100644 --- a/config.example.php +++ b/config.example.php @@ -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. diff --git a/docs/weerscherm-spec.md b/docs/weerscherm-spec.md index 6b9b5c5..6596e45 100644 --- a/docs/weerscherm-spec.md +++ b/docs/weerscherm-spec.md @@ -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. diff --git a/public/api/radar.php b/public/api/radar.php new file mode 100644 index 0000000..88d1376 --- /dev/null +++ b/public/api/radar.php @@ -0,0 +1,48 @@ +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"; +} diff --git a/public/app.js b/public/app.js index 12dfe15..c075186 100644 --- a/public/app.js +++ b/public/app.js @@ -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) => `

${dayAbbr(p.date, state.tz)}${fmm(p.mm)}

`) + .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'); diff --git a/public/index.html b/public/index.html index bd54daa..abe5712 100644 --- a/public/index.html +++ b/public/index.html @@ -47,7 +47,7 @@
-
+

Regen

@@ -63,7 +63,7 @@ -
Draai de iPad een kwartslag
diff --git a/public/styles.css b/public/styles.css index 798f6c8..ba5b6d0 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; diff --git a/public/sw.js b/public/sw.js index f5ab7c7..d9bf80a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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); } diff --git a/src/BuienradarClient.php b/src/BuienradarClient.php new file mode 100644 index 0000000..3bd0fb0 --- /dev/null +++ b/src/BuienradarClient.php @@ -0,0 +1,35 @@ +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; + } +} diff --git a/src/Config.php b/src/Config.php index 79b61e2..5ceaccf 100644 --- a/src/Config.php +++ b/src/Config.php @@ -20,6 +20,7 @@ final class Config 'measure' => 900, 'pressure' => 900, 'forecast' => 3600, + 'radar' => 240, ], 'storage_path' => null, ]; diff --git a/src/Http.php b/src/Http.php index 1be3561..2cb3884 100644 --- a/src/Http.php +++ b/src/Http.php @@ -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), diff --git a/src/RadarService.php b/src/RadarService.php new file mode 100644 index 0000000..81b7617 --- /dev/null +++ b/src/RadarService.php @@ -0,0 +1,75 @@ +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}"); + } + } +} diff --git a/tests/BuienradarClientTest.php b/tests/BuienradarClientTest.php new file mode 100644 index 0000000..a6fe806 --- /dev/null +++ b/tests/BuienradarClientTest.php @@ -0,0 +1,44 @@ +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, 'Oeps'); + + $this->expectException(UpstreamException::class); + $this->expectExceptionMessage('geen GIF'); + (new BuienradarClient($http))->rainRadar(); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 6ea3a3e..c0a44b3 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -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 diff --git a/tests/RadarServiceTest.php b/tests/RadarServiceTest.php new file mode 100644 index 0000000..64e0a00 --- /dev/null +++ b/tests/RadarServiceTest.php @@ -0,0 +1,118 @@ +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'); + } +} diff --git a/tests/js/sw.test.js b/tests/js/sw.test.js index 89d4b28..dee2d47 100644 --- a/tests/js/sw.test.js +++ b/tests/js/sw.test.js @@ -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'); +});