feat(workflow): publish verified forecast controls and consolidated docs
Tests / test (push) Successful in 1m1s

Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
This commit is contained in:
dh
2026-10-08 10:02:58 +00:00
parent af69151165
commit 74a2d6c685
82 changed files with 5054 additions and 617 deletions
+78 -60
View File
@@ -20,91 +20,108 @@ function check(bool $ok, string $name): void
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;
if (($element['name'] ?? '') !== 'PrognoseForecast') {
continue;
}
$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');
}
$anzeigen = ManagerPrognoseFormular::laufzeitAnzeige();
check(count($anzeigen) === 4, 'only known forecast runtime objects are consolidated');
check(count($anzeigen) === 4, 'known historical runtime identifiers remain available');
foreach ($anzeigen as $ident => $name) {
check(str_starts_with($ident, 'NetzfahrplanV4'), 'stable existing identifier retained');
check(!str_contains($name, 'V4'), 'technical version removed from display name');
check(!str_contains($name, 'V4'), 'technical version removed from historical display name');
}
check(str_contains($anzeigen['NetzfahrplanV4Aktivtest'], 'Testbetrieb'), 'test limitation remains visible');
check(str_contains($anzeigen['NetzfahrplanV4Aktivtest'], 'Testbetrieb'), 'historical test object is not relabelled as production control');
$module = file_get_contents(__DIR__ . '/../../Manager/module.php');
check(str_contains($module, 'ManagerPrognoseFormular::laufzeitAnzeige()'), 'runtime display migration is applied');
check(str_contains($module, 'IPS_SetHidden($objektID, true)'), 'legacy diagnostics are hidden, not deleted');
check(str_contains($module, "RegisterPropertyBoolean('NetzfahrplanAktiv', false)"), 'legacy controller property remains stored');
check(str_contains($module, "RegisterPropertyInteger('PrognoseSendeintervall'"), 'internal sending interval remains stored');
$propertySources = $module;
foreach (glob(__DIR__ . '/../../libs/ManagerNetzfahrplanV4*Trait.php') ?: [] as $path) {
$propertySources .= file_get_contents($path);
}
foreach (['NetzfahrplanV4EmpfangAktiv', 'NetzfahrplanV4SchattenAktiv', 'NetzfahrplanV4MessdatenAktiv',
'NetzfahrplanV4RegeltestErlaubt', 'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'] as $property) {
check(preg_match("/RegisterPropertyBoolean\\(\\s*'" . $property . "'/", $propertySources) === 1,
'hidden technical property remains registered: ' . $property);
}
$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,
];
$original = $base;
$state = ['prognoseAktiv' => true, 'lizenziert' => true, 'ready' => true,
'controlRequested' => false, 'controlActive' => false, 'state' => 'ready', 'reason' => 'Lokale Pruefungen bestanden.'];
$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);
$switches = array_keys(array_filter($f, static fn(array $field): bool => $field['type'] === 'CheckBox'));
check($switches === ['PrognoseAktiv', 'NetzfahrplanRegelungAktiv'], 'exactly learning and control switches are exposed');
check(str_contains($f['PrognoseAktiv']['onChange'], "'FormPrognoseAktiv'"), 'learning switch refreshes existing form guard');
check($f['NetzfahrplanRegelungAktiv']['enabled'] === true, 'ready licensed learning permits control request');
check(!isset($f['NetzfahrplanRegelungAktiv']['onChange']), 'control is a saved property, not a direct actuator action');
foreach (['NetzfahrplanAktiv', 'PrognoseSendeintervall', 'NetzfahrplanV4EmpfangAktiv',
'NetzfahrplanV4SchattenAktiv', 'NetzfahrplanV4MessdatenAktiv', 'NetzfahrplanV4RegeltestErlaubt',
'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt',
'PrognoseNetzfahrplanStart', 'PrognoseNetzfahrplanStop', 'PrognoseNetzfahrplanPruefen'] as $name) {
check(!isset($f[$name]), 'legacy or technical operator field removed: ' . $name);
}
$buttons = array_filter($f, static fn(array $field): bool => $field['type'] === 'Button');
check(array_keys($buttons) === ['PrognoseLizenzVerwalten'], 'only the portal link remains as a button');
check($buttons['PrognoseLizenzVerwalten']['link'] === true, 'portal button is a link');
check(str_contains($buttons['PrognoseLizenzVerwalten']['onClick'], 'https://license.enelix.ch'), 'existing license portal retained');
check(!str_contains($buttons['PrognoseLizenzVerwalten']['onClick'], 'IPS_RequestAction'), 'portal link cannot dispatch a command');
check($f['PrognoseNetzfahrplanStatus']['caption'] === 'Bereit: Lokale Pruefungen bestanden.', 'status displays current readiness and reason');
foreach ($base['elements'] as $index => $element) {
if (($element['name'] ?? '') !== 'PrognoseForecast') {
check($form['elements'][$index] === $element, 'unrelated panel unchanged: ' . $element['caption']);
check($form['elements'][$index] === $element, 'unrelated panel unchanged: ' . ($element['caption'] ?? $index));
}
}
foreach (['PrognoseAktiv', 'PrognoseSendeintervall', 'PrognoseLizenzInformation'] as $name) {
check($f[$name] === fields($base)[$name], 'existing forecast field unchanged: ' . $name);
foreach ([false, true] as $learning) {
foreach ([false, true] as $licensed) {
foreach ([false, true] as $ready) {
$changed = array_replace($state, ['prognoseAktiv' => $learning, 'lizenziert' => $licensed, 'ready' => $ready]);
$expected = $learning && $licensed && $ready;
check(ManagerPrognoseFormular::startErlaubt($changed) === $expected, 'all three start guards are required');
check(fields(ManagerPrognoseFormular::integrieren($base, $changed))['NetzfahrplanRegelungAktiv']['enabled'] === $expected,
'inactive control request follows start guard');
$changed['controlRequested'] = true;
check(fields(ManagerPrognoseFormular::integrieren($base, $changed))['NetzfahrplanRegelungAktiv']['enabled'] === true,
'requested control can be switched off despite any failed guard');
}
}
}
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));
foreach (['prognoseAktiv', 'lizenziert', 'ready'] as $key) {
foreach ([null, 0, 1, 'true', 'false', []] as $invalid) {
check(!ManagerPrognoseFormular::startErlaubt(array_replace($state, [$key => $invalid])), 'nonboolean guard fails closed: ' . $key);
}
$missing = $state;
unset($missing[$key]);
check(!ManagerPrognoseFormular::startErlaubt($missing), 'missing guard fails closed: ' . $key);
}
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');
foreach (['disabled' => 'Aus', 'collecting' => 'Daten sammeln', 'learning' => 'Lernen',
'ready' => 'Bereit', 'active' => 'Aktiv', 'interrupted' => 'Unterbrochen'] as $key => $label) {
check(ManagerPrognoseFormular::statustext(['state' => $key, 'reason' => 'Pruefgrund']) === $label . ': Pruefgrund',
'workflow state and reason visible: ' . $key);
}
check(str_starts_with(ManagerPrognoseFormular::statustext(['state' => 'future-state']), 'Status unbekannt:'), 'unknown workflow status is not reported as ready');
check(str_contains(ManagerPrognoseFormular::statustext([]), 'Aktuellen Status abwarten.'), 'missing reason has a waiting state');
$missing = $base;
$missing['elements'] = array_values(array_filter($missing['elements'], fn($e) => ($e['name'] ?? '') !== 'PrognoseForecast'));
$missing['elements'] = array_values(array_filter($missing['elements'], static fn(array $e): bool => ($e['name'] ?? '') !== 'PrognoseForecast'));
try {
ManagerPrognoseFormular::integrieren($missing, $state);
check(false, 'missing forecast anchor rejected');
@@ -123,5 +140,6 @@ try {
} 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');
check($form === ManagerPrognoseFormular::integrieren($base, $state), 'repeated rendering is deterministic');
check($base === $original, 'base form and historical configuration are not mutated');
echo "Total: $count checks. Form generation only; no IPS or device calls.\n";
+48 -44
View File
@@ -26,14 +26,10 @@ 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;
throw new RuntimeException('The normal form must never invoke the legacy trial controller.');
}
}
@@ -53,6 +49,7 @@ function fixture(): ManagerFormHarness
'NetzfahrplanV4EmpfangAktiv', 'NetzfahrplanV4MessdatenAktiv', 'NetzfahrplanV4RegeltestErlaubt',
'NetzfahrplanV4AktivtestErlaubt', 'NetzfahrplanV4WatchdogVerzichtErlaubt'], true);
$h->props['NetzfahrplanAktiv'] = false;
$h->props['NetzfahrplanRegelungAktiv'] = 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']);
@@ -67,6 +64,8 @@ function fixture(): ManagerFormHarness
],
], JSON_THROW_ON_ERROR);
$h->attrs['NetzfahrplanV4AktivtestEnde'] = time() + 600;
$h->attrs['PrognoseV4Serverstatus'] = json_encode(['receivedAt' => time(),
'workflow' => ['planningReady' => false, 'state' => 'collecting', 'reason' => 'Messdaten werden gesammelt.']], JSON_THROW_ON_ERROR);
$h->values = ['Aktiv' => true, 'NetzfahrplanV4Aktivtest' => false];
$GLOBALS['instanceStatus'] = 102;
return $h;
@@ -74,58 +73,63 @@ function fixture(): ManagerFormHarness
function rejected(ManagerFormHarness $h, $value, string $name): void
{
$before = [$h->props, $h->attrs, $h->values];
try {
$h->RequestAction('FormNetzfahrplanSchalten', $value);
} catch (RuntimeException | InvalidArgumentException $e) {
check($h->calls === [], $name);
check($h->calls === [] && [$h->props, $h->attrs, $h->values] === $before, $name);
return;
}
check(false, $name);
}
rejected(fixture(), true, 'old test permissions cannot bypass missing workflow readiness');
foreach ([1, 0, 'true', 'false', null, []] as $value) {
rejected(fixture(), $value, 'nonboolean compatibility action rejected: ' . gettype($value));
}
$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'] = '';
$before = [$h->props, $h->attrs, $h->values];
$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);
}
check($h->fields['NetzfahrplanRegelungAktiv']['value'] === false, 'compatibility disable updates the saved-property form field');
check($h->calls === [], 'compatibility form action invokes no legacy trial controller');
check([$h->props, $h->attrs, $h->values] === $before, 'form preview changes no properties, authority or device values');
$h = fixture(); $h->props['Lizenzcode'] = '';
rejected($h, true, 'missing license cannot be bypassed by calling the action directly');
rejected($h, true, 'missing license cannot be bypassed through the old action');
$h->RequestAction('FormNetzfahrplanSchalten', false);
check($h->fields['NetzfahrplanRegelungAktiv']['value'] === false && $h->calls === [], 'disable preview remains possible after license loss');
$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;
rejected($h, true, 'stored learning consent is required');
$h = fixture(); $h->attrs['PrognoseV4Serverstatus'] = '{}';
rejected($h, true, 'missing server workflow is not ready');
$h = fixture(); $h->attrs['PrognoseV4Serverstatus'] = json_encode(['receivedAt' => time() - 100,
'workflow' => ['planningReady' => true]], JSON_THROW_ON_ERROR);
rejected($h, true, 'stale server readiness cannot start control');
$h = fixture(); $h->attrs['PrognoseV4Einrichtungsfehler'] = 'Batteriezuordnung unvollstaendig.';
rejected($h, true, 'setup errors block the compatibility start action');
$h = fixture();
$h->RequestAction('FormPrognoseAktiv', false);
check(!$h->fields['NetzfahrplanRegelungAktiv']['enabled'] && $h->calls === [], 'unsaved learning disable prevents a new control request without dispatch');
check($h->fields['NetzfahrplanRegelungAktiv']['value'] === false, 'learning disable clears the form control request');
$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";
check(!$h->fields['NetzfahrplanRegelungAktiv']['enabled'] && $h->calls === [], 'unsaved learning enable does not grant stored readiness');
foreach (['license', 'setup', 'learning'] as $failure) {
$h = fixture(); $h->props['NetzfahrplanRegelungAktiv'] = true;
if ($failure === 'license') $h->props['Lizenzcode'] = '';
if ($failure === 'setup') $h->attrs['PrognoseV4Einrichtungsfehler'] = 'Zuordnung fehlt.';
if ($failure === 'learning') $h->props['PrognoseAktiv'] = false;
$h->RequestAction('FormPrognoseAktiv', false);
check($h->fields['NetzfahrplanRegelungAktiv']['enabled'] === true && $h->calls === [],
'already requested control remains switchable off after ' . $failure . ' failure');
}
$h = fixture(); $h->RequestAction('FormPrognoseAktiv', true);
check(str_contains($h->fields['PrognoseNetzfahrplanStatus']['caption'], 'Messdaten werden gesammelt.'), 'manager form displays the current workflow reason');
foreach (['PrognoseNetzfahrplanStart', 'PrognoseNetzfahrplanStop', 'PrognoseNetzfahrplanPruefen', 'NetzfahrplanV4EmpfangAktiv'] as $name) {
check(!isset($h->fields[$name]), 'form refresh no longer updates removed field: ' . $name);
}
echo "Total: $count checks. Real RequestAction and license checks; mocked IPS, no device calls.\n";