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
+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;
}
}