Lines 92.68% 38 / 41
Functions and Methods 93.33% 28 / 30
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
RecordingEntityExposure 92.68% 38 / 41 93.33% 28 / 30 36.51 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 seedSnapshot 100.00% 1 / 1 100.00% 1 / 1 1
 cancelSubscriptions 0.00% 0 / 2 0.00% 0 / 1 6
 refuseExposures 100.00% 1 / 1 100.00% 1 / 1 1
 exposeSensor 100.00% 1 / 1 100.00% 1 / 1 1
 exposeBinarySensor 100.00% 1 / 1 100.00% 1 / 1 1
 exposeSwitch 100.00% 1 / 1 100.00% 1 / 1 1
 exposeButton 100.00% 1 / 1 100.00% 1 / 1 1
 exposeNumber 100.00% 1 / 1 100.00% 1 / 1 1
 exposeSelect 100.00% 1 / 1 100.00% 1 / 1 1
 exposeText 100.00% 1 / 1 100.00% 1 / 1 1
 exposeTime 100.00% 1 / 1 100.00% 1 / 1 1
 exposeDate 100.00% 1 / 1 100.00% 1 / 1 1
 exposeDateTime 100.00% 1 / 1 100.00% 1 / 1 1
 exposeAction 100.00% 1 / 1 100.00% 1 / 1 1
 requireSensor 100.00% 1 / 1 100.00% 1 / 1 1
 requireBinarySensor 0.00% 0 / 1 0.00% 0 / 1 2
 requireSwitch 100.00% 1 / 1 100.00% 1 / 1 1
 requireButton 100.00% 1 / 1 100.00% 1 / 1 1
 requireNumber 100.00% 1 / 1 100.00% 1 / 1 1
 requireSelect 100.00% 1 / 1 100.00% 1 / 1 1
 requireText 100.00% 1 / 1 100.00% 1 / 1 1
 requireTime 100.00% 1 / 1 100.00% 1 / 1 1
 requireDate 100.00% 1 / 1 100.00% 1 / 1 1
 requireDateTime 100.00% 1 / 1 100.00% 1 / 1 1
 requireAction 100.00% 1 / 1 100.00% 1 / 1 1
 claimKey 100.00% 7 / 7 100.00% 1 / 1 3
 trackHandle 100.00% 4 / 4 100.00% 1 / 1 3
 requireHandle 100.00% 2 / 2 100.00% 1 / 1 2
 findHandle 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Testing\Exposure;
6
7use LogicException;
8use Stewart\Contracts\App\AppId;
9use Stewart\Contracts\Exception\ExposureException;
10use Stewart\Contracts\Exposure\Action\ActionConfig;
11use Stewart\Contracts\Exposure\Action\ExposedAction;
12use Stewart\Contracts\Exposure\BinarySensorConfig;
13use Stewart\Contracts\Exposure\ButtonConfig;
14use Stewart\Contracts\Exposure\DateConfig;
15use Stewart\Contracts\Exposure\DateTimeConfig;
16use Stewart\Contracts\Exposure\DeviceInfo;
17use Stewart\Contracts\Exposure\EntityExposure;
18use Stewart\Contracts\Exposure\ExposedBinarySensor;
19use Stewart\Contracts\Exposure\ExposedButton;
20use Stewart\Contracts\Exposure\ExposedDate;
21use Stewart\Contracts\Exposure\ExposedDateTime;
22use Stewart\Contracts\Exposure\ExposedEntityKey;
23use Stewart\Contracts\Exposure\ExposedEntitySnapshot;
24use Stewart\Contracts\Exposure\ExposedNumber;
25use Stewart\Contracts\Exposure\ExposedSelect;
26use Stewart\Contracts\Exposure\ExposedSensor;
27use Stewart\Contracts\Exposure\ExposedSwitch;
28use Stewart\Contracts\Exposure\ExposedText;
29use Stewart\Contracts\Exposure\ExposedTime;
30use Stewart\Contracts\Exposure\ExposureConfig;
31use Stewart\Contracts\Exposure\NumberConfig;
32use Stewart\Contracts\Exposure\SelectConfig;
33use Stewart\Contracts\Exposure\SensorConfig;
34use Stewart\Contracts\Exposure\SwitchConfig;
35use Stewart\Contracts\Exposure\TextConfig;
36use Stewart\Contracts\Exposure\TimeConfig;
37use Stewart\Testing\Exposure\Collection\RecordedExposureCollection;
38use Stewart\Testing\Time\ManualTimers;
39
40final class RecordingEntityExposure implements EntityExposure
41{
42    public private(set) RecordedExposureCollection $exposures;
43
44    /** @var array<string, RecordingExposure<covariant ExposureConfig>> */
45    private array $handlesByKey = [];
46
47    /** @var array<string, ExposedEntitySnapshot> */
48    private array $seededByKey = [];
49
50    private ?ExposureException $refusal = null;
51
52    public function __construct(
53        public readonly AppId $appId = new AppId('test-app'),
54        private readonly ManualTimers $timers = new ManualTimers(),
55    ) {
56        $this->exposures = RecordedExposureCollection::empty();
57    }
58
59    // Stands in for what Home Assistant restored or assigned, applied when the app exposes the key.
60    public function seedSnapshot(ExposedEntityKey|string $key, ExposedEntitySnapshot $snapshot): void
61    {
62        $this->seededByKey[ExposedEntityKey::fromKeyOrString($key)->value] = $snapshot;
63    }
64
65    public function cancelSubscriptions(): void
66    {
67        foreach ($this->handlesByKey as $handle) {
68            $handle->cancelCommandSubscriptions();
69        }
70    }
71
72    public function refuseExposures(ExposureException $refusal): void
73    {
74        $this->refusal = $refusal;
75    }
76
77    public function exposeSensor(ExposedEntityKey|string $key, SensorConfig $config = new SensorConfig(), ?DeviceInfo $device = null): ExposedSensor
78    {
79        return $this->trackHandle(new RecordingExposedSensor($this->claimKey($key, $config, $device), $config, $this->timers));
80    }
81
82    public function exposeBinarySensor(
83        ExposedEntityKey|string $key,
84        BinarySensorConfig $config = new BinarySensorConfig(),
85        ?DeviceInfo $device = null,
86    ): ExposedBinarySensor {
87        return $this->trackHandle(new RecordingExposedBinarySensor($this->claimKey($key, $config, $device), $config, $this->timers));
88    }
89
90    public function exposeSwitch(ExposedEntityKey|string $key, SwitchConfig $config = new SwitchConfig(), ?DeviceInfo $device = null): ExposedSwitch
91    {
92        return $this->trackHandle(new RecordingExposedSwitch($this->claimKey($key, $config, $device), $config, $this->timers));
93    }
94
95    public function exposeButton(ExposedEntityKey|string $key, ButtonConfig $config = new ButtonConfig(), ?DeviceInfo $device = null): ExposedButton
96    {
97        return $this->trackHandle(new RecordingExposedButton($this->claimKey($key, $config, $device), $config, $this->timers));
98    }
99
100    public function exposeNumber(ExposedEntityKey|string $key, NumberConfig $config, ?DeviceInfo $device = null): ExposedNumber
101    {
102        return $this->trackHandle(new RecordingExposedNumber($this->claimKey($key, $config, $device), $config, $this->timers));
103    }
104
105    public function exposeSelect(ExposedEntityKey|string $key, SelectConfig $config, ?DeviceInfo $device = null): ExposedSelect
106    {
107        return $this->trackHandle(new RecordingExposedSelect($this->claimKey($key, $config, $device), $config, $this->timers));
108    }
109
110    public function exposeText(ExposedEntityKey|string $key, TextConfig $config = new TextConfig(), ?DeviceInfo $device = null): ExposedText
111    {
112        return $this->trackHandle(new RecordingExposedText($this->claimKey($key, $config, $device), $config, $this->timers));
113    }
114
115    public function exposeTime(ExposedEntityKey|string $key, TimeConfig $config = new TimeConfig(), ?DeviceInfo $device = null): ExposedTime
116    {
117        return $this->trackHandle(new RecordingExposedTime($this->claimKey($key, $config, $device), $config, $this->timers));
118    }
119
120    public function exposeDate(ExposedEntityKey|string $key, DateConfig $config = new DateConfig(), ?DeviceInfo $device = null): ExposedDate
121    {
122        return $this->trackHandle(new RecordingExposedDate($this->claimKey($key, $config, $device), $config, $this->timers));
123    }
124
125    public function exposeDateTime(ExposedEntityKey|string $key, DateTimeConfig $config = new DateTimeConfig(), ?DeviceInfo $device = null): ExposedDateTime
126    {
127        return $this->trackHandle(new RecordingExposedDateTime($this->claimKey($key, $config, $device), $config, $this->timers));
128    }
129
130    public function exposeAction(ExposedEntityKey|string $key, ActionConfig $config = new ActionConfig()): ExposedAction
131    {
132        return $this->trackHandle(new RecordingExposedAction($this->claimKey($key, $config, null), $config, $this->timers));
133    }
134
135    public function requireSensor(ExposedEntityKey|string $key): RecordingExposedSensor
136    {
137        return $this->requireHandle($key, RecordingExposedSensor::class, 'sensor');
138    }
139
140    public function requireBinarySensor(ExposedEntityKey|string $key): RecordingExposedBinarySensor
141    {
142        return $this->requireHandle($key, RecordingExposedBinarySensor::class, 'binary sensor');
143    }
144
145    public function requireSwitch(ExposedEntityKey|string $key): RecordingExposedSwitch
146    {
147        return $this->requireHandle($key, RecordingExposedSwitch::class, 'switch');
148    }
149
150    public function requireButton(ExposedEntityKey|string $key): RecordingExposedButton
151    {
152        return $this->requireHandle($key, RecordingExposedButton::class, 'button');
153    }
154
155    public function requireNumber(ExposedEntityKey|string $key): RecordingExposedNumber
156    {
157        return $this->requireHandle($key, RecordingExposedNumber::class, 'number');
158    }
159
160    public function requireSelect(ExposedEntityKey|string $key): RecordingExposedSelect
161    {
162        return $this->requireHandle($key, RecordingExposedSelect::class, 'select');
163    }
164
165    public function requireText(ExposedEntityKey|string $key): RecordingExposedText
166    {
167        return $this->requireHandle($key, RecordingExposedText::class, 'text');
168    }
169
170    public function requireTime(ExposedEntityKey|string $key): RecordingExposedTime
171    {
172        return $this->requireHandle($key, RecordingExposedTime::class, 'time');
173    }
174
175    public function requireDate(ExposedEntityKey|string $key): RecordingExposedDate
176    {
177        return $this->requireHandle($key, RecordingExposedDate::class, 'date');
178    }
179
180    public function requireDateTime(ExposedEntityKey|string $key): RecordingExposedDateTime
181    {
182        return $this->requireHandle($key, RecordingExposedDateTime::class, 'datetime');
183    }
184
185    public function requireAction(ExposedEntityKey|string $key): RecordingExposedAction
186    {
187        return $this->requireHandle($key, RecordingExposedAction::class, 'action');
188    }
189
190    /** @throws ExposureException */
191    private function claimKey(ExposedEntityKey|string $key, ExposureConfig $config, ?DeviceInfo $device): ExposedEntityKey
192    {
193        $key = ExposedEntityKey::fromKeyOrString($key);
194
195        if ($this->refusal !== null) {
196            throw $this->refusal;
197        }
198
199        if ($this->findHandle($key)?->removed === false) {
200            throw ExposureException::keyTaken($this->appId, $key);
201        }
202
203        $this->exposures = $this->exposures->withRecordedExposure(new RecordedExposure($key, $config, $device));
204
205        return $key;
206    }
207
208    /**
209     * @template THandle of RecordingExposure<covariant ExposureConfig>
210     *
211     * @param THandle $handle
212     * @return THandle
213     */
214    private function trackHandle(RecordingExposure $handle): RecordingExposure
215    {
216        $seeded = $this->seededByKey[$handle->getKey()->value] ?? null;
217
218        if ($seeded !== null && $handle instanceof RecordingExposedEntity) {
219            $handle->applySnapshot($seeded);
220        }
221
222        return $this->handlesByKey[$handle->getKey()->value] = $handle;
223    }
224
225    /**
226     * @template THandle of RecordingExposure<covariant ExposureConfig>
227     *
228     * @param class-string<THandle> $class
229     * @return THandle
230     */
231    private function requireHandle(ExposedEntityKey|string $key, string $class, string $kind): RecordingExposure
232    {
233        $handle = $this->findHandle($key);
234
235        return $handle instanceof $class ? $handle : throw new LogicException(\sprintf('No %s was exposed as "%s".', $kind, $key));
236    }
237
238    /** @return RecordingExposure<covariant ExposureConfig>|null */
239    private function findHandle(ExposedEntityKey|string $key): ?RecordingExposure
240    {
241        return $this->handlesByKey[(string) $key] ?? null;
242    }
243}