feat: DashboardService with per-source cache and stale fallback, FixtureSource
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.003,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.004,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0.001,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0}}
|
||||
{"version":2,"defects":{"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":7,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":7},"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0,"Weerscherm\\Tests\\RainCalculatorTest::testNormalWeek":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingPreviousDayIsNullAndExcludedFromTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testMissingTodayFallsBackToSumRain24":0,"Weerscherm\\Tests\\RainCalculatorTest::testMonthBoundaryKeepsPreviousDaysButNotInMonthTotal":0,"Weerscherm\\Tests\\RainCalculatorTest::testDaylightSavingTransitionMapsTimestampsToLocalDates":0,"Weerscherm\\Tests\\RainCalculatorTest::testEmptyBodyGivesNulls":0,"Weerscherm\\Tests\\RainCalculatorTest::testDateBeginIsMinOfFirstOfMonthAndThreeDaysAgo":0,"Weerscherm\\Tests\\CacheTest::testWriteThenReadRoundTrip":0.002,"Weerscherm\\Tests\\CacheTest::testMissingKeyReadsNull":0.002,"Weerscherm\\Tests\\CacheTest::testCorruptFileReadsNull":0,"Weerscherm\\Tests\\CacheTest::testFreshness":0,"Weerscherm\\Tests\\CacheTest::testKeyIsSanitised":0,"Weerscherm\\Tests\\CacheTest::testWriteCreatesDirectory":0,"Weerscherm\\Tests\\ForecastMapperTest::testSkipsTodayAndReturnsFiveDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testNullValuesStayNull":0,"Weerscherm\\Tests\\ForecastMapperTest::testShorterResponseGivesFewerDays":0,"Weerscherm\\Tests\\ForecastMapperTest::testMissingTimeArrayGivesEmptyList":0,"Weerscherm\\Tests\\DashboardServiceTest::testHappyPathMatchesContract":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureIsRequestedForRainModuleFromMidnightOfDateBegin":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testForecastUsesStationLocationUnlessConfigured":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithoutCacheGivesNullsAndError":0,"Weerscherm\\Tests\\DashboardServiceTest::testStationsFailureWithCacheServesStaleData":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testAuthFailureGivesNetatmoAuthError":0,"Weerscherm\\Tests\\DashboardServiceTest::testMeasureFailureFallsBackToSumRain24":0,"Weerscherm\\Tests\\DashboardServiceTest::testForecastFailureLeavesRestIntact":0.001,"Weerscherm\\Tests\\DashboardServiceTest::testSecondBuildWithinTtlUsesCache":0.001}}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Bouwt de JSON-payload voor /api/data.php: haalt per bron data op (via cache of de WeatherSource),
|
||||
* valt bij een storing terug op de laatst gecachte data en markeert die als stale.
|
||||
*/
|
||||
final class DashboardService
|
||||
{
|
||||
private const ERR_NETATMO_AUTH = 'netatmo_auth';
|
||||
private const ERR_NETATMO = 'netatmo_unavailable';
|
||||
private const ERR_FORECAST = 'forecast_unavailable';
|
||||
|
||||
/** @var list<string> */
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
* @param array{stations: int, measure: int, forecast: int} $ttl
|
||||
* @param array<string, string> $indoorModules
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly WeatherSource $source,
|
||||
private readonly Cache $cache,
|
||||
private readonly array $ttl,
|
||||
private readonly string $timezoneFallback,
|
||||
private readonly ?float $forecastLat,
|
||||
private readonly ?float $forecastLon,
|
||||
private readonly array $indoorModules,
|
||||
private readonly ?string $deviceId,
|
||||
private readonly ?\DateTimeImmutable $now = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function build(): array
|
||||
{
|
||||
$this->errors = [];
|
||||
$nowUtc = $this->now ?? new \DateTimeImmutable('now');
|
||||
|
||||
$stations = $this->fetch('stations', fn() => $this->source->stationsData(), self::ERR_NETATMO);
|
||||
$station = StationMapper::map($stations['data'] ?? [], $this->deviceId, $this->indoorModules);
|
||||
|
||||
$tz = $this->timezone($station['timezone']);
|
||||
$now = $nowUtc->setTimezone($tz);
|
||||
|
||||
$rain = $this->rain($station, $now);
|
||||
$forecast = $this->forecast($station, $tz);
|
||||
|
||||
return [
|
||||
'generated_at' => $now->format(\DATE_ATOM),
|
||||
'timezone' => $tz->getName(),
|
||||
'station' => [
|
||||
'measured_at' => $this->iso($station['measured_at'], $tz),
|
||||
'stale' => $stations['stale'],
|
||||
],
|
||||
'outdoor' => [
|
||||
'temp' => $station['outdoor']['temp'],
|
||||
'trend' => $station['outdoor']['trend'],
|
||||
'min' => $station['outdoor']['min'],
|
||||
'min_at' => $this->iso($station['outdoor']['min_at'], $tz),
|
||||
'max' => $station['outdoor']['max'],
|
||||
'max_at' => $this->iso($station['outdoor']['max_at'], $tz),
|
||||
],
|
||||
'indoor' => $station['indoor'],
|
||||
'rain' => $rain,
|
||||
'forecast' => $forecast['days'],
|
||||
'forecast_stale' => $forecast['stale'],
|
||||
'errors' => $this->errors,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{today: ?float, previous_days: list<array{date: string, mm: ?float}>, month_total: ?float, stale: bool} */
|
||||
private function rain(array $station, \DateTimeImmutable $now): array
|
||||
{
|
||||
$empty = RainCalculator::compute([], $now, null) + ['stale' => false];
|
||||
if ($station['device_id'] === null || $station['rain_module_id'] === null) {
|
||||
return $empty;
|
||||
}
|
||||
$begin = RainCalculator::dateBegin($now)->getTimestamp();
|
||||
$measure = $this->fetch(
|
||||
'measure',
|
||||
fn() => $this->source->measure($station['device_id'], $station['rain_module_id'], $begin, $now->getTimestamp()),
|
||||
self::ERR_NETATMO,
|
||||
);
|
||||
if ($measure['data'] === null) {
|
||||
return ['today' => $station['sum_rain_24'], 'stale' => true] + $empty;
|
||||
}
|
||||
return RainCalculator::compute($measure['data'], $now, $station['sum_rain_24']) + ['stale' => $measure['stale']];
|
||||
}
|
||||
|
||||
/** @return array{days: list<array>, stale: bool} */
|
||||
private function forecast(array $station, \DateTimeZone $tz): array
|
||||
{
|
||||
$lat = $this->forecastLat ?? $station['lat'];
|
||||
$lon = $this->forecastLon ?? $station['lon'];
|
||||
if ($lat === null || $lon === null) {
|
||||
$this->addError(self::ERR_FORECAST);
|
||||
return ['days' => [], 'stale' => true];
|
||||
}
|
||||
$daily = $this->fetch('forecast', fn() => $this->source->forecast($lat, $lon, $tz->getName()), self::ERR_FORECAST);
|
||||
return [
|
||||
'days' => $daily['data'] === null ? [] : ForecastMapper::map($daily['data']),
|
||||
'stale' => $daily['stale'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-first per bron. Bij een fout: stale cache (ongeacht leeftijd) en een foutcode, anders null.
|
||||
*
|
||||
* @param callable(): array $fetcher
|
||||
* @return array{data: ?array, stale: bool}
|
||||
*/
|
||||
private function fetch(string $key, callable $fetcher, string $errorCode): array
|
||||
{
|
||||
$now = ($this->now ?? new \DateTimeImmutable('now'))->getTimestamp();
|
||||
$ttl = $this->ttl[$key] ?? 0;
|
||||
if ($ttl > 0 && $this->cache->isFresh($key, $ttl, $now)) {
|
||||
return ['data' => $this->cache->read($key)['data'], 'stale' => false];
|
||||
}
|
||||
try {
|
||||
$data = $fetcher();
|
||||
if ($ttl > 0) {
|
||||
$this->cache->write($key, $data, $now);
|
||||
}
|
||||
return ['data' => $data, 'stale' => false];
|
||||
} catch (NetatmoAuthException) {
|
||||
$this->addError(self::ERR_NETATMO_AUTH);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("weerscherm: $key: " . $e->getMessage());
|
||||
$this->addError($errorCode);
|
||||
}
|
||||
$cached = $this->cache->read($key);
|
||||
return ['data' => $cached['data'] ?? null, 'stale' => true];
|
||||
}
|
||||
|
||||
private function addError(string $code): void
|
||||
{
|
||||
if (!in_array($code, $this->errors, true)) {
|
||||
$this->errors[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
private function timezone(?string $name): \DateTimeZone
|
||||
{
|
||||
foreach ([$name, $this->timezoneFallback, 'UTC'] as $candidate) {
|
||||
if ($candidate !== null && in_array($candidate, \DateTimeZone::listIdentifiers(), true)) {
|
||||
return new \DateTimeZone($candidate);
|
||||
}
|
||||
}
|
||||
return new \DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
private function iso(?int $timestamp, \DateTimeZone $tz): ?string
|
||||
{
|
||||
return $timestamp === null ? null : (new \DateTimeImmutable('@' . $timestamp))->setTimezone($tz)->format(\DATE_ATOM);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Vaste voorbeelddata, relatief aan het huidige moment, zodat de frontend zonder Netatmo-koppeling
|
||||
* gebouwd en bekeken kan worden en altijd "vandaag" laat zien.
|
||||
*/
|
||||
final class FixtureSource implements WeatherSource
|
||||
{
|
||||
private const RAIN_PER_DAY = [4.3, 0, 3.2, 5.1, 0, 0, 15.5, 1.2, 6.8, 0.0, 2.4, 0.6, 3.9, 0, 1.1];
|
||||
|
||||
private readonly \DateTimeImmutable $now;
|
||||
|
||||
public function __construct(?\DateTimeImmutable $now = null, private readonly string $timezone = 'Europe/Berlin')
|
||||
{
|
||||
$this->now = ($now ?? new \DateTimeImmutable('now'))->setTimezone(new \DateTimeZone($this->timezone));
|
||||
}
|
||||
|
||||
public function stationsData(): array
|
||||
{
|
||||
$at = fn(string $time) => (int) $this->now->modify($time)->format('U');
|
||||
$today = fn(string $hm) => (int) $this->now->setTime((int) substr($hm, 0, 2), (int) substr($hm, 3, 2))->format('U');
|
||||
|
||||
return ['devices' => [[
|
||||
'_id' => '70:ee:50:00:00:01', 'type' => 'NAMain', 'station_name' => 'Thuis', 'module_name' => 'Woonkamer', 'reachable' => true,
|
||||
'place' => ['timezone' => $this->timezone, 'location' => [6.55, 51.82]],
|
||||
'dashboard_data' => ['time_utc' => $at('-2 minutes'), 'Temperature' => 21.3, 'min_temp' => 20.4, 'max_temp' => 22.1,
|
||||
'date_min_temp' => $today('05:50'), 'date_max_temp' => $today('13:40'), 'temp_trend' => 'stable'],
|
||||
'modules' => [
|
||||
['_id' => '02:00:00:00:00:01', 'type' => 'NAModule1', 'module_name' => 'Buiten', 'reachable' => true,
|
||||
'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Temperature' => 16.4, 'min_temp' => 9.2, 'max_temp' => 19.8,
|
||||
'date_min_temp' => $today('06:10'), 'date_max_temp' => $today('13:05'), 'temp_trend' => 'down']],
|
||||
['_id' => '03:00:00:00:00:02', 'type' => 'NAModule4', 'module_name' => 'Slaapkamer', 'reachable' => true,
|
||||
'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 18.9, 'min_temp' => 18.1, 'max_temp' => 19.6, 'temp_trend' => 'up']],
|
||||
['_id' => '03:00:00:00:00:03', 'type' => 'NAModule4', 'module_name' => 'Kantoor', 'reachable' => true,
|
||||
'dashboard_data' => ['time_utc' => $at('-4 minutes'), 'Temperature' => 20.2, 'min_temp' => 19.0, 'max_temp' => 21.0, 'temp_trend' => 'stable']],
|
||||
['_id' => '05:00:00:00:00:04', 'type' => 'NAModule3', 'module_name' => 'Regenmeter', 'reachable' => true,
|
||||
'dashboard_data' => ['time_utc' => $at('-3 minutes'), 'Rain' => 0, 'sum_rain_1' => 0, 'sum_rain_24' => 2.4]],
|
||||
],
|
||||
]]];
|
||||
}
|
||||
|
||||
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
$body = [];
|
||||
$count = count(self::RAIN_PER_DAY);
|
||||
foreach (self::RAIN_PER_DAY as $i => $mm) {
|
||||
$day = $this->now->setTime(3, 17)->modify('-' . ($count - 1 - $i) . ' days');
|
||||
if ($day->getTimestamp() >= $begin) {
|
||||
$body[(string) $day->getTimestamp()] = [$mm];
|
||||
}
|
||||
}
|
||||
return $body;
|
||||
}
|
||||
|
||||
public function forecast(float $lat, float $lon, string $timezone): array
|
||||
{
|
||||
$dates = [];
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$dates[] = $this->now->modify("+$i days")->format('Y-m-d');
|
||||
}
|
||||
return [
|
||||
'time' => $dates,
|
||||
'weather_code' => [3, 2, 0, 61, 80, 95],
|
||||
'temperature_2m_max' => [17.9, 18.3, 20.1, 17.4, 15.2, 16.0],
|
||||
'temperature_2m_min' => [11.0, 10.1, 11.3, 12.0, 9.4, 8.1],
|
||||
'precipitation_sum' => [0.0, 0.4, 0.0, 8.2, 3.1, 0.2],
|
||||
'precipitation_probability_max' => [10, 20, 5, 80, 60, 15],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/** Ruwe data van de drie upstream-bronnen. Implementaties: LiveSource (echte API's) en FixtureSource (voorbeelddata). */
|
||||
interface WeatherSource
|
||||
{
|
||||
/** @return array<string, mixed> de "body" van Netatmo getstationsdata */
|
||||
public function stationsData(): array;
|
||||
|
||||
/** @return array<string|int, mixed> de "body" van Netatmo getmeasure (timestamp => [mm]) */
|
||||
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array;
|
||||
|
||||
/** @return array<string, mixed> het "daily"-blok van Open-Meteo */
|
||||
public function forecast(float $lat, float $lon, string $timezone): array;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?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, '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', 'indoor', 'rain', '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'], $p['outdoor']);
|
||||
self::assertCount(3, $p['indoor']);
|
||||
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(5, $p['forecast']);
|
||||
self::assertSame('2026-09-12', $p['forecast'][0]['date']);
|
||||
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 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('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 testSecondBuildWithinTtlUsesCache(): void
|
||||
{
|
||||
$this->service()->build();
|
||||
$this->service('2026-09-11 12:36:05 UTC')->build();
|
||||
|
||||
self::assertSame(['stations' => 1, 'measure' => 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use Weerscherm\WeatherSource;
|
||||
|
||||
/** Testdubbel: geeft per methode een array terug of gooit de ingestelde exception, en telt de aanroepen. */
|
||||
final class FakeSource implements WeatherSource
|
||||
{
|
||||
/** @var array<string, array|\Throwable> */
|
||||
public array $responses = [];
|
||||
|
||||
/** @var array<string, int> */
|
||||
public array $calls = ['stations' => 0, 'measure' => 0, 'forecast' => 0];
|
||||
|
||||
/** @var array<string, array> */
|
||||
public array $lastArgs = [];
|
||||
|
||||
public static function fromFixtures(): self
|
||||
{
|
||||
$s = new self();
|
||||
$s->responses = [
|
||||
'stations' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true)['body'],
|
||||
'measure' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/getmeasure.json'), true)['body'],
|
||||
'forecast' => json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'],
|
||||
];
|
||||
return $s;
|
||||
}
|
||||
|
||||
public function stationsData(): array
|
||||
{
|
||||
return $this->answer('stations', []);
|
||||
}
|
||||
|
||||
public function measure(string $deviceId, string $moduleId, int $begin, int $end): array
|
||||
{
|
||||
return $this->answer('measure', func_get_args());
|
||||
}
|
||||
|
||||
public function forecast(float $lat, float $lon, string $timezone): array
|
||||
{
|
||||
return $this->answer('forecast', func_get_args());
|
||||
}
|
||||
|
||||
private function answer(string $key, array $args): array
|
||||
{
|
||||
$this->calls[$key]++;
|
||||
$this->lastArgs[$key] = $args;
|
||||
$r = $this->responses[$key] ?? new \RuntimeException("geen response voor $key");
|
||||
if ($r instanceof \Throwable) {
|
||||
throw $r;
|
||||
}
|
||||
return $r;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user