Lines
100.00%
59 / 59
Functions and Methods
100.00%
9 / 9
Classes and Traits
100.00%
1 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| AppHarness | 100.00% | 59 / 59 | 100.00% | 9 / 9 | 12 | 100.00% | 1 / 1 | |||
| __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 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace Stewart\Testing\App; | |
| 6 | ||
| 7 | use Amp\Future; | |
| 8 | use DateTimeImmutable; | |
| 9 | use Psr\Log\LoggerInterface; | |
| 10 | use ReflectionClass; | |
| 11 | use Stewart\Contracts\App; | |
| 12 | use Stewart\Contracts\App\AppId; | |
| 13 | use Stewart\Contracts\App\Collection\AppIdCollection; | |
| 14 | use Stewart\Contracts\Automation; | |
| 15 | use Stewart\Contracts\Exposure\EntityExposure; | |
| 16 | use Stewart\Contracts\HaContext; | |
| 17 | use Stewart\Contracts\Mqtt\Mqtt; | |
| 18 | use Stewart\Contracts\Registry\RegistryEditor; | |
| 19 | use Stewart\Contracts\Schedule\Scheduler; | |
| 20 | use Stewart\Contracts\Store\ReadableStore; | |
| 21 | use Stewart\Contracts\Store\Store; | |
| 22 | use Stewart\Contracts\Sun\SunCalendar; | |
| 23 | use Stewart\Contracts\Time\Clock; | |
| 24 | use Stewart\Contracts\Time\Duration; | |
| 25 | use Stewart\Contracts\Time\Timers; | |
| 26 | use Stewart\Store\KeyspacedStores; | |
| 27 | use Stewart\Store\ReadOnlyStore; | |
| 28 | use Stewart\Store\StorePrefix; | |
| 29 | use Stewart\Store\Stores; | |
| 30 | use Stewart\Store\StoreValueCodec; | |
| 31 | use Stewart\Support\Text\ClosestNameFinder; | |
| 32 | use Stewart\Testing\Exception\HarnessError; | |
| 33 | use Stewart\Testing\Exception\HarnessException; | |
| 34 | use Stewart\Testing\Exposure\RecordingEntityExposure; | |
| 35 | use Stewart\Testing\HaContext\RecordingHaContext; | |
| 36 | use Stewart\Testing\Logging\RecordingLogger; | |
| 37 | use Stewart\Testing\Mqtt\RecordingMqtt; | |
| 38 | use Stewart\Testing\Registry\RecordingRegistryEditor; | |
| 39 | use Stewart\Testing\Schedule\ManualScheduler; | |
| 40 | use Stewart\Testing\Store\InMemoryStoreBackend; | |
| 41 | use Stewart\Testing\Sun\FixedSunCalendar; | |
| 42 | use Stewart\Testing\Time\EventLoopTicks; | |
| 43 | use Stewart\Testing\Time\ManualTimers; | |
| 44 | use Stewart\Testing\Time\VirtualClock; | |
| 45 | ||
| 46 | use function Amp\async; | |
| 47 | ||
| 48 | final 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 | } |