Standardisiere Symcon-Modultests
Tests / test (push) Successful in 46s

This commit is contained in:
dh
2026-09-17 12:36:41 +00:00
parent 1baa63f23e
commit 9834cc0eb7
14 changed files with 987 additions and 2 deletions
+1 -1
View File
@@ -21,6 +21,6 @@ jobs:
- name: Abhaengigkeiten installieren - name: Abhaengigkeiten installieren
run: composer install --no-interaction --no-progress --prefer-dist run: composer install --no-interaction --no-progress --prefer-dist
- name: Pruefungen ausfuehren - name: Unit-Tests und Syntaxpruefung ausfuehren
run: composer check run: composer check
+1
View File
@@ -1,3 +1,4 @@
/vendor/ /vendor/
/.phpunit.cache/ /.phpunit.cache/
/.phpunit.result.cache /.phpunit.result.cache
/build/
+5 -1
View File
@@ -35,7 +35,11 @@ composer install
composer check composer check
``` ```
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 eigenständigen 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 | | Git-Branch | IP-Symcon-Kanal |
| --- | --- | | --- | --- |
+3
View File
@@ -16,6 +16,9 @@
"scripts": { "scripts": {
"lint": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l", "lint": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l",
"test": "phpunit", "test": "phpunit",
"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": [ "check": [
"@lint", "@lint",
"@test" "@test"
+104
View File
@@ -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\EnelixUtils\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: `ShellyModul`, `Verbrauchskostenreport`.
Die Utils-Module werden jeweils eigenständig und ohne Abhängigkeit zu Enelix EMS getestet.
## 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-Utils/tests/Symcon/bootstrap.php';
$result = enelixUtilsRunSymconTests('all');
echo $result['console'];
```
Ein Einzeltest wird beispielsweise mit
`enelixUtilsRunSymconTests('single', 'ShellyModul')` 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 ShellyModul
```
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.
+314
View File
@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\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}.");
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\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');
}
}
+33
View File
@@ -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;
+72
View File
@@ -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-Utils/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 = enelixUtilsRunSymconTests(\$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 ]]
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\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 enelixUtilsRunSymconTests(string $mode = 'all', $selection = []): array
{
$runner = new TestRunner('Enelix-Utils', __DIR__ . '/manifest.php');
return $runner->run($mode, $selection);
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'ShellyModul' => [
'file' => __DIR__ . '/modules/ShellyModul.php',
'paths' => [
'ShellyModul/*',
],
],
'Verbrauchskostenreport' => [
'file' => __DIR__ . '/modules/Verbrauchskostenreport.php',
'paths' => [
'Verbrauchskostenreport/*',
],
],
];
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\SymconTest\TestContext;
return static function (TestContext $test): void {
$instancesBefore = array_flip(IPS_GetInstanceList());
$mqttId = $test->createInstance(
'{C6D2AEB3-6E1F-4B2E-8E69-3A1A00246850}',
'MQTT-Testserver'
);
$ioId = (int) IPS_GetInstance($mqttId)['ConnectionID'];
if ($ioId > 0 && !isset($instancesBefore[$ioId])) {
IPS_SetParent($ioId, $test->rootId());
}
$moduleId = $test->createInstance(
'{21D82EDB-AC8E-4F3D-9FEE-D20BCAFC1C33}',
'Shelly Modul'
);
if ((int) IPS_GetInstance($moduleId)['ConnectionID'] !== $mqttId) {
IPS_DisconnectInstance($moduleId);
IPS_ConnectInstance($moduleId, $mqttId);
IPS_ApplyChanges($moduleId);
}
$test->runCase('Instanz wird mit eigener MQTT-Elterninstanz angelegt', static function (
TestContext $test
) use ($moduleId, $mqttId): void {
$connectionId = (int) IPS_GetInstance($moduleId)['ConnectionID'];
$test->assertSame($mqttId, $connectionId);
$test->assertTrue(IPS_InstanceExists($connectionId));
});
$test->runCase('Topicfilter wird angewendet und gespeichert', static function (
TestContext $test
) use ($moduleId): void {
$test->configureInstance($moduleId, [
'UseDeviceTopicFilter' => true,
'DeviceTopicPrefix' => 'shelly-test',
'CreateOther' => false,
]);
$test->assertSame(true, IPS_GetProperty($moduleId, 'UseDeviceTopicFilter'));
$test->assertSame('shelly-test', IPS_GetProperty($moduleId, 'DeviceTopicPrefix'));
});
$test->runCase('Öffentliche Schaltfunktion sendet an MQTT', static function (
TestContext $test
) use ($moduleId): void {
$test->assertTrue(
IPS_FunctionExists('SHELLY_SetOutput'),
'Die öffentliche Funktion SHELLY_SetOutput fehlt.'
);
SHELLY_SetOutput($moduleId, 'shelly-test-device', 0, true);
});
$test->runCase('Konfigurationsformular ist gültiges JSON', static function (
TestContext $test
) use ($moduleId): void {
$form = json_decode(
IPS_GetConfigurationForm($moduleId),
true,
512,
JSON_THROW_ON_ERROR
);
$test->assertTrue(is_array($form));
});
};
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use Belevo\EnelixUtils\SymconTest\TestContext;
return static function (TestContext $test): void {
$typeProfileExisted = IPS_VariableProfileExists('BELEVO.TariffType');
$priceProfileExisted = IPS_VariableProfileExists('BELEVO.TariffPrice');
$moduleId = $test->createInstance(
'{9DAC29C7-F381-F2A4-7CE2-F391C4123F38}',
'Verbrauchskostenreport'
);
$test->trackVariableProfile('BELEVO.TariffType', $typeProfileExisted);
$test->trackVariableProfile('BELEVO.TariffPrice', $priceProfileExisted);
$test->runCase('Instanz, Bedienvariablen und Reportmedium werden angelegt', static function (
TestContext $test
) use ($moduleId): void {
foreach ([
'FromDate', 'ToDate', 'LastResult', 'VisuTariffOverview',
'VisuTariffPayload', 'VisuUserOverview', 'CreateReport', 'ReportPDF',
] as $ident) {
$test->objectByIdent($ident, $moduleId);
}
$test->assertTrue(IPS_VariableProfileExists('BELEVO.TariffType'));
$test->assertTrue(IPS_VariableProfileExists('BELEVO.TariffPrice'));
});
$test->runCase('Datumsaktionen übernehmen Werte', static function (TestContext $test) use (
$moduleId
): void {
$from = strtotime('2026-01-01 00:00:00');
$to = strtotime('2026-01-31 23:59:59');
IPS_RequestAction($moduleId, 'FromDate', $from);
IPS_RequestAction($moduleId, 'ToDate', $to);
$test->assertSame($from, GetValue($test->objectByIdent('FromDate', $moduleId)));
$test->assertSame($to, GetValue($test->objectByIdent('ToDate', $moduleId)));
});
$test->runCase('Tariftabelle wird validiert und gespeichert', static function (
TestContext $test
) use ($moduleId): void {
$payload = json_encode([
[
'unit_type' => 'Netztarif',
'start' => '2026-01-01',
'end' => '2026-12-31',
'price' => '18.500',
],
], JSON_THROW_ON_ERROR);
IPS_RequestAction($moduleId, 'VisuTariffPayload', $payload);
$tariffs = json_decode(
(string) IPS_GetProperty($moduleId, 'Tariffs'),
true,
512,
JSON_THROW_ON_ERROR
);
$test->assertSame(1, count($tariffs));
$test->assertSame('Netztarif', $tariffs[0]['unit_type']);
$status = (string) GetValue($test->objectByIdent('VisuTariffStatus', $moduleId));
$test->assertTrue(str_contains($status, '1 Tarif'));
});
};
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\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 testAffectedModuleResolverSelectsAllModulesForFrameworkChanges(): void
{
self::assertSame(
['ShellyModul', 'Verbrauchskostenreport'],
$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;
}
}