Files
api/services/nginx/app/tests/Unit/Selfserve/SelfserveStudioGraphTest.php
T
Jeppe Bundgaard acdff75311 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.
2026-04-28 16:54:57 +02:00

340 lines
14 KiB
PHP

<?php
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
{
$reflection = new ReflectionClass(selfserve_studio_graph::class);
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();
$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']);
});