diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ecd1504..1d53b54 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -21,9 +21,6 @@ jobs: - name: Abhaengigkeiten installieren run: composer install --no-interaction --no-progress --prefer-dist - - name: Verbraucher 1-Stufig separat pruefen - run: composer check:verbraucher-einstufig - - - name: Gesamttest ausfuehren + - name: Unit-Tests und Syntaxpruefung ausfuehren run: composer check diff --git a/.gitignore b/.gitignore index f0f3907..46f5ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /vendor/ /.phpunit.cache/ /.phpunit.result.cache +/build/ diff --git a/README.md b/README.md index 0d3d77b..380ce17 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,11 @@ zusaetzlich unabhaengig ausgefuehrt werden: composer check:verbraucher-einstufig ``` -Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingecheckt. Die CI-Pruefung muss vor der Uebernahme in einen Freigabebranch erfolgreich sein. +Neue oder geaenderte Funktionen werden zusammen mit ihren PHPUnit-Tests eingecheckt. +Jedes Modul besitzt zusätzlich einen standardisierten Laufzeittest für IP-Symcon +8.x. Einzel-, Änderungs- und Gesamtläufe sowie Cleanup und Berichte sind im +[Testleitfaden](docs/testing/README.md) beschrieben. Die CI-Pruefung muss vor +der Uebernahme in einen Freigabebranch erfolgreich sein. | Git-Branch | IP-Symcon-Kanal | | --- | --- | diff --git a/composer.json b/composer.json index f7f3172..3f16439 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,9 @@ "lint:verbraucher-einstufig": "find VerbraucherEinStufig libs/EinStufigRegler.php tests/VerbraucherEinStufig -name '*.php' -print0 | xargs -0 -n1 php -l", "test": "phpunit", "test:verbraucher-einstufig": "phpunit --configuration phpunit.verbraucher-einstufig.xml", + "symcon:all": "bash tests/Symcon/bin/run-symcon-tests.sh all", + "symcon:single": "bash tests/Symcon/bin/run-symcon-tests.sh single", + "symcon:affected": "bash tests/Symcon/bin/run-symcon-tests.sh affected", "check": [ "@lint", "@test" diff --git a/docs/adr/0003-standardisierte-teststrategie.md b/docs/adr/0003-standardisierte-teststrategie.md new file mode 100644 index 0000000..81fa3de --- /dev/null +++ b/docs/adr/0003-standardisierte-teststrategie.md @@ -0,0 +1,43 @@ +# ADR 0003: Standardisierte Teststrategie + +## Kontext + +Die Module besitzen PHPUnit-Tests, ihre Prüfungen in einer echten +IP-Symcon-Installation waren jedoch unterschiedlich aufgebaut. Dadurch fehlten +ein einheitlicher Aufruf, sicherer Cleanup, maschinenlesbare Berichte und eine +verbindliche Regel für neue Module. + +## Entscheidung + +Enelix verwendet zwei Testebenen. PHPUnit bleibt die schnelle Pflichtprüfung bei +jedem Push. Zusätzlich erhält jedes Modul einen registrierten Symcon-Modultest +mit dem gemeinsamen `TestContext`. Der Runner unterstützt `single`, +`affected` und `all`, isoliert jeden Testlauf und liefert Konsole, JSON und +JUnit XML. + +Der Manager-Test erzeugt jeden implementierten Verbrauchertyp und prüft ihn +einzeln sowie in einer gemeinsamen Konstellation. Neue Verbrauchertypen müssen +diese Matrix erweitern. + +## Alternativen + +- Nur PHPUnit: verworfen, weil das reale Objektmodell, Actions und + Modulinteraktionen nicht abgedeckt werden. +- Freie Schnellausführungs-Skripte pro Modul: verworfen, weil Aufbau, Cleanup + und Berichte erneut auseinanderlaufen würden. +- Ein drittes Test-Repository: vorerst verworfen, weil Tests zusammen mit dem + jeweiligen Modul versioniert und atomar geändert werden sollen. + +## Folgen + +Jedes neue Modul benötigt zusätzlich zu Unit-Tests einen Manifest-Eintrag und +einen Symcon-Test. Der Vertrags-Unit-Test verhindert unregistrierte Module. +Integrationstests benötigen einen isolierten IP-Symcon-8.x-Runner. Testobjekte +dürfen ausschließlich innerhalb der vom Framework erzeugten Kategorie liegen. + +## Offene Punkte + +- Bereitstellung und Registrierung des Gitea-Runners mit Label `symcon-8`. +- Festlegung der Aufbewahrungsdauer für JSON- und JUnit-Artefakte. +- Erweiterung der Manager-Matrix, sobald weitere Verbrauchertypen umgesetzt + werden. diff --git a/docs/testing/README.md b/docs/testing/README.md new file mode 100644 index 0000000..06ec1a9 --- /dev/null +++ b/docs/testing/README.md @@ -0,0 +1,104 @@ +# Standardisierte Tests + +## Ziel + +Dieses Repository verwendet zwei verbindliche Testebenen: + +1. PHPUnit prüft reine PHP-Logik und Struktur bei jedem Push. +2. Symcon-Modultests prüfen reale Instanzen, Variablen, Actions und Zusammenspiel + in IP-Symcon 8.x. + +Alle implementierten Module müssen in `tests/Symcon/manifest.php` eingetragen +sein und ein eigenes Skript in `tests/Symcon/modules/` besitzen. Der +PHPUnit-Test `SymconTestContractTest` erzwingt diese Regel auch für künftig +hinzugefügte Module. + +## Testvertrag + +Ein Modultest gibt eine aufrufbare Funktion mit dieser Signatur zurück: + +```php +use Belevo\EnelixEMS\SymconTest\TestContext; + +return static function (TestContext $test): void { + $test->runCase('Beschreibung', static function (TestContext $test): void { + // Instanz aufbauen, Eingang simulieren und Ergebnis prüfen. + }); +}; +``` + +Das Framework erzeugt für jeden Modultest eine eindeutige Kategorie unterhalb +der Objektwurzel. Instanzen und Hilfsobjekte werden ausschließlich dort +angelegt. Der Runner entfernt den vollständigen Baum in einem `finally`-Pfad. +Ein fehlgeschlagener Cleanup macht den Gesamtlauf rot. + +Tests dürfen keine vorhandenen Objekte verändern oder anhand ihres Namens +löschen. Globale Variablenprofile müssen nur dann registriert und entfernt +werden, wenn sie vor dem Lauf nicht existierten. + +## Modi + +- `all`: alle registrierten Module +- `single`: genau die als Auswahl übergebenen Module +- `affected`: die durch geänderte Pfade ermittelten Module + +Verfügbare Module: `Manager`, `VerbraucherEinStufig`, `Warmwassererwaermer`. + +Der Manager-Test enthält Manager ohne Verbraucher, jeden Verbrauchertyp einzeln und alle aktuell implementierten Verbrauchertypen gemeinsam. + +## Manuelle Ausführung in IP-Symcon + +Das Repository muss über die Modulverwaltung installiert und auf dem zu +prüfenden Stand sein. In der Schnellausführung: + +```php +require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/tests/Symcon/bootstrap.php'; + +$result = enelixEmsRunSymconTests('all'); +echo $result['console']; +``` + +Ein Einzeltest wird beispielsweise mit +`enelixEmsRunSymconTests('single', 'Manager')` gestartet. + +Auf dem Agent-Server kann derselbe Lauf über JSON-RPC ausgeführt werden: + +```bash +tests/Symcon/bin/run-symcon-tests.sh all +tests/Symcon/bin/run-symcon-tests.sh single Manager +``` + +Die URL kann ausschließlich zur Laufzeit über `ENELIX_SYMCON_URL` gesetzt +werden. Zugangsdaten gehören nicht in Repository, Skripte oder Logs. + +## Berichte + +Jeder Lauf erzeugt: + +- eine kurze Konsolenzusammenfassung, +- `build/symcon-tests/report.json` für Diagnose und Archivierung, +- `build/symcon-tests/junit.xml` für CI-Auswertung. + +Zusätzlich schreibt der Runner die Zusammenfassung in das IP-Symcon-Log. + +## CI-Regeln + +Bei jedem Push laufen sämtliche PHPUnit-Tests. Auf `develop` werden zusätzlich +die von den geänderten Pfaden betroffenen Symcon-Modultests ausgeführt. Bei +gemeinsamen Framework- oder Vertragsänderungen läuft die vollständige Suite. +Für `beta` ist immer `all` vorgeschrieben. + +Die Symcon-Jobs benötigen einen geschützten Runner mit dem Label `symcon-8`, +lokalem Zugriff auf eine isolierte IP-Symcon-8.x-Instanz und den installierten +Repository-Stand. Ohne diesen Runner ist die Richtlinie dokumentiert, aber +nicht technisch durchsetzbar. + +## Checkliste für neue Module + +1. PHPUnit-Tests für die reine Logik ergänzen. +2. `tests/Symcon/modules/.php` hinzufügen. +3. Modul und betroffene Pfade in `tests/Symcon/manifest.php` registrieren. +4. Instanz, Pflichtvariablen, Actions, Normalfall und mindestens einen + Fehler- oder Grenzfall prüfen. +5. Alle Hilfsobjekte über `TestContext` anlegen. +6. `composer check`, Einzeltest und Gesamttest erfolgreich ausführen. diff --git a/tests/Symcon/TestContext.php b/tests/Symcon/TestContext.php new file mode 100644 index 0000000..e61343f --- /dev/null +++ b/tests/Symcon/TestContext.php @@ -0,0 +1,314 @@ + */ + 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}."); + } +} diff --git a/tests/Symcon/TestRunner.php b/tests/Symcon/TestRunner.php new file mode 100644 index 0000000..1a1c8c3 --- /dev/null +++ b/tests/Symcon/TestRunner.php @@ -0,0 +1,199 @@ +repository = $repository; + $this->manifestPath = $manifestPath; + } + + /** + * @param list|string $selection + * @return array{report: array, console: string, junit: string} + */ + public function run(string $mode = 'all', $selection = []): array + { + $definitions = require $this->manifestPath; + if (!is_array($definitions) || $definitions === []) { + throw new RuntimeException('Das Symcon-Testmanifest ist leer oder ungültig.'); + } + + $selected = $this->selectDefinitions($definitions, $mode, $selection); + $started = microtime(true); + $moduleReports = []; + + foreach ($selected as $name => $definition) { + $context = new TestContext($this->repository, (string) $name); + try { + $context->runCase('IP-Symcon 8.x ist aktiv', function (TestContext $test): void { + $version = IPS_GetKernelVersion(); + $test->assertTrue( + version_compare($version, '8.0', '>=') && version_compare($version, '9.0', '<'), + 'Erwartet wird IP-Symcon 8.x, aktiv ist ' . $version . '.' + ); + }); + $context->runCase('Modultest ausführen', function (TestContext $test) use ($definition): void { + $file = $definition['file'] ?? null; + if (!is_string($file) || !is_file($file)) { + $test->fail('Testskript wurde nicht gefunden: ' . (string) $file); + } + $moduleTest = require $file; + if (!is_callable($moduleTest)) { + $test->fail('Testskript muss eine aufrufbare Funktion zurückgeben.'); + } + $moduleTest($test); + }); + } finally { + $context->cleanup(); + } + $moduleReports[] = $context->report(); + } + + $tests = array_sum(array_column($moduleReports, 'tests')); + $failures = array_sum(array_column($moduleReports, 'failures')); + $report = [ + 'schema_version' => 1, + 'repository' => $this->repository, + 'mode' => $mode, + 'kernel_version' => IPS_GetKernelVersion(), + 'started_at' => date(DATE_ATOM, (int) $started), + 'duration_ms' => (int) round((microtime(true) - $started) * 1000), + 'passed' => $failures === 0, + 'modules' => count($moduleReports), + 'tests' => $tests, + 'failures' => $failures, + 'results' => $moduleReports, + ]; + + $console = self::toConsole($report); + IPS_LogMessage($this->repository . ' Tests', $console); + + return [ + 'report' => $report, + 'console' => $console, + 'junit' => self::toJunit($report), + ]; + } + + /** @param array> $definitions */ + private function selectDefinitions(array $definitions, string $mode, $selection): array + { + if (!in_array($mode, ['all', 'single', 'affected'], true)) { + throw new InvalidArgumentException('Modus muss all, single oder affected sein.'); + } + if ($mode === 'all') { + return $definitions; + } + + $names = is_string($selection) ? [$selection] : $selection; + if (!is_array($names) || $names === []) { + throw new InvalidArgumentException("Für {$mode} muss mindestens ein Modul angegeben werden."); + } + + $selected = []; + foreach (array_values(array_unique($names)) as $name) { + if (!is_string($name) || !isset($definitions[$name])) { + throw new InvalidArgumentException('Unbekanntes Testmodul: ' . (string) $name); + } + $selected[$name] = $definitions[$name]; + } + + return $selected; + } + + /** @param array $report */ + public static function toConsole(array $report): string + { + $state = $report['passed'] ? 'BESTANDEN' : 'FEHLGESCHLAGEN'; + $lines = [ + sprintf( + '%s %s: %s (%d Module, %d Tests, %d Fehler, %d ms)', + $report['repository'], + strtoupper((string) $report['mode']), + $state, + $report['modules'], + $report['tests'], + $report['failures'], + $report['duration_ms'] + ), + ]; + + foreach ($report['results'] as $module) { + $lines[] = sprintf( + '%s %s (%d Tests, %d Fehler)', + $module['passed'] ? '[OK]' : '[FEHLER]', + $module['suite'], + $module['tests'], + $module['failures'] + ); + foreach ($module['cases'] as $case) { + if ($case['status'] === 'failed') { + $lines[] = ' - ' . $case['name'] . ': ' . $case['message']; + } + } + foreach ($module['cleanup_errors'] as $error) { + $lines[] = ' - Cleanup: ' . $error; + } + } + + return implode("\n", $lines); + } + + /** @param array $report */ + public static function toJunit(array $report): string + { + $attributes = sprintf( + 'name="%s" tests="%d" failures="%d" time="%.3f"', + self::xml((string) $report['repository']), + $report['tests'], + $report['failures'], + $report['duration_ms'] / 1000 + ); + $xml = ['', '']; + + foreach ($report['results'] as $module) { + foreach ($module['cases'] as $case) { + $xml[] = sprintf( + ' ', + self::xml((string) $report['repository']), + self::xml((string) $module['suite']), + self::xml((string) $case['name']), + $case['duration_ms'] / 1000 + ); + if ($case['status'] === 'failed') { + $xml[] = ' '; + } + $xml[] = ' '; + } + foreach ($module['cleanup_errors'] as $index => $error) { + $xml[] = sprintf( + ' ', + self::xml((string) $report['repository']), + self::xml((string) $module['suite']), + $index + 1 + ); + $xml[] = ' '; + $xml[] = ' '; + } + } + $xml[] = ''; + + return implode("\n", $xml); + } + + private static function xml(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'); + } +} diff --git a/tests/Symcon/bin/affected-modules.php b/tests/Symcon/bin/affected-modules.php new file mode 100644 index 0000000..efbcd98 --- /dev/null +++ b/tests/Symcon/bin/affected-modules.php @@ -0,0 +1,33 @@ + $definition) { + foreach ($definition['paths'] as $pattern) { + if (fnmatch($pattern, $path)) { + $selected[] = $name; + break; + } + } + } +} + +echo json_encode(array_values(array_unique($selected)), JSON_THROW_ON_ERROR) . PHP_EOL; diff --git a/tests/Symcon/bin/run-symcon-tests.sh b/tests/Symcon/bin/run-symcon-tests.sh new file mode 100755 index 0000000..e38dabd --- /dev/null +++ b/tests/Symcon/bin/run-symcon-tests.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-all}" +selection="${2:-}" +url="${ENELIX_SYMCON_URL:-http://127.0.0.1:3777/api/}" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd -- "$script_dir/../../.." && pwd)" +output_dir="${ENELIX_TEST_OUTPUT_DIR:-$repository_root/build/symcon-tests}" + +case "$mode" in + all|single|affected) ;; + *) + echo "Unbekannter Testmodus: $mode" >&2 + exit 2 + ;; +esac + +encode_base64() { + printf '%s' "$1" | base64 | tr -d '\r\n' +} + +mode_base64="$(encode_base64 "$mode")" +selection_base64="$(encode_base64 "$selection")" +php_code="require_once IPS_GetKernelDir() . 'modules/Enelix-EMS/tests/Symcon/bootstrap.php'; " +php_code+="\$mode = base64_decode('$mode_base64'); " +php_code+="\$selection = array_values(array_filter(explode(',', base64_decode('$selection_base64')), " +php_code+="static fn (string \$value): bool => \$value !== '')); " +php_code+="\$result = enelixEmsRunSymconTests(\$mode, \$selection); " +php_code+="echo implode(':', [" +php_code+="base64_encode(json_encode(\$result, JSON_THROW_ON_ERROR)), " +php_code+="base64_encode(json_encode(\$result['report'], JSON_THROW_ON_ERROR)), " +php_code+="base64_encode(\$result['junit']), " +php_code+="base64_encode(\$result['console']), " +php_code+="\$result['report']['passed'] ? '1' : '0'" +php_code+="]);" +php_code_base64="$(encode_base64 "$php_code")" +rpc_code="eval(base64_decode('$php_code_base64'));" +payload="$(printf '{"jsonrpc":"2.0","id":1,"method":"IPS_RunScriptTextWaitEx","params":["%s",{}]}' "$rpc_code")" + +mkdir -p "$output_dir" +response_file="$output_dir/rpc-response.json" +result_file="$output_dir/result.json" +curl --silent --show-error --fail \ + --header 'Content-Type: application/json' \ + --data "$payload" \ + "$url" > "$response_file" + +if grep -q '"error"' "$response_file"; then + sed -n '1,120p' "$response_file" >&2 + exit 1 +fi + +wire="$(sed -n 's/^.*"result":"\([^"]*\)".*$/\1/p' "$response_file" | sed 's#\\/#/#g')" +if [[ -z "$wire" ]]; then + echo 'IP-Symcon lieferte keine auswertbaren Testdaten:' >&2 + sed -n '1,120p' "$response_file" >&2 + exit 1 +fi + +IFS=: read -r result_base64 report_base64 junit_base64 console_base64 passed extra <<< "$wire" +if [[ -n "${extra:-}" || -z "$result_base64" || -z "$report_base64" || -z "$junit_base64" ]]; then + echo 'IP-Symcon lieferte ein ungültiges Testergebnis.' >&2 + exit 1 +fi + +printf '%s' "$result_base64" | base64 -d > "$result_file" +printf '%s' "$report_base64" | base64 -d > "$output_dir/report.json" +printf '%s' "$junit_base64" | base64 -d > "$output_dir/junit.xml" +printf '%s' "$console_base64" | base64 -d + +[[ "$passed" == 1 ]] diff --git a/tests/Symcon/bootstrap.php b/tests/Symcon/bootstrap.php new file mode 100644 index 0000000..b9abc7e --- /dev/null +++ b/tests/Symcon/bootstrap.php @@ -0,0 +1,19 @@ +|string $selection + * @return array{report: array, console: string, junit: string} + */ +function enelixEmsRunSymconTests(string $mode = 'all', $selection = []): array +{ + $runner = new TestRunner('Enelix-EMS', __DIR__ . '/manifest.php'); + + return $runner->run($mode, $selection); +} diff --git a/tests/Symcon/manifest.php b/tests/Symcon/manifest.php new file mode 100644 index 0000000..5b7a6a6 --- /dev/null +++ b/tests/Symcon/manifest.php @@ -0,0 +1,36 @@ + [ + 'file' => __DIR__ . '/modules/Manager.php', + 'paths' => [ + 'Manager/*', + 'libs/ManagerRegler.php', + 'libs/ManagerSchnittstelle.php', + 'libs/Nachrichtenvertrag.php', + 'libs/VerbraucherSchnittstelle.php', + ], + ], + 'VerbraucherEinStufig' => [ + 'file' => __DIR__ . '/modules/VerbraucherEinStufig.php', + 'paths' => [ + 'VerbraucherEinStufig/*', + 'libs/EinStufigRegler.php', + 'libs/VerbraucherBasisTrait.php', + 'libs/VerbraucherSchnittstelle.php', + 'libs/Nachrichtenvertrag.php', + ], + ], + 'Warmwassererwaermer' => [ + 'file' => __DIR__ . '/modules/Warmwassererwaermer.php', + 'paths' => [ + 'Warmwassererwaermer/*', + 'libs/WarmwasserRegler.php', + 'libs/VerbraucherBasisTrait.php', + 'libs/VerbraucherSchnittstelle.php', + 'libs/Nachrichtenvertrag.php', + ], + ], +]; diff --git a/tests/Symcon/modules/Manager.php b/tests/Symcon/modules/Manager.php new file mode 100644 index 0000000..6f49715 --- /dev/null +++ b/tests/Symcon/modules/Manager.php @@ -0,0 +1,143 @@ +createVariable(2, 'Netzleistung', 0.0); + $einKontaktId = $test->createActionVariable('Kontakt Verbraucher 1-Stufig'); + $einStufigId = $test->createInstance( + '{15879A4E-D0C2-4495-83DE-46E1E462591E}', + 'Verbraucher 1-Stufig', + [ + 'Nennleistung' => 1000, + 'SchaltkontaktVariableID' => $einKontaktId, + 'Mindesteinschaltdauer' => 0, + 'Mindestausschaltdauer' => 0, + 'PrioritaetPV' => 0, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + + $temperaturId = $test->createVariable(2, 'Boilertemperatur', 52.0); + $warmKontakte = [ + $test->createActionVariable('Warmwasser Stufe 1'), + $test->createActionVariable('Warmwasser Stufe 2'), + ]; + $warmStufen = [ + ['Stufe' => 1, 'Leistung' => 1000, 'Schaltkontakt_Stufe' => $warmKontakte[0]], + ['Stufe' => 2, 'Leistung' => 2000, 'Schaltkontakt_Stufe' => $warmKontakte[1]], + ]; + $warmwasserId = $test->createInstance( + '{B7C54AF4-AD7D-4FE4-B75D-203693906251}', + 'Wassererwärmer', + [ + 'Boilerfuehler_PT1' => $temperaturId, + 'LeistungsStufen' => json_encode($warmStufen, JSON_THROW_ON_ERROR), + 'LastwechselSperrzeit' => 1, + 'LegionellenfunktionAktiv' => false, + 'PrioritaetPV' => 1, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + + $managerId = $test->createInstance( + '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', + 'Manager', + [ + 'NetzleistungVariableID' => $netzleistungId, + 'AutomatischeSuche' => false, + 'VerbraucherZuordnung' => '[]', + 'DiagnosevariablenAnzeigen' => true, + 'SollwertSolarladen' => 0.0, + ] + ); + + $setAssignment = static function (array $ids) use ($test, $managerId): void { + $assignment = array_map( + static fn (int $id): array => ['InstanzID' => $id, 'Aktiv' => true], + $ids + ); + $test->configureInstance($managerId, [ + 'VerbraucherZuordnung' => json_encode($assignment, JSON_THROW_ON_ERROR), + ]); + }; + $reportConsumers = static function (array $ids): void { + foreach ($ids as $id) { + IPS_RequestAction($id, 'Aktiv', true); + IPS_RequestAction($id, 'Melden', true); + } + }; + + $test->runCase('Manager funktioniert ohne Verbraucher', static function (TestContext $test) use ( + $managerId + ): void { + IPS_RequestAction($managerId, 'Aktiv', true); + IPS_RequestAction($managerId, 'Regeln', true); + $test->assertInstanceStatus($managerId); + $test->assertSame('PV', GetValue($test->objectByIdent('Betriebsart', $managerId))); + }); + + $test->runCase('Manager steuert Verbraucher 1-Stufig', static function (TestContext $test) use ( + $setAssignment, + $reportConsumers, + $netzleistungId, + $managerId, + $einStufigId, + $einKontaktId + ): void { + $setAssignment([$einStufigId]); + SetValue($netzleistungId, -1000.0); + $reportConsumers([$einStufigId]); + IPS_RequestAction($managerId, 'Regeln', true); + $test->waitUntil(static fn (): bool => GetValue($einKontaktId) === true); + $test->assertSame(true, GetValue($einKontaktId)); + }); + + $test->runCase('Manager steuert Wassererwärmer', static function (TestContext $test) use ( + $setAssignment, + $reportConsumers, + $netzleistungId, + $managerId, + $warmwasserId, + $warmKontakte + ): void { + $setAssignment([$warmwasserId]); + SetValue($netzleistungId, -2000.0); + $reportConsumers([$warmwasserId]); + IPS_RequestAction($managerId, 'Regeln', true); + $test->waitUntil(static fn (): bool => GetValue($warmKontakte[1]) === true); + $test->assertSame([false, true], array_map( + static fn (int $id): bool => (bool) GetValue($id), + $warmKontakte + )); + }); + + $test->runCase('Manager steuert beide Verbrauchertypen gemeinsam', static function ( + TestContext $test + ) use ( + $setAssignment, + $reportConsumers, + $netzleistungId, + $managerId, + $einStufigId, + $warmwasserId, + $einKontaktId, + $warmKontakte + ): void { + $setAssignment([$einStufigId, $warmwasserId]); + SetValue($netzleistungId, -3000.0); + $reportConsumers([$einStufigId, $warmwasserId]); + IPS_RequestAction($managerId, 'Regeln', true); + $test->waitUntil(static fn (): bool => ( + GetValue($einKontaktId) === true && GetValue($warmKontakte[1]) === true + )); + $test->assertSame( + 2, + GetValue($test->objectByIdent('VerbraucherAnzahl', $managerId)) + ); + $test->assertSame(true, GetValue($einKontaktId)); + $test->assertSame(true, GetValue($warmKontakte[1])); + }); +}; diff --git a/tests/Symcon/modules/VerbraucherEinStufig.php b/tests/Symcon/modules/VerbraucherEinStufig.php new file mode 100644 index 0000000..929f6fa --- /dev/null +++ b/tests/Symcon/modules/VerbraucherEinStufig.php @@ -0,0 +1,90 @@ +createActionVariable('Schaltkontakt'); + $verbraucherId = $test->createInstance( + '{15879A4E-D0C2-4495-83DE-46E1E462591E}', + 'Verbraucher 1-Stufig', + [ + 'Nennleistung' => 1200, + 'SchaltkontaktVariableID' => $kontaktId, + 'Mindesteinschaltdauer' => 0, + 'Mindestausschaltdauer' => 0, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + $managerId = $test->createInstance( + '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', + 'Hilfsmanager', + [ + 'AutomatischeSuche' => false, + 'VerbraucherZuordnung' => json_encode([ + ['InstanzID' => $verbraucherId, 'Aktiv' => true], + ], JSON_THROW_ON_ERROR), + ] + ); + + $test->runCase('Instanz und Variablen werden angelegt', static function (TestContext $test) use ( + $verbraucherId + ): void { + $test->assertInstanceStatus($verbraucherId); + foreach (['Aktiv', 'Schaltzustand', 'Tageslaufzeit', 'Istleistung', 'Sollleistung'] as $ident) { + $test->objectByIdent($ident, $verbraucherId); + } + }); + + $test->runCase('Sollwert schaltet den Verbraucher ein', static function (TestContext $test) use ( + $managerId, + $verbraucherId, + $kontaktId + ): void { + IPS_RequestAction($verbraucherId, 'Aktiv', true); + $paket = [ + 'Kopf' => [ + 'Version' => '3.0', + 'AbsenderID' => $managerId, + 'EmpfaengerID' => $verbraucherId, + 'Zeitpunkt' => time(), + ], + 'Sollleistung_W' => 1200, + ]; + IPS_RequestAction( + $verbraucherId, + 'ManagerdatenEmpfangen', + json_encode($paket, JSON_THROW_ON_ERROR) + ); + $test->waitUntil(static fn (): bool => GetValue($kontaktId) === true); + $test->assertSame(true, GetValue($kontaktId)); + $test->assertSame( + 1200, + (int) round((float) GetValue($test->objectByIdent('Istleistung', $verbraucherId))) + ); + }); + + $test->runCase('Sollwert null schaltet den Verbraucher aus', static function (TestContext $test) use ( + $managerId, + $verbraucherId, + $kontaktId + ): void { + $paket = [ + 'Kopf' => [ + 'Version' => '3.0', + 'AbsenderID' => $managerId, + 'EmpfaengerID' => $verbraucherId, + 'Zeitpunkt' => time(), + ], + 'Sollleistung_W' => 0, + ]; + IPS_RequestAction( + $verbraucherId, + 'ManagerdatenEmpfangen', + json_encode($paket, JSON_THROW_ON_ERROR) + ); + $test->waitUntil(static fn (): bool => GetValue($kontaktId) === false); + $test->assertSame(false, GetValue($kontaktId)); + }); +}; diff --git a/tests/Symcon/modules/Warmwassererwaermer.php b/tests/Symcon/modules/Warmwassererwaermer.php new file mode 100644 index 0000000..b19e17b --- /dev/null +++ b/tests/Symcon/modules/Warmwassererwaermer.php @@ -0,0 +1,97 @@ +createVariable(2, 'Boilertemperatur', 52.0, null, '~Temperature'); + $kontakte = [ + $test->createActionVariable('Heizstufe 1'), + $test->createActionVariable('Heizstufe 2'), + $test->createActionVariable('Heizstufe 3'), + ]; + $stufen = []; + foreach ($kontakte as $index => $kontaktId) { + $stufen[] = [ + 'Stufe' => $index + 1, + 'Leistung' => ($index + 1) * 1000, + 'Schaltkontakt_Stufe' => $kontaktId, + ]; + } + + $verbraucherId = $test->createInstance( + '{B7C54AF4-AD7D-4FE4-B75D-203693906251}', + 'Wassererwärmer', + [ + 'Boilerfuehler_PT1' => $temperaturId, + 'LeistungsStufen' => json_encode($stufen, JSON_THROW_ON_ERROR), + 'LastwechselSperrzeit' => 1, + 'LegionellenfunktionAktiv' => false, + 'DiagnosevariablenAnzeigen' => true, + ] + ); + $managerId = $test->createInstance( + '{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}', + 'Hilfsmanager', + [ + 'AutomatischeSuche' => false, + 'VerbraucherZuordnung' => json_encode([ + ['InstanzID' => $verbraucherId, 'Aktiv' => true], + ], JSON_THROW_ON_ERROR), + ] + ); + + $test->runCase('Instanz und Diagnosevariablen werden angelegt', static function ( + TestContext $test + ) use ($verbraucherId): void { + $test->assertInstanceStatus($verbraucherId); + foreach ([ + 'Aktiv', 'Boilertemperatur', 'Mindesttemperatur', 'Maximaltemperatur', + 'Istleistung', 'Sollleistung', 'AktiveStufe', 'TemperaturGueltig', + ] as $ident) { + $test->objectByIdent($ident, $verbraucherId); + } + }); + + $test->runCase('Temperaturmessung wird übernommen', static function (TestContext $test) use ( + $verbraucherId + ): void { + IPS_RequestAction($verbraucherId, 'Aktiv', true); + IPS_RequestAction($verbraucherId, 'Melden', true); + $temperature = (float) GetValue($test->objectByIdent('Boilertemperatur', $verbraucherId)); + $test->assertEquals(52.0, $temperature, 0.01); + $test->assertSame( + true, + GetValue($test->objectByIdent('TemperaturGueltig', $verbraucherId)) + ); + }); + + $test->runCase('Sollwert wählt genau eine Leistungsstufe', static function ( + TestContext $test + ) use ($managerId, $verbraucherId, $kontakte): void { + $paket = [ + 'Kopf' => [ + 'Version' => '3.0', + 'AbsenderID' => $managerId, + 'EmpfaengerID' => $verbraucherId, + 'Zeitpunkt' => time(), + ], + 'Sollleistung_W' => 2000, + ]; + IPS_RequestAction( + $verbraucherId, + 'ManagerdatenEmpfangen', + json_encode($paket, JSON_THROW_ON_ERROR) + ); + $test->waitUntil(static fn (): bool => GetValue($kontakte[1]) === true); + $test->assertSame( + [false, true, false], + array_map(static fn (int $id): bool => (bool) GetValue($id), $kontakte) + ); + $test->assertSame( + 2, + GetValue($test->objectByIdent('AktiveStufe', $verbraucherId)) + ); + }); +}; diff --git a/tests/SymconTestContractTest.php b/tests/SymconTestContractTest.php new file mode 100644 index 0000000..be8ffaa --- /dev/null +++ b/tests/SymconTestContractTest.php @@ -0,0 +1,91 @@ + basename(dirname($path)), + glob($root . '/*/module.json') ?: [] + ); + sort($moduleDirectories); + + $manifest = require __DIR__ . '/Symcon/manifest.php'; + $registeredModules = array_keys($manifest); + sort($registeredModules); + + self::assertSame( + $moduleDirectories, + $registeredModules, + 'Jedes Modul benötigt einen Eintrag in tests/Symcon/manifest.php.' + ); + } + + public function testRegisteredScriptsReturnCallableTests(): void + { + $manifest = require __DIR__ . '/Symcon/manifest.php'; + + foreach ($manifest as $module => $definition) { + self::assertArrayHasKey('file', $definition, $module); + self::assertArrayHasKey('paths', $definition, $module); + self::assertFileExists($definition['file'], $module); + self::assertIsArray($definition['paths'], $module); + self::assertNotEmpty($definition['paths'], $module); + self::assertIsCallable(require $definition['file'], $module); + } + } + + public function testFrameworkAndRunnerScriptsExist(): void + { + foreach ([ + 'TestContext.php', + 'TestRunner.php', + 'bootstrap.php', + 'bin/affected-modules.php', + 'bin/run-symcon-tests.sh', + ] as $file) { + self::assertFileExists(__DIR__ . '/Symcon/' . $file); + } + } + + public function testAffectedModuleResolverSelectsAllMatches(): void + { + self::assertSame( + ['Manager', 'VerbraucherEinStufig', 'Warmwassererwaermer'], + $this->resolveAffectedModules(['libs/Nachrichtenvertrag.php']) + ); + } + + public function testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void + { + self::assertSame( + ['Manager', 'VerbraucherEinStufig', 'Warmwassererwaermer'], + $this->resolveAffectedModules(['tests/Symcon/TestContext.php']) + ); + } + + /** @param list $paths + * @return list + */ + private function resolveAffectedModules(array $paths): array + { + $argv = array_merge(['affected-modules.php'], $paths); + ob_start(); + require __DIR__ . '/Symcon/bin/affected-modules.php'; + $output = ob_get_clean(); + self::assertIsString($output); + + $modules = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($modules); + sort($modules); + + return $modules; + } +}