Add requestBooleanFlag helper and enhance session synchronization logic

- Introduce `requestBooleanFlag` method for consistent boolean parameter handling with default values.
- Add `activate_machine` and `sync_relay_state` parameters to `synchronizeSession` for more flexible relay and machine activation control.
- Update methods, routes, and tests to integrate the new session synchronization parameters effectively.
- Enhance debugging support with additional metadata in simulation and payload captures.
This commit is contained in:
Jeppe Bundgaard
2026-04-28 16:54:57 +02:00
parent 5b94c9407b
commit acdff75311
11 changed files with 1453 additions and 25 deletions
File diff suppressed because one or more lines are too long
@@ -394,6 +394,73 @@ class selfserve_studio_graph
return $validation;
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
* @return array<string,mixed>
*/
public function simulateGraph(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array
{
$laneId = (int)($payload['lane_id'] ?? 0);
if ($laneId <= 0) {
throw new \RuntimeException('lane_id is required for studio simulation.');
}
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
$configSource = 'draft';
}
$versioning = new selfserve_config_versioning();
if ($configSource === 'published') {
$version = $versioning->getPublishedConfig($departmentId);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
$versionId = isset($version['version_id']) ? (int)$version['version_id'] : null;
} else {
$version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
$versionId = isset($version['id']) ? (int)$version['id'] : null;
}
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeHardware = $includeHardware !== false;
$gatewayWorkspace = ($includeHardware && ($permissions['modules_shelly_config'] ?? false))
? $this->buildGatewayWorkspace($departmentId)
: [
'gateways' => [],
'relays' => [],
'lanes' => [],
'issues' => [],
'actions' => [],
'restricted' => !$includeHardware ? false : true,
];
$lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace);
$graph = $this->buildGraphFromConfig($config, [
'department_id' => $departmentId,
'lookups' => $lookups,
'gateway_workspace' => $gatewayWorkspace,
]);
return (new selfserve_wash_flow())->previewStudioSimulation(
$departmentId,
$laneId,
(string)($payload['reg'] ?? ''),
array_key_exists('customer_number', $payload) ? $this->nullableInt($payload['customer_number']) : null,
array_key_exists('vehicle_type_id', $payload) ? $this->nullableInt($payload['vehicle_type_id']) : null,
[
'mode' => 'full_dry_run',
'config_source' => $configSource,
'config_payload' => $config,
'config_version_id' => $versionId,
'answer_overrides' => is_array($payload['answer_overrides'] ?? null) ? (array)$payload['answer_overrides'] : [],
'include_hardware' => $includeHardware,
'lookups' => $lookups,
'gateway_workspace' => $gatewayWorkspace,
'graph' => $graph,
],
);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
File diff suppressed because it is too large Load Diff
@@ -260,6 +260,33 @@ trait selfserve_lane_relay_controller_t
];
}
/**
* Persist the services currently visible to the user without mutating hardware.
*
* The user wash start flow calls this before the user confirms lane and wash type.
* Hardware activation remains owned by START / explicit relay endpoints.
*
* @param array<int,mixed> $allowedServices
* @return array{
* machine_visible: bool,
* relay_action: string,
* relay_target_on: bool
* }
*/
public function setAllowedServicesFromVisibleTasks(array $allowedServices): array
{
$normalizedServices = $this->normalizeVisibleServiceNames($allowedServices);
$this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $normalizedServices);
$machineVisible = in_array(selfserve_lane_services::MACHINE->name, $normalizedServices, true);
return [
'machine_visible' => $machineVisible,
'relay_action' => 'cache_only',
'relay_target_on' => $machineVisible,
];
}
/**
* @param array<int,mixed> $services
* @return string[]
+130 -1
View File
@@ -4674,6 +4674,22 @@ paths:
customer_id:
type: integer
nullable: true
vehicle_type:
type: integer
nullable: true
description: Optional product/vehicle type override used when refreshing the self-serve summary.
vehicle_type_id:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
sync_relay_state:
type: boolean
default: true
description: Whether the answer mutation should synchronize live relay state.
responses:
'200':
description: Successfully added vehicle condition
@@ -4718,6 +4734,22 @@ paths:
customer_id:
type: integer
nullable: true
vehicle_type:
type: integer
nullable: true
description: Optional product/vehicle type override used when refreshing the self-serve summary.
vehicle_type_id:
type: integer
nullable: true
description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay.
sync_relay_state:
type: boolean
default: true
description: Whether the mutation should synchronize live relay state.
responses:
'200':
description: Successfully updated vehicle condition
@@ -5304,13 +5336,34 @@ paths:
reg: { type: string }
customer_number: { type: integer, nullable: true }
vehicle_type_id: { type: integer, nullable: true }
config_source:
type: string
enum: [draft, published]
default: draft
answer_overrides:
type: array
items:
type: object
required: [question_id]
properties:
question_id: { type: integer }
value:
type: boolean
nullable: true
include_hardware:
type: boolean
default: true
mode:
type: string
enum: [full_dry_run]
default: full_dry_run
responses:
'200':
description: Simulator result
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveVehicleAllowedResponse'
$ref: '#/components/schemas/SelfserveStudioSimulationResponse'
/department/selfserve/studio/publish:
post:
@@ -15287,6 +15340,75 @@ components:
type: string
format: date-time
SelfserveStudioSimulationDebug:
type: object
required: [summary, parameters, stages, questions, conditions, rules, tasks, hardware, graph_annotations, recommendations]
properties:
summary:
type: object
additionalProperties: true
parameters:
type: object
additionalProperties: true
stages:
type: array
items:
type: object
additionalProperties: true
questions:
type: array
items:
type: object
additionalProperties: true
conditions:
type: array
items:
type: object
additionalProperties: true
rules:
type: array
items:
type: object
additionalProperties: true
tasks:
type: array
items:
type: object
additionalProperties: true
hardware:
type: object
additionalProperties: true
graph_annotations:
type: object
properties:
nodes:
type: object
additionalProperties:
type: object
additionalProperties: true
edges:
type: object
additionalProperties:
type: object
additionalProperties: true
recommendations:
type: array
items:
type: object
additionalProperties: true
SelfserveStudioSimulationResponse:
allOf:
- $ref: '#/components/schemas/SelfserveVehicleAllowedResponse'
- type: object
properties:
simulator_version: { type: integer }
dry_run: { type: boolean, enum: [true] }
mode: { type: string, enum: [full_dry_run] }
config_source: { type: string, enum: [draft, published] }
debug:
$ref: '#/components/schemas/SelfserveStudioSimulationDebug'
SelfserveVehicleAllowedResponse:
type: object
properties:
@@ -15330,6 +15452,13 @@ components:
allOf:
- $ref: '#/components/schemas/SelfserveWashSession'
nullable: true
config_source:
type: string
nullable: true
evaluation_trace:
type: object
nullable: true
additionalProperties: true
SelfserveWashSummary:
type: object
@@ -93,11 +93,11 @@ class departmentSelfserveStudioRoute
$this->assertDepartmentAccess($user, $departmentId);
try {
$result = (new selfserve_wash_flow())->previewVehicleEligibility(
$laneId,
(string)self::getParameter('reg'),
self::isParametersSet(['customer_number']) ? (int)self::getParameter('customer_number') : null,
self::isParametersSet(['vehicle_type_id']) ? (int)self::getParameter('vehicle_type_id') : null
$result = (new selfserve_studio_graph())->simulateGraph(
$departmentId,
self::getParametersAsArray(),
(int)$user->id,
$this->studioPermissions()
);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'SIMULATE_STUDIO_GRAPH', 'Simulated self-serve studio graph');
$response->success($result);
@@ -243,6 +243,8 @@ class departmentSelfserveVehicleConditionsRoute
$response->error('Missing required fields', 400);
}
$vehicle_type_id = $this->resolveVehicleTypeIdFromRequest();
$activate_machine = $this->requestBooleanFlag('activate_machine', true);
$sync_relay_state = $this->requestBooleanFlag('sync_relay_state', true);
if ($has_global) {
$customer_id = $response->isRequestParameterSet('customer_id') ? (int)$response->getRequestParameter('customer_id') : null;
@@ -259,7 +261,7 @@ class departmentSelfserveVehicleConditionsRoute
try {
$condition_o = new department_selfserve_vehicle_conditions_o();
$condition_o->add($department, $lane, $reg, $question, $value, $customer_id);
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, true, $vehicle_type_id, true);
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id);
$response->success([
'condition' => $condition_o->asArray(),
@@ -351,15 +353,17 @@ class departmentSelfserveVehicleConditionsRoute
$condition_o->customer_id->update($new_customer_id);
}
$vehicle_type_id = $this->resolveVehicleTypeIdFromRequest();
$activate_machine = $this->requestBooleanFlag('activate_machine', true);
$sync_relay_state = $this->requestBooleanFlag('sync_relay_state', true);
try {
$summary = $this->getWashFlow()->synchronizeSession(
(int)$condition_o->lane->value(),
(string)$condition_o->reg->value(),
$condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value(),
true,
$activate_machine,
$vehicle_type_id,
true
$sync_relay_state
);
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $user->id, 'UPDATE_VEHICLE_CONDITION', 'User updated department self-serve vehicle condition ' . $id);
$response->success([
@@ -417,11 +421,13 @@ class departmentSelfserveVehicleConditionsRoute
$reg = (string)$condition_o->reg->value();
$customer_id = $condition_o->customer_id->value() === null ? null : (int)$condition_o->customer_id->value();
$vehicle_type_id = $this->resolveVehicleTypeIdFromRequest();
$activate_machine = $this->requestBooleanFlag('activate_machine', true);
$sync_relay_state = $this->requestBooleanFlag('sync_relay_state', true);
$condition_o->delete();
try {
$summary = $this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, true, $vehicle_type_id, true);
$summary = $this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);
} catch (\Throwable) {
$summary = null;
}
@@ -506,6 +512,31 @@ class departmentSelfserveVehicleConditionsRoute
return $questions === [] && $tasks === [];
}
private function requestBooleanFlag(string $parameter, bool $default): bool
{
if (!$this->isParametersSet([$parameter])) {
return $default;
}
$value = $this->getParameter($parameter);
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value !== 0;
}
$normalized = strtolower(trim((string)$value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
return false;
}
return $default;
}
private function assertLaneAccess(object $user, int $laneId, bool $hasGlobalPermission): department_lanes_o
{
global $response;
@@ -522,8 +522,7 @@ class moduleSelfServeRoute
}
// Persist on lane cache (overwrites previous allowed services)
try {
$this->applyShellyTransportOverride($lane);
$relay_sync = $lane->syncMachineRelayFromVisibleServices($allowed_services, true);
$relay_sync = $lane->setAllowedServicesFromVisibleTasks($allowed_services);
$response->success([
'lane_id' => $lane_id,
'allowed_services' => $allowed_services,
@@ -560,6 +560,20 @@ it('does not enable MACHINE relay when allowEnable is false even if MACHINE is v
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;
@@ -118,7 +118,7 @@ it('applies Shelly transport overrides across self-serve relay side-effect route
expect($moduleSelfServeRoute)->not->toBeFalse();
expect(substr_count($moduleSelfServeRoute, 'applyShellyTransportOverride($lane)'))->toBeGreaterThanOrEqual(17);
expect($moduleSelfServeRoute)->toContain('$lane->execute($command, $args);');
expect($moduleSelfServeRoute)->toContain('$lane->syncMachineRelayFromVisibleServices($allowed_services, true);');
expect($moduleSelfServeRoute)->toContain('$lane->setAllowedServicesFromVisibleTasks($allowed_services);');
expect($moduleSelfServeRoute)->toContain('$lane->open($gate, $toggle_after);');
expect($moduleSelfServeRoute)->toContain('$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);');
expect($moduleSelfServeRoute)->toContain('$lane->turnOnRelay(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $duration);');
@@ -136,7 +136,8 @@ it('wires allowed services route through machine relay visibility sync', functio
expect($moduleSelfServeRoute)->not->toBeFalse();
expect($moduleSelfServeRoute)->toContain('/modules/self-serve/lane/services/allowed');
expect($moduleSelfServeRoute)->toContain('syncMachineRelayFromVisibleServices($allowed_services, true)');
expect($moduleSelfServeRoute)->toContain('setAllowedServicesFromVisibleTasks($allowed_services)');
expect($moduleSelfServeRoute)->not->toContain('syncMachineRelayFromVisibleServices($allowed_services, true)');
expect($moduleSelfServeRoute)->toContain("'relay_sync' => \$relay_sync");
});
@@ -277,8 +278,9 @@ it('wires vehicle type override into self-serve preview and synchronization rout
expect($vehicleConditionsRoute)->toContain('shouldRefreshSummaryForVehicleType');
expect($vehicleConditionsRoute)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)');
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false)');
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane, $reg, $customer_id, true, $vehicle_type_id, true)');
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_id, true, $vehicle_type_id, true)');
expect($vehicleConditionsRoute)->toContain('requestBooleanFlag');
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)');
expect($vehicleConditionsRoute)->toContain('synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state)');
expect($washFlow)->not->toBeFalse();
expect($washFlow)->toContain('resolveVehicleTypeId($vehicle, $vehicleTypeIdOverride)');
@@ -294,6 +296,8 @@ it('keeps read-only self-serve preview and summary refreshes from touching relay
expect($vehicleConditionsRoute)->toContain('$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);');
expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession(');
expect($vehicleConditionsRoute)->toContain('$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false);');
expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, true, $vehicle_type_id, true);');
expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, true, $vehicle_type_id, true);');
expect($vehicleConditionsRoute)->toContain('$activate_machine = $this->requestBooleanFlag(\'activate_machine\', true);');
expect($vehicleConditionsRoute)->toContain('$sync_relay_state = $this->requestBooleanFlag(\'sync_relay_state\', true);');
expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);');
expect($vehicleConditionsRoute)->toContain('$this->getWashFlow()->synchronizeSession($lane_id, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);');
});
@@ -3,6 +3,7 @@
app_require('modules/selfserve/classes/selfserve_studio_graph.php');
use modules\selfserve\classes\selfserve_studio_graph;
use modules\selfserve\classes\selfserve_wash_flow;
function selfserve_studio_graph_without_constructor(): selfserve_studio_graph
{
@@ -10,6 +11,12 @@ function selfserve_studio_graph_without_constructor(): selfserve_studio_graph
return $reflection->newInstanceWithoutConstructor();
}
function selfserve_wash_flow_without_constructor(): selfserve_wash_flow
{
$reflection = new ReflectionClass(selfserve_wash_flow::class);
return $reflection->newInstanceWithoutConstructor();
}
it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
$service = selfserve_studio_graph_without_constructor();
@@ -160,3 +167,173 @@ it('keeps layout loading compatible with native PDO named placeholders', functio
expect($source)->toContain(':user_id_filter');
expect($source)->toContain(':user_id_sort');
});
it('builds guided simulator debug payload with blockers and canvas annotations', function (): void {
$service = selfserve_wash_flow_without_constructor();
$debug = $service->buildStudioDebugPayload(6, [
'lane' => ['id' => 7, 'name' => 'Lane 7'],
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
'vehicle' => null,
'reg' => 'TEST123',
'customer_number' => null,
'vehicle_type_id' => 2,
'answers' => [11 => null],
'answer_sources' => [11 => 'override'],
'questions' => [],
'tasks' => [],
'allowed_services' => [],
'machine_available' => false,
'all_visible_questions_answered' => false,
'allowed' => false,
'config_version_id' => 90,
'config_source' => 'draft',
'evaluation_trace' => [
'visible_question_ids' => [11],
'visibility_condition_results' => [21 => true],
'condition_results' => [21 => true],
'task_gates' => [
['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false],
],
],
'debug_candidates' => [
'questions' => [
['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => 21, 'order_priority' => 1],
],
'conditions' => [
['id' => 21, 'name' => 'Trailer present', 'condition_id' => null],
],
'rules' => [
['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'],
],
'tasks' => [
['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => [1], 'order_priority' => 1],
],
],
], [
'lookups' => [
'labels' => [
'departments' => ['6' => 'Roskilde'],
'lanes' => ['7' => 'Lane 7'],
'vehicle_types' => ['2' => 'Forvogn'],
'machine_types' => ['1001' => 'Portal'],
'questions' => ['11' => 'Are mirrors folded?'],
'conditions' => ['21' => 'Trailer present'],
'rules' => ['31' => 'Mirror answer'],
'tasks' => ['41' => 'Fold mirrors'],
],
],
'gateway_workspace' => [
'gateways' => [
[
'id' => 701,
'label' => 'Roskilde Edge',
'status' => 'ONLINE',
'bindings' => [
['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
],
],
],
],
'graph' => [
'edges' => [
['id' => 'task-gate:question:11:41', 'source' => 'question:11', 'target' => 'task:41', 'label' => 'unlocks'],
],
],
]);
expect($debug['summary']['status'])->toBe('blocked')
->and($debug['parameters']['config_source'])->toBe('draft')
->and($debug['questions'][0]['state'])->toBe('missing')
->and($debug['questions'][0]['answer_source'])->toBe('override')
->and($debug['tasks'][0]['state'])->toBe('blocked')
->and(array_column($debug['recommendations'], 'title'))->toContain('Answer required questions')
->and(array_column($debug['recommendations'], 'title'))->toContain('Configure lane machine relay')
->and($debug['graph_annotations']['nodes']['question:11']['state'])->toBe('warning')
->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error');
});
it('resolves simulator gateway service bindings from lane relay slots', function (): void {
$service = selfserve_wash_flow_without_constructor();
$debug = $service->buildStudioDebugPayload(6, [
'lane' => ['id' => 7, 'name' => 'Lane 7'],
'machine_type' => ['id' => 1001, 'name' => 'Portal'],
'vehicle' => null,
'reg' => 'TEST123',
'customer_number' => null,
'vehicle_type_id' => 2,
'answers' => [11 => true],
'answer_sources' => [11 => 'override'],
'questions' => [],
'tasks' => [
['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']],
],
'allowed_services' => ['MACHINE'],
'machine_available' => true,
'all_visible_questions_answered' => true,
'allowed' => true,
'config_version_id' => 90,
'config_source' => 'draft',
'evaluation_trace' => [
'visible_question_ids' => [11],
'visibility_condition_results' => [],
'condition_results' => [],
'task_gates' => [
['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
],
],
'debug_candidates' => [
'questions' => [
['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1],
],
'conditions' => [],
'rules' => [],
'tasks' => [
['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [1], 'order_priority' => 1],
],
],
], [
'lookups' => [
'labels' => [
'departments' => ['6' => 'Roskilde'],
'lanes' => ['7' => 'Lane 7'],
'vehicle_types' => ['2' => 'Forvogn'],
'machine_types' => ['1001' => 'Portal'],
'questions' => ['11' => 'Are mirrors folded?'],
'tasks' => ['41' => 'Start machine'],
],
],
'gateway_workspace' => [
'gateways' => [
[
'id' => 701,
'label' => 'Roskilde Edge',
'status' => 'ONLINE',
'bindings' => [
['relay_id' => 'M-7', 'label' => 'Machine relay'],
],
],
],
'lanes' => [
[
'id' => 7,
'relay_slots' => [
['relay_id' => 'M-7', 'slot' => 'MACHINE'],
],
],
],
],
'graph' => [
'edges' => [
['id' => 'task-service:41:MACHINE:701:M-7:0', 'source' => 'task:41', 'target' => 'binding:701:M-7:0', 'label' => 'MACHINE'],
],
],
]);
expect($debug['hardware']['missing_service_bindings'])->toBe([])
->and($debug['hardware']['service_bindings']['MACHINE'][0]['node_id'])->toBe('binding:701:M-7:0')
->and($debug['hardware']['summary'])->toBe('Lane relay and gateway service bindings are ready for the simulated services.')
->and($debug['tasks'][0]['relay_bindings'][0]['service'])->toBe('MACHINE')
->and(array_column($debug['recommendations'], 'title'))->toBe(['Flow is ready']);
});