Lines 86.44% 102 / 118
Methods 68.57% 24 / 35
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 15 / 15 100.00% 1 / 1 1
 seedState 100.00% 5 / 5 100.00% 1 / 1 1
 seedHistoricalState 100.00% 2 / 2 100.00% 1 / 1 1
 stubHistoryFailure 100.00% 2 / 2 100.00% 1 / 1 1
 stubServiceAnswer 0.00% 0 / 2 0.00% 0 / 1 2
 stubServiceFailure 0.00% 0 / 2 0.00% 0 / 1 2
 stubEventFireFailure 100.00% 2 / 2 100.00% 1 / 1 1
 cancelSubscriptions 0.00% 0 / 2 0.00% 0 / 1 6
 pushStateChange 80.00% 4 / 5 0.00% 0 / 1 2.03
 pushState 100.00% 10 / 10 100.00% 1 / 1 2
 pushEvent 0.00% 0 / 1 0.00% 0 / 1 2
 pushTrigger 100.00% 1 / 1 100.00% 1 / 1 1
 listWatchedTriggers 100.00% 1 / 1 100.00% 1 / 1 1
 pushConnection 100.00% 2 / 2 100.00% 1 / 1 1
 getLastCall 0.00% 0 / 1 0.00% 0 / 1 2
 getState 100.00% 1 / 1 100.00% 1 / 1 1
 getEntity 100.00% 1 / 1 100.00% 1 / 1 1
 requireState 100.00% 2 / 2 100.00% 1 / 1 1
 getHistory 100.00% 8 / 8 100.00% 1 / 1 3
 listStates 66.66% 2 / 3 0.00% 0 / 1 4.59
 watchStateChanges 100.00% 6 / 6 100.00% 1 / 1 2
 waitForState 100.00% 1 / 1 100.00% 1 / 1 1
 getRegistry 100.00% 1 / 1 100.00% 1 / 1 1
 watchEvents 100.00% 3 / 3 100.00% 1 / 1 1
 watchTrigger 100.00% 5 / 5 100.00% 1 / 1 1
 callService 100.00% 1 / 1 100.00% 1 / 1 1
 callServiceForResponse 100.00% 1 / 1 100.00% 1 / 1 1
 fireEvent 100.00% 9 / 9 100.00% 1 / 1 3
 wasCausedByStewart 100.00% 1 / 1 100.00% 1 / 1 1
 publish 100.00% 3 / 3 100.00% 1 / 1 1
 watchTopic 0.00% 0 / 3 0.00% 0 / 1 2
 watchConnection 0.00% 0 / 1 0.00% 0 / 1 2
 isConnected 0.00% 0 / 1 0.00% 0 / 1 2
 callStubbedService 92.30% 12 / 13 0.00% 0 / 1 3.00
 issueContext 100.00% 1 / 1 100.00% 1 / 1 1
58final class RecordingHaContext implements HaContext
59{
60    public const string STEWART_USER_ID = 'stewart-user';
61
62    private const string CONTEXT_ID_PREFIX = 'recorded-';
63
64    public private(set) RecordedServiceCallCollection $calls;
65
66    public private(set) TopicEventCollection $published;
67
68    public private(set) RecordedEventFireCollection $firedEvents;
69
70    public private(set) RecordedHistoryQueryCollection $historyQueries;
71
72    private EntityStateCollection $states;
73
74    private readonly SeededHistory $history;
75
76    /** @var array<string, HistoryException> */
77    private array $historyFailures = [];
78
79    /** @var array<string, array<string, mixed>|ServiceCallException> */
80    private array $serviceOutcomes = [];
81
82    /** @var array<string, EventFireException> */
83    private array $eventFireFailures = [];
84
85    private int $issuedContextCount = 0;
86
87    /** @var PushSource<StateChange> */
88    private readonly PushSource $stateChanges;
89
90    /** @var PushSource<HaEvent> */
91    private readonly PushSource $events;
92
93    /** @var PushSource<TopicEvent> */
94    private readonly PushSource $topics;
95
96    /** @var PushSource<ConnectionEvent> */
97    private readonly PushSource $connectionEvents;
98
99    /** @var PushSource<PushedTrigger> */
100    private readonly PushSource $triggers;
101
102    private WatchedTriggerCollection $watchedTriggers;
103
104    private readonly StewartIdentity $identity;
105
106    public private(set) bool $connected = true;
107
108    public readonly VirtualClock $clock;
109
110    public readonly InMemoryRegistry $registry;
111
112    public function __construct(public readonly ManualTimers $timers = new ManualTimers())
113    {
114        $this->clock = $timers->clock;
115        $this->registry = new InMemoryRegistry();
116        $this->calls = RecordedServiceCallCollection::empty();
117        $this->published = TopicEventCollection::empty();
118        $this->firedEvents = RecordedEventFireCollection::empty();
119        $this->states = EntityStateCollection::empty();
120        $this->historyQueries = RecordedHistoryQueryCollection::empty();
121        $this->history = new SeededHistory();
122        $this->stateChanges = new PushSource($timers->handlerFibers);
123        $this->events = new PushSource($timers->handlerFibers);
124        $this->topics = new PushSource($timers->handlerFibers);
125        $this->connectionEvents = new PushSource($timers->handlerFibers);
126        $this->triggers = new PushSource($timers->handlerFibers);
127        $this->watchedTriggers = WatchedTriggerCollection::empty();
128        $this->identity = new StewartIdentity(self::STEWART_USER_ID);
129    }
130
131    /** @param array<string, mixed> $attributes */
132    public function seedState(string $entityId, string $state, array $attributes = [], ?Instant $changedAt = null): self
133    {
134        $changedAt ??= $this->clock->getNow();
135        $seeded = new EntityState(new EntityId($entityId), $state, $attributes, $changedAt, $changedAt);
136        $this->states = $this->states->withState($seeded);
137        $this->history->recordState($seeded, $changedAt);
138
139        return $this;
140    }
141
142    /** @param array<string, mixed> $attributes */
143    public function seedHistoricalState(string $entityId, string $state, Instant $changedAt, array $attributes = []): self
144    {
145        $this->history->recordState(new EntityState(new EntityId($entityId), $state, $attributes, $changedAt, $changedAt), $changedAt);
146
147        return $this;
148    }
149
150    public function stubHistoryFailure(string $entityId, HistoryException $failure): self
151    {
152        $this->historyFailures[new EntityId($entityId)->value] = $failure;
153
154        return $this;
155    }
156
157    /** @param array<string, mixed> $response */
158    public function stubServiceAnswer(string $domain, string $service, array $response): self
159    {
160        $this->serviceOutcomes[$domain . '.' . $service] = $response;
161
162        return $this;
163    }
164
165    public function stubServiceFailure(string $domain, string $service, ServiceCallException $failure): self
166    {
167        $this->serviceOutcomes[$domain . '.' . $service] = $failure;
168
169        return $this;
170    }
171
172    public function stubEventFireFailure(string $eventType, EventFireException $failure): self
173    {
174        $this->eventFireFailures[$eventType] = $failure;
175
176        return $this;
177    }
178
179    public function cancelSubscriptions(): void
180    {
181        foreach ([$this->stateChanges, $this->events, $this->topics, $this->connectionEvents, $this->triggers] as $source) {
182            $source->cancelAttachments();
183        }
184    }
185
186    public function pushStateChange(StateChange $change): void
187    {
188        if ($change->to === null) {
189            $this->states = $this->states->withoutState($change->entityId);
190        } else {
191            $this->states = $this->states->withState($change->to);
192            $this->history->recordState($change->to, $this->clock->getNow());
193        }
194
195        $this->stateChanges->push($change);
196    }
197
198    /** @param array<string, mixed> $attributes */
199    public function pushState(string $entityId, string $state, array $attributes = [], ?EventContext $context = null): void
200    {
201        $id = new EntityId($entityId);
202        $previous = $this->states->find($id);
203        $now = $this->clock->getNow();
204        $changedAt = $previous?->state === $state ? $previous->lastChangedAt ?? $now : $now;
205
206        $this->pushStateChange(new StateChange(
207            $id,
208            $previous,
209            new EntityState($id, $state, $attributes, $changedAt, $now, $context),
210            context: $context,
211        ));
212    }
213
214    public function pushEvent(HaEvent $event): void
215    {
216        $this->events->push($event);
217    }
218
219    public function pushTrigger(HaTrigger|HaTriggerCollection $trigger, TriggerEvent $event): void
220    {
221        $this->triggers->push(new PushedTrigger(TriggerSpec::fromSpec($trigger), $event));
222    }
223
224    public function listWatchedTriggers(): WatchedTriggerCollection
225    {
226        return $this->watchedTriggers;
227    }
228
229    public function pushConnection(ConnectionEvent $event): void
230    {
231        $this->connected = !$event instanceof ConnectionLost;
232        $this->connectionEvents->push($event);
233    }
234
235    /** @throws LogicException */
236    public function getLastCall(): RecordedServiceCall
237    {
238        return $this->calls->getLast() ?? throw new LogicException('No service has been called.');
239    }
240
241    public function getState(EntityId|string $entityId): ?EntityState
242    {
243        return $this->states->find(EntityId::fromStringOrId($entityId));
244    }
245
246    public function getEntity(EntityId|string $entityId): Entity
247    {
248        return new Entity($this, EntityId::fromStringOrId($entityId));
249    }
250
251    public function requireState(EntityId|string $entityId): EntityState
252    {
253        $id = EntityId::fromStringOrId($entityId);
254
255        return $this->states->find($id) ?? throw StateException::entityNotFound($id);
256    }
257
258    public function getHistory(EntityId|string $entityId, HistoryQuery $query): EntityStateHistory
259    {
260        $id = EntityId::fromStringOrId($entityId);
261        $window = $query->resolveWindowAt($this->clock->getNow());
262        $this->historyQueries = $this->historyQueries->withRecordedQuery(new RecordedHistoryQuery($id, $window, $query->detail));
263
264        if (!$this->connected) {
265            throw HistoryException::unreachable($id, 'Home Assistant is disconnected');
266        }
267
268        if (isset($this->historyFailures[$id->value])) {
269            throw $this->historyFailures[$id->value];
270        }
271
272        return $this->history->sliceForWindow($id, $window, $query->detail);
273    }
274
275    public function listStates(string|EntityId|Selector|SelectorCollection|EntityFilter|null $selector = null): EntityStateCollection
276    {
277        return match (true) {
278            $selector === null => $this->states,
279            $selector instanceof EntityFilter => $this->states->filterByEntityFilter($selector, $this->registry),
280            default => $this->states->filterBySelector(Selector::fromSpec($selector)),
281        };
282    }
283
284    public function watchStateChanges(string|EntityId|Selector|SelectorCollection|EntityFilter $selector): StateChangeStream
285    {
286        $registry = $this->registry;
287
288        if ($selector instanceof EntityFilter) {
289            $matches = static fn(StateChange $change): bool => $selector->matchesEntity($change->entityId, $registry);
290        } else {
291            $matcher = Selector::fromSpec($selector);
292            $matches = static fn(StateChange $change): bool => $matcher->matches($change->entityId->value);
293        }
294
295        return new StateChanges($this->stateChanges, $this->timers, new RecordedStateReader($this, $selector))->filter($matches);
296    }
297
298    public function waitForState(EntityId|string $entityId, string $state, ?Duration $timeout = null): ?EntityState
299    {
300        return $this->getEntity($entityId)->waitForState($state, $timeout);
301    }
302
303    public function getRegistry(): Registry
304    {
305        return $this->registry;
306    }
307
308    public function watchEvents(string|Selector|SelectorCollection $eventType): EventStream
309    {
310        $matcher = EventTypeSelector::fromSpec($eventType)->selector;
311
312        return new OperatorStream($this->events, $this->timers)
313            ->filter(static fn(HaEvent $event): bool => $matcher->matches($event->type));
314    }
315
316    public function watchTrigger(HaTrigger|HaTriggerCollection|array $trigger, array $variables = []): EventStream
317    {
318        $spec = TriggerSpec::fromSpec($trigger, $variables);
319        $this->watchedTriggers = $this->watchedTriggers->withWatchedTrigger(new WatchedTrigger($spec->triggers, $spec->variables));
320
321        return new OperatorStream($this->triggers, $this->timers)
322            ->filter(static fn(PushedTrigger $pushed): bool => $pushed->spec->hasSameTriggersAs($spec))
323            ->map(static fn(PushedTrigger $pushed): TriggerEvent => $pushed->event);
324    }
325
326    public function callService(string $domain, string $service, array $data = [], ?ServiceTargetSource $target = null): EventContext
327    {
328        return $this->callStubbedService($domain, $service, $data, $target, false)->context ?? EventContext::unknown();
329    }
330
331    public function callServiceForResponse(string $domain, string $service, array $data = [], ?ServiceTargetSource $target = null): ServiceResponse
332    {
333        return $this->callStubbedService($domain, $service, $data, $target, true);
334    }
335
336    public function fireEvent(string $eventType, array $data = []): EventContext
337    {
338        $payload = new EventPayload($eventType, $data);
339
340        if (!$this->connected) {
341            throw EventFireException::unreachable($eventType, 'Home Assistant is disconnected');
342        }
343
344        $fire = new RecordedEventFire($payload->eventType, $payload->data, $this->issueContext());
345        $this->firedEvents = $this->firedEvents->withRecordedFire($fire);
346
347        if (isset($this->eventFireFailures[$eventType])) {
348            throw $this->eventFireFailures[$eventType];
349        }
350
351        $this->events->push(new HaEvent($fire->eventType, $fire->data, EventOrigin::Remote, $this->clock->getNow(), $fire->context));
352
353        return $fire->context;
354    }
355
356    public function wasCausedByStewart(StateChange|EntityState|HaEvent $subject): bool
357    {
358        return $this->identity->wasCausedByStewart($subject);
359    }
360
361    public function publish(string $topic, bool|int|float|string|array|null $payload = null): void
362    {
363        $event = new TopicEvent($topic, new TopicPayload($payload)->value, new AppId('test'), $this->clock->getNow());
364
365        $this->published = $this->published->withTopicEvent($event);
366        $this->topics->push($event);
367    }
368
369    public function watchTopic(string|Selector|SelectorCollection $topic): EventStream
370    {
371        $matcher = Selector::fromSpec($topic);
372
373        return new OperatorStream($this->topics, $this->timers)
374            ->filter(static fn(TopicEvent $event): bool => $matcher->matches($event->topic));
375    }
376
377    public function watchConnection(): EventStream
378    {
379        return new OperatorStream($this->connectionEvents, $this->timers);
380    }
381
382    public function isConnected(): bool
383    {
384        return $this->connected;
385    }
386
387    /**
388     * @param array<string, mixed> $data
389     * @throws ServiceCallException
390     */
391    private function callStubbedService(string $domain, string $service, array $data, ?ServiceTargetSource $target, bool $returnsResponse): ServiceResponse
392    {
393        if (!$this->connected) {
394            throw ServiceCallException::unreachable($domain, $service, 'Home Assistant is disconnected');
395        }
396
397        $call = new RecordedServiceCall(
398            $domain,
399            $service,
400            ServiceFields::fromFieldsDroppingNulls($data)->fields,
401            $target?->toServiceTarget(),
402            $returnsResponse,
403            $this->issueContext(),
404        );
405
406        $this->calls = $this->calls->withRecordedCall($call);
407        $outcome = $this->serviceOutcomes[$call->getServiceName()] ?? [];
408
409        return $outcome instanceof ServiceCallException ? throw $outcome : new ServiceResponse($domain, $service, $outcome, $call->context);
410    }
411
412    private function issueContext(): EventContext
413    {
414        return new EventContext(self::CONTEXT_ID_PREFIX . ++$this->issuedContextCount, userId: self::STEWART_USER_ID);
415    }
416}