feat(manager): integrate forecast controls and merge V4 development
Tests / test (push) Failing after 49s
Tests / test (push) Failing after 49s
Merge the V4 development history with current manager, SDL and setpoint fixes. Retain guarded trial behavior and consolidate controls in Prognose / Forecast. Exclude the unverified accounting-evidence change; no runtime deployment or new dispatch permission. Validated: 357 PHPUnit tests / 1758 assertions, 150 PHP syntax checks, 310 backend tests, 16 portal tests and isolated UI/receiver checks. develop and beta publication explicitly approved by Daniel Haefliger.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../libs/ManagerPrognoseFormular.php';
|
||||
|
||||
use Belevo\EnelixEMS\ManagerPrognoseFormular;
|
||||
|
||||
$count = 0;
|
||||
function check(bool $ok, string $name): void
|
||||
{
|
||||
global $count;
|
||||
if (!$ok) {
|
||||
throw new RuntimeException('FAIL ' . $name);
|
||||
}
|
||||
$count++;
|
||||
echo 'PASS ' . $name . PHP_EOL;
|
||||
}
|
||||
|
||||
function fields(array $form): array
|
||||
{
|
||||
foreach ($form['elements'] as $element) {
|
||||
if (($element['name'] ?? '') === 'PrognoseForecast') {
|
||||
$fields = [];
|
||||
foreach ($element['items'] as $field) {
|
||||
if (isset($field['name'])) {
|
||||
if (isset($fields[$field['name']])) {
|
||||
throw new RuntimeException('Duplicate field: ' . $field['name']);
|
||||
}
|
||||
$fields[$field['name']] = $field;
|
||||
}
|
||||
}
|
||||
return $fields;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('Forecast panel missing');
|
||||
}
|
||||
|
||||
$base = json_decode(file_get_contents(__DIR__ . '/../../Manager/form.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$state = [
|
||||
'prognoseAktiv' => true, 'lizenziert' => true, 'senderAktiv' => true, 'empfangAktiv' => true,
|
||||
'messdatenAktiv' => true, 'legacyAktiv' => false, 'managerAktiv' => true, 'testErlaubt' => true,
|
||||
'aktivtestErlaubt' => true, 'watchdogVerzichtErlaubt' => true, 'aktivtestAktiv' => false,
|
||||
'ende' => 1791387487, 'jetzt' => 1791300000,
|
||||
];
|
||||
$form = ManagerPrognoseFormular::integrieren($base, $state);
|
||||
$f = fields($form);
|
||||
check(count($form['elements']) === count($base['elements']), 'no additional panel');
|
||||
check(!str_contains(json_encode($form), 'V4 Planempfang'), 'separate V4 tab removed');
|
||||
foreach (['PrognoseAktiv', 'NetzfahrplanAktiv', 'PrognoseSendeintervall', 'NetzfahrplanV4EmpfangAktiv',
|
||||
'PrognoseNetzfahrplanStart', 'PrognoseNetzfahrplanStop', 'PrognoseNetzfahrplanPruefen',
|
||||
'PrognoseLizenzVerwalten', 'PrognoseNetzfahrplanStatus'] as $name) {
|
||||
check(isset($f[$name]), 'one forecast panel contains ' . $name);
|
||||
}
|
||||
foreach ($base['elements'] as $index => $element) {
|
||||
if (($element['name'] ?? '') !== 'PrognoseForecast') {
|
||||
check($form['elements'][$index] === $element, 'unrelated panel unchanged: ' . $element['caption']);
|
||||
}
|
||||
}
|
||||
foreach (['PrognoseAktiv', 'PrognoseSendeintervall', 'PrognoseLizenzInformation'] as $name) {
|
||||
check($f[$name] === fields($base)[$name], 'existing forecast field unchanged: ' . $name);
|
||||
}
|
||||
check($f['NetzfahrplanAktiv']['visible'] === false, 'inactive legacy control hidden for configured V4');
|
||||
check($f['PrognoseNetzfahrplanStart']['enabled'], 'licensed existing consent enables explicit start');
|
||||
check(str_contains($f['PrognoseNetzfahrplanStart']['onClick'], "'FormNetzfahrplanSchalten', true"), 'start uses guarded manager action');
|
||||
check(str_contains($f['PrognoseNetzfahrplanStop']['onClick'], "'FormNetzfahrplanSchalten', false"), 'stop uses existing stop path');
|
||||
check(str_contains($f['PrognoseNetzfahrplanPruefen']['onClick'], "'NetzfahrplanV4Empfangen'"), 'plan check uses existing throttled receiver');
|
||||
check(str_contains($f['PrognoseLizenzVerwalten']['onClick'], 'https://license.enelix.ch'), 'existing license portal retained');
|
||||
check(str_contains($f['PrognoseNetzfahrplanStatus']['caption'], '07.10.2026 15:38:07 UTC'), 'original expiry displayed without extension');
|
||||
check(str_contains($f['PrognoseNetzfahrplanStatus']['caption'], 'Kein freigegebener Dauerbetrieb'), 'test limitation remains explicit');
|
||||
foreach (['prognoseAktiv', 'lizenziert', 'senderAktiv', 'empfangAktiv', 'managerAktiv',
|
||||
'testErlaubt', 'aktivtestErlaubt', 'watchdogVerzichtErlaubt'] as $key) {
|
||||
$blocked = array_replace($state, [$key => false]);
|
||||
$b = fields(ManagerPrognoseFormular::integrieren($base, $blocked));
|
||||
check(!$b['PrognoseNetzfahrplanStart']['enabled'], 'start blocked without ' . $key);
|
||||
check($b['PrognoseNetzfahrplanStop']['enabled'], 'stop remains possible without ' . $key);
|
||||
}
|
||||
foreach ([['legacyAktiv' => true], ['aktivtestAktiv' => true], ['ende' => 1791300000], ['ende' => -1]] as $change) {
|
||||
check(!ManagerPrognoseFormular::startErlaubt(array_replace($state, $change)), 'conflict, replay or expiry blocks start: ' . json_encode($change));
|
||||
}
|
||||
check(!ManagerPrognoseFormular::startErlaubt([]), 'missing state fails closed');
|
||||
check(ManagerPrognoseFormular::startErlaubt(array_replace($state, ['ende' => 0])), 'first start still uses existing backend consent procedure');
|
||||
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, ['legacyAktiv' => true, 'lizenziert' => false])));
|
||||
check($b['NetzfahrplanAktiv']['visible'] && $b['NetzfahrplanAktiv']['enabled'], 'active legacy controller can be disabled despite lost license');
|
||||
check(str_contains($b['PrognoseNetzfahrplanStatus']['caption'], 'konkurrier') === false
|
||||
&& str_contains($b['PrognoseNetzfahrplanStatus']['caption'], 'gesperrt'), 'controller conflict is visible');
|
||||
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, ['lizenziert' => false])));
|
||||
check($b['NetzfahrplanV4EmpfangAktiv']['enabled'], 'active receiver remains switchable off without license');
|
||||
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, ['lizenziert' => false, 'empfangAktiv' => false])));
|
||||
check(!$b['NetzfahrplanV4EmpfangAktiv']['enabled'], 'unlicensed receiver cannot be enabled through form');
|
||||
$b = fields(ManagerPrognoseFormular::integrieren($base, array_replace($state, [
|
||||
'senderAktiv' => false, 'empfangAktiv' => false, 'messdatenAktiv' => false,
|
||||
])));
|
||||
check($b['NetzfahrplanAktiv'] === fields($base)['NetzfahrplanAktiv'], 'non-migrated installation retains legacy control');
|
||||
$missing = $base;
|
||||
$missing['elements'] = array_values(array_filter($missing['elements'], fn($e) => ($e['name'] ?? '') !== 'PrognoseForecast'));
|
||||
try {
|
||||
ManagerPrognoseFormular::integrieren($missing, $state);
|
||||
check(false, 'missing forecast anchor rejected');
|
||||
} catch (LogicException $e) {
|
||||
check(true, 'missing forecast anchor rejected');
|
||||
}
|
||||
$duplicate = $base;
|
||||
foreach ($base['elements'] as $element) {
|
||||
if (($element['name'] ?? '') === 'PrognoseForecast') {
|
||||
$duplicate['elements'][] = $element;
|
||||
}
|
||||
}
|
||||
try {
|
||||
ManagerPrognoseFormular::integrieren($duplicate, $state);
|
||||
check(false, 'duplicate forecast anchor rejected');
|
||||
} catch (LogicException $e) {
|
||||
check(true, 'duplicate forecast anchor rejected');
|
||||
}
|
||||
check($form === ManagerPrognoseFormular::integrieren($base, $state), 'repeated rendering is deterministic and does not modify the base form');
|
||||
echo "Total: $count checks. Form generation only; no IPS or device calls.\n";
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (function_exists('IPS_GetKernelVersion')) {
|
||||
throw new RuntimeException('Isolated CLI test only; never run in the Symcon kernel.');
|
||||
}
|
||||
|
||||
class IPSModule
|
||||
{
|
||||
public int $InstanceID = 17004;
|
||||
public array $props = [], $attrs = [], $values = [], $fields = [];
|
||||
protected function ReadPropertyBoolean(string $key): bool { return (bool) ($this->props[$key] ?? false); }
|
||||
protected function ReadPropertyInteger(string $key): int { return (int) ($this->props[$key] ?? 0); }
|
||||
protected function ReadPropertyString(string $key): string { return (string) ($this->props[$key] ?? ''); }
|
||||
protected function ReadAttributeString(string $key): string { return (string) ($this->attrs[$key] ?? ''); }
|
||||
protected function ReadAttributeInteger(string $key): int { return (int) ($this->attrs[$key] ?? 0); }
|
||||
protected function GetValue(string $key) { return $this->values[$key] ?? false; }
|
||||
protected function UpdateFormField(string $name, string $field, $value): void { $this->fields[$name][$field] = $value; }
|
||||
}
|
||||
|
||||
function IPS_GetInstance(int $id): array { return ['InstanceStatus' => $GLOBALS['instanceStatus']]; }
|
||||
|
||||
require_once __DIR__ . '/../../Manager/module.php';
|
||||
|
||||
final class ManagerFormHarness extends Manager
|
||||
{
|
||||
public array $calls = [];
|
||||
public bool $reject = false;
|
||||
public function V4ManagerAktivtestSchalten(bool $aktiv): void
|
||||
{
|
||||
$this->calls[] = $aktiv;
|
||||
if ($this->reject) {
|
||||
throw new RuntimeException('Existing backend refused start');
|
||||
}
|
||||
$this->values['NetzfahrplanV4Aktivtest'] = $aktiv;
|
||||
}
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
function check(bool $ok, string $name): void
|
||||
{
|
||||
global $count;
|
||||
if (!$ok) { throw new RuntimeException('FAIL ' . $name); }
|
||||
$count++;
|
||||
echo 'PASS ' . $name . PHP_EOL;
|
||||
}
|
||||
|
||||
function fixture(): ManagerFormHarness
|
||||
{
|
||||
$h = new ManagerFormHarness();
|
||||
$h->props = array_fill_keys(['PrognoseAktiv', 'NetzfahrplanV4SchattenAktiv',
|
||||
'NetzfahrplanV4EmpfangAktiv', 'NetzfahrplanV4MessdatenAktiv', 'NetzfahrplanV4RegeltestErlaubt',
|
||||
'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'], true);
|
||||
$h->props['NetzfahrplanAktiv'] = false;
|
||||
// Synthetic fixture, not a license or credential from an installation.
|
||||
$h->props['Lizenzcode'] = 'ENX' . str_repeat('A', 16);
|
||||
$h->attrs['LizenzcodeHash'] = hash('sha256', $h->props['Lizenzcode']);
|
||||
$h->attrs['LizenzInstallationID'] = '10000000-0000-4000-8000-000000000001';
|
||||
$h->attrs['LizenzLease'] = json_encode([
|
||||
'status' => 'active', 'development' => true, 'installationId' => $h->attrs['LizenzInstallationID'],
|
||||
'issuedAt' => gmdate('c', time() - 100), 'refreshAfter' => gmdate('c', time() + 100),
|
||||
'offlineUntil' => gmdate('c', time() + 3600),
|
||||
'entitlements' => [
|
||||
['catalog_key' => 'manager_peak', 'quantity' => 1, 'status' => 'active'],
|
||||
['catalog_key' => 'grid_schedule', 'quantity' => 1, 'status' => 'active'],
|
||||
],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$h->attrs['NetzfahrplanV4AktivtestEnde'] = time() + 600;
|
||||
$h->values = ['Aktiv' => true, 'NetzfahrplanV4Aktivtest' => false];
|
||||
$GLOBALS['instanceStatus'] = 102;
|
||||
return $h;
|
||||
}
|
||||
|
||||
function rejected(ManagerFormHarness $h, $value, string $name): void
|
||||
{
|
||||
try {
|
||||
$h->RequestAction('FormNetzfahrplanSchalten', $value);
|
||||
} catch (RuntimeException | InvalidArgumentException $e) {
|
||||
check($h->calls === [], $name);
|
||||
return;
|
||||
}
|
||||
check(false, $name);
|
||||
}
|
||||
|
||||
$h = fixture();
|
||||
$before = [$h->props, $h->attrs];
|
||||
$h->RequestAction('FormNetzfahrplanSchalten', true);
|
||||
check($h->calls === [true], 'real manager action delegates exactly one explicit start');
|
||||
check(!$h->fields['PrognoseNetzfahrplanStart']['enabled'], 'started session cannot be restarted from form');
|
||||
check([$h->props, $h->attrs] === $before, 'form action changes no configuration, expiry or license');
|
||||
$h->props['Lizenzcode'] = '';
|
||||
$h->RequestAction('FormNetzfahrplanSchalten', false);
|
||||
check($h->calls === [true, false], 'stop still delegates after license loss');
|
||||
check(!$h->values['NetzfahrplanV4Aktivtest'], 'stop status is refreshed');
|
||||
foreach ([1, 'true', 'false', null, []] as $value) {
|
||||
rejected(fixture(), $value, 'nonboolean input rejected: ' . gettype($value));
|
||||
}
|
||||
foreach (['PrognoseAktiv', 'NetzfahrplanV4SchattenAktiv', 'NetzfahrplanV4EmpfangAktiv',
|
||||
'NetzfahrplanV4RegeltestErlaubt', 'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'] as $prop) {
|
||||
$h = fixture(); $h->props[$prop] = false;
|
||||
rejected($h, true, 'server-side start guard checks ' . $prop);
|
||||
}
|
||||
$h = fixture(); $h->props['Lizenzcode'] = '';
|
||||
rejected($h, true, 'missing license cannot be bypassed by calling the action directly');
|
||||
$h = fixture(); $lease = json_decode($h->attrs['LizenzLease'], true);
|
||||
$lease['entitlements'][1]['quantity'] = 0; $h->attrs['LizenzLease'] = json_encode($lease);
|
||||
rejected($h, true, 'manager license alone does not grant grid schedule');
|
||||
$h = fixture(); $h->props['NetzfahrplanAktiv'] = true;
|
||||
rejected($h, true, 'competing legacy controller blocks action');
|
||||
$h = fixture(); $h->attrs['NetzfahrplanV4AktivtestEnde'] = time() - 1;
|
||||
rejected($h, true, 'expired consent cannot be renewed by the form');
|
||||
$h = fixture(); $GLOBALS['instanceStatus'] = 203;
|
||||
rejected($h, true, 'inactive or faulty manager cannot start');
|
||||
$h = fixture(); $h->values['Aktiv'] = false;
|
||||
rejected($h, true, 'disabled manager cannot start');
|
||||
$h = fixture(); $h->values['NetzfahrplanV4Aktivtest'] = true;
|
||||
rejected($h, true, 'existing session cannot be restarted');
|
||||
$h = fixture(); $h->reject = true;
|
||||
try {
|
||||
$h->RequestAction('FormNetzfahrplanSchalten', true);
|
||||
check(false, 'backend rejection preserved');
|
||||
} catch (RuntimeException $e) {
|
||||
check($e->getMessage() === 'Existing backend refused start' && $h->calls === [true], 'backend rejection preserved without retry or bypass');
|
||||
}
|
||||
$h = fixture(); $h->RequestAction('FormPrognoseAktiv', false);
|
||||
check(!$h->fields['PrognoseNetzfahrplanStart']['enabled'] && $h->calls === [], 'unsaved forecast disable prevents start without a control call');
|
||||
$h = fixture(); $h->props['PrognoseAktiv'] = false;
|
||||
$h->RequestAction('FormPrognoseAktiv', true);
|
||||
check(!$h->fields['PrognoseNetzfahrplanStart']['enabled'] && $h->calls === [], 'unsaved forecast enable does not grant stored permission');
|
||||
echo "Total: $count checks. Real RequestAction and license checks; mocked IPS and control delegate.\n";
|
||||
Reference in New Issue
Block a user