*/ private array $cases = []; /** @var list */ private array $cleanupErrors = []; /** @var list */ private array $profilesToDelete = []; public function __construct(string $repository, string $suite) { $this->repository = $repository; $this->suite = $suite; $this->runId = date('Ymd-His') . '-' . strtoupper(bin2hex(random_bytes(4))); $this->rootId = IPS_CreateCategory(); IPS_SetParent($this->rootId, 0); IPS_SetIdent($this->rootId, 'ENELIX_TEST_' . str_replace('-', '_', $this->runId)); IPS_SetName($this->rootId, 'Enelix Test ' . $suite . ' ' . $this->runId); IPS_SetInfo($this->rootId, 'Temporär; wird durch den Enelix-Test-Runner entfernt.'); } public function rootId(): int { return $this->rootId; } public function runId(): string { return $this->runId; } public function runCase(string $name, callable $test): void { $started = microtime(true); try { $test($this); $this->cases[] = $this->caseResult($name, 'passed', $started, ''); } catch (Throwable $error) { $this->cases[] = $this->caseResult($name, 'failed', $started, $error->getMessage()); } } public function fail(string $message): void { throw new RuntimeException($message); } public function assertTrue(bool $condition, string $message = 'Bedingung ist nicht erfüllt.'): void { if (!$condition) { $this->fail($message); } } public function assertSame($expected, $actual, string $message = ''): void { if ($expected === $actual) { return; } $prefix = $message === '' ? '' : $message . ' '; $this->fail( $prefix . 'Erwartet: ' . $this->export($expected) . '; erhalten: ' . $this->export($actual) ); } public function assertEquals(float $expected, float $actual, float $delta, string $message = ''): void { if (abs($expected - $actual) <= $delta) { return; } $prefix = $message === '' ? '' : $message . ' '; $this->fail($prefix . "Erwartet: {$expected} ± {$delta}; erhalten: {$actual}"); } public function assertInstanceStatus(int $instanceId, int $expected = 102): void { $this->assertTrue(IPS_InstanceExists($instanceId), 'Instanz existiert nicht.'); $status = (int) IPS_GetInstance($instanceId)['InstanceStatus']; $this->assertSame($expected, $status, 'Unerwarteter Instanzstatus.'); } public function objectByIdent(string $ident, int $parentId): int { $objectId = @IPS_GetObjectIDByIdent($ident, $parentId); if ($objectId === false) { $this->fail("Objekt {$ident} unter {$parentId} wurde nicht angelegt."); } return (int) $objectId; } public function createCategory(string $name, ?int $parentId = null): int { $id = IPS_CreateCategory(); IPS_SetParent($id, $parentId ?? $this->rootId); IPS_SetName($id, $name); return $id; } public function createVariable( int $type, string $name, $value, ?int $parentId = null, string $profile = '' ): int { $id = IPS_CreateVariable($type); IPS_SetParent($id, $parentId ?? $this->rootId); IPS_SetName($id, $name); if ($profile !== '' && IPS_VariableProfileExists($profile)) { IPS_SetVariableCustomProfile($id, $profile); } SetValue($id, $value); return $id; } public function createActionVariable(string $name, bool $value = false): int { $variableId = $this->createVariable(0, $name, $value); $scriptId = IPS_CreateScript(0); IPS_SetParent($scriptId, $this->rootId); IPS_SetName($scriptId, $name . ' Aktion'); IPS_SetHidden($scriptId, true); IPS_SetScriptContent($scriptId, <<<'PHP' $properties */ public function createInstance(string $moduleId, string $name, array $properties = []): int { if (!in_array($moduleId, IPS_GetModuleList(), true)) { $this->fail("Modul {$moduleId} ist in IP-Symcon nicht verfügbar."); } $id = IPS_CreateInstance($moduleId); IPS_SetParent($id, $this->rootId); IPS_SetName($id, $name); foreach ($properties as $property => $value) { IPS_SetProperty($id, $property, $value); } IPS_ApplyChanges($id); return $id; } /** @param array $properties */ public function configureInstance(int $instanceId, array $properties): void { foreach ($properties as $property => $value) { IPS_SetProperty($instanceId, $property, $value); } IPS_ApplyChanges($instanceId); } public function trackVariableProfile(string $name, bool $existedBefore): void { if (!$existedBefore && IPS_VariableProfileExists($name)) { $this->profilesToDelete[] = $name; } } public function waitUntil(callable $condition, int $timeoutMs = 2000, int $intervalMs = 50): void { $deadline = microtime(true) + ($timeoutMs / 1000); do { if ($condition()) { return; } IPS_Sleep($intervalMs); } while (microtime(true) < $deadline); $this->fail("Bedingung wurde nach {$timeoutMs} ms nicht erfüllt."); } public function cleanup(): void { if (IPS_ObjectExists($this->rootId)) { try { $this->disconnectInstances($this->rootId); $this->deleteObjectTree($this->rootId); } catch (Throwable $error) { $this->cleanupErrors[] = $error->getMessage(); } } foreach (array_unique($this->profilesToDelete) as $profile) { try { if (IPS_VariableProfileExists($profile)) { IPS_DeleteVariableProfile($profile); } } catch (Throwable $error) { $this->cleanupErrors[] = "Profil {$profile}: " . $error->getMessage(); } } if (IPS_ObjectExists($this->rootId)) { $this->cleanupErrors[] = 'Temporäre Testkategorie konnte nicht vollständig entfernt werden.'; } } /** @return array */ public function report(): array { $failed = count(array_filter( $this->cases, static fn (array $case): bool => $case['status'] === 'failed' )); $failed += count($this->cleanupErrors); return [ 'repository' => $this->repository, 'suite' => $this->suite, 'run_id' => $this->runId, 'passed' => $failed === 0, 'tests' => count($this->cases) + count($this->cleanupErrors), 'failures' => $failed, 'cases' => $this->cases, 'cleanup_errors' => $this->cleanupErrors, ]; } /** @return array{name: string, status: string, duration_ms: int, message: string} */ private function caseResult(string $name, string $status, float $started, string $message): array { return [ 'name' => $name, 'status' => $status, 'duration_ms' => (int) round((microtime(true) - $started) * 1000), 'message' => $message, ]; } private function export($value): string { return str_replace(["\r", "\n"], ['\\r', '\\n'], var_export($value, true)); } private function disconnectInstances(int $objectId): void { if (!IPS_ObjectExists($objectId)) { return; } foreach (IPS_GetChildrenIDs($objectId) as $childId) { $this->disconnectInstances((int) $childId); } if ((int) IPS_GetObject($objectId)['ObjectType'] !== 1) { return; } $connectionId = (int) IPS_GetInstance($objectId)['ConnectionID']; if ($connectionId > 0) { IPS_DisconnectInstance($objectId); } } private function deleteObjectTree(int $objectId): void { if (!IPS_ObjectExists($objectId)) { return; } foreach (array_reverse(IPS_GetChildrenIDs($objectId)) as $childId) { $this->deleteObjectTree((int) $childId); } $type = (int) IPS_GetObject($objectId)['ObjectType']; switch ($type) { case 0: IPS_DeleteCategory($objectId); return; case 1: IPS_DeleteInstance($objectId); return; case 2: IPS_DeleteVariable($objectId); return; case 3: IPS_DeleteScript($objectId, true); return; case 4: IPS_DeleteEvent($objectId); return; case 5: IPS_DeleteMedia($objectId, true); return; case 6: IPS_DeleteLink($objectId); return; } throw new RuntimeException("Unbekannter Objekttyp {$type} bei Objekt {$objectId}."); } }