getMethod($method); return $target->invokeArgs($route, $args); } beforeEach(function (): void { $_SERVER['REQUEST_URI'] = '/departments/weather'; }); it('builds a timeline range from start of yesterday to end of today', function (): void { $route = new moduleWeatherAPIRoute(); $range = weather_timeline_range_invoke_private($route, 'getDepartmentWeatherTimelineRange'); $expectedStart = new DateTime(date('Y-m-d 00:00:00')); $expectedStart->sub(new DateInterval('P1D')); $expectedEndExclusive = new DateTime(date('Y-m-d 00:00:00')); $expectedEndExclusive->add(new DateInterval('P1D')); expect($range['start'] instanceof DateTime)->toBeTrue(); expect($range['endExclusive'] instanceof DateTime)->toBeTrue(); expect($range['start']->format('Y-m-d H:i:s'))->toBe($expectedStart->format('Y-m-d H:i:s')); expect($range['endExclusive']->format('Y-m-d H:i:s'))->toBe($expectedEndExclusive->format('Y-m-d H:i:s')); $hourCount = (int)(($range['endExclusive']->getTimestamp() - $range['start']->getTimestamp()) / 3600); expect($hourCount)->toBe(48); }); it('builds a timeline range relative to explicit date_from and date_to override', function (): void { $route = new moduleWeatherAPIRoute(); $range = weather_timeline_range_invoke_private($route, 'getDepartmentWeatherTimelineRange', ['2026-03-08', '2026-03-10']); expect($range['start']->format('Y-m-d H:i:s'))->toBe('2026-03-08 00:00:00'); expect($range['endExclusive']->format('Y-m-d H:i:s'))->toBe('2026-03-11 00:00:00'); $hourCount = (int)(($range['endExclusive']->getTimestamp() - $range['start']->getTimestamp()) / 3600); expect($hourCount)->toBe(72); }); it('resolves forecast day count for a given timeline range with sane limits', function (): void { $route = new moduleWeatherAPIRoute(); $start = new DateTime(date('Y-m-d 00:00:00')); $end = new DateTime(date('Y-m-d 00:00:00')); $end->add(new DateInterval('P3D')); $days = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$start, $end]); expect($days)->toBe(3); $farEnd = new DateTime(date('Y-m-d 00:00:00')); $farEnd->add(new DateInterval('P40D')); $clamped = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$start, $farEnd]); expect($clamped)->toBe(14); $pastStart = new DateTime(date('Y-m-d 00:00:00')); $pastStart->sub(new DateInterval('P10D')); $pastEnd = new DateTime(date('Y-m-d 00:00:00')); $pastEnd->sub(new DateInterval('P5D')); $pastDays = weather_timeline_range_invoke_private($route, 'resolveForecastDaysForTimelineRange', [$pastStart, $pastEnd]); expect($pastDays)->toBe(1); }); it('marks non-started slots as unknown regardless of wash-hour ratio', function (): void { $route = new moduleWeatherAPIRoute(); $targets = [ 'degraded_threshold' => 1.0, 'healthy_threshold' => 1.3, ]; $futureStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, false, $targets]); expect($futureStatus)->toBe('unknown'); $missingTargetStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, true]); expect($missingTargetStatus)->toBe('unknown'); $startedStatus = weather_timeline_range_invoke_private($route, 'calculateStatus', [10, 2.0, true, $targets]); expect($startedStatus)->toBe('healthy'); });