Naast het getal in elke modulekaart staat nu hetzelfde soort lijntje als bij buiten (200x40). Zonder labels: daar is in zo'n kaart geen ruimte voor, de schaal staat al op de regel eronder en het venster is hetzelfde "vandaag" als bij het grafiekje van buiten ernaast. Per module een eigen getmeasure, gecachet onder temperature-<module> met een gedeelde TTL; fetch() heeft daarvoor een losse ttlKey gekregen, want anders zou de TTL-lookup per module mislukken en zou er helemaal niet gecachet worden. Het basisstation meet zelf temperatuur en wordt zonder module_id opgevraagd, dus die parameter is optioneel geworden. In de payload als indoor[].history; mislukt er een, dan blijft die lijst leeg en verdwijnt alleen dat lijntje, met de foutcode in errors. Een stale-vlag per kamer zou het contract nodeloos zwaar maken. De hoogte is overal 40px, ook bij een vastgezette module: gelijke hoogte houdt de kamers onderling vergelijkbaar, en een hoger lijntje zou de toch al krappe speling onder de kolom opeten. De grafiekjes voegen geen hoogte toe aan de kaarten. FakeSource houdt nu alle aanroepen bij in plaats van alleen de laatste, zodat een test de volgorde van de vier temperatuur-calls kan nalopen. Servicewerker naar v11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
12 KiB
PHP
273 lines
12 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->allArgs['temperature'][0];
|
|
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 testEveryIndoorModuleGetsItsOwnHistory(): void
|
|
{
|
|
$p = $this->service()->build();
|
|
|
|
foreach ($p['indoor'] as $room) {
|
|
self::assertNotEmpty($room['history'], "kamer {$room['name']} heeft een verloop");
|
|
self::assertSame(['at', 'temp'], array_keys($room['history'][0]));
|
|
}
|
|
|
|
// Het basisstation meet zelf; dat vraag je op zonder module_id.
|
|
$modules = array_map(fn(array $args) => $args[1], $this->source->allArgs['temperature']);
|
|
self::assertSame(
|
|
['02:00:00:00:00:01', null, '03:00:00:00:00:02', '03:00:00:00:00:03'],
|
|
$modules,
|
|
'buiten, dan het basisstation zonder module_id, dan de twee losse modules',
|
|
);
|
|
}
|
|
|
|
public function testIndoorHistoryFailureOnlyEmptiesThatList(): void
|
|
{
|
|
$this->source->responses['temperature'] = new UpstreamException('500');
|
|
|
|
$p = $this->service()->build();
|
|
|
|
foreach ($p['indoor'] as $room) {
|
|
self::assertSame([], $room['history']);
|
|
}
|
|
self::assertSame(21.3, $p['indoor'][0]['temp'], 'de actuele waarden komen uit stationsdata en blijven staan');
|
|
self::assertSame(['netatmo_unavailable'], $p['errors']);
|
|
}
|
|
|
|
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' => 4, '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(4, $this->source->calls['temperature'], 'verloop per module nog binnen 15 min');
|
|
self::assertSame(1, $this->source->calls['measure'], 'measure nog binnen 15 min');
|
|
}
|
|
}
|