- Add `DepartmentLaneDynamicImageRouteTest` to verify dynamic image preview handling for studio lanes. - Introduce `selfserve_machine_signal` class to standardize signal normalization, recording, and gateway signal management workflows. - Add `selfserve_virtual_hardware` class to handle virtual hardware configurations, including gateway and binding management. - Enhance structure with auxiliary methods for payload normalization, workspace merging, and validation warnings.
667 lines
29 KiB
PHP
667 lines
29 KiB
PHP
<?php
|
|
|
|
app_require('modules/selfserve/classes/selfserve_studio_graph.php');
|
|
app_require('modules/selfserve/classes/selfserve_virtual_hardware.php');
|
|
|
|
use modules\selfserve\classes\selfserve_studio_graph;
|
|
use modules\selfserve\classes\selfserve_virtual_hardware;
|
|
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();
|
|
}
|
|
|
|
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 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('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.');
|
|
});
|
|
|
|
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,
|
|
],
|
|
],
|
|
'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);
|
|
});
|
|
|
|
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'");
|
|
});
|
|
|
|
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']);
|
|
});
|
|
|
|
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('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]);
|
|
});
|