Lines 100.00% 59 / 59
Methods 100.00% 9 / 9
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __construct 100.00% 10 / 10 100.00% 1 / 1 1
 provideService 100.00% 2 / 2 100.00% 1 / 1 1
 startApp 100.00% 11 / 11 100.00% 1 / 1 1
 advanceTime 100.00% 3 / 3 100.00% 1 / 1 1
 stopApp 100.00% 8 / 8 100.00% 1 / 1 1
 createFakesFor 100.00% 14 / 14 100.00% 1 / 1 1
 finishInitializing 100.00% 6 / 6 100.00% 1 / 1 3
 readAppId 100.00% 3 / 3 100.00% 1 / 1 1
 requireNotStarted 100.00% 2 / 2 100.00% 1 / 1 2
48final class AppHarness
49{
50    private const string STORE_PREFIX = 'stewart';
51
52    public readonly ManualTimers $timers;
53
54    public readonly VirtualClock $clock;
55
56    public readonly RecordingHaContext $ha;
57
58    public readonly RecordingEntityExposure $entities;
59
60    public readonly ManualScheduler $scheduler;
61
62    public readonly FixedSunCalendar $sunCalendar;
63
64    public readonly Stores $stores;
65
66    public readonly RecordingMqtt $mqtt;
67
68    public readonly RecordingLogger $logger;
69
70    public readonly RecordingRegistryEditor $registryEditor;
71
72    /** @var array<string, object> */
73    private array $providedServices = [];
74
75    private ?App $app = null;
76
77    /** @var Future<mixed>|null */
78    private ?Future $initializing = null;
79
80    public function __construct(?DateTimeImmutable $start = null)
81    {
82        $this->timers = new ManualTimers(new VirtualClock($start));
83        $this->clock = $this->timers->clock;
84        $this->ha = new RecordingHaContext($this->timers);
85        $this->entities = new RecordingEntityExposure(timers: $this->timers);
86        $this->sunCalendar = new FixedSunCalendar($this->clock);
87        $this->scheduler = new ManualScheduler($this->timers, $this->sunCalendar, $this->ha);
88        $this->stores = new KeyspacedStores(new InMemoryStoreBackend($this->clock), new StorePrefix(self::STORE_PREFIX), AppIdCollection::empty(), new StoreValueCodec(), new ClosestNameFinder());
89        $this->mqtt = new RecordingMqtt($this->timers);
90        $this->logger = new RecordingLogger();
91        $this->registryEditor = new RecordingRegistryEditor($this->ha->registry);
92    }
93
94    /**
95     * @template T of object
96     * @param class-string<T> $type
97     * @param T $service
98     * @throws HarnessException
99     */
100    public function provideService(string $type, object $service): void
101    {
102        $this->requireNotStarted();
103        $this->providedServices[$type] = $service;
104    }
105
106    /**
107     * @template T of App
108     * @param class-string<T> $class
109     * @param array<string, mixed> $options
110     * @return T
111     * @throws HarnessException
112     */
113    public function startApp(string $class, array $options = []): App
114    {
115        $this->requireNotStarted();
116        $reflection = new ReflectionClass($class);
117        $appId = $this->readAppId($reflection);
118        $resolver = new AppArgumentResolver([...$this->createFakesFor($appId), ...$this->providedServices], $this->stores, $this->ha);
119        $app = $reflection->newInstanceArgs($resolver->resolveArguments($reflection, $options));
120        $this->app = $app;
121
122        // Like the worker, nothing reaches the app until initialize() returns.
123        $this->timers->handlerFibers->holdDeliveries();
124        $this->initializing = async($app->initialize(...));
125        EventLoopTicks::settle();
126        $this->finishInitializing();
127
128        return $app;
129    }
130
131    /** @throws HarnessException */
132    public function advanceTime(Duration $by): void
133    {
134        $this->timers->delay($by);
135        $this->finishInitializing();
136        $this->timers->handlerFibers->rethrowFailures();
137    }
138
139    /** @throws HarnessException */
140    public function stopApp(): void
141    {
142        $app = $this->app ?? throw HarnessException::createForReason(HarnessError::NotStarted);
143
144        $this->scheduler->cancelAll();
145        $this->ha->cancelSubscriptions();
146        $this->entities->cancelSubscriptions();
147        $this->mqtt->cancelSubscriptions();
148        EventLoopTicks::settle();
149        $this->timers->handlerFibers->rethrowFailuresExceptAbandonedWaits();
150
151        async($app->dispose(...))->await();
152    }
153
154    /** @return array<string, object> */
155    private function createFakesFor(AppId $appId): array
156    {
157        $store = $this->stores->openAppStore($appId);
158
159        return [
160            HaContext::class => $this->ha,
161            EntityExposure::class => $this->entities,
162            Scheduler::class => $this->scheduler,
163            Timers::class => $this->timers,
164            Clock::class => $this->clock,
165            Store::class => $store,
166            ReadableStore::class => new ReadOnlyStore($store),
167            Mqtt::class => $this->mqtt,
168            LoggerInterface::class => $this->logger,
169            RegistryEditor::class => $this->registryEditor,
170            SunCalendar::class => $this->sunCalendar,
171        ];
172    }
173
174    private function finishInitializing(): void
175    {
176        if ($this->initializing === null || !$this->initializing->isComplete()) {
177            return;
178        }
179
180        $initializing = $this->initializing;
181        $this->initializing = null;
182        $this->timers->handlerFibers->releaseDeliveries();
183        $initializing->await();
184    }
185
186    /**
187     * @template T of App
188     * @param ReflectionClass<T> $reflection
189     * @throws HarnessException
190     */
191    private function readAppId(ReflectionClass $reflection): AppId
192    {
193        $automation = ($reflection->getAttributes(Automation::class)[0] ?? null)?->newInstance()
194            ?? throw HarnessException::createForReason(HarnessError::AutomationMissing, class: $reflection->getName());
195
196        return new AppId($automation->id);
197    }
198
199    /** @throws HarnessException */
200    private function requireNotStarted(): void
201    {
202        if ($this->app !== null) {
203            throw HarnessException::createForReason(HarnessError::AlreadyStarted, class: $this->app::class);
204        }
205    }
206}