feat(manager): integrate forecast controls and merge V4 development
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:
dh
2026-10-06 14:22:16 +00:00
207 changed files with 20340 additions and 11 deletions
+131
View File
@@ -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";