Add audit issue navigation and task-button normalization to Self-Serve Studio. Implement node ID resolution, audit visualization logic, and reusable task button helpers. Extend E2E and unit tests.

This commit is contained in:
Jeppe Bundgaard
2026-04-29 09:51:09 +02:00
parent a658fe7863
commit de2fa57c63
14 changed files with 3008 additions and 339 deletions
@@ -8,6 +8,10 @@ const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = use
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { ref, watch } from "vue";
import SelfServeTaskAttachmentsModal from "@/components/displays/department/tables/SelfServeTaskAttachmentsModal.vue";
import {
SELF_SERVE_TASK_BUTTON_OPTIONS,
normalizeSelfServeTaskButtons,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
// Define the props
const props = defineProps({
@@ -36,24 +40,15 @@ const closeAttachmentsModal = () => {
const editButtonsFunction = (id, column, value, onAfterSubmit) => {
editingButtonsId.value = id;
try {
const parsed = JSON.stringify(value);
if (Array.isArray(parsed)) {
currentButtonsSelection.value = parsed;
} else {
console.warn('Parsed buttons value is not an array:', parsed);
currentButtonsSelection.value = [];
}
} catch (e) {
console.warn('Error parsing buttons value:', e);
currentButtonsSelection.value = [];
}
currentButtonsSelection.value = normalizeSelfServeTaskButtons(value);
showButtonsEditModal.value = true;
};
const saveButtonsEdit = async () => {
console.warn('Saving buttons with selection:', currentButtonsSelection.value);
await SessionUser.objects.self_serve_tasks.set.buttons(editingButtonsId.value, currentButtonsSelection.value);
await SessionUser.objects.self_serve_tasks.set.buttons(
editingButtonsId.value,
normalizeSelfServeTaskButtons(currentButtonsSelection.value)
);
showButtonsEditModal.value = false;
loadList();
};
@@ -237,15 +232,15 @@ const getScopeLabel = (object) => {
<button class="delete" @click="cancelButtonsEdit" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div v-for="n in 12" :key="n" class="field mb-3">
<div v-for="option in SELF_SERVE_TASK_BUTTON_OPTIONS" :key="String(option.id)" class="field mb-3">
<input
class="is-checkradio"
type="checkbox"
:id="`btn-${n-1}`"
:id="`btn-${option.id}`"
v-model="currentButtonsSelection"
:value="n-1"
:value="option.id"
>
<label :for="`btn-${n-1}`">Button {{ n-1 }}</label>
<label :for="`btn-${option.id}`">{{ option.name }}</label>
</div>
</section>
<footer class="modal-card-foot">
@@ -7,6 +7,7 @@ import { useSelfServeLogic } from "@/composables/useSelfServeLogic";
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
import { API_URL } from "@/config";
import { normalizeSelfServeTaskButtons } from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
const { t: $t } = useI18n();
@@ -145,22 +146,15 @@ const parseNonNegativeInt = (value) => {
};
const dynamicImageButtons = computed(() => {
const buttonIds = new Set();
const buttonIds = new Map();
activeTasks.value.forEach((task) => {
if (!Array.isArray(task?.buttons)) {
return;
}
task.buttons.forEach((button) => {
const buttonId = parseInt(button);
if (!Number.isNaN(buttonId) && buttonId >= 0 && buttonId <= 11) {
buttonIds.add(buttonId);
}
normalizeSelfServeTaskButtons(task?.buttons).forEach((button) => {
buttonIds.set(`${typeof button}:${button}`, button);
});
});
return [...buttonIds];
return [...buttonIds.values()];
});
const dynamicImageThumbPosition = computed(() => {
@@ -4,6 +4,11 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { ref } from "vue";
import { showSelfServeTaskForm } from "@/components/session/token/SessionUser/Objects/selfServeObjectForms.js";
import {
SELF_SERVE_TASK_BUTTON_OPTIONS,
formatSelfServeTaskButtons,
normalizeSelfServeTaskButtons,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
/**
* Local tasks cache
@@ -80,7 +85,7 @@ const normalizePayload = (payload = {}) => {
description: payload.description,
order_priority: parseIntOrZero(payload.order_priority),
services: Array.isArray(payload.services) ? payload.services : [],
buttons: Array.isArray(payload.buttons) ? payload.buttons.map((button) => parseIntOrZero(button)) : [],
buttons: normalizeSelfServeTaskButtons(payload.buttons),
dynamic_images_vehicle_type: parseNullablePositiveInt(payload.dynamic_images_vehicle_type),
};
};
@@ -250,17 +255,13 @@ export const SelfServeTasks = {
},
buttons: {
label: "Knapper",
type: "multi-select-int",
type: "multi-select",
sortable: false,
creation: {
required: false
},
parse: (buttons) => {
if (!buttons || !Array.isArray(buttons) || buttons.length === 0) {
return "Ingen";
}
return buttons.join(", ");
}
options: async () => SELF_SERVE_TASK_BUTTON_OPTIONS,
parse: (buttons) => formatSelfServeTaskButtons(buttons)
},
dynamic_images_vehicle_type: {
label: "Køretøjstype (maskine UI)",
@@ -306,7 +307,7 @@ export const SelfServeTasks = {
lane: (id, lane) => ObjectsGlobal.set.column(SelfServeTasks.meta.endpoint, id, "lane", parseInt(lane || 0)),
department: (id, department) => ObjectsGlobal.set.column(SelfServeTasks.meta.endpoint, id, "department", parseInt(department || 0)),
services: (id, services) => ObjectsGlobal.set.column(SelfServeTasks.meta.endpoint, id, "services", Array.isArray(services) ? services : []),
buttons: (id, buttons) => ObjectsGlobal.set.column(SelfServeTasks.meta.endpoint, id, "buttons", Array.isArray(buttons) ? buttons.map(b => parseInt(b)) : []),
buttons: (id, buttons) => ObjectsGlobal.set.column(SelfServeTasks.meta.endpoint, id, "buttons", normalizeSelfServeTaskButtons(buttons)),
dynamic_images_vehicle_type: (id, vehicleType) => ObjectsGlobal.set.column(SelfServeTasks.meta.endpoint, id, "dynamic_images_vehicle_type", vehicleType ? parseInt(vehicleType) : null),
},
get: {
@@ -1,6 +1,10 @@
import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
SELF_SERVE_TASK_BUTTON_OPTIONS,
normalizeSelfServeTaskButtons,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
const escapeHtml = (value = "") => String(value)
.replaceAll("&", "&amp;")
@@ -334,7 +338,7 @@ export const showSelfServeTaskForm = async ({ object, record = null, initialData
], record?.services ?? initialData.services ?? [])),
wrapField("buttons", "Knapper", buildCheckboxList(
"buttons",
Array.from({ length: 12 }, (_, index) => ({ id: index, name: `Button ${index}` })),
SELF_SERVE_TASK_BUTTON_OPTIONS,
(record?.buttons ?? initialData.buttons ?? []).map(String)
)),
wrapField("dynamic_images_vehicle_type", "Køretøjstype (maskine UI)", buildSelect(
@@ -390,7 +394,7 @@ export const showSelfServeTaskForm = async ({ object, record = null, initialData
gate_type: selectedConditionId ? "CONDITION" : "ALWAYS",
gate_ref_id: selectedConditionId,
services: getCheckedValues("services"),
buttons: getCheckedValues("buttons").map((value) => parseIntOrZero(value)),
buttons: normalizeSelfServeTaskButtons(getCheckedValues("buttons")),
dynamic_images_vehicle_type: parseNullableInt(getFieldValue("dynamic_images_vehicle_type")),
};
@@ -0,0 +1,117 @@
export const SELF_SERVE_TASK_BUTTON_RESET = "reset";
export const SELF_SERVE_TASK_BUTTON_START = "start";
export const SELF_SERVE_TASK_BUTTON_OPTIONS = [
{
id: SELF_SERVE_TASK_BUTTON_RESET,
name: "Reset",
description: "Reset button",
},
...Array.from({ length: 12 }, (_, index) => ({
id: index,
name: `Program ${index + 1}`,
description: `Machine button ${index}`,
})),
{
id: SELF_SERVE_TASK_BUTTON_START,
name: "Start",
description: "Start button",
},
];
const KNOWN_SPECIAL_BUTTONS = new Set([
SELF_SERVE_TASK_BUTTON_RESET,
SELF_SERVE_TASK_BUTTON_START,
]);
const BUTTON_LABELS = new Map(
SELF_SERVE_TASK_BUTTON_OPTIONS.map((option) => [String(option.id), option.name])
);
const normalizeListInput = (value) => {
if (Array.isArray(value)) {
return value;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return [];
}
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return normalizeListInput(parsed);
}
} catch {
// Comma-separated values are accepted for quick manual edits.
}
return trimmed.split(",").map((entry) => entry.trim()).filter(Boolean);
}
return [];
};
export const normalizeSelfServeTaskButton = (value) => {
if (value === null || value === undefined) {
return null;
}
const text = String(value).trim();
if (!text) {
return null;
}
const lowerText = text.toLowerCase();
if (KNOWN_SPECIAL_BUTTONS.has(lowerText)) {
return lowerText;
}
const parsed = Number(text);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 11) {
return null;
}
return parsed;
};
export const normalizeSelfServeTaskButtons = (value) => {
const normalized = [];
const seen = new Set();
for (const entry of normalizeListInput(value)) {
const button = normalizeSelfServeTaskButton(entry);
if (button === null) {
continue;
}
const key = `${typeof button}:${button}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push(button);
}
return normalized;
};
export const formatSelfServeTaskButton = (button) => {
const normalized = normalizeSelfServeTaskButton(button);
if (normalized === null) {
return null;
}
return BUTTON_LABELS.get(String(normalized)) || `Button ${normalized}`;
};
export const formatSelfServeTaskButtons = (buttons, emptyLabel = "Ingen") => {
const labels = normalizeSelfServeTaskButtons(buttons)
.map(formatSelfServeTaskButton)
.filter(Boolean);
return labels.length > 0 ? labels.join(", ") : emptyLabel;
};
@@ -0,0 +1,101 @@
const NODE_KIND_PREFIXES = {
condition: "condition",
question: "question",
task: "task",
rule: "rule",
lane: "lane",
relay: "relay",
gateway: "gateway",
edge_gateway: "gateway",
machine_type: "machine_type",
vehicle_type: "vehicle_type",
};
const normalizeId = (value) => {
const normalized = String(value ?? "").trim();
return normalized && normalized !== "0" ? normalized : "";
};
const normalizeKind = (value) => String(value ?? "")
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
const availableNodeIdSet = (nodes) => {
if (!Array.isArray(nodes)) {
return null;
}
return new Set(nodes
.map((node) => (typeof node === "string" ? node : node?.id))
.filter(Boolean)
.map(String));
};
const pushUnique = (nodeIds, nodeId, availableIds) => {
const normalizedNodeId = normalizeId(nodeId);
if (!normalizedNodeId) {
return;
}
if (availableIds && !availableIds.has(normalizedNodeId)) {
return;
}
if (!nodeIds.includes(normalizedNodeId)) {
nodeIds.push(normalizedNodeId);
}
};
const pushEntityNode = (nodeIds, kind, id, availableIds) => {
const prefix = NODE_KIND_PREFIXES[normalizeKind(kind)];
const normalizedId = normalizeId(id);
if (!prefix || !normalizedId) {
return;
}
pushUnique(nodeIds, `${prefix}:${normalizedId}`, availableIds);
};
const collectStructuredTargets = (item, nodeIds, availableIds) => {
for (const nodeId of [item?.node_id, item?.target_node_id]) {
pushUnique(nodeIds, nodeId, availableIds);
}
for (const list of [item?.node_ids, item?.target_node_ids]) {
if (!Array.isArray(list)) {
continue;
}
for (const nodeId of list) {
pushUnique(nodeIds, nodeId, availableIds);
}
}
pushEntityNode(nodeIds, item?.object_type || item?.entity || item?.kind, item?.object_id || item?.id, availableIds);
};
const collectMessageTargets = (message, nodeIds, availableIds) => {
const text = String(message || "");
const conditionCycle = text.match(/Condition cycle detected:\s*([0-9\s>,.-]+)/);
if (conditionCycle) {
for (const id of conditionCycle[1].match(/\d+/g) || []) {
pushEntityNode(nodeIds, "condition", id, availableIds);
}
}
for (const match of text.matchAll(/\b(Condition|Question|Task|Rule|Lane|Relay)\s+#?(\d+)\b/g)) {
pushEntityNode(nodeIds, match[1], match[2], availableIds);
}
for (const match of text.matchAll(/\b(Machine type|Vehicle type)\s+#?(\d+)\b/g)) {
pushEntityNode(nodeIds, match[1], match[2], availableIds);
}
for (const match of text.matchAll(/\brelay IDs?:\s*([^.;]+)/gi)) {
for (const relayId of match[1].split(",")) {
pushEntityNode(nodeIds, "relay", relayId.trim(), availableIds);
}
}
};
export const resolveAuditIssueNodeIds = (item, nodes = null) => {
const nodeIds = [];
const availableIds = availableNodeIdSet(nodes);
collectStructuredTargets(item || {}, nodeIds, availableIds);
collectMessageTargets(item?.message, nodeIds, availableIds);
return nodeIds;
};
@@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test";
import { resolveAuditIssueNodeIds } from "../../src/views/dashboards/departmentDashboard/modules/self-serve/auditIssueNavigation.js";
test.describe("self-serve studio audit navigation", () => {
const graphNodes = [{ id: "condition:51" }, { id: "condition:52" }, { id: "question:12" }, { id: "relay:relay-a" }];
test("resolves clickable graph targets for audit issues", () => {
expect(
resolveAuditIssueNodeIds(
{
message: "Condition 51 cannot reference itself in an expression.",
},
graphNodes
)
).toEqual(["condition:51"]);
expect(
resolveAuditIssueNodeIds(
{
message: "Condition cycle detected: 51 -> 52 -> 51",
},
graphNodes
)
).toEqual(["condition:51", "condition:52"]);
expect(
resolveAuditIssueNodeIds(
{
message: "Virtual coverage only for relay IDs: relay-a.",
},
graphNodes
)
).toEqual(["relay:relay-a"]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveAuditIssueNodeIds } from "@/views/dashboards/departmentDashboard/modules/self-serve/auditIssueNavigation.js";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio audit navigation", () => {
const graphNodes = [
{ id: "condition:51" },
{ id: "condition:52" },
{ id: "question:12" },
{ id: "task:9" },
{ id: "relay:relay-a" },
];
it("resolves condition audit issues to graph node ids", () => {
expect(
resolveAuditIssueNodeIds(
{
message: "Condition 51 cannot reference itself in an expression.",
},
graphNodes
)
).toEqual(["condition:51"]);
});
it("resolves all condition nodes in a cycle audit issue", () => {
expect(
resolveAuditIssueNodeIds(
{
message: "Condition cycle detected: 51 -> 52 -> 51",
},
graphNodes
)
).toEqual(["condition:51", "condition:52"]);
});
it("uses structured node ids and filters missing graph nodes", () => {
expect(
resolveAuditIssueNodeIds(
{
message: "Server annotated issue",
node_ids: ["question:12", "question:999"],
},
graphNodes
)
).toEqual(["question:12"]);
});
it("resolves relay ids from virtual hardware warnings", () => {
expect(
resolveAuditIssueNodeIds(
{
message: "Virtual coverage only for relay IDs: relay-a.",
},
graphNodes
)
).toEqual(["relay:relay-a"]);
});
it("wires audit rows to the graph focus helper", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
expect(studioSource).toContain('import { resolveAuditIssueNodeIds } from "./auditIssueNavigation.js";');
expect(studioSource).toContain("target_node_ids: resolveAuditIssueNodeIds(item, flowNodes.value)");
expect(studioSource).toContain('@click="selectValidationItem(item)"');
expect(studioSource).toContain("focusDebugNodes(targetNodeIds, true, { clearFilters: true })");
});
});
+66 -106
View File
@@ -4,130 +4,90 @@ import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio task scope", () => {
it("renders task selectors for lane and vehicle", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
describe("self-serve studio task editing", () => {
const studioSource = () =>
readSource("src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue");
expect(studioSource).toContain('v-model="taskForm.lane"');
expect(studioSource).toContain('v-model="taskForm.product"');
expect(studioSource).toContain("Select lane");
expect(studioSource).toContain("Select vehicle type");
expect(studioSource).toContain('v-for="entry in lanes"');
it("creates tasks with scoped runtime fields and dynamic image defaults", () => {
const source = studioSource();
expect(source).toContain("const createNode = async (kind) => {");
expect(source).toContain('task: "New task"');
expect(source).toContain('gate_type: "ALWAYS"');
expect(source).toContain("lane: parseNullableInt(filters.value.lane_id) || 0");
expect(source).toContain("product: parseNullableInt(filters.value.vehicle_type_id) || 0");
expect(source).toContain("machine_type_id: parseNullableInt(filters.value.machine_type_id)");
expect(source).toContain("services: []");
expect(source).toContain("buttons: []");
});
it("keeps department and lane visible for machine-type task scope", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("normalizes task services and mapped buttons before saving inspector edits", () => {
const source = studioSource();
expect(studioSource).toContain('v-if="taskForm.scope_mode === \'machine_type\'" class="field"');
expect(studioSource).toContain('<label class="label">Department</label>');
expect(studioSource).toContain('<select v-model="taskForm.lane">');
expect(studioSource).toContain("<template v-if=\"taskForm.scope_mode === 'legacy'\">");
expect(studioSource).toContain('<label class="label">Vehicle Type</label>');
expect(source).toContain('inspectorForm.value.gate_type ||= "ALWAYS"');
expect(source).toContain("inspectorForm.value.services = normalizeServiceList(inspectorForm.value.services)");
expect(source).toContain("inspectorForm.value.buttons = normalizeButtonList(inspectorForm.value.buttons)");
expect(source).toContain("data.services = normalizeServiceList(data.services)");
expect(source).toContain("data.buttons = normalizeButtonList(data.buttons)");
});
it("uses primary vehicle type product selection for machine scope", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("renders mapped dynamic image task buttons in the inspector", () => {
const source = studioSource();
expect(studioSource).toContain("const machineScopePrimaryVehicleTypeOptions = computed(() => {");
expect(studioSource).toContain("Primary Vehicle Type Product ID");
expect(studioSource).toContain('v-for="entry in machineScopePrimaryVehicleTypeOptions"');
expect(studioSource).toContain("machineScopePrimaryVehicleTypeProductId");
expect(studioSource).toContain('product: taskForm.value.scope_mode === "machine_type"');
expect(source).toContain("SELF_SERVE_TASK_BUTTON_OPTIONS");
expect(source).toContain('data-testid="studio-task-button-picker"');
expect(source).toContain('v-model="inspectorForm.buttons"');
expect(source).toContain("taskButtonIsSelected(inspectorForm.buttons, option.id)");
expect(source).toContain("taskButtonSummary(rawFor(data).buttons)");
});
it("defaults new task modal to legacy scope so department and lane are immediately visible", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("renders dynamic image previews in simulator results", () => {
const source = studioSource();
expect(studioSource).toContain('scope_mode: "legacy"');
expect(studioSource).toContain("machine_type_id: scopedProductId.value || 0");
expect(source).toContain("const simulatorDynamicImageButtons = computed(() => {");
expect(source).toContain("const simulatorDynamicImageUrl = computed(() => {");
expect(source).toContain("buttons: JSON.stringify(simulatorDynamicImageButtons.value)");
expect(source).toContain('data-testid="studio-simulator-dynamic-image"');
expect(source).toContain('@error="onSimulatorDynamicImageError"');
});
it("guards task submission for machine-type and legacy scope requirements", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("renders machine type and dynamic image filters in the studio toolbar", () => {
const source = studioSource();
expect(studioSource).toContain("Task name is required.");
expect(studioSource).toContain("Task description is required.");
expect(studioSource).toContain("Primary vehicle type product id is required for this scope.");
expect(studioSource).toContain("Department is required for Department/Lane/Vehicle scope.");
expect(studioSource).toContain("Lane is required for Department/Lane/Vehicle scope.");
expect(studioSource).toContain("Vehicle type is required for Department/Lane/Vehicle scope.");
expect(studioSource).toContain("if (payload.lane <= 0)");
expect(studioSource).toContain("if (payload.product <= 0)");
expect(source).toContain('dynamic_image_id: ""');
expect(source).toContain('data-testid="studio-filter-machine-type"');
expect(source).toContain('data-testid="studio-filter-dynamic-image"');
expect(source).toContain("lookupRows('dynamic_images')");
expect(source).toContain(
'matchesScopeFilter(node, "machine_type", normalizeFilterValue(filters.value.machine_type_id))'
);
expect(source).toContain(
'matchesScopeFilter(node, "dynamic_image", normalizeFilterValue(filters.value.dynamic_image_id))'
);
expect(source).toContain("laneLookupForNode(node)?.machine_type_id");
expect(source).toContain("laneLookupForNode(node)?.dynamic_image_id");
});
it("requires gate reference for non-ALWAYS gate types", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("manages lanes and previews the selected lane dynamic image", () => {
const source = studioSource();
expect(studioSource).toContain('if (payload.gate_type !== "ALWAYS")');
expect(studioSource).toContain("if (!payload.gate_ref_id)");
expect(studioSource).toContain("Gate reference is required for selected gate type.");
expect(studioSource).toContain(
'payload.condition_id = payload.gate_type === "CONDITION" ? payload.gate_ref_id : null;'
);
expect(source).toContain('{ id: "lanes", label: "Lanes"');
expect(source).toContain("const saveLane = async () => {");
expect(source).toContain('entity: "lane"');
expect(source).toContain("const buildLaneDynamicImagePreviewUrl = (laneId, dynamicImageId) => {");
expect(source).toContain("dynamic_image_id: String(normalizedDynamicImageId)");
expect(source).toContain('data-testid="studio-lane-dynamic-image"');
expect(source).toContain('data-testid="studio-lane-dynamic-image-preview"');
expect(source).toContain('data-testid="studio-inspector-lane-dynamic-image-preview"');
});
it("includes all task payload parameters in saveTask submission", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("supports task priority reordering from the task panel", () => {
const source = studioSource();
expect(studioSource).toContain("const payload = {");
expect(studioSource).toContain('task: String(taskForm.value.task || "").trim()');
expect(studioSource).toContain('description: String(taskForm.value.description || "").trim()');
expect(studioSource).toContain("order_priority: parseIntOrZero(taskForm.value.order_priority)");
expect(studioSource).toContain("services: normalizeTaskServices(taskForm.value.services)");
expect(studioSource).toContain("buttons: normalizeTaskButtons(taskForm.value.buttons)");
expect(studioSource).toContain(
"dynamic_images_vehicle_type: parseNullableInt(taskForm.value.dynamic_images_vehicle_type)"
);
expect(studioSource).toContain("gate_type: gateType");
expect(studioSource).toContain("gate_ref_id: null");
expect(studioSource).toContain("condition_id: null");
expect(studioSource).toContain('machine_type_id: taskForm.value.scope_mode === "machine_type"');
expect(studioSource).toContain(
"const resolvedDepartmentId = parseIntOrZero(taskForm.value.department || departmentId.value);"
);
expect(studioSource).toContain("const resolvedLaneId = parseIntOrZero(taskForm.value.lane);");
expect(studioSource).toContain("department: resolvedDepartmentId");
expect(studioSource).toContain("lane: resolvedLaneId");
expect(studioSource).toContain('product: taskForm.value.scope_mode === "machine_type"');
});
it("renders task controls for order priority and dynamic images vehicle type", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
expect(studioSource).toContain('<label class="label">Order Priority</label>');
expect(studioSource).toContain('v-model="taskForm.order_priority"');
expect(studioSource).toContain('<label class="label">Dynamic Images Thumb Position</label>');
expect(studioSource).toContain('v-model="taskForm.dynamic_images_vehicle_type"');
});
it("supports drag-and-drop reordering for task order priority", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
expect(studioSource).toContain("const onTaskDrop = async (event, newIndex) => {");
expect(studioSource).toContain("SessionUser.objects.self_serve_tasks.set.order_priority(entry.id, nextPriority)");
expect(studioSource).toContain('toast.success("Task order updated.");');
expect(studioSource).toContain('@dragover="onTaskDragOver($event, index)"');
expect(studioSource).toContain('@drop="onTaskDrop($event, index)"');
expect(studioSource).toContain('@dragstart="onTaskDragStart($event, index)"');
expect(studioSource).toContain(':draggable="!isReorderingTasks"');
expect(studioSource).toContain("fa-grip-lines");
expect(source).toContain("const reorderTask = async (taskNode, direction) => {");
expect(source).toContain("order_priority: index + 1");
expect(source).toContain('action: "reorder"');
expect(source).toContain('@click="reorderTask(node, -1)"');
expect(source).toContain('@click="reorderTask(node, 1)"');
});
});
@@ -5,47 +5,49 @@ import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio vehicle type scope", () => {
it("adds a vehicle-type select bound to product query scope", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
const studioSource = () =>
readSource("src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue");
expect(studioSource).toContain("const scopedProductId = computed(() => parseScopedProduct(route.query.product));");
expect(studioSource).toContain("const scopedProductModel = computed({");
expect(studioSource).toContain("delete nextQuery.product;");
expect(studioSource).toContain('data-testid="self-serve-studio-vehicle-type-select"');
expect(studioSource).toContain('<option :value="null">All vehicle types</option>');
it("renders a vehicle type toolbar filter bound to product scope", () => {
const source = studioSource();
expect(source).toContain('vehicle_type_id: ""');
expect(source).toContain('data-testid="studio-filter-vehicle-type"');
expect(source).toContain('<option value="">All vehicle types</option>');
expect(source).toContain(':value="vehicleType.product || vehicleType.id"');
});
it("applies product scope filtering and defaults for new records", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("applies vehicle type scope filtering and defaults for new records", () => {
const source = studioSource();
expect(studioSource).toContain(
"const scopedQuestions = computed(() => questions.value.filter((entry) => matchesScopedProduct(entry)));"
expect(source).toContain(
'matchesScopeFilter(node, "vehicle_type", normalizeFilterValue(filters.value.vehicle_type_id))'
);
expect(studioSource).toContain(
"const scopedConditions = computed(() => conditions.value.filter((entry) => matchesScopedProduct(entry)));"
expect(source).toContain("const isSelectedScopeAnchorNode = (node) => {");
expect(source).toContain('["lane", "vehicle_type"].includes(kind)');
expect(source).toContain("if (isSelectedScopeAnchorNode(node)) {");
expect(source).toContain(
'const conditionNodes = computed(() => filteredListNodes.value.filter((node) => node.data?.kind === "condition"));'
);
expect(studioSource).toContain(
"const scopedTasks = computed(() => tasks.value.filter((entry) => matchesScopedProduct(entry)));"
expect(source).toContain(
'const taskNodes = computed(() => filteredListNodes.value.filter((node) => node.data?.kind === "task"));'
);
expect(studioSource).toContain("const machineTypeId = parseNullableInt(item.machine_type_id);");
expect(studioSource).toContain("return machineTypeId === scopedProductId.value;");
expect(studioSource).not.toContain(
"if (parseNullableInt(item.machine_type_id)) {\n return true;\n }\n const product = parseIntOrZero(item.product);"
);
expect(studioSource).toContain("product: scopedProductId.value || 0,");
expect(source).toContain("product: parseNullableInt(filters.value.vehicle_type_id) || 0");
expect(source).toContain('return kind === "vehicle_type"');
expect(source).toContain(": firstFilterValue(raw.product, raw.product_id, raw.vehicle_type_id);");
});
it("sorts scoped question and task source lists by order priority", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("uses toolbar lane and vehicle type filters as the simulator scope", () => {
const source = studioSource();
expect(studioSource).toContain("const compareByOrderPriority = (left, right) => {");
expect(studioSource).toContain(".sort(compareByOrderPriority);");
expect(studioSource).toContain("questions.value = [...questions.value].sort(compareByOrderPriority);");
expect(source).toContain("const selectedSimulatorLaneId = computed(() => firstPositiveInt(");
expect(source).toContain("filters.value.lane_id");
expect(source).toContain("const selectedSimulatorVehicleTypeId = computed(() => (");
expect(source).toContain("vehicleTypeMatchesFilter(vehicleType, selectedValue)");
expect(source).toContain("lane_id: laneId");
expect(source).toContain("vehicle_type_id: selectedSimulatorVehicleTypeId.value");
expect(source).toContain('data-testid="studio-simulator-scope"');
expect(source).not.toContain('v-model="simulatorForm.lane_id"');
expect(source).not.toContain('v-model="simulatorForm.vehicle_type_id"');
});
});
@@ -20,6 +20,7 @@ describe("self-serve tasks payload contract", () => {
const tasksSource = readSource("src/components/session/token/SessionUser/Objects/SelfServeTasks.vue");
expect(tasksSource).toContain("const normalizePayload = (payload = {}) => {");
expect(tasksSource).toContain("normalizeSelfServeTaskButtons(payload.buttons)");
expect(tasksSource).toContain("condition_id: normalizedGateRefId");
expect(tasksSource).toContain("gate_type: normalizedGateType");
expect(tasksSource).toContain("gate_ref_id: normalizedGateRefId");
@@ -27,4 +28,14 @@ describe("self-serve tasks payload contract", () => {
"dynamic_images_vehicle_type: parseNullablePositiveInt(payload.dynamic_images_vehicle_type)"
);
});
it("maps reset/start task buttons through shared checkboxes", () => {
const formSource = readSource("src/components/session/token/SessionUser/Objects/selfServeObjectForms.js");
const helperSource = readSource("src/components/session/token/SessionUser/Objects/selfServeTaskButtons.js");
expect(formSource).toContain("SELF_SERVE_TASK_BUTTON_OPTIONS");
expect(formSource).toContain('normalizeSelfServeTaskButtons(getCheckedValues("buttons"))');
expect(helperSource).toContain('SELF_SERVE_TASK_BUTTON_RESET = "reset"');
expect(helperSource).toContain('SELF_SERVE_TASK_BUTTON_START = "start"');
});
});
+2 -2
View File
@@ -191,7 +191,7 @@ describe("SelfServeTryModal", () => {
mocks.activeTasks.value = [
{
id: 1,
buttons: [0, 2, 2, -1, 99],
buttons: ["reset", 0, 2, 2, "start", -1, 99],
dynamic_images_vehicle_type: 5,
},
];
@@ -221,7 +221,7 @@ describe("SelfServeTryModal", () => {
expect(imageUrl.searchParams.get("current_step")).toBe("0");
expect(imageUrl.searchParams.get("vehicle_type")).toBe("3");
expect(imageUrl.searchParams.get("thumb_position")).toBe("5");
expect(JSON.parse(imageUrl.searchParams.get("buttons"))).toEqual([0, 2]);
expect(JSON.parse(imageUrl.searchParams.get("buttons"))).toEqual(["reset", 0, 2, "start"]);
});
it("does not render dynamic image preview without dynamic-image context", async () => {