value; } } class SelfserveDepartmentLaneRelayFake { public SelfserveRelayValueFake $relay_machine_id; public SelfserveRelayValueFake $relay_machine_program_picker_id; public SelfserveRelayValueFake $relay_machine_cleaner_id; public function __construct( string $machineRelayId = 'relay-machine', string $programPickerRelayId = 'relay-program', string $cleanerRelayId = 'relay-cleaner' ) { $this->relay_machine_id = new SelfserveRelayValueFake($machineRelayId); $this->relay_machine_program_picker_id = new SelfserveRelayValueFake($programPickerRelayId); $this->relay_machine_cleaner_id = new SelfserveRelayValueFake($cleanerRelayId); } } class SelfserveShellyRedisClientFake { public function __construct(private readonly SelfserveShellyRedisFake $owner) {} public function set(string $key, string $value, mixed ...$args): bool|string { return $this->owner->clientSet($key, $value, $args); } public function pttl(string $key): int { return $this->owner->clientPttl($key); } } class SelfserveShellyRedisFake { /** @var array */ private array $store = []; /** @var array */ private array $expiresAt = []; private SelfserveShellyRedisClientFake $client; /** @var callable():float */ private $nowProvider; public function __construct(callable $nowProvider) { $this->nowProvider = $nowProvider; $this->client = new SelfserveShellyRedisClientFake($this); } public function get(string $key): ?string { $this->purgeExpired($key); return $this->store[$key] ?? null; } public function setEx(string $key, string $value, int $ttl): self { $this->store[$key] = $value; $this->expiresAt[$key] = $this->now() + max(1, $ttl); return $this; } public function get_client(): SelfserveShellyRedisClientFake { return $this->client; } public function clientSet(string $key, string $value, array $args): bool|string { $this->purgeExpired($key); $useNx = false; $ttlMs = null; $count = count($args); for ($i = 0; $i < $count; $i++) { $token = strtoupper((string)$args[$i]); if ($token === 'NX') { $useNx = true; continue; } if ($token === 'PX' && isset($args[$i + 1])) { $ttlMs = max(1, (int)$args[$i + 1]); $i++; } } if ($useNx && array_key_exists($key, $this->store)) { return false; } $this->store[$key] = $value; if ($ttlMs === null) { unset($this->expiresAt[$key]); } else { $this->expiresAt[$key] = $this->now() + ($ttlMs / 1000); } return 'OK'; } public function clientPttl(string $key): int { $this->purgeExpired($key); if (!array_key_exists($key, $this->store)) { return -2; } if (!isset($this->expiresAt[$key])) { return -1; } $remainingMs = (int)ceil(($this->expiresAt[$key] - $this->now()) * 1000); return $remainingMs > 0 ? $remainingMs : 0; } private function now(): float { $provider = $this->nowProvider; return (float)$provider(); } private function purgeExpired(string $key): void { if (!isset($this->expiresAt[$key])) { return; } if ($this->now() < $this->expiresAt[$key]) { return; } unset($this->expiresAt[$key], $this->store[$key]); } } class SelfserveLaneRelayControllerHarness { use selfserve_lane_relay_controller_t; public const CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES = 'allowed_services'; public int $id = 1; public object $department_lane; public bool $selfServeEnabled = true; public float $now = 0.0; /** @var array */ public array $sleepCalls = []; /** @var array */ public array $shellyCalls = []; /** @var array */ public array $laneCache = []; /** @var array> */ private array $queuedResponses = []; private selfserve_lane_status $laneStatus; private ?SelfserveShellyRedisFake $redis; public function __construct(?SelfserveShellyRedisFake $redis = null) { $this->department_lane = new SelfserveDepartmentLaneRelayFake(); $this->laneStatus = selfserve_lane_status::AVAILABLE; $this->redis = $redis; } public function queueShellyResponse(string $endpoint, array|object|null $response): void { if (!isset($this->queuedResponses[$endpoint])) { $this->queuedResponses[$endpoint] = []; } $this->queuedResponses[$endpoint][] = $response; } public function getShellyCallCount(string $endpoint): int { $count = 0; foreach ($this->shellyCalls as $call) { if ($call['endpoint'] === $endpoint) { $count++; } } return $count; } public function getLaneStatus(): selfserve_lane_status { return $this->laneStatus; } public function setLaneStatus(selfserve_lane_status $laneStatus): void { $this->laneStatus = $laneStatus; } public function getLaneCache(int $lane_id, string $key): mixed { return $this->laneCache[$key . '_' . $lane_id] ?? null; } public function setLaneCache(int $lane_id, string $key, mixed $value): self { $this->laneCache[$key . '_' . $lane_id] = $value; return $this; } protected function sendShellyPost(string $endpoint, array $payload): array|object|null { $this->shellyCalls[] = [ 'endpoint' => $endpoint, 'payload' => $payload, 'time' => $this->now, ]; if (!isset($this->queuedResponses[$endpoint]) || count($this->queuedResponses[$endpoint]) < 1) { return []; } return array_shift($this->queuedResponses[$endpoint]); } protected function redisFacade(): mixed { return $this->redis; } protected function nowTimestamp(): float { return $this->now; } protected function sleepMicroseconds(int $microseconds): void { if ($microseconds > 0) { $this->sleepCalls[] = $microseconds; $this->now += ($microseconds / 1000000); } } protected function isDepartmentSelfServeEnabled(): bool { return $this->selfServeEnabled; } } function selfserve_lane_shelly_test_harness(bool $withRedis = true): SelfserveLaneRelayControllerHarness { $harness = null; $redis = null; if ($withRedis) { $redis = new SelfserveShellyRedisFake(static function () use (&$harness): float { return $harness?->now ?? 0.0; }); } $harness = new SelfserveLaneRelayControllerHarness($redis); return $harness; } it('batches sequential machine relay status requests into a single Shelly get call', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->queueShellyResponse('/v2/devices/api/get', [ [ 'id' => 'relay-machine', 'online' => true, 'status' => ['switch:0' => ['output' => true]], ], [ 'id' => 'relay-program', 'online' => true, 'status' => ['switch:0' => ['output' => false]], ], [ 'id' => 'relay-cleaner', 'online' => true, 'status' => ['switch:0' => ['output' => true]], ], ]); $machine = $harness->getMachineRelayStatus(); $programPicker = $harness->getMachineProgramPickerRelayStatus(); $cleaner = $harness->getMachineCleanerRelayStatus(); expect($machine['relay_id'])->toBe('relay-machine'); expect($machine['on'])->toBeTrue(); expect($programPicker['relay_id'])->toBe('relay-program'); expect($programPicker['on'])->toBeFalse(); expect($cleaner['relay_id'])->toBe('relay-cleaner'); expect($cleaner['on'])->toBeTrue(); expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1); expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-machine', 'relay-program', 'relay-cleaner']); expect($harness->shellyCalls[0]['payload']['select'])->toBe(['status']); }); it('keeps back-to-back get/switch requests ordered through the relay controller', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->queueShellyResponse('/v2/devices/api/get', [ [ 'id' => 'relay-machine', 'online' => true, 'status' => ['switch:0' => ['output' => true]], ], ]); $harness->queueShellyResponse('/v2/devices/api/set/switch', [ [ 'id' => 'relay-machine', 'online' => true, 'status' => ['switch:0' => ['output' => false]], ], ]); $harness->getMachineRelayStatus(); $harness->setMachineRelayStatus(false); expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/get'); expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch'); }); it('executes sequential switch requests used by wash start and stop flows', function (): void { $harness = selfserve_lane_shelly_test_harness(); for ($i = 0; $i < 5; $i++) { $harness->queueShellyResponse('/v2/devices/api/set/switch', [ [ 'id' => 'relay-machine', 'online' => true, 'status' => ['switch:0' => ['output' => true]], ], ]); } // START-like sequence. $harness->setMachineCleanerRelayStatusHard(true); $harness->setMachineRelayStatusHard(true); // STOP-like sequence. $harness->setMachineCleanerRelayStatusHard(false); $harness->setMachineProgramPickerRelayStatusHard(false); $harness->setMachineRelayStatusHard(false); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(5); }); it('retries missing Shelly status payloads until relay status becomes ready', function (): void { $harness = selfserve_lane_shelly_test_harness(false); $harness->queueShellyResponse('/v2/devices/api/get', [ ['id' => 'relay-machine', 'online' => true], ]); $harness->queueShellyResponse('/v2/devices/api/get', [ [ 'id' => 'relay-machine', 'online' => true, 'status' => ['switch:0' => ['output' => true]], ], ]); $status = $harness->getMachineRelayStatus(); expect($status['relay_id'])->toBe('relay-machine'); expect($status['on'])->toBeTrue(); expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(2); expect($harness->sleepCalls)->toContain(250000); }); it('times out with a clear Shelly readiness error when payload remains missing', function (): void { $harness = selfserve_lane_shelly_test_harness(false); for ($i = 0; $i < 100; $i++) { $harness->queueShellyResponse('/v2/devices/api/get', [ ['id' => 'relay-machine', 'online' => true], ]); } $exception = null; try { $harness->getMachineRelayStatus(); } catch (\Exception $e) { $exception = $e; } expect($exception)->toBeInstanceOf(\Exception::class); expect($exception?->getMessage())->toContain('not ready'); expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBeGreaterThan(1); }); it('uses direct set/switch and seeds cache so immediate status read does not call Shelly get', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->queueShellyResponse('/v2/devices/api/set/switch', [ [ 'id' => 'relay-program', 'online' => true, 'status' => ['switch:0' => ['output' => true]], ], ]); $harness->setMachineProgramPickerRelayStatus(true); $status = $harness->getMachineProgramPickerRelayStatus(); expect($status['relay_id'])->toBe('relay-program'); expect($status['on'])->toBeTrue(); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0); expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/set/switch'); expect($harness->shellyCalls[0]['payload'])->toMatchArray([ 'id' => 'relay-program', 'channel' => 0, 'on' => true, ]); expect(array_key_exists('toggle_after', $harness->shellyCalls[0]['payload']))->toBeFalse(); }); it('preserves local gateway diagnostic metadata on relay status snapshots', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->queueShellyResponse('/v2/devices/api/get', [ [ 'id' => 'relay-machine', 'relay_id' => 'relay-machine', 'online' => true, 'on' => true, 'status' => ['switch:0' => ['output' => true]], 'binding' => [ 'logical_relay_id' => 'relay-machine', 'device_id' => 'device-machine', 'local_ip' => '192.168.1.50', 'channel' => 0, ], 'execution' => [ 'transport' => 'gateway', 'fallback_mode' => 'PREFER_LOCAL', ], 'raw' => [ 'source' => 'edge-gateway', ], ], ]); $status = $harness->getMachineRelayStatus(); expect($status)->toMatchArray([ 'relay_id' => 'relay-machine', 'online' => true, 'on' => true, 'status' => ['switch:0' => ['output' => true]], 'binding' => [ 'logical_relay_id' => 'relay-machine', 'device_id' => 'device-machine', 'local_ip' => '192.168.1.50', 'channel' => 0, ], 'execution' => [ 'transport' => 'gateway', 'fallback_mode' => 'PREFER_LOCAL', ], 'raw' => [ 'source' => 'edge-gateway', ], ]); }); it('includes positive relay timers in Shelly switch payloads', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->forceTurnOnMachineRelay(9); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); expect($harness->shellyCalls[0]['payload'])->toMatchArray([ 'id' => 'relay-machine', 'channel' => 0, 'on' => true, 'toggle_after' => 9, ]); }); it('supports hard relay set even when lane status is CLOSED', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->setLaneStatus(selfserve_lane_status::CLOSED); $harness->queueShellyResponse('/v2/devices/api/set/switch', [ [ 'id' => 'relay-machine', 'online' => true, 'status' => ['switch:0' => ['output' => false]], ], ]); $result = $harness->setMachineRelayStatusHard(false); expect($result)->toBeTrue(); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); }); it('blocks explicit relay writes when department self-serve is disabled', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->selfServeEnabled = false; expect(fn() => $harness->setMachineRelayStatusHard(false)) ->toThrow(\Exception::class, 'Self-serve is not enabled'); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); }); it('enables MACHINE relay when MACHINE is visible in allowed services', function (): void { $harness = selfserve_lane_shelly_test_harness(); $result = $harness->syncMachineRelayFromVisibleServices(['machine'], true); expect($result)->toMatchArray([ 'machine_visible' => true, 'relay_action' => 'enabled', 'relay_target_on' => true, ]); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); expect($harness->shellyCalls[0]['payload'])->toMatchArray([ 'id' => 'relay-machine', 'channel' => 0, 'on' => true, ]); }); it('turns MACHINE relay off immediately when MACHINE is not visible', function (): void { $harness = selfserve_lane_shelly_test_harness(); $result = $harness->syncMachineRelayFromVisibleServices(['MACHINE_CLEANER'], true); expect($result)->toMatchArray([ 'machine_visible' => false, 'relay_action' => 'disabled', 'relay_target_on' => false, ]); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); expect($harness->shellyCalls[0]['payload'])->toMatchArray([ 'id' => 'relay-machine', 'channel' => 0, 'on' => false, ]); }); it('does not enable MACHINE relay when allowEnable is false even if MACHINE is visible', function (): void { $harness = selfserve_lane_shelly_test_harness(); $result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], false); expect($result)->toMatchArray([ 'machine_visible' => true, 'relay_action' => 'noop_enable_blocked', 'relay_target_on' => true, ]); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); }); it('persists allowed services without relay writes for pre-start wash setup', function (): void { $harness = selfserve_lane_shelly_test_harness(); $result = $harness->setAllowedServicesFromVisibleTasks(['machine']); expect($result)->toMatchArray([ 'machine_visible' => true, 'relay_action' => 'cache_only', 'relay_target_on' => true, ]); expect($harness->getLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES))->toBe(['MACHINE']); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); }); it('returns disabled no-op from machine relay visibility sync when department self-serve is disabled', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->selfServeEnabled = false; $result = $harness->syncMachineRelayFromVisibleServices(['machine'], true); expect($result)->toMatchArray([ 'machine_visible' => true, 'relay_action' => 'noop_selfserve_disabled', 'relay_target_on' => true, ]); expect($harness->getLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES))->toBe(['MACHINE']); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); }); it('is a safe no-op when MACHINE relay is not configured', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->department_lane = new SelfserveDepartmentLaneRelayFake( machineRelayId: '', programPickerRelayId: 'relay-program', cleanerRelayId: 'relay-cleaner' ); $result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], true); expect($result)->toMatchArray([ 'machine_visible' => true, 'relay_action' => 'noop_missing_machine_relay', 'relay_target_on' => true, ]); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); }); it('uses local demo responses and skips Shelly cloud calls for demo relay ids', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->department_lane = new SelfserveDepartmentLaneRelayFake( machineRelayId: 'demo-machine', programPickerRelayId: 'demo-program', cleanerRelayId: 'demo-cleaner' ); $result = $harness->setMachineRelayStatus(true); $status = $harness->getMachineRelayStatus(); expect($result)->toBeTrue(); expect($status)->toMatchArray([ 'relay_id' => 'demo-machine', 'online' => true, 'on' => true, ]); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0); expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0); }); it('returns default OFF status for demo relay ids without Shelly lookups', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->department_lane = new SelfserveDepartmentLaneRelayFake( machineRelayId: 'demo-machine', programPickerRelayId: 'demo-program', cleanerRelayId: 'demo-cleaner' ); $status = $harness->getMachineRelayStatus(); expect($status)->toMatchArray([ 'relay_id' => 'demo-machine', 'online' => true, 'on' => false, ]); expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0); }); it('excludes demo relay ids from Shelly status batch payloads', function (): void { $harness = selfserve_lane_shelly_test_harness(); $harness->department_lane = new SelfserveDepartmentLaneRelayFake( machineRelayId: 'demo-machine', programPickerRelayId: 'relay-program', cleanerRelayId: 'demo-cleaner' ); $harness->queueShellyResponse('/v2/devices/api/get', [ [ 'id' => 'relay-program', 'online' => true, 'status' => ['switch:0' => ['output' => true]], ], ]); $status = $harness->getMachineProgramPickerRelayStatus(); expect($status['relay_id'])->toBe('relay-program'); expect($status['on'])->toBeTrue(); expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1); expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-program']); });