Files
dh 4a01e9df0f
Tests / test (push) Successful in 40s
Standardisiere Symcon-Modultests
2026-09-17 12:36:37 +00:00

315 lines
9.7 KiB
PHP

<?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}.");
}
}