@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/vendor/
|
||||
/.phpunit.cache/
|
||||
/.phpunit.result.cache
|
||||
/build/
|
||||
|
||||
@@ -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 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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/<Modul>.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.
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\SymconTest;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class TestContext
|
||||
{
|
||||
private string $repository;
|
||||
private string $suite;
|
||||
private string $runId;
|
||||
private int $rootId;
|
||||
/** @var list<array{name: string, status: string, duration_ms: int, message: string}> */
|
||||
private array $cases = [];
|
||||
/** @var list<string> */
|
||||
private array $cleanupErrors = [];
|
||||
/** @var list<string> */
|
||||
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'
|
||||
<?php
|
||||
|
||||
SetValue((int) $_IPS['VARIABLE'], (bool) $_IPS['VALUE']);
|
||||
PHP
|
||||
);
|
||||
IPS_SetVariableCustomAction($variableId, $scriptId);
|
||||
|
||||
return $variableId;
|
||||
}
|
||||
|
||||
/** @param array<string, bool|int|float|string> $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<string, bool|int|float|string> $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<string, mixed> */
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\SymconTest;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
final class TestRunner
|
||||
{
|
||||
private string $repository;
|
||||
private string $manifestPath;
|
||||
|
||||
public function __construct(string $repository, string $manifestPath)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->manifestPath = $manifestPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|string $selection
|
||||
* @return array{report: array<string, mixed>, 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<string, array<string, mixed>> $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<string, mixed> $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<string, mixed> $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 = ['<?xml version="1.0" encoding="UTF-8"?>', '<testsuite ' . $attributes . '>'];
|
||||
|
||||
foreach ($report['results'] as $module) {
|
||||
foreach ($module['cases'] as $case) {
|
||||
$xml[] = sprintf(
|
||||
' <testcase classname="%s.%s" name="%s" time="%.3f">',
|
||||
self::xml((string) $report['repository']),
|
||||
self::xml((string) $module['suite']),
|
||||
self::xml((string) $case['name']),
|
||||
$case['duration_ms'] / 1000
|
||||
);
|
||||
if ($case['status'] === 'failed') {
|
||||
$xml[] = ' <failure message="' . self::xml((string) $case['message']) . '"/>';
|
||||
}
|
||||
$xml[] = ' </testcase>';
|
||||
}
|
||||
foreach ($module['cleanup_errors'] as $index => $error) {
|
||||
$xml[] = sprintf(
|
||||
' <testcase classname="%s.%s" name="Cleanup %d">',
|
||||
self::xml((string) $report['repository']),
|
||||
self::xml((string) $module['suite']),
|
||||
$index + 1
|
||||
);
|
||||
$xml[] = ' <failure message="' . self::xml((string) $error) . '"/>';
|
||||
$xml[] = ' </testcase>';
|
||||
}
|
||||
}
|
||||
$xml[] = '</testsuite>';
|
||||
|
||||
return implode("\n", $xml);
|
||||
}
|
||||
|
||||
private static function xml(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$manifest = require dirname(__DIR__) . '/manifest.php';
|
||||
$paths = array_slice($argv, 1);
|
||||
$shared = [
|
||||
'library.json',
|
||||
'composer.json',
|
||||
'tests/Symcon/TestContext.php',
|
||||
'tests/Symcon/TestRunner.php',
|
||||
'tests/Symcon/bootstrap.php',
|
||||
'tests/Symcon/manifest.php',
|
||||
];
|
||||
$selected = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$path = ltrim(str_replace('\\', '/', $path), './');
|
||||
if (in_array($path, $shared, true)) {
|
||||
$selected = array_keys($manifest);
|
||||
break;
|
||||
}
|
||||
foreach ($manifest as $name => $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;
|
||||
Executable
+72
@@ -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 ]]
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Belevo\EnelixEMS\SymconTest\TestRunner;
|
||||
|
||||
require_once __DIR__ . '/TestContext.php';
|
||||
require_once __DIR__ . '/TestRunner.php';
|
||||
|
||||
/**
|
||||
* @param list<string>|string $selection
|
||||
* @return array{report: array<string, mixed>, console: string, junit: string}
|
||||
*/
|
||||
function enelixEmsRunSymconTests(string $mode = 'all', $selection = []): array
|
||||
{
|
||||
$runner = new TestRunner('Enelix-EMS', __DIR__ . '/manifest.php');
|
||||
|
||||
return $runner->run($mode, $selection);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'Manager' => [
|
||||
'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',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||
|
||||
return static function (TestContext $test): void {
|
||||
$netzleistungId = $test->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]));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||
|
||||
return static function (TestContext $test): void {
|
||||
$kontaktId = $test->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));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Belevo\EnelixEMS\SymconTest\TestContext;
|
||||
|
||||
return static function (TestContext $test): void {
|
||||
$temperaturId = $test->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))
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SymconTestContractTest extends TestCase
|
||||
{
|
||||
public function testEveryModuleHasRegisteredSymconTest(): void
|
||||
{
|
||||
$root = dirname(__DIR__);
|
||||
$moduleDirectories = array_map(
|
||||
static fn (string $path): string => 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<string> $paths
|
||||
* @return list<string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user