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(); $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('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('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(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']); });