From 89e1e544c50d49f4e12afe588340d8f2583dbad4 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:38:30 +0200 Subject: [PATCH] feat: ForecastMapper and file Cache Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- src/Cache.php | 53 ++++++++++++++++++++++++++ src/ForecastMapper.php | 45 ++++++++++++++++++++++ tests/CacheTest.php | 72 ++++++++++++++++++++++++++++++++++++ tests/ForecastMapperTest.php | 57 ++++++++++++++++++++++++++++ tests/fixtures/forecast.json | 25 +++++++++++++ 6 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 src/Cache.php create mode 100644 src/ForecastMapper.php create mode 100644 tests/CacheTest.php create mode 100644 tests/ForecastMapperTest.php create mode 100644 tests/fixtures/forecast.json diff --git a/.phpunit.result.cache b/.phpunit.result.cache index fa3bd28..03ac22b 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -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}} \ No newline at end of file +{"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}} \ No newline at end of file diff --git a/src/Cache.php b/src/Cache.php new file mode 100644 index 0000000..a3af485 --- /dev/null +++ b/src/Cache.php @@ -0,0 +1,53 @@ +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'; + } +} diff --git a/src/ForecastMapper.php b/src/ForecastMapper.php new file mode 100644 index 0000000..a63273a --- /dev/null +++ b/src/ForecastMapper.php @@ -0,0 +1,45 @@ + $daily + * @return list + */ + 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; + } +} diff --git a/tests/CacheTest.php b/tests/CacheTest.php new file mode 100644 index 0000000..4d211d9 --- /dev/null +++ b/tests/CacheTest.php @@ -0,0 +1,72 @@ +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'); + } +} diff --git a/tests/ForecastMapperTest.php b/tests/ForecastMapperTest.php new file mode 100644 index 0000000..1a768e0 --- /dev/null +++ b/tests/ForecastMapperTest.php @@ -0,0 +1,57 @@ + */ + 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([])); + } +} diff --git a/tests/fixtures/forecast.json b/tests/fixtures/forecast.json new file mode 100644 index 0000000..1b5d86f --- /dev/null +++ b/tests/fixtures/forecast.json @@ -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] + } +}