Add service and role properties to task and binding nodes in Self-serve Studio Graph tests

This commit is contained in:
Jeppe Bundgaard
2026-04-28 15:42:19 +02:00
parent 206b487fa2
commit 5b94c9407b
3 changed files with 259 additions and 12 deletions
File diff suppressed because one or more lines are too long
@@ -211,7 +211,8 @@ class selfserve_studio_graph
}
$tasksByScope = [];
foreach ($this->sortedRows((array)($config['tasks'] ?? []), ['order_priority', 'id']) as $index => $task) {
$taskRows = $this->sortedRows((array)($config['tasks'] ?? []), ['order_priority', 'id']);
foreach ($taskRows as $index => $task) {
$id = (int)($task['id'] ?? 0);
$nodes[] = $this->node('task:' . $id, 'default', $this->entityLabel('task', $id, $task, $lookups), 'task', [
'object_id' => $id,
@@ -250,6 +251,7 @@ class selfserve_studio_graph
}
$this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace);
$this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace);
return [
'nodes' => $this->applyLayoutToNodes($nodes, $layout),
@@ -541,6 +543,8 @@ class selfserve_studio_graph
*/
private function appendGatewayNodesAndEdges(array &$nodes, array &$edges, array $workspace): void
{
$relayServices = $this->relayServicesFromWorkspace($workspace);
foreach ((array)($workspace['gateways'] ?? []) as $index => $gateway) {
if (!is_array($gateway)) {
continue;
@@ -565,6 +569,13 @@ class selfserve_studio_graph
if ($relayId === '') {
continue;
}
$bindingServices = $this->bindingServices($binding, $relayId, $relayServices);
if ($bindingServices !== []) {
$binding['services'] = $bindingServices;
if (trim((string)($binding['role'] ?? '')) === '' && count($bindingServices) === 1) {
$binding['role'] = $bindingServices[0];
}
}
$bindingId = 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex;
$nodes[] = $this->node($bindingId, 'default', (string)($binding['label'] ?? ('Relay ' . $relayId)), 'relay_binding', [
'object_id' => $relayId,
@@ -610,6 +621,39 @@ class selfserve_studio_graph
}
}
/**
* @param array<int,array<string,mixed>> $edges
* @param array<int,array<string,mixed>> $tasks
* @param array<string,mixed> $workspace
*/
private function appendTaskServiceEdges(array &$edges, array $tasks, array $workspace): void
{
$bindingsByService = [];
foreach ($this->gatewayBindingReferences($workspace) as $binding) {
foreach ((array)$binding['services'] as $service) {
$bindingsByService[$service][] = $binding;
}
}
foreach ($tasks as $task) {
$taskId = (int)($task['id'] ?? 0);
if ($taskId <= 0) {
continue;
}
foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) {
foreach ($bindingsByService[$service] ?? [] as $binding) {
$edges[] = $this->edge(
'task-service:' . $taskId . ':' . $service . ':' . $binding['gateway_id'] . ':' . $binding['relay_id'] . ':' . $binding['binding_index'],
'task:' . $taskId,
(string)$binding['node_id'],
'task_service',
$service
);
}
}
}
}
/**
* @param array<string,mixed> $operation
*/
@@ -621,11 +665,11 @@ class selfserve_studio_graph
$id = (int)($operation['id'] ?? $data['id'] ?? 0);
if ($action === 'connect') {
$this->applyConnection((string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false);
$this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false);
return;
}
if ($action === 'disconnect') {
$this->applyConnection((string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true);
$this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true);
return;
}
if ($action === 'reorder') {
@@ -884,7 +928,7 @@ class selfserve_studio_graph
}
}
private function applyConnection(string $source, string $target, bool $disconnect): void
private function applyConnection(int $departmentId, string $source, string $target, bool $disconnect): void
{
[$sourceType, $sourceId] = $this->parseNodeId($source);
[$targetType, $targetId] = $this->parseNodeId($target);
@@ -892,6 +936,23 @@ class selfserve_studio_graph
throw new \RuntimeException('Invalid connection endpoints.');
}
if ($sourceType === 'task' && $targetType === 'binding') {
$service = $this->serviceForBindingNode($departmentId, $target);
if ($service === '') {
throw new \RuntimeException('Relay binding has no service role to connect to the task.');
}
$this->updateTaskServiceConnection($departmentId, (int)$sourceId, $service, $disconnect);
return;
}
if ($sourceType === 'binding' && $targetType === 'task') {
$service = $this->serviceForBindingNode($departmentId, $source);
if ($service === '') {
throw new \RuntimeException('Relay binding has no service role to connect to the task.');
}
$this->updateTaskServiceConnection($departmentId, (int)$targetId, $service, $disconnect);
return;
}
if ($sourceType === 'condition' && $targetType === 'question') {
db::getPDO()->prepare('UPDATE department_selfserve_questions SET condition_id = :condition_id WHERE id = :id')->execute([
':condition_id' => $disconnect ? null : (int)$sourceId,
@@ -1184,13 +1245,183 @@ class selfserve_studio_graph
*/
private function normalizeTaskPayload(array $task): array
{
foreach (['services', 'buttons'] as $field) {
if (isset($task[$field]) && is_string($task[$field])) {
$decoded = json_decode((string)$task[$field], true);
$task[$field] = is_array($decoded) ? $decoded : [];
$task['services'] = $this->normalizeServiceList($task['services'] ?? []);
$task['buttons'] = $this->normalizeArrayPayload($task['buttons'] ?? []);
return $task;
}
/**
* @param array<string,mixed> $workspace
* @return array<string,array<int,string>>
*/
private function relayServicesFromWorkspace(array $workspace): array
{
$servicesByRelay = [];
foreach ((array)($workspace['lanes'] ?? []) as $lane) {
if (!is_array($lane)) {
continue;
}
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
if (!is_array($slot)) {
continue;
}
$relayId = trim((string)($slot['relay_id'] ?? ''));
$service = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '');
if ($relayId === '' || $service === '') {
continue;
}
$servicesByRelay[$relayId][$service] = true;
}
}
return $task;
return array_map(static fn(array $services): array => array_keys($services), $servicesByRelay);
}
/**
* @param array<string,mixed> $binding
* @param array<string,array<int,string>> $relayServices
* @return array<int,string>
*/
private function bindingServices(array $binding, string $relayId, array $relayServices): array
{
$services = [];
foreach (['role', 'service', 'slot'] as $field) {
$service = $this->normalizeServiceName($binding[$field] ?? '');
if ($service !== '') {
$services[$service] = true;
}
}
foreach ($this->normalizeServiceList($binding['services'] ?? []) as $service) {
$services[$service] = true;
}
foreach ((array)($relayServices[$relayId] ?? []) as $service) {
$normalized = $this->normalizeServiceName($service);
if ($normalized !== '') {
$services[$normalized] = true;
}
}
return array_keys($services);
}
/**
* @param array<string,mixed> $workspace
* @return array<int,array<string,mixed>>
*/
private function gatewayBindingReferences(array $workspace): array
{
$relayServices = $this->relayServicesFromWorkspace($workspace);
$references = [];
foreach ((array)($workspace['gateways'] ?? []) as $gateway) {
if (!is_array($gateway)) {
continue;
}
$gatewayId = (int)($gateway['id'] ?? 0);
if ($gatewayId <= 0) {
continue;
}
foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$services = $this->bindingServices($binding, $relayId, $relayServices);
if ($services === []) {
continue;
}
$references[] = [
'gateway_id' => $gatewayId,
'relay_id' => $relayId,
'binding_index' => (int)$bindingIndex,
'node_id' => 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex,
'services' => $services,
];
}
}
return $references;
}
private function serviceForBindingNode(int $departmentId, string $nodeId): string
{
$workspace = $this->buildGatewayWorkspace($departmentId);
foreach ($this->gatewayBindingReferences($workspace) as $binding) {
if ((string)$binding['node_id'] === $nodeId) {
return (string)($binding['services'][0] ?? '');
}
}
return '';
}
private function updateTaskServiceConnection(int $departmentId, int $taskId, string $service, bool $disconnect): void
{
if ($taskId <= 0 || $service === '') {
return;
}
$pdo = db::getPDO();
$statement = $pdo->prepare(
'SELECT services FROM department_selfserve_tasks WHERE id = :id AND department IN (0, :department) LIMIT 1'
);
$statement->execute([
':id' => $taskId,
':department' => $departmentId,
]);
$row = $statement->fetch(\PDO::FETCH_ASSOC);
if (!is_array($row)) {
throw new \RuntimeException('Task is not available in the selected department.');
}
$services = $this->normalizeServiceList($row['services'] ?? []);
$serviceSet = array_fill_keys($services, true);
if ($disconnect) {
unset($serviceSet[$service]);
} else {
$serviceSet[$service] = true;
}
$pdo->prepare(
'UPDATE department_selfserve_tasks SET services = :services WHERE id = :id AND department IN (0, :department)'
)->execute([
':services' => $this->jsonArray(array_keys($serviceSet)),
':id' => $taskId,
':department' => $departmentId,
]);
}
/**
* @return array<int,mixed>
*/
private function normalizeArrayPayload(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value)), static fn(string $item): bool => $item !== '');
}
return is_array($value) ? array_values($value) : [];
}
/**
* @return array<int,string>
*/
private function normalizeServiceList(mixed $value): array
{
$services = [];
foreach ($this->normalizeArrayPayload($value) as $entry) {
$service = $this->normalizeServiceName($entry);
if ($service !== '') {
$services[$service] = true;
}
}
return array_keys($services);
}
private function normalizeServiceName(mixed $value): string
{
return strtoupper(trim((string)$value));
}
/**
@@ -1307,11 +1538,14 @@ class selfserve_studio_graph
if ($gatewayId <= 0 || $relayId === '') {
continue;
}
$services = $this->bindingServices($binding, $relayId, []);
$rows[] = [
'id' => $gatewayId . ':' . $relayId . ':' . $index,
'label' => (string)($binding['label'] ?? ('Gateway ' . $gatewayId . ' relay ' . $relayId)),
'gateway_id' => $gatewayId,
'relay_id' => $relayId,
'role' => (string)($binding['role'] ?? ''),
'services' => $services,
];
}
}
@@ -24,7 +24,7 @@ it('serializes questions, conditions, tasks, scopes, and gateways into one graph
['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],
['id' => 30, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 1, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1, 'services' => ['MACHINE']],
],
], [
'lookups' => [
@@ -50,7 +50,7 @@ it('serializes questions, conditions, tasks, scopes, and gateways into one graph
'label' => 'Gateway A',
'status' => 'ONLINE',
'bindings' => [
['relay_id' => 'relay-1', 'label' => 'Machine relay'],
['relay_id' => 'relay-1', 'label' => 'Machine relay', 'role' => 'MACHINE'],
],
],
],
@@ -84,7 +84,20 @@ it('serializes questions, conditions, tasks, scopes, and gateways into one graph
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 {