feat: ForecastMapper and file Cache

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 14:38:30 +02:00
co-authored by Claude Fable 5.1
parent d657fd069f
commit 89e1e544c5
6 changed files with 253 additions and 1 deletions
+1 -1
View File
@@ -1 +1 @@
{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.003,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"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.001,"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}}
{"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}}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/** Eenvoudige bestandscache per sleutel: één JSON-bestand met stored_at en data. Schrijft atomisch. */
final class Cache
{
public function __construct(private readonly string $dir)
{
}
/** @return array{data: array, stored_at: int}|null */
public function read(string $key): ?array
{
$file = $this->file($key);
if (!is_file($file)) {
return null;
}
$raw = json_decode((string) file_get_contents($file), true);
if (!is_array($raw) || !is_array($raw['data'] ?? null) || !is_int($raw['stored_at'] ?? null)) {
return null;
}
return ['data' => $raw['data'], 'stored_at' => $raw['stored_at']];
}
public function isFresh(string $key, int $ttl, int $now): bool
{
$entry = $this->read($key);
return $entry !== null && ($now - $entry['stored_at']) < $ttl;
}
public function write(string $key, array $data, int $now): void
{
if (!is_dir($this->dir) && !mkdir($this->dir, 0750, true) && !is_dir($this->dir)) {
throw new \RuntimeException("Cachemap kan niet worden aangemaakt: {$this->dir}");
}
$file = $this->file($key);
$tmp = $file . '.tmp.' . uniqid('', true);
$json = json_encode(['stored_at' => $now, 'data' => $data], JSON_THROW_ON_ERROR);
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $file)) {
@unlink($tmp);
throw new \RuntimeException("Cachebestand kan niet worden geschreven: $file");
}
}
private function file(string $key): string
{
$safe = strtolower((string) preg_replace('/[^a-z0-9_-]+/i', '_', $key));
return $this->dir . '/' . trim($safe, '_') . '.json';
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Weerscherm;
/** Zet het "daily"-blok van Open-Meteo om naar 5 dagen vanaf morgen (index 0 is vandaag). */
final class ForecastMapper
{
private const DAYS = 5;
/**
* @param array<string, mixed> $daily
* @return list<array{date: string, weather_code: ?int, temp_max: ?float, temp_min: ?float, precip_mm: ?float, precip_prob: ?int}>
*/
public static function map(array $daily): array
{
$dates = is_array($daily['time'] ?? null) ? $daily['time'] : [];
$out = [];
for ($i = 1; $i <= self::DAYS && isset($dates[$i]); $i++) {
if (!is_string($dates[$i])) {
continue;
}
$out[] = [
'date' => $dates[$i],
'weather_code' => self::intOrNull($daily['weather_code'][$i] ?? null),
'temp_max' => self::floatOrNull($daily['temperature_2m_max'][$i] ?? null),
'temp_min' => self::floatOrNull($daily['temperature_2m_min'][$i] ?? null),
'precip_mm' => self::floatOrNull($daily['precipitation_sum'][$i] ?? null),
'precip_prob' => self::intOrNull($daily['precipitation_probability_max'][$i] ?? null),
];
}
return $out;
}
private static function floatOrNull(mixed $v): ?float
{
return is_int($v) || is_float($v) ? (float) $v : null;
}
private static function intOrNull(mixed $v): ?int
{
return is_int($v) || is_float($v) ? (int) round($v) : null;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\Cache;
final class CacheTest extends TestCase
{
private string $dir;
protected function setUp(): void
{
$this->dir = sys_get_temp_dir() . '/weerscherm-cache-' . uniqid();
}
protected function tearDown(): void
{
array_map('unlink', glob($this->dir . '/*') ?: []);
@rmdir($this->dir);
}
public function testWriteThenReadRoundTrip(): void
{
$cache = new Cache($this->dir);
$cache->write('stations', ['a' => 1.5, 'b' => null], 1000);
self::assertSame(['data' => ['a' => 1.5, 'b' => null], 'stored_at' => 1000], $cache->read('stations'));
}
public function testMissingKeyReadsNull(): void
{
self::assertNull((new Cache($this->dir))->read('nope'));
}
public function testCorruptFileReadsNull(): void
{
mkdir($this->dir, 0700, true);
file_put_contents($this->dir . '/broken.json', '{not json');
self::assertNull((new Cache($this->dir))->read('broken'));
}
public function testFreshness(): void
{
$cache = new Cache($this->dir);
$cache->write('k', [], 1000);
self::assertTrue($cache->isFresh('k', 300, 1299));
self::assertFalse($cache->isFresh('k', 300, 1300));
self::assertFalse($cache->isFresh('missing', 300, 1000));
}
public function testKeyIsSanitised(): void
{
$cache = new Cache($this->dir);
$cache->write('../Evil Key', ['x' => 1], 1);
self::assertFileExists($this->dir . '/evil_key.json');
self::assertSame(['x' => 1], $cache->read('../Evil Key')['data']);
}
public function testWriteCreatesDirectory(): void
{
(new Cache($this->dir . '/nested'))->write('k', [], 1);
self::assertDirectoryExists($this->dir . '/nested');
unlink($this->dir . '/nested/k.json');
rmdir($this->dir . '/nested');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Weerscherm\Tests;
use PHPUnit\Framework\TestCase;
use Weerscherm\ForecastMapper;
final class ForecastMapperTest extends TestCase
{
/** @return array<string, mixed> */
private function daily(): array
{
return json_decode((string) file_get_contents(__DIR__ . '/fixtures/forecast.json'), true)['daily'];
}
public function testSkipsTodayAndReturnsFiveDays(): void
{
$f = ForecastMapper::map($this->daily());
self::assertCount(5, $f);
self::assertSame([
'date' => '2026-09-12', 'weather_code' => 2, 'temp_max' => 18.3, 'temp_min' => 10.1,
'precip_mm' => 0.4, 'precip_prob' => 20,
], $f[0]);
self::assertSame('2026-09-16', $f[4]['date']);
}
public function testNullValuesStayNull(): void
{
$daily = $this->daily();
$daily['weather_code'][3] = null;
$daily['precipitation_probability_max'][3] = null;
$f = ForecastMapper::map($daily);
self::assertNull($f[2]['weather_code']);
self::assertNull($f[2]['precip_prob']);
self::assertSame(17.4, $f[2]['temp_max']);
}
public function testShorterResponseGivesFewerDays(): void
{
$daily = $this->daily();
foreach ($daily as $key => $values) {
$daily[$key] = array_slice($values, 0, 3);
}
self::assertCount(2, ForecastMapper::map($daily));
}
public function testMissingTimeArrayGivesEmptyList(): void
{
self::assertSame([], ForecastMapper::map([]));
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"latitude": 51.82,
"longitude": 6.56,
"generationtime_ms": 0.2,
"utc_offset_seconds": 7200,
"timezone": "Europe/Berlin",
"timezone_abbreviation": "CEST",
"elevation": 20.0,
"daily_units": {
"time": "iso8601",
"weather_code": "wmo code",
"temperature_2m_max": "°C",
"temperature_2m_min": "°C",
"precipitation_sum": "mm",
"precipitation_probability_max": "%"
},
"daily": {
"time": ["2026-09-11", "2026-09-12", "2026-09-13", "2026-09-14", "2026-09-15", "2026-09-16"],
"weather_code": [3, 2, 0, 61, 80, 2],
"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]
}
}