Extend Self-Serve Studio with "action" nodes, supporting creation, focus, and editing workflows. Add unit tests, normalize forms, and enhance virtual binding/gateway logic. Update E2E tests and node visualization settings.

This commit is contained in:
Jeppe Bundgaard
2026-04-29 10:56:40 +02:00
parent de2fa57c63
commit 9641f67e3a
7 changed files with 1381 additions and 36 deletions
@@ -5,8 +5,7 @@ import { BLoading } from "buefy";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { finished_loading, is_latest_fetch, is_loading, next_fetch_id } from "@/views/dashboards/departmentDashboard/other/displays/DepartmentsOverviewObject.vue";
import { selected_date, selected_date_to } from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
import { buildOutsideHoursWarningText, createEmptyOutsideHours, normalizeOutsideHours } from "@/views/dashboards/departmentDashboard/modules/daily-report/outsideHours.js";
import { departments as loadedDepartments } from "@/components/pagination/departmentTabs.vue";
import { createEmptyOutsideHours, normalizeOutsideHours } from "@/views/dashboards/departmentDashboard/modules/daily-report/outsideHours.js";
const props = defineProps({
department_id: {
@@ -75,12 +74,6 @@ watch([selected_date, selected_date_to], () => {
<div class="column is-2 has-text-centered" :data-testid="`overview-department-${props.department_id}-night-washes`">
<h3 class="title is-5" :data-testid="`overview-department-${props.department_id}-night-washes-total`">{{ outsideHours.total }}</h3>
<h6 class="subtitle is-6">{{ $t("admin.daily_report.reports.night_washes.title") }}</h6>
<p
v-if="buildOutsideHoursWarningText(outsideHours, loadedDepartments, $t)"
class="is-size-7 has-text-warning-dark"
>
{{ buildOutsideHoursWarningText(outsideHours, loadedDepartments, $t) }}
</p>
<b-loading :is-full-page="false" :modelValue="is_loading(identifier)" />
</div>
<div class="column is-2 has-text-centered">
@@ -367,6 +367,9 @@ test.describe("Admin overview night washes", () => {
await expect(page.getByTestId("overview-department-1-night-washes-total")).toContainText("2");
await expect(page.getByTestId("overview-department-2-night-washes-total")).toContainText("3");
await expect(page.getByTestId("overview-department-3-night-washes-total")).toContainText("0");
await expect(page.getByTestId("overview-department-3-night-washes")).not.toContainText(
/mangler|Opening hours missing|Missing Hours/
);
await tabs.nth(2).click();
await expect(page.getByTestId("outside-hours-chart")).toBeVisible();
+300 -11
View File
@@ -26,8 +26,28 @@ function buildStudioGraph() {
},
],
products: [
{ id: 2, label: "Forvogn", is_wash: 1, subscription_allowed: 1 },
{ id: 8, label: "Bus", is_wash: 1, subscription_allowed: 1 },
{
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: [
@@ -52,6 +72,7 @@ function buildStudioGraph() {
{ 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" }],
bindings: [{ id: "701:M-7:0", label: "Machine relay", role: "MACHINE", services: ["MACHINE"] }],
@@ -66,6 +87,7 @@ function buildStudioGraph() {
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" },
bindings: { "701:M-7:0": "Machine relay" },
@@ -83,7 +105,20 @@ function buildStudioGraph() {
object_id: 2,
label: "Forvogn",
subtitle: "Vehicle scope",
raw: { id: 2, product: 2, product_id: 2, name: "Forvogn", label: "Forvogn", source: "products" },
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",
},
},
},
{
@@ -95,7 +130,20 @@ function buildStudioGraph() {
object_id: 8,
label: "Bus",
subtitle: "Vehicle scope",
raw: { id: 8, product: 8, product_id: 8, name: "Bus", label: "Bus", source: "products" },
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",
},
},
},
{
@@ -202,6 +250,43 @@ function buildStudioGraph() {
},
},
},
{
id: "action:81",
type: "default",
position: { x: 1280, y: 140 },
data: {
kind: "action",
object_id: 81,
label: "Open entry on start",
subtitle: "On self-serve wash start command / Both / Open lane entrance port",
event_label: "On self-serve wash start command",
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",
@@ -375,6 +460,30 @@ function buildStudioGraph() {
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",
@@ -795,6 +904,28 @@ function buildSimulationResponse() {
},
{
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",
@@ -808,7 +939,7 @@ function buildSimulationResponse() {
skip_block_reason: "Virtual studio hardware only; live dispatch would require a real gateway binding.",
},
{
sequence: 3,
sequence: 4,
runtime_stage: "machine_start_signal",
signal_type: "shelly_event",
relay_role: "MACHINE",
@@ -822,7 +953,7 @@ function buildSimulationResponse() {
skip_block_reason: "Virtual studio hardware only; live dispatch would require a real gateway binding.",
},
{
sequence: 4,
sequence: 5,
runtime_stage: "machine_start",
signal_type: "relay_switch",
relay_role: "CLEANER",
@@ -834,7 +965,7 @@ function buildSimulationResponse() {
payload: { id: "CLEAN-7", channel: 0, on: true },
},
{
sequence: 5,
sequence: 6,
runtime_stage: "stop",
signal_type: "relay_pulse",
relay_role: "EXIT",
@@ -850,6 +981,7 @@ function buildSimulationResponse() {
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: {
@@ -1087,6 +1219,23 @@ async function installStudioRoutes(page, graph, captured) {
},
});
}
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: "On self-serve wash start command / Both / Open lane entrance port",
event_label: "On self-serve wash start command",
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 };
@@ -1103,6 +1252,14 @@ async function installStudioRoutes(page, graph, captured) {
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) {
@@ -1128,6 +1285,47 @@ async function installStudioRoutes(page, graph, captured) {
await route.fulfill(json({ data: graph }));
});
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 } }));
@@ -1212,6 +1410,7 @@ test.describe("All-in-one self-serve studio", () => {
dynamicImages: [],
gatewayActions: [],
virtualHardware: [],
productUpdates: [],
rollbacks: [],
published: 0,
};
@@ -1230,6 +1429,11 @@ test.describe("All-in-one self-serve studio", () => {
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");
await expect(page.getByTestId("studio-palette-action")).toBeVisible();
await expect(page.getByTestId("studio-custom-node-action").first()).toContainText(
"On self-serve wash start command"
);
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.getByTestId("studio-custom-node-scope").first()).toContainText("Forvogn");
@@ -1325,8 +1529,8 @@ test.describe("All-in-one self-serve studio", () => {
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.locator(".studio-task-list")).toContainText("Start bus wash");
await expect(page.locator(".studio-task-list")).not.toContainText("Fold mirrors");
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 () =>
@@ -1364,6 +1568,16 @@ test.describe("All-in-one self-serve studio", () => {
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");
@@ -1387,11 +1601,32 @@ test.describe("All-in-one self-serve studio", () => {
.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(
"On self-serve wash start command"
);
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("1 uses");
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?");
@@ -1459,6 +1694,44 @@ test.describe("All-in-one self-serve studio", () => {
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");
@@ -1468,7 +1741,20 @@ test.describe("All-in-one self-serve studio", () => {
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.getByTitle("Discover gateway").click();
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" });
@@ -1520,6 +1806,9 @@ test.describe("All-in-one self-serve studio", () => {
await expect(page.getByText("Tasks and Services")).toBeVisible();
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');
@@ -208,4 +208,54 @@ describe("Department overview period sync behavior", () => {
);
expect(rangeCalls).toEqual(expect.arrayContaining(["10:2026-03-23:2026-03-24", "20:2026-03-23:2026-03-24"]));
});
it("does not show missing-opening-hours labels in compact department rows", async () => {
getTransactionCountMock.mockResolvedValueOnce({
data: {
data: {
earnings: 10,
products: 2,
quantity: 3,
washes: 4,
outside_hours: {
total: 1,
by_source: {
orders: 0,
xlvask: 1,
selfserve: 0,
},
has_missing_opening_hours: true,
missing_department_ids: [20],
},
},
},
});
const wrapper = mount(DepartmentDailyReportSmall, {
props: {
department_id: 20,
},
global: {
mocks: {
$t: (key, values = {}) => {
if (key === "admin.daily_report.reports.night_washes.warning_missing_opening_hours") {
return `Opening hours missing for: ${values.departments}`;
}
return key;
},
},
stubs: {
BLoading: true,
},
},
});
await flushAll();
await flushAll();
expect(wrapper.text()).toContain("Dept 20");
expect(wrapper.text()).toContain("1");
expect(wrapper.text()).not.toContain("Opening hours missing for:");
});
});
@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio create focus", () => {
const studioSource = () =>
readSource("src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue");
it("selects and focuses a newly created graph node after save hydration", () => {
const source = studioSource();
expect(source).toContain("const setSelectedNode = (nodeId, openInspector = true) => {");
expect(source).toContain("const findCreatedNode = (graph, kind, previousNodeIds) => {");
expect(source).toContain("const focusCreatedNode = async (nodeId) => {");
expect(source).toContain("const previousNodeIds = new Set(flowNodes.value.map((node) => node.id));");
expect(source).toContain("const createdNode = findCreatedNode(graph, kind, previousNodeIds);");
expect(source).toContain("await focusCreatedNode(createdNode.id);");
expect(source).toContain('activePanel.value = "inspector";');
expect(source).toContain("flowNodes.value = flowNodes.value.map((node) => {");
expect(source).toContain("nodes: [nodeId]");
});
});
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio managed inspector", () => {
const studioSource = () =>
readSource("src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue");
it("routes non-graph self-serve nodes to managed inspector panels", () => {
const source = studioSource();
expect(source).toContain(
'const managedInspectorNodeKinds = ["runtime_checkpoint", "vehicle_type", "machine_type", "edge_gateway", "relay_binding", "relay"];'
);
expect(source).toContain('v-else-if="isSelectedManaged"');
expect(source).toContain('data-testid="studio-managed-inspector"');
expect(source).toContain('data-testid="studio-runtime-inspector"');
expect(source).toContain('data-testid="studio-gateway-inspector"');
expect(source).toContain('data-testid="studio-binding-inspector"');
expect(source).toContain('data-testid="studio-relay-inspector"');
});
it("persists vehicle and machine type configuration through their owning endpoints", () => {
const source = studioSource();
expect(source).toContain("const normalizeVehicleTypeForm = (raw = {}) => {");
expect(source).toContain("const saveVehicleTypeProduct = async () => {");
expect(source).toContain('await requestPut("/products", {');
expect(source).toContain('data-testid="studio-vehicle-type-form"');
expect(source).toContain('data-testid="studio-vehicle-type-name"');
expect(source).toContain("const normalizeMachineTypeForm = (raw = {}) => {");
expect(source).toContain("const saveMachineType = async () => {");
expect(source).toContain('await requestPut("/department/selfserve/machine-types", {');
});
it("manages virtual hardware from selected gateway and relay binding nodes", () => {
const source = studioSource();
expect(source).toContain("const saveSelectedVirtualGateway = () => {");
expect(source).toContain('return applyVirtualHardwareOperation("upsert_gateway", {');
expect(source).toContain("const saveSelectedVirtualBinding = () => {");
expect(source).toContain('return applyVirtualHardwareOperation("upsert_binding", payload);');
expect(source).toContain("const deleteSelectedVirtualBinding = () => deleteVirtualBinding({");
expect(source).toContain("const selectedGatewayBindingEntries = computed(() => {");
expect(source).toContain("const selectedRelayBindingEntries = computed(() => {");
});
});