value; } } class SelfserveLaneCommandDepartmentLaneFake { public SelfserveLaneCommandValueFake $relay_machine_id; public SelfserveLaneCommandValueFake $relay_machine_program_picker_id; public SelfserveLaneCommandValueFake $relay_machine_cleaner_id; public function __construct(string $machineRelayId, string $programRelayId, string $cleanerRelayId) { $this->relay_machine_id = new SelfserveLaneCommandValueFake($machineRelayId); $this->relay_machine_program_picker_id = new SelfserveLaneCommandValueFake($programRelayId); $this->relay_machine_cleaner_id = new SelfserveLaneCommandValueFake($cleanerRelayId); } } class SelfserveLaneStopFlowHarness { use selfserve_lane_command_t; public const DEFAULT_WASH_START_TIME = 0; public const DEFAULT_CUSTOMER_NUMBER = 0; public const DEFAULT_LICENSE_PLATE = ''; public int $id = 77; public object $department_lane; public int $invoiceCalls = 0; public int $ensureInvoiceOrderContextCalls = 0; public int $vehicleTypeProductAddCalls = 0; public int $programSelectorStatusReads = 0; public ?bool $lastVehicleTypeProductDecision = null; public ?\Throwable $openThrowable = null; public ?\Throwable $reportedStopTimeout = null; /** @var selfserve_lane_port[] */ public array $openedPorts = []; /** @var selfserve_lane_relay[] */ public array $turnedOffRelays = []; /** @var array */ public array $events = []; private selfserve_lane_status $laneStatus; private selfserve_lane_mode $laneMode; private selfserve_lane_state $laneState; private int $customerNumber = 1234; private string $licensePlate = 'AB12345'; private int $washStartTime = 120; private ?int $reservationStartTime = null; private bool $bypassCustomerValidation = false; private bool $programSelectorOnline; private bool $programSelectorOn; private bool $machineStartTriggered; public function __construct( bool $machineStartTriggered, bool $programSelectorOnline = false, bool $programSelectorOn = true, string $machineRelayId = 'relay-machine', string $programRelayId = 'relay-program', string $cleanerRelayId = 'relay-cleaner' ) { $this->machineStartTriggered = $machineStartTriggered; $this->programSelectorOnline = $programSelectorOnline; $this->programSelectorOn = $programSelectorOn; $this->department_lane = new SelfserveLaneCommandDepartmentLaneFake( $machineRelayId, $programRelayId, $cleanerRelayId ); $this->laneStatus = selfserve_lane_status::OCCUPIED; $this->laneMode = selfserve_lane_mode::MANUAL; $this->laneState = selfserve_lane_state::IN_WASH; } public function getLaneStatus(): selfserve_lane_status { return $this->laneStatus; } public function setLaneStatus(selfserve_lane_status $status): void { $this->laneStatus = $status; } public function setLaneMode(selfserve_lane_mode $mode): void { $this->laneMode = $mode; } public function setLaneState(selfserve_lane_state $state): void { $this->laneState = $state; } public function setWashStartTime(?int $time): void { $this->washStartTime = $time ?? self::DEFAULT_WASH_START_TIME; } public function setCustomerNumber(?int $customerNumber): void { $this->customerNumber = $customerNumber ?? self::DEFAULT_CUSTOMER_NUMBER; } public function getCustomerNumber(): int { return $this->customerNumber; } public function setLicensePlate(?string $licensePlate): void { $this->licensePlate = $licensePlate ?? self::DEFAULT_LICENSE_PLATE; } public function getLicensePlate(): string { return $this->licensePlate; } public function setReservationStartTime(?int $reservationStartTime): void { $this->reservationStartTime = $reservationStartTime; } public function isBypassCustomerNumberValidation(): bool { return $this->bypassCustomerValidation; } public function invoice(): bool { $this->invoiceCalls++; return true; } public function ensureInvoiceOrderContextForVehicleProduct(): bool { $this->ensureInvoiceOrderContextCalls++; return true; } public function addVehicleTypeProductToLastInvoiceOrder(): bool { $this->vehicleTypeProductAddCalls++; return true; } public function getMachineProgramPickerRelayStatus(): array { $this->programSelectorStatusReads++; return [ 'online' => $this->programSelectorOnline, 'on' => $this->programSelectorOn, ]; } public function open(selfserve_lane_port $port): bool { $this->openedPorts[] = $port; $this->events[] = 'open:' . $port->name; if ($this->openThrowable !== null) { throw $this->openThrowable; } return true; } public function turnOffRelay(selfserve_lane_relay $relay): bool { $this->turnedOffRelays[] = $relay; $this->events[] = 'relay:' . $relay->name . ':off'; return true; } public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on, ?int $toggle_after = null): bool { if ($on === false) { $this->turnedOffRelays[] = $relay; $this->events[] = 'relay:' . $relay->name . ':off'; } return true; } public function logLaneAction(...$args): void {} protected function completeLatestSessionForStop(): void { // No-op in unit tests. } protected function hasMachineStartSignalForStop(): bool { return $this->machineStartTriggered; } protected function addVehicleTypeProductToInvoiceIfNeeded(bool $should_add): void { $this->lastVehicleTypeProductDecision = $should_add; if ($should_add) { $this->vehicleTypeProductAddCalls++; } } protected function reportWashStopExitTimeout(\Throwable $e): void { $this->reportedStopTimeout = $e; } } it('adds vehicle type product on STOP when the physical machine ON signal was recorded, after turning relays off before exit', function (): void { $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); $lane->execute(selfserve_lane_command::STOP, $args); expect($lane->invoiceCalls)->toBe(1); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); expect($lane->vehicleTypeProductAddCalls)->toBe(1); expect($lane->lastVehicleTypeProductDecision)->toBeTrue(); expect($lane->programSelectorStatusReads)->toBe(0); expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); expect($lane->turnedOffRelays)->toBe([ selfserve_lane_relay::MACHINE_CLEANER, selfserve_lane_relay::MACHINE_PROGRAM_PICKER, selfserve_lane_relay::MACHINE, ]); expect($lane->events)->toBe([ 'relay:MACHINE_CLEANER:off', 'relay:MACHINE_PROGRAM_PICKER:off', 'relay:MACHINE:off', 'open:EXIT', ]); expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); }); it('skips vehicle type product add when no physical machine ON signal was recorded and only disables configured relays', function (): void { $lane = new SelfserveLaneStopFlowHarness( machineStartTriggered: false, programSelectorOnline: false, machineRelayId: 'relay-machine', programRelayId: 'relay-program', cleanerRelayId: '' ); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); $lane->execute(selfserve_lane_command::STOP, $args); expect($lane->invoiceCalls)->toBe(1); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); expect($lane->vehicleTypeProductAddCalls)->toBe(0); expect($lane->lastVehicleTypeProductDecision)->toBeFalse(); expect($lane->programSelectorStatusReads)->toBe(0); expect($lane->turnedOffRelays)->toBe([ selfserve_lane_relay::MACHINE_PROGRAM_PICKER, selfserve_lane_relay::MACHINE, ]); expect($lane->events)->toBe([ 'relay:MACHINE_PROGRAM_PICKER:off', 'relay:MACHINE:off', 'open:EXIT', ]); }); it('does not use selector relay online status as machine-wash billing evidence', function (): void { $lane = new SelfserveLaneStopFlowHarness( machineStartTriggered: false, programSelectorOnline: true, programSelectorOn: true ); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); $lane->execute(selfserve_lane_command::STOP, $args); expect($lane->invoiceCalls)->toBe(1); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); expect($lane->vehicleTypeProductAddCalls)->toBe(0); expect($lane->lastVehicleTypeProductDecision)->toBeFalse(); expect($lane->programSelectorStatusReads)->toBe(0); }); it('continues STOP when exit relay dispatch times out ambiguously', function (): void { $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); $timeout = new \RuntimeException('Edge gateway command timed out'); $lane->openThrowable = $timeout; $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); $lane->execute(selfserve_lane_command::STOP, $args); expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); expect($lane->reportedStopTimeout)->toBe($timeout); expect($lane->invoiceCalls)->toBe(1); expect($lane->vehicleTypeProductAddCalls)->toBe(1); expect($lane->turnedOffRelays)->toBe([ selfserve_lane_relay::MACHINE_CLEANER, selfserve_lane_relay::MACHINE_PROGRAM_PICKER, selfserve_lane_relay::MACHINE, ]); expect($lane->events)->toBe([ 'relay:MACHINE_CLEANER:off', 'relay:MACHINE_PROGRAM_PICKER:off', 'relay:MACHINE:off', 'open:EXIT', ]); expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); }); it('still fails STOP for non-timeout exit relay errors', function (): void { $lane = new SelfserveLaneStopFlowHarness(machineStartTriggered: true); $lane->openThrowable = new \RuntimeException('Invalid relay ID for port EXIT'); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); expect(fn() => $lane->execute(selfserve_lane_command::STOP, $args)) ->toThrow(\RuntimeException::class, 'Invalid relay ID'); expect($lane->reportedStopTimeout)->toBeNull(); expect($lane->invoiceCalls)->toBe(0); expect($lane->getLaneStatus())->toBe(selfserve_lane_status::OCCUPIED); });