2]; } function IPS_InstanceExists(int $id): bool { return isset($GLOBALS['workflowInstances'][$id]); } function IPS_GetInstance(int $id): array { return $GLOBALS['workflowInstances'][$id]; } function IPS_GetProperty(int $id, string $key) { return $GLOBALS['workflowProperties'][$id][$key] ?? null; } function IPS_RequestAction(int $id, string $action, $value): bool { $GLOBALS['workflowActions'][] = ['id' => $id, 'action' => $action, 'value' => json_decode($value, true, 64, JSON_THROW_ON_ERROR)]; return !in_array($id, $GLOBALS['workflowReject'] ?? [], true); } final class WorkflowHarness { use ManagerPrognoseWorkflowTrait; use ManagerNetzfahrplanV4DatenTrait; public int $InstanceID = 900; public array $properties = []; public array $attributes = []; public array $values = ['Aktiv' => true, 'NetzfahrplanV4Aktivtest' => false]; public array $timers = []; public array $requests = []; public array $session = []; public array $operation = []; public array $actual = []; public bool $licensed = true; public ?string $httpError = null; public ?string $selectedDataset = null; public int $uiUpdates = 0; public function __construct() { $this->registrierePrognoseWorkflow(); $this->registriereNetzfahrplanV4Daten(); [$response, $id, $operation, $now] = \receiverFixture(); $GLOBALS['workflowTime'] = $now; $id = '11111111-2222-4333-8444-555555555555'; $response['installationId'] = $response['plan']['installationId'] = $id; $response['plan']['inputQuality'] = ['loadBasis' => 'base_load', 'accountingEvidenceId' => 'physical-map-v1']; $this->properties += ['PrognoseAktiv' => true, 'NetzleistungVariableID' => 101, 'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60, 'SDLAktiv' => false, 'SDLLeistungVariableID' => 0, 'AnlagenWechselrichter' => json_encode([ ['ID' => 'pv-wr', 'Typ' => 'pv', 'IstleistungVariableID' => 102, 'Istleistungsfaktor' => 1.0], ['ID' => 'battery-wr', 'Typ' => 'battery'], ], JSON_THROW_ON_ERROR), 'AnlagenBatterien' => json_encode([['ID' => 'battery', 'WechselrichterID' => 'battery-wr', 'Kopplung' => 'ac', 'LeistungVariableID' => 103, 'Leistungsfaktor' => 1.0]], JSON_THROW_ON_ERROR)]; $this->attributes += ['LizenzInstallationID' => $id, 'PrognoseInstallationsToken' => '']; $this->attributes['NetzfahrplanV4Empfang'] = json_encode(['response' => $response, 'receivedAt' => $now], JSON_THROW_ON_ERROR); $operation['localFeedback'] = ['b' => [ 'version' => 1, 'kind' => 'local_physical_feedback', 'installationId' => $id, 'managerId' => 900, 'assetId' => 'b', 'batteryInstanceId' => 901, 'checkedAt' => $now, 'sourceOldestAt' => $now, 'maxAgeSeconds' => 60, 'configHash' => str_repeat('a', 64), 'gridW' => $operation['gridW'], 'batteryW' => 0.0, 'estimated' => false, 'separatelyMeasured' => true, 'deviceReadConfirmed' => true, 'usableForTrial' => true, ]]; $this->operation = $operation; $this->actual = ['b' => 0.0]; $GLOBALS['workflowObjects'] = []; $GLOBALS['workflowInstances'] = [ 900 => ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => 'manager-module']], 901 => ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => 'battery-module']], 902 => ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => 'charger-module']], ]; foreach ([101 => 'Grid', 102 => 'PV', 103 => 'Battery'] as $source => $ident) { $GLOBALS['workflowObjects'][$source] = ['ParentID' => $source + 100, 'ObjectIdent' => $ident, 'ObjectType' => 2]; $GLOBALS['workflowInstances'][$source + 100] = ['InstanceStatus' => 102, 'ModuleInfo' => ['ModuleID' => NetzfahrplanV4Geraeteabruf::MODBUS_DEVICE]]; } $GLOBALS['workflowProperties'] = [901 => ['NetzfahrplanV4GeraeteWatchdogNachweis' => 'device-watchdog-reviewed-v1']]; $GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = []; } public function RegisterPropertyBoolean(string $name, bool $value): void { $this->properties[$name] ??= $value; } public function RegisterPropertyString(string $name, string $value): void { $this->properties[$name] ??= $value; } public function RegisterAttributeString(string $name, string $value): void { $this->attributes[$name] ??= $value; } public function RegisterAttributeInteger(string $name, int $value): void { $this->attributes[$name] ??= $value; } public function RegisterTimer(string $name, int $value, string $script): void { $this->timers[$name] = $value; } public function RegisterVariableString(string $name, string $caption, string $profile, int $position): void {} public function ReadPropertyBoolean(string $name): bool { return $this->properties[$name] ?? false; } public function ReadPropertyString(string $name): string { return $this->properties[$name] ?? ''; } public function ReadPropertyInteger(string $name): int { return $this->properties[$name] ?? 0; } public function ReadPropertyFloat(string $name): float { return $this->properties[$name] ?? 0.0; } public function ReadAttributeString(string $name): string { return $this->attributes[$name] ?? ''; } public function ReadAttributeInteger(string $name): int { return $this->attributes[$name] ?? 0; } public function WriteAttributeString(string $name, string $value): void { $this->attributes[$name] = $value; } public function WriteAttributeInteger(string $name, int $value): void { $this->attributes[$name] = $value; } public function SetTimerInterval(string $name, int $value): void { $this->timers[$name] = $value; } public function GetValue(string $name) { return $this->values[$name] ?? false; } public function SetValue(string $name, $value): void { $this->values[$name] = $value; } private function leseJsonAttribut(string $name): array { return json_decode($this->ReadAttributeString($name) ?: '{}', true, 64, JSON_THROW_ON_ERROR); } private function berechtigungLizenziert(string $capability): bool { return $this->licensed; } private function aktiveVerbraucherIDs(): array { return [901, 902]; } private function v4ManagerTestSession(): array { return $this->session; } private function netzfahrplanV4LokaleVorschauwerte(): array { return [$this->operation, $this->actual]; } private function aktualisierePrognoseBedienung(): void { ++$this->uiUpdates; } private function prognoseWorkflowHttp(string $suffix, array $body): array { $this->requests[] = ['suffix' => $suffix, 'body' => $body]; if ($this->httpError !== null) throw new RuntimeException($this->httpError); if ($suffix === '/setup') { return ['datasetId' => $body['dataset']['datasetId'], 'selectedDatasetId' => $this->selectedDataset ?? $body['dataset']['datasetId']]; } return ['status' => 'recorded']; } public function server(bool $ready, string $state = 'ready', ?int $at = null): void { $this->attributes['PrognoseV4Serverstatus'] = json_encode(['receivedAt' => $at ?? time(), 'workflow' => ['planningReady' => $ready, 'state' => $state, 'reason' => 'server-' . $state]], JSON_THROW_ON_ERROR); } public function state(): array { return $this->prognoseWorkflowZustand(); } public function prepare(): void { $this->vorbereitePrognoseWorkflow(); } public function tick(): void { $this->aktualisierePrognoseWorkflow(); } public function dataset(): string { return $this->prognoseV4Datensatz(); } public function mapping(): string { return $this->prognoseV4Messkonfiguration(); } public function correct(array &$allocation): ?array { return $this->korrigierePrognoseBatterie($allocation, [], true); } } $count = 0; function workflowCheck(bool $ok, string $label): void { global $count; if (!$ok) throw new RuntimeException('FAIL ' . $label); ++$count; echo 'PASS ' . $label . "\n"; } $h = new WorkflowHarness(); $h->server(false, 'collecting'); workflowCheck($h->state()['state'] === 'collecting' && !$h->state()['ready'], 'model not ready does not enable control'); $h->properties['NetzfahrplanRegelungAktiv'] = true; $a = ['Sollwerte' => [901 => 1200, 902 => 5000]]; $old = $a; workflowCheck($h->correct($a) === null && $a === $old, 'persisted control request cannot bypass missing model'); $h->properties['NetzfahrplanAktiv'] = true; $h->properties['NetzfahrplanV4RegeltestErlaubt'] = true; $h->properties['NetzfahrplanV4AktivtestErlaubt'] = true; workflowCheck(!$h->state()['ready'] && !$h->state()['controlActive'], 'old flags do not promote an unready new workflow'); $h = new WorkflowHarness(); $h->server(false, 'learning'); workflowCheck($h->state()['state'] === 'learning', 'learning state remains visible before readiness'); $h = new WorkflowHarness(); $h->server(true); workflowCheck($h->state()['state'] === 'ready' && $h->state()['ready'] && !$h->state()['controlActive'], 'ready model and physical guards still do not activate control'); $h->properties['NetzfahrplanRegelungAktiv'] = true; $h->attributes['PrognoseV4Regelstatus'] = json_encode(['controlActive' => true, 'at' => time()], JSON_THROW_ON_ERROR); workflowCheck($h->state()['state'] === 'active' && $h->state()['controlActive'], 'fresh local delivery status required for active state'); $h->attributes['PrognoseV4Regelstatus'] = json_encode(['controlActive' => true, 'at' => time() - 11], JSON_THROW_ON_ERROR); workflowCheck($h->state()['state'] === 'interrupted' && !$h->state()['controlActive'], 'stale active status expires'); $h->server(true, 'ready', time() - 91); workflowCheck(!$h->state()['ready'] && !$h->state()['controlActive'], 'stale server status blocks activation'); $h->server(true, 'ready', time() + 1); workflowCheck(!$h->state()['ready'], 'future server status blocked'); $h = new WorkflowHarness(); $h->server(true); $h->licensed = false; workflowCheck($h->state()['state'] === 'interrupted' && !$h->state()['ready'], 'missing licence blocks readiness'); $h->tick(); workflowCheck($h->requests === [] && $h->uiUpdates === 1, 'no unlicensed server traffic and visible UI refresh'); $h = new WorkflowHarness(); $h->server(true); $h->properties['PrognoseAktiv'] = false; $h->properties['NetzfahrplanRegelungAktiv'] = true; workflowCheck($h->state()['state'] === 'disabled' && !$h->state()['ready'], 'learning switch off prevents control'); $h->tick(); workflowCheck(count($h->requests) === 1 && $h->requests[0]['suffix'] === '/workflow' && !$h->requests[0]['body']['learningEnabled'] && !$h->requests[0]['body']['controlActive'], 'learning off is reported without setup or activation'); $h = new WorkflowHarness(); $h->server(true); $h->values['Aktiv'] = false; workflowCheck(!$h->state()['ready'], 'inactive manager blocks readiness'); $h = new WorkflowHarness(); $h->server(true); $h->session = ['sessionId' => 'existing']; workflowCheck(!$h->state()['ready'], 'separate commissioning session cannot run alongside ordinary control'); $h = new WorkflowHarness(); $h->server(true); $h->values['NetzfahrplanV4Aktivtest'] = true; workflowCheck(!$h->state()['ready'], 'old active test is not ordinary control permission'); $h = new WorkflowHarness(); $h->server(true); $GLOBALS['workflowProperties'][901]['NetzfahrplanV4GeraeteWatchdogNachweis'] = 'explicit_test_plant_watchdog_waiver'; workflowCheck(!$h->state()['ready'], 'historical watchdog waiver cannot activate ordinary control'); $h = new WorkflowHarness(); $h->server(true); $h->operation['localFeedback']['b']['checkedAt'] -= 3; workflowCheck(!$h->state()['ready'], 'stale physical feedback blocks ready despite healthy server'); $h = new WorkflowHarness(); $h->server(true); $h->attributes['PrognoseV4Einrichtungsfehler'] = 'mapping failed'; workflowCheck($h->state()['reason'] === 'mapping failed' && !$h->state()['ready'], 'setup error cannot preserve readiness'); $h = new WorkflowHarness(); $h->server(true); $h->httpError = 'synthetic HTTP failure'; $h->tick(); workflowCheck($h->state()['state'] === 'interrupted' && !$h->state()['ready'], 'connection failure prevents active state'); $h->httpError = null; $h->tick(); workflowCheck($h->attributes['PrognoseV4Verbindungsfehler'] === '' && $h->state()['ready'], 'successful report recovers connection status'); $h = new WorkflowHarness(); $h->prepare(); workflowCheck($h->attributes['PrognoseV4Einrichtungsfehler'] === '' && str_starts_with($h->dataset(), 'v4-'), 'ordinary metered plant automatically configured'); $automatic = $h->attributes['PrognoseV4Einrichtung']; $h->tick(); workflowCheck(array_column($h->requests, 'suffix') === ['/setup', '/workflow'], 'automatic dataset registered before workflow report'); $h->requests = []; $h->tick(); workflowCheck(array_column($h->requests, 'suffix') === ['/workflow'], 'unchanged setup throttled'); $h->properties['Netzleistungsfaktor'] = 1000.0; $h->prepare(); workflowCheck($h->attributes['PrognoseV4Einrichtung'] === $automatic && $h->attributes['PrognoseV4Einrichtungsfehler'] !== '', 'changed units hold while original setup remains preserved'); $h = new WorkflowHarness(); $GLOBALS['workflowInstances'][203]['ModuleInfo']['ModuleID'] = 'foreign-virtual-storage-module'; $h->prepare(); workflowCheck($h->attributes['PrognoseV4Einrichtungsfehler'] !== '' && $h->attributes['PrognoseV4Einrichtung'] === '{}', 'foreign library is not physical measurement evidence'); $h = new WorkflowHarness(); $h->prepare(); $automatic = $h->attributes['PrognoseV4Einrichtung']; $mapping = $h->mapping(); $h->properties['NetzfahrplanV4Messkonfiguration'] = $mapping; $h->properties['NetzfahrplanV4Datensatz'] = 'specialist-existing-v2'; $h->prepare(); $h->tick(); workflowCheck($h->mapping() === $mapping && $h->dataset() === 'specialist-existing-v2', 'custom mapping and dataset retained byte for byte'); workflowCheck($h->attributes['PrognoseV4Einrichtung'] === $automatic, 'historical automatic setup preserved for diagnosis'); workflowCheck(array_column($h->requests, 'suffix') === ['/workflow'], 'custom mapping never re-registers old automatic dataset'); $h->properties['NetzfahrplanV4Datensatz'] = ''; $h->prepare(); workflowCheck($h->attributes['PrognoseV4Einrichtungsfehler'] !== '', 'custom mapping without explicit dataset cannot reuse old automatic identity'); $h = new WorkflowHarness(); $h->prepare(); $h->selectedDataset = 'existing-server-choice'; $h->tick(); workflowCheck($h->attributes['PrognoseV4Verbindungsfehler'] !== '' && count($h->requests) === 1, 'server dataset choice is never silently overwritten or reported ready'); // Execute the actual Manager send block and sender method in a fake-only harness. $module = file_get_contents(__DIR__ . '/../../Manager/module.php'); $start = strpos($module, ' $this->v4ManagerTestUebergabe($ergebnis, $verbraucher, $synchronisiert);'); $end = strpos($module, ' $this->aktualisiereStoerungen($verbraucher, $stoerungen);', $start); $methodStart = strpos($module, ' private function sendeManagerdaten('); $methodEnd = strpos($module, "\n /**", $methodStart); if ($start === false || $end === false || $methodStart === false || $methodEnd === false) throw new RuntimeException('Manager source boundary changed'); $block = substr($module, $start, $end - $start); $method = substr($module, $methodStart, $methodEnd - $methodStart); class WorkflowSendBase { public int $InstanceID = 900; public array $attributes = ['LetzteSollwerte' => '{}']; public array $logs = []; protected function v4ManagerTestUebergabe(array &$allocation, array $consumers, bool $sync): void {} protected function korrigierePrognoseBatterie(array &$allocation, array $consumers, bool $sync): ?array { $allocation['Sollwerte'][901] = 2000; return ['batteryInstanceId' => 901, 'targetW' => 2000, 'physicalResponseVerified' => false]; } protected function leseJsonAttribut(string $name): array { return json_decode($this->attributes[$name], true, 64, JSON_THROW_ON_ERROR); } protected function WriteAttributeString(string $name, string $value): void { $this->attributes[$name] = $value; } protected function v4ManagerTestAbbrechen(string $reason): void {} protected function protokolliere(string $kind, array $data): void { $this->logs[] = $data; } } eval('namespace Belevo\\EnelixEMS; use RuntimeException; use Throwable; class WorkflowSendHarness extends WorkflowSendBase {' . 'public function run(array $ergebnis): array { $verbraucher=[]; $synchronisiert=true; $erneuern=false;' . '$betriebsart="PV"; $stoerungen=[];' . $block . 'return $ergebnis; }' . $method . '}'); $setup = new WorkflowHarness(); $sender = new WorkflowSendHarness(); $sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]); workflowCheck(array_column($GLOBALS['workflowActions'], 'id') === [902, 901], 'other commands sent before corrected battery'); workflowCheck($GLOBALS['workflowActions'][1]['value']['Sollleistung_W'] === 2000, 'successful handoff uses corrected battery target'); workflowCheck(json_decode($sender->attributes['PrognoseV4Regelstatus'], true)['controlActive'] === true, 'active only after successful sends'); $GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = [902]; $sender = new WorkflowSendHarness(); $sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]); $report = json_decode($sender->attributes['PrognoseV4Regelstatus'], true); workflowCheck(array_column($GLOBALS['workflowActions'], 'id') === [902, 901] && $GLOBALS['workflowActions'][1]['value']['Sollleistung_W'] === 1500, 'failed other command falls back to original ordinary battery target'); workflowCheck(!$report['controlActive'] && $report['fallbackTargetW'] === 1500, 'fallback is not reported as active V4'); workflowCheck(!array_key_exists(902, json_decode($sender->attributes['LetzteSollwerte'], true)), 'false device return does not advance accepted target'); workflowCheck(count($sender->logs) === 1 && $sender->logs[0]['InstanzID'] === 901, 'rejected handoff is not logged as successful'); $GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = [901]; $sender = new WorkflowSendHarness(); $sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]); workflowCheck(!json_decode($sender->attributes['PrognoseV4Regelstatus'], true)['controlActive'], 'false battery return cannot claim active control'); workflowCheck(!array_key_exists(901, json_decode($sender->attributes['LetzteSollwerte'], true)), 'false battery return retains previous accepted target'); $GLOBALS['workflowActions'] = []; $GLOBALS['workflowReject'] = []; $sender = new WorkflowSendHarness(); $sender->attributes['LetzteSollwerte'] = json_encode([901 => 2000, 902 => 5000], JSON_THROW_ON_ERROR); $sender->run(['Sollwerte' => [901 => 1500, 902 => 5000]]); workflowCheck(array_column($GLOBALS['workflowActions'], 'id') === [901], 'active battery target renewed even if value unchanged'); echo "TOTAL $count workflow checks passed; fake clocks, transport and Symcon calls only.\n";