In de lege ruimte rechts van de grote buitentemperatuur staat nu het
verloop van vandaag: onderschrift "Vandaag", de lijn, en tijdlabels op
hele uren met "nu" rechts. Geen y-as, want de laagste en hoogste waarde
van vandaag staan al onder de balk en dat is precies de schaal.
Het getal en het grafiekje staan in een flexrij, zodat het grafiekje de
resterende ruimte pakt en meebeweegt als het getal smaller of breder
wordt. Vlak na middernacht zijn er nog geen twee meetpunten; dan blijft
het blokje verborgen in plaats van een leeg kader te tonen.
Data uit een extra getmeasure (scale=30min, type=Temperature, op de
buitenmodule) vanaf middernacht lokale tijd, cache 15 minuten, in de
payload als outdoor_history: {points, stale} zodat een storing daar de
rest niet raakt. PressureHistory is generiek gemaakt tot MeasureSeries
met de naam van het waardeveld als parameter, in plaats van er een bijna
identieke tweede klasse naast te zetten. StationMapper geeft nu ook het
id van de buitenmodule door.
Onderweg gevonden: FixtureSource deed een modulo op een float, wat een
PHP-deprecation vóór de JSON in de response zette. Dat verklaart de
kapotte JSON die eerder in de browserconsole langskwam. Opgelost met
intdiv. Servicewerker naar v10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
11 KiB
PHP
241 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Weerscherm\Tests;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use Weerscherm\Cache;
|
|
use Weerscherm\DashboardService;
|
|
use Weerscherm\NetatmoAuthException;
|
|
use Weerscherm\UpstreamException;
|
|
|
|
final class DashboardServiceTest extends TestCase
|
|
{
|
|
private string $dir;
|
|
private FakeSource $source;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->dir = sys_get_temp_dir() . '/weerscherm-dash-' . uniqid();
|
|
$this->source = FakeSource::fromFixtures();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
array_map('unlink', glob($this->dir . '/*') ?: []);
|
|
@rmdir($this->dir);
|
|
}
|
|
|
|
private function service(?string $now = '2026-09-11 12:32:05 UTC', ?float $lat = null, ?float $lon = null): DashboardService
|
|
{
|
|
return new DashboardService(
|
|
$this->source,
|
|
new Cache($this->dir),
|
|
['stations' => 300, 'measure' => 900, 'pressure' => 900, 'temperature' => 900, 'forecast' => 3600],
|
|
'Europe/Amsterdam',
|
|
$lat,
|
|
$lon,
|
|
[],
|
|
null,
|
|
$now === null ? null : new \DateTimeImmutable($now),
|
|
);
|
|
}
|
|
|
|
public function testHappyPathMatchesContract(): void
|
|
{
|
|
$p = $this->service()->build();
|
|
|
|
self::assertSame(['generated_at', 'timezone', 'station', 'outdoor', 'outdoor_history', 'indoor', 'rain', 'pressure', 'forecast', 'forecast_stale', 'errors'], array_keys($p));
|
|
self::assertSame('2026-09-11T14:32:05+02:00', $p['generated_at']);
|
|
self::assertSame('Europe/Berlin', $p['timezone']);
|
|
self::assertSame(['measured_at' => '2026-09-11T14:30:12+02:00', 'stale' => false], $p['station']);
|
|
self::assertSame(['temp' => 16.4, 'trend' => 'down', 'min' => 9.2, 'min_at' => '2026-09-11T06:10:00+02:00', 'max' => 19.8, 'max_at' => '2026-09-11T13:05:00+02:00', 'humidity' => 71.0], $p['outdoor']);
|
|
self::assertCount(3, $p['indoor']);
|
|
self::assertSame(612, $p['indoor'][0]['co2']);
|
|
self::assertSame(48.0, $p['indoor'][0]['humidity']);
|
|
self::assertSame(1014.2, $p['pressure']['current']);
|
|
self::assertSame('up', $p['pressure']['trend']);
|
|
self::assertCount(49, $p['pressure']['history']);
|
|
self::assertSame('2026-09-10T14:30:00+02:00', $p['pressure']['history'][0]['at']);
|
|
self::assertFalse($p['pressure']['stale']);
|
|
self::assertSame(2.4, $p['rain']['today']);
|
|
self::assertSame(['2026-09-08', '2026-09-09', '2026-09-10'], array_column($p['rain']['previous_days'], 'date'));
|
|
self::assertEqualsWithDelta(38.5, $p['rain']['month_total'], 0.0001);
|
|
self::assertFalse($p['rain']['stale']);
|
|
self::assertCount(6, $p['forecast']);
|
|
self::assertSame('2026-09-11', $p['forecast'][0]['date']);
|
|
self::assertCount(12, $p['forecast'][0]['hourly']);
|
|
self::assertSame(['at', 'temp', 'precip_mm', 'wind_kmh', 'wind_dir'], array_keys($p['forecast'][0]['hourly'][0]));
|
|
self::assertSame('2026-09-11T00:00:00+02:00', $p['forecast'][0]['hourly'][0]['at']);
|
|
self::assertFalse($p['forecast_stale']);
|
|
self::assertSame([], $p['errors']);
|
|
}
|
|
|
|
public function testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin(): void
|
|
{
|
|
$this->service()->build();
|
|
|
|
[$deviceId, $moduleId, $begin, $end] = $this->source->lastArgs['measure'];
|
|
self::assertSame('70:ee:50:00:00:01', $deviceId);
|
|
self::assertSame('05:00:00:00:00:04', $moduleId);
|
|
self::assertSame((new \DateTimeImmutable('2026-09-01 00:00', new \DateTimeZone('Europe/Berlin')))->getTimestamp(), $begin);
|
|
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
|
}
|
|
|
|
public function testOutdoorHistoryCoversTodayFromLocalMidnight(): void
|
|
{
|
|
$p = $this->service()->build();
|
|
|
|
[$deviceId, $moduleId, $begin, $end] = $this->source->lastArgs['temperature'];
|
|
self::assertSame('70:ee:50:00:00:01', $deviceId);
|
|
self::assertSame('02:00:00:00:00:01', $moduleId, 'de buitenmodule, niet het basisstation');
|
|
self::assertSame((new \DateTimeImmutable('2026-09-11 00:00', new \DateTimeZone('Europe/Berlin')))->getTimestamp(), $begin);
|
|
self::assertSame((new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp(), $end);
|
|
|
|
self::assertNotEmpty($p['outdoor_history']['points']);
|
|
self::assertSame(['at', 'temp'], array_keys($p['outdoor_history']['points'][0]));
|
|
self::assertFalse($p['outdoor_history']['stale']);
|
|
}
|
|
|
|
public function testOutdoorHistoryFailureLeavesTheRestIntact(): void
|
|
{
|
|
$this->source->responses['temperature'] = new UpstreamException('500');
|
|
|
|
$p = $this->service()->build();
|
|
|
|
self::assertSame(['points' => [], 'stale' => true], $p['outdoor_history']);
|
|
self::assertSame(16.4, $p['outdoor']['temp'], 'de actuele waarden komen uit stationsdata en blijven staan');
|
|
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
|
}
|
|
|
|
public function testPressureHistoryIsRequestedForLast24Hours(): void
|
|
{
|
|
$this->service()->build();
|
|
|
|
[$deviceId, $begin, $end] = $this->source->lastArgs['pressure'];
|
|
$now = (new \DateTimeImmutable('2026-09-11 12:32:05 UTC'))->getTimestamp();
|
|
self::assertSame('70:ee:50:00:00:01', $deviceId);
|
|
self::assertSame($now - 86400, $begin);
|
|
self::assertSame($now, $end);
|
|
}
|
|
|
|
public function testPressureFailureKeepsCurrentValueAndMarksStale(): void
|
|
{
|
|
$this->source->responses['pressure'] = new UpstreamException('500');
|
|
|
|
$p = $this->service()->build();
|
|
|
|
self::assertSame(1014.2, $p['pressure']['current']);
|
|
self::assertSame([], $p['pressure']['history']);
|
|
self::assertTrue($p['pressure']['stale']);
|
|
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
|
}
|
|
|
|
public function testForecastUsesStationLocationUnlessConfigured(): void
|
|
{
|
|
$this->service()->build();
|
|
self::assertSame([51.82, 6.55, 'Europe/Berlin'], $this->source->lastArgs['forecast']);
|
|
|
|
$this->source = FakeSource::fromFixtures();
|
|
array_map('unlink', glob($this->dir . '/*') ?: []);
|
|
$this->service(lat: 52.1, lon: 5.1)->build();
|
|
self::assertSame([52.1, 5.1, 'Europe/Berlin'], $this->source->lastArgs['forecast']);
|
|
}
|
|
|
|
public function testStationsFailureWithoutCacheGivesNullsAndError(): void
|
|
{
|
|
$this->source->responses['stations'] = new UpstreamException('timeout');
|
|
|
|
$p = $this->service()->build();
|
|
|
|
self::assertNull($p['outdoor']['temp']);
|
|
self::assertNull($p['station']['measured_at']);
|
|
self::assertTrue($p['station']['stale']);
|
|
self::assertSame([], $p['indoor']);
|
|
self::assertNull($p['rain']['today']);
|
|
self::assertSame(0, $this->source->calls['measure'], 'zonder regenmeter-id geen getmeasure-call');
|
|
self::assertSame(0, $this->source->calls['pressure'], 'zonder device-id geen luchtdruk-call');
|
|
self::assertSame(0, $this->source->calls['temperature'], 'zonder buitenmodule geen temperatuur-call');
|
|
self::assertSame(['points' => [], 'stale' => false], $p['outdoor_history']);
|
|
self::assertSame(['current' => null, 'trend' => null, 'history' => [], 'stale' => false], $p['pressure']);
|
|
self::assertSame('Europe/Amsterdam', $p['timezone'], 'fallback-tijdzone uit config');
|
|
self::assertSame(['netatmo_unavailable', 'forecast_unavailable'], $p['errors']);
|
|
}
|
|
|
|
public function testStationsFailureWithCacheServesStaleData(): void
|
|
{
|
|
$this->service()->build();
|
|
$this->source->responses['stations'] = new UpstreamException('timeout');
|
|
|
|
$p = $this->service('2026-09-11 13:00:00 UTC')->build();
|
|
|
|
self::assertSame(16.4, $p['outdoor']['temp']);
|
|
self::assertTrue($p['station']['stale']);
|
|
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
|
}
|
|
|
|
public function testAuthFailureGivesNetatmoAuthError(): void
|
|
{
|
|
$this->source->responses['stations'] = new NetatmoAuthException('invalid_grant');
|
|
|
|
$p = $this->service()->build();
|
|
|
|
self::assertContains('netatmo_auth', $p['errors']);
|
|
self::assertNotContains('netatmo_unavailable', $p['errors']);
|
|
}
|
|
|
|
public function testMeasureFailureFallsBackToSumRain24(): void
|
|
{
|
|
$this->source->responses['measure'] = new UpstreamException('500');
|
|
|
|
$p = $this->service()->build();
|
|
|
|
self::assertSame(2.4, $p['rain']['today']);
|
|
self::assertSame([null, null, null], array_column($p['rain']['previous_days'], 'mm'));
|
|
self::assertNull($p['rain']['month_total']);
|
|
self::assertTrue($p['rain']['stale']);
|
|
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
|
}
|
|
|
|
public function testForecastFailureLeavesRestIntact(): void
|
|
{
|
|
$this->source->responses['forecast'] = new UpstreamException('down');
|
|
|
|
$p = $this->service()->build();
|
|
|
|
self::assertSame([], $p['forecast']);
|
|
self::assertTrue($p['forecast_stale']);
|
|
self::assertSame(['forecast_unavailable'], $p['errors']);
|
|
self::assertSame(16.4, $p['outdoor']['temp']);
|
|
}
|
|
|
|
public function testLegacyCachedForecastShapeIsTreatedAsUnavailableNotSilentlyEmpty(): void
|
|
{
|
|
// Vóór 2026-09-13 cachete DashboardService het platte "daily"-blok direct onder de sleutel
|
|
// "forecast". Na de uitbreiding met uurdata verwacht de code {daily, hourly}. Een nog niet
|
|
// verlopen cache in het oude formaat mag niet stil een lege verwachting zonder foutmelding geven.
|
|
$legacyDailyOnly = json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'];
|
|
(new Cache($this->dir))->write('forecast', $legacyDailyOnly, (new \DateTimeImmutable('2026-09-11 12:30:00 UTC'))->getTimestamp());
|
|
|
|
$p = $this->service()->build();
|
|
|
|
self::assertSame([], $p['forecast']);
|
|
self::assertTrue($p['forecast_stale']);
|
|
self::assertSame(['forecast_unavailable'], $p['errors']);
|
|
self::assertSame(0, $this->source->calls['forecast'], 'de cache was nog vers, dus geen live call verwacht');
|
|
}
|
|
|
|
public function testSecondBuildWithinTtlUsesCache(): void
|
|
{
|
|
$this->service()->build();
|
|
$this->service('2026-09-11 12:36:05 UTC')->build();
|
|
|
|
self::assertSame(['stations' => 1, 'measure' => 1, 'pressure' => 1, 'temperature' => 1, 'forecast' => 1], $this->source->calls);
|
|
|
|
$this->service('2026-09-11 12:38:05 UTC')->build();
|
|
self::assertSame(2, $this->source->calls['stations'], 'stations na 5 min opnieuw');
|
|
self::assertSame(1, $this->source->calls['measure'], 'measure nog binnen 15 min');
|
|
}
|
|
}
|