newInstanceWithoutConstructor(); } function selfserve_wash_flow_without_constructor(): selfserve_wash_flow { $reflection = new ReflectionClass(selfserve_wash_flow::class); return $reflection->newInstanceWithoutConstructor(); } function selfserve_virtual_hardware_without_constructor(): selfserve_virtual_hardware { $reflection = new ReflectionClass(selfserve_virtual_hardware::class); return $reflection->newInstanceWithoutConstructor(); } it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void { $service = selfserve_studio_graph_without_constructor(); $graph = $service->buildGraphFromConfig([ 'questions' => [ ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => 10, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1], ], 'conditions' => [ ['id' => 10, 'name' => 'Trailer present', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2], ], 'rules' => [ ['id' => 20, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1, 'name' => 'Mirror answer'], ], 'tasks' => [ ['id' => 30, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 1, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1, 'services' => ['MACHINE']], ], ], [ 'lookups' => [ 'departments' => [['id' => 2, 'label' => 'Roskilde']], 'lanes' => [['id' => 7, 'label' => 'Lane 7']], 'products' => [['id' => 3, 'label' => 'Forvogn']], 'machine_types' => [], 'vehicle_types' => [['id' => 3, 'product' => 3, 'label' => 'Forvogn', 'source' => 'products']], 'labels' => [ 'departments' => ['2' => 'Roskilde'], 'lanes' => ['7' => 'Lane 7'], 'products' => ['3' => 'Forvogn'], 'vehicle_types' => ['3' => 'Forvogn'], 'questions' => ['1' => 'Are mirrors folded?'], 'conditions' => ['10' => 'Trailer present'], 'tasks' => ['30' => 'Fold mirrors'], ], ], 'gateway_workspace' => [ 'gateways' => [ [ 'id' => 50, 'label' => 'Gateway A', 'status' => 'ONLINE', 'bindings' => [ ['relay_id' => 'relay-1', 'label' => 'Machine relay', 'role' => 'MACHINE'], ], ], ], 'relays' => [ ['relay_id' => 'relay-1', 'name' => 'Machine relay'], ], 'lanes' => [ [ 'id' => 7, 'relay_slots' => [ ['relay_id' => 'relay-1', 'slot' => 'MACHINE'], ], ], ], ], ]); $nodeIds = array_column($graph['nodes'], 'id'); $edgeIds = array_column($graph['edges'], 'id'); expect($nodeIds)->toContain('question:1'); expect($nodeIds)->toContain('condition:10'); expect($nodeIds)->toContain('rule:20'); expect($nodeIds)->toContain('task:30'); expect($nodeIds)->toContain('vehicle_type:3'); expect($nodeIds)->toContain('gateway:50'); expect($nodeIds)->toContain('relay:relay-1'); expect($edgeIds)->toContain('question-gate:10:1'); expect($edgeIds)->toContain('task-gate:question:1:30'); expect($edgeIds)->toContain('scope:vehicle_type:3:question:1'); expect($edgeIds)->toContain('scope:vehicle_type:3:condition:10'); expect($edgeIds)->toContain('scope:vehicle_type:3:task:30'); expect($edgeIds)->toContain('gateway-binding:50:relay-1:0'); expect($edgeIds)->toContain('task-service:30:MACHINE:50:relay-1:0'); expect($edgeIds)->toContain('relay-lane:relay-1:7:MACHINE'); $taskNode = array_values(array_filter( $graph['nodes'], static fn(array $node): bool => ($node['id'] ?? null) === 'task:30' ))[0] ?? null; $bindingNode = array_values(array_filter( $graph['nodes'], static fn(array $node): bool => ($node['id'] ?? null) === 'binding:50:relay-1:0' ))[0] ?? null; expect($taskNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']); expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']); }); it('serializes configurable studio actions with event, gate, scope, and ordering edges', function (): void { $service = selfserve_studio_graph_without_constructor(); $graph = $service->buildGraphFromConfig([ 'schema_version' => 2, 'questions' => [], 'conditions' => [ ['id' => 10, 'name' => 'Machine selected'], ], 'rules' => [], 'tasks' => [], 'actions' => [ [ 'id' => 80, 'name' => 'Open lane entry', 'event' => 'wash_start_command', 'wash_mode' => 'both', 'operation' => 'open_lane_entrance_port', 'condition_id' => 10, 'lane' => 7, 'order_priority' => 1, ], [ 'id' => 81, 'name' => 'Cleaner off', 'event' => 'wash_start_command', 'wash_mode' => 'machine', 'operation' => 'set_cleaner_relay', 'relay_state' => false, 'lane' => 7, 'order_priority' => 2, ], ], ], [ 'lookups' => [ 'labels' => [ 'lanes' => ['7' => 'Lane 7'], 'conditions' => ['10' => 'Machine selected'], 'actions' => ['80' => 'Open lane entry', '81' => 'Cleaner off'], ], ], 'gateway_workspace' => [ 'gateways' => [ [ 'id' => 50, 'label' => 'Gateway A', 'status' => 'ONLINE', 'bindings' => [ ['relay_id' => 'ENTRY-7', 'label' => 'Entry relay', 'role' => 'ENTRY', 'services' => ['ENTRY']], ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner relay', 'role' => 'CLEANER', 'services' => ['CLEANER']], ], ], ], 'relays' => [ ['relay_id' => 'ENTRY-7', 'name' => 'Entry relay'], ['relay_id' => 'CLEAN-7', 'name' => 'Cleaner relay'], ], 'lanes' => [ [ 'id' => 7, 'relay_slots' => [ ['relay_id' => 'ENTRY-7', 'slot' => 'ENTRY'], ['relay_id' => 'CLEAN-7', 'slot' => 'CLEANER'], ], ], ], ], ]); $nodeIds = array_column($graph['nodes'], 'id'); $edgeIds = array_column($graph['edges'], 'id'); $actionNode = array_values(array_filter( $graph['nodes'], static fn(array $node): bool => ($node['id'] ?? null) === 'action:81' ))[0] ?? null; expect($nodeIds)->toContain('action:80') ->and($nodeIds)->toContain('action:81') ->and($edgeIds)->toContain('action-event:wash_start_command:80') ->and($edgeIds)->toContain('action-gate:10:80') ->and($edgeIds)->toContain('action-order:wash_start_command:80:81') ->and($edgeIds)->toContain('action-relay:80:ENTRY-7:ENTRY:7') ->and($edgeIds)->toContain('action-relay:81:CLEAN-7:CLEANER:7') ->and($edgeIds)->toContain('scope:lane:7:action:80') ->and($actionNode['data']['action_label'])->toBe('Turn OFF CLEANER') ->and($actionNode['data']['relay_role'])->toBe('CLEANER'); }); it('validates action configuration and keeps warnings non-blocking', function (): void { $versioning = new class extends selfserve_config_versioning { public function __construct() { } }; $validation = $versioning->validateConfig([ 'schema_version' => 2, 'questions' => [ ['id' => 1, 'question' => 'Machine selected?'], ], 'conditions' => [ [ 'id' => 10, 'name' => 'Gate', 'expression' => [ 'type' => 'group', 'operator' => 'ALL', 'children' => [ ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], ], ], ], ], 'rules' => [], 'tasks' => [], 'actions' => [ [ 'id' => 80, 'name' => 'Manual machine start action', 'event' => 'machine_start_triggered', 'wash_mode' => 'manual', 'operation' => 'set_machine_relay', 'condition_id' => 10, 'relay_state' => true, 'options' => ['failure_policy' => 'block'], ], ], ]); expect($validation['valid'])->toBeTrue() ->and($validation['stats']['actions'])->toBe(1) ->and(implode("\n", $validation['warnings']))->toContain('uses manual mode for the machine-start event'); }); it('serializes v2 condition expressions without standalone rule nodes', function (): void { $service = selfserve_studio_graph_without_constructor(); $graph = $service->buildGraphFromConfig([ 'schema_version' => 2, 'questions' => [ ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1], ], 'conditions' => [ [ 'id' => 10, 'name' => 'Ready', 'lane' => 7, 'product' => 3, 'department' => 2, 'expression' => [ 'type' => 'group', 'operator' => 'ALL', 'children' => [ ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], ], ], ], ], 'rules' => [ ['id' => 99, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1], ], 'tasks' => [], ], [ 'lookups' => [ 'labels' => [ 'questions' => ['1' => 'Are mirrors folded?'], 'conditions' => ['10' => 'Ready'], ], ], ]); $nodeIds = array_column($graph['nodes'], 'id'); $edgeIds = array_column($graph['edges'], 'id'); $conditionNode = array_values(array_filter( $graph['nodes'], static fn(array $node): bool => ($node['id'] ?? null) === 'condition:10' ))[0] ?? null; expect($nodeIds)->toContain('condition:10'); expect($nodeIds)->not->toContain('rule:99'); expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.0'), 0, 8)); expect($conditionNode['data']['expression_summary'] ?? null)->toContain('Question 1'); }); it('serializes branch and case condition expression dependencies', function (): void { $service = selfserve_studio_graph_without_constructor(); $graph = $service->buildGraphFromConfig([ 'schema_version' => 2, 'questions' => [ ['id' => 1, 'question' => 'Has booking?'], ['id' => 2, 'question' => 'Allowed?'], ], 'conditions' => [ [ 'id' => 10, 'name' => 'Branch condition', 'expression' => [ 'type' => 'branch', 'branches' => [ [ 'kind' => 'if', 'when' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'], 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], ], [ 'kind' => 'else', 'else' => true, 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_FALSE'], ], ], ], ], [ 'id' => 20, 'name' => 'Case condition', 'expression' => [ 'type' => 'case', 'subject_type' => 'condition', 'subject_id' => 10, 'cases' => [ [ 'value' => true, 'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'], ], ], ], ], ], 'rules' => [], 'tasks' => [], ]); $edgeIds = array_column($graph['edges'], 'id'); $conditionNode = array_values(array_filter( $graph['nodes'], static fn(array $node): bool => ($node['id'] ?? null) === 'condition:20' ))[0] ?? null; expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.b0.when'), 0, 8)) ->and($edgeIds)->toContain('expression:10:question:2:' . substr(md5('0.b0.then'), 0, 8)) ->and($edgeIds)->toContain('expression:20:condition:10:' . substr(md5('0.case'), 0, 8)) ->and($edgeIds)->toContain('expression:20:question:2:' . substr(md5('0.c0.then'), 0, 8)) ->and($conditionNode['data']['expression_summary'] ?? null)->toContain('Case Condition 10'); }); it('keeps runtime on published v2 configs and leaves draft JSON as the studio edit surface', function (): void { $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName()); $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); expect($washFlowSource)->toContain('getPublishedV2Config($departmentId)'); expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';"); expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);'); expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.'); expect($studioGraphSource)->toContain('upsert_path'); }); it('upserts path editor answers into generated condition and task config rows', function (): void { $service = selfserve_studio_graph_without_constructor(); $method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation'); $method->setAccessible(true); $config = [ 'schema_version' => 2, 'questions' => [ ['id' => 11, 'question' => 'Machine wash is allowed', 'order_priority' => 1], ['id' => 12, 'question' => 'Trailer present', 'order_priority' => 2], ], 'conditions' => [], 'rules' => [], 'tasks' => [], 'actions' => [], 'v2_meta' => ['next_ids' => ['condition' => 100, 'task' => 200]], ]; $method->invokeArgs($service, [6, &$config, [ 'action' => 'upsert_path', 'entity' => 'path', 'data' => [ 'path_key' => 'allowed_front', 'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001], 'answers' => [ ['question_id' => 11, 'value' => true], ['question_id' => 12, 'value' => false], ], 'result' => [ 'machine_allowed' => true, 'task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker', 'reset', 2, 'start'], 'dynamic_images_vehicle_type' => 3, 'tasks' => [ [ 'task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3, ], [ 'task' => 'Press reset', 'services' => ['MACHINE'], 'buttons' => ['reset'], ], [ 'task' => 'Press machine button 2', 'services' => ['MACHINE'], 'buttons' => [2], ], [ 'task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start'], ], ], ], ], ]]); $condition = $config['conditions'][0] ?? []; $tasks = array_values($config['tasks'] ?? []); $pathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? []; $validation = (new class extends selfserve_config_versioning { public function __construct() { } })->validateConfig($config); expect($condition['generated_by'])->toBe('path_editor') ->and($condition['path_key'])->toBe('allowed_front') ->and($condition['lane'])->toBe(7) ->and($condition['product'])->toBe(2) ->and($condition['machine_type_id'])->toBe(1001) ->and($condition['expression']['children'])->toHaveCount(2) ->and($condition['expression']['children'][0]['operator'])->toBe('IS_TRUE') ->and($condition['expression']['children'][1]['operator'])->toBe('IS_FALSE') ->and($tasks)->toHaveCount(4) ->and(array_column($tasks, 'task'))->toBe(['Set program', 'Press reset', 'Press machine button 2', 'Press start']) ->and(array_column($tasks, 'order_priority'))->toBe([10, 20, 30, 40]) ->and(array_column($tasks, 'gate_ref_id'))->toBe([(int)$condition['id'], (int)$condition['id'], (int)$condition['id'], (int)$condition['id']]) ->and($tasks[0]['generated_by'])->toBe('path_editor') ->and($tasks[0]['gate_type'])->toBe('CONDITION') ->and($tasks[0]['services'])->toBe(['MACHINE', 'PROGRAM_PICKER']) ->and($tasks[0]['buttons'])->toBe(['program_picker']) ->and($tasks[0]['dynamic_images_vehicle_type'])->toBe(3) ->and($tasks[1]['buttons'])->toBe(['reset']) ->and($tasks[2]['buttons'])->toBe([2]) ->and($tasks[3]['buttons'])->toBe(['start']) ->and($pathMeta['condition_id'])->toBe((int)$condition['id']) ->and($pathMeta['task_id'])->toBe((int)$tasks[0]['id']) ->and($pathMeta['task_ids'])->toBe(array_map(static fn(array $task): int => (int)$task['id'], $tasks)) ->and($pathMeta['result']['buttons'])->toBe(['program_picker', 'reset', 2, 'start']) ->and($pathMeta['path_signature'])->not->toBe('') ->and($validation['valid'])->toBeTrue(); $method->invokeArgs($service, [6, &$config, [ 'action' => 'upsert_path', 'entity' => 'path', 'data' => [ 'path_key' => 'allowed_front', 'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001], 'answers' => [ ['question_id' => 11, 'value' => true], ['question_id' => 12, 'value' => false], ], 'result' => [ 'machine_allowed' => true, 'task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker', 'start'], 'dynamic_images_vehicle_type' => 3, 'tasks' => [ ['task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3], ['task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start']], ], ], ], ]]); $updatedTasks = array_values(array_filter( $config['tasks'] ?? [], static fn(array $task): bool => ($task['path_key'] ?? '') === 'allowed_front' )); $updatedPathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? []; expect($updatedTasks)->toHaveCount(2) ->and(array_column($updatedTasks, 'id'))->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]) ->and(array_column($updatedTasks, 'task'))->toBe(['Set program', 'Press start']) ->and($updatedPathMeta['task_ids'])->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]); $method->invokeArgs($service, [6, &$config, [ 'action' => 'upsert_path', 'entity' => 'path', 'data' => [ 'path_key' => 'legacy_front', 'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001], 'answers' => [ ['question_id' => 11, 'value' => false], ], 'result' => [ 'machine_allowed' => true, 'task' => 'Legacy start', 'services' => ['MACHINE'], 'buttons' => ['start'], ], ], ]]); $legacyPathMeta = $config['v2_meta']['path_editor']['paths']['legacy_front'] ?? []; $legacyTask = array_values(array_filter( $config['tasks'] ?? [], static fn(array $task): bool => ($task['path_key'] ?? '') === 'legacy_front' ))[0] ?? []; expect($legacyTask['task'])->toBe('Legacy start') ->and($legacyTask['buttons'])->toBe(['start']) ->and($legacyPathMeta['task_id'])->toBe((int)$legacyTask['id']) ->and($legacyPathMeta['task_ids'])->toBe([(int)$legacyTask['id']]); }); it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void { $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName()); $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); $attachmentPayloadSource = file_get_contents(WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php'); expect($washFlowSource)->toContain("require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';") ->and($washFlowSource)->toContain('(new selfserve_task_attachment_payloads())->attachToTasks(') ->and($washFlowSource)->toContain("'attachments' => \$task['attachments'] ?? []") ->and($washFlowSource)->toContain("'attachments' => \$taskAttachments") ->and($studioGraphSource)->toContain('$configWithAttachments = $this->withTaskAttachments($config);') ->and($studioGraphSource)->toContain('$task[\'attachments\'] = is_array($task[\'attachments\'] ?? null) ? array_values($task[\'attachments\']) : [];') ->and($attachmentPayloadSource)->toContain("private const OBJECT_TYPE = 'department_selfserve_tasks';") ->and($attachmentPayloadSource)->toContain('listMany(self::OBJECT_TYPE, $taskIds)') ->and($attachmentPayloadSource)->toContain('generateDirectDownloadUrl($fileName)'); }); it('derives studio vehicle type lookup rows from selectable wash products', function (): void { $service = selfserve_studio_graph_without_constructor(); $method = new ReflectionMethod(selfserve_studio_graph::class, 'vehicleTypeRowsFromProducts'); $vehicleTypes = $method->invoke($service, [ ['id' => 3, 'name' => 'Forvogn', 'description' => 'Front vehicle', 'is_wash' => 1, 'subscription_allowed' => 1, 'order_priority' => 10], ['id' => 4, 'name' => 'Trækker', 'description' => 'Tractor unit', 'is_wash' => '1', 'subscription_allowed' => '1', 'order_priority' => 20], ['id' => 5, 'name' => 'Addon', 'description' => '', 'is_wash' => 0, 'subscription_allowed' => 1, 'order_priority' => 30], ['id' => 6, 'name' => 'Internal wash', 'description' => '', 'is_wash' => 1, 'subscription_allowed' => 0, 'order_priority' => 40], ]); expect(array_column($vehicleTypes, 'id'))->toBe([3, 4]); expect(array_column($vehicleTypes, 'label'))->toBe(['Forvogn', 'Trækker']); expect($vehicleTypes[0]['product'])->toBe(3); expect($vehicleTypes[0]['product_id'])->toBe(3); expect($vehicleTypes[0]['source'])->toBe('products'); }); it('exposes dynamic images and referenced machine types as studio lookup choices', function (): void { $service = selfserve_studio_graph_without_constructor(); $dynamicImages = new ReflectionMethod(selfserve_studio_graph::class, 'dynamicImageRowsFromLanes'); $machineTypes = new ReflectionMethod(selfserve_studio_graph::class, 'addReferencedMachineTypeRows'); $dynamicImageRows = $dynamicImages->invoke($service, [ ['id' => 7, 'label' => 'Lane 7', 'dynamic_image_id' => 1], ['id' => 8, 'label' => 'Lane 8', 'dynamic_image_id' => 9], ]); $machineTypeRows = $machineTypes->invoke( $service, [ ['id' => 1001, 'name' => 'Portal', 'label' => 'Portal'], ], [ ['id' => 7, 'machine_type_id' => 2002], ], [ 'conditions' => [ ['id' => 21, 'machine_type_id' => 3003], ], 'tasks' => [ ['id' => 41, 'machine_type_id' => 1001], ], ] ); expect(array_column($dynamicImageRows, 'id'))->toBe([1, 9]); expect($dynamicImageRows[0]['label'])->toBe('Machine 1'); expect($dynamicImageRows[1]['label'])->toBe('Dynamic image 9'); expect(array_column($machineTypeRows, 'id'))->toBe([1001, 2002, 3003]); expect($machineTypeRows[0]['label'])->toBe('Portal'); expect($machineTypeRows[1]['label'])->toBe('Machine type 2002'); expect($machineTypeRows[2]['label'])->toBe('Machine type 3003'); }); it('keeps lane management fields on lane scope nodes', function (): void { $service = selfserve_studio_graph_without_constructor(); $graph = $service->buildGraphFromConfig([ 'questions' => [], 'conditions' => [], 'rules' => [], 'tasks' => [], ], [ 'lookups' => [ 'lanes' => [ [ 'id' => 7, 'department' => 6, 'name' => 'Lane 7', 'label' => 'Lane 7', 'relay_machine_id' => 'M-7', 'machine_type_id' => 1001, 'dynamic_image_id' => 1, 'selfserve_enabled' => true, ], ], 'machine_types' => [['id' => 1001, 'label' => 'Portal']], 'dynamic_images' => [['id' => 1, 'label' => 'Machine 1']], 'labels' => [ 'lanes' => ['7' => 'Lane 7'], 'machine_types' => ['1001' => 'Portal'], 'dynamic_images' => ['1' => 'Machine 1'], ], ], ]); $laneNode = array_values(array_filter( $graph['nodes'], static fn(array $node): bool => ($node['id'] ?? null) === 'lane:7' ))[0] ?? null; expect($laneNode)->not->toBeNull() ->and($laneNode['data']['raw']['relay_machine_id'])->toBe('M-7') ->and($laneNode['data']['raw']['machine_type_id'])->toBe(1001) ->and($laneNode['data']['raw']['dynamic_image_id'])->toBe(1) ->and($laneNode['data']['raw']['selfserve_enabled'])->toBeTrue(); }); it('routes studio lane graph operations through department_lanes', function (): void { $source = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName()); expect($source)->toContain("if (\$entity === 'lane')") ->and($source)->toContain('private function applyLaneOperation') ->and($source)->toContain('private function createLane') ->and($source)->toContain('private function updateLane') ->and($source)->toContain('INSERT INTO department_lanes') ->and($source)->toContain("'dynamic_image_id'") ->and($source)->toContain("'selfserve_enabled'") ->and($source)->toContain('normalizeLaneField') ->and($source)->toContain('normalizeSelfServeEnabledValue') ->and($source)->toContain('disableSelfServeRelaysBestEffort'); }); it('applies saved layout without changing graph semantics', function (): void { $service = selfserve_studio_graph_without_constructor(); $graph = $service->buildGraphFromConfig([ 'questions' => [ ['id' => 1, 'question' => 'Question', 'order_priority' => 1], ], 'conditions' => [], 'rules' => [], 'tasks' => [], ], [ 'lookups' => [ 'labels' => [], ], ], [ 'nodes' => [ 'question:1' => ['x' => 123, 'y' => 456], ], ]); $questionNode = array_values(array_filter( $graph['nodes'], static fn(array $node): bool => ($node['id'] ?? null) === 'question:1' ))[0] ?? null; expect($questionNode)->not->toBeNull(); expect($questionNode['position'])->toBe(['x' => 123.0, 'y' => 456.0]); }); it('keeps layout loading compatible with native PDO named placeholders', function (): void { $method = new ReflectionMethod(selfserve_studio_graph::class, 'loadLayout'); $source = implode('', array_slice( file((string)$method->getFileName()) ?: [], $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1 )); expect($source)->not->toContain('user_id = :user_id OR user_id IS NULL'); expect($source)->not->toContain('user_id = :user_id THEN 0 ELSE 1'); 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($debug['tasks'][0]['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual missing.') ->and($debug['tasks'][0]['causes'][0])->toMatchArray([ 'kind' => 'question', 'id' => 11, 'label' => 'Are mirrors folded?', 'expected' => true, 'actual' => null, ]) ->and($debug['dynamic_image_buttons'][0])->toMatchArray([ 'kind' => 'dynamic_image_button', 'label' => '1', 'state' => 'hidden', 'task_id' => 41, ]) ->and(array_column($debug['decisions'], 'kind'))->toContain('question') ->and(array_column($debug['decisions'], 'kind'))->toContain('task') ->and(array_column($debug['decisions'], 'kind'))->toContain('signal') ->and(array_column($debug['decisions'], 'kind'))->toContain('dynamic_image_button') ->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('uses program picker button numbers as thumb selectors in simulator debug decisions', function (): void { $service = selfserve_wash_flow_without_constructor(); $debug = $service->buildStudioDebugPayload(6, [ 'lane' => [ 'id' => 7, 'department' => 6, 'name' => 'Lane 7', 'relay_machine_id' => 'M-7', 'relay_machine_program_picker_id' => 'PICKER-7', ], 'machine_type' => ['id' => 1001, 'name' => 'Portal'], 'vehicle' => null, 'reg' => 'TEST123', 'customer_number' => null, 'vehicle_type_id' => 4, 'answers' => [], 'questions' => [], 'tasks' => [ ['id' => 41, 'task' => 'Choose program', 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1], ['id' => 42, 'task' => 'Press first button', 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2], ], 'allowed_services' => ['PROGRAM_PICKER', 'MACHINE'], 'machine_available' => true, 'all_visible_questions_answered' => true, 'allowed' => true, 'config_version_id' => 90, 'config_source' => 'draft', 'evaluation_trace' => [ 'visible_question_ids' => [], 'visibility_condition_results' => [], 'condition_results' => [], 'task_gates' => [ ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], ['task_id' => 42, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], ], ], 'debug_candidates' => [ 'questions' => [], 'conditions' => [], 'rules' => [], 'tasks' => [ ['id' => 41, 'task' => 'Choose program', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1], ['id' => 42, 'task' => 'Press first button', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2], ], ], ], [ 'lookups' => [ 'labels' => [ 'departments' => ['6' => 'Roskilde'], 'lanes' => ['7' => 'Lane 7'], 'vehicle_types' => ['4' => 'Program 4'], 'machine_types' => ['1001' => 'Portal'], 'tasks' => ['41' => 'Choose program', '42' => 'Press first button'], ], ], 'gateway_workspace' => [ 'gateways' => [ [ 'id' => 701, 'label' => 'Roskilde Edge', 'status' => 'ONLINE', 'bindings' => [ ['relay_id' => 'PICKER-7', 'label' => 'Program picker relay', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']], ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']], ], ], ], ], ]); $dynamicImageDecisions = array_values(array_filter( $debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? null) === 'dynamic_image_button' )); expect(array_column($debug['dynamic_image_buttons'], 'button'))->toBe(['program_picker', 0]) ->and(array_column($debug['dynamic_image_buttons'], 'label'))->toBe(['Program picker', '0']) ->and(array_column($dynamicImageDecisions, 'label'))->toBe(['Program picker', '0']); }); it('adds exact hidden question, skipped action, signal, and button decision causes', function (): void { $service = selfserve_wash_flow_without_constructor(); $debug = $service->buildStudioDebugPayload(6, [ 'lane' => [ 'id' => 7, 'department' => 6, 'name' => 'Lane 7', 'relay_machine_id' => 'M-7', ], 'machine_type' => ['id' => 1001, 'name' => 'Portal'], 'vehicle' => null, 'reg' => 'TEST123', 'customer_number' => null, 'vehicle_type_id' => 2, 'answers' => [11 => false], 'answer_sources' => [11 => 'override'], 'questions' => [], 'tasks' => [], 'allowed_services' => [], 'machine_available' => true, 'all_visible_questions_answered' => true, 'allowed' => false, 'config_version_id' => 90, 'config_source' => 'draft', 'evaluation_trace' => [ 'visible_question_ids' => [11], 'visibility_condition_results' => [21 => false], 'condition_results' => [21 => false], 'visibility_expression_traces' => [ 21 => [ 'type' => 'condition', 'condition_id' => 21, 'result' => false, 'expression' => [ 'type' => 'group', 'operator' => 'ALL', 'result' => false, 'children' => [ [ 'type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 11, 'operator' => 'IS_TRUE', 'actual_value' => false, 'result' => false, 'reason' => 'Predicate did not pass.', ], ], 'reason' => 'Group did not pass.', ], ], ], 'condition_expression_traces' => [ 21 => [ 'type' => 'condition', 'condition_id' => 21, 'result' => false, 'expression' => [ 'type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 11, 'operator' => 'IS_TRUE', 'actual_value' => false, 'result' => false, 'reason' => 'Predicate did not pass.', ], ], ], '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' => null, 'order_priority' => 1], ['id' => 12, 'question' => 'Is the lift lowered?', 'condition_id' => 21, 'order_priority' => 2], ], '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' => ['start'], 'order_priority' => 1], ], 'actions' => [ [ 'id' => 81, 'name' => 'Open entry on start', 'event' => 'wash_start_command', 'wash_mode' => 'both', 'operation' => 'open_lane_entrance_port', 'condition_id' => 21, 'order_priority' => 1, ], ], 'visible_answers' => [11 => false], ], ], [ 'lookups' => [ 'labels' => [ 'departments' => ['6' => 'Roskilde'], 'lanes' => ['7' => 'Lane 7'], 'vehicle_types' => ['2' => 'Forvogn'], 'machine_types' => ['1001' => 'Portal'], 'questions' => ['11' => 'Are mirrors folded?', '12' => 'Is the lift lowered?'], 'conditions' => ['21' => 'Trailer present'], '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']], ], ], ], ], ]); $hiddenQuestion = array_values(array_filter($debug['questions'], static fn(array $question): bool => (int)$question['id'] === 12))[0] ?? []; $taskDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'task'))[0] ?? []; $actionDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'action'))[0] ?? []; $signalDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'signal' && ($decision['state'] ?? '') === 'blocked'))[0] ?? []; $buttonDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'dynamic_image_button'))[0] ?? []; expect($hiddenQuestion['state'])->toBe('hidden') ->and($hiddenQuestion['reason'])->toBe('Question Is the lift lowered? hidden because visibility condition Trailer present expected true, actual false.') ->and($debug['conditions'][0]['causes'][0])->toMatchArray([ 'kind' => 'question', 'id' => 11, 'label' => 'Are mirrors folded?', 'expected' => true, 'actual' => false, ]) ->and($taskDecision['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual false.') ->and($actionDecision['state'])->toBe('skipped') ->and($actionDecision['causes'][0])->toMatchArray([ 'kind' => 'condition', 'id' => 21, 'label' => 'Trailer present', 'expected' => true, 'actual' => false, ]) ->and($signalDecision['causes'][0]['reason'])->toContain('blocked') ->and($buttonDecision['state'])->toBe('hidden') ->and($buttonDecision['causes'][0]['label'])->toBe('Fold mirrors'); }); it('projects visible question answer paths into grouped task service and signal outcomes', function (): void { $service = selfserve_studio_graph_without_constructor(); $simulate = function (array $overrides): array { $answers = []; foreach ($overrides as $entry) { $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; } $mirrorAnswer = $answers[11] ?? null; $liftAnswer = $answers[12] ?? null; $liftVisible = $mirrorAnswer === true; $allowed = $mirrorAnswer === true && $liftAnswer === true; return [ 'allowed' => $allowed, 'questions' => array_values(array_filter([ ['id' => 11, 'question' => 'Are mirrors folded?', 'answer' => $mirrorAnswer], $liftVisible ? ['id' => 12, 'question' => 'Is the lift lowered?', 'answer' => $liftAnswer] : null, ])), 'tasks' => $allowed ? [ ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']], ] : [], 'allowed_services' => $allowed ? ['MACHINE'] : [], 'debug' => [ 'questions' => [ [ 'id' => 11, 'node_id' => 'question:11', 'label' => 'Are mirrors folded?', 'visible' => true, 'answer' => $mirrorAnswer, ], [ 'id' => 12, 'node_id' => 'question:12', 'label' => 'Is the lift lowered?', 'visible' => $liftVisible, 'answer' => $liftVisible ? $liftAnswer : null, ], ], 'tasks' => [ [ 'id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => ['start'], 'order_priority' => 1, ], ], 'signal_timeline' => [ [ 'sequence' => 1, 'runtime_stage' => 'eligibility_sync', 'signal_type' => 'session_event', 'relay_role' => 'SESSION', 'source' => 'none', 'predicted_status' => $allowed ? 'sent' : 'skipped', 'payload' => ['allowed' => $allowed], ], [ 'sequence' => 2, 'runtime_stage' => 'machine_start_signal', 'signal_type' => 'shelly_event', 'relay_role' => 'MACHINE', 'relay_id' => 'M-7', 'target_binding' => 'binding:701:M-7:0', 'target_gateway_label' => 'Roskilde Edge', 'source' => 'real', 'predicted_status' => $allowed ? 'sent' : 'skipped', 'payload' => ['event' => 'input.toggle_on'], ], ], ], ]; }; $projection = $service->projectPathOutcomesFromSimulator($simulate, [ 'max_states' => 20, 'scope' => [ 'department_id' => 6, 'lane_id' => 7, 'vehicle_type_id' => 2, 'config_source' => 'draft', 'hardware_mode' => 'studio', ], ]); $allowedOutcome = array_values(array_filter( $projection['outcomes'], static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === true ))[0] ?? null; $blockedOutcome = array_values(array_filter( $projection['outcomes'], static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === false ))[0] ?? null; expect($projection['truncated'])->toBeFalse() ->and($projection['summary']['state_count'])->toBe(5) ->and($projection['summary']['terminal_path_count'])->toBe(3) ->and($projection['summary']['outcome_count'])->toBe(2) ->and($projection['summary']['path_sample_count'])->toBe(3) ->and($projection['summary']['question_ids'])->toBe([11, 12]) ->and($projection['paths'])->toHaveCount(3) ->and($allowedOutcome)->not->toBeNull() ->and($allowedOutcome['path_count'])->toBe(1) ->and($allowedOutcome['services'])->toBe(['MACHINE']) ->and($allowedOutcome['tasks'][0]['label'])->toBe('Start machine') ->and($allowedOutcome['signals'][1]['relay_role'])->toBe('MACHINE') ->and($allowedOutcome['node_ids'])->toContain('binding:701:M-7:0') ->and($blockedOutcome)->not->toBeNull() ->and($blockedOutcome['path_count'])->toBe(2); $oneAnswerBlockedSample = array_values(array_filter( $blockedOutcome['sample_chains'], static fn(array $chain): bool => count((array)($chain['answers'] ?? [])) === 1 ))[0] ?? null; expect($oneAnswerBlockedSample)->not->toBeNull() ->and($oneAnswerBlockedSample['answers'][0]['question_id'])->toBe(11) ->and($oneAnswerBlockedSample['answers'][0]['answer'])->toBeFalse(); $allowedPath = array_values(array_filter( $projection['paths'], static fn(array $path): bool => ($path['allowed'] ?? false) === true ))[0] ?? null; expect($allowedPath)->not->toBeNull() ->and($allowedPath['result'])->toBe('Allowed') ->and($allowedPath['answers'])->toHaveCount(2) ->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?') ->and($allowedPath['services'])->toBe(['MACHINE']) ->and($allowedPath['path_signature'])->not->toBe('') ->and($allowedPath['result_signature'])->not->toBe('') ->and($allowedPath['confirmation_status'])->toBe('unconfirmed') ->and($allowedPath['node_ids'])->toContain('question:11') ->and($allowedPath['node_ids'])->toContain('task:41'); }); it('marks projected path confirmations confirmed or stale by stable signatures', function (): void { $service = selfserve_studio_graph_without_constructor(); $simulate = static function (string $button): callable { return static function (array $overrides) use ($button): array { $answers = []; foreach ($overrides as $entry) { $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; } $allowed = ($answers[11] ?? null) === true; return [ 'allowed' => $allowed, 'questions' => [ ['id' => 11, 'question' => 'Machine wash is allowed', 'answer' => $answers[11] ?? null], ], 'tasks' => $allowed ? [ ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => [$button]], ] : [], 'allowed_services' => $allowed ? ['MACHINE'] : [], 'debug' => [ 'questions' => [ ['id' => 11, 'node_id' => 'question:11', 'label' => 'Machine wash is allowed', 'visible' => true, 'answer' => $answers[11] ?? null], ], 'tasks' => [ ['id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => [$button], 'order_priority' => 1], ], 'signal_timeline' => [], ], ]; }; }; $scope = [ 'department_id' => 6, 'lane_id' => 7, 'vehicle_type_id' => 2, 'config_source' => 'draft', 'config_version_id' => 90, 'hardware_mode' => 'studio', ]; $initial = $service->projectPathOutcomesFromSimulator($simulate('start'), ['scope' => $scope]); $allowedPath = array_values(array_filter( $initial['paths'], static fn(array $path): bool => ($path['allowed'] ?? false) === true ))[0] ?? []; $rows = [[ 'path_signature' => $allowedPath['path_signature'], 'result_signature' => $allowedPath['result_signature'], 'answers' => $allowedPath['answers'], 'result' => ['allowed' => true], 'scope' => $scope, 'confirmed_at' => '2026-05-27 10:00:00', 'confirmed_by' => 9, ]]; $confirmed = $service->projectPathOutcomesFromSimulator($simulate('start'), [ 'scope' => $scope, 'confirmation_rows' => $rows, ]); $changed = $service->projectPathOutcomesFromSimulator($simulate('reset'), [ 'scope' => $scope, 'confirmation_rows' => $rows, ]); $confirmedAllowed = array_values(array_filter( $confirmed['paths'], static fn(array $path): bool => ($path['allowed'] ?? false) === true ))[0] ?? []; $staleAllowed = array_values(array_filter( $changed['paths'], static fn(array $path): bool => ($path['allowed'] ?? false) === true ))[0] ?? []; expect($confirmedAllowed['confirmation_status'])->toBe('confirmed') ->and($confirmed['summary']['confirmations']['confirmed'])->toBe(1) ->and($staleAllowed['confirmation_status'])->toBe('stale') ->and($staleAllowed['stale_reason'])->toBe('Result changed since confirmation.') ->and($changed['summary']['confirmations']['stale'])->toBe(1); }); it('truncates path outcome projection when the state cap is reached', function (): void { $service = selfserve_studio_graph_without_constructor(); $simulate = function (array $overrides): array { $answers = []; foreach ($overrides as $entry) { $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; } $first = $answers[1] ?? null; $secondVisible = $first === true; return [ 'allowed' => false, 'questions' => [], 'tasks' => [], 'allowed_services' => [], 'debug' => [ 'questions' => [ ['id' => 1, 'node_id' => 'question:1', 'label' => 'First', 'visible' => true, 'answer' => $first], ['id' => 2, 'node_id' => 'question:2', 'label' => 'Second', 'visible' => $secondVisible, 'answer' => $answers[2] ?? null], ], 'tasks' => [], 'signal_timeline' => [], ], ]; }; $projection = $service->projectPathOutcomesFromSimulator($simulate, ['max_states' => 2]); expect($projection['truncated'])->toBeTrue() ->and($projection['summary']['state_count'])->toBe(2) ->and($projection['warnings'][0])->toContain('truncated at 2 explored state'); }); it('returns complete terminal path results for wide question trees and reports progress', function (): void { $service = selfserve_studio_graph_without_constructor(); $simulate = function (array $overrides): array { $answers = []; foreach ($overrides as $entry) { $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null; } $questions = []; foreach (range(1, 12) as $questionId) { $questions[] = [ 'id' => $questionId, 'node_id' => 'question:' . $questionId, 'label' => 'Question ' . $questionId, 'visible' => true, 'answer' => $answers[$questionId] ?? null, ]; } $complete = count($answers) === 12; $allowed = $complete && !in_array(false, $answers, true); return [ 'allowed' => $allowed, 'questions' => [], 'tasks' => $allowed ? [ ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']], ] : [], 'allowed_services' => $allowed ? ['MACHINE'] : [], 'debug' => [ 'questions' => $questions, 'tasks' => [ [ 'id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => ['start'], 'order_priority' => 1, ], ], 'signal_timeline' => [], ], ]; }; $progressEvents = []; $projection = $service->projectPathOutcomesFromSimulator($simulate, [ 'progress_interval_states' => 512, 'progress_callback' => static function (array $partial) use (&$progressEvents): void { $progressEvents[] = [ 'percent' => (int)($partial['progress']['percent'] ?? 0), 'terminal_path_count' => (int)($partial['summary']['terminal_path_count'] ?? 0), 'path_sample_count' => (int)($partial['summary']['path_sample_count'] ?? 0), ]; }, ]); expect($projection['truncated'])->toBeFalse() ->and($projection['summary']['state_count'])->toBe(8191) ->and($projection['summary']['question_count'])->toBe(12) ->and($projection['summary']['terminal_path_count'])->toBe(4096) ->and($projection['summary']['outcome_count'])->toBe(2) ->and($projection['summary']['path_sample_count'])->toBe(4096) ->and($projection['progress']['complete'])->toBeTrue() ->and($projection['progress']['percent'])->toBe(100) ->and($projection['paths'])->toHaveCount(4096) ->and($projection['paths'][0]['answers'])->toHaveCount(12) ->and($projection['paths'][0]['result'])->toBe('Allowed') ->and($progressEvents)->not->toBeEmpty() ->and($progressEvents[0]['terminal_path_count'])->toBeGreaterThan(0) ->and($progressEvents[0]['path_sample_count'])->toBeGreaterThan(0); }); 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']); }); it('generates and merges virtual hardware as studio-only relay coverage', function (): void { $service = selfserve_virtual_hardware_without_constructor(); $workspace = [ 'gateways' => [], 'relays' => [], 'lanes' => [ [ 'id' => 7, 'name' => 'Lane 7', 'relay_slots' => [ ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], ['slot' => 'EXIT', 'relay_id' => 'EXIT-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], ], 'binding_coverage' => ['required' => 2, 'bound' => 0, 'missing' => 2, 'state' => 'MISSING'], ], ], 'issues' => [ ['severity' => 'danger', 'code' => 'NO_GATEWAY', 'message' => 'No edge gateway has been claimed for this department.'], ['severity' => 'warning', 'code' => 'LANE_BINDING_GAP', 'message' => 'Lane 7 is missing relay bindings.', 'target_type' => 'lane', 'target_id' => 7], ], 'actions' => [], ]; $config = $service->generateFromLanes($workspace); $merged = $service->mergeWorkspaceWithConfig($workspace, $config); expect($config['bindings'])->toHaveCount(2) ->and($merged['virtual']['has_virtual_hardware'])->toBeTrue() ->and($merged['gateways'][0]['virtual'])->toBeTrue() ->and($merged['gateways'][0]['bindings'][0]['relay_id'])->toBe('M-7') ->and($merged['lanes'][0]['binding_coverage']['state'])->toBe('READY') ->and($merged['lanes'][0]['relay_slots'][0]['coverage']['virtual'])->toBeTrue() ->and(array_column($merged['issues'], 'code'))->toContain('VIRTUAL_HARDWARE_ACTIVE') ->and($service->validationWarnings($merged)[0])->toContain('live relay dispatch still requires a real edge gateway'); }); it('renders virtual gateway nodes and task service edges in the studio graph', function (): void { $virtual = selfserve_virtual_hardware_without_constructor(); $graphService = selfserve_studio_graph_without_constructor(); $workspace = $virtual->mergeWorkspaceWithConfig([ 'gateways' => [], 'relays' => [], 'lanes' => [ [ 'id' => 7, 'name' => 'Lane 7', 'relay_slots' => [ ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']], ], 'binding_coverage' => ['required' => 1, 'bound' => 0, 'missing' => 1, 'state' => 'MISSING'], ], ], 'issues' => [], 'actions' => [], ], [ 'schema_version' => 1, 'enabled' => true, 'gateways' => [['key' => 'virtual-main', 'label' => 'Virtual Studio Gateway', 'status' => 'VIRTUAL']], 'relays' => [['relay_id' => 'M-7', 'name' => 'Lane 7 MACHINE']], 'bindings' => [['gateway_key' => 'virtual-main', 'relay_id' => 'M-7', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'label' => 'Lane 7 MACHINE']], ]); $graph = $graphService->buildGraphFromConfig([ 'questions' => [], 'conditions' => [], 'rules' => [], 'tasks' => [ ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'order_priority' => 1], ], ], [ 'lookups' => [ 'labels' => [ 'tasks' => ['41' => 'Start machine'], 'lanes' => ['7' => 'Lane 7'], ], ], 'gateway_workspace' => $workspace, ]); $nodeIds = array_column($graph['nodes'], 'id'); $edgeIds = array_column($graph['edges'], 'id'); $gatewayNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'gateway:virtual-main'))[0] ?? []; $bindingNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'binding:virtual-main:M-7:0'))[0] ?? []; expect($nodeIds)->toContain('gateway:virtual-main') ->and($nodeIds)->toContain('binding:virtual-main:M-7:0') ->and($edgeIds)->toContain('task-service:41:MACHINE:virtual-main:M-7:0') ->and($gatewayNode['data']['raw']['virtual'])->toBeTrue() ->and($bindingNode['data']['raw']['virtual'])->toBeTrue(); }); it('inserts configured action signals into the simulator timeline in runtime order', function (): void { $service = selfserve_wash_flow_without_constructor(); $debug = $service->buildStudioDebugPayload(6, [ 'lane' => [ 'id' => 7, 'department' => 6, 'name' => 'Lane 7', 'relay_in_id' => 'ENTRY-7', 'relay_out_id' => 'EXIT-7', 'relay_machine_id' => 'M-7', 'relay_machine_program_picker_id' => 'PICKER-7', 'relay_machine_cleaner_id' => 'CLEAN-7', ], 'machine_type' => ['id' => 1001, 'name' => 'Portal'], 'vehicle' => null, 'reg' => 'TEST123', 'customer_number' => null, 'vehicle_type_id' => 2, 'answers' => [], 'answer_sources' => [], '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' => [], 'visibility_condition_results' => [], 'condition_results' => [21 => true], 'task_gates' => [ ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], ], ], 'debug_candidates' => [ 'questions' => [], 'conditions' => [ ['id' => 21, 'name' => 'Gate'], ], 'rules' => [], 'tasks' => [ ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1], ], 'actions' => [ [ 'id' => 81, 'name' => 'Open entry on start', 'event' => 'wash_start_command', 'wash_mode' => 'both', 'operation' => 'open_lane_entrance_port', 'condition_id' => 21, 'order_priority' => 1, 'options' => ['toggle_after_seconds' => 2], ], [ 'id' => 82, 'name' => 'Program picker off when machine starts', 'event' => 'machine_start_triggered', 'wash_mode' => 'machine', 'operation' => 'set_program_picker_relay', 'relay_state' => false, 'order_priority' => 1, ], [ 'id' => 83, 'name' => 'Cleaner off on stop', 'event' => 'wash_stop_command', 'wash_mode' => 'machine', 'operation' => 'set_cleaner_relay', 'relay_state' => false, 'order_priority' => 1, ], ], ], ], [ 'gateway_workspace' => [ 'gateways' => [ [ 'id' => 701, 'label' => 'Roskilde Edge', 'status' => 'ONLINE', 'bindings' => [ ['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']], ['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']], ['relay_id' => 'PICKER-7', 'label' => 'Program picker', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']], ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']], ['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']], ], ], ], 'lanes' => [ [ 'id' => 7, 'relay_slots' => [ ['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'], ['slot' => 'MACHINE', 'relay_id' => 'M-7'], ['slot' => 'PROGRAM_PICKER', 'relay_id' => 'PICKER-7'], ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], ], ], ], ], 'lookups' => [ 'labels' => [ 'lanes' => ['7' => 'Lane 7'], 'conditions' => ['21' => 'Gate'], 'tasks' => ['41' => 'Start machine'], ], ], ]); expect(array_column($debug['signal_timeline'], 'sequence'))->toBe(range(1, 11)) ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe([ 'SESSION', 'ENTRY', 'MACHINE', 'MACHINE', 'PROGRAM_PICKER', 'CLEANER', 'CLEANER', 'EXIT', 'CLEANER', 'MACHINE', 'SESSION', ]) ->and($debug['signal_timeline'][1]['signal_type'])->toBe('studio_action_relay_pulse') ->and($debug['signal_timeline'][1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 2]) ->and($debug['signal_timeline'][4]['signal_type'])->toBe('studio_action_relay_switch') ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['action_id' => 82, 'id' => 'PICKER-7', 'on' => false]) ->and($debug['signal_timeline'][6]['payload'])->toMatchArray(['action_id' => 83, 'id' => 'CLEAN-7', 'on' => false]) ->and($debug['actions'][0]['state'])->toBe('active') ->and($debug['graph_annotations']['nodes']['action:81']['state'])->toBe('active'); }); it('simulates lane-scoped wash start actions for property gates and lane entrance ports', function (): void { $service = selfserve_wash_flow_without_constructor(); $debug = $service->buildStudioDebugPayload(6, [ 'lane' => [ 'id' => 7, 'department' => 6, 'name' => 'Lane 7', 'relay_in_id' => 'ENTRY-7', 'relay_out_id' => 'EXIT-7', 'relay_machine_id' => 'M-7', 'relay_machine_cleaner_id' => 'CLEAN-7', ], 'machine_type' => ['id' => 1001, 'name' => 'Portal'], 'vehicle' => null, 'reg' => 'TEST123', 'customer_number' => 123, 'vehicle_type_id' => 2, 'answers' => [], 'answer_sources' => [], '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' => [], 'visibility_condition_results' => [], 'condition_results' => [], 'task_gates' => [], ], 'debug_candidates' => [ 'questions' => [], 'conditions' => [], 'rules' => [], 'tasks' => [['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE']]], 'actions' => [ [ 'id' => 80, 'name' => 'Open property entrance on lane start', 'event' => 'wash_start_command', 'wash_mode' => 'both', 'operation' => 'open_property_entrance_gate', 'lane' => 7, 'order_priority' => 1, ], [ 'id' => 81, 'name' => 'Open lane entrance on lane start', 'event' => 'wash_start_command', 'wash_mode' => 'both', 'operation' => 'open_lane_entrance_port', 'lane' => 7, 'order_priority' => 2, 'options' => ['toggle_after_seconds' => 3], ], ], ], ], [ 'gateway_workspace' => [ 'gateways' => [ [ 'id' => 701, 'label' => 'Roskilde Edge', 'status' => 'ONLINE', 'bindings' => [ ['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']], ['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']], ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']], ['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']], ], ], ], 'lanes' => [ [ 'id' => 7, 'relay_slots' => [ ['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'], ['slot' => 'MACHINE', 'relay_id' => 'M-7'], ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], ], ], ], ], ]); $startSignals = array_values(array_filter( $debug['signal_timeline'], static fn(array $row): bool => str_starts_with((string)($row['signal_type'] ?? ''), 'studio_action_') && ($row['payload']['event'] ?? null) === 'wash_start_command' )); expect(array_column($startSignals, 'relay_role'))->toBe(['PROPERTY_ENTRANCE', 'ENTRY']) ->and(array_column($startSignals, 'predicted_status'))->toBe(['sent', 'sent']) ->and($startSignals[0]['payload'])->toMatchArray(['action_id' => 80, 'command' => 'OPEN_PROPERTY_ACCESS_GATE']) ->and($startSignals[1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 3]); }); it('adds ordered simulator signal timeline rows for virtual hardware dry runs', 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' => [], 'answer_sources' => [], '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' => [], 'visibility_condition_results' => [], 'condition_results' => [], 'task_gates' => [ ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true], ], ], 'debug_candidates' => [ 'questions' => [], 'conditions' => [], 'rules' => [], 'tasks' => [ ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1], ], ], ], [ 'gateway_workspace' => [ 'gateways' => [ [ 'id' => 'virtual-main', 'label' => 'Virtual Studio Gateway', 'status' => 'VIRTUAL', 'virtual' => true, 'bindings' => [ ['node_id' => 'binding:virtual-main:M-7:0', 'relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'virtual' => true], ['node_id' => 'binding:virtual-main:CLEAN-7:1', 'relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER'], 'virtual' => true], ['node_id' => 'binding:virtual-main:EXIT-7:2', 'relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT'], 'virtual' => true], ], ], ], 'lanes' => [ [ 'id' => 7, 'relay_slots' => [ ['slot' => 'MACHINE', 'relay_id' => 'M-7'], ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'], ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'], ], ], ], 'virtual' => ['has_virtual_hardware' => true], ], 'lookups' => ['labels' => ['lanes' => ['7' => 'Lane 7'], 'tasks' => ['41' => 'Start machine']]], ]); expect(array_column($debug['signal_timeline'], 'sequence'))->toBe([1, 2, 3, 4, 5, 6, 7, 8]) ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe(['SESSION', 'MACHINE', 'MACHINE', 'CLEANER', 'EXIT', 'CLEANER', 'MACHINE', 'SESSION']) ->and($debug['signal_timeline'][1]['predicted_status'])->toBe('virtual_only') ->and($debug['signal_timeline'][2]['signal_type'])->toBe('shelly_event') ->and($debug['signal_timeline'][2]['runtime_stage'])->toBe('machine_start_signal') ->and($debug['signal_timeline'][2]['transport'])->toBe('shelly_webhook_or_edge_gateway_event') ->and($debug['signal_timeline'][2]['payload'])->toMatchArray(['event' => 'input.toggle_on', 'bill_machine_wash' => true]) ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['id' => 'EXIT-7', 'toggle_after' => 1]) ->and($debug['hardware']['signal_timeline'][6]['payload'])->toMatchArray(['id' => 'M-7', 'on' => false]); });