2442 lines
91 KiB
JavaScript
2442 lines
91 KiB
JavaScript
import { expect, test } from "@playwright/test";
|
|
import { mockApi, primeMockSession } from "./support/network.js";
|
|
|
|
const json = (body, status = 200) => ({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const mirrorTaskAttachments = [
|
|
{
|
|
id: 201,
|
|
content: { other: "mirror-guide.pdf", document: "tasks/mirror-guide.pdf" },
|
|
download_link: "https://cdn.example.test/mirror-guide.pdf",
|
|
},
|
|
{
|
|
id: 202,
|
|
content: { other: "mirror-photo.jpg", image: "tasks/mirror-photo.jpg" },
|
|
download_link: "https://cdn.example.test/mirror-photo.jpg",
|
|
},
|
|
];
|
|
|
|
const graphTaskRaw = (graph, taskId) => graph.nodes.find((node) => node.id === `task:${taskId}`)?.data?.raw || null;
|
|
|
|
const setGraphTaskAttachments = (graph, taskId, attachments) => {
|
|
const raw = graphTaskRaw(graph, taskId);
|
|
if (raw) {
|
|
raw.attachments = attachments;
|
|
}
|
|
};
|
|
|
|
function buildStudioGraph() {
|
|
const lookups = {
|
|
departments: [{ id: 6, label: "Roskilde" }],
|
|
lanes: [
|
|
{
|
|
id: 7,
|
|
department: 6,
|
|
name: "Lane 7",
|
|
label: "Lane 7",
|
|
relay_in_id: "IN-7",
|
|
relay_out_id: "EXIT-7",
|
|
relay_machine_id: "M-7",
|
|
relay_machine_program_picker_id: "",
|
|
relay_machine_cleaner_id: "CLEAN-7",
|
|
machine_type_id: 1001,
|
|
dynamic_image_id: 1,
|
|
selfserve_enabled: true,
|
|
},
|
|
],
|
|
products: [
|
|
{
|
|
id: 2,
|
|
name: "Forvogn",
|
|
label: "Forvogn",
|
|
description: "Forvogn wash",
|
|
price: 649,
|
|
category: "6",
|
|
piktogram: "05",
|
|
is_wash: 1,
|
|
subscription_allowed: 1,
|
|
},
|
|
{
|
|
id: 8,
|
|
name: "Bus",
|
|
label: "Bus",
|
|
description: "Bus wash",
|
|
price: 799,
|
|
category: "8",
|
|
piktogram: "08",
|
|
is_wash: 1,
|
|
subscription_allowed: 1,
|
|
},
|
|
],
|
|
machine_types: [{ id: 1001, label: "Portal" }],
|
|
dynamic_images: [
|
|
{ id: 1, label: "Machine 1" },
|
|
{ id: 2, label: "Machine 2" },
|
|
],
|
|
vehicle_types: [
|
|
{ id: 2, product: 2, product_id: 2, label: "Forvogn", source: "products" },
|
|
{ id: 8, product: 8, product_id: 8, label: "Bus", source: "products" },
|
|
],
|
|
questions: [
|
|
{ id: 11, label: "Are mirrors folded?" },
|
|
{ id: 12, label: "Is the lift lowered?" },
|
|
{ id: 18, label: "Bus roof fixtures checked?" },
|
|
],
|
|
conditions: [
|
|
{ id: 21, label: "Trailer present" },
|
|
{ id: 28, label: "Bus program allowed" },
|
|
],
|
|
rules: [],
|
|
tasks: [
|
|
{ id: 41, label: "Fold mirrors" },
|
|
{ id: 48, label: "Start bus wash" },
|
|
],
|
|
actions: [{ id: 81, label: "Open entry on start" }],
|
|
gateways: [{ id: 701, label: "Roskilde Edge 01", status: "ONLINE" }],
|
|
relays: [
|
|
{ id: "M-7", label: "Machine relay" },
|
|
{ id: "IN-7", label: "Entry relay" },
|
|
],
|
|
bindings: [
|
|
{ id: "701:M-7:0", label: "Machine relay", role: "MACHINE", services: ["MACHINE"] },
|
|
{ id: "701:IN-7:1", label: "Entry relay", role: "ENTRY", services: ["ENTRY"] },
|
|
],
|
|
labels: {
|
|
departments: { 6: "Roskilde" },
|
|
lanes: { 7: "Lane 7" },
|
|
products: { 2: "Forvogn", 8: "Bus" },
|
|
machine_types: { 1001: "Portal" },
|
|
dynamic_images: { 1: "Machine 1", 2: "Machine 2" },
|
|
vehicle_types: { 2: "Forvogn", 8: "Bus" },
|
|
questions: { 11: "Are mirrors folded?", 12: "Is the lift lowered?", 18: "Bus roof fixtures checked?" },
|
|
conditions: { 21: "Trailer present", 28: "Bus program allowed" },
|
|
rules: { 31: "Mirror answer" },
|
|
tasks: { 41: "Fold mirrors", 48: "Start bus wash" },
|
|
actions: { 81: "Open entry on start" },
|
|
gateways: { 701: "Roskilde Edge 01" },
|
|
relays: { "M-7": "Machine relay", "IN-7": "Entry relay" },
|
|
bindings: { "701:M-7:0": "Machine relay", "701:IN-7:1": "Entry relay" },
|
|
},
|
|
};
|
|
|
|
return {
|
|
nodes: [
|
|
{
|
|
id: "vehicle_type:2",
|
|
type: "default",
|
|
position: { x: 0, y: 140 },
|
|
data: {
|
|
kind: "vehicle_type",
|
|
object_id: 2,
|
|
label: "Forvogn",
|
|
subtitle: "Vehicle scope",
|
|
raw: {
|
|
id: 2,
|
|
product: 2,
|
|
product_id: 2,
|
|
name: "Forvogn",
|
|
label: "Forvogn",
|
|
description: "Forvogn wash",
|
|
price: 649,
|
|
category: "6",
|
|
piktogram: "05",
|
|
is_wash: 1,
|
|
subscription_allowed: 1,
|
|
source: "products",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "vehicle_type:8",
|
|
type: "default",
|
|
position: { x: 0, y: 330 },
|
|
data: {
|
|
kind: "vehicle_type",
|
|
object_id: 8,
|
|
label: "Bus",
|
|
subtitle: "Vehicle scope",
|
|
raw: {
|
|
id: 8,
|
|
product: 8,
|
|
product_id: 8,
|
|
name: "Bus",
|
|
label: "Bus",
|
|
description: "Bus wash",
|
|
price: 799,
|
|
category: "8",
|
|
piktogram: "08",
|
|
is_wash: 1,
|
|
subscription_allowed: 1,
|
|
source: "products",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "lane:7",
|
|
type: "default",
|
|
position: { x: 0, y: 520 },
|
|
data: {
|
|
kind: "lane",
|
|
object_id: 7,
|
|
label: "Lane 7",
|
|
subtitle: "Lane scope",
|
|
raw: {
|
|
id: 7,
|
|
name: "Lane 7",
|
|
label: "Lane 7",
|
|
machine_type_id: 1001,
|
|
dynamic_image_id: 1,
|
|
selfserve_enabled: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "condition:21",
|
|
type: "default",
|
|
position: { x: 320, y: 140 },
|
|
data: {
|
|
kind: "condition",
|
|
object_id: 21,
|
|
label: "Trailer present",
|
|
subtitle: "Lane 7 / Forvogn",
|
|
expression_summary: "All: Are mirrors folded? is true",
|
|
raw: {
|
|
id: 21,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
name: "Trailer present",
|
|
description: "",
|
|
expression: {
|
|
type: "group",
|
|
operator: "ALL",
|
|
children: [{ type: "predicate", subject_type: "question", subject_id: 11, operator: "IS_TRUE" }],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "question:11",
|
|
type: "default",
|
|
position: { x: 640, y: 140 },
|
|
data: {
|
|
kind: "question",
|
|
object_id: 11,
|
|
label: "Are mirrors folded?",
|
|
subtitle: "Lane 7 / Forvogn",
|
|
raw: {
|
|
id: 11,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
condition_id: 21,
|
|
question: "Are mirrors folded?",
|
|
description: "",
|
|
order_priority: 1,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "question:12",
|
|
type: "default",
|
|
position: { x: 642, y: 150 },
|
|
data: {
|
|
kind: "question",
|
|
object_id: 12,
|
|
label: "Is the lift lowered?",
|
|
subtitle: "Lane 7 / Forvogn",
|
|
raw: {
|
|
id: 12,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
condition_id: null,
|
|
question: "Is the lift lowered?",
|
|
description: "",
|
|
order_priority: 2,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "task:41",
|
|
type: "default",
|
|
position: { x: 960, y: 140 },
|
|
data: {
|
|
kind: "task",
|
|
object_id: 41,
|
|
label: "Fold mirrors",
|
|
subtitle: "Lane 7 / Forvogn",
|
|
raw: {
|
|
id: 41,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
task: "Fold mirrors",
|
|
description: "",
|
|
gate_type: "QUESTION",
|
|
gate_ref_id: 11,
|
|
order_priority: 1,
|
|
services: ["MACHINE"],
|
|
buttons: ["reset", 1, "start"],
|
|
dynamic_images_vehicle_type: 5,
|
|
attachments: mirrorTaskAttachments,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "action:81",
|
|
type: "default",
|
|
position: { x: 1280, y: 140 },
|
|
data: {
|
|
kind: "action",
|
|
object_id: 81,
|
|
label: "Open entry on start",
|
|
subtitle: "When wash starts / Both / Open lane entrance port",
|
|
event_label: "When wash starts",
|
|
action_label: "Open lane entrance port",
|
|
relay_role: "ENTRY",
|
|
raw: {
|
|
id: 81,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
machine_type_id: 1001,
|
|
condition_id: 21,
|
|
name: "Open entry on start",
|
|
description: "",
|
|
event: "wash_start_command",
|
|
wash_mode: "both",
|
|
operation: "open_lane_entrance_port",
|
|
relay_state: null,
|
|
enabled: true,
|
|
order_priority: 1,
|
|
options: {
|
|
delay_ms: 0,
|
|
toggle_after_seconds: 2,
|
|
retry_count: 0,
|
|
failure_policy: "continue",
|
|
record_event: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "condition:28",
|
|
type: "default",
|
|
position: { x: 1880, y: 900 },
|
|
data: {
|
|
kind: "condition",
|
|
object_id: 28,
|
|
label: "Bus program allowed",
|
|
subtitle: "Lane 7 / Bus",
|
|
expression_summary: "All: Bus roof fixtures checked? is true",
|
|
raw: {
|
|
id: 28,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 8,
|
|
name: "Bus program allowed",
|
|
description: "",
|
|
expression: {
|
|
type: "group",
|
|
operator: "ALL",
|
|
children: [{ type: "predicate", subject_type: "question", subject_id: 18, operator: "IS_TRUE" }],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "question:18",
|
|
type: "default",
|
|
position: { x: 2440, y: 1380 },
|
|
data: {
|
|
kind: "question",
|
|
object_id: 18,
|
|
label: "Bus roof fixtures checked?",
|
|
subtitle: "Lane 7 / Bus",
|
|
raw: {
|
|
id: 18,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 8,
|
|
condition_id: 28,
|
|
question: "Bus roof fixtures checked?",
|
|
description: "",
|
|
order_priority: 3,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "task:48",
|
|
type: "default",
|
|
position: { x: 3600, y: 1840 },
|
|
data: {
|
|
kind: "task",
|
|
object_id: 48,
|
|
label: "Start bus wash",
|
|
subtitle: "Lane 7 / Bus",
|
|
raw: {
|
|
id: 48,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 8,
|
|
task: "Start bus wash",
|
|
description: "",
|
|
gate_type: "QUESTION",
|
|
gate_ref_id: 18,
|
|
order_priority: 2,
|
|
services: ["MACHINE"],
|
|
buttons: [2, "start"],
|
|
dynamic_images_vehicle_type: 8,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "gateway:701",
|
|
type: "default",
|
|
position: { x: 1280, y: 140 },
|
|
data: {
|
|
kind: "edge_gateway",
|
|
object_id: 701,
|
|
label: "Roskilde Edge 01",
|
|
subtitle: "ONLINE",
|
|
raw: { id: 701, label: "Roskilde Edge 01", status: "ONLINE" },
|
|
},
|
|
},
|
|
{
|
|
id: "binding:701:M-7:0",
|
|
type: "default",
|
|
position: { x: 1600, y: 140 },
|
|
data: {
|
|
kind: "relay_binding",
|
|
object_id: "M-7",
|
|
label: "Machine relay",
|
|
subtitle: "MACHINE",
|
|
raw: { relay_id: "M-7", label: "Machine relay", role: "MACHINE", services: ["MACHINE"], channel: 0 },
|
|
},
|
|
},
|
|
{
|
|
id: "binding:701:IN-7:1",
|
|
type: "default",
|
|
position: { x: 1600, y: 300 },
|
|
data: {
|
|
kind: "relay_binding",
|
|
object_id: "IN-7",
|
|
label: "Entry relay",
|
|
subtitle: "ENTRY",
|
|
raw: { relay_id: "IN-7", label: "Entry relay", role: "ENTRY", services: ["ENTRY"], channel: 1 },
|
|
},
|
|
},
|
|
{
|
|
id: "relay:M-7",
|
|
type: "default",
|
|
position: { x: 1920, y: 140 },
|
|
data: {
|
|
kind: "relay",
|
|
object_id: "M-7",
|
|
label: "Machine relay",
|
|
subtitle: "Hardware relay",
|
|
raw: { relay_id: "M-7", name: "Machine relay", status: "ONLINE" },
|
|
},
|
|
},
|
|
{
|
|
id: "relay:IN-7",
|
|
type: "default",
|
|
position: { x: 1920, y: 300 },
|
|
data: {
|
|
kind: "relay",
|
|
object_id: "IN-7",
|
|
label: "Entry relay",
|
|
subtitle: "Hardware relay",
|
|
raw: { relay_id: "IN-7", name: "Entry relay", status: "ONLINE" },
|
|
},
|
|
},
|
|
],
|
|
edges: [
|
|
{
|
|
id: "scope:vehicle_type:2:condition:21",
|
|
source: "vehicle_type:2",
|
|
target: "condition:21",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:lane:7:condition:21",
|
|
source: "lane:7",
|
|
target: "condition:21",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:lane:7:question:11",
|
|
source: "lane:7",
|
|
target: "question:11",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:lane:7:question:12",
|
|
source: "lane:7",
|
|
target: "question:12",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:lane:7:task:41",
|
|
source: "lane:7",
|
|
target: "task:41",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:vehicle_type:2:question:11",
|
|
source: "vehicle_type:2",
|
|
target: "question:11",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:vehicle_type:2:question:12",
|
|
source: "vehicle_type:2",
|
|
target: "question:12",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:vehicle_type:2:task:41",
|
|
source: "vehicle_type:2",
|
|
target: "task:41",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:lane:7:action:81",
|
|
source: "lane:7",
|
|
target: "action:81",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:vehicle_type:2:action:81",
|
|
source: "vehicle_type:2",
|
|
target: "action:81",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "action-gate:21:81",
|
|
source: "condition:21",
|
|
target: "action:81",
|
|
type: "smoothstep",
|
|
label: "allows",
|
|
data: { kind: "action_gate" },
|
|
},
|
|
{
|
|
id: "question-gate:21:11",
|
|
source: "condition:21",
|
|
target: "question:11",
|
|
type: "smoothstep",
|
|
label: "show if",
|
|
data: { kind: "visibility_gate" },
|
|
},
|
|
{
|
|
id: "expression:21:question:11:a1e88f96",
|
|
source: "question:11",
|
|
target: "condition:21",
|
|
type: "smoothstep",
|
|
label: "IS_TRUE",
|
|
data: { kind: "condition_expression", subject_type: "question", subject_id: 11, condition_id: 21 },
|
|
},
|
|
{
|
|
id: "task-gate:question:11:41",
|
|
source: "question:11",
|
|
target: "task:41",
|
|
type: "smoothstep",
|
|
label: "unlocks",
|
|
data: { kind: "task_gate" },
|
|
},
|
|
{
|
|
id: "scope:vehicle_type:8:condition:28",
|
|
source: "vehicle_type:8",
|
|
target: "condition:28",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:lane:7:condition:28",
|
|
source: "lane:7",
|
|
target: "condition:28",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:lane:7:question:18",
|
|
source: "lane:7",
|
|
target: "question:18",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:lane:7:task:48",
|
|
source: "lane:7",
|
|
target: "task:48",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:vehicle_type:8:question:18",
|
|
source: "vehicle_type:8",
|
|
target: "question:18",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "scope:vehicle_type:8:task:48",
|
|
source: "vehicle_type:8",
|
|
target: "task:48",
|
|
type: "smoothstep",
|
|
label: "scope",
|
|
data: { kind: "scope" },
|
|
},
|
|
{
|
|
id: "question-gate:28:18",
|
|
source: "condition:28",
|
|
target: "question:18",
|
|
type: "smoothstep",
|
|
label: "show if",
|
|
data: { kind: "visibility_gate" },
|
|
},
|
|
{
|
|
id: "expression:28:question:18:a1e88f96",
|
|
source: "question:18",
|
|
target: "condition:28",
|
|
type: "smoothstep",
|
|
label: "IS_TRUE",
|
|
data: { kind: "condition_expression", subject_type: "question", subject_id: 18, condition_id: 28 },
|
|
},
|
|
{
|
|
id: "task-gate:question:18:48",
|
|
source: "question:18",
|
|
target: "task:48",
|
|
type: "smoothstep",
|
|
label: "unlocks",
|
|
data: { kind: "task_gate" },
|
|
},
|
|
{
|
|
id: "gateway-binding:701:M-7:0",
|
|
source: "gateway:701",
|
|
target: "binding:701:M-7:0",
|
|
type: "smoothstep",
|
|
label: "binds",
|
|
data: { kind: "gateway_binding" },
|
|
},
|
|
{
|
|
id: "binding-relay:701:M-7:0",
|
|
source: "binding:701:M-7:0",
|
|
target: "relay:M-7",
|
|
type: "smoothstep",
|
|
label: "controls",
|
|
data: { kind: "relay_binding" },
|
|
},
|
|
{
|
|
id: "gateway-binding:701:IN-7:1",
|
|
source: "gateway:701",
|
|
target: "binding:701:IN-7:1",
|
|
type: "smoothstep",
|
|
label: "binds",
|
|
data: { kind: "gateway_binding" },
|
|
},
|
|
{
|
|
id: "binding-relay:701:IN-7:1",
|
|
source: "binding:701:IN-7:1",
|
|
target: "relay:IN-7",
|
|
type: "smoothstep",
|
|
label: "controls",
|
|
data: { kind: "relay_binding" },
|
|
},
|
|
{
|
|
id: "action-relay:81:IN-7:ENTRY:7",
|
|
source: "action:81",
|
|
target: "relay:IN-7",
|
|
type: "smoothstep",
|
|
label: "ENTRY",
|
|
data: { kind: "action_relay" },
|
|
},
|
|
{
|
|
id: "task-service:41:MACHINE:701:M-7:0",
|
|
source: "task:41",
|
|
target: "binding:701:M-7:0",
|
|
type: "smoothstep",
|
|
label: "MACHINE",
|
|
data: { kind: "task_service" },
|
|
},
|
|
{
|
|
id: "task-service:48:MACHINE:701:M-7:0",
|
|
source: "task:48",
|
|
target: "binding:701:M-7:0",
|
|
type: "smoothstep",
|
|
label: "MACHINE",
|
|
data: { kind: "task_service" },
|
|
},
|
|
],
|
|
lookups,
|
|
validation: { valid: true, errors: [], warnings: [], items: [] },
|
|
layout: { nodes: [], viewport: [], runtime_affecting: false },
|
|
versions: [
|
|
{ id: 90, version_number: 3, status: "DRAFT", created_at: "2026-04-28 09:00:00" },
|
|
{ id: 80, version_number: 2, status: "PUBLISHED", published_at: "2026-04-27 09:00:00" },
|
|
],
|
|
active_config: null,
|
|
draft: { id: 90, status: "DRAFT", version_number: 3 },
|
|
simulator_defaults: { department: 6, lane_id: 7, vehicle_type_id: 2, reg: "TEST123", customer_number: null },
|
|
gateway_workspace: {
|
|
gateways: [
|
|
{
|
|
id: 701,
|
|
label: "Roskilde Edge 01",
|
|
status: "ONLINE",
|
|
bindings: [
|
|
{ relay_id: "M-7", label: "Machine relay", role: "MACHINE", services: ["MACHINE"], channel: 0 },
|
|
{ relay_id: "IN-7", label: "Entry relay", role: "ENTRY", services: ["ENTRY"], channel: 1 },
|
|
],
|
|
},
|
|
],
|
|
relays: [
|
|
{ relay_id: "M-7", name: "Machine relay" },
|
|
{ relay_id: "IN-7", name: "Entry relay" },
|
|
],
|
|
lanes: [
|
|
{
|
|
id: 7,
|
|
name: "Lane 7",
|
|
selfserve_enabled: true,
|
|
relay_slots: [
|
|
{ slot: "ENTRY", relay_id: "IN-7", coverage: { covered: true, status: "BOUND" } },
|
|
{ slot: "MACHINE", relay_id: "M-7", coverage: { covered: true, status: "BOUND" } },
|
|
{ slot: "EXIT", relay_id: "EXIT-7", coverage: { covered: false, status: "MISSING" } },
|
|
{ slot: "CLEANER", relay_id: "CLEAN-7", coverage: { covered: false, status: "MISSING" } },
|
|
],
|
|
binding_coverage: { required: 4, bound: 2, missing: 2, state: "MISSING" },
|
|
},
|
|
],
|
|
issues: [],
|
|
actions: [],
|
|
virtual: {
|
|
enabled: true,
|
|
has_virtual_hardware: false,
|
|
gateway_count: 0,
|
|
binding_count: 0,
|
|
config: { gateways: [], relays: [], bindings: [] },
|
|
},
|
|
},
|
|
permissions: {
|
|
can_view: true,
|
|
can_edit: true,
|
|
can_publish: true,
|
|
can_rollback: true,
|
|
can_simulate: true,
|
|
modules_shelly_config: true,
|
|
can_manage_gateways: true,
|
|
can_run_gateway_destructive_actions: true,
|
|
can_run_live_lane_actions: true,
|
|
},
|
|
meta: { department_id: 6, layout_affects_runtime: false, generated_at: "2026-04-28T09:00:00+02:00" },
|
|
};
|
|
}
|
|
|
|
function buildSimulationResponse() {
|
|
return {
|
|
allowed: false,
|
|
dry_run: true,
|
|
mode: "full_dry_run",
|
|
config_source: "draft",
|
|
questions: [{ id: 11, question: "Are mirrors folded?", answer: null }],
|
|
tasks: [
|
|
{
|
|
id: 41,
|
|
task: "Fold mirrors",
|
|
services: ["MACHINE"],
|
|
buttons: ["reset", 1, "start"],
|
|
dynamic_images_vehicle_type: 5,
|
|
attachments: mirrorTaskAttachments,
|
|
},
|
|
],
|
|
allowed_services: [],
|
|
machine_available: false,
|
|
all_visible_questions_answered: false,
|
|
debug: {
|
|
summary: {
|
|
status: "blocked",
|
|
title: "Blocked",
|
|
next_action: "1 visible question(s) are missing answers.",
|
|
primary_blocker: {
|
|
title: "Answer required questions",
|
|
message: "1 visible question(s) are missing answers.",
|
|
node_ids: ["question:11"],
|
|
},
|
|
},
|
|
parameters: {
|
|
department_id: 6,
|
|
lane_id: 7,
|
|
lane: "Lane 7",
|
|
vehicle_type_id: 2,
|
|
vehicle_type: "Forvogn",
|
|
registration: "AB12345",
|
|
config_source: "draft",
|
|
mode: "full_dry_run",
|
|
dry_run: true,
|
|
},
|
|
stages: [
|
|
{
|
|
id: "input",
|
|
title: "Input normalization",
|
|
status: "ok",
|
|
summary: "Registration normalized.",
|
|
node_ids: ["checkpoint:start"],
|
|
edge_ids: [],
|
|
},
|
|
{
|
|
id: "questions",
|
|
title: "Question visibility and answers",
|
|
status: "error",
|
|
summary: "1 visible question missing answers.",
|
|
node_ids: ["question:11"],
|
|
edge_ids: [],
|
|
},
|
|
{
|
|
id: "tasks",
|
|
title: "Task gates and services",
|
|
status: "error",
|
|
summary: "0 tasks active.",
|
|
node_ids: ["task:41"],
|
|
edge_ids: [],
|
|
},
|
|
{
|
|
id: "hardware",
|
|
title: "Gateway and relay readiness",
|
|
status: "error",
|
|
summary: "Lane relay missing.",
|
|
node_ids: ["lane:7"],
|
|
edge_ids: [],
|
|
},
|
|
],
|
|
questions: [
|
|
{
|
|
id: 11,
|
|
node_id: "question:11",
|
|
label: "Are mirrors folded?",
|
|
state: "missing",
|
|
visible: true,
|
|
answer: null,
|
|
answer_source: "override",
|
|
reason: "Visible question is missing an answer.",
|
|
},
|
|
],
|
|
conditions: [
|
|
{
|
|
id: 21,
|
|
node_id: "condition:21",
|
|
label: "Trailer present",
|
|
result: false,
|
|
state: "failed",
|
|
reason: "Predicate did not pass.",
|
|
expression_summary: "All: Are mirrors folded? is true",
|
|
next_fix: "Set Are mirrors folded? so it satisfies is true.",
|
|
},
|
|
],
|
|
rules: [],
|
|
tasks: [
|
|
{
|
|
id: 41,
|
|
node_id: "task:41",
|
|
label: "Fold mirrors",
|
|
active: false,
|
|
state: "blocked",
|
|
gate_type: "QUESTION",
|
|
gate_ref_label: "Are mirrors folded?",
|
|
services: ["MACHINE"],
|
|
buttons: ["reset", 1, "start"],
|
|
dynamic_images_vehicle_type: 5,
|
|
attachments: mirrorTaskAttachments,
|
|
reason: "Task gate did not pass.",
|
|
},
|
|
],
|
|
hardware: {
|
|
machine_relay_configured: false,
|
|
allowed_services: [],
|
|
missing_service_bindings: [],
|
|
virtual: { has_virtual_hardware: true, gateway_count: 1, binding_count: 3 },
|
|
signal_timeline: [
|
|
{
|
|
sequence: 1,
|
|
runtime_stage: "eligibility_sync",
|
|
signal_type: "session_event",
|
|
relay_role: "SESSION",
|
|
source: "none",
|
|
predicted_status: "sent",
|
|
payload: { event: "SESSION_SYNCED", allowed: false },
|
|
},
|
|
{
|
|
sequence: 2,
|
|
runtime_stage: "eligibility_sync",
|
|
signal_type: "relay_switch",
|
|
relay_role: "MACHINE",
|
|
relay_id: "M-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
target_binding: "binding:virtual-main:M-7:0",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { id: "M-7", channel: 0, on: true },
|
|
skip_block_reason: "Virtual studio hardware only; live dispatch would require a real gateway binding.",
|
|
},
|
|
{
|
|
sequence: 3,
|
|
runtime_stage: "machine_start_signal",
|
|
signal_type: "shelly_event",
|
|
relay_role: "MACHINE",
|
|
relay_id: "M-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
target_binding: "binding:virtual-main:M-7:0",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { event: "input.toggle_on", alternate_event: "switch.on", bill_machine_wash: true },
|
|
skip_block_reason: "Virtual studio hardware only; live dispatch would require a real gateway binding.",
|
|
},
|
|
{
|
|
sequence: 4,
|
|
runtime_stage: "machine_start",
|
|
signal_type: "relay_switch",
|
|
relay_role: "CLEANER",
|
|
relay_id: "CLEAN-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { id: "CLEAN-7", channel: 0, on: true },
|
|
},
|
|
{
|
|
sequence: 5,
|
|
runtime_stage: "stop",
|
|
signal_type: "relay_pulse",
|
|
relay_role: "EXIT",
|
|
relay_id: "EXIT-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { id: "EXIT-7", on: true, toggle_after: 1 },
|
|
},
|
|
{
|
|
sequence: 6,
|
|
runtime_stage: "stop",
|
|
signal_type: "relay_switch",
|
|
relay_role: "CLEANER",
|
|
relay_id: "CLEAN-7",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { id: "CLEAN-7", channel: 0, on: false },
|
|
},
|
|
{
|
|
sequence: 7,
|
|
runtime_stage: "stop",
|
|
signal_type: "relay_switch",
|
|
relay_role: "MACHINE",
|
|
relay_id: "M-7",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { id: "M-7", channel: 0, on: false },
|
|
},
|
|
{
|
|
sequence: 8,
|
|
runtime_stage: "session_completion",
|
|
signal_type: "session_event",
|
|
relay_role: "SESSION",
|
|
source: "none",
|
|
predicted_status: "skipped",
|
|
payload: { event: "SESSION_COMPLETED", reset_lane_state: true },
|
|
},
|
|
],
|
|
summary: "Lane relay missing.",
|
|
dry_run_operations: [
|
|
{
|
|
operation: "machine_relay_enable",
|
|
message: "Dry run predicts no machine relay action because eligibility is blocked.",
|
|
},
|
|
],
|
|
},
|
|
signal_timeline: [
|
|
{
|
|
sequence: 1,
|
|
runtime_stage: "eligibility_sync",
|
|
signal_type: "session_event",
|
|
relay_role: "SESSION",
|
|
source: "none",
|
|
predicted_status: "sent",
|
|
payload: { event: "SESSION_SYNCED", allowed: false },
|
|
},
|
|
{
|
|
sequence: 2,
|
|
runtime_stage: "start",
|
|
signal_type: "studio_action_relay_pulse",
|
|
relay_role: "ENTRY",
|
|
relay_id: "IN-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
target_binding: "binding:virtual-main:IN-7:3",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: {
|
|
action_id: 81,
|
|
action: "Open entry on start",
|
|
event: "wash_start_command",
|
|
operation: "open_lane_entrance_port",
|
|
id: "IN-7",
|
|
on: true,
|
|
toggle_after: 2,
|
|
},
|
|
skip_block_reason: "Virtual studio hardware only; live dispatch would require a real gateway binding.",
|
|
},
|
|
{
|
|
sequence: 3,
|
|
runtime_stage: "eligibility_sync",
|
|
signal_type: "relay_switch",
|
|
relay_role: "MACHINE",
|
|
relay_id: "M-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
target_binding: "binding:virtual-main:M-7:0",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { id: "M-7", channel: 0, on: true },
|
|
skip_block_reason: "Virtual studio hardware only; live dispatch would require a real gateway binding.",
|
|
},
|
|
{
|
|
sequence: 4,
|
|
runtime_stage: "machine_start_signal",
|
|
signal_type: "shelly_event",
|
|
relay_role: "MACHINE",
|
|
relay_id: "M-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
target_binding: "binding:virtual-main:M-7:0",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { event: "input.toggle_on", alternate_event: "switch.on", bill_machine_wash: true },
|
|
skip_block_reason: "Virtual studio hardware only; live dispatch would require a real gateway binding.",
|
|
},
|
|
{
|
|
sequence: 5,
|
|
runtime_stage: "machine_start",
|
|
signal_type: "relay_switch",
|
|
relay_role: "CLEANER",
|
|
relay_id: "CLEAN-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { id: "CLEAN-7", channel: 0, on: true },
|
|
},
|
|
{
|
|
sequence: 6,
|
|
runtime_stage: "stop",
|
|
signal_type: "relay_pulse",
|
|
relay_role: "EXIT",
|
|
relay_id: "EXIT-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { id: "EXIT-7", on: true, toggle_after: 1 },
|
|
},
|
|
],
|
|
graph_annotations: {
|
|
nodes: {
|
|
"question:11": { state: "warning", label: "Missing answer" },
|
|
"task:41": { state: "blocked", label: "Gate blocked" },
|
|
"action:81": { state: "active", label: "Action would run for this simulator event." },
|
|
"lane:7": { state: "error", label: "Lane relay missing" },
|
|
},
|
|
edges: {
|
|
"task-gate:question:11:41": { state: "blocked", label: "unlocks" },
|
|
},
|
|
},
|
|
recommendations: [
|
|
{
|
|
severity: "error",
|
|
title: "Answer required questions",
|
|
message: "1 visible question(s) are missing answers.",
|
|
node_ids: ["question:11"],
|
|
},
|
|
{
|
|
severity: "error",
|
|
title: "Configure lane machine relay",
|
|
message: "The selected lane has no machine relay configured.",
|
|
node_ids: ["lane:7"],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildPathOutcomesResponse(request = {}) {
|
|
const vehicleTypeId = request.vehicle_type_id ?? null;
|
|
const vehicleType = vehicleTypeId === 8 ? "Bus" : vehicleTypeId === 2 ? "Forvogn" : "All current vehicle types";
|
|
return {
|
|
scope: {
|
|
department_id: 6,
|
|
department: "Roskilde",
|
|
lane_id: request.lane_id ?? 7,
|
|
lane: "Lane 7",
|
|
vehicle_type_id: vehicleTypeId,
|
|
vehicle_type: vehicleType,
|
|
vehicle_type_count: vehicleTypeId ? 1 : 2,
|
|
registration: "TEST123",
|
|
config_source: request.config_source || "draft",
|
|
hardware_mode: request.hardware_mode || "studio",
|
|
max_states: request.max_states ?? null,
|
|
},
|
|
summary: {
|
|
state_count: 5,
|
|
terminal_path_count: 3,
|
|
outcome_count: 2,
|
|
question_count: 2,
|
|
question_ids: [11, 12],
|
|
max_states: request.max_states ?? null,
|
|
path_sample_count: 2,
|
|
},
|
|
outcomes: [
|
|
{
|
|
id: "outcome-1",
|
|
summary: "Allowed / MACHINE / 1 task / 2 signals",
|
|
path_count: 1,
|
|
allowed: true,
|
|
services: ["MACHINE"],
|
|
tasks: [
|
|
{
|
|
id: 41,
|
|
node_id: "task:41",
|
|
label: "Fold mirrors",
|
|
services: ["MACHINE"],
|
|
buttons: ["reset", 1, "start"],
|
|
order_priority: 1,
|
|
},
|
|
],
|
|
signals: [
|
|
{
|
|
sequence: 1,
|
|
runtime_stage: "eligibility_sync",
|
|
signal_type: "session_event",
|
|
relay_role: "SESSION",
|
|
source: "none",
|
|
virtual: false,
|
|
predicted_status: "sent",
|
|
payload: { allowed: true, allowed_services: ["MACHINE"] },
|
|
},
|
|
{
|
|
sequence: 2,
|
|
runtime_stage: "machine_start_signal",
|
|
signal_type: "shelly_event",
|
|
relay_role: "MACHINE",
|
|
relay_id: "M-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
target_binding: "binding:virtual-main:M-7:0",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { event: "input.toggle_on", bill_machine_wash: true },
|
|
},
|
|
],
|
|
sample_chains: [
|
|
{
|
|
scope: { lane: "Lane 7", vehicle_type: vehicleType },
|
|
answers: [
|
|
{
|
|
question_id: 11,
|
|
question: "Are mirrors folded?",
|
|
node_id: "question:11",
|
|
answer: true,
|
|
answer_label: "Yes",
|
|
},
|
|
{
|
|
question_id: 12,
|
|
question: "Is the lift lowered?",
|
|
node_id: "question:12",
|
|
answer: true,
|
|
answer_label: "Yes",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
scopes: [{ lane: "Lane 7", vehicle_type: vehicleType }],
|
|
node_ids: ["question:11", "question:12", "task:41", "binding:virtual-main:M-7:0"],
|
|
},
|
|
{
|
|
id: "outcome-2",
|
|
summary: "Blocked / No services / 0 tasks / 1 signal",
|
|
path_count: 2,
|
|
allowed: false,
|
|
services: [],
|
|
tasks: [],
|
|
signals: [
|
|
{
|
|
sequence: 1,
|
|
runtime_stage: "eligibility_sync",
|
|
signal_type: "session_event",
|
|
relay_role: "SESSION",
|
|
source: "none",
|
|
virtual: false,
|
|
predicted_status: "skipped",
|
|
payload: { allowed: false, allowed_services: [] },
|
|
},
|
|
],
|
|
sample_chains: [
|
|
{
|
|
scope: { lane: "Lane 7", vehicle_type: vehicleType },
|
|
answers: [
|
|
{
|
|
question_id: 11,
|
|
question: "Are mirrors folded?",
|
|
node_id: "question:11",
|
|
answer: false,
|
|
answer_label: "No",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
scopes: [{ lane: "Lane 7", vehicle_type: vehicleType }],
|
|
node_ids: ["question:11"],
|
|
},
|
|
],
|
|
paths: [
|
|
{
|
|
id: "path-1",
|
|
result: "Allowed",
|
|
summary: "Allowed / MACHINE / 1 task / 2 signals",
|
|
allowed: true,
|
|
services: ["MACHINE"],
|
|
tasks: [
|
|
{
|
|
id: 41,
|
|
node_id: "task:41",
|
|
label: "Fold mirrors",
|
|
services: ["MACHINE"],
|
|
buttons: ["reset", 1, "start"],
|
|
order_priority: 1,
|
|
},
|
|
],
|
|
signals: [
|
|
{
|
|
sequence: 1,
|
|
runtime_stage: "eligibility_sync",
|
|
signal_type: "session_event",
|
|
relay_role: "SESSION",
|
|
source: "none",
|
|
virtual: false,
|
|
predicted_status: "sent",
|
|
payload: { allowed: true, allowed_services: ["MACHINE"] },
|
|
},
|
|
{
|
|
sequence: 2,
|
|
runtime_stage: "machine_start_signal",
|
|
signal_type: "shelly_event",
|
|
relay_role: "MACHINE",
|
|
relay_id: "M-7",
|
|
target_gateway_label: "Virtual Studio Gateway",
|
|
target_binding: "binding:virtual-main:M-7:0",
|
|
source: "virtual",
|
|
virtual: true,
|
|
predicted_status: "virtual_only",
|
|
payload: { event: "input.toggle_on", bill_machine_wash: true },
|
|
},
|
|
],
|
|
task_count: 1,
|
|
signal_count: 2,
|
|
answers: [
|
|
{
|
|
question_id: 11,
|
|
question: "Are mirrors folded?",
|
|
node_id: "question:11",
|
|
answer: true,
|
|
answer_label: "Yes",
|
|
},
|
|
{
|
|
question_id: 12,
|
|
question: "Is the lift lowered?",
|
|
node_id: "question:12",
|
|
answer: true,
|
|
answer_label: "Yes",
|
|
},
|
|
],
|
|
scope: { lane: "Lane 7", vehicle_type: vehicleType },
|
|
node_ids: ["question:11", "question:12", "task:41", "binding:virtual-main:M-7:0"],
|
|
},
|
|
{
|
|
id: "path-2",
|
|
result: "Blocked",
|
|
summary: "Blocked / No services / 0 tasks / 1 signal",
|
|
allowed: false,
|
|
services: [],
|
|
tasks: [],
|
|
signals: [
|
|
{
|
|
sequence: 1,
|
|
runtime_stage: "eligibility_sync",
|
|
signal_type: "session_event",
|
|
relay_role: "SESSION",
|
|
source: "none",
|
|
virtual: false,
|
|
predicted_status: "skipped",
|
|
payload: { allowed: false, allowed_services: [] },
|
|
},
|
|
],
|
|
task_count: 0,
|
|
signal_count: 1,
|
|
answers: [
|
|
{
|
|
question_id: 11,
|
|
question: "Are mirrors folded?",
|
|
node_id: "question:11",
|
|
answer: false,
|
|
answer_label: "No",
|
|
},
|
|
],
|
|
scope: { lane: "Lane 7", vehicle_type: vehicleType },
|
|
node_ids: ["question:11"],
|
|
},
|
|
],
|
|
warnings: [],
|
|
truncated: false,
|
|
progress: {
|
|
complete: true,
|
|
percent: 100,
|
|
state_count: 5,
|
|
pending_state_count: 0,
|
|
terminal_path_count: 3,
|
|
},
|
|
};
|
|
}
|
|
|
|
function applyVirtualHardwareToGraph(graph) {
|
|
if (!graph.gateway_workspace.virtual?.has_virtual_hardware) {
|
|
graph.gateway_workspace.virtual = {
|
|
enabled: true,
|
|
has_virtual_hardware: true,
|
|
gateway_count: 1,
|
|
binding_count: 3,
|
|
virtual_only_relay_ids: ["M-7", "EXIT-7", "CLEAN-7"],
|
|
config: {
|
|
gateways: [{ key: "virtual-main", label: "Virtual Studio Gateway", status: "VIRTUAL" }],
|
|
relays: [
|
|
{ relay_id: "M-7", name: "Lane 7 MACHINE", virtual: true },
|
|
{ relay_id: "EXIT-7", name: "Lane 7 EXIT", virtual: true },
|
|
{ relay_id: "CLEAN-7", name: "Lane 7 CLEANER", virtual: true },
|
|
],
|
|
bindings: [
|
|
{
|
|
id: "virtual-main:M-7:MACHINE",
|
|
gateway_key: "virtual-main",
|
|
relay_id: "M-7",
|
|
role: "MACHINE",
|
|
services: ["MACHINE"],
|
|
label: "Lane 7 MACHINE",
|
|
virtual: true,
|
|
},
|
|
{
|
|
id: "virtual-main:EXIT-7:EXIT",
|
|
gateway_key: "virtual-main",
|
|
relay_id: "EXIT-7",
|
|
role: "EXIT",
|
|
services: ["EXIT"],
|
|
label: "Lane 7 EXIT",
|
|
virtual: true,
|
|
},
|
|
{
|
|
id: "virtual-main:CLEAN-7:CLEANER",
|
|
gateway_key: "virtual-main",
|
|
relay_id: "CLEAN-7",
|
|
role: "CLEANER",
|
|
services: ["CLEANER"],
|
|
label: "Lane 7 CLEANER",
|
|
virtual: true,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
graph.gateway_workspace.gateways.push({
|
|
id: "virtual-main",
|
|
key: "virtual-main",
|
|
label: "Virtual Studio Gateway",
|
|
status: "VIRTUAL",
|
|
virtual: true,
|
|
studio_only: true,
|
|
bindings: [
|
|
{
|
|
node_id: "binding:virtual-main:M-7:0",
|
|
gateway_key: "virtual-main",
|
|
relay_id: "M-7",
|
|
label: "Lane 7 MACHINE",
|
|
role: "MACHINE",
|
|
services: ["MACHINE"],
|
|
channel: 0,
|
|
virtual: true,
|
|
},
|
|
{
|
|
node_id: "binding:virtual-main:EXIT-7:1",
|
|
gateway_key: "virtual-main",
|
|
relay_id: "EXIT-7",
|
|
label: "Lane 7 EXIT",
|
|
role: "EXIT",
|
|
services: ["EXIT"],
|
|
channel: 0,
|
|
virtual: true,
|
|
},
|
|
{
|
|
node_id: "binding:virtual-main:CLEAN-7:2",
|
|
gateway_key: "virtual-main",
|
|
relay_id: "CLEAN-7",
|
|
label: "Lane 7 CLEANER",
|
|
role: "CLEANER",
|
|
services: ["CLEANER"],
|
|
channel: 0,
|
|
virtual: true,
|
|
},
|
|
],
|
|
});
|
|
graph.gateway_workspace.relays.push(
|
|
{ relay_id: "EXIT-7", name: "Lane 7 EXIT", virtual: true },
|
|
{ relay_id: "CLEAN-7", name: "Lane 7 CLEANER", virtual: true }
|
|
);
|
|
graph.nodes.push(
|
|
{
|
|
id: "gateway:virtual-main",
|
|
type: "default",
|
|
position: { x: 1280, y: 360 },
|
|
data: {
|
|
kind: "edge_gateway",
|
|
object_id: "virtual-main",
|
|
label: "Virtual Studio Gateway",
|
|
subtitle: "VIRTUAL",
|
|
raw: { id: "virtual-main", label: "Virtual Studio Gateway", status: "VIRTUAL", virtual: true },
|
|
},
|
|
},
|
|
{
|
|
id: "binding:virtual-main:M-7:0",
|
|
type: "default",
|
|
position: { x: 1600, y: 360 },
|
|
data: {
|
|
kind: "relay_binding",
|
|
object_id: "M-7",
|
|
label: "Lane 7 MACHINE",
|
|
subtitle: "MACHINE",
|
|
raw: { relay_id: "M-7", label: "Lane 7 MACHINE", role: "MACHINE", services: ["MACHINE"], virtual: true },
|
|
},
|
|
}
|
|
);
|
|
graph.edges.push({
|
|
id: "task-service:41:MACHINE:virtual-main:M-7:0",
|
|
source: "task:41",
|
|
target: "binding:virtual-main:M-7:0",
|
|
type: "smoothstep",
|
|
label: "MACHINE",
|
|
data: { kind: "task_service" },
|
|
});
|
|
graph.lookups.gateways.push({
|
|
id: "virtual-main",
|
|
label: "Virtual Studio Gateway",
|
|
status: "VIRTUAL",
|
|
virtual: true,
|
|
});
|
|
graph.lookups.bindings.push({
|
|
id: "virtual-main:M-7:0",
|
|
label: "Lane 7 MACHINE",
|
|
gateway_id: "virtual-main",
|
|
relay_id: "M-7",
|
|
role: "MACHINE",
|
|
services: ["MACHINE"],
|
|
virtual: true,
|
|
});
|
|
graph.validation = {
|
|
valid: true,
|
|
errors: [],
|
|
warnings: [
|
|
"Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.",
|
|
],
|
|
items: [
|
|
{
|
|
severity: "warning",
|
|
message:
|
|
"Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
async function installStudioRoutes(page, graph, captured) {
|
|
const dynamicImageSvg = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
|
<rect width="24" height="24" fill="#2563eb"/>
|
|
</svg>
|
|
`;
|
|
const customerResults = [
|
|
{ id: 601001, customerNumber: 601001, name: "Acme Logistics", city: "Roskilde" },
|
|
{ id: 602002, customerNumber: 602002, name: "Nordic Wash Transport", city: "Koge" },
|
|
];
|
|
|
|
await page.route(/\/customers(?:\?.*)?$/i, async (route, request) => {
|
|
const url = new URL(request.url());
|
|
const search = String(url.searchParams.get("search") || "").toLowerCase();
|
|
captured.customerSearches ||= [];
|
|
captured.customerSearches.push(url.searchParams.get("search") || "");
|
|
const filteredCustomers = customerResults.filter(
|
|
(customer) => String(customer.customerNumber).includes(search) || customer.name.toLowerCase().includes(search)
|
|
);
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: filteredCustomers,
|
|
meta: {
|
|
pagination: {
|
|
page: 1,
|
|
limit: 10,
|
|
total: filteredCustomers.length,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/graph(?:\?.*)?$/i, async (route, request) => {
|
|
if (request.method() === "GET") {
|
|
await route.fulfill(json({ data: graph }));
|
|
return;
|
|
}
|
|
|
|
const body = request.postDataJSON?.() || {};
|
|
captured.graphSaves.push(body);
|
|
for (const operation of body.operations || []) {
|
|
if (operation.action === "create" && operation.entity === "question") {
|
|
graph.nodes.push({
|
|
id: "question:99",
|
|
type: "default",
|
|
position: { x: 700, y: 320 },
|
|
data: {
|
|
kind: "question",
|
|
object_id: 99,
|
|
label: operation.data.question,
|
|
subtitle: "Lane 7 / Forvogn",
|
|
raw: { id: 99, ...operation.data },
|
|
},
|
|
});
|
|
}
|
|
if (operation.action === "create" && operation.entity === "action") {
|
|
graph.nodes.push({
|
|
id: "action:99",
|
|
type: "default",
|
|
position: { x: 1280, y: 320 },
|
|
data: {
|
|
kind: "action",
|
|
object_id: 99,
|
|
label: operation.data.name,
|
|
subtitle: "When wash starts / Both / Open lane entrance port",
|
|
event_label: "When wash starts",
|
|
action_label: "Open lane entrance port",
|
|
relay_role: "ENTRY",
|
|
raw: { id: 99, ...operation.data },
|
|
},
|
|
});
|
|
}
|
|
if (operation.action === "update" && operation.entity === "question" && Number(operation.id) === 11) {
|
|
const node = graph.nodes.find((entry) => entry.id === "question:11");
|
|
node.data.raw = { ...node.data.raw, ...operation.data };
|
|
node.data.label = operation.data.question;
|
|
}
|
|
if (operation.action === "update" && operation.entity === "condition") {
|
|
const node = graph.nodes.find((entry) => entry.id === `condition:${operation.id}`);
|
|
node.data.raw = { ...node.data.raw, ...operation.data };
|
|
node.data.label = operation.data.name || node.data.label;
|
|
node.data.expression_summary = "All: Are mirrors folded? is true";
|
|
}
|
|
if (operation.action === "update" && operation.entity === "task" && Number(operation.id) === 41) {
|
|
const node = graph.nodes.find((entry) => entry.id === "task:41");
|
|
node.data.raw = { ...node.data.raw, ...operation.data };
|
|
node.data.label = operation.data.task || node.data.label;
|
|
}
|
|
if (operation.action === "update" && operation.entity === "action" && Number(operation.id) === 81) {
|
|
const node = graph.nodes.find((entry) => entry.id === "action:81");
|
|
node.data.raw = { ...node.data.raw, ...operation.data };
|
|
node.data.label = operation.data.name || node.data.label;
|
|
node.data.action_label =
|
|
operation.data.operation === "set_cleaner_relay" ? "Turn OFF CLEANER" : "Open lane entrance port";
|
|
node.data.relay_role = operation.data.operation === "set_cleaner_relay" ? "CLEANER" : "ENTRY";
|
|
}
|
|
if (operation.action === "update" && operation.entity === "lane") {
|
|
const lane = graph.lookups.lanes.find((entry) => Number(entry.id) === Number(operation.id));
|
|
if (lane) {
|
|
Object.assign(lane, operation.data);
|
|
lane.label = operation.data.name || lane.label;
|
|
graph.lookups.labels.lanes[String(operation.id)] = lane.label;
|
|
}
|
|
}
|
|
if (operation.action === "create" && operation.entity === "lane") {
|
|
const newId = Math.max(0, ...graph.lookups.lanes.map((entry) => Number(entry.id) || 0)) + 1;
|
|
const lane = {
|
|
id: newId,
|
|
department: 6,
|
|
name: operation.data.name,
|
|
label: operation.data.name,
|
|
machine_type_id: operation.data.machine_type_id ?? null,
|
|
dynamic_image_id: operation.data.dynamic_image_id ?? null,
|
|
selfserve_enabled: operation.data.selfserve_enabled ?? true,
|
|
};
|
|
graph.lookups.lanes.push(lane);
|
|
graph.lookups.labels.lanes[String(newId)] = lane.label;
|
|
}
|
|
}
|
|
await route.fulfill(json({ data: graph }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/tasks\/attachments\/upload(?:\?.*)?$/i, async (route, request) => {
|
|
const body = request.postDataJSON?.() || {};
|
|
captured.taskAttachmentUploads ||= [];
|
|
captured.taskAttachmentUploads.push(body);
|
|
const taskId = Number(body.task_id);
|
|
const attachments = [...(graphTaskRaw(graph, taskId)?.attachments || [])];
|
|
const attachment = {
|
|
id: 900 + attachments.length,
|
|
object_type: "department_selfserve_tasks",
|
|
object_id: taskId,
|
|
content: { other: body.file_name, document: `tasks/${body.file_name}` },
|
|
download_link: `https://cdn.example.test/${body.file_name}`,
|
|
};
|
|
setGraphTaskAttachments(graph, taskId, [...attachments, attachment]);
|
|
await route.fulfill(json({ data: attachment }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/tasks\/attachments\/download(?:\?.*)?$/i, async (route, request) => {
|
|
const url = new URL(request.url());
|
|
captured.taskAttachmentDownloads ||= [];
|
|
captured.taskAttachmentDownloads.push({
|
|
task_id: Number(url.searchParams.get("task_id")),
|
|
attachment_id: Number(url.searchParams.get("attachment_id")),
|
|
});
|
|
await route.fulfill(json({ data: { download_link: "https://cdn.example.test/generated-task-file.pdf" } }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/tasks\/attachments(?:\?.*)?$/i, async (route, request) => {
|
|
const body = request.postDataJSON?.() || {};
|
|
const url = new URL(request.url());
|
|
const taskId = Number(body.task_id || url.searchParams.get("id"));
|
|
if (request.method() === "DELETE") {
|
|
captured.taskAttachmentDeletes ||= [];
|
|
captured.taskAttachmentDeletes.push(body);
|
|
const attachmentId = Number(body.attachment_id);
|
|
setGraphTaskAttachments(
|
|
graph,
|
|
taskId,
|
|
(graphTaskRaw(graph, taskId)?.attachments || []).filter((attachment) => Number(attachment.id) !== attachmentId)
|
|
);
|
|
await route.fulfill(json({ data: { message: "Attachment deleted successfully" } }));
|
|
return;
|
|
}
|
|
await route.fulfill(json({ data: graphTaskRaw(graph, taskId)?.attachments || [] }));
|
|
});
|
|
|
|
await page.route(/\/products(?:\?.*)?$/i, async (route, request) => {
|
|
if (request.method() !== "PUT") {
|
|
await route.fulfill(json({ data: graph.lookups.products }));
|
|
return;
|
|
}
|
|
const body = request.postDataJSON?.() || {};
|
|
captured.productUpdates ||= [];
|
|
captured.productUpdates.push(body);
|
|
const product = graph.lookups.products.find((entry) => Number(entry.id) === Number(body.id));
|
|
if (product) {
|
|
Object.assign(product, body, {
|
|
label: body.name || product.label,
|
|
name: body.name || product.name,
|
|
});
|
|
graph.lookups.labels.products[String(body.id)] = product.label;
|
|
graph.lookups.labels.vehicle_types[String(body.id)] = product.label;
|
|
}
|
|
const vehicleType = graph.lookups.vehicle_types.find(
|
|
(entry) => Number(entry.product || entry.id) === Number(body.id)
|
|
);
|
|
if (vehicleType) {
|
|
Object.assign(vehicleType, body, {
|
|
product: body.id,
|
|
product_id: body.id,
|
|
label: body.name || vehicleType.label,
|
|
});
|
|
}
|
|
const node = graph.nodes.find((entry) => entry.id === `vehicle_type:${body.id}`);
|
|
if (node) {
|
|
node.data.label = body.name || node.data.label;
|
|
node.data.raw = {
|
|
...node.data.raw,
|
|
...body,
|
|
product: body.id,
|
|
product_id: body.id,
|
|
label: body.name || node.data.raw?.label,
|
|
};
|
|
}
|
|
await route.fulfill(json({ data: product || body }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/layout(?:\?.*)?$/i, async (route, request) => {
|
|
captured.layouts.push(request.postDataJSON?.() || {});
|
|
await route.fulfill(json({ data: { nodes: [], viewport: [], runtime_affecting: false } }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/validate(?:\?.*)?$/i, async (route) => {
|
|
await route.fulfill(json({ data: graph.validation }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/virtual-hardware(?:\?.*)?$/i, async (route, request) => {
|
|
const body = request.postDataJSON?.() || {};
|
|
captured.virtualHardware.push(body);
|
|
if (
|
|
body.operation === "generate_from_lanes" ||
|
|
body.operation === "upsert_binding" ||
|
|
body.operation === "upsert_gateway"
|
|
) {
|
|
applyVirtualHardwareToGraph(graph);
|
|
}
|
|
if (body.operation === "reset") {
|
|
graph.gateway_workspace.virtual = {
|
|
enabled: true,
|
|
has_virtual_hardware: false,
|
|
gateway_count: 0,
|
|
binding_count: 0,
|
|
config: { gateways: [], relays: [], bindings: [] },
|
|
};
|
|
}
|
|
await route.fulfill(json({ data: graph }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/simulate(?:\?.*)?$/i, async (route, request) => {
|
|
captured.simulations.push(request.postDataJSON?.() || {});
|
|
await route.fulfill(json({ data: buildSimulationResponse() }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/path-outcomes\/stream(?:\?.*)?$/i, async (route, request) => {
|
|
const body = request.postDataJSON?.() || {};
|
|
captured.pathOutcomes.push(body);
|
|
const complete = buildPathOutcomesResponse(body);
|
|
const progress = {
|
|
...complete,
|
|
summary: {
|
|
...complete.summary,
|
|
state_count: 3,
|
|
terminal_path_count: 1,
|
|
outcome_count: 1,
|
|
path_sample_count: 1,
|
|
},
|
|
outcomes: complete.outcomes.slice(0, 1),
|
|
paths: complete.paths.slice(0, 1),
|
|
progress: {
|
|
complete: false,
|
|
percent: 55,
|
|
state_count: 3,
|
|
pending_state_count: 2,
|
|
terminal_path_count: 1,
|
|
},
|
|
};
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/x-ndjson",
|
|
body: `${JSON.stringify({ type: "progress", data: progress })}\n<br />\n${JSON.stringify({
|
|
type: "complete",
|
|
data: complete,
|
|
})}\n`,
|
|
});
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/path-outcomes(?:\?.*)?$/i, async (route, request) => {
|
|
const body = request.postDataJSON?.() || {};
|
|
captured.pathOutcomes.push(body);
|
|
await route.fulfill(json({ data: buildPathOutcomesResponse(body) }));
|
|
});
|
|
|
|
await page.route(/\/department\/lanes\/dynamic-image(?:\?.*)?$/i, async (route, request) => {
|
|
captured.dynamicImages.push(new URL(request.url()));
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "image/svg+xml",
|
|
body: dynamicImageSvg,
|
|
});
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/publish(?:\?.*)?$/i, async (route) => {
|
|
captured.published += 1;
|
|
await route.fulfill(
|
|
json({ data: { id: 91, status: "PUBLISHED", version_number: 4, warnings: graph.validation.warnings || [] } })
|
|
);
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/rollback(?:\?.*)?$/i, async (route, request) => {
|
|
captured.rollbacks.push(request.postDataJSON?.() || {});
|
|
await route.fulfill(json({ data: { id: 92, status: "PUBLISHED", version_number: 5 } }));
|
|
});
|
|
|
|
await page.route(/\/department\/selfserve\/studio\/gateway-action(?:\?.*)?$/i, async (route, request) => {
|
|
captured.gatewayActions.push(request.postDataJSON?.() || {});
|
|
await route.fulfill(json({ data: { action: "discovery", operation: { id: 1, status: "PENDING" } } }));
|
|
});
|
|
}
|
|
|
|
test.describe("All-in-one self-serve studio", () => {
|
|
test.describe.configure({ mode: "serial", timeout: 120_000 });
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: ["superuser", "user"],
|
|
selfServe: true,
|
|
});
|
|
await primeMockSession(page, { token: "self-serve-studio-token" });
|
|
});
|
|
|
|
test("loads the replacement graph workspace and edits a question from the inspector", async ({ page }, testInfo) => {
|
|
const graph = buildStudioGraph();
|
|
const captured = {
|
|
graphSaves: [],
|
|
layouts: [],
|
|
simulations: [],
|
|
pathOutcomes: [],
|
|
dynamicImages: [],
|
|
gatewayActions: [],
|
|
virtualHardware: [],
|
|
productUpdates: [],
|
|
rollbacks: [],
|
|
published: 0,
|
|
};
|
|
await installStudioRoutes(page, graph, captured);
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(Element.prototype, "requestFullscreen", {
|
|
configurable: true,
|
|
value: () => Promise.resolve(),
|
|
});
|
|
Object.defineProperty(Document.prototype, "exitFullscreen", {
|
|
configurable: true,
|
|
value: () => Promise.resolve(),
|
|
});
|
|
});
|
|
|
|
await page.goto("/admin/6/modules/self-serve/studio", { waitUntil: "domcontentloaded", timeout: 90_000 });
|
|
|
|
await expect(page.getByTestId("self-serve-studio")).toBeVisible();
|
|
await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible();
|
|
await expect(page.getByTestId("studio-flow-actions")).toBeVisible();
|
|
await expect(page.getByText("Are mirrors folded?").first()).toBeVisible();
|
|
await expect(page.getByText("Roskilde Edge 01").first()).toBeVisible();
|
|
await expect(page.getByTestId("studio-custom-node-question").first()).toContainText("Visibility");
|
|
await expect(page.getByTestId("studio-custom-node-condition").first()).toContainText("Expression");
|
|
await expect(page.getByTestId("studio-palette-rule")).toHaveCount(0);
|
|
await expect(page.getByTestId("studio-custom-node-rule")).toHaveCount(0);
|
|
await expect(page.getByTestId("studio-custom-node-task").first()).toContainText("services");
|
|
await expect(page.getByTestId("studio-custom-node-task").first()).toContainText("MACHINE");
|
|
const foldMirrorsNode = page.locator('.vue-flow__node[data-id="task:41"]');
|
|
await expect(foldMirrorsNode.getByTestId("studio-task-node-attachments")).toContainText("mirror-guide.pdf");
|
|
await expect(foldMirrorsNode.getByTestId("studio-task-node-attachments")).toContainText("mirror-photo.jpg");
|
|
await expect(foldMirrorsNode).toContainText("2 attachments");
|
|
await expect(page.getByTestId("studio-palette-action")).toBeVisible();
|
|
await expect(page.getByTestId("studio-custom-node-action").first()).toContainText("When wash starts");
|
|
await expect(page.getByTestId("studio-custom-node-action").first()).toContainText("Open lane entrance port");
|
|
await expect(page.getByTestId("studio-custom-node-gateway").first()).toContainText("ONLINE");
|
|
await expect(page.getByTestId("studio-custom-node-binding").first()).toContainText("MACHINE");
|
|
await expect(page.locator('[data-id="action-relay:81:IN-7:ENTRY:7"]')).toHaveCount(1);
|
|
await expect(page.getByTestId("studio-custom-node-scope").first()).toContainText("Forvogn");
|
|
await expect(page.getByTestId("studio-filter-machine-type")).toContainText("Portal");
|
|
await expect(page.getByTestId("studio-filter-dynamic-image")).toContainText("Machine 1");
|
|
|
|
await page.getByTestId("studio-filter-machine-type").selectOption({ label: "Portal" });
|
|
await expect(page.getByTestId("studio-custom-node-task").filter({ hasText: "Fold mirrors" }).first()).toBeVisible();
|
|
await page.getByTestId("studio-filter-machine-type").selectOption("");
|
|
await page.getByTestId("studio-filter-dynamic-image").selectOption({ label: "Machine 1" });
|
|
await expect(page.getByTestId("studio-custom-node-task").filter({ hasText: "Fold mirrors" }).first()).toBeVisible();
|
|
await page.getByTestId("studio-filter-dynamic-image").selectOption({ label: "Machine 2" });
|
|
await expect(
|
|
page.getByTestId("studio-custom-node-task").filter({ hasText: "Fold mirrors" }).first()
|
|
).not.toBeVisible();
|
|
await page.getByTestId("studio-filter-dynamic-image").selectOption("");
|
|
|
|
await page.getByTestId("studio-panel-lanes").click();
|
|
await expect(page.getByTestId("studio-lane-list")).toContainText("Lane 7");
|
|
await expect(page.getByTestId("studio-lane-dynamic-preview")).toContainText("Machine 1");
|
|
await expect(page.getByTestId("studio-lane-dynamic-image-preview")).toBeVisible();
|
|
let lanePreviewUrl = new URL(await page.getByTestId("studio-lane-dynamic-image-preview").getAttribute("src"));
|
|
expect(lanePreviewUrl.searchParams.get("dynamic_image_id")).toBe("1");
|
|
await page.getByTestId("studio-lane-dynamic-image").selectOption({ label: "Machine 2" });
|
|
lanePreviewUrl = new URL(await page.getByTestId("studio-lane-dynamic-image-preview").getAttribute("src"));
|
|
expect(lanePreviewUrl.searchParams.get("dynamic_image_id")).toBe("2");
|
|
await expect(page.getByTestId("studio-lane-selfserve-enabled")).toBeChecked();
|
|
await page.getByTestId("studio-lane-selfserve-enabled").uncheck();
|
|
await page.getByTestId("studio-lane-save").click();
|
|
await expect
|
|
.poll(() =>
|
|
captured.graphSaves.some(
|
|
(save) =>
|
|
save.operations?.[0]?.entity === "lane" &&
|
|
save.operations?.[0]?.action === "update" &&
|
|
save.operations?.[0]?.id === 7 &&
|
|
save.operations?.[0]?.data?.dynamic_image_id === 2 &&
|
|
save.operations?.[0]?.data?.selfserve_enabled === false
|
|
)
|
|
)
|
|
.toBe(true);
|
|
await expect(page.getByTestId("studio-lane-row-7")).toContainText("Machine 2");
|
|
await expect(page.getByTestId("studio-lane-row-7")).toContainText("Disabled");
|
|
await page.getByTestId("studio-panel-flow").click();
|
|
|
|
await page.getByTitle("Auto layout").click();
|
|
await page.waitForTimeout(350);
|
|
const customNodesDoNotOverlap = await page.evaluate(() => {
|
|
const rects = [...document.querySelectorAll(".studio-custom-node")]
|
|
.map((node) => node.getBoundingClientRect())
|
|
.filter((rect) => rect.width > 0 && rect.height > 0);
|
|
|
|
for (let i = 0; i < rects.length; i += 1) {
|
|
for (let j = i + 1; j < rects.length; j += 1) {
|
|
const left = rects[i];
|
|
const right = rects[j];
|
|
const separated =
|
|
left.right <= right.left + 1 ||
|
|
right.right <= left.left + 1 ||
|
|
left.bottom <= right.top + 1 ||
|
|
right.bottom <= left.top + 1;
|
|
if (!separated) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
expect(customNodesDoNotOverlap).toBe(true);
|
|
|
|
const controlsDoNotCoverDetails = await page.evaluate(() => {
|
|
const controls = document.querySelector('[data-testid="studio-flow-actions"]')?.getBoundingClientRect();
|
|
const details = document.querySelector(".studio-details")?.getBoundingClientRect();
|
|
if (!controls || !details) {
|
|
return false;
|
|
}
|
|
return (
|
|
controls.right <= details.left ||
|
|
controls.bottom <= details.top ||
|
|
controls.left >= details.right ||
|
|
controls.top >= details.bottom
|
|
);
|
|
});
|
|
expect(controlsDoNotCoverDetails).toBe(true);
|
|
|
|
await page.getByTestId("studio-fullscreen-toggle").click();
|
|
await expect(page.getByTestId("self-serve-studio")).toHaveClass(/is-studio-fullscreen/);
|
|
await expect(page.getByTestId("studio-fullscreen-toggle")).toContainText("Exit fullscreen");
|
|
await page.getByTestId("studio-fullscreen-toggle").click();
|
|
await expect(page.getByTestId("self-serve-studio")).not.toHaveClass(/is-studio-fullscreen/);
|
|
|
|
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" });
|
|
await expect
|
|
.poll(async () => page.locator('[data-testid="studio-custom-node-scope"]:visible h4').allTextContents())
|
|
.toEqual(["Bus"]);
|
|
await expect(page.getByText("Bus roof fixtures checked?").first()).toBeVisible();
|
|
await expect(page.getByText("Start bus wash").first()).toBeVisible();
|
|
await expect(page.getByText("Are mirrors folded?").first()).not.toBeVisible();
|
|
await page.getByTestId("studio-panel-tasks").click();
|
|
await expect(page.getByTestId("studio-task-list")).toContainText("Start bus wash");
|
|
await expect(page.getByTestId("studio-task-list")).not.toContainText("Fold mirrors");
|
|
await page.getByTestId("studio-panel-flow").click();
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate(() => {
|
|
const widths = [...document.querySelectorAll(".studio-custom-node")]
|
|
.filter((node) => node.offsetParent !== null)
|
|
.map((node) => node.getBoundingClientRect().width);
|
|
return Math.min(...widths);
|
|
})
|
|
)
|
|
.toBeGreaterThan(110);
|
|
const focusedNodeMetrics = await page.evaluate(() => {
|
|
const rects = [...document.querySelectorAll(".studio-custom-node")]
|
|
.filter((node) => node.offsetParent !== null)
|
|
.map((node) => node.getBoundingClientRect());
|
|
const left = Math.min(...rects.map((rect) => rect.left));
|
|
const right = Math.max(...rects.map((rect) => rect.right));
|
|
const top = Math.min(...rects.map((rect) => rect.top));
|
|
const bottom = Math.max(...rects.map((rect) => rect.bottom));
|
|
return {
|
|
count: rects.length,
|
|
minWidth: Math.min(...rects.map((rect) => rect.width)),
|
|
spreadX: right - left,
|
|
spreadY: bottom - top,
|
|
};
|
|
});
|
|
expect(focusedNodeMetrics.count).toBeGreaterThanOrEqual(4);
|
|
const isWebKitProject = testInfo.project.name.startsWith("webkit-");
|
|
const maxFocusedSpreadX = isWebKitProject ? 1120 : 980;
|
|
const maxFocusedSpreadY = isWebKitProject ? 560 : 460;
|
|
expect(focusedNodeMetrics.spreadX).toBeLessThan(maxFocusedSpreadX);
|
|
expect(focusedNodeMetrics.spreadY).toBeLessThan(maxFocusedSpreadY);
|
|
await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" });
|
|
await expect
|
|
.poll(async () => page.locator('[data-testid="studio-custom-node-scope"]:visible h4').allTextContents())
|
|
.toEqual(expect.arrayContaining(["Lane 7", "Bus"]));
|
|
await page.getByTestId("studio-filter-vehicle-type").selectOption("");
|
|
await page.getByTestId("studio-filter-lane").selectOption("");
|
|
await expect(page.getByText("Are mirrors folded?").first()).toBeVisible();
|
|
|
|
await page.locator(".studio-node-list button").filter({ hasText: "Forvogn" }).click();
|
|
await expect(page.getByTestId("studio-managed-inspector")).toBeVisible();
|
|
await expect(page.getByTestId("studio-vehicle-type-form")).toBeVisible();
|
|
await expect(page.getByTestId("studio-vehicle-type-price")).toHaveValue("649");
|
|
await page.getByTestId("studio-vehicle-type-name").fill("Forvogn XL");
|
|
await page.getByRole("button", { name: "Save vehicle type" }).click();
|
|
await expect.poll(() => captured.productUpdates.at(-1)?.name).toBe("Forvogn XL");
|
|
await expect(page.locator('.vue-flow__node[data-id="vehicle_type:2"]')).toContainText("Forvogn XL");
|
|
await page.getByTestId("studio-panel-flow").click();
|
|
|
|
await page.locator(".studio-node-list button").filter({ hasText: "Fold mirrors" }).click();
|
|
await expect(page.getByRole("heading", { name: "Inspector" })).toBeVisible();
|
|
await expect(page.getByTestId("studio-service-picker")).toContainText("MACHINE");
|
|
await expect(page.getByTestId("studio-service-picker")).toContainText("Machine relay on Roskilde Edge 01");
|
|
await expect(page.getByTestId("studio-service-picker").locator('input[value="MACHINE"]')).toBeChecked();
|
|
await expect(page.getByTestId("studio-task-button-picker")).toContainText("Reset");
|
|
await expect(page.getByTestId("studio-task-button-picker")).toContainText("Start");
|
|
await expect(page.getByTestId("studio-task-button-picker").locator('input[value="reset"]')).toBeChecked();
|
|
await expect(page.getByTestId("studio-task-button-picker").locator('input[value="start"]')).toBeChecked();
|
|
await expect(page.getByTestId("studio-task-attachment-manager")).toContainText("mirror-guide.pdf");
|
|
await expect(page.getByTestId("studio-task-attachment-manager")).toContainText("mirror-photo.jpg");
|
|
await page.getByTestId("studio-task-attachment-upload").setInputFiles({
|
|
name: "driver-checklist.txt",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("Check mirrors before wash"),
|
|
});
|
|
await expect.poll(() => captured.taskAttachmentUploads?.at(-1)?.file_name).toBe("driver-checklist.txt");
|
|
await expect(page.getByTestId("studio-task-attachment-manager")).toContainText("driver-checklist.txt");
|
|
await page
|
|
.locator(".studio-task-attachment-item")
|
|
.filter({ hasText: "driver-checklist.txt" })
|
|
.getByTitle("Remove file")
|
|
.click();
|
|
await expect.poll(() => captured.taskAttachmentDeletes?.at(-1)?.attachment_id).toBe(902);
|
|
await expect(page.getByTestId("studio-task-attachment-manager")).not.toContainText("driver-checklist.txt");
|
|
await page.getByTestId("studio-service-picker").locator('input[value="MACHINE"]').uncheck();
|
|
await page.getByRole("button", { name: "Save" }).click();
|
|
await expect
|
|
.poll(() =>
|
|
captured.graphSaves.some(
|
|
(save) =>
|
|
save.operations?.[0]?.entity === "task" &&
|
|
Array.isArray(save.operations?.[0]?.data?.services) &&
|
|
save.operations[0].data.services.length === 0 &&
|
|
!Object.prototype.hasOwnProperty.call(save.operations[0].data, "attachments")
|
|
)
|
|
)
|
|
.toBe(true);
|
|
await expect(page.locator('.vue-flow__node[data-id="task:41"]')).toContainText("No service");
|
|
|
|
await page.getByTestId("studio-panel-tasks").click();
|
|
await expect(page.locator(".studio-task-row").filter({ hasText: "Open entry on start" })).toContainText(
|
|
"When wash starts"
|
|
);
|
|
await page.locator(".studio-task-row").filter({ hasText: "Open entry on start" }).getByRole("button").click();
|
|
await expect(page.getByTestId("studio-inspector-action-event")).toHaveValue("wash_start_command");
|
|
await page.getByTestId("studio-inspector-action-operation").selectOption("set_cleaner_relay");
|
|
await page.getByRole("button", { name: "OFF" }).click();
|
|
await page.getByRole("button", { name: "Save" }).click();
|
|
await expect
|
|
.poll(() =>
|
|
captured.graphSaves.some(
|
|
(save) =>
|
|
save.operations?.[0]?.entity === "action" &&
|
|
save.operations?.[0]?.data?.operation === "set_cleaner_relay" &&
|
|
save.operations?.[0]?.data?.relay_state === false &&
|
|
save.operations?.[0]?.data?.options?.failure_policy === "continue"
|
|
)
|
|
)
|
|
.toBe(true);
|
|
|
|
await page.getByTestId("studio-panel-conditions").click();
|
|
await expect(page.locator(".studio-condition-card").filter({ hasText: "Trailer present" })).toContainText(
|
|
"Are mirrors folded?"
|
|
);
|
|
await expect(page.locator(".studio-condition-card").filter({ hasText: "Trailer present" })).toContainText("2 uses");
|
|
await page.locator(".studio-condition-card").filter({ hasText: "Trailer present" }).click();
|
|
await expect(page.getByTestId("condition-expression-builder")).toBeVisible();
|
|
await expect(page.getByTestId("condition-expression-row").first()).toContainText("Are mirrors folded?");
|
|
await page.getByTestId("condition-expression-add").click();
|
|
await page.getByTestId("condition-expression-subject").last().selectOption({ label: "Is the lift lowered?" });
|
|
await page.getByTestId("condition-expression-predicate-operator").last().selectOption("IS_FALSE_OR_NOT_SET");
|
|
await expect(page.getByTestId("condition-expression-summary")).toContainText("Is the lift lowered?");
|
|
await page.getByRole("button", { name: "Save" }).click();
|
|
await expect
|
|
.poll(() =>
|
|
captured.graphSaves.some(
|
|
(save) =>
|
|
save.operations?.[0]?.entity === "condition" &&
|
|
save.operations?.[0]?.data?.expression?.children?.some(
|
|
(child) =>
|
|
child.subject_type === "question" && child.subject_id === 12 && child.operator === "IS_FALSE_OR_NOT_SET"
|
|
)
|
|
)
|
|
)
|
|
.toBe(true);
|
|
|
|
await page.getByTestId("studio-panel-flow").click();
|
|
await page.locator(".studio-node-list button").filter({ hasText: "Are mirrors folded?" }).click();
|
|
await expect(page.getByRole("heading", { name: "Inspector" })).toBeVisible();
|
|
await page.getByRole("textbox", { name: "Question", exact: true }).fill("Are loose straps secured?");
|
|
await page.getByRole("button", { name: "Save" }).click();
|
|
|
|
await expect
|
|
.poll(() =>
|
|
captured.graphSaves.some(
|
|
(save) =>
|
|
save.operations?.[0]?.entity === "question" &&
|
|
save.operations?.[0]?.data?.question === "Are loose straps secured?"
|
|
)
|
|
)
|
|
.toBe(true);
|
|
await expect(page.locator('.vue-flow__node[data-id="question:11"]')).toContainText("Are loose straps secured?");
|
|
});
|
|
|
|
test("edits if/else and case condition operators from the inspector", async ({ page }) => {
|
|
const graph = buildStudioGraph();
|
|
const captured = {
|
|
graphSaves: [],
|
|
layouts: [],
|
|
simulations: [],
|
|
pathOutcomes: [],
|
|
dynamicImages: [],
|
|
gatewayActions: [],
|
|
virtualHardware: [],
|
|
productUpdates: [],
|
|
rollbacks: [],
|
|
published: 0,
|
|
};
|
|
await installStudioRoutes(page, graph, captured);
|
|
|
|
await page.goto("/admin/6/modules/self-serve/studio", { waitUntil: "domcontentloaded", timeout: 90_000 });
|
|
await page.getByTestId("studio-panel-conditions").click();
|
|
await page.locator(".studio-condition-card").filter({ hasText: "Trailer present" }).click();
|
|
await expect(page.getByTestId("condition-expression-builder")).toBeVisible();
|
|
|
|
await page.getByTestId("condition-expression-type").selectOption("branch");
|
|
await expect(page.getByTestId("condition-expression-branch")).toHaveCount(2);
|
|
const firstBranch = page.getByTestId("condition-expression-branch").first();
|
|
await firstBranch.getByRole("button", { name: "Add then" }).click();
|
|
const branchThen = firstBranch.getByTestId("condition-expression-branch-then-row").last();
|
|
await branchThen.locator("select").nth(1).selectOption({ label: "Is the lift lowered?" });
|
|
await branchThen.locator("select").nth(2).selectOption("IS_FALSE");
|
|
await page.getByTestId("condition-expression-add-elseif").click();
|
|
await expect(page.getByTestId("condition-expression-summary")).toContainText("Else if");
|
|
await page.getByRole("button", { name: "Save" }).click();
|
|
await expect
|
|
.poll(() => {
|
|
const expression = captured.graphSaves.findLast((save) => save.operations?.[0]?.entity === "condition")
|
|
?.operations?.[0]?.data?.expression;
|
|
return (
|
|
expression?.type === "branch" &&
|
|
expression.branches?.[0]?.kind === "if" &&
|
|
expression.branches?.[0]?.when?.children?.[0]?.subject_id === 11 &&
|
|
expression.branches?.[0]?.then?.children?.[0]?.subject_id === 12 &&
|
|
expression.branches?.[0]?.then?.children?.[0]?.operator === "IS_FALSE" &&
|
|
expression.branches?.some((branch) => branch.kind === "else_if") &&
|
|
expression.branches?.some((branch) => branch.kind === "else")
|
|
);
|
|
})
|
|
.toBe(true);
|
|
|
|
await page.getByTestId("condition-expression-type").selectOption("case");
|
|
await page.getByTestId("condition-expression-case-subject").selectOption({ label: "Are mirrors folded?" });
|
|
const firstCase = page.getByTestId("condition-expression-case").first();
|
|
await firstCase.getByTestId("condition-expression-case-value").selectOption({ label: "False" });
|
|
await firstCase.getByRole("button", { name: "Add then" }).click();
|
|
const caseThen = firstCase.getByTestId("condition-expression-case-then-row").last();
|
|
await caseThen.locator("select").nth(1).selectOption({ label: "Is the lift lowered?" });
|
|
await caseThen.locator("select").nth(2).selectOption("IS_TRUE_OR_NOT_SET");
|
|
await expect(page.getByTestId("condition-expression-summary")).toContainText("Case Are mirrors folded?");
|
|
await page.getByRole("button", { name: "Save" }).click();
|
|
await expect
|
|
.poll(() => {
|
|
const expression = captured.graphSaves.findLast((save) => save.operations?.[0]?.entity === "condition")
|
|
?.operations?.[0]?.data?.expression;
|
|
return (
|
|
expression?.type === "case" &&
|
|
expression.subject_type === "question" &&
|
|
expression.subject_id === 11 &&
|
|
expression.cases?.[0]?.value === false &&
|
|
expression.cases?.[0]?.then?.children?.[0]?.subject_id === 12 &&
|
|
expression.cases?.[0]?.then?.children?.[0]?.operator === "IS_TRUE_OR_NOT_SET"
|
|
);
|
|
})
|
|
.toBe(true);
|
|
});
|
|
|
|
test("supports drag-drop quick add, gateway action, simulator, publish, and rollback panels", async ({ page }) => {
|
|
const graph = buildStudioGraph();
|
|
const captured = {
|
|
graphSaves: [],
|
|
layouts: [],
|
|
simulations: [],
|
|
pathOutcomes: [],
|
|
dynamicImages: [],
|
|
gatewayActions: [],
|
|
virtualHardware: [],
|
|
rollbacks: [],
|
|
published: 0,
|
|
};
|
|
await installStudioRoutes(page, graph, captured);
|
|
|
|
await page.goto("/admin/6/modules/self-serve/studio", { waitUntil: "domcontentloaded", timeout: 90_000 });
|
|
await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible();
|
|
|
|
await page.evaluate(() => {
|
|
const source = document.querySelector('[data-testid="studio-palette-question"]');
|
|
const target = document.querySelector('[data-testid="self-serve-studio-flow"]');
|
|
const dataTransfer = new DataTransfer();
|
|
source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
|
|
target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
|
target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
|
});
|
|
await expect.poll(() => captured.graphSaves.some((save) => save.operations?.[0]?.action === "create")).toBe(true);
|
|
await expect(page.getByText("New question").first()).toBeVisible();
|
|
|
|
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" });
|
|
await page.getByTestId("studio-palette-action").click();
|
|
await expect
|
|
.poll(() =>
|
|
captured.graphSaves.some(
|
|
(save) =>
|
|
save.operations?.[0]?.action === "create" &&
|
|
save.operations?.[0]?.entity === "action" &&
|
|
save.operations?.[0]?.data?.product === 8
|
|
)
|
|
)
|
|
.toBe(true);
|
|
await expect(page.getByTestId("studio-inspector-action-name")).toHaveValue("Open lane entrance port");
|
|
await expect(page.getByTestId("studio-inspector-action-event")).toBeVisible();
|
|
await expect(page.locator('.vue-flow__node[data-id="action:99"]')).toHaveClass(/selected/);
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate(() => {
|
|
const node = document.querySelector('.vue-flow__node[data-id="action:99"]');
|
|
const surface = document.querySelector('[data-testid="self-serve-studio-flow"]');
|
|
if (!node || !surface) {
|
|
return false;
|
|
}
|
|
const nodeRect = node.getBoundingClientRect();
|
|
const surfaceRect = surface.getBoundingClientRect();
|
|
const nodeCenterX = nodeRect.left + nodeRect.width / 2;
|
|
const nodeCenterY = nodeRect.top + nodeRect.height / 2;
|
|
const surfaceCenterX = surfaceRect.left + surfaceRect.width / 2;
|
|
const surfaceCenterY = surfaceRect.top + surfaceRect.height / 2;
|
|
return (
|
|
Math.abs(nodeCenterX - surfaceCenterX) < surfaceRect.width * 0.3 &&
|
|
Math.abs(nodeCenterY - surfaceCenterY) < surfaceRect.height * 0.3
|
|
);
|
|
})
|
|
)
|
|
.toBe(true);
|
|
await page.getByTestId("studio-filter-vehicle-type").selectOption("");
|
|
|
|
await page.getByTestId("studio-panel-hardware").click();
|
|
await expect(page.getByTestId("studio-service-coverage")).toContainText("MACHINE");
|
|
await expect(page.getByTestId("studio-service-coverage")).toContainText("Fold mirrors");
|
|
await expect(page.getByTestId("studio-service-coverage")).toContainText("Start bus wash");
|
|
await page.getByRole("button", { name: "Generate from lanes" }).click();
|
|
await expect.poll(() => captured.virtualHardware[0]?.operation).toBe("generate_from_lanes");
|
|
await expect(page.getByTestId("studio-virtual-hardware")).toContainText("3 virtual bindings active");
|
|
await expect(page.getByText("Virtual Studio Gateway").first()).toBeVisible();
|
|
await expect(page.getByTestId("studio-service-coverage")).toContainText("Virtual coverage");
|
|
await page.getByTestId("studio-panel-flow").click();
|
|
await page.getByTitle("Fit view").click();
|
|
await page.locator(".studio-node-list button").filter({ hasText: "Lane 7 MACHINE" }).click();
|
|
await expect(page.getByTestId("studio-binding-inspector")).toBeVisible();
|
|
await expect(page.getByTestId("studio-binding-services")).toHaveValue("MACHINE");
|
|
await page.getByTestId("studio-binding-label").fill("Lane 7 MACHINE updated");
|
|
await page.getByRole("button", { name: "Save binding" }).click();
|
|
await expect.poll(() => captured.virtualHardware.at(-1)?.operation).toBe("upsert_binding");
|
|
await expect.poll(() => captured.virtualHardware.at(-1)?.data?.label).toBe("Lane 7 MACHINE updated");
|
|
await page.getByTestId("studio-panel-flow").click();
|
|
await page.locator(".studio-node-list button").filter({ hasText: "Roskilde Edge 01" }).click();
|
|
await expect(page.getByTestId("studio-managed-inspector")).toContainText("Roskilde Edge 01");
|
|
await expect(page.getByTestId("studio-gateway-inspector")).toContainText("Bindings");
|
|
await page.getByRole("button", { name: "Discover" }).click();
|
|
await expect.poll(() => captured.gatewayActions[0]?.action).toBe("discovery");
|
|
|
|
await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" });
|
|
await page.getByTestId("studio-panel-simulator").click();
|
|
await expect(page.getByTestId("studio-hardware-mode")).toContainText("Studio");
|
|
await expect(page.getByTestId("studio-simulator-scope")).toContainText("Lane 7");
|
|
await expect(page.getByTestId("studio-simulator-scope")).toContainText("Auto");
|
|
await expect(page.locator(".studio-simulator-form select")).toHaveCount(0);
|
|
await page.getByRole("button", { name: "Run" }).click();
|
|
await expect.poll(() => captured.simulations[0]?.vehicle_type_id).toBeNull();
|
|
await expect(page.getByTestId("studio-simulator-auto-decision")).toContainText("Auto decided: Forvogn (#2).");
|
|
|
|
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" });
|
|
await page.getByTestId("studio-panel-paths").click();
|
|
await expect(page.getByTestId("studio-path-outcomes")).toBeVisible();
|
|
await expect(page.getByTestId("studio-path-outcomes-progress")).toBeVisible();
|
|
await expect.poll(() => captured.pathOutcomes[0]?.lane_id).toBe(7);
|
|
await expect.poll(() => captured.pathOutcomes[0]?.vehicle_type_id).toBe(8);
|
|
await expect
|
|
.poll(() => Object.prototype.hasOwnProperty.call(captured.pathOutcomes[0] || {}, "max_states"))
|
|
.toBe(false);
|
|
await expect(page.getByTestId("studio-path-outcomes-error")).toHaveCount(0);
|
|
await expect(page.getByTestId("studio-path-outcomes-summary")).toContainText("2");
|
|
await expect(page.getByTestId("studio-path-outcome-detail")).toContainText("MACHINE");
|
|
await expect(page.getByTestId("studio-path-results")).toContainText("Paths and Results");
|
|
await expect(page.getByTestId("studio-path-results")).toContainText("Allowed / MACHINE / 1 task / 2 signals");
|
|
await expect(page.getByTestId("studio-path-results")).toContainText("Are mirrors folded?");
|
|
await expect(page.getByTestId("studio-path-results")).toContainText("Yes");
|
|
await expect(page.getByTestId("studio-path-signal-timeline")).toContainText("machine_start_signal / MACHINE");
|
|
await expect(page.getByTestId("studio-path-signal-timeline")).toContainText('"bill_machine_wash":true');
|
|
await page.getByTestId("studio-path-outcome-focus").click();
|
|
await expect(page.getByTestId("studio-panel-flow")).toHaveClass(/is-active/);
|
|
await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible();
|
|
await page.getByTestId("studio-filter-vehicle-type").selectOption("");
|
|
await page.getByTestId("studio-panel-paths").click();
|
|
await expect(page.getByTestId("studio-path-outcomes-stale")).toBeVisible();
|
|
await page.getByTestId("studio-path-outcomes-refresh").click();
|
|
await expect.poll(() => captured.pathOutcomes.at(-1)?.vehicle_type_id).toBeNull();
|
|
await page.getByTestId("studio-panel-flow").click();
|
|
|
|
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" });
|
|
await page.getByTestId("studio-panel-simulator").click();
|
|
await expect(page.getByTestId("studio-simulator-scope")).toContainText("Bus");
|
|
await expect(page.locator(".studio-simulator-form select")).toHaveCount(0);
|
|
await page.getByLabel("Registration").fill("AB12345");
|
|
await page.getByTestId("studio-simulator-customer-search").fill("Acme");
|
|
await expect.poll(() => captured.customerSearches?.at(-1)).toBe("Acme");
|
|
await expect(page.getByTestId("studio-simulator-customer-results")).toContainText("Acme Logistics");
|
|
await page.getByTestId("studio-simulator-customer-option-601001").click();
|
|
await expect(page.getByTestId("studio-simulator-selected-customer")).toContainText("Acme Logistics");
|
|
await expect(page.getByTestId("studio-simulator-selected-customer")).toContainText("#601001");
|
|
await page.getByRole("button", { name: "Published" }).click();
|
|
await page
|
|
.locator(".studio-simulator-question")
|
|
.filter({ hasText: "Are mirrors folded?" })
|
|
.getByRole("button", { name: "No" })
|
|
.click();
|
|
await page.getByRole("button", { name: "Run" }).click();
|
|
await expect.poll(() => captured.simulations.at(-1)?.reg).toBe("AB12345");
|
|
await expect.poll(() => captured.simulations.at(-1)?.customer_number).toBe(601001);
|
|
await expect.poll(() => captured.simulations.at(-1)?.lane_id).toBe(7);
|
|
await expect.poll(() => captured.simulations.at(-1)?.vehicle_type_id).toBe(8);
|
|
await expect.poll(() => captured.simulations.at(-1)?.config_source).toBe("published");
|
|
await expect.poll(() => captured.simulations.at(-1)?.hardware_mode).toBe("studio");
|
|
await expect
|
|
.poll(() => captured.simulations.at(-1)?.answer_overrides?.[0])
|
|
.toEqual({ question_id: 11, value: false });
|
|
await expect(page.getByText("Guided Trace")).toBeVisible();
|
|
await expect(page.getByText("Set Are mirrors folded? so it satisfies is true.")).toBeVisible();
|
|
await expect(page.getByTestId("studio-simulator-dynamic-image")).toBeVisible();
|
|
await expect
|
|
.poll(() => captured.dynamicImages.at(-1)?.searchParams.get("buttons"))
|
|
.toBe(JSON.stringify(["reset", 1, "start"]));
|
|
await expect.poll(() => captured.dynamicImages.at(-1)?.searchParams.get("vehicle_type")).toBe("8");
|
|
await expect.poll(() => captured.dynamicImages.at(-1)?.searchParams.get("thumb_position")).toBe("5");
|
|
await expect(page.getByText("Question visibility and answers")).toBeVisible();
|
|
await expect(page.getByText("Answer required questions")).toBeVisible();
|
|
await expect(page.getByText("Tasks and Services")).toBeVisible();
|
|
const foldMirrorsDebugTask = page.locator(".studio-debug-task").filter({ hasText: "Fold mirrors" });
|
|
await expect(foldMirrorsDebugTask.locator(".studio-debug-attachment-list")).toContainText("mirror-guide.pdf");
|
|
await expect(foldMirrorsDebugTask.locator(".studio-debug-attachment-list")).toContainText("mirror-photo.jpg");
|
|
await expect(page.getByText("Gateway Readiness")).toBeVisible();
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText("Signals");
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText("start / ENTRY");
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText("studio_action_relay_pulse");
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText('"action_id":81');
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText("eligibility_sync / MACHINE");
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText("machine_start_signal / MACHINE");
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText('"bill_machine_wash":true');
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText("machine_start / CLEANER");
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText("stop / EXIT");
|
|
await expect(page.getByTestId("studio-signal-timeline")).toContainText('"toggle_after":1');
|
|
await expect(page.locator('.vue-flow__node[data-id="question:11"]')).toHaveClass(/studio-node-sim-warning/);
|
|
await page.getByRole("button", { name: "Show JSON" }).click();
|
|
await expect(page.locator(".studio-json")).toContainText("Fold mirrors");
|
|
await expect(page.locator(".studio-json")).toContainText("signal_timeline");
|
|
|
|
await page.getByTestId("studio-panel-versions").click();
|
|
await page.getByRole("button", { name: "Publish" }).first().click();
|
|
await expect.poll(() => captured.published).toBe(1);
|
|
await page.getByTestId("studio-panel-audit").click();
|
|
await expect(page.getByText("Studio uses virtual hardware coverage")).toBeVisible();
|
|
await page.getByTestId("studio-panel-versions").click();
|
|
await page.getByRole("button", { name: "Rollback" }).last().click();
|
|
await expect.poll(() => captured.rollbacks[0]?.target_version_id).toBe(80);
|
|
});
|
|
|
|
test("opens legacy condition rule URLs in the studio conditions panel and blocks invalid publishes", async ({
|
|
page,
|
|
}) => {
|
|
const graph = buildStudioGraph();
|
|
graph.validation = {
|
|
valid: false,
|
|
errors: ["Condition 21 is used but has an empty expression."],
|
|
warnings: [],
|
|
items: [{ severity: "error", message: "Condition 21 is used but has an empty expression." }],
|
|
};
|
|
const captured = {
|
|
graphSaves: [],
|
|
layouts: [],
|
|
simulations: [],
|
|
pathOutcomes: [],
|
|
dynamicImages: [],
|
|
gatewayActions: [],
|
|
virtualHardware: [],
|
|
rollbacks: [],
|
|
published: 0,
|
|
};
|
|
await installStudioRoutes(page, graph, captured);
|
|
|
|
await page.goto("/admin/6/modules/self-serve/condition-rules", { waitUntil: "domcontentloaded", timeout: 90_000 });
|
|
|
|
await expect(page).toHaveURL(/\/admin\/6\/modules\/self-serve\/studio.*tab=conditions/);
|
|
await expect(page.getByTestId("studio-panel-conditions")).toHaveClass(/is-active/);
|
|
await expect(page.locator(".studio-condition-card").filter({ hasText: "Trailer present" })).toContainText("error");
|
|
await expect(page.getByRole("button", { name: "Publish" }).first()).toBeDisabled();
|
|
await expect(page.getByTestId("studio-palette-rule")).toHaveCount(0);
|
|
await expect(page.getByText("Rules")).toHaveCount(0);
|
|
|
|
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" });
|
|
await expect(page.getByTestId("studio-filter-vehicle-type")).toHaveValue("8");
|
|
await page.getByTestId("studio-panel-audit").click();
|
|
await page
|
|
.locator(".studio-validation-item")
|
|
.filter({ hasText: "Condition 21 is used but has an empty expression." })
|
|
.click();
|
|
await expect(page.getByRole("heading", { name: "Inspector" })).toBeVisible();
|
|
await expect(page.getByRole("textbox", { name: "Name" })).toHaveValue("Trailer present");
|
|
await expect(page.getByTestId("studio-filter-vehicle-type")).toHaveValue("");
|
|
});
|
|
});
|