Files
Enelix-EMS/tests/Symcon/TestRunner.php
T
dh 4a01e9df0f
Tests / test (push) Successful in 40s
Standardisiere Symcon-Modultests
2026-09-17 12:36:37 +00:00

200 lines
7.2 KiB
PHP

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