Compare commits

...
Author SHA1 Message Date
TRU-83 bugfix sub-agent 12fe4745d7 fix(self-serve): restrict Dognvask machine to single vehicle type (traekker) (TRU-84)
The Dognvask machine is a single-vehicle-type wash machine. When the
current lane resolves to a Dognvask machine type, the path editor and
scope guide now:

- restrict the vehicle type filter to the 'traekker' (tractor unit) entry
  in both the main toolbar and the scope guide
- show a visible 'Dognvask: trækker only' hint badge so operators can
  see why the options are limited
- auto-correct the vehicle type filter when the user switches to or from
  a Dognvask-configured lane (clearing the filter if no traekker type
  is configured, otherwise snapping to the only allowed option)

Detection compares the resolved selfserve_machine_types row name against
'dognvask' (case-insensitive). The allowed vehicle type is matched by
label/name against 'traekker' / 'tractor' / 'tractor unit' to tolerate
the various Danish/English renderings used across the system.

This follows the same pattern introduced in TRU-91 (path editor machine
filter), which scoped machine-button options to the current lane's
machine type.
2026-08-17 18:30:52 +00:00
openhands c11b164188 fix(TRU-89): customer portal - 'tilladelser' (permissions) tab
Adds a new permissions tab in the kundeportal that exposes the two
primary role templates (driver / booking_coordinator) and the five
permission groups (vehicles, selfserve, bookings, orders, driver
management) so the customer can pick a default driver access template
for new drivers created via the QR flow (TRU-88 / TRU-90).
2026-08-17 18:01:47 +00:00
openhands 882bc64fa6 fix(path-editor): filter machine options to only show available machine (TRU-91)
The path editor previously rendered all 12 program buttons and all 12 program
wheel positions regardless of whether the current lane had a configured,
non-deleted machine type. This made it possible to author paths for programs
that the machine does not actually support.

Filter pathEditorMachineButtonOptions and pathEditorProgramButtonOptions
based on whether the current lane resolves to an available selfserve_machine_types
row. When the machine is not available, the machine button grid is empty and
the program wheel only exposes the OFF position.
2026-08-17 16:17:04 +00:00
8 changed files with 628 additions and 16 deletions
@@ -60,6 +60,16 @@ const menu_items = computed(() => [
'user'
]
},
{
name: t('user_menu.my_permissions'),
route: '/user/permissions',
icon: 'fas fa-user-shield',
children: [],
permissions: [
'user',
'SUBUSERS_LIST'
]
},
]);
</script>
@@ -53,6 +53,30 @@
"menu": {
"statistics": "@:{'phrases.compat.global.statistics'}"
},
"permissions": {
"card_chauffor_label": "Chauffør (standard)",
"card_chauffor_description": "Kan bruge døgnvask, oprette bookinger, se køretøjer og ordrer.",
"card_booking_label": "Bookingkoodinator",
"card_booking_description": "Kan oprette og redigere bookinger samt se køretøjer og ordrer.",
"card_intro": "Vælg den rolle, som nye chauffører som standard skal have, når de oprettes via QR-kode.",
"groups": {
"vehicles": "Køretøjer",
"selfserve": "Døgnvask",
"bookings": "Bookinger",
"orders": "Ordrer",
"driver_management": "Chaufførstyring"
},
"loading": "Henter tilladelser...",
"save": "Gem standardrolle",
"saved": "Standardrollen er gemt.",
"save_error": "Standardrollen kunne ikke gemmes.",
"selected_template": "Valgt rolle: {label}",
"subtitle": "Vælg standardtilladelser for nye chauffører.",
"toggle_booking_coordinator": "Bookingkoodinator",
"toggle_chauffor": "Chauffør",
"toggle_description": "De 5 kategorier nedenfor viser, hvad rollen giver adgang til.",
"title": "Mine tilladelser"
},
"orders": {
"single_subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.detaljer'} @:{'terms.glossary.om'} transaktionen",
"subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}",
@@ -3,6 +3,7 @@
"user_menu": {
"my_bookings": "@:{'terms.glossary.mine'} @:{'terms.glossary.bookinger'}",
"my_invoices": "@:{'terms.glossary.mine'} @:{'terms.glossary.fakturaer'}",
"my_permissions": "@:{'terms.glossary.mine'} @:{'terms.glossary.tilladelser'}",
"my_vehicles": "@:{'terms.glossary.mine'} @:{'terms.glossary.køretøjer'}",
"my_washes": "@:{'terms.glossary.mine'} @:{'terms.glossary.vaske'}"
}
+7
View File
@@ -146,6 +146,7 @@ const DepartmentTimeBookingsTypes = lazyView('@/views/dashboards/departmentDashb
const DepartmentModulesSetup = lazyView('@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue');
const Vehicle = lazyView('@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue');
const MyInvoices = lazyView('@/views/dashboards/userDashboard/invoices/MyInvoices.vue');
const MyPermissions = lazyView('@/views/dashboards/userDashboard/permissions/MyPermissions.vue');
const MicrosoftCallbackToken = lazyView('@/views/callback/microsoft/MicrosoftCallbackToken.vue');
const ConfigurationEntra = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue');
const DepartmentTimeBookingsNew = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue');
@@ -369,6 +370,12 @@ export const router = createRouter({
component: MyInvoices,
meta: { middleware: authMiddleware }
},
{
name: 'mypermissions',
path: '/user/permissions',
component: MyPermissions,
meta: { middleware: authMiddleware }
},
{
name: 'myorder',
path: '/user/orders/:orderId',
@@ -1264,14 +1264,19 @@ const createPathEditorQuestion = async () => {
await loadPathOutcomes({ force: true });
};
const pathEditorProgramButtonOptions = computed(() => [
{ id: 0, value: "0", label: "0 = OFF" },
...Array.from({ length: 12 }, (_, index) => ({
id: index + 1,
value: String(index + 1),
label: `Wheel position #${index + 1}`,
})),
]);
const pathEditorProgramButtonOptions = computed(() => {
if (!pathEditorMachineIsAvailable.value) {
return [{ id: 0, value: "0", label: "0 = OFF" }];
}
return [
{ id: 0, value: "0", label: "0 = OFF" },
...Array.from({ length: 12 }, (_, index) => ({
id: index + 1,
value: String(index + 1),
label: `Wheel position #${index + 1}`,
})),
];
});
@@ -1392,16 +1397,92 @@ const pathVerificationSelectedCaseHasRun = computed(() =>
)
);
const pathEditorMachineButtonOptions = computed(() =>
Array.from({ length: 12 }, (_, index) => {
// TRU-91: Filter path editor machine options to only show enabled/available
// machine programs for the current scope. A machine is considered "available"
// when the current lane has a machine_type_id that resolves to a non-deleted
// selfserve_machine_types row in the lookups.
const pathEditorCurrentLaneRow = computed(() => {
const laneId = parseIntOrZero(selectedSimulatorLaneId.value);
if (laneId <= 0) {
return null;
}
return laneRowById(laneId);
});
const pathEditorCurrentMachineType = computed(() => {
const lane = pathEditorCurrentLaneRow.value;
if (!lane) {
return null;
}
const machineTypeId = parseNullableInt(lane.machine_type_id);
if (!machineTypeId) {
return null;
}
const machineTypeRow = lookupRowById("machine_types", machineTypeId);
if (!machineTypeRow) {
return null;
}
if (
machineTypeRow.deleted_at !== null &&
machineTypeRow.deleted_at !== undefined &&
machineTypeRow.deleted_at !== ""
) {
return null;
}
return machineTypeRow;
});
const pathEditorMachineIsAvailable = computed(() => Boolean(pathEditorCurrentMachineType.value));
// TRU-84: Dognvask machine is a single-vehicle-type machine. When the current
// lane is configured to use the Dognvask machine, the vehicle type toolbar and
// the path editor are restricted to the "traekker" (tractor unit) vehicle type.
const pathEditorCurrentMachineTypeName = computed(() => {
const machineType = pathEditorCurrentMachineType.value;
if (!machineType) {
return "";
}
return String(machineType.name || machineType.label || "").trim().toLowerCase();
});
const pathEditorIsDognvaskMachine = computed(
() => pathEditorCurrentMachineTypeName.value === "dognvask"
);
const isTraekkerVehicleType = (vehicleType) => {
if (!vehicleType) {
return false;
}
const candidate = String(
vehicleType.label || vehicleType.name || vehicleType.vehicleTypeId || ""
)
.trim()
.toLowerCase();
return candidate === "traekker" || candidate === "tractor" || candidate === "tractor unit";
};
const pathEditorAllowedVehicleTypes = computed(() => {
const rows = lookupRows("vehicle_types");
if (!pathEditorIsDognvaskMachine.value) {
return rows;
}
const traekker = rows.filter((row) => isTraekkerVehicleType(row));
return traekker;
});
const pathEditorMachineButtonOptions = computed(() => {
if (!pathEditorMachineIsAvailable.value) {
return [];
}
return Array.from({ length: 12 }, (_, index) => {
const option = taskButtonOptions.find((entry) => entry.id === index);
return {
id: index,
label: option?.description || `Machine button ${index}`,
description: option?.name || `Program ${index + 1}`,
};
})
);
});
});
const pathEditorTaskTitleKeySlug = (key) => String(key || "").replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -5432,6 +5513,35 @@ watch(
scheduleSimulatorRun("scope", 150);
}
);
// TRU-84: Dognvask machines are single-vehicle-type machines. When the
// selected lane resolves to a Dognvask machine, force the vehicle type filter
// to the traekker option and keep it in sync when the user switches lanes.
watch(
() => [filters.value.lane_id, pathEditorIsDognvaskMachine.value, pathEditorAllowedVehicleTypes.value],
() => {
if (!pathEditorIsDognvaskMachine.value) {
return;
}
const allowed = pathEditorAllowedVehicleTypes.value;
if (allowed.length === 0) {
if (filters.value.vehicle_type_id !== "") {
filters.value = { ...filters.value, vehicle_type_id: "" };
}
return;
}
const allowedIds = new Set(
allowed.map((row) => String(vehicleTypeFilterValue(row)))
);
const current = String(filters.value.vehicle_type_id ?? "");
if (!allowedIds.has(current)) {
filters.value = {
...filters.value,
vehicle_type_id: String(vehicleTypeFilterValue(allowed[0])),
};
}
},
{ immediate: true }
);
watch(simulatorDynamicImageUrl, () => {
simulatorDynamicImageHidden.value = false;
simulatorDynamicImageError.value = "";
@@ -5637,10 +5747,19 @@ onBeforeUnmount(() => {
<section class="studio-scope-guide-column">
<span class="studio-scope-guide-step">2</span>
<h3>Vehicle type</h3>
<h3>
Vehicle type
<span
v-if="pathEditorIsDognvaskMachine"
class="tag is-info is-light is-small studio-scope-guide-constraint"
data-testid="studio-scope-guide-dognvask-hint"
>
Dognvask: trækker only
</span>
</h3>
<div class="studio-scope-option-list">
<button
v-for="vehicleType in lookupRows('vehicle_types')"
v-for="vehicleType in pathEditorAllowedVehicleTypes"
:key="vehicleType.id"
type="button"
class="studio-scope-option"
@@ -5657,7 +5776,7 @@ onBeforeUnmount(() => {
<small>#{{ vehicleTypeFilterValue(vehicleType) }}</small>
</span>
</button>
<p v-if="lookupRows('vehicle_types').length === 0" class="studio-empty-inline">
<p v-if="pathEditorAllowedVehicleTypes.length === 0" class="studio-empty-inline">
No vehicle types are configured.
</p>
</div>
@@ -7149,13 +7268,20 @@ onBeforeUnmount(() => {
<select v-model="filters.vehicle_type_id" data-testid="studio-filter-vehicle-type">
<option value="" disabled>Select vehicle type</option>
<option
v-for="vehicleType in lookupRows('vehicle_types')"
v-for="vehicleType in pathEditorAllowedVehicleTypes"
:key="vehicleType.id"
:value="vehicleTypeFilterValue(vehicleType)"
>
{{ vehicleType.label }}
</option>
</select>
<span
v-if="pathEditorIsDognvaskMachine"
class="tag is-info is-light is-small studio-scope-dognvask-hint"
data-testid="studio-filter-dognvask-hint"
>
Dognvask: trækker only
</span>
</div>
<div class="select is-small">
<select v-model="filters.machine_type_id" data-testid="studio-filter-machine-type">
@@ -0,0 +1,330 @@
<script setup>
import { onMounted, ref, computed } from "vue";
import Swal from "sweetalert2";
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
/**
* Permission groups (5 toggles) that mirror the existing 5 capability groups
* in modules/subusers/classes/subuser_permission_templates_service.php.
* They cover the 5 main areas a customer wants to grant their drivers:
* - vehicles (Køretøjer)
* - selfserve (Døgnvask)
* - bookings (Bookinger)
* - orders (Ordrer)
* - driver_management (Chaufførstyring)
*/
const PERMISSION_GROUPS = [
{ key: "vehicles", capabilities: ["VEHICLES_LIST", "VEHICLES_EDIT", "VEHICLES_DELETE", "VEHICLES_ADD"] },
{ key: "selfserve", capabilities: ["SELFSERVE_LIST", "SELFSERVE_EDIT", "SELFSERVE_DELETE", "SELFSERVE_ADD"] },
{ key: "bookings", capabilities: ["BOOKINGS_LIST", "BOOKINGS_EDIT", "BOOKINGS_DELETE", "BOOKINGS_ADD"] },
{ key: "orders", capabilities: ["ORDERS_LIST", "ORDERS_EDIT"] },
{ key: "driver_management", capabilities: ["SUBUSERS_LIST", "SUBUSERS_EDIT", "SUBUSERS_DELETE", "SUBUSERS_ADD"] },
];
/**
* The two primary roles (templates) shown to the customer in the kunde portal
* "tilladelser" tab. The customer picks one as the default for new drivers
* created through the QR flow (see TRU-88 / TRU-90).
*
* - chauffeur (driver) : standard self-serve + bookings
* - booking_coordinator : extended bookings/orders (no self-serve)
*/
const PRIMARY_ROLE_KEYS = ["driver", "booking_coordinator"];
const isLoading = ref(true);
const isSaving = ref(false);
const accessModel = ref({ templates: [], groups: [] });
const selectedTemplateKey = ref(null);
const selectedTemplate = computed(() => {
if (!selectedTemplateKey.value) {
return null;
}
return accessModel.value.templates.find((template) => template.key === selectedTemplateKey.value) || null;
});
const isGroupActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
return groups.some((group) => group.key === groupKey);
};
const isGroupFullyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
const group = groups.find((g) => g.key === groupKey);
if (!group) {
return false;
}
// "Fully active" means all 4 capabilities in the group are granted
const groupDefinition = PERMISSION_GROUPS.find((g) => g.key === groupKey);
if (!groupDefinition) {
return false;
}
return groupDefinition.capabilities.every((capability) => group.capabilities.includes(capability));
};
const isGroupPartiallyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
return isGroupActive(groupKey) && !isGroupFullyActive(groupKey);
};
const isPrimaryRole = (key) => PRIMARY_ROLE_KEYS.includes(key);
const loadAccessModel = async () => {
isLoading.value = true;
try {
const model = await SessionUser.objects.subuser_grants.functions.getPermissionTemplates();
const payload = model?.data?.data || model?.data || model;
accessModel.value = {
templates: Array.isArray(payload?.templates) ? payload.templates : [],
groups: Array.isArray(payload?.groups) ? payload.groups : [],
};
// Read the customer's stored default template via SessionUser / API
const stored = await SessionUser.request("/customer/default-driver-template", "GET")
.then((response) => response?.data?.data?.template_key || response?.data?.template_key || null)
.catch(() => null);
if (stored && accessModel.value.templates.some((template) => template.key === stored)) {
selectedTemplateKey.value = stored;
} else {
const chauffeur = accessModel.value.templates.find((template) => template.key === "driver");
selectedTemplateKey.value = chauffeur?.key || accessModel.value.templates[0]?.key || null;
}
} catch (error) {
console.error("Failed to load permission templates:", error);
accessModel.value = { templates: [], groups: [] };
await Swal.fire({
title: t("common.error"),
text: error?.message || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isLoading.value = false;
}
};
const onSelectTemplate = (key) => {
if (!accessModel.value.templates.some((template) => template.key === key)) {
return;
}
selectedTemplateKey.value = key;
};
const onSaveDefault = async () => {
if (!selectedTemplateKey.value || isSaving.value) {
return;
}
isSaving.value = true;
try {
await SessionUser.request("/customer/default-driver-template", "PUT", {
template_key: selectedTemplateKey.value,
});
await Swal.fire({
title: t("user_dashboard.permissions.saved"),
icon: "success",
timer: 1500,
showConfirmButton: false,
});
} catch (error) {
await Swal.fire({
title: t("common.error"),
text: SessionUser.functions.parseErrorMessage(error) || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isSaving.value = false;
}
};
onMounted(loadAccessModel);
</script>
<template>
<UserDashboardPageWrapper
:title="$t('user_dashboard.permissions.title')"
:subtitle="$t('user_dashboard.permissions.subtitle')"
>
<div v-if="isLoading" class="notification is-light">
{{ $t("user_dashboard.permissions.loading") }}
</div>
<template v-else>
<div class="permissions-tab" data-testid="user-permissions-tab">
<p class="block has-text-grey">
{{ $t("user_dashboard.permissions.card_intro") }}
</p>
<div class="columns is-multiline" data-testid="user-permissions-roles">
<div
v-for="template in accessModel.templates.filter((tpl) => isPrimaryRole(tpl.key))"
:key="template.key"
class="column is-half"
>
<button
type="button"
class="permission-role-card"
:class="{ 'permission-role-card--selected': selectedTemplateKey === template.key }"
:data-testid="`user-permission-role-${template.key}`"
@click="onSelectTemplate(template.key)"
>
<span class="permission-role-card__title">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_label")
: $t("user_dashboard.permissions.card_booking_label")
}}
</span>
<span class="permission-role-card__description">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_description")
: $t("user_dashboard.permissions.card_booking_description")
}}
</span>
<span
v-if="selectedTemplateKey === template.key"
class="tag is-success is-light mt-2"
:data-testid="`user-permission-role-selected-${template.key}`"
>
{{ $t("user_dashboard.permissions.selected_template", { label: template.label }) }}
</span>
</button>
</div>
</div>
<div class="permissions-tab__toggles" data-testid="user-permissions-toggles">
<h3 class="title is-6 mb-2">
<span class="icon"><i class="fas fa-sliders-h"></i></span>
<span>{{ $t("user_dashboard.permissions.toggle_description") }}</span>
</h3>
<div class="columns is-multiline">
<div
v-for="group in PERMISSION_GROUPS"
:key="group.key"
class="column is-half"
>
<div
class="permission-toggle box"
:class="{
'permission-toggle--active': isGroupFullyActive(group.key),
'permission-toggle--partial': isGroupPartiallyActive(group.key),
}"
:data-testid="`user-permission-toggle-${group.key}`"
>
<div class="level is-mobile mb-0">
<div class="level-left">
<div class="level-item">
<div>
<p class="permission-toggle__label">
{{ $t(`user_dashboard.permissions.groups.${group.key}`) }}
</p>
<p class="help has-text-grey">
{{ group.capabilities.length }} rettigheder
</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<b-switch
:model-value="isGroupFullyActive(group.key) || isGroupPartiallyActive(group.key)"
disabled
size="is-small"
type="is-success"
:data-testid="`user-permission-toggle-switch-${group.key}`"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="permissions-tab__actions mt-4">
<button
class="button is-link"
:class="{ 'is-loading': isSaving }"
:disabled="!selectedTemplateKey || isSaving"
data-testid="user-permissions-save"
@click="onSaveDefault"
>
<span class="icon"><i class="fas fa-save"></i></span>
<span>{{ $t("user_dashboard.permissions.save") }}</span>
</button>
</div>
</div>
</template>
</UserDashboardPageWrapper>
</template>
<style scoped>
.permissions-tab .permission-role-card {
align-items: flex-start;
background: #fff;
border: 1px solid #dbdbdb;
border-radius: 6px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 150px;
padding: 1.25rem;
text-align: left;
width: 100%;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.permissions-tab .permission-role-card:hover,
.permissions-tab .permission-role-card--selected {
border-color: #3273dc;
box-shadow: 0 0 0 1px #3273dc;
}
.permissions-tab .permission-role-card__title {
color: #1f2933;
font-weight: 700;
font-size: 1.1rem;
}
.permissions-tab .permission-role-card__description {
color: #4a4a4a;
font-size: 0.9rem;
line-height: 1.4;
}
.permissions-tab .permission-toggle {
border: 1px solid #e6ebf1;
border-radius: 6px;
padding: 0.85rem 1rem;
background: #fafbfc;
}
.permissions-tab .permission-toggle--active {
background: #f1faf3;
border-color: #48c78e;
}
.permissions-tab .permission-toggle--partial {
background: #fff8e1;
border-color: #ffe08a;
}
.permissions-tab .permission-toggle__label {
font-weight: 600;
color: #1f2933;
margin: 0;
}
</style>
@@ -0,0 +1,81 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio Dognvask single vehicle type (TRU-84)", () => {
const studioSource = () =>
readSource("src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue");
it("defines a Dognvask detection helper tied to the current lane machine type", () => {
const source = studioSource();
expect(source).toContain("pathEditorCurrentMachineTypeName");
expect(source).toContain("pathEditorIsDognvaskMachine");
expect(source).toContain('pathEditorCurrentMachineTypeName.value === "dognvask"');
});
it("provides a path editor allowed vehicle types list that filters to traekker for Dognvask", () => {
const source = studioSource();
expect(source).toContain("pathEditorAllowedVehicleTypes");
expect(source).toContain("isTraekkerVehicleType");
expect(source).toMatch(
/pathEditorAllowedVehicleTypes[\s\S]{0,400}if\s*\(\s*!pathEditorIsDognvaskMachine\.value\s*\)\s*\{\s*return\s+rows;\s*\}/
);
expect(source).toMatch(
/pathEditorAllowedVehicleTypes[\s\S]{0,800}const\s+traekker\s*=\s*rows\.filter\(\(row\)\s*=>\s*isTraekkerVehicleType\(row\)\);/
);
});
it("recognises traekker/tractor/tractor unit as the Dognvask vehicle type", () => {
const source = studioSource();
expect(source).toMatch(
/isTraekkerVehicleType[\s\S]{0,400}return\s+candidate\s*===\s*"traekker"\s*\|\|\s*candidate\s*===\s*"tractor"\s*\|\|\s*candidate\s*===\s*"tractor unit";/
);
});
it("uses the allowed vehicle types list in the toolbar filter", () => {
const source = studioSource();
// Toolbar select sits above the v-for option; assert the data-testid and the v-for coexist.
const toolbarIndex = source.indexOf('data-testid="studio-filter-vehicle-type"');
expect(toolbarIndex).toBeGreaterThanOrEqual(0);
const toolbarSection = source.slice(toolbarIndex, toolbarIndex + 400);
expect(toolbarSection).toContain("v-for=\"vehicleType in pathEditorAllowedVehicleTypes\"");
});
it("uses the allowed vehicle types list in the scope guide", () => {
const source = studioSource();
// Scope guide uses :data-testid="`studio-scope-option-vehicle-${...}`" with v-for before it.
const scopeGuideIndex = source.indexOf("studio-scope-option-vehicle-${");
expect(scopeGuideIndex).toBeGreaterThanOrEqual(0);
const scopeGuideSection = source.slice(Math.max(0, scopeGuideIndex - 800), scopeGuideIndex);
expect(scopeGuideSection).toContain("v-for=\"vehicleType in pathEditorAllowedVehicleTypes\"");
});
it("auto-corrects the vehicle type filter when the current machine is Dognvask", () => {
const source = studioSource();
expect(source).toContain(
"() => [filters.value.lane_id, pathEditorIsDognvaskMachine.value, pathEditorAllowedVehicleTypes.value]"
);
expect(source).toMatch(
/if\s*\(\s*!pathEditorIsDognvaskMachine\.value\s*\)\s*\{\s*return;\s*\}/
);
expect(source).toContain('filters.value = { ...filters.value, vehicle_type_id: "" }');
expect(source).toMatch(
/filters\.value\s*=\s*\{[\s\S]{0,200}vehicle_type_id:\s*String\(vehicleTypeFilterValue\(allowed\[0\]\)\)/
);
});
it("shows a visible Dognvask hint badge in the toolbar and scope guide", () => {
const source = studioSource();
expect(source).toContain('data-testid="studio-filter-dognvask-hint"');
expect(source).toContain('data-testid="studio-scope-guide-dognvask-hint"');
});
});
@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("self-serve studio path editor machine filter (TRU-91)", () => {
const studioSource = readSource(
"src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue"
);
it("defines a path editor machine-availability check tied to the current lane and machine type", () => {
expect(studioSource).toContain("pathEditorCurrentLaneRow");
expect(studioSource).toContain("pathEditorCurrentMachineType");
expect(studioSource).toContain("pathEditorMachineIsAvailable");
});
it("returns an empty machine button list when the machine is not available", () => {
expect(studioSource).toMatch(
/pathEditorMachineButtonOptions[\s\S]{0,400}if\s*\(\s*!pathEditorMachineIsAvailable\.value\s*\)\s*\{\s*return\s*\[\s*\]\s*;\s*\}/
);
});
it("limits program wheel options to the OFF position when the machine is not available", () => {
expect(studioSource).toMatch(
/pathEditorProgramButtonOptions[\s\S]{0,600}if\s*\(\s*!pathEditorMachineIsAvailable\.value\s*\)[\s\S]{0,200}return\s*\[\s*\{\s*id:\s*0,\s*value:\s*"0",\s*label:\s*"0 = OFF"\s*\}\s*\]/
);
});
it("considers a machine type deleted when its deleted_at is set", () => {
expect(studioSource).toMatch(/machineTypeRow\.deleted_at[\s\S]{0,80}return\s+null/);
});
});