Lines 100.00% 35 / 35
Functions and Methods 100.00% 8 / 8
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ManualTimers 100.00% 35 / 35 100.00% 8 / 8 14 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 startTimer 100.00% 7 / 7 100.00% 1 / 1 1
 waitFor 100.00% 6 / 6 100.00% 1 / 1 2
 timeout 100.00% 5 / 5 100.00% 1 / 1 1
 delay 100.00% 8 / 8 100.00% 1 / 1 2
 countPendingTimers 100.00% 1 / 1 100.00% 1 / 1 1
 findEarliestDueBy 100.00% 6 / 6 100.00% 1 / 1 5
 forget 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Testing\Time;
6
7use Amp\Cancellation;
8use Amp\DeferredCancellation;
9use Amp\TimeoutException;
10use Closure;
11use Stewart\Contracts\Time\Duration;
12use Stewart\Contracts\Time\MonotonicTime;
13use Stewart\Contracts\Time\PendingWait;
14use Stewart\Contracts\Time\TimerHandle;
15use Stewart\Contracts\Time\Timers;
16use Stewart\Support\Time\Deadlines;
17use Stewart\Support\Time\DeferredWait;
18use Stewart\Testing\Async\HandlerFibers;
19
20final class ManualTimers implements Timers, Deadlines
21{
22    /** @var array<int, ManualTimer> */
23    private array $timers = [];
24
25    public readonly HandlerFibers $handlerFibers;
26
27    public function __construct(public readonly VirtualClock $clock = new VirtualClock())
28    {
29        $this->handlerFibers = new HandlerFibers();
30    }
31
32    public function startTimer(Duration $delay, Closure $callback): TimerHandle
33    {
34        $timer = new ManualTimer(
35            $this->clock->getMonotonicTime()->plus($delay),
36            $callback,
37            $this->forget(...),
38        );
39
40        $this->timers[spl_object_id($timer)] = $timer;
41
42        return $timer;
43    }
44
45    /**
46     * @template T
47     * @param Closure(PendingWait<T>): (Closure(): void) $arm
48     * @return T|null
49     */
50    public function waitFor(Closure $arm, ?Duration $timeout = null): mixed
51    {
52        /** @var DeferredWait<T> $wait */
53        $wait = new DeferredWait();
54        $teardown = $arm($wait);
55        $timeoutTimer = $timeout === null ? null : $this->startTimer($timeout, $wait->expire(...));
56
57        try {
58            return $wait->awaitOutcome();
59        } finally {
60            $timeoutTimer?->cancel();
61            $teardown();
62        }
63    }
64
65    public function timeout(Duration $limit): Cancellation
66    {
67        $deadline = new DeferredCancellation();
68        $timer = $this->startTimer($limit, static function () use ($deadline): void {
69            $deadline->cancel(new TimeoutException());
70        });
71
72        return new ManualTimeout($deadline->getCancellation(), $timer);
73    }
74
75    public function delay(Duration $wait, ?Cancellation $cancellation = null): void
76    {
77        $cancellation?->throwIfRequested();
78
79        $target = $this->clock->getMonotonicTime()->plus($wait);
80
81        while (($due = $this->findEarliestDueBy($target)) !== null) {
82            $this->clock->moveTo($due->dueAt);
83            $due->fire();
84            // Fibers the timer resumed run before the next timer is due, so the timers they start count.
85            EventLoopTicks::settle();
86            $cancellation?->throwIfRequested();
87        }
88
89        $this->clock->moveTo($target);
90    }
91
92    public function countPendingTimers(): int
93    {
94        return \count($this->timers);
95    }
96
97    private function findEarliestDueBy(MonotonicTime $target): ?ManualTimer
98    {
99        $earliest = null;
100
101        foreach ($this->timers as $timer) {
102            if ($timer->dueAt->isAfter($target)) {
103                continue;
104            }
105
106            if ($earliest === null || $timer->dueAt->isBefore($earliest->dueAt)) {
107                $earliest = $timer;
108            }
109        }
110
111        return $earliest;
112    }
113
114    private function forget(ManualTimer $timer): void
115    {
116        unset($this->timers[spl_object_id($timer)]);
117    }
118}