Extend Self-Serve Studio with vehicle type management, mutation options, and enhanced simulation/debug tools. Update OpenAPI, UI components, and related E2E/unit tests.
This commit is contained in:
+130
-1
@@ -4644,6 +4644,22 @@ paths:
|
||||
customer_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_type:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Optional product/vehicle type override used when refreshing the self-serve summary.
|
||||
vehicle_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the answer mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully added vehicle condition
|
||||
@@ -4688,6 +4704,22 @@ paths:
|
||||
customer_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
vehicle_type:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Optional product/vehicle type override used when refreshing the self-serve summary.
|
||||
vehicle_type_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Alias for vehicle_type.
|
||||
activate_machine:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the session synchronization may enable the machine relay.
|
||||
sync_relay_state:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Whether the mutation should synchronize live relay state.
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully updated vehicle condition
|
||||
@@ -5274,13 +5306,34 @@ paths:
|
||||
reg: { type: string }
|
||||
customer_number: { type: integer, nullable: true }
|
||||
vehicle_type_id: { type: integer, nullable: true }
|
||||
config_source:
|
||||
type: string
|
||||
enum: [draft, published]
|
||||
default: draft
|
||||
answer_overrides:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [question_id]
|
||||
properties:
|
||||
question_id: { type: integer }
|
||||
value:
|
||||
type: boolean
|
||||
nullable: true
|
||||
include_hardware:
|
||||
type: boolean
|
||||
default: true
|
||||
mode:
|
||||
type: string
|
||||
enum: [full_dry_run]
|
||||
default: full_dry_run
|
||||
responses:
|
||||
'200':
|
||||
description: Simulator result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SelfserveVehicleAllowedResponse'
|
||||
$ref: '#/components/schemas/SelfserveStudioSimulationResponse'
|
||||
|
||||
/department/selfserve/studio/publish:
|
||||
post:
|
||||
@@ -15129,6 +15182,75 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
SelfserveStudioSimulationDebug:
|
||||
type: object
|
||||
required: [summary, parameters, stages, questions, conditions, rules, tasks, hardware, graph_annotations, recommendations]
|
||||
properties:
|
||||
summary:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
parameters:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
stages:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
questions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
conditions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
rules:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
tasks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
hardware:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
graph_annotations:
|
||||
type: object
|
||||
properties:
|
||||
nodes:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
edges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
recommendations:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
SelfserveStudioSimulationResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SelfserveVehicleAllowedResponse'
|
||||
- type: object
|
||||
properties:
|
||||
simulator_version: { type: integer }
|
||||
dry_run: { type: boolean, enum: [true] }
|
||||
mode: { type: string, enum: [full_dry_run] }
|
||||
config_source: { type: string, enum: [draft, published] }
|
||||
debug:
|
||||
$ref: '#/components/schemas/SelfserveStudioSimulationDebug'
|
||||
|
||||
SelfserveVehicleAllowedResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -15172,6 +15294,13 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SelfserveWashSession'
|
||||
nullable: true
|
||||
config_source:
|
||||
type: string
|
||||
nullable: true
|
||||
evaluation_trace:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
|
||||
SelfserveWashSummary:
|
||||
type: object
|
||||
|
||||
@@ -126,14 +126,15 @@ export const SelfServeVehicleConditions = {
|
||||
}
|
||||
}
|
||||
},
|
||||
add: async (department, lane, customer_id, reg, question, value) => {
|
||||
add: async (department, lane, customer_id, reg, question, value, options = {}) => {
|
||||
return ObjectsGlobal.add.object(SelfServeVehicleConditions.meta.endpoint, {
|
||||
department: parseInt(department),
|
||||
lane: parseInt(lane),
|
||||
customer_id: parseInt(customer_id),
|
||||
reg: reg,
|
||||
question: parseInt(question),
|
||||
value: value === "true" || value === true
|
||||
value: value === "true" || value === true,
|
||||
...options
|
||||
});
|
||||
},
|
||||
set: {
|
||||
|
||||
@@ -612,13 +612,25 @@ export function useSelfServeLogic() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const normalizedReg = reg.trim().toUpperCase();
|
||||
const requestVehicleTypeCandidate = vehicleTypeId === undefined || vehicleTypeId === null
|
||||
? (lastVehicleTypeOverride.value ?? lastResolvedVehicleTypeId.value)
|
||||
: vehicleTypeId;
|
||||
const normalizedRequestVehicleTypeId = parseInt(requestVehicleTypeCandidate);
|
||||
const mutationOptions = {
|
||||
activate_machine: false,
|
||||
sync_relay_state: false,
|
||||
};
|
||||
if (!Number.isNaN(normalizedRequestVehicleTypeId) && normalizedRequestVehicleTypeId > 0) {
|
||||
mutationOptions.vehicle_type = normalizedRequestVehicleTypeId;
|
||||
}
|
||||
const response = await SessionUser.objects.self_serve_vehicle_conditions.add(
|
||||
departmentId,
|
||||
laneId,
|
||||
customerNumber || 0,
|
||||
normalizedReg,
|
||||
questionId,
|
||||
value
|
||||
value,
|
||||
mutationOptions
|
||||
);
|
||||
|
||||
const payload = response?.data?.data || response?.data || response || {};
|
||||
@@ -629,9 +641,7 @@ export function useSelfServeLogic() {
|
||||
[parseInt(questionId)]: value,
|
||||
};
|
||||
|
||||
const refreshVehicleTypeCandidate = vehicleTypeId === undefined || vehicleTypeId === null
|
||||
? (lastVehicleTypeOverride.value ?? lastResolvedVehicleTypeId.value)
|
||||
: vehicleTypeId;
|
||||
const refreshVehicleTypeCandidate = requestVehicleTypeCandidate;
|
||||
const normalizedRefreshVehicleTypeId = parseInt(refreshVehicleTypeCandidate);
|
||||
const refreshVehicleTypeId = !Number.isNaN(normalizedRefreshVehicleTypeId) && normalizedRefreshVehicleTypeId > 0
|
||||
? normalizedRefreshVehicleTypeId
|
||||
|
||||
@@ -206,7 +206,13 @@ export function useWashSessionActions(options) {
|
||||
|
||||
isStartingWash.value = true;
|
||||
try {
|
||||
await updateLaneAllowedServices(laneId);
|
||||
try {
|
||||
await updateLaneAllowedServices(laneId);
|
||||
} catch (error) {
|
||||
console.error("Error updating allowed services before start:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const startResponse = await executeSelfServeCommand(laneId, "START", {
|
||||
customer_number: parseInt(customerNumber),
|
||||
license_plate: licensePlate.trim().toUpperCase(),
|
||||
@@ -229,7 +235,11 @@ export function useWashSessionActions(options) {
|
||||
});
|
||||
|
||||
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
|
||||
await enableMachineRelay(laneId);
|
||||
try {
|
||||
await enableMachineRelay(laneId);
|
||||
} catch (error) {
|
||||
console.error("Error enabling machine relay after wash start:", error);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
+540
-5
@@ -137,8 +137,12 @@ const simulatorForm = ref({
|
||||
reg: "TEST123",
|
||||
customer_number: null,
|
||||
vehicle_type_id: null,
|
||||
config_source: "draft",
|
||||
include_hardware: true,
|
||||
});
|
||||
const simulatorResult = ref(null);
|
||||
const simulatorAnswerOverrides = ref({});
|
||||
const simulatorShowJson = ref(false);
|
||||
const searchTerm = ref("");
|
||||
const filters = ref({
|
||||
lane_id: "",
|
||||
@@ -169,6 +173,30 @@ const permissions = computed(() => graphPayload.value?.permissions || {});
|
||||
const selectedNode = computed(() => flowNodes.value.find((node) => node.id === selectedNodeId.value) || null);
|
||||
const editableNodeKinds = ["question", "condition", "rule", "task"];
|
||||
const isSelectedEditable = computed(() => editableNodeKinds.includes(selectedNode.value?.data?.kind));
|
||||
const simulatorDebug = computed(() => simulatorResult.value?.debug || null);
|
||||
const simulatorSummary = computed(() => simulatorDebug.value?.summary || null);
|
||||
const simulatorRecommendations = computed(() => Array.isArray(simulatorDebug.value?.recommendations) ? simulatorDebug.value.recommendations : []);
|
||||
const simulatorStages = computed(() => Array.isArray(simulatorDebug.value?.stages) ? simulatorDebug.value.stages : []);
|
||||
const simulatorQuestions = computed(() => {
|
||||
if (Array.isArray(simulatorDebug.value?.questions) && simulatorDebug.value.questions.length > 0) {
|
||||
return simulatorDebug.value.questions;
|
||||
}
|
||||
return flowNodes.value
|
||||
.filter((node) => node.data?.kind === "question")
|
||||
.map((node) => ({
|
||||
id: node.data?.object_id || node.data?.raw?.id,
|
||||
node_id: node.id,
|
||||
label: node.data?.label || node.data?.raw?.question || node.id,
|
||||
state: "available",
|
||||
answer: null,
|
||||
answer_source: "missing",
|
||||
}))
|
||||
.filter((question) => question.id);
|
||||
});
|
||||
const simulatorConditions = computed(() => Array.isArray(simulatorDebug.value?.conditions) ? simulatorDebug.value.conditions : []);
|
||||
const simulatorRules = computed(() => Array.isArray(simulatorDebug.value?.rules) ? simulatorDebug.value.rules : []);
|
||||
const simulatorTasks = computed(() => Array.isArray(simulatorDebug.value?.tasks) ? simulatorDebug.value.tasks : []);
|
||||
const simulatorHardware = computed(() => simulatorDebug.value?.hardware || null);
|
||||
|
||||
const lookupRows = (type) => (Array.isArray(lookups.value?.[type]) ? lookups.value[type] : []);
|
||||
const labelFor = (type, id) => {
|
||||
@@ -436,8 +464,26 @@ const computeAutoLayoutPositions = (nodes, options = {}) => {
|
||||
return positions;
|
||||
};
|
||||
|
||||
const PRESENTATION_NODE_CLASSES = new Set(["studio-node-filtered", "studio-node-selected"]);
|
||||
const PRESENTATION_EDGE_CLASSES = new Set(["studio-edge-filtered"]);
|
||||
const SIMULATION_STATE_CLASSES = {
|
||||
active: "studio-node-sim-active",
|
||||
visited: "studio-node-sim-visited",
|
||||
blocked: "studio-node-sim-blocked",
|
||||
warning: "studio-node-sim-warning",
|
||||
error: "studio-node-sim-error",
|
||||
next: "studio-node-sim-next",
|
||||
not_applicable: "studio-node-sim-muted",
|
||||
};
|
||||
const SIMULATION_EDGE_CLASSES = {
|
||||
active: "studio-edge-sim-active",
|
||||
visited: "studio-edge-sim-visited",
|
||||
blocked: "studio-edge-sim-blocked",
|
||||
warning: "studio-edge-sim-warning",
|
||||
error: "studio-edge-sim-error",
|
||||
next: "studio-edge-sim-next",
|
||||
not_applicable: "studio-edge-sim-muted",
|
||||
};
|
||||
const PRESENTATION_NODE_CLASSES = new Set(["studio-node-filtered", "studio-node-selected", ...Object.values(SIMULATION_STATE_CLASSES)]);
|
||||
const PRESENTATION_EDGE_CLASSES = new Set(["studio-edge-filtered", ...Object.values(SIMULATION_EDGE_CLASSES)]);
|
||||
const baseNodeClass = (className) => String(className || "")
|
||||
.split(/\s+/)
|
||||
.filter((entry) => entry && !PRESENTATION_NODE_CLASSES.has(entry))
|
||||
@@ -447,6 +493,16 @@ const baseEdgeClass = (className) => String(className || "")
|
||||
.filter((entry) => entry && !PRESENTATION_EDGE_CLASSES.has(entry))
|
||||
.join(" ");
|
||||
|
||||
const simulationNodeClass = (nodeId) => {
|
||||
const state = simulatorDebug.value?.graph_annotations?.nodes?.[nodeId]?.state;
|
||||
return SIMULATION_STATE_CLASSES[state] || "";
|
||||
};
|
||||
|
||||
const simulationEdgeClass = (edgeId) => {
|
||||
const state = simulatorDebug.value?.graph_annotations?.edges?.[edgeId]?.state;
|
||||
return SIMULATION_EDGE_CLASSES[state] || "";
|
||||
};
|
||||
|
||||
const visibleCanvasNodeIds = computed(() => new Set(flowNodes.value.filter(matchesNodeFilters).map((node) => node.id)));
|
||||
const hasActiveCanvasFilters = computed(() => Boolean(
|
||||
searchTerm.value.trim()
|
||||
@@ -495,6 +551,7 @@ const applyCanvasFilters = () => {
|
||||
class: [
|
||||
baseNodeClass(node.class),
|
||||
visibleIds.has(node.id) ? "" : "studio-node-filtered",
|
||||
simulationNodeClass(node.id),
|
||||
].filter(Boolean).join(" "),
|
||||
}));
|
||||
flowEdges.value = flowEdges.value.map((edge) => ({
|
||||
@@ -502,6 +559,7 @@ const applyCanvasFilters = () => {
|
||||
class: [
|
||||
baseEdgeClass(edge.class),
|
||||
visibleIds.has(edge.source) && visibleIds.has(edge.target) ? "" : "studio-edge-filtered",
|
||||
simulationEdgeClass(edge.id),
|
||||
].filter(Boolean).join(" "),
|
||||
}));
|
||||
nextTick(() => {
|
||||
@@ -735,7 +793,11 @@ const hydrateFromGraph = (payload) => {
|
||||
reg: payload?.simulator_defaults?.reg || "TEST123",
|
||||
customer_number: payload?.simulator_defaults?.customer_number || null,
|
||||
vehicle_type_id: payload?.simulator_defaults?.vehicle_type_id || null,
|
||||
config_source: simulatorForm.value?.config_source || "draft",
|
||||
include_hardware: simulatorForm.value?.include_hardware !== false,
|
||||
};
|
||||
simulatorResult.value = null;
|
||||
simulatorAnswerOverrides.value = {};
|
||||
|
||||
suppressLayoutSave.value = true;
|
||||
unfilteredNodePositions.value = null;
|
||||
@@ -883,6 +945,26 @@ const selectNode = (nodeId) => {
|
||||
activePanel.value = "inspector";
|
||||
};
|
||||
|
||||
const focusDebugNodes = async (nodeIds = [], openInspector = false) => {
|
||||
const ids = [...new Set((Array.isArray(nodeIds) ? nodeIds : []).filter(Boolean))];
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
selectedNodeId.value = ids[0];
|
||||
if (openInspector) {
|
||||
activePanel.value = "inspector";
|
||||
}
|
||||
await nextTick();
|
||||
fitView({
|
||||
nodes: ids,
|
||||
includeHiddenNodes: false,
|
||||
minZoom: 0.62,
|
||||
maxZoom: 1.05,
|
||||
padding: 0.2,
|
||||
duration: 240,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const refreshInspectorForm = () => {
|
||||
const raw = selectedRaw.value || {};
|
||||
inspectorForm.value = JSON.parse(JSON.stringify(raw));
|
||||
@@ -976,18 +1058,42 @@ const runGatewayAction = async (gatewayId, action, confirm = false) => {
|
||||
|
||||
const runSimulator = async () => {
|
||||
try {
|
||||
const answerOverrides = Object.entries(simulatorAnswerOverrides.value)
|
||||
.filter(([, value]) => value === true || value === false || value === null)
|
||||
.map(([questionId, value]) => ({ question_id: parseIntOrZero(questionId), value }));
|
||||
simulatorResult.value = await requestPost("/department/selfserve/studio/simulate", {
|
||||
department: departmentId.value,
|
||||
lane_id: parseIntOrZero(simulatorForm.value.lane_id),
|
||||
reg: simulatorForm.value.reg,
|
||||
customer_number: parseNullableInt(simulatorForm.value.customer_number),
|
||||
vehicle_type_id: parseNullableInt(simulatorForm.value.vehicle_type_id),
|
||||
config_source: simulatorForm.value.config_source || "draft",
|
||||
include_hardware: simulatorForm.value.include_hardware !== false,
|
||||
mode: "full_dry_run",
|
||||
answer_overrides: answerOverrides,
|
||||
});
|
||||
applyCanvasFilters();
|
||||
} catch (error) {
|
||||
withErrorToast(error, "Simulation failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const setSimulatorAnswerOverride = (questionId, value) => {
|
||||
simulatorAnswerOverrides.value = {
|
||||
...simulatorAnswerOverrides.value,
|
||||
[questionId]: value,
|
||||
};
|
||||
};
|
||||
|
||||
const clearSimulatorAnswerOverrides = () => {
|
||||
simulatorAnswerOverrides.value = {};
|
||||
};
|
||||
|
||||
const simulatorAnswerButtonClass = (questionId, value) => ({
|
||||
"is-primary": simulatorAnswerOverrides.value?.[questionId] === value,
|
||||
"is-light": simulatorAnswerOverrides.value?.[questionId] !== value,
|
||||
});
|
||||
|
||||
const reorderTask = async (taskNode, direction) => {
|
||||
const ordered = taskNodes.value
|
||||
.map((node) => node.data.raw)
|
||||
@@ -1799,8 +1905,15 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="activePanel === 'simulator'">
|
||||
<header class="studio-details-header"><h3>Simulator</h3></header>
|
||||
<form class="studio-form" @submit.prevent="runSimulator">
|
||||
<header class="studio-details-header">
|
||||
<h3>Simulator</h3>
|
||||
<span v-if="simulatorSummary" class="studio-sim-status" :class="`is-${simulatorSummary.status}`">{{ simulatorSummary.title }}</span>
|
||||
</header>
|
||||
<form class="studio-form studio-simulator-form" @submit.prevent="runSimulator">
|
||||
<div class="studio-segmented-control" role="group" aria-label="Configuration source">
|
||||
<button type="button" class="button is-small" :class="{ 'is-primary': simulatorForm.config_source === 'draft', 'is-light': simulatorForm.config_source !== 'draft' }" @click="simulatorForm.config_source = 'draft'">Draft</button>
|
||||
<button type="button" class="button is-small" :class="{ 'is-primary': simulatorForm.config_source === 'published', 'is-light': simulatorForm.config_source !== 'published' }" @click="simulatorForm.config_source = 'published'">Published</button>
|
||||
</div>
|
||||
<label>Lane
|
||||
<select v-model="simulatorForm.lane_id" class="input">
|
||||
<option v-for="lane in lookupRows('lanes')" :key="lane.id" :value="lane.id">{{ lane.label }}</option>
|
||||
@@ -1814,12 +1927,114 @@ onBeforeUnmount(() => {
|
||||
</label>
|
||||
<label>Registration<input v-model="simulatorForm.reg" class="input"></label>
|
||||
<label>Customer number<input v-model="simulatorForm.customer_number" class="input" type="number"></label>
|
||||
<label class="checkbox studio-inline-toggle">
|
||||
<input v-model="simulatorForm.include_hardware" type="checkbox">
|
||||
<span>Include hardware</span>
|
||||
</label>
|
||||
<button class="button is-primary" type="submit">
|
||||
<span class="icon"><i class="fas fa-play"></i></span>
|
||||
<span>Run</span>
|
||||
</button>
|
||||
</form>
|
||||
<pre v-if="simulatorResult" class="studio-json">{{ formatJson(simulatorResult) }}</pre>
|
||||
|
||||
<section class="studio-debug-section">
|
||||
<header class="studio-debug-section-header">
|
||||
<h4>Question Overrides</h4>
|
||||
<button class="button is-small is-light" type="button" @click="clearSimulatorAnswerOverrides">Clear</button>
|
||||
</header>
|
||||
<div class="studio-simulator-question-list">
|
||||
<article v-for="question in simulatorQuestions" :key="question.id" class="studio-simulator-question" :class="`is-${question.state || 'available'}`" @click="focusDebugNodes([question.node_id], false)">
|
||||
<div>
|
||||
<strong>{{ question.label }}</strong>
|
||||
<small>{{ question.state || 'available' }} · {{ question.answer_source || 'missing' }}</small>
|
||||
</div>
|
||||
<div class="buttons has-addons">
|
||||
<button type="button" class="button is-small" :class="simulatorAnswerButtonClass(question.id, true)" @click.stop="setSimulatorAnswerOverride(question.id, true)">Yes</button>
|
||||
<button type="button" class="button is-small" :class="simulatorAnswerButtonClass(question.id, false)" @click.stop="setSimulatorAnswerOverride(question.id, false)">No</button>
|
||||
<button type="button" class="button is-small" :class="simulatorAnswerButtonClass(question.id, null)" @click.stop="setSimulatorAnswerOverride(question.id, null)">Unset</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-if="simulatorDebug">
|
||||
<section class="studio-debug-summary" :class="`is-${simulatorSummary?.status || 'blocked'}`">
|
||||
<div>
|
||||
<strong>{{ simulatorSummary?.title }}</strong>
|
||||
<p>{{ simulatorSummary?.next_action }}</p>
|
||||
</div>
|
||||
<button v-if="simulatorSummary?.primary_blocker?.node_ids?.length" class="button is-small is-light" type="button" @click="focusDebugNodes(simulatorSummary.primary_blocker.node_ids, true)">
|
||||
<span class="icon"><i class="fas fa-crosshairs"></i></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="studio-debug-section">
|
||||
<header class="studio-debug-section-header"><h4>Guided Trace</h4></header>
|
||||
<div class="studio-debug-timeline">
|
||||
<button v-for="stage in simulatorStages" :key="stage.id" type="button" class="studio-debug-stage" :class="`is-${stage.status}`" @click="focusDebugNodes(stage.node_ids, false)">
|
||||
<span class="studio-debug-dot"></span>
|
||||
<strong>{{ stage.title }}</strong>
|
||||
<small>{{ stage.summary }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="studio-debug-section">
|
||||
<header class="studio-debug-section-header"><h4>Next Fixes</h4></header>
|
||||
<button v-for="recommendation in simulatorRecommendations" :key="`${recommendation.title}-${recommendation.message}`" type="button" class="studio-debug-recommendation" :class="`is-${recommendation.severity}`" @click="focusDebugNodes(recommendation.node_ids, true)">
|
||||
<strong>{{ recommendation.title }}</strong>
|
||||
<span>{{ recommendation.message }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="studio-debug-section">
|
||||
<header class="studio-debug-section-header"><h4>Conditions and Rules</h4></header>
|
||||
<div class="studio-debug-compact-list">
|
||||
<button v-for="condition in simulatorConditions" :key="condition.id" type="button" :class="condition.result ? 'is-ok' : 'is-blocked'" @click="focusDebugNodes([condition.node_id], true)">
|
||||
<span><i class="fas fa-code-branch"></i> {{ condition.label }}</span>
|
||||
<strong>{{ condition.result ? 'Pass' : 'Fail' }}</strong>
|
||||
</button>
|
||||
<button v-for="rule in simulatorRules" :key="`rule-${rule.id}`" type="button" :class="rule.satisfied ? 'is-ok' : 'is-blocked'" @click="focusDebugNodes([rule.node_id], true)">
|
||||
<span><i class="fas fa-gavel"></i> {{ rule.label }}</span>
|
||||
<strong>{{ rule.satisfied ? 'Pass' : 'Fail' }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="studio-debug-section">
|
||||
<header class="studio-debug-section-header"><h4>Tasks and Services</h4></header>
|
||||
<div class="studio-debug-task-list">
|
||||
<button v-for="task in simulatorTasks" :key="task.id" type="button" class="studio-debug-task" :class="task.active ? 'is-active' : 'is-blocked'" @click="focusDebugNodes([task.node_id], true)">
|
||||
<strong>{{ task.label }}</strong>
|
||||
<span>{{ task.gate_type }} · {{ task.gate_ref_label }}</span>
|
||||
<small>{{ (task.services || []).join(', ') || 'No services' }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="studio-debug-section">
|
||||
<header class="studio-debug-section-header"><h4>Gateway Readiness</h4></header>
|
||||
<div class="studio-debug-hardware">
|
||||
<p>{{ simulatorHardware?.summary }}</p>
|
||||
<div class="studio-debug-chip-row">
|
||||
<span v-for="service in simulatorHardware?.allowed_services || []" :key="service">{{ service }}</span>
|
||||
<span v-if="(simulatorHardware?.missing_service_bindings || []).length" class="is-warning">Missing {{ simulatorHardware.missing_service_bindings.join(', ') }}</span>
|
||||
<span :class="simulatorHardware?.machine_relay_configured ? 'is-ok' : 'is-warning'">{{ simulatorHardware?.machine_relay_configured ? 'Lane relay configured' : 'Lane relay missing' }}</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="operation in simulatorHardware?.dry_run_operations || []" :key="operation.operation">{{ operation.message }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="studio-debug-section">
|
||||
<button class="button is-small is-light" type="button" @click="simulatorShowJson = !simulatorShowJson">
|
||||
<span class="icon"><i class="fas fa-code"></i></span>
|
||||
<span>{{ simulatorShowJson ? 'Hide JSON' : 'Show JSON' }}</span>
|
||||
</button>
|
||||
<pre v-if="simulatorShowJson" class="studio-json">{{ formatJson(simulatorResult) }}</pre>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activePanel === 'versions'">
|
||||
@@ -2349,6 +2564,58 @@ onBeforeUnmount(() => {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
:deep(.studio-node-sim-active .studio-flow-node) {
|
||||
border-color: #16a34a;
|
||||
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.18), 0 12px 28px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
:deep(.studio-node-sim-visited .studio-flow-node) {
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.16), 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
:deep(.studio-node-sim-warning .studio-flow-node),
|
||||
:deep(.studio-node-sim-next .studio-flow-node) {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.2), 0 12px 28px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
:deep(.studio-node-sim-blocked .studio-flow-node),
|
||||
:deep(.studio-node-sim-error .studio-flow-node) {
|
||||
border-color: #dc2626;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.18), 0 12px 28px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
:deep(.studio-node-sim-muted .studio-flow-node) {
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
:deep(.studio-edge-sim-active path) {
|
||||
stroke: #16a34a !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
|
||||
:deep(.studio-edge-sim-visited path) {
|
||||
stroke: #0284c7 !important;
|
||||
stroke-width: 2.6px !important;
|
||||
}
|
||||
|
||||
:deep(.studio-edge-sim-warning path),
|
||||
:deep(.studio-edge-sim-next path) {
|
||||
stroke: #f59e0b !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
|
||||
:deep(.studio-edge-sim-blocked path),
|
||||
:deep(.studio-edge-sim-error path) {
|
||||
stroke: #dc2626 !important;
|
||||
stroke-width: 3px !important;
|
||||
}
|
||||
|
||||
:deep(.studio-edge-sim-muted path) {
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.studio-loading {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
@@ -2544,6 +2811,263 @@ onBeforeUnmount(() => {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.studio-sim-status {
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.studio-sim-status.is-allowed,
|
||||
.studio-sim-status.is-success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.studio-sim-status.is-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.studio-sim-status.is-blocked {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.studio-simulator-form {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.studio-segmented-control {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.studio-inline-toggle {
|
||||
align-items: center !important;
|
||||
flex-direction: row !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.studio-debug-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.studio-debug-section-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.studio-debug-section-header h4 {
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.studio-simulator-question-list,
|
||||
.studio-debug-task-list,
|
||||
.studio-debug-compact-list,
|
||||
.studio-debug-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.studio-simulator-question,
|
||||
.studio-debug-summary,
|
||||
.studio-debug-recommendation,
|
||||
.studio-debug-task,
|
||||
.studio-debug-compact-list button,
|
||||
.studio-debug-stage,
|
||||
.studio-debug-hardware {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
color: #1f2937;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.studio-simulator-question {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.studio-simulator-question strong,
|
||||
.studio-debug-recommendation strong,
|
||||
.studio-debug-task strong,
|
||||
.studio-debug-stage strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.studio-simulator-question small,
|
||||
.studio-debug-task span,
|
||||
.studio-debug-task small,
|
||||
.studio-debug-stage small,
|
||||
.studio-debug-recommendation span {
|
||||
color: #64748b;
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.studio-simulator-question .buttons {
|
||||
flex-wrap: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.studio-simulator-question .button {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.studio-debug-summary {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.studio-debug-summary p,
|
||||
.studio-debug-hardware p {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.studio-debug-summary.is-allowed {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.studio-debug-summary.is-warning {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.studio-debug-summary.is-blocked {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.studio-debug-stage {
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 3px 8px;
|
||||
grid-template-columns: 12px minmax(0, 1fr);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.studio-debug-stage small {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.studio-debug-dot {
|
||||
background: #94a3b8;
|
||||
border-radius: 999px;
|
||||
height: 9px;
|
||||
margin-top: 4px;
|
||||
width: 9px;
|
||||
}
|
||||
|
||||
.studio-debug-stage.is-ok .studio-debug-dot,
|
||||
.studio-debug-compact-list .is-ok strong {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.studio-debug-stage.is-ok .studio-debug-dot {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.studio-debug-stage.is-warning .studio-debug-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.studio-debug-stage.is-error .studio-debug-dot {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.studio-debug-recommendation,
|
||||
.studio-debug-task,
|
||||
.studio-debug-compact-list button {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.studio-debug-recommendation.is-error,
|
||||
.studio-debug-task.is-blocked,
|
||||
.studio-debug-compact-list .is-blocked {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.studio-debug-recommendation.is-warning {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.studio-debug-recommendation.is-success,
|
||||
.studio-debug-task.is-active,
|
||||
.studio-debug-compact-list .is-ok {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.studio-debug-compact-list button {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.studio-debug-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.studio-debug-chip-row span {
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 999px;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.studio-debug-chip-row span.is-ok {
|
||||
background: #dcfce7;
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.studio-debug-chip-row span.is-warning {
|
||||
background: #fef3c7;
|
||||
border-color: #fde68a;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.studio-debug-hardware ul {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
margin: 6px 0 0 16px;
|
||||
}
|
||||
|
||||
.studio-task-row,
|
||||
.studio-hardware-item,
|
||||
.studio-version-list article {
|
||||
@@ -2748,5 +3272,16 @@ onBeforeUnmount(() => {
|
||||
.studio-flow-surface {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.studio-simulator-question,
|
||||
.studio-debug-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.studio-simulator-question .buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -545,6 +545,7 @@ const submitQuestionAnswer = async (questionId: number, value: boolean) => {
|
||||
reg: licensePlateInput.value,
|
||||
questionId,
|
||||
value,
|
||||
vehicleTypeId: vehicleTypeSelect.value || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error synchronizing self-serve answer:", error);
|
||||
|
||||
@@ -448,6 +448,149 @@ function buildStudioGraph() {
|
||||
};
|
||||
}
|
||||
|
||||
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"] }],
|
||||
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: true,
|
||||
state: "passed",
|
||||
reason: "Condition passed.",
|
||||
},
|
||||
],
|
||||
rules: [{ id: 31, node_id: "rule:31", label: "Mirror answer", satisfied: false, reason: "Rule did not pass." }],
|
||||
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"],
|
||||
reason: "Task gate did not pass.",
|
||||
},
|
||||
],
|
||||
hardware: {
|
||||
machine_relay_configured: false,
|
||||
allowed_services: [],
|
||||
missing_service_bindings: [],
|
||||
summary: "Lane relay missing.",
|
||||
dry_run_operations: [
|
||||
{
|
||||
operation: "machine_relay_enable",
|
||||
message: "Dry run predicts no machine relay action because eligibility is blocked.",
|
||||
},
|
||||
],
|
||||
},
|
||||
graph_annotations: {
|
||||
nodes: {
|
||||
"question:11": { state: "warning", label: "Missing answer" },
|
||||
"task:41": { state: "blocked", label: "Gate blocked" },
|
||||
"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"],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function installStudioRoutes(page, graph, captured) {
|
||||
await page.route(/\/department\/selfserve\/studio\/graph(?:\?.*)?$/i, async (route, request) => {
|
||||
if (request.method() === "GET") {
|
||||
@@ -497,7 +640,7 @@ async function installStudioRoutes(page, graph, captured) {
|
||||
|
||||
await page.route(/\/department\/selfserve\/studio\/simulate(?:\?.*)?$/i, async (route, request) => {
|
||||
captured.simulations.push(request.postDataJSON?.() || {});
|
||||
await route.fulfill(json({ data: { allowed: true, questions: [], tasks: [{ task: "Fold mirrors" }] } }));
|
||||
await route.fulfill(json({ data: buildSimulationResponse() }));
|
||||
});
|
||||
|
||||
await page.route(/\/department\/selfserve\/studio\/publish(?:\?.*)?$/i, async (route) => {
|
||||
@@ -633,7 +776,7 @@ test.describe("All-in-one self-serve studio", () => {
|
||||
await page.getByTestId("studio-filter-vehicle-type").selectOption("");
|
||||
await expect(page.getByText("Are mirrors folded?").first()).toBeVisible();
|
||||
|
||||
await page.getByTestId("studio-custom-node-task").filter({ hasText: "Fold mirrors" }).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");
|
||||
@@ -698,8 +841,23 @@ test.describe("All-in-one self-serve studio", () => {
|
||||
|
||||
await page.getByTestId("studio-panel-simulator").click();
|
||||
await page.getByLabel("Registration").fill("AB12345");
|
||||
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[0]?.reg).toBe("AB12345");
|
||||
await expect.poll(() => captured.simulations[0]?.config_source).toBe("published");
|
||||
await expect.poll(() => captured.simulations[0]?.answer_overrides?.[0]).toEqual({ question_id: 11, value: false });
|
||||
await expect(page.getByText("Guided Trace")).toBeVisible();
|
||||
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();
|
||||
await expect(page.getByText("Gateway Readiness")).toBeVisible();
|
||||
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 page.getByTestId("studio-panel-versions").click();
|
||||
|
||||
@@ -90,6 +90,7 @@ function captureSelfServeGatewayRequests(page) {
|
||||
const captured = {
|
||||
previews: [],
|
||||
summaries: [],
|
||||
answers: [],
|
||||
allowedServices: [],
|
||||
commands: [],
|
||||
};
|
||||
@@ -114,6 +115,8 @@ function captureSelfServeGatewayRequests(page) {
|
||||
captured.previews.push(entry);
|
||||
} else if (url.pathname.endsWith("/department/selfserve/washes/summary")) {
|
||||
captured.summaries.push(entry);
|
||||
} else if (url.pathname.endsWith("/department/selfserve/vehicle/conditions")) {
|
||||
captured.answers.push(entry);
|
||||
} else if (url.pathname.endsWith("/modules/self-serve/lane/services/allowed")) {
|
||||
captured.allowedServices.push(entry);
|
||||
} else if (url.pathname.endsWith("/modules/self-serve/lane/command")) {
|
||||
@@ -387,6 +390,15 @@ test.describe("Self-serve wash", () => {
|
||||
|
||||
await expect(page.getByTestId("self-serve-runtime-error")).toBeHidden();
|
||||
await expect(page.getByTestId("self-serve-action-error")).toBeHidden();
|
||||
expect(requests.answers).toHaveLength(1);
|
||||
expect(requests.answers[0].body).toMatchObject({
|
||||
lane: 7,
|
||||
question: 11,
|
||||
value: true,
|
||||
vehicle_type: 2,
|
||||
activate_machine: false,
|
||||
sync_relay_state: false,
|
||||
});
|
||||
expect(requests.allowedServices).toHaveLength(0);
|
||||
expect(requests.commands).toHaveLength(0);
|
||||
expect(requests.previews.some((entry) => entry.url.searchParams.get("vehicle_type") === "2")).toBe(true);
|
||||
|
||||
@@ -392,6 +392,7 @@ describe("MyWashStart", () => {
|
||||
reg: "AB12345",
|
||||
questionId: 11,
|
||||
value: true,
|
||||
vehicleTypeId: 2,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -518,6 +519,7 @@ describe("MyWashStart", () => {
|
||||
reg: "AB12345",
|
||||
questionId: 11,
|
||||
value: true,
|
||||
vehicleTypeId: 2,
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
@@ -362,7 +362,10 @@ describe("useSelfServeLogic", () => {
|
||||
value: true,
|
||||
});
|
||||
|
||||
expect(mocks.add).toHaveBeenCalledWith(3, 7, 12345, "CD67890", 2, true);
|
||||
expect(mocks.add).toHaveBeenCalledWith(3, 7, 12345, "CD67890", 2, true, {
|
||||
activate_machine: false,
|
||||
sync_relay_state: false,
|
||||
});
|
||||
expect(mocks.previewAllowed).toHaveBeenCalledWith(7, "CD67890");
|
||||
expect(logic.answers.value[2]).toBe(true);
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2]);
|
||||
@@ -411,6 +414,11 @@ describe("useSelfServeLogic", () => {
|
||||
|
||||
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(1, 7, "AB12345", 9);
|
||||
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(2, 7, "AB12345", 9);
|
||||
expect(mocks.add).toHaveBeenCalledWith(1, 7, 12345, "AB12345", 1, true, {
|
||||
activate_machine: false,
|
||||
sync_relay_state: false,
|
||||
vehicle_type: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the resolved auto vehicle type for lane/reg summary fallback", async () => {
|
||||
|
||||
@@ -159,4 +159,66 @@ describe("useWashSessionActions property gate commands", () => {
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not duplicate action alerts when allowed-service setup fails before start", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const washInProgress = ref(false);
|
||||
const washLaneId = ref(null);
|
||||
const washStartTime = ref(null);
|
||||
const currentStep = ref(2);
|
||||
const updateLaneAllowedServices = vi.fn(async () => {
|
||||
throw new Error("Edge gateway command timed out");
|
||||
});
|
||||
|
||||
const { actions, request, alertFn } = createActions({
|
||||
updateLaneAllowedServices,
|
||||
washInProgress,
|
||||
washLaneId,
|
||||
washStartTime,
|
||||
currentStep,
|
||||
});
|
||||
|
||||
const result = await actions.onStartWash(7, "ab12345", 12345679, 4);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(updateLaneAllowedServices).toHaveBeenCalledWith(7);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(alertFn).not.toHaveBeenCalled();
|
||||
expect(washInProgress.value).toBe(false);
|
||||
expect(currentStep.value).toBe(2);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps the wash started when the post-start machine relay retry fails", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const washInProgress = ref(false);
|
||||
const washLaneId = ref(null);
|
||||
const washStartTime = ref(null);
|
||||
const currentStep = ref(2);
|
||||
const enableMachineRelay = vi.fn(async () => {
|
||||
throw new Error("Edge gateway command timed out");
|
||||
});
|
||||
|
||||
const { actions, alertFn } = createActions({
|
||||
washInProgress,
|
||||
washLaneId,
|
||||
washStartTime,
|
||||
currentStep,
|
||||
radioWashType: ref("Machine"),
|
||||
enableMachineRelay,
|
||||
isServiceAllowed: vi.fn(() => true),
|
||||
});
|
||||
|
||||
const result = await actions.onStartWash(7, "ab12345", 12345679, 4);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(washInProgress.value).toBe(true);
|
||||
expect(washLaneId.value).toBe(7);
|
||||
expect(currentStep.value).toBe(4);
|
||||
expect(enableMachineRelay).toHaveBeenCalledWith(7);
|
||||
expect(alertFn).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user