Lines 100.00% 26 / 26
Functions and Methods 100.00% 13 / 13
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ManualScheduler 100.00% 26 / 26 100.00% 13 / 13 16 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 runEvery 100.00% 1 / 1 100.00% 1 / 1 1
 runDailyAt 100.00% 1 / 1 100.00% 1 / 1 1
 runOnCron 100.00% 1 / 1 100.00% 1 / 1 1
 runAfter 100.00% 1 / 1 100.00% 1 / 1 1
 runAt 100.00% 1 / 1 100.00% 1 / 1 1
 runAtEntityTime 100.00% 3 / 3 100.00% 1 / 1 1
 runAtSunEvent 100.00% 1 / 1 100.00% 1 / 1 1
 cancelAll 100.00% 2 / 2 100.00% 1 / 1 2
 armElapsedSchedule 100.00% 7 / 7 100.00% 1 / 1 2
 armWallClockSchedule 100.00% 5 / 5 100.00% 1 / 1 2
 armTask 100.00% 1 / 1 100.00% 1 / 1 1
 claimTaskId 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Testing\Schedule;
6
7use Closure;
8use DateTimeImmutable;
9use Stewart\Contracts\Entity\EntityId;
10use Stewart\Contracts\HaContext;
11use Stewart\Contracts\Schedule\DayOfWeek;
12use Stewart\Contracts\Schedule\ScheduledRun;
13use Stewart\Contracts\Schedule\ScheduledTask;
14use Stewart\Contracts\Schedule\Scheduler;
15use Stewart\Contracts\Schedule\TimeOfDay;
16use Stewart\Contracts\Sun\SunCalendar;
17use Stewart\Contracts\Sun\SunEvent;
18use Stewart\Contracts\Time\Duration;
19use Stewart\Contracts\Time\Instant;
20use Stewart\Contracts\Time\SunOffset;
21use Stewart\Support\Schedule\CalendarSchedule;
22use Stewart\Support\Schedule\CronSchedule;
23use Stewart\Support\Schedule\DelaySchedule;
24use Stewart\Support\Schedule\ElapsedSchedule;
25use Stewart\Support\Schedule\EntityTime;
26use Stewart\Support\Schedule\IntervalSchedule;
27use Stewart\Support\Schedule\OneShotSchedule;
28use Stewart\Support\Schedule\SunEventSchedule;
29use Stewart\Support\Schedule\WallClockSchedule;
30use Stewart\Testing\Time\ManualTimers;
31
32// Runs schedules on virtual time; there is no pause, and an overrunning handler never skips a run.
33final class ManualScheduler implements Scheduler
34{
35    /** @var list<ScheduledTask> */
36    private array $tasks = [];
37
38    private int $taskCounter = 0;
39
40    public function __construct(
41        private readonly ManualTimers $timers,
42        private readonly SunCalendar $sunCalendar,
43        private readonly HaContext $ha,
44    ) {}
45
46    public function runEvery(Duration $period, Closure $handler): ScheduledTask
47    {
48        return $this->armElapsedSchedule(IntervalSchedule::every($period), $handler);
49    }
50
51    public function runDailyAt(TimeOfDay|string $time, Closure $handler, DayOfWeek ...$onlyOn): ScheduledTask
52    {
53        return $this->armWallClockSchedule(CalendarSchedule::dailyAt($time, ...$onlyOn), $handler);
54    }
55
56    public function runOnCron(string $expression, Closure $handler): ScheduledTask
57    {
58        return $this->armWallClockSchedule(CronSchedule::parse($expression), $handler);
59    }
60
61    public function runAfter(Duration $delay, Closure $handler): ScheduledTask
62    {
63        return $this->armElapsedSchedule(DelaySchedule::after($delay), $handler);
64    }
65
66    public function runAt(DateTimeImmutable $moment, Closure $handler): ScheduledTask
67    {
68        return $this->armWallClockSchedule(OneShotSchedule::fromMoment($moment), $handler);
69    }
70
71    public function runAtEntityTime(EntityId|string $entity, Closure $handler): ScheduledTask
72    {
73        $entityId = EntityId::fromStringOrId($entity);
74        EntityTime::requireSupportedEntity($entityId);
75
76        return $this->tasks[] = new ManualEntityTimeTask($this->claimTaskId(), $entityId, $this->ha, $this->timers->clock, $this->armWallClockSchedule(...), $handler);
77    }
78
79    public function runAtSunEvent(SunEvent $event, Closure $handler, ?SunOffset $offset = null): ScheduledTask
80    {
81        return $this->armWallClockSchedule(SunEventSchedule::forEvent($this->sunCalendar, $event, $offset), $handler);
82    }
83
84    public function cancelAll(): void
85    {
86        foreach ($this->tasks as $task) {
87            $task->cancel();
88        }
89    }
90
91    /** @param Closure(ScheduledRun): void $handler */
92    private function armElapsedSchedule(ElapsedSchedule $schedule, Closure $handler): ScheduledTask
93    {
94        $clock = $this->timers->clock;
95        $anchor = $clock->getMonotonicTime();
96        $anchoredAt = $clock->getNow();
97
98        return $this->armTask(static function (Instant $after) use ($schedule, $anchor, $anchoredAt): ?Instant {
99            $due = $schedule->findNextDueAfter($anchor, $anchor->plus($after->elapsedSince($anchoredAt)));
100
101            return $due === null ? null : $anchoredAt->plus($due->elapsedSince($anchor));
102        }, $handler);
103    }
104
105    /** @param Closure(ScheduledRun): void $handler */
106    private function armWallClockSchedule(WallClockSchedule $schedule, Closure $handler): ScheduledTask
107    {
108        $zone = $this->timers->clock->getTimeZone();
109
110        return $this->armTask(static function (Instant $after) use ($schedule, $zone): ?Instant {
111            $next = $schedule->findNextOccurrenceAfter($after->toDateTime($zone));
112
113            return $next === null ? null : Instant::fromDateTime($next);
114        }, $handler);
115    }
116
117    /**
118     * @param Closure(Instant): ?Instant $findNextRunAfter
119     * @param Closure(ScheduledRun): void $handler
120     */
121    private function armTask(Closure $findNextRunAfter, Closure $handler): ScheduledTask
122    {
123        return $this->tasks[] = new ManualScheduledTask($this->claimTaskId(), $this->timers, $findNextRunAfter, $handler);
124    }
125
126    private function claimTaskId(): string
127    {
128        return 'manual:' . $this->taskCounter++;
129    }
130}