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:
Jeppe Bundgaard
2026-04-28 16:54:27 +02:00
parent 8615cc6678
commit 11bb8f824d
11 changed files with 945 additions and 17 deletions
+130 -1
View File
@@ -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: {
+14 -4
View File
@@ -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
+12 -2
View File
@@ -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) {
@@ -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);
+160 -2
View File
@@ -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();
+12
View File
@@ -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);
+2
View File
@@ -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();
+9 -1
View File
@@ -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();
});
});