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:
@@ -10,6 +10,24 @@ final class BatterieModulstrukturTest extends TestCase
|
||||
{
|
||||
private const MODULPFAD = __DIR__ . '/../Batterie/';
|
||||
|
||||
public function testV4StoppUndSollwertVerwerfenBleibenGemeinsamErhalten(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
self::assertMatchesRegularExpression(
|
||||
"/case 'SichererZustand':\\s*\\\$this->V4BatterieTestStoppen\\(\\);"
|
||||
. "\\s*\\\$this->verwerfeSollwert\\(\\);\\s*\\\$this->schreibeRegister\\(0, true\\);/",
|
||||
$modul
|
||||
);
|
||||
self::assertSame(1, preg_match(
|
||||
'/private function ManagerdatenEmpfangenIntern\\(array \\$daten\\): void(.*?)private function aktualisiere\\(/s',
|
||||
$modul,
|
||||
$empfang
|
||||
));
|
||||
self::assertStringContainsString('$this->verwerfeSollwert();', $empfang[1]);
|
||||
self::assertStringContainsString('$this->aktualisiereIntern(false);', $empfang[1]);
|
||||
self::assertStringNotContainsString('$this->aktualisiere(', $empfang[1]);
|
||||
}
|
||||
|
||||
public function testModulIstVollstaendigUndMetadatenSindGueltig(): void
|
||||
{
|
||||
foreach (['module.json', 'form.json', 'module.php', 'README.md'] as $datei) {
|
||||
@@ -127,4 +145,17 @@ final class BatterieModulstrukturTest extends TestCase
|
||||
substr_count($manager, '{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}')
|
||||
);
|
||||
}
|
||||
|
||||
public function testBatterieformularHatKeinenSeparatenV4Bereich(): void
|
||||
{
|
||||
$modul = (string) file_get_contents(self::MODULPFAD . 'module.php');
|
||||
$formular = (string) file_get_contents(self::MODULPFAD . 'form.json');
|
||||
self::assertStringNotContainsString('V4 physische Rueckmeldung', $modul);
|
||||
self::assertStringNotContainsString('NetzfahrplanV4RueckmeldungKonfiguration', $formular);
|
||||
self::assertStringNotContainsString("'name'=>'NetzfahrplanV4RueckmeldungKonfiguration'", $modul);
|
||||
|
||||
$rueckmeldung = (string) file_get_contents(__DIR__ . '/../libs/BatterieNetzfahrplanV4RueckmeldungTrait.php');
|
||||
self::assertStringContainsString("RegisterPropertyString('NetzfahrplanV4RueckmeldungKonfiguration'", $rueckmeldung);
|
||||
self::assertStringContainsString('$this->konfiguriereV4Rueckmeldung();', $modul);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,9 +154,14 @@ final class ManagerModulstrukturTest extends TestCase
|
||||
"WriteAttributeString('LizenzInstallationID'",
|
||||
$treffer[0]
|
||||
);
|
||||
self::assertStringContainsString(
|
||||
"parent::ApplyChanges();\n\n \$this->initialisiereLizenzInstallationID();",
|
||||
$inhalt
|
||||
self::assertSame(1, preg_match(
|
||||
'/public function ApplyChanges\(\): void(.*?)public function /s',
|
||||
$inhalt,
|
||||
$applyChanges
|
||||
));
|
||||
self::assertMatchesRegularExpression(
|
||||
'/parent::ApplyChanges\(\);.*?\$this->initialisiereLizenzInstallationID\(\);/s',
|
||||
$applyChanges[1]
|
||||
);
|
||||
self::assertSame(1, substr_count(
|
||||
$inhalt,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once __DIR__ . '/../libs/NetzfahrplanV4Betriebsdaten.php';
|
||||
require_once __DIR__ . '/fixtures/NativeV4Scenarios.php';
|
||||
|
||||
final class NetzfahrplanV4BetriebsdatenTest extends TestCase
|
||||
{
|
||||
public function testOfflineInputScenarios(): void
|
||||
{
|
||||
self::assertCount(24, NativeV4Scenarios::run());
|
||||
}
|
||||
|
||||
public function testSenderCannotChangeLivePlanOrActuators(): void
|
||||
{
|
||||
$source = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
|
||||
foreach (['sendeManagerdaten(', 'aktualisiereNetzfahrplan(', '->regeln(', '->SetStatus(', "WriteAttributeString('Netzfahrplan',"] as $forbidden) {
|
||||
self::assertStringNotContainsString($forbidden, $source);
|
||||
}
|
||||
self::assertStringContainsString("RegisterPropertyBoolean('NetzfahrplanV4SchattenAktiv', false)", $source);
|
||||
self::assertStringContainsString('/prognosis/planner-v4/operation', $source);
|
||||
self::assertStringNotContainsString('/prognosis/schedule', $source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once __DIR__ . '/../libs/NetzfahrplanV4Bezugszaehler.php';
|
||||
require_once __DIR__ . '/fixtures/V4BezugszaehlerScenarios.php';
|
||||
|
||||
final class NetzfahrplanV4BezugszaehlerTest extends TestCase
|
||||
{
|
||||
public function testReadOnlyCounterSources(): void
|
||||
{
|
||||
self::assertCount(20, V4BezugszaehlerScenarios::run());
|
||||
}
|
||||
|
||||
public function testNoLegacyMeterOrActuatorFallback(): void
|
||||
{
|
||||
$source = file_get_contents(__DIR__ . '/../libs/NetzfahrplanV4Bezugszaehler.php');
|
||||
foreach (['SetValue(', 'IPS_SetProperty(', 'RequestAction(', 'AC_Set', '53476'] as $forbidden) {
|
||||
self::assertStringNotContainsString($forbidden, $source);
|
||||
}
|
||||
$trait = file_get_contents(__DIR__ . '/../libs/ManagerNetzfahrplanV4Trait.php');
|
||||
self::assertStringNotContainsString("'NetzbezugEnergieVariableID'", $trait);
|
||||
self::assertStringContainsString("RegisterPropertyString('NetzfahrplanV4BezugszaehlerQuellen', '[]')", $trait);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
final class NetzfahrplanV4BilanzierungTest extends TestCase
|
||||
{
|
||||
public function testIsolatedAccountingChecks(): void
|
||||
{
|
||||
$pipes=[];
|
||||
$p=proc_open([PHP_BINARY,__DIR__.'/V4Accounting/checks.php'],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
|
||||
self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);fclose($pipes[2]);self::assertSame(0,proc_close($p),$out.$err);self::assertSame('',$err);
|
||||
self::assertStringContainsString('TOTAL 42 checks passed',$out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NetzfahrplanV4DatenTest extends TestCase
|
||||
{
|
||||
public function testApplicationDataPathWithIsolatedRuntime(): void
|
||||
{
|
||||
$pipes=[];
|
||||
$process=proc_open([PHP_BINARY,__DIR__.'/V4ApplicationData/checks.php'],
|
||||
[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
|
||||
self::assertIsResource($process);fclose($pipes[0]);
|
||||
$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);fclose($pipes[2]);
|
||||
self::assertSame(0,proc_close($process),$out.$err);
|
||||
self::assertStringContainsString('TOTAL 27 application-data checks passed',$out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NetzfahrplanV4EmpfangTest extends TestCase
|
||||
{
|
||||
/** @dataProvider scripts */
|
||||
public function testIsolatedReceiverChecks(string $script): void
|
||||
{
|
||||
$pipes = [];
|
||||
$process = proc_open([PHP_BINARY, __DIR__ . '/V4Receiver/' . $script],
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
self::assertIsResource($process);
|
||||
fclose($pipes[0]);
|
||||
$out = stream_get_contents($pipes[1]);$err = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);fclose($pipes[2]);
|
||||
self::assertSame(0, proc_close($process), $out . $err);
|
||||
self::assertStringContainsString('Total:', $out);
|
||||
}
|
||||
|
||||
public function scripts(): array
|
||||
{
|
||||
return [['protocol_checks.php'], ['receiver_checks.php']];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
final class NetzfahrplanV4MessaufnahmeTest extends TestCase
|
||||
{
|
||||
/** @dataProvider scripts */
|
||||
public function testPassiveCapture(string $name): void
|
||||
{
|
||||
$pipes=[];$p=proc_open([PHP_BINARY,__DIR__.'/V4DataCapture/'.$name],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
|
||||
self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);
|
||||
self::assertSame(0,proc_close($p),$out.$err);self::assertStringContainsString('TOTAL',$out);
|
||||
}
|
||||
public function scripts(): array { return [['checks.php'],['native_checks.php']]; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
final class NetzfahrplanV4MesssichtTest extends TestCase
|
||||
{
|
||||
/** @dataProvider scripts */
|
||||
public function testIsolatedViews(string $script): void
|
||||
{
|
||||
$p=proc_open([PHP_BINARY,__DIR__.'/V4SeparatedObservation/'.$script],[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
|
||||
self::assertIsResource($p);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);
|
||||
self::assertSame(0,proc_close($p),$out.$err);self::assertStringContainsString('TOTAL ',$out);self::assertSame('',$err);
|
||||
}
|
||||
public function scripts(): array {return [['checks.php'],['native_checks.php']];}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NetzfahrplanV4RegeltestTest extends TestCase
|
||||
{
|
||||
public function testIsolatedControlledTrialStateMachines(): void
|
||||
{
|
||||
$pipes = [];
|
||||
$process = proc_open([PHP_BINARY, __DIR__ . '/V4ControlTrial/checks.php'],
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
self::assertIsResource($process);
|
||||
fclose($pipes[0]);
|
||||
$out = stream_get_contents($pipes[1]);$err = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);fclose($pipes[2]);
|
||||
self::assertSame(0, proc_close($process), $out . $err);
|
||||
self::assertSame('', $err);
|
||||
self::assertStringContainsString('TOTAL 96 checks passed.', $out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NetzfahrplanV4RueckmeldungTest extends TestCase
|
||||
{
|
||||
private function checkScript(string $name): void
|
||||
{
|
||||
$pipes=[];
|
||||
$p=proc_open([PHP_BINARY,__DIR__.'/V4Feedback/'.$name],
|
||||
[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
|
||||
self::assertIsResource($p);fclose($pipes[0]);
|
||||
$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);fclose($pipes[2]);
|
||||
self::assertSame(0,proc_close($p),$out.$err);
|
||||
self::assertSame('',$err);self::assertStringContainsString('TOTAL ',$out);
|
||||
}
|
||||
public function testPhysicalFeedback(): void {$this->checkScript('checks.php');}
|
||||
public function testConfirmedDeviceReads(): void {$this->checkScript('device_read_checks.php');}
|
||||
public function testActualReceiverIntegration(): void {$this->checkScript('receiver_checks.php');}
|
||||
public function testActualFallbackWrapper(): void {$this->checkScript('fallback_checks.php');}
|
||||
public function testMessageWithoutVisibleDiagnostics(): void {$this->checkScript('message_checks.php');}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__.'/../../libs/NetzfahrplanV4Bilanzierung.php';
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Bilanzierung as B;
|
||||
$count=0;
|
||||
function check(bool $yes,string $name): void {global $count;if(!$yes)throw new RuntimeException('FAIL '.$name);$count++;echo 'PASS '.$name."\n";}
|
||||
function fails(callable $f,string $name):void {try{$f();}catch(InvalidArgumentException $e){check(true,$name);return;}throw new RuntimeException('FAIL expected refusal: '.$name);}
|
||||
function fixture():array {
|
||||
$now=1790950000;$s=[];$m=[];
|
||||
foreach(['grid'=>1000.,'pv'=>5000.,'physical_storage'=>3000.,'controlled_virtual'=>2000.,'external_virtual'=>1000.] as $role=>$value){
|
||||
$id=101+count($s);$s[]=['key'=>$role,'role'=>$role,'variableId'=>$id,'parentId'=>10,'ident'=>$role,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>[]];
|
||||
$m[$id]=['value'=>$value,'updated'=>$now,'parentID'=>10,'ident'=>$role];
|
||||
}
|
||||
$s[3]['dependsOn']=['physical_storage'];$s[4]['dependsOn']=['physical_storage'];
|
||||
return [['version'=>1,'sources'=>$s,'maxSkewSeconds'=>30,'splitToleranceW'=>100.],$m,$now];
|
||||
}
|
||||
function take(array $c,array $m,int $now):array{return B::aufnehmen($c,static fn($id)=>$m[$id],$now);}
|
||||
[$c,$m,$n]=fixture();$r=take($c,$m,$n);
|
||||
check($r['baseLoadCandidateW']===3000.,'subtract physical battery once, not virtual twice');
|
||||
check($r['externalEffectW']===1000. && $r['allocationResidualW']===0.,'SDL kept outside household');
|
||||
check($r['reconstructedGridW']===1000. && $r['arithmeticBalanceErrorW']===0.,'signed PCC balance closes');
|
||||
check($r['controlEligible']===false && !$r['meteringBoundaryVerified'],'algebra never grants control');
|
||||
check(!$r['forecastHistoryMigrated'],'old forecasts never relabelled');
|
||||
$m[101]['value']=-5000.;$m[103]['value']=-3000.;$m[104]['value']=-2000.;$m[105]['value']=-1000.;$r=take($c,$m,$n);
|
||||
check($r['baseLoadCandidateW']===3000. && $r['externalEffectW']===-1000.,'discharge and export signs correct');
|
||||
[$c,$m,$n]=fixture();$m[103]['value']=2000.;$m[104]['value']=42000.;$m[105]['value']=-40000.;$r=take($c,$m,$n);
|
||||
check($r['allocationResidualW']===0. && $r['baseLoadCandidateW']===4000.,'opposed virtual accounts are not two physical loads');
|
||||
[$c,$m,$n]=fixture();$m[104]['value']=0.;$m[105]['value']=0.;$r=take($c,$m,$n);
|
||||
check($r['externalEffectW']===3000. && $r['baseLoadCandidateW']===3000.,'held virtual values cannot contaminate base load');
|
||||
check(in_array('virtual_physical_allocation_mismatch',$r['issues'],true),'split discrepancy explicit');
|
||||
[$c,$m,$n]=fixture();$m[103]['updated']=$n-120;$r=take($c,$m,$n);
|
||||
check(in_array('stale_origin:physical_storage',$r['issues'],true),'old physical reading rejected despite new acquisition');
|
||||
check(in_array('stale_origin:controlled_virtual',$r['issues'],true),'fresh virtual timestamp cannot mask old physical input');
|
||||
check($r['sources']['controlled_virtual']['oldestOriginAt']===$n-120,'original dependency timestamp preserved');
|
||||
check($r['status']==='quality_hold','bad quality remains a diagnostic, not a training sample');
|
||||
[$c,$m,$n]=fixture();$m[102]['updated']=$n-31;$r=take($c,$m,$n);check(in_array('source_time_skew',$r['issues'],true),'skew rejected separately from age');
|
||||
$m[102]['updated']=$n+1;$r=take($c,$m,$n);check(in_array('future_source:pv',$r['issues'],true),'future timestamp rejected');
|
||||
[$c,$m,$n]=fixture();foreach($m as &$v)$v['value']=0.;unset($v);$r=take($c,$m,$n);check($r['status']==='consistent_candidate' && $r['baseLoadCandidateW']===0.,'true all-zero observation accepted without fake history');
|
||||
[$c,$m,$n]=fixture();$m[101]['value']=-6000.;$r=take($c,$m,$n);check($r['baseLoadCandidateW']===-4000. && in_array('negative_base_load',$r['issues'],true),'negative residual not silently clipped to zero');
|
||||
[$c,$m,$n]=fixture();$m[102]['value']=-10.;check(in_array('negative_unsigned_source:pv',take($c,$m,$n)['issues'],true),'PV sign validity');
|
||||
[$c,$m,$n]=fixture();$m[103]['value']=-3000.;$c['sources'][2]['factorToW']=-1.;check(take($c,$m,$n)['baseLoadCandidateW']===3000.,'explicit vendor sign conversion');
|
||||
[$c,$m,$n]=fixture();$m[101]['value']=1.;$c['sources'][0]['factorToW']=1000.;check(take($c,$m,$n)['baseLoadCandidateW']===3000.,'kW to W factor applied once');
|
||||
[$c,$m,$n]=fixture();$c['sources'][]=['key'=>'boiler','role'=>'flexible_load','variableId'=>200,'parentId'=>10,'ident'=>'boiler','factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>[]];$m[200]=['value'=>500.,'updated'=>$n,'parentID'=>10,'ident'=>'boiler'];$r=take($c,$m,$n);check($r['baseLoadCandidateW']===2500. && $r['reconstructedGridW']===1000.,'future flexible load excluded exactly once');
|
||||
foreach([true,'1000',NAN,INF] as $bad){[$c,$m,$n]=fixture();$m[101]['value']=$bad;fails(fn()=>take($c,$m,$n),'invalid numeric input '.gettype($bad));}
|
||||
[$c,$m,$n]=fixture();$m[101]['parentID']=99;fails(fn()=>take($c,$m,$n),'different source parent rejected');
|
||||
[$c,$m,$n]=fixture();$m[101]['ident']='other';fails(fn()=>take($c,$m,$n),'different source ident rejected');
|
||||
[$c,$m,$n]=fixture();$c['sources'][]=$c['sources'][0];fails(fn()=>take($c,$m,$n),'duplicate physical source rejected');
|
||||
[$c,$m,$n]=fixture();$c['sources'][3]['dependsOn']=['absent'];fails(fn()=>take($c,$m,$n),'missing origin rejected');
|
||||
[$c,$m,$n]=fixture();$c['sources'][2]['dependsOn']=['controlled_virtual'];fails(fn()=>take($c,$m,$n),'dependency loop rejected');
|
||||
[$c,$m,$n]=fixture();$a=take($c,$m,$n)['mappingId'];$c['sources']=array_reverse($c['sources']);check(take($c,$m,$n)['mappingId']===$a,'source order does not change mapping identity');
|
||||
$c['sources'][0]['factorToW']=2.;check(take($c,$m,$n)['mappingId']!==$a,'factor changes invalidate old mapping identity');
|
||||
[$c,$m,$n]=fixture();$calls=[];
|
||||
fails(function()use($c,$m,$n,&$calls){B::aufnehmen($c,function($id)use($m,&$calls){$calls[$id]=($calls[$id]??0)+1;$v=$m[$id];if($calls[$id]>1)$v['value']++;return $v;},$n);},'same-second concurrent value change rejected');
|
||||
$source=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Bilanzierung.php');check(!preg_match('/\b(?:SetValue|IPS_RequestAction|RequestAction|curl_exec|IPS_SetProperty|AC_SetLoggingStatus)\s*\(/',$source),'adapter has no runtime or external write calls');
|
||||
[$c,$m,$n]=fixture();$integer=$c;$integer['splitToleranceW']=100;
|
||||
check(B::konfigurieren($c)===B::konfigurieren($integer),'integer and float tolerance normalize identically');
|
||||
check(take($c,$m,$n)['mappingId']===take($integer,$m,$n)['mappingId'],'equal tolerance gives identical mapping hash');
|
||||
$roundtrip=json_decode(json_encode($c,JSON_THROW_ON_ERROR),true,64,JSON_THROW_ON_ERROR);
|
||||
check(take($roundtrip,$m,$n)['mappingId']===take($c,$m,$n)['mappingId'],'plain JSON property roundtrip preserves mapping');
|
||||
$normalized=B::konfigurieren($c);check(B::konfigurieren($normalized)===$normalized,'normalization remains idempotent');
|
||||
check(is_int($integer['splitToleranceW']) && is_float($c['splitToleranceW']),'input configurations are not mutated');
|
||||
$changed=$c;$changed['splitToleranceW']=101;
|
||||
check(take($changed,$m,$n)['mappingId']!==take($c,$m,$n)['mappingId'],'real tolerance change still changes identity');
|
||||
foreach([true,'100'] as $invalid){$bad=$c;$bad['splitToleranceW']=$invalid;fails(fn()=>B::konfigurieren($bad),'non-numeric tolerance remains rejected '.gettype($invalid));}
|
||||
echo "TOTAL $count checks passed; synthetic snapshots only.\n";
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__.'/../../libs/NetzfahrplanV4Datenarchiv.php';
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Datenarchiv as A;
|
||||
$n=0;$now=1791028800;
|
||||
function checkArchive(bool $x,string $why):void {global $n;if(!$x)throw new RuntimeException('FAIL '.$why);$n++;echo 'PASS '.$why."\n";}
|
||||
function cleanupArchive(string $dir):void {foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($dir);}
|
||||
$base=sys_get_temp_dir().'/v4-archive-'.bin2hex(random_bytes(6));mkdir($base,0700);
|
||||
try {
|
||||
$old='raw-'.gmdate('Ymd',$now-10*86400).'.jsonl';$recent='raw-'.gmdate('Ymd',$now-2*86400).'.jsonl';$day='raw-'.gmdate('Ymd',$now).'.jsonl';
|
||||
$raw="{\"sample\":1}\n{\"sample\":2}\n";file_put_contents($base.'/'.$old,$raw);file_put_contents($base.'/'.$recent,$raw);file_put_contents($base.'/'.$day,$raw);
|
||||
checkArchive(A::run($base,[],$now)['archived']===0,'no acknowledgement means no archival');
|
||||
$r=A::run($base,['day'=>$old,'offset'=>5],$now);checkArchive($r['archived']===0&&is_file($base.'/'.$old),'partially acknowledged day retained');
|
||||
$r=A::run($base,['day'=>$day,'offset'=>5],$now);checkArchive($r['archived']===1&&!is_file($base.'/'.$old),'only completely acknowledged old day retired');
|
||||
$files=glob($base.'/archive/*.gz');checkArchive(count($files)===1&&gzdecode(file_get_contents($files[0]))===$raw,'compressed original verified byte for byte');
|
||||
checkArchive(is_file($base.'/'.$recent)&&is_file($base.'/'.$day),'recent and current outbox preserved');
|
||||
checkArchive(A::run($base,['day'=>$day,'offset'=>5],$now)['archived']===0,'repeat is idempotent');
|
||||
file_put_contents($base.'/'.$old,$raw);file_put_contents($files[0],gzencode('corrupt'));
|
||||
try {A::run($base,['day'=>$day,'offset'=>5],$now);throw new LogicException('expected refusal');} catch(RuntimeException $e) {checkArchive(is_file($base.'/'.$old),'archive corruption never loses original');}
|
||||
unlink($files[0]);$lock=fopen($base.'/.writer.lock','c+b');flock($lock,LOCK_EX);
|
||||
checkArchive(A::run($base,['day'=>$day,'offset'=>5],$now)['status']==='writer_busy','archive serializes with actual capture writer');flock($lock,LOCK_UN);fclose($lock);
|
||||
foreach(range(11,15)as$d)file_put_contents($base.'/raw-'.gmdate('Ymd',$now-$d*86400).'.jsonl',$raw);
|
||||
checkArchive(A::run($base,['day'=>$day,'offset'=>5],$now)['archived']===2,'bounded archive work per invocation');
|
||||
$text=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Datenarchiv.php');checkArchive(!preg_match('/\b(?:RequestAction|IPS_SetProperty|curl_exec)\s*\(/',$text),'archive has no control or network calls');
|
||||
echo "TOTAL $n archive checks passed; only temporary test data.\n";
|
||||
} finally {cleanupArchive($base);}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace Belevo\EnelixEMS;
|
||||
|
||||
require_once __DIR__.'/../../libs/ManagerNetzfahrplanV4DatenTrait.php';
|
||||
|
||||
$checks=0;$clock=1791028800;$sent=[];$mode='ok';$sensor=[];$locks=[];
|
||||
$base=sys_get_temp_dir().'/v4-app-data-'.bin2hex(random_bytes(5));mkdir($base,0700);
|
||||
function check(bool $v,string $name):void {global $checks;if(!$v)throw new \RuntimeException('FAIL '.$name);$checks++;echo 'PASS '.$name."\n";}
|
||||
function time():int {global $clock;return $clock;}
|
||||
function IPS_GetKernelDir():string {global $base;return $base.'/';}
|
||||
function IPS_SemaphoreEnter($key,$timeout):bool {global $locks;if($locks[$key]??false)return false;$locks[$key]=true;return true;}
|
||||
function IPS_SemaphoreLeave($key):void {global $locks;$locks[$key]=false;}
|
||||
function IPS_VariableExists($id):bool {global $sensor;return isset($sensor[$id]);}
|
||||
function IPS_GetVariable($id):array {global $sensor;return ['VariableType'=>2,'VariableUpdated'=>$sensor[$id]['updated'],'VariableChanged'=>$sensor[$id]['changed']];}
|
||||
function IPS_GetObject($id):array {return ['ParentID'=>200,'ObjectIdent'=>'s'.$id];}
|
||||
function GetValue($id) {global $sensor;return $sensor[$id]['value'];}
|
||||
foreach(['CURLOPT_POST','CURLOPT_RETURNTRANSFER','CURLOPT_FOLLOWLOCATION','CURLOPT_CONNECTTIMEOUT','CURLOPT_TIMEOUT','CURLOPT_SSL_VERIFYPEER','CURLOPT_SSL_VERIFYHOST','CURLOPT_HTTPHEADER','CURLOPT_POSTFIELDS','CURLINFO_HTTP_CODE'] as $i=>$k)if(!defined($k))define($k,$i+100);
|
||||
function curl_init($url){$o=new \stdClass();$o->url=$url;return $o;}
|
||||
function curl_setopt_array($h,$options){$h->options=$options;return true;}
|
||||
function curl_exec($h){global $sent,$mode;$p=json_decode($h->options[CURLOPT_POSTFIELDS],true);$sent[]=$p;
|
||||
return json_encode(['status'=>'stored','datasetId'=>$p['datasetId'],'acceptedThrough'=>$mode==='wrong_ack'?'2000-01-01T00:00:00+00:00':end($p['records'])['capturedAt']]);}
|
||||
function curl_getinfo($h,$info){global $mode;return $mode==='429'?429:200;}
|
||||
function curl_close($h):void {}
|
||||
class Lizenzpruefung {const NETZFAHRPLAN='test-only';}
|
||||
class FakeManager {
|
||||
use ManagerNetzfahrplanV4DatenTrait;
|
||||
public int $InstanceID=17004;public array $p=[],$a=[],$vars=[],$timers=[];public bool $licensed=true;
|
||||
public function boot():void {$this->registriereNetzfahrplanV4Daten();}
|
||||
public function configure():void {$this->konfiguriereNetzfahrplanV4Daten();}
|
||||
public function folder():string {return $this->v4DatenVerzeichnis();}
|
||||
public function send():array {return $this->v4DatenUebertragen($this->folder());}
|
||||
public function RegisterPropertyBoolean($k,$v){$this->p[$k]??=$v;}
|
||||
public function RegisterPropertyString($k,$v){$this->p[$k]??=$v;}
|
||||
public function RegisterAttributeString($k,$v){$this->a[$k]??=$v;}
|
||||
public function RegisterAttributeInteger($k,$v){$this->a[$k]??=$v;}
|
||||
public function RegisterVariableString($k,...$args){$this->vars[$k]='';}
|
||||
public function RegisterTimer($k,$v,$code){$this->timers[$k]=$v;}
|
||||
public function SetTimerInterval($k,$v){$this->timers[$k]=$v;}
|
||||
public function ReadPropertyBoolean($k){return $this->p[$k];}
|
||||
public function ReadPropertyString($k){return $this->p[$k];}
|
||||
public function ReadAttributeString($k){return $this->a[$k];}
|
||||
public function ReadAttributeInteger($k){return $this->a[$k];}
|
||||
public function WriteAttributeString($k,$v){$this->a[$k]=$v;}
|
||||
public function WriteAttributeInteger($k,$v){$this->a[$k]=$v;}
|
||||
public function SetValue($k,$v){if($k!=='NetzfahrplanV4Datenstatus')throw new \RuntimeException('Unexpected variable write');$this->vars[$k]=$v;}
|
||||
private function berechtigungLizenziert($k){return $this->licensed;}
|
||||
}
|
||||
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,
|
||||
'reportedInventorySha256'=>str_repeat('a',64),'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]],'extraSources'=>[]];
|
||||
foreach(['grid','pv','physical_storage','controlled_virtual','external_virtual'] as $i=>$role){$id=101+$i;
|
||||
$c['accounting']['sources'][]=['key'=>'s'.$id,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>'s'.$id,'factorToW'=>1,'maxAgeSeconds'=>60,'dependsOn'=>[]];
|
||||
$sensor[$id]=['value'=>[3000,5000,2000,2000,0][$i],'updated'=>$clock,'changed'=>$clock];}
|
||||
$m=new FakeManager();$m->boot();$m->a['LizenzInstallationID']=$c['installationId'];$m->a['PrognoseInstallationsToken']='synthetic-not-a-real-token';
|
||||
try {
|
||||
$m->ErfasseNetzfahrplanV4Daten();check($sent===[],'disabled by default makes no network request');
|
||||
$m->p['NetzfahrplanV4MessdatenAktiv']=true;$m->p['NetzfahrplanV4Datensatz']='physical-v1';$m->p['NetzfahrplanV4Messkonfiguration']=json_encode($c);
|
||||
$m->configure();check($m->timers['NetzfahrplanV4DatenErfassen']===30000,'only application acquisition timer enabled');
|
||||
$m->ErfasseNetzfahrplanV4Daten();check(count($sent)===1,'native capture delivered to existing device API');
|
||||
$cursor=$m->a['NetzfahrplanV4DatenCursor'];check(json_decode($cursor,true)['offset']>0,'matching ack commits durable byte cursor');
|
||||
check($sent[0]['records'][0]['raw']['s101']['value']===3000,'physical reading retained');
|
||||
check($sent[0]['records'][0]['raw']['s101']['sourceUpdatedAt']===$clock,'original timestamp retained');
|
||||
check(!str_contains(json_encode($sent),'synthetic-not-a-real-token'),'token never included in data payload');
|
||||
check($sent[0]['records'][0]['controlEligible']===false,'data delivery is not actuator permission');
|
||||
$expected=hash('sha256',json_encode(NetzfahrplanV4Messaufnahme::configuration($c),JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
|
||||
check($sent[0]['records'][0]['mappingSha256']===$expected,'property roundtrip preserves approved mapping identity');
|
||||
check(json_decode($m->a['NetzfahrplanV4LetzteDatenquittung'],true)['acceptedThrough']===end($sent[0]['records'])['capturedAt'],'successful acknowledgement persisted independently');
|
||||
$clock+=30;$m->ErfasseNetzfahrplanV4Daten();check(count($sent)===1,'30-second collection does not double request rate');
|
||||
check($m->a['NetzfahrplanV4DatenCursor']===$cursor,'unsent rows remain behind cursor');
|
||||
check(isset(json_decode($m->vars['NetzfahrplanV4Datenstatus'],true)['lastAcknowledgement']['acceptedThrough']),'scheduled status retains last successful receipt');
|
||||
$clock+=30;$mode='429';$m->ErfasseNetzfahrplanV4Daten();check($m->a['NetzfahrplanV4DatenCursor']===$cursor,'429 never advances acknowledgement cursor');
|
||||
check(json_decode($m->vars['NetzfahrplanV4Datenstatus'],true)['delivery']['dataRetained'],'offline data retained');
|
||||
check(json_decode($m->vars['NetzfahrplanV4Datenstatus'],true)['delivery']['httpStatus']===429,'safe HTTP error code visible without credentials');
|
||||
$mode='wrong_ack';$clock+=120;$m->ErfasseNetzfahrplanV4Daten();check($m->a['NetzfahrplanV4DatenCursor']===$cursor,'wrong ack timestamp rejected');
|
||||
$mode='ok';$clock+=240;$m->ErfasseNetzfahrplanV4Daten();check($m->a['NetzfahrplanV4DatenCursor']!==$cursor,'later success sends the preserved backlog');
|
||||
check(count(end($sent)['records'])===4,'all previously unacknowledged rows included once');
|
||||
$file=glob($m->folder().'/raw-*.jsonl')[0];$count=count(file($file));check($count===5,'raw journal never cleared by upload');
|
||||
$lastCursor=$m->a['NetzfahrplanV4DatenCursor'];file_put_contents($file,'incomplete',FILE_APPEND);
|
||||
check($m->send()['status']==='up_to_date','in-progress tail not transmitted');check($m->a['NetzfahrplanV4DatenCursor']===$lastCursor,'partial tail not acknowledged');
|
||||
// Only temporary test journal: turn the prior incomplete tail into an additional complete queued record.
|
||||
$buf=file_get_contents($file);$buf=substr($buf,0,-strlen('incomplete'));
|
||||
$extra=$sent[0]['records'][0];$clock+=60;$extra['capturedAt']=gmdate('c',$clock);$extra['captureStartedAt']=$extra['capturedAt'];
|
||||
file_put_contents($file,$buf.json_encode($extra)."\n".'crash-tail');
|
||||
$clock+=60;$beforeCount=count($sent);$m->ErfasseNetzfahrplanV4Daten();
|
||||
$err=json_decode($m->vars['NetzfahrplanV4Datenstatus'],true);
|
||||
check($err['status']==='capture_error'&&$err['delivery']['status']==='acknowledged','capture failure does not deadlock pending delivery');
|
||||
check(count($sent)===$beforeCount+1&&str_ends_with(file_get_contents($file),'crash-tail'),'complete prefix sent while original incomplete tail retained');
|
||||
$m->p['NetzfahrplanV4MessdatenAktiv']=false;$m->configure();check($m->timers['NetzfahrplanV4DatenErfassen']===0,'disable affects acquisition only');
|
||||
check(count($m->vars)===1,'only own manager data-status variable written');
|
||||
$text=file_get_contents(__DIR__.'/../../libs/ManagerNetzfahrplanV4DatenTrait.php');
|
||||
check(!preg_match('/\b(?:RequestAction\(|IPS_ApplyChanges\(|MC_Reload\(|schreibeRegister\()/',$text),'data path contains no actuator/module reload calls');
|
||||
echo "TOTAL $checks application-data checks passed; mocked IPS and HTTP only.\n";
|
||||
} finally {
|
||||
foreach(new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base,\FilesystemIterator::SKIP_DOTS),\RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($base);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// Included only by the offline harness. No device or network calls.
|
||||
[$m,$b,$s]=fixture();start($m,$s);$m->op['gridW']=3000.;tick($m);
|
||||
check(end($b->writes)===-3000,'discharge sign and current net load compensation');
|
||||
$b->measurements['Ladezustand']=15.;$b->watchdog();check(end($b->writes)===0,'reserve stops an active discharge independently');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$m->op['gridW']=3000.;tick($m);$b->state['HystereseAktiv']=true;$b->watchdog();
|
||||
check(end($b->writes)===0,'local discharge hysteresis overrides previous command');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$m->op['gridW']=3000.;tick($m);$b->measurements['MaxEntladen']=500.;$b->watchdog();
|
||||
check(end($b->writes)===-500,'discharge availability decrease is immediate');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=2.;$b->watchdog();
|
||||
check(!$b->active()&&end($b->writes)===0,'technical minimum violation aborts rather than inventing SOC');
|
||||
[$m,$b,$s]=fixture();$s['maxChargeW']=1000.;start($m,$s);tick($m);
|
||||
check(end($b->writes)===1000,'independent local cap can be stricter than server cap');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$b->attrs['LetzterSollwert']=500;$b->attrs['AenderungFreigabeAb']=$clock+4;tick($m);
|
||||
check(end($b->writes)===500,'switch lock holds power increase');
|
||||
advance(5);tick($m);check(end($b->writes)===3000,'power increase only after switching lock');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$b->attrs['LetzterSollwert']=500;$b->attrs['AenderungFreigabeAb']=$clock+4;$m->op['gridW']=3000.;tick($m);
|
||||
check(end($b->writes)===0,'sign reversal first passes zero during switching lock');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$b->attrs['LetzterSollwert']=3000;$b->attrs['AenderungFreigabeAb']=$clock+4;$b->measurements['MaxLaden']=100.;tick($m);
|
||||
check(end($b->writes)===100,'safety reduction not delayed by switching lock');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=15.;$b->props['NachladenMitMaximalleistung']=false;$b->props['MaximaleNachladeleistung']=200;$b->watchdog();
|
||||
check(end($b->writes)===200,'configured reserve-recovery charging limit remains enforced');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->licensed=false;tick($m);
|
||||
check(!$m->active()&&!$b->active()&&end($b->writes)===0,'license withdrawal revokes both software sessions');
|
||||
[$m,$b,$s]=fixture();$s['durationSeconds']=30;start($m,$s);tick($m);advance(30);tick($m);
|
||||
check(!$m->active()&&!$b->active()&&end($b->writes)===0,'local shorter test expiration respected');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->vars['Aktiv']=false;tick($m);
|
||||
check(!$m->active()&&!$b->active(),'manual manager disable ends trial');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->vars['Aktiv']=false;$b->watchdog();
|
||||
check(!$b->active()&&end($b->writes)===0,'manual battery disable independently ends trial');
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);
|
||||
$before=$b->cachedFeedback();$reads=$GLOBALS['deviceReads'];
|
||||
$GLOBALS['locks']['ENELIX.V4.Feedback.44234']=true;
|
||||
$b->watchdogOnly();
|
||||
check($b->active() && $GLOBALS['deviceReads']===$reads,'busy sampler cannot block watchdog or trigger device I/O');
|
||||
check($b->cachedFeedback()===$before,'cache preserves original confirmed timestamps and values');
|
||||
$b->V4BatterieTestStoppen();
|
||||
check(!$b->active() && end($b->writes)===0,'stop reaches zero while the independent sampler is busy');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);advance(3);$b->watchdogOnly();
|
||||
check(!$b->active() && end($b->writes)===0,'stale sample revokes the command after two seconds');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$GLOBALS['mono']+=2.01;$b->watchdogOnly();
|
||||
check(!$b->active() && end($b->writes)===0,'monotonic sample age blocks frozen wall clock');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$GLOBALS['clock']--;$b->watchdogOnly();
|
||||
check(!$b->active() && end($b->writes)===0,'future cache timestamp is never made current');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$deviceReadOk=false;$b->sample();
|
||||
rejects(fn()=>$b->cachedFeedback(),'failed acquisition invalidates a still-young cached sample');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$reads=$GLOBALS['deviceReads'];
|
||||
$c=$m->command();$c['sequence']++;$b->V4BatterieTestBefehl(json_encode($c));
|
||||
check($b->active() && $GLOBALS['deviceReads']===$reads,'command independently validates cache and sends without transport I/O');
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
// CLI ONLY. All IPS functions and register writes below are synthetic; no live kernel/network.
|
||||
namespace Belevo\EnelixEMS {
|
||||
if (\function_exists('IPS_GetKernelVersion')) throw new \RuntimeException('Offline test must NEVER run in Symcon.');
|
||||
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4TestTrait.php';
|
||||
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4AktivtestTrait.php';
|
||||
require_once __DIR__ . '/../../libs/BatterieNetzfahrplanV4TestTrait.php';
|
||||
class Lizenzpruefung { public const NETZFAHRPLAN='grid_schedule'; }
|
||||
class Nachrichtenvertrag { public const LEISTUNGSQUELLE_GEMESSEN='measured'; }
|
||||
function time() { return $GLOBALS['clock']; }
|
||||
function hrtime($asNumber=true) { return (int)($GLOBALS['mono']*1e9); }
|
||||
function IPS_SemaphoreEnter($key,$wait) { if (!empty($GLOBALS['locks'][$key])) return false;$GLOBALS['locks'][$key]=true;return true; }
|
||||
function IPS_SemaphoreLeave($key) { unset($GLOBALS['locks'][$key]); }
|
||||
function IPS_InstanceExists($id) { return $id===200 || isset($GLOBALS['objects'][$id]); }
|
||||
function IPS_GetInstance($id) {
|
||||
if ($id===200) return ['InstanceStatus'=>102,'ModuleInfo'=>['ModuleID'=>'{90BF9234-B44C-7EB3-280B-FBD6075ADDAA}']];
|
||||
return ['InstanceStatus'=>$GLOBALS['objects'][$id]->ready?102:201,
|
||||
'ModuleInfo'=>['ModuleID'=>$id===44234?'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}':'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];
|
||||
}
|
||||
function ModBus_RequestRead($id) {
|
||||
if (!empty($GLOBALS['locks']['ENELIX.V4.Battery.44234'])) throw new \LogicException('Device I/O under battery control lock');
|
||||
$GLOBALS['deviceReads'] = ($GLOBALS['deviceReads'] ?? 0) + 1;
|
||||
$GLOBALS['clock'] += $GLOBALS['readDelay'] ?? 0;
|
||||
return $id===200 && ($GLOBALS['deviceReadOk']??true);
|
||||
}
|
||||
function IPS_GetProperty($id,$key) { return $GLOBALS['objects'][$id]->props[$key]; }
|
||||
function IPS_VariableExists($id) { return in_array($id,[50001,50002],true); }
|
||||
function IPS_GetVariable($id) { $t=$GLOBALS['feedbackAt'][$id]??$GLOBALS['clock'];return ['VariableUpdated'=>$t,'VariableChanged'=>$t]; }
|
||||
function IPS_GetObject($id) { return ['ParentID'=>200,'ObjectIdent'=>'source'.$id]; }
|
||||
function GetValue($id) { return $id===50001?$GLOBALS['objects'][17004]->op['gridW']:$GLOBALS['objects'][17004]->actual['b']; }
|
||||
|
||||
function IPS_RequestAction($id,$key,$value) {
|
||||
$GLOBALS['calls'][]=[$id,$key];$b=$GLOBALS['objects'][$id];
|
||||
if (($GLOBALS['actionRefusal']??null)===$key) return false;
|
||||
if ($key==='NetzfahrplanV4TestStart') $GLOBALS['readDelay']=$GLOBALS['startReadDelay']??0;
|
||||
if ($key==='NetzfahrplanV4TestStart') $b->V4BatterieTestStarten($value);
|
||||
elseif ($key==='NetzfahrplanV4TestCommand') $b->V4BatterieTestBefehl($value);
|
||||
elseif ($key==='NetzfahrplanV4TestStop') $b->V4BatterieTestStoppen();
|
||||
else throw new \RuntimeException('Unexpected external action in offline test');
|
||||
return true;
|
||||
}
|
||||
trait StoreHarness {
|
||||
public array $props=[],$attrs=[],$buf=[],$timers=[],$vars=['Aktiv'=>true];
|
||||
public bool $ready=true;
|
||||
private function RegisterPropertyBoolean($k,$v) { $this->props[$k]??=$v; }
|
||||
private function RegisterPropertyInteger($k,$v) { $this->props[$k]??=$v; }
|
||||
private function RegisterPropertyString($k,$v) { $this->props[$k]??=$v; }
|
||||
private function RegisterAttributeString($k,$v) { $this->attrs[$k]??=$v; }
|
||||
private function RegisterAttributeInteger($k,$v) { $this->attrs[$k]??=$v; }
|
||||
private function RegisterVariableBoolean($k,$caption,$profile,$position) { $this->vars[$k]??=false; }
|
||||
private function EnableAction($k) {}
|
||||
private function RegisterTimer($k,$ms,$script) { $this->timers[$k]=$ms; }
|
||||
private function SetTimerInterval($k,$ms) { $this->timers[$k]=$ms; }
|
||||
private function ReadPropertyBoolean($k) { return $this->props[$k]; }
|
||||
private function ReadPropertyString($k) { return $this->props[$k]; }
|
||||
private function ReadPropertyInteger($k) { return $this->props[$k]; }
|
||||
private function ReadPropertyFloat($k) { return (float)$this->props[$k]; }
|
||||
private function ReadAttributeString($k) { return $this->attrs[$k]??''; }
|
||||
private function ReadAttributeInteger($k) { return $this->attrs[$k]??0; }
|
||||
private function WriteAttributeString($k,$v) { $this->attrs[$k]=$v; }
|
||||
private function WriteAttributeInteger($k,$v) { $this->attrs[$k]=$v; }
|
||||
private function GetBuffer($k) { return $this->buf[$k]??''; }
|
||||
private function SetBuffer($k,$v) { $this->buf[$k]=$v; }
|
||||
private function GetValue($k) { return $this->vars[$k]; }
|
||||
private function SetValue($k,$v) { $this->vars[$k]=$v; }
|
||||
}
|
||||
class BatteryHarness {
|
||||
use StoreHarness, BatterieNetzfahrplanV4TestTrait;
|
||||
public int $InstanceID=44234;
|
||||
public array $state=['HystereseAktiv'=>false],$writes=[],$measurements=[];
|
||||
public bool $failRegisters=false;
|
||||
public function __construct() {
|
||||
$this->props=['Batteriemanagement'=>2,'ReserveLadezustand'=>15.,'MindestLadezustand'=>3.,
|
||||
'NachladenMitMaximalleistung'=>true,'MaximaleNachladeleistung'=>1000,
|
||||
'LadezustandVariableID'=>1001,'IstleistungVariableID'=>1002];
|
||||
$this->measurements=['Gueltig'=>true,'Ladezustand'=>50.,'MaxLaden'=>39000.,'MaxEntladen'=>39000.];
|
||||
$this->attrs['LetzterSollwert']=0;$this->attrs['AenderungFreigabeAb']=0;
|
||||
$this->registriereV4BatterieTest();
|
||||
}
|
||||
private function zugeordneteManagerIDs() { return [17004]; }
|
||||
private function setzeZustand($k,$v) { $this->state[$k]=$v; }
|
||||
private function leseZustand($k) { return $this->state[$k]??false; }
|
||||
private function schreibeRegister($w,$force) { if ($this->failRegisters) throw new \RuntimeException('synthetic register failure');$this->writes[]=$w;$this->attrs['LetzterSollwert']=$w; }
|
||||
private function aktualisiereIntern($notify) { $w=$this->v4BatterieTestZiel($this->measurements,$this->vars['Aktiv'],time());if ($w!==null) $this->schreibeRegister($w,false); }
|
||||
public function sample() { try { $this->aktualisiereV4Rueckmeldung(); } catch (\Throwable) {} }
|
||||
public function watchdog() { $this->sample(); $this->aktualisiereIntern(true); }
|
||||
public function watchdogOnly() { $this->aktualisiereIntern(true); }
|
||||
public function cachedFeedback() { return $this->v4BatterieRueckmeldung(); }
|
||||
public function active() { return $this->v4BatterieTestAktiv(); }
|
||||
}
|
||||
class ManagerHarness {
|
||||
use StoreHarness, ManagerNetzfahrplanV4TestTrait, ManagerNetzfahrplanV4AktivtestTrait;
|
||||
public int $InstanceID=17004;
|
||||
public array $op=[],$actual=['b'=>0.];public int $fallback=0;
|
||||
public bool $licensed=true;
|
||||
public function __construct($response,$op) {
|
||||
$this->props=['NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanV4EmpfangAktiv'=>true,'NetzfahrplanAktiv'=>false,
|
||||
'AnlagenBatterien'=>json_encode([['ID'=>'b','SOCVariableID'=>1001,'LeistungVariableID'=>1002]])];
|
||||
$this->op=$op;$this->attrs=['LizenzInstallationID'=>$response['installationId'],
|
||||
'NetzfahrplanV4Empfang'=>json_encode(['response'=>$response,'receivedAt'=>time()])];
|
||||
$this->registriereV4ManagerTest();
|
||||
$this->registriereV4ManagerAktivtest();
|
||||
}
|
||||
private function berechtigungLizenziert($k) { return $this->licensed; }
|
||||
private function actieveDummy() {}
|
||||
private function aktiveVerbraucherIDs() { return [44234]; }
|
||||
private function netzfahrplanV4LokaleVorschauwerte() {
|
||||
$GLOBALS['objects'][44234]->sample(); // independent acquisition timer, outside control locks
|
||||
$op=$this->op;$f=json_decode($GLOBALS['objects'][44234]->GetV4BatterieRueckmeldung(),true);
|
||||
$op['localFeedback']=['b'=>$f];return [$op,$this->actual];
|
||||
}
|
||||
private function regeln($renew) { $this->fallback++; }
|
||||
public function tick(&$allocation,$consumers=null,$sync=true) {
|
||||
$this->v4ManagerTestUebergabe($allocation,$consumers??[['InstanzID'=>44234,'Daten'=>[]]],$sync);
|
||||
}
|
||||
public function active() { return (bool)$this->v4ManagerTestSession(); }
|
||||
public function command() { return json_decode($this->attrs['NetzfahrplanV4RegeltestStatus'],true)['command']??null; }
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
require_once __DIR__.'/../V4Receiver/fixture.php';
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Regeltest as Gate;
|
||||
use Belevo\EnelixEMS\BatteryHarness;
|
||||
use Belevo\EnelixEMS\ManagerHarness;
|
||||
$count=0;
|
||||
function check($ok,$name) { global $count;if (!$ok) throw new RuntimeException('FAIL '.$name);$count++;echo "PASS $name\n"; }
|
||||
function rejects(callable $call,$name) { try {$call();}catch(Throwable $e){check(true,$name);return;}throw new RuntimeException('FAIL did not reject '.$name); }
|
||||
function fixture($permissions=true,$serverAuthority=true): array {
|
||||
$GLOBALS['actionRefusal']=null;$GLOBALS['readDelay']=0;$GLOBALS['startReadDelay']=0;
|
||||
[$r,$id,$op,$now]=receiverFixture();$GLOBALS['clock']=$now;$GLOBALS['mono']=1000.;$GLOBALS['locks']=[];$GLOBALS['calls']=[];$GLOBALS['deviceReadOk']=true;
|
||||
$r['plan']['inputQuality']=['loadBasis'=>'base_load','accountingEvidenceId'=>'synthetic-meter-boundary-v1'];
|
||||
$r['plan']['peakCostIsEstimate']=true;
|
||||
$a=['kind'=>'controlled_trial_authority','version'=>1,'sourcePlanRemainsShadow'=>true,
|
||||
'sessionId'=>'20000000-0000-4000-8000-000000000001','installationId'=>$id,
|
||||
'managerId'=>17004,'batteryInstanceId'=>44234,'assetId'=>'b',
|
||||
'sourceShadowPlanId'=>$r['plan']['planId'],'revision'=>1,'family'=>'3',
|
||||
'issuedAt'=>gmdate('c',$now),'expiresAt'=>gmdate('c',$now+300),'checkedAt'=>gmdate('c',$now),'validUntil'=>gmdate('c',$now+90),
|
||||
'maxChargeW'=>39000.,'maxDischargeW'=>39000.,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog',
|
||||
'accountingEvidenceId'=>'synthetic-meter-boundary-v1','acceptEstimatedPeak'=>true,'controlContext'=>$r['plan']['controlContext']];
|
||||
$r['controlledTrial']=$serverAuthority?$a:null;
|
||||
$m=new ManagerHarness($r,$op);$b=new BatteryHarness();$GLOBALS['objects']=[17004=>$m,44234=>$b];$GLOBALS['feedbackAt']=[];
|
||||
$fc=['version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234,
|
||||
'mode'=>'physical_sum','allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,
|
||||
'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]];
|
||||
foreach ([50001=>'grid',50002=>'physical'] as $vid=>$role) $fc['sources'][]=['key'=>'source'.$vid,'role'=>$role,'variableId'=>$vid,'parentId'=>200,'ident'=>'source'.$vid,'factorToW'=>1.0];
|
||||
$b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($fc);
|
||||
$op['localFeedback']=['b'=>json_decode($b->GetV4BatterieRueckmeldung(),true)];
|
||||
|
||||
$m->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;$b->props['NetzfahrplanV4RegeltestErlaubt']=$permissions;
|
||||
$b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='synthetic-hardware-watchdog';
|
||||
$start=['confirmation'=>'START_BOUNDED_V4_CONTROL_TRIAL','sessionId'=>$a['sessionId'],'durationSeconds'=>300,'maxChargeW'=>39000.,'maxDischargeW'=>39000.];
|
||||
return [$m,$b,$start,$r,$op,$now];
|
||||
}
|
||||
function start($m,$s) {$m->V4ManagerTestStarten(json_encode($s));}
|
||||
function tick($m) {$a=['Sollwerte'=>[44234=>999]];$m->tick($a);return $a;}
|
||||
function advance($s) {$GLOBALS['clock']+=$s;$GLOBALS['mono']+=$s;}
|
||||
function activeFixture(): array {
|
||||
[$m,$b,$s,$r,$op,$now]=fixture();
|
||||
$m->props['NetzfahrplanV4AktivtestErlaubt']=true;
|
||||
$m->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=true;
|
||||
$b->props['NetzfahrplanV4AktivtestErlaubt']=true;
|
||||
$b->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=true;
|
||||
return [$m,$b,$s,$r,$op,$now];
|
||||
}
|
||||
|
||||
[$m,$b,$s]=fixture(false);rejects(fn()=>start($m,$s),'default local permission blocks');check($b->writes===[],'no register writes without consent');
|
||||
[$m,$b,$s]=fixture(true,false);rejects(fn()=>start($m,$s),'ordinary shadow response never authorizes');check($calls===[],'no driver call for preview alone');
|
||||
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4RegeltestErlaubt']=false;rejects(fn()=>start($m,$s),'second battery gate independent');
|
||||
[$m,$b,$s]=fixture();$m->props['NetzfahrplanAktiv']=true;rejects(fn()=>start($m,$s),'old schedule excludes new controller');
|
||||
[$m,$b,$s,$r]=fixture();$r['plan']['inputQuality']['loadBasis']='house_total';$m->attrs['NetzfahrplanV4Empfang']=json_encode(['response'=>$r,'receivedAt'=>$clock]);rejects(fn()=>start($m,$s),'unverified aggregate accounting blocks test');
|
||||
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4GeraeteWatchdogNachweis']='';rejects(fn()=>start($m,$s),'unknown device watchdog blocks test');
|
||||
[$m,$b,$s]=fixture();$m->ready=false;rejects(fn()=>start($m,$s),'unready manager blocks test');
|
||||
[$m,$b,$s,$r]=fixture();
|
||||
$r['plan']['points'][0]['gridTargetW']=25000.;$r['plan']['points'][0]['batteryTargetW']=28000.;
|
||||
$r['plan']['points'][0]['assetTargetsW']['b']=28000.;$r['plan']['points'][0]['intent']='gridCharge';
|
||||
$m->attrs['NetzfahrplanV4Empfang']=json_encode(['response'=>$r,'receivedAt'=>$clock]);
|
||||
start($m,$s);tick($m);check(end($b->writes)===28000&&$m->command()['maxChargeW']===39000,'full dynamic plant power allowed above former 5kW test cap');
|
||||
[$m,$b,$s]=fixture();start($m,$s);check($m->active()&&$b->active(),'both sessions armed');check($b->writes===[],'arm itself sends no register command');
|
||||
$allocation=tick($m);check(!isset($allocation['Sollwerte'][44234]),'one actuator owner removes ordinary target');
|
||||
check(end($b->writes)===3000,'actual trait chain delivers bounded 3kW command');
|
||||
$c=$m->command();check($c['kind']==='controlled_trial_command'&&$c['expiresAt']===$clock+10,'independent command type and ten-second lease');
|
||||
check(json_decode($b->GetV4BatterieTestStatus(),true)['physicalResponseVerified']===false,'controller acceptance is not physical proof');
|
||||
advance(2);tick($m);check($m->command()['sequence']===2,'monotonic command sequence');
|
||||
rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'replayed command refused');check(!$b->active()&&end($b->writes)===0,'replay aborts and releases output');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);advance(11);$b->watchdog();check(!$b->active()&&end($b->writes)===0,'watchdog expiry drops to zero');
|
||||
[$m,$b,$s]=fixture();start($m,$s);advance(11);$b->watchdog();check(!$b->active()&&end($b->writes)===0,'missing first command expires');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$mono+=11;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'monotonic watchdog works without wall clock advance');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$clock-=1;$b->watchdog();check(!$b->active(),'backwards wall clock aborts');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->V4ManagerTestStoppen();check(!$m->active()&&!$b->active()&&end($b->writes)===0&&$m->fallback===1,'manual stop releases before ordinary fallback');
|
||||
rejects(fn()=>start($m,$s),'stopped local session cannot resurrect');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->buf=[];$b->V4BatterieTestStoppen();check(end($b->writes)===0&&!$b->active(),'runtime restart clears persisted command marker');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Gueltig']=false;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'invalid device measurements release command');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['MaxLaden']=1000.;$b->watchdog();check(end($b->writes)===1000,'device availability reduction rechecked');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->measurements['Ladezustand']=99.;$b->watchdog();check(end($b->writes)===0,'full battery cannot keep charging');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->props['NetzfahrplanV4RegeltestErlaubt']=false;$b->watchdog();check(!$b->active()&&end($b->writes)===0,'battery consent withdrawn aborts');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->props['NetzfahrplanV4RegeltestErlaubt']=false;$a=tick($m);check(!$m->active()&&isset($a['Sollwerte'][44234])&&end($b->writes)===0,'manager consent withdrawn resumes ordinary allocation');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$m->attrs['NetzfahrplanV4Empfang']='{}';$a=tick($m);check(!$m->active()&&end($b->writes)===0,'invalidated receiver cache ends trial');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$a=['Sollwerte'=>[44234=>123]];$m->tick($a,null,false);check(!$m->active()&&end($b->writes)===0,'unsynchronized consumers abort');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$b->failRegisters=true;advance(2);tick($m);check(!$m->active()&&!$b->active(),'register failure revokes both sessions');check(json_decode($m->GetV4ManagerTestStatus(),true)['controllerStopAccepted']===false,'stop failure not disguised as safe');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$a=['Sollwerte'=>[44234=>999,33333=>1000]];$m->tick($a,[['InstanzID'=>44234,'Daten'=>[]],['InstanzID'=>33333,'Daten'=>['Leistungsquelle'=>'measured','Istleistung_W'=>0]]]);
|
||||
check(end($b->writes)===2000&&$a['Sollwerte'][33333]===1000,'other consumer delta counted once without takeover');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$m->actual['b']=2000.;$m->op['gridW']=-1000.;tick($m);check(end($b->writes)===3000,'command is total battery power not additive');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$m->actual['b']=0.;$m->op['gridW']=-40000.;tick($m);check($m->active()&&end($b->writes)===39000,'full dynamic power corrects export while hard grid limit stays enforced');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$clock+=91;$mono+=91;tick($m);check(!$m->active()&&end($b->writes)===0,'expired server observation cancels even with future prices');
|
||||
[$m,$b,$s,$r,$op,$now]=fixture();$local=['enabled'=>true,'legacyEnabled'=>false,'installationId'=>$r['installationId'],'sessionId'=>$s['sessionId'],'managerId'=>17004,'batteryInstanceId'=>44234,'assetId'=>'b','expiresAt'=>$now+300,'maxChargeW'=>5000,'maxDischargeW'=>5000,'sequence'=>1,'actuatorWatchdogEvidenceId'=>'synthetic-hardware-watchdog'];
|
||||
foreach (['sessionId','installationId','sourceShadowPlanId'] as $key) {
|
||||
$bad=$r;$bad['controlledTrial'][$key]='ffffffff-ffff-4fff-8fff-ffffffffffff';rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'wrong authority binding '.$key);
|
||||
}
|
||||
$bad=$r;$bad['controlledTrial']['validUntil']=gmdate('c',$now+120);rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'oversized server authorization lease');
|
||||
$bad=$r;$bad['controlledTrial']['acceptEstimatedPeak']=false;rejects(fn()=>Gate::befehl($bad,$op,['b'=>0.],$local,$now,$now),'peak uncertainty needs explicit acceptance');
|
||||
[$m,$b]=activeFixture();$m->V4ManagerAktivtestSchalten(true);
|
||||
check($m->active()&&$b->active()&&$m->vars['NetzfahrplanV4Aktivtest'],'single runtime switch starts bounded active test');
|
||||
$allocation=tick($m);
|
||||
check(end($b->writes)===3000&&!isset($allocation['Sollwerte'][44234]),'active test uses guarded command path');
|
||||
check($m->command()['executionMeaning']==='explicit_time_boxed_test_plant_operation','active command is explicitly distinguished');
|
||||
[$m2,$b2]=activeFixture();$m2->V4ManagerAktivtestSchalten(true);
|
||||
$mixed=['Sollwerte'=>[44234=>999,33333=>1000]];
|
||||
$m2->tick($mixed,[['InstanzID'=>44234,'Daten'=>[]],['InstanzID'=>33333,'Daten'=>['Leistungsquelle'=>'calculated','Istleistung_W'=>500]]],false);
|
||||
check($m2->active()&&$b2->active()&&!isset($mixed['Sollwerte'][44234])&&!isset($mixed['Sollwerte'][33333]),'active test freezes other consumers and tolerates unrelated desync');
|
||||
$GLOBALS['objects']=[17004=>$m,44234=>$b];
|
||||
check($m->attrs['NetzfahrplanV4AktivtestEnde']-$m->attrs['NetzfahrplanV4AktivtestBeginn']===172800,'active test has fixed 48-hour window');
|
||||
advance(91);
|
||||
$GLOBALS['feedbackAt']=[50001=>$clock,50002=>$clock];
|
||||
$m->op['observedAt']=gmdate('c',$clock);
|
||||
$m->op['batteries'][0]['measuredAt']=gmdate('c',$clock);
|
||||
$envelope=json_decode($m->attrs['NetzfahrplanV4Empfang'],true);
|
||||
$envelope['receivedAt']=$clock;
|
||||
$envelope['response']['checkedAt']=gmdate('c',$clock);
|
||||
$m->attrs['NetzfahrplanV4Empfang']=json_encode($envelope);
|
||||
tick($m);
|
||||
check($m->active()&&$b->active(),'local active authority refreshes while plan and measurements remain valid');
|
||||
$m->V4ManagerAktivtestSchalten(false);
|
||||
check(!$m->active()&&!$b->active()&&!$m->vars['NetzfahrplanV4Aktivtest']&&end($b->writes)===0,'switch off revokes session and requests zero');
|
||||
[$m,$b]=activeFixture();$m->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=false;
|
||||
rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'active test requires explicit watchdog waiver property');
|
||||
[$m,$b]=activeFixture();$b->props['NetzfahrplanV4WatchdogVerzichtErlaubt']=false;
|
||||
rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'battery independently requires explicit watchdog waiver property');
|
||||
[$m,$b,$s]=fixture();$GLOBALS['actionRefusal']='NetzfahrplanV4TestStart';
|
||||
rejects(fn()=>start($m,$s),'FALSE start result is rejected');
|
||||
check(!$m->active()&&!$b->active(),'refused start cannot arm manager');
|
||||
[$m,$b]=activeFixture();$GLOBALS['actionRefusal']='NetzfahrplanV4TestStart';
|
||||
rejects(fn()=>$m->V4ManagerAktivtestSchalten(true),'FALSE active start result is rejected');
|
||||
check(!$m->vars['NetzfahrplanV4Aktivtest']&&!$m->active(),'refused active start leaves switch off');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$GLOBALS['actionRefusal']='NetzfahrplanV4TestCommand';tick($m);
|
||||
check(!$m->active()&&!$b->active()&&$m->command()===null,'FALSE command result aborts instead of reporting sent');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$GLOBALS['actionRefusal']='NetzfahrplanV4TestStop';$m->V4ManagerTestStoppen();
|
||||
check(json_decode($m->GetV4ManagerTestStatus(),true)['controllerStopAccepted']===false,'FALSE stop result stays visibly unconfirmed');
|
||||
[$m,$b,$s]=fixture();$GLOBALS['startReadDelay']=1;start($m,$s);
|
||||
check($m->active()&&$b->active(),'battery arming uses an independently confirmed snapshot without locked I/O');
|
||||
[$m,$b,$s]=fixture();start($m,$s);$b->measurements['Gueltig']=false;tick($m);
|
||||
check(str_contains(json_decode($b->GetV4BatterieTestStatus(),true)['reason'],'Messwerte ungueltig'),
|
||||
'command rejection preserves underlying safety reason after zero request');
|
||||
require __DIR__ . '/additional_checks.php';
|
||||
require __DIR__ . '/feedback_checks.php';
|
||||
require __DIR__ . '/cache_checks.php';
|
||||
echo "TOTAL $count checks passed. Mocked IPS, clocks and register writes only.\n";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// Run through actual Manager/Battery trial traits, fresh read helper, and simulated register driver.
|
||||
[$m,$b,$s]=fixture();$b->props['NetzfahrplanV4RueckmeldungKonfiguration']='{}';
|
||||
rejects(fn()=>start($m,$s),'trial cannot use old filtered feedback when mapping missing');check($b->writes===[],'missing corrected feedback sends no command');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$deviceReadOk=false;
|
||||
$a=tick($m);check(!$m->active()&&!$b->active()&&isset($a['Sollwerte'][44234]),'failed confirmed read returns battery to normal allocation');
|
||||
check(end($b->writes)===0,'failed confirmed read revokes prior command');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$deviceReadOk=false;$b->watchdog();
|
||||
check(!$b->active()&&end($b->writes)===0,'independent battery read catches device failure without manager tick');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=json_decode($b->props['NetzfahrplanV4RueckmeldungKonfiguration'],true);$c['trackingToleranceW']=250;
|
||||
$b->props['NetzfahrplanV4RueckmeldungKonfiguration']=json_encode($c);$b->watchdog();
|
||||
check(!$b->active()&&end($b->writes)===0,'mapping revision change revokes active battery session');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();$c['sequence']++;$c['feedbackConfigHash']=str_repeat('f',64);
|
||||
rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'changed feedback command binding rejected');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();check(isset($c['feedbackConfigHash'],$c['feedbackSourceOldestAt'])&&array_key_exists('importLimitW',$c),'command includes source provenance and limits');
|
||||
$c['sequence']++;unset($c['exportLimitW']);rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'missing explicit grid limit not interpreted as unlimited');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$c=$m->command();$c['sequence']++;$c['importLimitW']=1000.;$c['watts']=0;
|
||||
$m->op['gridW']=1500.;$b->measurements['Ladezustand']=15.;
|
||||
$b->sample();
|
||||
rejects(fn()=>$b->V4BatterieTestBefehl(json_encode($c)),'final device clipping cannot silently violate accepted grid boundary');
|
||||
check(!$b->active()&&end($b->writes)===0,'unreachable final limit aborts software trial');
|
||||
[$m,$b,$s]=fixture();start($m,$s);tick($m);$feedbackAt[50002]=$clock+1;tick($m);
|
||||
check(!$m->active(),'future physical sample is not smoothed into control');
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
// CLI only: exercise the actual module method with synthetic variable actions.
|
||||
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline test only.');
|
||||
class IPSModule
|
||||
{
|
||||
public int $InstanceID = 0;
|
||||
public array $props = [], $attrs = [];
|
||||
protected function ReadPropertyInteger($key) { return $this->props[$key] ?? 0; }
|
||||
protected function ReadPropertyBoolean($key) { return $this->props[$key] ?? false; }
|
||||
protected function ReadAttributeInteger($key) { return $this->attrs[$key] ?? 0; }
|
||||
protected function ReadAttributeBoolean($key) { return $this->attrs[$key] ?? false; }
|
||||
protected function ReadAttributeString($key) { return $this->attrs[$key] ?? ''; }
|
||||
protected function WriteAttributeInteger($key, $value) { $this->attrs[$key] = $value; }
|
||||
protected function WriteAttributeBoolean($key, $value) { $this->attrs[$key] = $value; }
|
||||
protected function WriteAttributeString($key, $value) { $this->attrs[$key] = $value; }
|
||||
}
|
||||
function IPS_GetObjectIDByIdent($ident, $parent) { return false; }
|
||||
function RequestAction($id, $value) { $GLOBALS['writes'][] = [$id, $value]; return $GLOBALS['accepted']; }
|
||||
require_once __DIR__ . '/../../Batterie/module.php';
|
||||
$battery = new Batterie();
|
||||
$battery->InstanceID = 44234;
|
||||
$battery->props = [
|
||||
'Batterietyp' => Belevo\EnelixEMS\BatterieRegler::TYP_GOODWE,
|
||||
'Batteriemanagement' => Belevo\EnelixEMS\BatterieRegler::STEUERUNG_ENELIX,
|
||||
'ManagementRegisterVariableID' => 1, 'ModusRegisterVariableID' => 2,
|
||||
'LeistungsRegisterVariableID' => 3, 'Aenderungssperre' => 0,
|
||||
];
|
||||
$method = new ReflectionMethod(Batterie::class, 'schreibeRegister');
|
||||
$accepted = false; $writes = [];
|
||||
try { $method->invoke($battery, 12000, false); throw new LogicException('FALSE was accepted'); }
|
||||
catch (RuntimeException $error) {
|
||||
if (!str_contains($error->getMessage(), 'Registeraktion nicht bestaetigt')) throw $error;
|
||||
}
|
||||
if (($battery->attrs['RegisterInitialisiert'] ?? false) || ($battery->attrs['RegisterSchreiben'] ?? false)) {
|
||||
throw new RuntimeException('Failed action cached or lock not released');
|
||||
}
|
||||
$accepted = true;
|
||||
$method->invoke($battery, 12000, false);
|
||||
if (($battery->attrs['LetzterSollwert'] ?? 0) !== 12000 || !$battery->attrs['RegisterInitialisiert']) {
|
||||
throw new RuntimeException('Successful retry not cached');
|
||||
}
|
||||
echo "PASS actual module rejects FALSE register action, releases guard and retries successfully\n";
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../libs/NetzfahrplanV4Messaufnahme.php';
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme as Capture;
|
||||
|
||||
$n = 0;
|
||||
function ck(bool $ok, string $label): void { global $n; if (!$ok) throw new RuntimeException('FAIL ' . $label); $n++; echo 'PASS ' . $label . "\n"; }
|
||||
function refuses(callable $f, string $label): void { try { $f(); } catch (Throwable $e) { ck(true,$label); return; } throw new RuntimeException('Expected refusal: '.$label); }
|
||||
$now = 1790952000;
|
||||
$roles=['grid','pv','physical_storage','controlled_virtual','external_virtual'];
|
||||
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,
|
||||
'reportedInventorySha256'=>str_repeat('a',64),'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]],
|
||||
'extraSources'=>[['key'=>'soc','variableId'=>106,'parentId'=>200,'ident'=>'soc','unit'=>'percent']]];
|
||||
$values=[3000,5000,2000,1500,500]; $readings=[];
|
||||
foreach($roles as $i=>$role){
|
||||
$key='s'.$i;$id=101+$i;
|
||||
$c['accounting']['sources'][]=['key'=>$key,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>$key,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>in_array($i,[3,4],true)?['s2']:[]];
|
||||
$readings[$id]=['value'=>$values[$i],'updated'=>$now,'changed'=>$now-30,'parentID'=>200,'ident'=>$key];
|
||||
}
|
||||
$readings[106]=['value'=>0.,'updated'=>$now,'changed'=>0,'parentID'=>200,'ident'=>'soc'];
|
||||
function take(array $c,array $r,int $now): array { return Capture::capture($c,static fn(int $id):array=>$r[$id],static fn():int=>$now); }
|
||||
$r=take($c,$readings,$now);
|
||||
ck($r['assessment']['baseLoadCandidateW']===6000.,'physical battery subtracted exactly once');
|
||||
ck($r['raw']['soc']['value']===0. && $r['raw']['soc']['sourceChangedAt']===0,'real zero and unchanged timestamp retained');
|
||||
ck(!$r['trainingEligible'] && !$r['controlEligible'] && !$r['measurementBoundaryVerified'],'algebra never enables training or control');
|
||||
ck($r['futureSdl']==='unknown_no_assumption','no fabricated future SDL');
|
||||
ck(count($r['raw'])===6,'raw extra sources retained separately');
|
||||
$a=$readings;$a[103]['updated']=$now-90;$a[104]['updated']=$now;
|
||||
$x=take($c,$a,$now);ck($x['assessment']['status']==='quality_hold','old physical measurement quarantines candidate');
|
||||
ck($x['raw']['s2']['sourceUpdatedAt']===$now-90,'original old timestamp preserved');
|
||||
ck(in_array('stale_origin:s3',$x['assessment']['issues'],true),'fresh virtual write cannot mask old dependency');
|
||||
$a=$readings;$a[101]['value']=null;$x=take($c,$a,$now);ck($x['raw']['s0']['value']===null && $x['assessment']===null,'missing source not zero');
|
||||
$a=$readings;$a[101]['value']='sensitive-test-text';$x=take($c,$a,$now);ck(!str_contains(json_encode($x),'sensitive-test-text'),'unexpected strings never logged');
|
||||
foreach([true,NAN,INF,1.0e20] as $v){$a=$readings;$a[101]['value']=$v;$x=take($c,$a,$now);ck($x['raw']['s0']['value']===null && is_string(json_encode($x,JSON_THROW_ON_ERROR)),'invalid numeric represented safely');}
|
||||
$a=$readings;$a[101]['parentID']=999;$x=take($c,$a,$now);ck($x['raw']['s0']['value']===null && $x['assessment']===null,'reused ID not recorded as correct measurement');
|
||||
$a=$readings;$a[102]['updated']=$now+1;$x=take($c,$a,$now);ck($x['assessment']===null && in_array('s1:future_source',$x['issues'],true),'future timestamp marked');
|
||||
$a=$readings;$a[101]['updated']=0;$x=take($c,$a,$now);ck($x['assessment']===null,'absent timestamp not invented');
|
||||
$counts=[];$x=Capture::capture($c,static function($id)use(&$counts,$readings){$r=$readings[$id];$counts[$id]=($counts[$id]??0)+1;if($id===101 && $counts[$id]===2)$r['value']++;return $r;},static fn()=>$now);
|
||||
ck($x['assessment']===null && $x['raw']['s0']['secondValue']===3001,'same-second concurrent change visible');
|
||||
$x=Capture::capture($c,static function($id)use($readings){if($id===101)throw new RuntimeException('do-not-emit-secret');return $readings[$id];},static fn()=>$now);
|
||||
ck(!str_contains(json_encode($x),'do-not-emit-secret') && $x['assessment']===null,'source errors bounded without secret messages');
|
||||
$a=$readings;$a[106]['value']=null;$x=take($c,$a,$now);ck($x['assessment']!==null && !$x['trainingEligible'],'optional SOC error does not destroy available raw powers');
|
||||
$a=$readings;$a[101]['value']=-10000;$x=take($c,$a,$now);ck($x['assessment']['baseLoadCandidateW']<0 && $x['assessment']['status']==='quality_hold','negative candidate not clipped');
|
||||
$a=$c;$a['extraSources'][]=$a['extraSources'][0];refuses(fn()=>take($a,$readings,$now),'duplicate source rejected');
|
||||
$a=$c;$a['accounting']['sources']=array_reverse($a['accounting']['sources']);ck(take($a,$readings,$now)['mappingSha256']===$r['mappingSha256'],'mapping identity stable');
|
||||
$t=0;refuses(fn()=>Capture::capture($c,static fn($id)=>$readings[$id],static function()use(&$t,$now){return $now-$t++;}),'backwards clock rejected');
|
||||
$base=sys_get_temp_dir().'/v4-raw-test-'.bin2hex(random_bytes(5));mkdir($base,0700);$dir=$base.'/data';mkdir($dir,0700);
|
||||
try{
|
||||
$file=Capture::append($dir,$r);$first=file_get_contents($file);Capture::append($dir,$r);
|
||||
ck(file_get_contents($file)===$first.$first,'append retains existing observations');
|
||||
ck(count(file($file))===2,'one record per line');
|
||||
ck((fileperms($file)&0777)===0640,'data permissions restricted');
|
||||
$old=file_get_contents($file);refuses(fn()=>Capture::append($dir,$r,1),'quota refuses without deleting data');ck(file_get_contents($file)===$old,'quota leaves journal unchanged');
|
||||
file_put_contents($file,'partial',FILE_APPEND);refuses(fn()=>Capture::append($dir,$r),'crash-truncated tail not silently extended');ck(str_ends_with(file_get_contents($file),'partial'),'incomplete record retained for review');
|
||||
unlink($file);file_put_contents($base.'/foreign','untouched');symlink($base.'/foreign',$file);refuses(fn()=>Capture::append($dir,$r),'journal symlink refused');ck(file_get_contents($base.'/foreign')==='untouched','unrelated file preserved');unlink($file);
|
||||
$lock=fopen($dir.'/.writer.lock','c+b');flock($lock,LOCK_EX);refuses(fn()=>Capture::append($dir,$r),'parallel writer refused');flock($lock,LOCK_UN);fclose($lock);
|
||||
$a=$r;$a['blob']=str_repeat('x',300000);refuses(fn()=>Capture::append($dir,$a),'oversized record refused');
|
||||
}finally{foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($base);}
|
||||
$source=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Messaufnahme.php');
|
||||
ck(!preg_match('/\b(?:IPS_|GetValue|SetValue|curl_|RequestAction|shell_exec|exec\s*\()/',$source),'capture core has no device or network calls');
|
||||
echo "TOTAL $n checks passed; synthetic values and temporary files only.\n";
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
// ALL IPS functions below are mocks. Never execute this test in a real Symcon kernel.
|
||||
if (PHP_SAPI !== 'cli') throw new RuntimeException('CLI isolation required');
|
||||
require_once __DIR__ . '/../../examples/V4DataCapture/install.php';
|
||||
$count=0;$objects=[];$values=[];$scripts=[];$timers=[];$nextId=60000;$locks=[];$timerCalls=[];
|
||||
function yes(bool $v,string $s):void{global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo 'PASS '.$s."\n";}
|
||||
function IPS_GetKernelVersion(){return '8.0';}
|
||||
function IPS_GetObject($id){return $GLOBALS['objects'][$id]??throw new RuntimeException('Missing object');}
|
||||
function IPS_GetChildrenIDs($id){return array_keys(array_filter($GLOBALS['objects'],static fn($o)=>$o['ParentID']===$id));}
|
||||
function IPS_InstanceExists($id){return $id===17004;}
|
||||
function IPS_GetInstance($id){return ['ModuleInfo'=>['ModuleID'=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];}
|
||||
function IPS_VariableExists($id){return isset($GLOBALS['values'][$id]);}
|
||||
function IPS_GetVariable($id){$v=$GLOBALS['values'][$id];return ['VariableType'=>is_string($v)?3:2,'VariableUpdated'=>time(),'VariableChanged'=>time()-10];}
|
||||
function GetValue($id){return $GLOBALS['values'][$id];}
|
||||
function SetValue($id,$v){if($id<60000||IPS_GetObject($id)['ObjectIdent']!=='CaptureStatus')throw new RuntimeException('Foreign variable write');$GLOBALS['values'][$id]=$v;return true;}
|
||||
function IPS_SemaphoreEnter($key,$wait){if($GLOBALS['locks'][$key]??false)return false;$GLOBALS['locks'][$key]=true;return true;}
|
||||
function IPS_SemaphoreLeave($key){$GLOBALS['locks'][$key]=false;}
|
||||
function makeObj($type){$id=$GLOBALS['nextId']++;$GLOBALS['objects'][$id]=['ObjectType'=>$type,'ObjectIdent'=>'','ParentID'=>0,'ObjectInfo'=>''];return $id;}
|
||||
function IPS_CreateCategory(){return makeObj(0);}
|
||||
function IPS_CreateScript($type){$id=makeObj(3);$GLOBALS['scripts'][$id]='<?php';return $id;}
|
||||
function IPS_CreateVariable($type){if($type!==3)throw new RuntimeException('Only string status allowed');$id=makeObj(2);$GLOBALS['values'][$id]='';return $id;}
|
||||
function setObj($id,$key,$v){if($id<60000)throw new RuntimeException('Existing object modified');$GLOBALS['objects'][$id][$key]=$v;return true;}
|
||||
function IPS_SetParent($id,$v){return setObj($id,'ParentID',$v);}
|
||||
function IPS_SetIdent($id,$v){return setObj($id,'ObjectIdent',$v);}
|
||||
function IPS_SetName($id,$v){return setObj($id,'ObjectName',$v);}
|
||||
function IPS_SetInfo($id,$v){return setObj($id,'ObjectInfo',$v);}
|
||||
function IPS_GetScriptContent($id){return $GLOBALS['scripts'][$id];}
|
||||
function IPS_SetScriptContent($id,$v){if($id<60000)throw new RuntimeException('Foreign script write');$GLOBALS['scripts'][$id]=$v;return true;}
|
||||
function IPS_SetScriptTimer($id,$seconds){if($id<60000||IPS_GetObject($id)['ObjectIdent']!=='RawCapture')throw new RuntimeException('Foreign timer changed');$GLOBALS['timers'][$id]=$seconds;$GLOBALS['timerCalls'][]=[$id,$seconds];return true;}
|
||||
|
||||
// A separate fake stage with real candidate code and known synthetic numeric sources.
|
||||
$base=sys_get_temp_dir().'/v4-native-capture-'.bin2hex(random_bytes(5));mkdir($base,0700);mkdir($base.'/data',0700);
|
||||
$repo=dirname(__DIR__,2);$names=['libs/NetzfahrplanV4Bilanzierung.php','libs/NetzfahrplanV4Messaufnahme.php','examples/V4DataCapture/runtime.php','examples/V4DataCapture/install.php'];
|
||||
$hashes=[];
|
||||
foreach($names as $name){$p=$base.'/source/'.$name;if(!is_dir(dirname($p)))mkdir(dirname($p),0700,true);copy($repo.'/'.$name,$p);$hashes['source/'.$name]=hash_file('sha256',$p);}
|
||||
$info=['source'=>'synthetic_test','physicalKwh'=>322,'evKwh'=>160];file_put_contents($base.'/USER_REPORTED_INVENTORY.json',json_encode($info));
|
||||
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,'reportedInventorySha256'=>hash_file('sha256',$base.'/USER_REPORTED_INVENTORY.json'),
|
||||
'extraSources'=>[],'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]]];
|
||||
foreach(['grid','pv','physical_storage','controlled_virtual','external_virtual']as$i=>$role){$id=101+$i;$c['accounting']['sources'][]=['key'=>'x'.$i,'variableId'=>$id,'parentId'=>200,'ident'=>'v'.$i,'role'=>$role,'factorToW'=>1,'maxAgeSeconds'=>60,'dependsOn'=>[]];$objects[$id]=['ParentID'=>200,'ObjectIdent'=>'v'.$i,'ObjectType'=>2,'ObjectInfo'=>'original'];$values[$id]=[1000,2000,500,500,0][$i];}
|
||||
file_put_contents($base.'/config.json',json_encode($c));$hashes['config.json']=hash_file('sha256',$base.'/config.json');$hashes['USER_REPORTED_INVENTORY.json']=hash_file('sha256',$base.'/USER_REPORTED_INVENTORY.json');
|
||||
file_put_contents($base.'/MANIFEST.json',json_encode(['scope'=>'passive_raw_capture_only','hashes'=>$hashes]));
|
||||
$before=$objects;$beforeValues=$values;
|
||||
try{
|
||||
ob_start();$r=v4captureInstall($base);ob_end_clean();
|
||||
yes($r['status']==='capture_installed','passive installation completes');
|
||||
yes(count($r['createdObjects'])===4,'only category, two scripts and one status variable');
|
||||
yes($timers[$r['scriptId']]===30,'only new capture timer runs at 30 seconds');
|
||||
yes($r['firstCapture']['status']==='recorded'&&count(file($r['firstCapture']['journal']))===1,'first capture actually written before activation');
|
||||
yes($r['firstCapture']['accountingQuality']==='consistent_candidate','candidate is not a training permit');
|
||||
foreach($before as$id=>$o){yes($objects[$id]===$o && $values[$id]===$beforeValues[$id],'existing source preserved '.$id);}
|
||||
$size=count($objects);ob_start();$again=v4captureInstall($base);ob_end_clean();yes(count($objects)===$size&&$again['createdObjects']===[],'installation idempotent, no duplicate timers');
|
||||
$bad=$scripts[$r['scriptId']];$scripts[$r['scriptId']]="<?php // concurrent edit\n";ob_start();$err=v4captureInstall($base);ob_end_clean();yes($err['status']==='needs_review'&&$scripts[$r['scriptId']]!==$bad,'parallel script edit refused');$scripts[$r['scriptId']]=$bad;
|
||||
$values[101]=null;$raw=v4captureRun($base,$r['categoryId']);yes($raw['status']==='recorded'&&$raw['accountingQuality']==='unavailable','missing measurement still records an explicit gap');$values[101]=1000;
|
||||
v4captureStop($base,$r['categoryId']);yes($timers[$r['scriptId']]===0,'stop affects capture timer only');yes(file_exists($r['firstCapture']['journal']),'stop retains original journal');
|
||||
ob_start();$resume=v4captureInstall($base);ob_end_clean();yes($resume['status']==='capture_installed'&&$timers[$r['scriptId']]===30,'restart by installer retains data');
|
||||
$objects[$r['categoryId']]['ObjectInfo']='unrelated';ob_start();$err=v4captureInstall($base);ob_end_clean();yes($err['status']==='needs_review','foreign category identity not taken over');
|
||||
foreach(['runtime.php','install.php']as$f){$s=file_get_contents($repo.'/examples/V4DataCapture/'.$f);yes(!preg_match('/\b(?:RequestAction|IPS_RequestAction|IPS_SetProperty|IPS_ApplyChanges|MC_ReloadModule|AC_SetLoggingStatus|curl_exec|shell_exec)\s*\(/',$s),'no control/network/module reload in '.$f);}
|
||||
}finally{foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$p){$p->isDir()&&!$p->isLink()?rmdir($p->getPathname()):unlink($p->getPathname());}rmdir($base);}
|
||||
echo "TOTAL $count native-mock checks passed; no Symcon kernel calls.\n";
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
// Offline only, no actual Symcon calls.
|
||||
declare(strict_types=1);
|
||||
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline test only');
|
||||
require_once __DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php';
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Rueckmeldung as F;
|
||||
$count=0;
|
||||
function check($b,$s){global $count;if(!$b)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
|
||||
function fails($f,$s){try{$f();}catch(Throwable $e){check(true,$s);return;}throw new RuntimeException('FAIL no rejection '.$s);}
|
||||
function fx($mode='physical_sum') {
|
||||
$now=1791032400;$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','assetId'=>'ev','managerId'=>17004,'batteryInstanceId'=>44234,
|
||||
'mode'=>$mode,'allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,'sources'=>[]];
|
||||
$roles=['grid','physical','physical'];if($mode==='virtual_split')$roles=array_merge($roles,['ev_request','sdl_request','gateway_active']);$values=[3000.0,-2000.0,-1000.0,-3000.0,0.0,true];$r=[];
|
||||
foreach($roles as$i=>$role){$id=100+$i;$c['sources'][]=['key'=>'s'.$id,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>'q'.$id,'factorToW'=>1];$r[$id]=['value'=>$values[$i],'updated'=>$now,'changed'=>$now-60,'parentID'=>200,'ident'=>'q'.$id,'confirmedAt'=>$now];}
|
||||
return [$c,$r,$now];
|
||||
}
|
||||
function readit($c,$r,$n){return F::read($c,fn($id)=>$r[$id],$n);}
|
||||
[$c,$r,$n]=fx();$f=readit($c,$r,$n);
|
||||
check($f['batteryW']===-3000.0&&$f['gridW']===3000.0,'physical total and grid signs retained');
|
||||
check($f['usableForTrial']&&!$f['estimated']&&!$f['canDispatch'],'usable feedback alone never grants dispatch');
|
||||
F::requireTrial($f,$c,$n);check(true,'matching trial binding accepted');
|
||||
check(F::fingerprint($c)===F::fingerprint(json_decode(json_encode($c),true)),'configuration hash stable over numeric property roundtrip');
|
||||
$d=$c;$d['sources']=array_reverse($d['sources']);check(F::fingerprint($c)===F::fingerprint($d),'source list order canonical');
|
||||
$d['trackingToleranceW']=201;check(F::fingerprint($c)!==F::fingerprint($d),'changed tolerance invalidates session hash');
|
||||
foreach([true,'1',NAN,INF,null]as$bad){$x=$r;$x[101]['value']=$bad;fails(fn()=>readit($c,$x,$n),'invalid physical number '.gettype($bad));}
|
||||
$x=$r;$x[101]['updated']=$n-61;$x[101]['changed']=$n-90;$f=readit($c,$x,$n);check($f['sourceVariableOldestAt']===$n-61,'confirmed read does not manufacture VariableUpdated');
|
||||
$x=$r;unset($x[101]['confirmedAt']);fails(fn()=>readit($c,$x,$n),'physical source without confirmed device read rejected');
|
||||
$x=$r;$x[101]['confirmedAt']=$n-31;fails(fn()=>readit($c,$x,$n),'incoherent confirmed device timestamps rejected');
|
||||
$x=$r;$x[101]['updated']=$n+1;fails(fn()=>readit($c,$x,$n),'future original sample rejected');
|
||||
$x=$r;$x[101]['parentID']=201;fails(fn()=>readit($c,$x,$n),'reused variable wrong parent rejected');
|
||||
$x=$r;$x[101]['ident']='other';fails(fn()=>readit($c,$x,$n),'reused variable wrong ident rejected');
|
||||
$d=$c;$d['sources'][1]['variableId']=$d['sources'][0]['variableId'];fails(fn()=>F::configuration($d),'duplicate physical source rejected');
|
||||
$d=$c;$d['maxAgeSeconds']=300;fails(fn()=>F::configuration($d),'historical freshness not accepted for live control');
|
||||
$calls=[];fails(function()use($c,$r,$n,&$calls){F::read($c,function($id)use($r,&$calls){$calls[$id]=($calls[$id]??0)+1;$x=$r[$id];if($calls[$id]>1)$x['value']+=1;return$x;},$n);},'same-second change during double read rejected');
|
||||
$d=$c;$d['sources'][1]['factorToW']=-1;check(readit($d,$r,$n)['batteryW']===1000.0,'vendor sign conversion once');
|
||||
$f=readit($c,$r,$n);fails(fn()=>F::requireTrial($f,$c,$n+3),'old validated snapshot not reused');
|
||||
$d=$c;$d['managerId']=20000;fails(fn()=>F::requireTrial($f,$d,$n),'different manager cannot use feedback');
|
||||
$d=$c;$d['feedbackConfigHash']=str_repeat('a',64);fails(fn()=>F::requireTrial($f,$d,$n),'changed mapping aborts trial');
|
||||
[$c,$r,$n]=fx('virtual_split');$r[103]['updated']=$r[104]['updated']=$r[105]['updated']=$n-86400;$r[103]['changed']=$r[104]['changed']=$r[105]['changed']=$n-172800;
|
||||
$r[101]['value']=$r[102]['value']=$r[103]['value']=$r[104]['value']=0.0;$f=readit($c,$r,$n);
|
||||
check($f['batteryW']===0.0&&$f['estimated'],'unchanged idle commands need no artificial heartbeat');
|
||||
check(!$f['usableForTrial'],'virtual estimate requires explicit acceptance');
|
||||
$c['allowEstimatedForTrial']=true;$f=readit($c,$r,$n);check($f['usableForTrial']&&!$f['gatewayWatchdogVerified'],'idle acceptance is not gateway watchdog proof');
|
||||
$r[101]['value']=20.0;$f=readit($c,$r,$n);check($f['batteryW']===20.0,'idle residual not clipped to zero');
|
||||
$r[101]['value']=60.0;fails(fn()=>readit($c,$r,$n),'unexplained idle power refused');
|
||||
$r[101]['value']=-2000.0;$r[102]['value']=-1000.0;$r[103]['value']=2000.0;$r[103]['updated']=$n;$r[103]['changed']=$n;
|
||||
$f=readit($c,$r,$n);check($f['batteryW']===-3000.0,'single EV feedback follows physics not desired sign or held filter');
|
||||
$r[104]['value']=1000.0;$r[104]['updated']=$n;$r[104]['changed']=$n;$r[101]['confirmedAt']=$n-1;
|
||||
fails(fn()=>readit($c,$r,$n),'SDL transition after physical confirmation cannot be guessed');
|
||||
[$c,$r,$n]=fx('virtual_split');$c['allowEstimatedForTrial']=true;$r[103]['value']=3000.0;$r[104]['value']=1000.0;$r[101]['value']=1900.0;$r[102]['value']=2000.0;$f=readit($c,$r,$n);
|
||||
check($f['batteryW']===2925.0&&$f['externalModelW']===975.0,'small tracking error allocated but explicitly estimated');
|
||||
check(!$f['separatelyMeasured']&&$f['heldFilterUsed']===false,'partition never labelled separately measured');
|
||||
$r[101]['value']=1000.0;fails(fn()=>readit($c,$r,$n),'large tracking error prevents stale virtual correction');
|
||||
$r[103]['value']=42000.0;$r[104]['value']=-40000.0;$r[101]['value']=0.0;$r[102]['value']=2000.0;$f=readit($c,$r,$n);
|
||||
check(!$f['usableForTrial']&&in_array('opposing_virtual_flows_not_identifiable',$f['reasons'],true),'counterflow cannot be independently attributed');
|
||||
$r[105]['value']=false;fails(fn()=>readit($c,$r,$n),'disabled gateway blocks feedback');
|
||||
$src=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php');
|
||||
check(!preg_match('/\b(?:SetValue|IPS_RequestAction|curl_exec|IPS_SetProperty|AC_SetLoggingStatus)\s*\(/',$src),'pure feedback has no control side effects');
|
||||
echo "TOTAL $count physical-feedback checks passed. Synthetic source readings only.\n";
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
// Offline only, no IP-Symcon or real device access.
|
||||
declare(strict_types=1);
|
||||
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline test only');
|
||||
require_once __DIR__.'/../../libs/NetzfahrplanV4Rueckmeldung.php';
|
||||
require_once __DIR__.'/../../libs/NetzfahrplanV4Geraeteabruf.php';
|
||||
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Geraeteabruf as D;
|
||||
|
||||
$count=0;
|
||||
function dcheck($ok,$name){global $count;if(!$ok)throw new RuntimeException('FAIL '.$name);$count++;echo "PASS $name\n";}
|
||||
function dfails($fn,$name){try{$fn();}catch(Throwable $e){dcheck(true,$name);return;}throw new RuntimeException('FAIL no rejection '.$name);}
|
||||
function dfixture(){
|
||||
$now=1791032400;
|
||||
$config=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','assetId'=>'ev','managerId'=>17004,'batteryInstanceId'=>44234,
|
||||
'mode'=>'physical_sum','allowEstimatedForTrial'=>false,'maxSkewSeconds'=>30,'maxAgeSeconds'=>60,'idleToleranceW'=>50.0,'trackingToleranceW'=>200.0,
|
||||
'sources'=>[
|
||||
['key'=>'grid','role'=>'grid','variableId'=>10,'parentId'=>20,'ident'=>'grid','factorToW'=>1.0],
|
||||
['key'=>'battery_a','role'=>'physical','variableId'=>11,'parentId'=>21,'ident'=>'a','factorToW'=>1.0],
|
||||
['key'=>'battery_b','role'=>'physical','variableId'=>12,'parentId'=>21,'ident'=>'b','factorToW'=>1.0],
|
||||
]];
|
||||
$records=[
|
||||
10=>['value'=>3000.0,'updated'=>$now-100,'changed'=>$now-200,'parentID'=>20,'ident'=>'grid'],
|
||||
11=>['value'=>-1000.0,'updated'=>$now-100,'changed'=>$now-200,'parentID'=>21,'ident'=>'a'],
|
||||
12=>['value'=>-2000.0,'updated'=>$now-100,'changed'=>$now-200,'parentID'=>21,'ident'=>'b'],
|
||||
];
|
||||
return [$config,$records,$now];
|
||||
}
|
||||
[$c,$r,$n]=dfixture();$calls=[];$times=[$n-1,$n-1,$n];
|
||||
$f=D::snapshot($c,fn($id)=>$id===20?D::MBUS_DEVICE:D::MODBUS_DEVICE,
|
||||
function($id,$module)use(&$calls){$calls[]=$id;return true;},
|
||||
fn($id)=>$r[$id],function()use(&$times){return array_shift($times);});
|
||||
sort($calls);dcheck($calls===[20,21],'one refresh per physical parent');
|
||||
dcheck($f['deviceReadConfirmed']===true&&$f['sourceOldestAt']===$n-1,'successful read confirmation is freshness basis');
|
||||
dcheck($f['sourceVariableOldestAt']===$n-100,'original VariableUpdated retained separately');
|
||||
dcheck($f['batteryW']===-3000.0&&$f['usableForTrial'],'unchanged values accepted after confirmed device response');
|
||||
|
||||
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];$attempts=0;$pauses=[];
|
||||
$f=D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,
|
||||
function()use(&$attempts){$attempts++;return $attempts>=2;},fn($id)=>$r[$id],
|
||||
function()use(&$times){return array_shift($times);},function($us)use(&$pauses){$pauses[]=$us;});
|
||||
dcheck($attempts===3&&$pauses===[200000],'transient busy read is retried once without relaxing confirmation');
|
||||
|
||||
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];
|
||||
dfails(fn()=>D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,fn()=>false,fn($id)=>$r[$id],function()use(&$times){return array_shift($times);}),
|
||||
'failed device read rejected');
|
||||
|
||||
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];$reads=0;
|
||||
$f=D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,fn()=>true,
|
||||
function($id)use(&$r,&$reads){$reads++;$x=$r[$id];if($reads>3&&$id===10){$x['value']++;$x['updated']++;$x['changed']++;}return $x;},
|
||||
function()use(&$times){return array_shift($times);});
|
||||
dcheck($f['gridW']===3000.0,'newer process value after confirmed response does not invalidate confirmed snapshot');
|
||||
|
||||
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];$reads=0;
|
||||
dfails(fn()=>D::snapshot($c,fn($id)=>D::MODBUS_DEVICE,fn()=>true,
|
||||
function($id)use(&$r,&$reads){$reads++;$x=$r[$id];if($reads>3&&$id===10)$x['parentID']=999;return $x;},
|
||||
function()use(&$times){return array_shift($times);}), 'source identity change after confirmed response rejected');
|
||||
|
||||
[$c,$r,$n]=dfixture();$times=[$n,$n,$n];
|
||||
dfails(fn()=>D::snapshot($c,fn($id)=>'{00000000-0000-0000-0000-000000000000}',fn()=>true,fn($id)=>$r[$id],
|
||||
function()use(&$times){return array_shift($times);}), 'unsupported device module rejected');
|
||||
|
||||
echo "TOTAL $count confirmed-device-read checks passed. Synthetic calls only.\n";
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// Exercise the EXACT serialized fallback wrapper from Manager/module.php in an isolated harness.
|
||||
if (function_exists('IPS_GetKernelVersion')) throw new RuntimeException('Offline only');
|
||||
$source=file_get_contents(__DIR__.'/../../Manager/module.php');
|
||||
$a=strpos($source,' private function regeln(bool $erneuern): void');$b=strpos($source,' private function regelnIntern(bool $erneuern): void',$a);
|
||||
if($a===false||$b===false)throw new RuntimeException('Manager wrapper source changed');
|
||||
$method=substr($source,$a,$b-$a);$calls=[];$locked=false;
|
||||
function IPS_SemaphoreEnter($k,$t){global$locked;if($locked)return false;$locked=true;return true;}
|
||||
function IPS_SemaphoreLeave($k){global$locked;$locked=false;}
|
||||
function IPS_GetInstance($id){return ['InstanceStatus'=>$GLOBALS['instance']->status];}
|
||||
eval('class FallbackHarness { const STATUS_AKTIV=102; public $InstanceID=17004,$trial=true,$active=true,$status=102,$abortFirst=true,$throwFirst=false,$passes=[],$attributes=[],$stops=[];'.
|
||||
'public function run(){ $this->regeln(false); } private function v4ManagerTestSession(){return $this->trial?[1]:[];} private function GetValue($k){return $this->active;}'.
|
||||
'private function WriteAttributeString($k,$v){$this->attributes[$k]=$v;} private function v4ManagerTestAbbrechen($s){$this->stops[]=$s;$this->trial=false;}'.
|
||||
'private function regelnIntern(bool $r):void { $this->passes[]=$r;if(count($this->passes)==1){if($this->throwFirst)throw new RuntimeException("synthetic_failure");if($this->abortFirst)$this->trial=false;}}'. $method.'}');
|
||||
$count=0;function ck($v,$s){global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
|
||||
$instance=new FallbackHarness();$instance->run();ck($instance->passes===[false,true],'trial abort performs exactly one fresh ordinary recomputation');
|
||||
ck($instance->attributes['LetzteSollwerte']==='{}','pre-trial cached target is discarded before fallback');ck(!$locked,'control semaphore released after fallback');
|
||||
$instance=new FallbackHarness();$instance->trial=false;$instance->run();ck($instance->passes===[false],'ordinary control path does not gain extra passes');
|
||||
$instance=new FallbackHarness();$instance->abortFirst=false;$instance->run();ck($instance->passes===[false],'valid trial not overridden by ordinary pass');
|
||||
$instance=new FallbackHarness();$instance->status=201;$instance->run();ck($instance->passes===[false],'invalid measurement status prevents forced ordinary recomputation');
|
||||
$instance=new FallbackHarness();$instance->throwFirst=true;try{$instance->run();}catch(RuntimeException $e){}
|
||||
ck(!$locked&&!$instance->trial&&count($instance->stops)===1,'exception revokes trial and releases semaphore without endless retries');
|
||||
echo "TOTAL $count actual manager fallback-wrapper checks passed. No real IPS runtime.\n";
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// Exact Battery message builder with no optional visible diagnostic variables.
|
||||
if(function_exists('IPS_GetKernelVersion'))throw new RuntimeException('Offline only');
|
||||
class IPSModule {
|
||||
public int $InstanceID=44234;public array $buffers=['V4FeedbackEstimated'=>'1'];public bool $valid=true;
|
||||
protected function GetBuffer($k){return $this->buffers[$k]??'';}
|
||||
protected function GetValue($k){throw new RuntimeException('No diagnostic variables exist: '.$k);}
|
||||
protected function ReadAttributeBoolean($k){return $k==='ZustandLadezustandGueltig'?$this->valid:false;}
|
||||
protected function ReadAttributeInteger($k){return 0;}
|
||||
protected function ReadAttributeFloat($k){return 0.0;}
|
||||
protected function ReadAttributeString($k){return $k==='Leistungsangebot'?'[0]':($k==='Betriebsart'?'Auto':'');}
|
||||
protected function ReadPropertyInteger($k){return 1;}
|
||||
}
|
||||
require __DIR__.'/../../Batterie/module.php';
|
||||
use Belevo\EnelixEMS\Nachrichtenvertrag as N;
|
||||
$b=new Batterie();$read=new ReflectionMethod(Batterie::class,'baueVerbraucherdaten');$count=0;
|
||||
function ck($v,$s){global$count;if(!$v)throw new RuntimeException('FAIL '.$s);$count++;echo "PASS $s\n";}
|
||||
$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_BERECHNET,'partition source quality independent of hidden diagnostic variables');
|
||||
$b->buffers['V4FeedbackEstimated']='0';$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_GEMESSEN,'ordinary physical source marked measured');
|
||||
$b->valid=false;$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_NICHT_VORHANDEN&&$d['Istleistung_W']===null,'invalid sample never published as measured');
|
||||
$b->valid=true;$b->buffers['V4FeedbackEstimated']='1';$b->buffers['V4BatteryTrialSession']='{}';$d=$read->invoke($b,17004);ck($d['Leistungsquelle']===N::LEISTUNGSQUELLE_BERECHNET,'last estimate not relabelled measured immediately after trial ends');
|
||||
$source=file_get_contents(__DIR__.'/../../Batterie/module.php');ck(str_contains($source,"SetBuffer('V4FeedbackEstimated', (\$messwerte['V4FeedbackEstimated'] ?? false) ? '1' : '0')"),'fresh measurement updates internal source quality');
|
||||
echo "TOTAL $count actual message-builder checks passed. No real variables or writes.\n";
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
namespace Belevo\EnelixEMS {
|
||||
if (\function_exists('IPS_GetKernelVersion')) throw new \RuntimeException('CLI test only');
|
||||
require_once __DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php';
|
||||
class Lizenzpruefung {const NETZFAHRPLAN='synthetic';}
|
||||
function time(){return $GLOBALS['now'];}
|
||||
function IPS_InstanceExists($id){return $id===44234;}
|
||||
function IPS_GetInstance($id){return ['ModuleInfo'=>['ModuleID'=>'{437FB683-517F-4FEC-8CCB-FE6B0A62B69E}']];}
|
||||
function IPS_GetProperty($id,$k){return ['IstleistungVariableID'=>1002,'LadezustandVariableID'=>1001,'NetzfahrplanV4RueckmeldungKonfiguration'=>$GLOBALS['configured']?'configured':'{}'][$k];}
|
||||
function IPS_VariableExists($id){return $id===1002;}
|
||||
function IPS_GetVariable($id){return ['VariableUpdated'=>$GLOBALS['now']];}
|
||||
function GetValue($id){return -9000.;} // deliberately held/incorrect legacy virtual display
|
||||
class FeedbackReceiver {
|
||||
use ManagerNetzfahrplanV4EmpfangTrait;
|
||||
public int $InstanceID=17004;public array $props,$attrs,$op;
|
||||
public function __construct($r,$op){$this->op=$op;$this->props=['NetzfahrplanV4EmpfangAktiv'=>true,'NetzfahrplanV4SchattenAktiv'=>true,'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>1002,'SOCVariableID'=>1001,'Leistungsfaktor'=>1]])];$this->attrs=['LizenzInstallationID'=>$r['installationId'],'NetzfahrplanV4Empfang'=>json_encode(['response'=>$r,'receivedAt'=>time()]),'NetzfahrplanV4EmpfangStatus'=>'{}'];}
|
||||
private function ReadPropertyBoolean($k){return $this->props[$k];}
|
||||
private function ReadPropertyString($k){return $this->props[$k];}
|
||||
private function ReadPropertyInteger($k){return $this->props[$k];}
|
||||
private function ReadAttributeString($k){return $this->attrs[$k];}
|
||||
private function aktiveVerbraucherIDs(){return [44234];}
|
||||
private function berechtigungLizenziert($k){return true;}
|
||||
public function GetNetzfahrplanV4Diagnose(){return json_encode(['operation'=>$this->op]);}
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
function ENELIX_GetV4BatterieRueckmeldung($id){$GLOBALS['now']+=($GLOBALS['feedbackDelay']??0);$GLOBALS['feedback']['checkedAt']=$GLOBALS['now'];return json_encode($GLOBALS['feedback']);}
|
||||
require_once __DIR__.'/../V4Receiver/fixture.php';
|
||||
use Belevo\EnelixEMS\FeedbackReceiver;
|
||||
[$response,$id,$op,$now]=receiverFixture();$configured=true;$count=0;$feedbackDelay=0;
|
||||
$feedback=['kind'=>'local_physical_feedback','version'=>1,'installationId'=>$id,'assetId'=>'b','managerId'=>17004,'batteryInstanceId'=>44234,'batteryW'=>0.,'gridW'=>$op['gridW'],'checkedAt'=>$now,'estimated'=>true,'usableForTrial'=>false,'canDispatch'=>false,'configHash'=>str_repeat('a',64)];
|
||||
function ck($v,$t){global$count;if(!$v)throw new RuntimeException('FAIL '.$t);$count++;echo "PASS $t\n";}
|
||||
$m=new FeedbackReceiver($response,$op);$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
|
||||
ck($p['status']==='preview_only'&&$p['actualBatteryW']==0,'real receiver uses corrected physical feedback rather than held -9kW');
|
||||
ck($p['previewBatteryW']==3000,'net target translated once to total battery demand');
|
||||
ck($p['canDispatch']===false&&$p['localFeedback']['b']['estimated']===true,'model feedback remains marked and preview grants no dispatch');
|
||||
$feedbackDelay=1;$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);$feedbackDelay=0;
|
||||
ck($p['status']==='preview_only','feedback timestamp produced after device read is compared with post-read clock');
|
||||
$feedback=['status'=>'unavailable','reason'=>'feedback_device_read_failed:grid','batteryW'=>null,'gridW'=>null];$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
|
||||
ck($p['status']==='unavailable'&&$p['previewBatteryW']===null,'configured feedback failure never falls back to held virtual display');
|
||||
ck(str_contains($p['reason'],'feedback_unavailable:feedback_device_read_failed:grid'),'configured feedback exposes bounded root cause without credentials');
|
||||
$configured=false;$p=json_decode($m->GetNetzfahrplanV4Vorschau(),true);
|
||||
ck($p['status']==='preview_only'&&$p['feedbackMeaning']==='legacy_display_only_not_for_trial','unconfigured old preview stays compatible but explicitly not for control');
|
||||
ck($p['canDispatch']===false,'legacy display cannot gain actuation permission');
|
||||
echo "TOTAL $count actual receiver checks passed; synthetic read-only controller API.\n";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
function receiverFixture(): array
|
||||
{
|
||||
$now = strtotime('2026-10-02T12:00:00Z');
|
||||
$id = 'e3a08f9e-af12-4695-99bd-8b51c0520021';
|
||||
$operation = ['eventId' => 'op-original', 'observedAt' => gmdate('c', $now), 'gridW' => -3000.0,
|
||||
'batteries' => [['id' => 'b', 'capacityKwh' => 20.0, 'socPercent' => 50.0, 'minSocPercent' => 15.0,
|
||||
'maxSocPercent' => 90.0, 'physicalMinSocPercent' => 3.0, 'rearmSocPercent' => 17.0,
|
||||
'gridCharging' => true, 'dischargeBlocked' => false, 'maxChargeW' => 39000.0, 'maxDischargeW' => 39000.0,
|
||||
'measuredAt' => gmdate('c', $now)]],
|
||||
'limits' => ['importW' => 25000.0, 'exportW' => 25000.0, 'managerMonthLimitsW' => []]];
|
||||
$points = [];
|
||||
for ($i = 0; $i < 6; $i++) $points[] = ['time' => gmdate('c', $now + $i*300), 'validUntil' => gmdate('c', $now + ($i+1)*300),
|
||||
'baselineGridW' => -3000.0, 'gridTargetW' => 0.0, 'batteryTargetW' => 3000.0, 'pvCurtailmentW' => 0.0,
|
||||
'assetTargetsW' => ['b' => 3000.0], 'socEndPercent' => ['b' => 50.0 + $i], 'intent' => 'pvCharge',
|
||||
'importLimitW' => 25000.0, 'exportLimitW' => 25000.0];
|
||||
$plan = ['schemaVersion' => 2, 'planId' => '10000000-0000-4000-8000-000000000001', 'installationId' => $id,
|
||||
'configRevision' => 1, 'sourceFamily' => '3', 'runMode' => 'shadow', 'liveEnabled' => false, 'executable' => true,
|
||||
'status' => 'optimal', 'generatedAt' => gmdate('c', $now), 'validFrom' => gmdate('c', $now),
|
||||
'validUntil' => gmdate('c', $now + 1800), 'pricesKnownUntil' => gmdate('c', $now + 1800),
|
||||
'controlContext' => \Belevo\EnelixEMS\NetzfahrplanV4Planpruefung::kontext($operation),
|
||||
'inputRefs' => ['operation' => 'op-original', 'forecast' => 'fc-original', 'tariffs' => 'tar-original'],
|
||||
'points' => $points, 'warnings' => ['Estimated peak; shadow only']];
|
||||
$response = ['receiverProtocolVersion' => 1, 'installationId' => $id, 'checkedAt' => gmdate('c', $now),
|
||||
'liveEnabled' => false, 'fresh' => true, 'pending' => null,
|
||||
'settings' => ['revision' => 1, 'family' => '3', 'runMode' => 'shadow', 'roundtripEfficiency' => .9],
|
||||
'families' => [['key' => '3'], ['key' => '13'], ['key' => '23']], 'plan' => $plan,
|
||||
'lastRun' => ['status' => 'optimal', 'detail' => ['planId' => $plan['planId']]]];
|
||||
return [$response, $id, $operation, $now];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../libs/NetzfahrplanV4Planpruefung.php';
|
||||
require_once __DIR__ . '/fixture.php';
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Planpruefung as P;
|
||||
$count = 0;
|
||||
function check($ok, string $label): void { global $count; if (!$ok) throw new RuntimeException('FAIL ' . $label); $count++;echo 'PASS ' . $label . "\n"; }
|
||||
function invalid(string $name, callable $change): void {
|
||||
[$r,$id,$op,$now] = receiverFixture();$receipt=$now;
|
||||
$change($r,$op,$now,$receipt);
|
||||
try { P::pruefen($r,$id,$op,$now,$receipt); } catch (Throwable $e) { check(true,$name);return; }
|
||||
check(false,$name);
|
||||
}
|
||||
[$r,$id,$op,$now]=receiverFixture();
|
||||
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0.0],$now);
|
||||
check($v['previewBatteryW']===3000.0 && $v['canDispatch']===false && $v['liveEnabled']===false,'zero grid uses actual surplus, never permission');
|
||||
invalid('wrong installation',fn(&$r)=>$r['installationId']='00000000-0000-4000-8000-000000000001');
|
||||
invalid('foreign plan',fn(&$r)=>$r['plan']['installationId']='00000000-0000-4000-8000-000000000001');
|
||||
invalid('old protocol',function(&$r){unset($r['receiverProtocolVersion']);});
|
||||
invalid('revision mismatch',fn(&$r)=>$r['plan']['configRevision']=2);
|
||||
invalid('boolean revision',fn(&$r)=>$r['plan']['configRevision']=true);
|
||||
invalid('live response',fn(&$r)=>$r['liveEnabled']=true);
|
||||
invalid('live plan',fn(&$r)=>$r['plan']['runMode']='live');
|
||||
invalid('fake executable',fn(&$r)=>$r['plan']['executable']='true');
|
||||
invalid('stale server flag',fn(&$r)=>$r['fresh']=false);
|
||||
invalid('pending computation',fn(&$r)=>$r['pending']=['reason'=>'new price']);
|
||||
invalid('last run different plan',fn(&$r)=>$r['lastRun']['detail']['planId']='wrong');
|
||||
invalid('last run invalid',fn(&$r)=>$r['lastRun']['status']='invalid_inputs');
|
||||
invalid('family mismatch',fn(&$r)=>$r['settings']['family']='23');
|
||||
invalid('unknown family',fn(&$r)=>$r['plan']['sourceFamily']='unregistered');
|
||||
invalid('receipt stale',function(&$r,&$op,&$now,&$receipt){$receipt-=91;});
|
||||
invalid('future receipt',function(&$r,&$op,&$now,&$receipt){$receipt++;});
|
||||
invalid('stale server response',function(&$r)use($now){$r['checkedAt']=gmdate('c',$now-91);});
|
||||
invalid('future plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now+10);});
|
||||
invalid('stale plan',function(&$r)use($now){$r['plan']['generatedAt']=gmdate('c',$now-901);});
|
||||
invalid('price horizon too short',function(&$r)use($now){$r['plan']['pricesKnownUntil']=gmdate('c',$now+300);});
|
||||
invalid('stale local operation',function(&$r,&$op)use($now){$op['observedAt']=gmdate('c',$now-61);});
|
||||
invalid('changed local reserve',fn(&$r,&$op)=>$op['batteries'][0]['minSocPercent']=30.0);
|
||||
invalid('changed local grid limit',fn(&$r,&$op)=>$op['limits']['importW']=2000.0);
|
||||
invalid('typed context required',fn(&$r)=>$r['plan']['controlContext']['batteries']['b']['capacityKwh']='20');
|
||||
invalid('input provenance missing',function(&$r){unset($r['plan']['inputRefs']);});
|
||||
invalid('unknown assets',function(&$r){$r['plan']['points'][0]['assetTargetsW']=['c'=>3000];});
|
||||
invalid('overlap',function(&$r){$r['plan']['points'][1]['time']=$r['plan']['points'][0]['time'];});
|
||||
invalid('gap',function(&$r)use($now){$r['plan']['points'][1]['time']=gmdate('c',$now+310);});
|
||||
invalid('bool target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']=false);
|
||||
invalid('string target',fn(&$r)=>$r['plan']['points'][0]['gridTargetW']='0');
|
||||
invalid('NaN target',fn(&$r)=>$r['plan']['points'][0]['batteryTargetW']=NAN);
|
||||
invalid('broken balance',fn(&$r)=>$r['plan']['points'][0]['baselineGridW']=10000.0);
|
||||
invalid('unknown intent',fn(&$r)=>$r['plan']['points'][0]['intent']='discharge_all');
|
||||
invalid('no timezone',fn(&$r)=>$r['plan']['points'][0]['time']='2026-10-02T12:00:00');
|
||||
invalid('bad calendar date',fn(&$r)=>$r['plan']['points'][0]['time']='2026-02-30T12:00:00Z');
|
||||
invalid('missing step',fn(&$r)=>$r['plan']['points']=[]);
|
||||
invalid('negative curtailment',fn(&$r)=>$r['plan']['points'][0]['pvCurtailmentW']=-100.0);
|
||||
[$r,$id,$op,$now]=receiverFixture();$r['checkedAt']=$op['observedAt']=gmdate('c',$now+300);$op['batteries'][0]['measuredAt']=$op['observedAt'];
|
||||
$x=P::pruefen($r,$id,$op,$now+300,$now+300);
|
||||
check($x['point']['time']===gmdate('c',$now+300),'half-open boundary picks current, not interpolated');
|
||||
[$r,$id,$op,$now]=receiverFixture();$r['plan']['sourceFamily']=$r['settings']['family']='future-family';$r['families'][]=['key'=>'future-family'];
|
||||
check(P::pruefen($r,$id,$op,$now,$now)['plan']['sourceFamily']==='future-family','registry is extensible');
|
||||
[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['dischargeBlocked']=true;
|
||||
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);
|
||||
check($v['previewBatteryW']===0.0,'local hysteresis prevents preview discharge');
|
||||
[$r,$id,$op,$now]=receiverFixture();$op['gridW']=3000.0;$op['batteries'][0]['socPercent']=15.001;
|
||||
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);
|
||||
check($v['previewBatteryW']>-3 && $v['previewBatteryW']<0,'remaining-step energy cannot cross reserve');
|
||||
[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['socPercent']=90;
|
||||
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.0,'full battery cannot charge');
|
||||
[$r,$id,$op,$now]=receiverFixture();$op['batteries'][0]['maxChargeW']=0;$op['limits']['exportW']=0.;$r['plan']['controlContext']=P::kontext($op);
|
||||
$x=P::pruefen($r,$id,$op,$now,$now);$v=P::vorschau($x,$op,['b'=>0],$now);check($v['unmetGridLimitW']===3000.0 && !$v['canDispatch'],'zero export limit reports unmet constraint');
|
||||
[$r,$id,$op,$now]=receiverFixture();$x=P::pruefen($r,$id,$op,$now,$now);$op['gridW']=2000;$op['batteries'][0]['gridCharging']=false;
|
||||
$x['point']['gridTargetW']=8000.;$x['point']['intent']='gridCharge';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'grid charging opt-in enforced');
|
||||
$op['batteries'][0]['gridCharging']=true;$x['point']['intent']='hold';$v=P::vorschau($x,$op,['b'=>0],$now);check($v['previewBatteryW']===0.,'nonzero hold does not accidentally charge');
|
||||
$x['point']['gridTargetW']=0.;$x['point']['intent']='discharge';$op['gridW']=0.;$v=P::vorschau($x,$op,['b'=>-2000],$now);check($v['previewBatteryW']===-2000.,'uses actual battery feedback, not incremental zero');
|
||||
try { P::dekodieren(str_repeat('x',P::MAX_RESPONSE_BYTES+1));check(false,'body limit'); } catch (InvalidArgumentException $e) {check(true,'body limit');}
|
||||
echo "Total: $count checks. Synthetic data only.\n";
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
namespace Belevo\EnelixEMS {
|
||||
require_once __DIR__ . '/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php';
|
||||
class Lizenzpruefung { public const NETZFAHRPLAN = 'grid_schedule'; }
|
||||
foreach (['CURLOPT_CUSTOMREQUEST','CURLOPT_FOLLOWLOCATION','CURLOPT_CONNECTTIMEOUT','CURLOPT_TIMEOUT',
|
||||
'CURLOPT_SSL_VERIFYPEER','CURLOPT_SSL_VERIFYHOST','CURLOPT_PROTOCOLS','CURLOPT_HTTPHEADER',
|
||||
'CURLOPT_WRITEFUNCTION','CURLOPT_POSTFIELDS','CURLINFO_HTTP_CODE','CURLPROTO_HTTPS'] as $i=>$name) {
|
||||
if (!defined($name)) define($name,50000+$i);
|
||||
}
|
||||
function time() { return $GLOBALS['clock']; }
|
||||
function IPS_SemaphoreEnter($name,$wait) { if ($GLOBALS['lock']) return false;$GLOBALS['lock']=true;return true; }
|
||||
function IPS_SemaphoreLeave($name) { $GLOBALS['lock']=false; }
|
||||
function IPS_VariableExists($id) {return $id===100;}
|
||||
function IPS_GetVariable($id) {return ['VariableUpdated'=>$GLOBALS['clock']];}
|
||||
function GetValue($id) {return $GLOBALS['actual'];}
|
||||
function curl_init($url) { return (object)['url'=>$url,'options'=>[],'code'=>0]; }
|
||||
function curl_setopt_array($h,$options) { $h->options=$options;return true; }
|
||||
function curl_getinfo($h,$key) { return $h->code; }
|
||||
function curl_close($h) {}
|
||||
function curl_exec($h) {
|
||||
$GLOBALS['requests'][]=['url'=>$h->url,'options'=>$h->options];
|
||||
$ack=str_ends_with($h->url,'/ack');
|
||||
$h->code=$ack?$GLOBALS['ackCode']:$GLOBALS['getCode'];
|
||||
$body=$ack?['status'=>'recorded']:$GLOBALS['response'];
|
||||
$json=json_encode($body,JSON_THROW_ON_ERROR);
|
||||
$callback=$h->options[CURLOPT_WRITEFUNCTION];
|
||||
return $callback($h,$json)===strlen($json);
|
||||
}
|
||||
final class ReceiverHarness {
|
||||
use ManagerNetzfahrplanV4EmpfangTrait;
|
||||
public int $InstanceID=17004;
|
||||
public array $props=[],$attrs=[],$vars=[],$timers=[];
|
||||
public bool $licensed=true;
|
||||
public array $session=[];
|
||||
public array $op=[];
|
||||
public function __construct($id,$op) {
|
||||
$this->op=$op;
|
||||
$this->props=['NetzfahrplanV4EmpfangAktiv'=>false,'NetzfahrplanV4SchattenAktiv'=>true,
|
||||
'NetzfahrplanAktiv'=>false,'MesswertMaxAlter'=>60,
|
||||
'AnlagenBatterien'=>json_encode([['ID'=>'b','LeistungVariableID'=>100,'Leistungsfaktor'=>1]])];
|
||||
$this->attrs=['LizenzInstallationID'=>$id,'PrognoseInstallationsToken'=>str_repeat('x',43)];
|
||||
$this->registriereNetzfahrplanV4Empfang();
|
||||
}
|
||||
public function receive() {$this->empfangeNetzfahrplanV4();}
|
||||
public function update() {$this->aktualisiereNetzfahrplanV4Vorschau();}
|
||||
public function stop() {$this->stoppeNetzfahrplanV4Vorschau();}
|
||||
public function configure() {$this->konfiguriereNetzfahrplanV4Vorschau();}
|
||||
public function GetNetzfahrplanV4Diagnose() { return json_encode(['operation'=>$this->op]); }
|
||||
private function aktiveVerbraucherIDs() {return [];}
|
||||
private function v4ManagerTestSession() {return $this->session;}
|
||||
private function berechtigungLizenziert($key) {return $this->licensed;}
|
||||
private function ReadPropertyBoolean($key) {return (bool)$this->props[$key];}
|
||||
private function ReadPropertyString($key) {return $this->props[$key];}
|
||||
private function ReadPropertyInteger($key) {return (int)$this->props[$key];}
|
||||
private function ReadAttributeString($key) {return $this->attrs[$key]??'';}
|
||||
private function ReadAttributeInteger($key) {return (int)($this->attrs[$key]??0);}
|
||||
private function WriteAttributeString($key,$val) {
|
||||
if (!str_starts_with($key,'NetzfahrplanV4')) throw new \RuntimeException('Unexpected external attribute');
|
||||
$this->attrs[$key]=$val;
|
||||
}
|
||||
private function WriteAttributeInteger($key,$val) { $this->attrs[$key]=$val; }
|
||||
private function RegisterPropertyBoolean($key,$default) {$this->props[$key]??=$default;}
|
||||
private function RegisterAttributeString($key,$default) {$this->attrs[$key]??=$default;}
|
||||
private function RegisterAttributeInteger($key,$default) {$this->attrs[$key]??=$default;}
|
||||
private function RegisterVariableString($key,$name,$profile,$pos) {$this->vars[$key]='';}
|
||||
private function RegisterTimer($key,$interval,$script) {$this->timers[$key]=$interval;}
|
||||
private function SetTimerInterval($key,$interval) {$this->timers[$key]=$interval;}
|
||||
private function SetValue($key,$value) {
|
||||
if (!in_array($key,['NetzfahrplanV4Vorschau','NetzfahrplanV4VorschauJSON'],true)) throw new \RuntimeException('Actuator/foreign variable write');
|
||||
$this->vars[$key]=$value;
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
require_once __DIR__.'/fixture.php';
|
||||
use Belevo\EnelixEMS\ReceiverHarness;
|
||||
$count=0;
|
||||
function check($ok,$name) {global $count;if (!$ok) throw new RuntimeException('FAIL '.$name);$count++;echo 'PASS '.$name."\n";}
|
||||
function fixture() {
|
||||
[$r,$id,$op,$now]=receiverFixture();
|
||||
$GLOBALS['response']=$r;$GLOBALS['clock']=$now;$GLOBALS['actual']=0.;$GLOBALS['requests']=[];
|
||||
$GLOBALS['lock']=false;$GLOBALS['getCode']=200;$GLOBALS['ackCode']=200;
|
||||
return new ReceiverHarness($id,$op);
|
||||
}
|
||||
function advance($h,$seconds) {
|
||||
$GLOBALS['clock']+=$seconds;$GLOBALS['response']['checkedAt']=gmdate('c',$GLOBALS['clock']);
|
||||
$h->op['observedAt']=gmdate('c',$GLOBALS['clock']);$h->op['batteries'][0]['measuredAt']=$h->op['observedAt'];
|
||||
}
|
||||
$h=fixture();$h->receive();check(count($requests)===0,'default disabled no network');
|
||||
$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->configure();check($h->timers['NetzfahrplanV4VorschauAktualisieren']===10000,'watchdog without network');
|
||||
$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
|
||||
check($v['status']==='preview_only' && $v['previewBatteryW']===3000 && $v['canDispatch']===false,'read and show only');
|
||||
check(count($requests)===2 && str_ends_with($requests[1]['url'],'/ack'),'one read and one acknowledgement');
|
||||
$body=json_decode($requests[1]['options'][CURLOPT_POSTFIELDS],true);check($body['status']==='shadow_seen' && $body['step']===$response['plan']['points'][0]['time'],'ack is shadow_seen, never applied');
|
||||
$h->receive();check(count($requests)===2,'repeated click throttled');
|
||||
advance($h,60);$h->receive();check(count($requests)===3,'same plan step not acknowledged twice');
|
||||
advance($h,91);$h->update();$v=json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true);
|
||||
check($v['status']==='unavailable' && $v['previewBatteryW']===null,'expired cache clears numeric targets, not fake zero');
|
||||
check(!str_contains($h->vars['NetzfahrplanV4Vorschau'],'3000'),'expired HTML clears previous setpoint');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();advance($h,61);$GLOBALS['getCode']=429;$h->receive();
|
||||
check($h->attrs['NetzfahrplanV4Empfang']==='{}' && json_decode($h->GetNetzfahrplanV4Vorschau(),true)['previewBatteryW']===null,'429 invalidates cache');
|
||||
check(!str_contains(json_encode($h->vars),str_repeat('x',43)),'no credentials in diagnosis');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->props['NetzfahrplanAktiv']=true;$h->receive();check(count($requests)===0,'legacy execution on blocks preview acquisition');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['response']['installationId']='00000000-0000-4000-8000-000000000001';$h->receive();
|
||||
check(count($requests)===1 && $h->attrs['NetzfahrplanV4Empfang']==='{}','foreign response not acknowledged');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['ackCode']=503;$h->receive();$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
|
||||
check($v['status']==='preview_only' && !$v['reception']['acknowledged'] && isset($v['reception']['acknowledgementError']),'ack failure is visible, no false execution confirmation');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->op['batteries'][0]['minSocPercent']=30.;$h->update();
|
||||
check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'local policy change invalidates cache');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->stop();check($h->attrs['NetzfahrplanV4Empfang']==='{}' && $h->timers['NetzfahrplanV4VorschauAktualisieren']===0,'apply/restart invalidates cached plan');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->licensed=false;$h->receive();check(count($requests)===0,'no license no read');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['lock']=true;$h->receive();check(count($requests)===0,'concurrent receiver does not send');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->receive();$h->props['NetzfahrplanV4EmpfangAktiv']=false;$h->update();check(json_decode($h->vars['NetzfahrplanV4VorschauJSON'],true)['previewBatteryW']===null,'disable clears displayed target');
|
||||
$source=file_get_contents(__DIR__.'/../../libs/ManagerNetzfahrplanV4EmpfangTrait.php');
|
||||
check(!str_contains($source, 'netzfahrplanV4EmpfangFormular'), 'receiver no longer owns a separate form panel');
|
||||
check(!preg_match('/\b(?:SendDataToChildren|SendDataToParent|sendeManagerdaten|regeln|SetStatus|EnableAction|IPS_SetProperty|IPS_ApplyChanges)\s*\(/',$source),'no control or external configuration call in receiver');
|
||||
check(!str_contains($source,"'status' => 'applied'"),'never acknowledges applied');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->configure();
|
||||
check($h->timers['NetzfahrplanV4EmpfangAktualisieren']===10000,'refresh has an independent timer');
|
||||
$h->session=['kind'=>'local_active_test_consent'];$h->receive();$saved=$h->attrs['NetzfahrplanV4Empfang'];
|
||||
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+45,'active refresh due after 45 seconds');
|
||||
advance($h,45);$GLOBALS['getCode']=503;$h->receive();
|
||||
check($h->attrs['NetzfahrplanV4Empfang']===$saved,'temporary error retains exact original active envelope');
|
||||
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+15,'temporary error retries after 15 seconds');
|
||||
advance($h,46);$h->receive();
|
||||
check($h->attrs['NetzfahrplanV4Empfang']==='{}','active envelope older than 90 seconds is discarded');
|
||||
$h->stop();check($h->timers['NetzfahrplanV4EmpfangAktualisieren']===0,'stop disables independent refresh timer');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$h->session=['kind'=>'local_active_test_consent'];$h->receive();
|
||||
$future=json_decode($h->attrs['NetzfahrplanV4Empfang'],true);$future['receivedAt']=$clock+120;
|
||||
$h->attrs['NetzfahrplanV4Empfang']=json_encode($future);advance($h,45);$GLOBALS['getCode']=503;$h->receive();
|
||||
check($h->attrs['NetzfahrplanV4Empfang']==='{}','future receipt cannot survive failed refresh');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['response']['pending']=['sequence'=>1];$GLOBALS['response']['fresh']=false;$h->receive();
|
||||
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+15 && $h->attrs['NetzfahrplanV4Empfang']==='{}',
|
||||
'pending startup retries after 15 seconds without creating a usable envelope');
|
||||
$v=json_decode($h->GetNetzfahrplanV4Vorschau(),true);
|
||||
check(($v['reception']['status']??null)==='rejected_or_unavailable','unavailable preview preserves receiver rejection detail');
|
||||
$h=fixture();$h->props['NetzfahrplanV4EmpfangAktiv']=true;$GLOBALS['getCode']=429;$h->receive();
|
||||
check($h->attrs['NetzfahrplanV4NaechsterAbruf']===$clock+60,'startup rate-limit response retains ordinary backoff');
|
||||
echo "Total: $count checks. Mocked IPS and HTTP only.\n";
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__.'/../../libs/NetzfahrplanV4Messsicht.php';
|
||||
require_once __DIR__.'/fixture.php';
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Messaufnahme as Capture;
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Messsicht as View;
|
||||
$n=0;
|
||||
function ck(bool $v,string $label):void{global $n;if(!$v)throw new RuntimeException('FAIL '.$label);$n++;echo 'PASS '.$label."\n";}
|
||||
function refuses(callable $f,string $label):void{try{$f();}catch(Throwable $e){ck(true,$label);return;}throw new RuntimeException('Expected refusal: '.$label);}
|
||||
function take($c,$p,$r,$now):array{return View::calculate(Capture::capture($c,static fn($id)=>$r[$id],static fn()=>$now),$c,$p);}
|
||||
[$c,$p,$r,$now]=separatedFixture();$x=take($c,$p,$r,$now);
|
||||
ck($x['physicalLoad']['loadCandidateW']===6000.,'physical sources subtracted once');
|
||||
ck($x['solarAcAlternative']['loadCandidateW']===6000.,'solar AC terminal used without second battery subtraction');
|
||||
ck($x['solarOrigin']['reconstructedPvCandidateW']===1000.,'original mantissa scale and battery retained');
|
||||
ck($x['allocationModel']['unfilteredModelEvW']===2500.&&$x['allocationModel']['unfilteredModelSdlW']===500.,'unfiltered partition uses physical feedback');
|
||||
ck(!$x['canDispatch']&&!$x['trainingEligible']&&!$x['allocationModel']['separatelyMeasured'],'no measurement or control permission invented');
|
||||
$a=$r;$a[106]['value']=-9000;$x=take($c,$p,$a,$now);
|
||||
ck($x['physicalLoad']['status']==='consistent_candidate'&&$x['physicalLoad']['loadCandidateW']===6000.,'held virtual output cannot contaminate physical load');
|
||||
ck($x['allocationModel']['unfilteredModelEvW']===2500.&&!$x['allocationModel']['reportedAllocationWithinTolerance'],'held EV value only compared not reused');
|
||||
$a=$r;$a[107]['value']='never-export-this';$x=take($c,$p,$a,$now);
|
||||
ck($x['physicalLoad']['status']==='consistent_candidate','invalid SDL output does not veto physical load');
|
||||
ck(!str_contains(json_encode($x),'never-export-this'),'unknown string is not propagated');
|
||||
$a=$r;$a[108]['value']=42000;$a[109]['value']=-40000;$a[104]['value']=2000;$a[105]['value']=0;$x=take($c,$p,$a,$now);
|
||||
ck($x['allocationModel']['unfilteredModelEvW']===42000.&&$x['allocationModel']['unfilteredModelSdlW']===-40000.,'opposed commands do not divide by near-zero net');
|
||||
$a=$r;$a[108]['value']=0;$a[109]['value']=0;$x=take($c,$p,$a,$now);
|
||||
ck($x['allocationModel']['unfilteredModelEvW']===null&&$x['allocationModel']['unallocatedPhysicalW']===3000.,'idle physical power kept unallocated not invented as EV');
|
||||
$a=$r;$a[104]['value']=-3125;$a[105]['value']=0;$a[108]['value']=-10500;$a[109]['value']=0;$a[106]['value']=-8334;$a[107]['value']=0;$x=take($c,$p,$a,$now);
|
||||
ck($x['allocationModel']['unfilteredModelEvW']===-3125.,'real export-example candidate is physical -3125 not held -8334');
|
||||
ck($x['allocationModel']['status']==='quality_hold'&&in_array('physical_request_tracking_error',$x['allocationModel']['issues'],true),'tracking disagreement remains visible');
|
||||
$a=$r;$a[108]['changed']=$now;$a[104]['updated']=$now-10;$x=take($c,$p,$a,$now);
|
||||
ck(in_array('request_newer_than_feedback:req_ev',$x['allocationModel']['issues'],true),'post-command feedback not pretended');
|
||||
$a=$r;unset($a[108]['changed']);$x=take($c,$p,$a,$now);
|
||||
ck(in_array('request_change_time_unknown:req_ev',$x['allocationModel']['issues'],true),'unknown command change time explicit');
|
||||
$a=$r;$a[105]['updated']=$now-70;$x=take($c,$p,$a,$now);
|
||||
ck($x['storageFeedback']['status']==='quality_hold'&&$x['physicalLoad']['status']==='quality_hold','old physical source blocks physical feedback');
|
||||
ck($x['solarAcAlternative']['status']==='consistent_candidate','AC alternative does not inherit unrelated solar battery age');
|
||||
$a=$r;$a[111]['updated']=$now-70;$x=take($c,$p,$a,$now);
|
||||
ck($x['solarAcAlternative']['status']==='quality_hold'&&$x['physicalLoad']['status']==='quality_hold','fresh solar derived output cannot hide old scale origin');
|
||||
$a=$r;$a[110]['value']=500;$a[111]['value']=1;$a[103]['value']=6000;$x=take($c,$p,$a,$now);
|
||||
ck($x['solarOrigin']['acTerminalCandidateW']===5000.&&$x['solarAcAlternative']['loadCandidateW']===11000.,'signed scale applied exactly once');
|
||||
$a=$r;$a[110]['value']=-500;$a[111]['value']=0;$a[105]['value']=0;$a[103]['value']=0;$x=take($c,$p,$a,$now);
|
||||
ck($x['solarOrigin']['reconstructedPvCandidateW']===-500.&&$x['solarAcAlternative']['loadCandidateW']===5500.,'negative AC not clamped or disguised as PV');
|
||||
foreach([true,'0',INF,NAN,-32768]as$invalid){$a=$r;$a[111]['value']=$invalid;$x=take($c,$p,$a,$now);ck($x['solarOrigin']['acTerminalCandidateW']===null,'invalid scale has no numeric fallback');}
|
||||
$a=$r;$a[110]['value']=-32768;$x=take($c,$p,$a,$now);ck($x['solarOrigin']['acTerminalCandidateW']===null,'SunSpec invalid power sentinel rejected');
|
||||
$a=$r;$a[102]['updated']=$now-40;$x=take($c,$p,$a,$now);
|
||||
ck($x['physicalLoad']['status']==='quality_hold'&&$x['storageFeedback']['status']==='consistent_candidate','slow PV does not veto independent storage feedback');
|
||||
$a=$r;$a[108]['updated']=$now-90;$x=take($c,$p,$a,$now);ck($x['allocationModel']['status']==='quality_hold','old request timestamp visible');
|
||||
$a=$r;$a[104]['updated']=$now+1;$x=take($c,$p,$a,$now);ck($x['storageFeedback']['status']==='quality_hold','future physical value refused');
|
||||
$a=$r;$a[104]['parentID']=999;$x=take($c,$p,$a,$now);ck($x['storageFeedback']['physicalStorageCandidateW']===null,'reused measurement identity not accepted');
|
||||
$a=$r;$a[101]['value']=-20000;$x=take($c,$p,$a,$now);ck($x['physicalLoad']['loadCandidateW']<0&&$x['physicalLoad']['status']==='quality_hold','negative load candidate retained as invalid not zero');
|
||||
$a=$r;foreach($a as &$v)$v['value']=0;unset($v);$x=take($c,$p,$a,$now);ck($x['physicalLoad']['loadCandidateW']===0.&&$x['physicalLoad']['status']==='consistent_candidate','real zero observation accepted');
|
||||
$raw=Capture::capture($c,static fn($id)=>$r[$id],static fn()=>$now);$raw['assessment']['baseLoadCandidateW']=999999.;$x=View::calculate($raw,$c,$p);ck($x['physicalLoad']['loadCandidateW']===6000.,'assessment independently recalculated');
|
||||
foreach(['installationId','mappingSha256','reportedInventorySha256']as$key){$bad=$raw;$bad[$key]='wrong';refuses(fn()=>View::calculate($bad,$c,$p),'wrong identity '.$key);}
|
||||
$bad=$p;$bad['sdlRequestKey']='req_ev';refuses(fn()=>View::calculate($raw,$c,$bad),'duplicate request channels refused');
|
||||
$bad=$p;$bad['solarReference']['scaleKey']='soc';refuses(fn()=>View::calculate($raw,$c,$bad),'scale cannot be an arbitrary percent channel');
|
||||
$before=json_encode([$raw,$c,$p]);View::calculate($raw,$c,$p);ck(json_encode([$raw,$c,$p])===$before,'inputs not mutated');
|
||||
$src=file_get_contents(__DIR__.'/../../libs/NetzfahrplanV4Messsicht.php');ck(!preg_match('/\b(?:IPS_|GetValue\(|SetValue\(|RequestAction\(|curl_|exec\s*\()/',$src),'pure model has no control or native calls');
|
||||
echo "TOTAL $n checks passed. Synthetic observations only.\n";
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
function separatedFixture(): array
|
||||
{
|
||||
$now=strtotime('2026-10-02T16:00:00Z');
|
||||
$c=['version'=>1,'installationId'=>'e3a08f9e-af12-4695-99bd-8b51c0520021','managerId'=>17004,'reportedInventorySha256'=>str_repeat('a',64),
|
||||
'accounting'=>['version'=>1,'maxSkewSeconds'=>30,'splitToleranceW'=>100.,'sources'=>[]],'extraSources'=>[]];
|
||||
$defs=[['grid','grid',3000],['pv_g','pv',5000],['pv_se','pv',1000],['bat_g','physical_storage',2000],['bat_se','physical_storage',1000],['ev','controlled_virtual',2500],['sdl','external_virtual',500]];
|
||||
$r=[];
|
||||
foreach($defs as $i=>[$key,$role,$value]){$id=101+$i;$c['accounting']['sources'][]=['key'=>$key,'role'=>$role,'variableId'=>$id,'parentId'=>200,'ident'=>$key,'factorToW'=>1.,'maxAgeSeconds'=>60,'dependsOn'=>in_array($role,['controlled_virtual','external_virtual'],true)?['bat_g','bat_se']:[]];$r[$id]=['value'=>$value,'updated'=>$now,'changed'=>$now-30,'parentID'=>200,'ident'=>$key];}
|
||||
foreach([['req_ev','W',2500],['req_sdl','W',500],['se_ac','raw_power',0],['se_sf','scale',0],['soc','percent',50]] as $i=>[$key,$unit,$value]){
|
||||
$id=108+$i;$c['extraSources'][]=['key'=>$key,'variableId'=>$id,'parentId'=>200,'ident'=>$key,'unit'=>$unit];$r[$id]=['value'=>$value,'updated'=>$now,'changed'=>$now-30,'parentID'=>200,'ident'=>$key];}
|
||||
$p=['version'=>1,'evRequestKey'=>'req_ev','sdlRequestKey'=>'req_sdl','extraMaxAgeSeconds'=>60,'trackingToleranceW'=>100.,'solarReference'=>['pvKey'=>'pv_se','batteryKey'=>'bat_se','rawKey'=>'se_ac','scaleKey'=>'se_sf']];
|
||||
return [$c,$p,$r,$now];
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__.'/fixture.php';
|
||||
$n=0;
|
||||
function ck($ok,$label){global $n;if(!$ok)throw new RuntimeException('FAIL '.$label);$n++;echo 'PASS '.$label."\n";}
|
||||
function IPS_GetKernelVersion(){return '8.0';}
|
||||
function IPS_InstanceExists($id){return $id===17004;}
|
||||
function IPS_GetInstance($id){if($id!==17004)throw new RuntimeException('foreign instance');return ['ModuleInfo'=>['ModuleID'=>'{6F771B18-59D4-4C8A-B951-3B2FE9F6A2C4}']];}
|
||||
function IPS_GetChildrenIDs($parent){return array_keys(array_filter($GLOBALS['objects'],fn($o)=>$o['ParentID']===$parent));}
|
||||
function IPS_GetObject($id){return $GLOBALS['objects'][$id];}
|
||||
function IPS_GetVariable($id){return $GLOBALS['variables'][$id];}
|
||||
function IPS_VariableExists($id){return isset($GLOBALS['variables'][$id]);}
|
||||
function GetValue($id){return $GLOBALS['values'][$id];}
|
||||
function SetValue($id,$v){if($id<50000||!is_string($v))throw new RuntimeException('FORBIDDEN source/actuator write');$GLOBALS['values'][$id]=$v;return true;}
|
||||
function IPS_SemaphoreEnter($name,$wait){if($GLOBALS['locks'][$name]??false)return false;$GLOBALS['locks'][$name]=true;return true;}
|
||||
function IPS_SemaphoreLeave($name){$GLOBALS['locks'][$name]=false;}
|
||||
function makeObject($type){$id=++$GLOBALS['next'];$GLOBALS['objects'][$id]=['ParentID'=>0,'ObjectIdent'=>'','ObjectType'=>$type,'ObjectInfo'=>''];return $id;}
|
||||
function IPS_CreateCategory(){return makeObject(0);}
|
||||
function IPS_CreateVariable($type){ck($type===3,'only new textual observation variables');$id=makeObject(2);$GLOBALS['variables'][$id]=['VariableType'=>3];$GLOBALS['values'][$id]='';return $id;}
|
||||
function IPS_CreateScript($type){if($type!==0)throw new RuntimeException('script type');$id=makeObject(3);$GLOBALS['scripts'][$id]='';return $id;}
|
||||
function changeObject($id,$key,$v){if($id<50000)throw new RuntimeException('FORBIDDEN existing object change');$GLOBALS['objects'][$id][$key]=$v;return true;}
|
||||
function IPS_SetParent($id,$v){return changeObject($id,'ParentID',$v);}
|
||||
function IPS_SetIdent($id,$v){return changeObject($id,'ObjectIdent',$v);}
|
||||
function IPS_SetName($id,$v){return changeObject($id,'ObjectName',$v);}
|
||||
function IPS_SetInfo($id,$v){return changeObject($id,'ObjectInfo',$v);}
|
||||
function IPS_GetScriptContent($id){return $GLOBALS['scripts'][$id];}
|
||||
function IPS_SetScriptContent($id,$s){if($id<50000)throw new RuntimeException('existing script');$GLOBALS['scripts'][$id]=$s;return true;}
|
||||
function IPS_SetScriptTimer($id,$seconds){if($id<50000||!isset($GLOBALS['scripts'][$id])||!in_array($seconds,[0,30],true))throw new RuntimeException('FORBIDDEN timer change');$GLOBALS['timers'][$id]=$seconds;return true;}
|
||||
[$c,$p,$r,$ignored]=separatedFixture();$now=time();$objects=[];$variables=[];$values=[];$scripts=[];$timers=[];$locks=[];$next=50000;
|
||||
foreach($r as$id=>$v){$objects[$id]=['ParentID'=>$v['parentID'],'ObjectIdent'=>$v['ident'],'ObjectType'=>2,'ObjectInfo'=>'original'];$variables[$id]=['VariableType'=>is_int($v['value'])?1:2,'VariableUpdated'=>$now,'VariableChanged'=>$now-30];$values[$id]=$v['value'];}
|
||||
$sourceValues=$values;$base=sys_get_temp_dir().'/v4views-test-'.bin2hex(random_bytes(6));mkdir($base,0700);mkdir($base.'/data',0700);
|
||||
$files=['source/libs/NetzfahrplanV4Bilanzierung.php','source/libs/NetzfahrplanV4Messaufnahme.php','source/libs/NetzfahrplanV4Messsicht.php','source/examples/V4SeparatedObservation/runtime.php','source/examples/V4SeparatedObservation/install.php'];
|
||||
foreach($files as$name){$dest=$base.'/'.$name;if(!is_dir(dirname($dest)))mkdir(dirname($dest),0700,true);copy(__DIR__.'/../../'.substr($name,7),$dest);}
|
||||
file_put_contents($base.'/INVENTORY.json','{"effectiveEvCapacityKwh":161.44,"evPowerKw":39}');$c['reportedInventorySha256']=hash_file('sha256',$base.'/INVENTORY.json');
|
||||
file_put_contents($base.'/config.json',json_encode($c,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));file_put_contents($base.'/policy.json',json_encode($p,JSON_THROW_ON_ERROR|JSON_PRESERVE_ZERO_FRACTION));
|
||||
$manifest=['scope'=>'passive_separated_observation','hashes'=>[]];foreach(array_merge($files,['config.json','policy.json','INVENTORY.json'])as$name)$manifest['hashes'][$name]=hash_file('sha256',$base.'/'.$name);file_put_contents($base.'/MANIFEST.json',json_encode($manifest));
|
||||
require_once $base.'/source/examples/V4SeparatedObservation/install.php';
|
||||
try{
|
||||
ob_start();$report=v4viewsInstall($base);ob_end_clean();ck($report['status']==='observer_installed','native installation completes');$cat=$report['categoryId'];
|
||||
ck(count($report['createdObjects'])===6,'one new category three read-only variables two scripts');
|
||||
ck($timers===[$report['scriptId']=>30],'only new passive observation timer');
|
||||
ck($report['firstObservation']['status']==='recorded','actual first record precedes timer');
|
||||
$journal=$report['firstObservation']['journal'];$original=file_get_contents($journal);
|
||||
ck((fileperms($journal)&0777)===0644&&(fileperms(dirname($journal))&0077)===0,'selected new output readable inside private directory');
|
||||
foreach($sourceValues as$id=>$value)ck($values[$id]===$value,'source unchanged '.$id);
|
||||
ob_start();$again=v4viewsInstall($base);ob_end_clean();ck($again['createdObjects']===[],'idempotent no duplicate scripts or variables');
|
||||
ck(str_starts_with(file_get_contents($journal),$original),'repeat preserves historical observations');
|
||||
$before=file_get_contents($journal);$locks['ENELIX.V4.SeparatedView.'.$c['installationId']]=true;$busy=v4viewsRun($base,$cat);ck($busy['status']==='busy'&&file_get_contents($journal)===$before,'parallel observer does not duplicate writes');$locks=[];
|
||||
unset($variables[101]);$failed=v4viewsRun($base,$cat);ck($failed['status']==='recorded','missing measurement explicitly recorded');
|
||||
$latest=json_decode(file_get_contents($base.'/data/LATEST.json'),true);ck($latest['views']['physicalLoad']['loadCandidateW']===null&&!$latest['controlEnabled'],'bad source never old target or fake zero');$variables[101]=['VariableType'=>1,'VariableUpdated'=>$now,'VariableChanged'=>$now-30];
|
||||
$before=file_get_contents($journal);v4viewsStop($base,$cat);ck($timers[$report['scriptId']]===0&&file_get_contents($journal)===$before,'stop only observer no deletion');
|
||||
ck(json_decode(file_get_contents($base.'/data/LATEST.json'),true)['status']==='stopped','stopped view clears latest numbers');
|
||||
$scripts[$report['scriptId']].='// parallel edit';ob_start();$bad=v4viewsInstall($base);ob_end_clean();ck($bad['status']==='needs_review'&&$timers[$report['scriptId']]===0,'parallel script edit not overwritten or activated');
|
||||
$old=file_get_contents($base.'/config.json');file_put_contents($base.'/config.json',$old.' ');try{v4viewsConfig($base);$refused=false;}catch(Throwable $e){$refused=true;}ck($refused,'changed config refused');file_put_contents($base.'/config.json',$old);
|
||||
chmod($base,0755);clearstatcache();try{v4viewsConfig($base);$refused=false;}catch(Throwable $e){$refused=true;}ck($refused,'public stage refused before reading output');chmod($base,0700);
|
||||
foreach(['runtime.php','install.php']as$file){$text=file_get_contents($base.'/source/examples/V4SeparatedObservation/'.$file);ck(!preg_match('/\b(?:RequestAction|IPS_ApplyChanges|IPS_SetProperty|IPS_SetConfiguration|MC_Reload|curl_exec|shell_exec)\s*\(/',$text),'no actuation or configuration calls in '.$file);}
|
||||
}finally{
|
||||
foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST)as$f){$f->isDir()&&!$f->isLink()?rmdir($f->getPathname()):unlink($f->getPathname());}rmdir($base);
|
||||
}
|
||||
echo "TOTAL $n native-mock checks passed. No actual Symcon calls.\n";
|
||||
Vendored
+140
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Betriebsdaten;
|
||||
use RuntimeException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/** Runs offline. No IPS API, network, physical devices or production settings. */
|
||||
final class NativeV4Scenarios
|
||||
{
|
||||
public static function run(): array
|
||||
{
|
||||
$now = strtotime('2026-10-01T12:05:00Z');
|
||||
$m = ['NetzleistungVariableID' => 1, 'NetzbezugEnergieVariableID' => 9,
|
||||
'Netzleistungsfaktor' => 1.0, 'MesswertMaxAlter' => 60, 'Lastspitzenmodus' => 1,
|
||||
'Lastspitzengrenze' => 25000.0, 'EinspeisebegrenzungAktiv' => true, 'Einspeisegrenze' => 25000.0];
|
||||
$m['V4BezugszaehlerQuellen'] = [
|
||||
['VariableID' => 6, 'ElternID' => 99, 'Ident' => 'Energy_0', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
|
||||
['VariableID' => 7, 'ElternID' => 99, 'Ident' => 'Energy_1', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
|
||||
];
|
||||
$a = [['ID' => 'ev', 'Nennkapazitaet_kWh' => 161.44, 'Nutzkapazitaet_kWh' => 161.44,
|
||||
'MaxLadeleistung_kW' => 39.0, 'MaxEntladeleistung_kW' => 30.0, 'SOCVariableID' => 2, 'LeistungVariableID' => 5]];
|
||||
$c = [['InstanzID' => 42, 'LadezustandVariableID' => 2, 'MaxLadeleistungVariableID' => 3,
|
||||
'MaxEntladeleistungVariableID' => 4, 'IstleistungVariableID' => 5,
|
||||
'ReserveLadezustand' => 15.0, 'MindestLadezustand' => 3.0, 'MesswertMaxAlter' => 30,
|
||||
'Verfuegbar' => true, 'Batteriemanagement' => 2, 'EmpfangenAm' => $now]];
|
||||
$read = static function (int $id) use ($now): array {
|
||||
return ['value' => [1 => -5332.0, 2 => 19.0, 3 => 39000.0, 4 => 35000.0, 6 => 3992.203, 7 => 0.0][$id],
|
||||
'updated' => $now, 'parentID' => 99, 'ident' => [6 => 'Energy_0', 7 => 'Energy_1'][$id] ?? 'other'];
|
||||
};
|
||||
$check = static function (bool $ok): void { if (!$ok) { throw new RuntimeException('Assertion failed'); } };
|
||||
$fail = static function (callable $fn): void {
|
||||
try { $fn(); } catch (InvalidArgumentException $e) { return; }
|
||||
throw new RuntimeException('Expected invalid-input rejection');
|
||||
};
|
||||
$cases = [];
|
||||
$build = static function ($mm = null, $aa = null, $cc = null, $r = null, $e = []) use ($m, $a, $c, $read, $now): array {
|
||||
return NetzfahrplanV4Betriebsdaten::erstellen($mm ?? $m, $aa ?? $a, $cc ?? $c, $r ?? $read, $e, $now);
|
||||
};
|
||||
$cases['native_mapping_and_asymmetric_limits'] = static function () use ($build, $check): void {
|
||||
$x = $build()['operation']; $b = $x['batteries'][0];
|
||||
$check($x['gridW'] === -5332.0 && $b['capacityKwh'] === 161.44 && $b['minSocPercent'] === 15.0
|
||||
&& $b['maxChargeW'] === 39000.0 && $b['maxDischargeW'] === 30000.0);
|
||||
};
|
||||
$cases['manager_cap_not_paid_peak'] = static function () use ($build, $check): void {
|
||||
$x = $build()['operation']; $check($x['limits']['importW'] === 25000.0 && (array) $x['measuredPeaks'] === [] && $x['quarterPast'] === null);
|
||||
};
|
||||
$cases['empty_maps_are_objects'] = static function () use ($build, $check): void {
|
||||
$x = json_decode(json_encode($build()['operation'])); $check(is_object($x->measuredPeaks) && is_object($x->limits->managerMonthLimitsW));
|
||||
};
|
||||
$cases['zero_limits_not_unlimited'] = static function () use ($build, $m, $check): void {
|
||||
$m['Einspeisegrenze'] = 0.0; $m['Lastspitzengrenze'] = 0.0; $x = $build($m)['operation'];
|
||||
$check($x['limits']['importW'] === 0.0 && $x['limits']['exportW'] === 0.0);
|
||||
};
|
||||
$cases['disabled_limits_are_null'] = static function () use ($build, $m, $check): void {
|
||||
$m['EinspeisebegrenzungAktiv'] = false; $m['Lastspitzenmodus'] = 0; $x = $build($m)['operation'];
|
||||
$check($x['limits']['importW'] === null && $x['limits']['exportW'] === null);
|
||||
};
|
||||
$cases['all_monthly_limits'] = static function () use ($build, $m, $check, $fail): void {
|
||||
$m['Lastspitzenmodus'] = 2; $m['Monatsgrenzen'] = [];
|
||||
for ($i = 1; $i <= 12; $i++) { $m['Monatsgrenzen'][] = ['MonatIndex' => $i, 'Grenze_W' => $i * 1000]; }
|
||||
$x = $build($m)['operation']; $check($x['limits']['managerMonthLimitsW']->{'10'} === 10000.0);
|
||||
array_pop($m['Monatsgrenzen']); $fail(static fn() => $build($m));
|
||||
};
|
||||
$cases['no_unconfirmed_soc_capacity'] = static function () use ($build, $a, $m, $fail, $check): void {
|
||||
$a[0]['Nutzkapazitaet_kWh'] = 140.; $fail(static fn() => $build(null, $a));
|
||||
$m['BatterieOptionen'] = ['ev' => ['SOCKapazitaet_kWh' => 161.44]];
|
||||
$check($build($m, $a)['operation']['batteries'][0]['capacityKwh'] === 161.44);
|
||||
};
|
||||
$cases['no_duplicate_controller'] = static function () use ($build, $a, $fail): void {
|
||||
$a[] = array_replace($a[0], ['ID' => 'other']); $fail(static fn() => $build(null, $a));
|
||||
};
|
||||
$cases['no_ambiguous_mapping'] = static function () use ($build, $c, $fail): void {
|
||||
$c[] = array_replace($c[0], ['InstanzID' => 43]); $fail(static fn() => $build(null, null, $c));
|
||||
};
|
||||
$cases['mismatched_power_source'] = static function () use ($build, $c, $fail): void {
|
||||
$c[0]['IstleistungVariableID'] = 100; $fail(static fn() => $build(null, null, $c));
|
||||
};
|
||||
$cases['stale_measurements_fail'] = static function () use ($build, $read, $now, $fail): void {
|
||||
$r = static function ($id) use ($read, $now) { $v = $read($id); $v['updated'] = $now - 61; return $v; };
|
||||
$fail(static fn() => $build(null, null, null, $r));
|
||||
};
|
||||
$cases['stale_consumer_cache_fails'] = static function () use ($build, $c, $now, $fail): void {
|
||||
$c[0]['EmpfangenAm'] = $now - 61; $fail(static fn() => $build(null, null, $c));
|
||||
};
|
||||
$cases['soc_below_reserve_is_not_fabricated'] = static function () use ($build, $read, $check): void {
|
||||
$r = static function ($id) use ($read) { $v = $read($id); if ($id === 2) { $v['value'] = 5.; } return $v; };
|
||||
$b = $build(null, null, null, $r)['operation']['batteries'][0];
|
||||
$check($b['socPercent'] === 5.0 && $b['minSocPercent'] === 15.0 && $b['physicalMinSocPercent'] === 3.0 && $b['recoveryAllowed'] === true && $b['dischargeBlocked'] === true);
|
||||
};
|
||||
$cases['unavailable_asset_no_power'] = static function () use ($build, $c, $check): void {
|
||||
$c[0]['Verfuegbar'] = false; $x = $build(null, null, $c)['operation']['batteries'][0];
|
||||
$check($x['maxChargeW'] === 0.0 && $x['maxDischargeW'] === 0.0);
|
||||
};
|
||||
$cases['hysteresis_not_silently_ignored'] = static function () use ($build, $c, $check): void {
|
||||
$c[0]['HystereseAktiv'] = true; $x = $build(null, null, $c);
|
||||
$check($x['operation']['batteries'][0]['dischargeBlocked'] === true && $x['operation']['batteries'][0]['maxDischargeW'] === 30000.0 && count($x['warnings']) > 1);
|
||||
};
|
||||
$cases['grid_charge_explicit_opt_in'] = static function () use ($build, $m, $check): void {
|
||||
$check($build()['operation']['batteries'][0]['gridCharging'] === false);
|
||||
$m['NetzladenErlaubt'] = true; $check($build($m)['operation']['batteries'][0]['gridCharging'] === true);
|
||||
};
|
||||
$ev = ['version' => 1, 'meterId' => \Belevo\EnelixEMS\NetzfahrplanV4Bezugszaehler::identitaet($m['V4BezugszaehlerQuellen']), 'measuredAt' => gmdate('c', $now),
|
||||
'measuredPeaks' => ['2026-10' => ['kw' => 18.4, 'source' => 'meter_month_register']],
|
||||
'quarterPast' => ['start' => '2026-10-01T12:00:00Z', 'measuredSeconds' => 300, 'importKwh' => 0.5]];
|
||||
$cases['actual_meter_evidence_used'] = static function () use ($build, $ev, $check): void {
|
||||
$x = $build(null, null, null, null, $ev);
|
||||
$check($x['status'] === 'ready_shadow' && $x['operation']['measuredPeaks']->{'2026-10'}['kw'] === 18.4 && $x['operation']['quarterPast']['importKwh'] === 0.5);
|
||||
};
|
||||
$cases['stale_quarter_not_extrapolated'] = static function () use ($build, $ev, $now, $check): void {
|
||||
$ev['measuredAt'] = gmdate('c', $now - 1); $x = $build(null, null, null, null, $ev);
|
||||
$check($x['operation']['quarterPast'] === null && count($x['warnings']) > 0);
|
||||
};
|
||||
$cases['wrong_meter_evidence_fails'] = static function () use ($build, $ev, $fail): void {
|
||||
$ev['meterId'] = 'symcon:100'; $fail(static fn() => $build(null, null, null, null, $ev));
|
||||
};
|
||||
$cases['planned_peak_not_evidence'] = static function () use ($build, $ev, $fail): void {
|
||||
$ev['measuredPeaks']['2026-10']['source'] = 'manager_cap'; $fail(static fn() => $build(null, null, null, null, $ev));
|
||||
};
|
||||
$cases['deterministic_retry_event_id'] = static function () use ($build, $check): void {
|
||||
$check($build()['operation']['eventId'] === $build()['operation']['eventId']);
|
||||
};
|
||||
$cases['legacy_meter_identity_not_accepted'] = static function () use ($build, $ev, $fail): void {
|
||||
$ev['meterId'] = 'symcon:9'; $fail(static fn() => $build(null, null, null, null, $ev));
|
||||
};
|
||||
$cases['missing_v4_sources_never_use_legacy'] = static function () use ($build, $m, $fail): void {
|
||||
unset($m['V4BezugszaehlerQuellen']); $fail(static fn() => $build($m));
|
||||
};
|
||||
$cases['counter_snapshot_is_not_billing_evidence'] = static function () use ($build, $check): void {
|
||||
$v = $build(); $check($v['bezugszaehler']['totalKwh'] === 3992.203
|
||||
&& !$v['bezugszaehler']['billingEvidence'] && (array) $v['operation']['measuredPeaks'] === []);
|
||||
};
|
||||
$passed = [];
|
||||
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
|
||||
return $passed;
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Belevo\EnelixEMS\Tests;
|
||||
|
||||
use Belevo\EnelixEMS\NetzfahrplanV4Bezugszaehler as Meter;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
/** Offline only: injected readings, no Symcon, archive, network or actuator access. */
|
||||
final class V4BezugszaehlerScenarios
|
||||
{
|
||||
public static function run(): array
|
||||
{
|
||||
$now = 1790874000;
|
||||
$sources = [
|
||||
['VariableID' => 59607, 'ElternID' => 11490, 'Ident' => 'Energy_0', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
|
||||
['VariableID' => 26620, 'ElternID' => 11490, 'Ident' => 'Energy_1', 'FaktorZuKWh' => 1.0, 'Messgroesse' => 'WirkenergieBezug'],
|
||||
];
|
||||
$samples = [
|
||||
59607 => ['value' => 3992.203, 'updated' => $now, 'parentID' => 11490, 'ident' => 'Energy_0'],
|
||||
26620 => ['value' => 0.0, 'updated' => $now, 'parentID' => 11490, 'ident' => 'Energy_1'],
|
||||
];
|
||||
$check = static function (bool $value): void { if (!$value) { throw new RuntimeException('Assertion failed'); } };
|
||||
$reject = static function (callable $fn): void {
|
||||
try { $fn(); } catch (InvalidArgumentException $e) { return; }
|
||||
throw new RuntimeException('Expected invalid source/sample rejection');
|
||||
};
|
||||
$snapshot = static function ($ss = null, $mm = null) use ($sources, $samples, $now): array {
|
||||
$mm = $mm ?? $samples;
|
||||
return Meter::lesen($ss ?? $sources, static fn(int $id) => $mm[$id] ?? null, $now);
|
||||
};
|
||||
$cases = [];
|
||||
$cases['zero_t2_is_valid_but_not_history'] = static function () use ($snapshot, $check): void {
|
||||
$v = $snapshot();
|
||||
$check(abs($v['totalKwh'] - 3992.203) < 1e-9 && !$v['historyComplete'] && !$v['billingEvidence']);
|
||||
$check(!isset($v['measuredPeaks']) && !isset($v['quarterPast']));
|
||||
};
|
||||
$cases['both_tariffs_are_summed'] = static function () use ($snapshot, $samples, $check): void {
|
||||
$samples[26620]['value'] = 27.25;
|
||||
$check(abs($snapshot(null, $samples)['totalKwh'] - 4019.453) < 1e-9);
|
||||
};
|
||||
$cases['zero_total_not_replaced'] = static function () use ($snapshot, $samples, $check): void {
|
||||
$samples[59607]['value'] = 0;
|
||||
$check($snapshot(null, $samples)['totalKwh'] === 0.0);
|
||||
};
|
||||
$cases['source_order_does_not_change_identity'] = static function () use ($sources, $check): void {
|
||||
$check(Meter::identitaet($sources) === Meter::identitaet(array_reverse($sources)));
|
||||
};
|
||||
$cases['changed_factor_invalidates_identity'] = static function () use ($sources, $check): void {
|
||||
$old = Meter::identitaet($sources); $sources[0]['FaktorZuKWh'] = 0.001;
|
||||
$check($old !== Meter::identitaet($sources));
|
||||
};
|
||||
$cases['changed_variable_invalidates_identity'] = static function () use ($sources, $check): void {
|
||||
$old = Meter::identitaet($sources); $sources[0]['VariableID'] = 12345;
|
||||
$check($old !== Meter::identitaet($sources));
|
||||
};
|
||||
$cases['no_fallback_to_legacy_source'] = static function () use ($snapshot, $reject): void { $reject(static fn() => $snapshot([])); };
|
||||
$cases['duplicate_variable_rejected'] = static function () use ($sources, $snapshot, $reject): void {
|
||||
$sources[1] = $sources[0]; $reject(static fn() => $snapshot($sources));
|
||||
};
|
||||
$cases['different_meter_boundary_rejected'] = static function () use ($sources, $snapshot, $reject): void {
|
||||
$sources[1]['ElternID'] = 48065; $reject(static fn() => $snapshot($sources));
|
||||
};
|
||||
$cases['reactive_quantity_rejected'] = static function () use ($sources, $snapshot, $reject): void {
|
||||
$sources[0]['Messgroesse'] = 'Blindenergie'; $reject(static fn() => $snapshot($sources));
|
||||
};
|
||||
$cases['explicit_conversion_required'] = static function () use ($sources, $snapshot, $reject): void {
|
||||
unset($sources[0]['FaktorZuKWh']); $reject(static fn() => $snapshot($sources));
|
||||
};
|
||||
$cases['missing_tariff_not_zero'] = static function () use ($snapshot, $samples, $reject): void {
|
||||
unset($samples[26620]); $reject(static fn() => $snapshot(null, $samples));
|
||||
};
|
||||
$cases['stale_zero_tariff_rejected'] = static function () use ($snapshot, $samples, $reject): void {
|
||||
$samples[26620]['updated'] -= 61; $reject(static fn() => $snapshot(null, $samples));
|
||||
};
|
||||
$cases['future_reading_rejected'] = static function () use ($snapshot, $samples, $reject): void {
|
||||
$samples[26620]['updated']++; $reject(static fn() => $snapshot(null, $samples));
|
||||
};
|
||||
$cases['wrong_ident_rejected'] = static function () use ($snapshot, $samples, $reject): void {
|
||||
$samples[59607]['ident'] = 'Energy_6'; $reject(static fn() => $snapshot(null, $samples));
|
||||
};
|
||||
$cases['wrong_parent_rejected'] = static function () use ($snapshot, $samples, $reject): void {
|
||||
$samples[59607]['parentID'] = 48065; $reject(static fn() => $snapshot(null, $samples));
|
||||
};
|
||||
$cases['invalid_numbers_rejected'] = static function () use ($snapshot, $samples, $reject): void {
|
||||
foreach ([false, '3992.203', NAN, INF, -1.0] as $bad) {
|
||||
$samples[59607]['value'] = $bad; $reject(static fn() => $snapshot(null, $samples));
|
||||
}
|
||||
};
|
||||
$cases['skewed_tariff_observations_rejected'] = static function () use ($snapshot, $samples, $reject): void {
|
||||
$samples[59607]['updated'] -= 3; $reject(static fn() => $snapshot(null, $samples));
|
||||
};
|
||||
$cases['concurrent_update_in_same_second_rejected'] = static function () use ($sources, $samples, $now, $reject): void {
|
||||
$calls = 0;
|
||||
$read = static function (int $id) use ($samples, &$calls): array {
|
||||
$v = $samples[$id]; if (++$calls > 2) { $v['value'] += 0.1; } return $v;
|
||||
};
|
||||
$reject(static fn() => Meter::lesen($sources, $read, $now));
|
||||
};
|
||||
$cases['observation_not_fake_exact_billing_time'] = static function () use ($snapshot, $samples, $now, $check): void {
|
||||
$samples[59607]['updated'] -= 1;
|
||||
$v = $snapshot(null, $samples);
|
||||
$check($v['sourceObservationFrom'] === gmdate('c', $now - 1));
|
||||
$check($v['sourceObservationUntil'] === gmdate('c', $now) && $v['observedAt'] === gmdate('c', $now));
|
||||
$check(!$v['billingEvidence']);
|
||||
};
|
||||
$passed = [];
|
||||
foreach ($cases as $name => $fn) { $fn(); $passed[] = $name; }
|
||||
return $passed;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user