diff --git a/playwright.global-setup.mjs b/playwright.global-setup.mjs index 69caa7d0..03b1f00a 100644 --- a/playwright.global-setup.mjs +++ b/playwright.global-setup.mjs @@ -386,12 +386,21 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {} async function warmUpDevServer(url) { const warmupTargets = await buildWarmupTargets(); + const selfServeEntryModules = [ + "/src/views/dashboards/userDashboard/wash/MyWash.vue", + "/src/views/dashboards/userDashboard/wash/MyWashStart.vue", + "/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue", + "/src/views/guest/book/GuestBookExteriorWash.vue", + ]; for (const target of warmupTargets) { await warmUpAsset(new URL(target.pathname, url).toString(), target.expectedContentType); } await warmModuleGraph(new URL("/src/main.js", url).toString()); + for (const entryModule of selfServeEntryModules) { + await warmModuleGraph(new URL(entryModule, url).toString(), { depth: 2 }); + } await new Promise((resolve) => setTimeout(resolve, 1000)); } diff --git a/src/App.vue b/src/App.vue index 4be08c13..db248338 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,13 +4,13 @@ import { computed, defineAsyncComponent, onMounted, watch } from "vue"; import { useHead } from "@vueuse/head"; import favicon from '@/assets/favicon.ico'; import {useRoute} from "vue-router"; -import LayoutV2 from "@/components/page/wrappers/LayoutV2.vue"; import { useI18n } from "vue-i18n"; import { hasStoredSessionToken } from "@/services/sessionStorage.js"; const route = useRoute(); const { t, te, locale } = useI18n({ useScope: "global" }); const APP_TITLE = "Truck Wash"; +const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue")); const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue")); const VersionCheck = defineAsyncComponent(() => import("@/components/global/VersionCheck.vue")); const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue")); diff --git a/src/components/displays/department/tables/SelfServeTryModal.vue b/src/components/displays/department/tables/SelfServeTryModal.vue index 17feb73b..3c37fa24 100644 --- a/src/components/displays/department/tables/SelfServeTryModal.vue +++ b/src/components/displays/department/tables/SelfServeTryModal.vue @@ -62,6 +62,7 @@ const { syncVehicleAnswer, clearVehicleAnswers, evaluateCondition, + downloadAttachment, } = useSelfServeLogic(); const emptyCompletedTasks = computed(() => ({})); @@ -429,7 +430,7 @@ watch(dynamicImageUrl, () => { :completedTasks="emptyCompletedTasks" :show-checkboxes="false" @toggle-task="() => {}" - @download-attachment="() => {}" + @download-attachment="downloadAttachment" />

Ingen aktive tasks.

diff --git a/src/components/displays/selfServe/SelfServeQuestionCards.vue b/src/components/displays/selfServe/SelfServeQuestionCards.vue index 45abbbd0..c74e2f90 100644 --- a/src/components/displays/selfServe/SelfServeQuestionCards.vue +++ b/src/components/displays/selfServe/SelfServeQuestionCards.vue @@ -4,8 +4,6 @@ import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue"; defineProps<{ visibleQuestions: Array<{ id: number; question: string; description?: string }>; answers: Record; - loadingQuestionId?: number | null; - loadingAnswerValue?: boolean | null; }>(); const emit = defineEmits<{ @@ -30,12 +28,8 @@ const emit = defineEmits<{ diff --git a/src/components/displays/selfServe/SelfServeTaskList.vue b/src/components/displays/selfServe/SelfServeTaskList.vue index 60cf46b7..ed82cfb3 100644 --- a/src/components/displays/selfServe/SelfServeTaskList.vue +++ b/src/components/displays/selfServe/SelfServeTaskList.vue @@ -11,7 +11,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (e: "toggle-task", taskId: number, value: boolean): void; - (e: "download-attachment", taskId: number, attachmentId: number): void; + (e: "download-attachment", taskId: number, attachmentId: number | undefined, attachment?: any): void; }>(); const isImageAttachment = (attachment: any) => ( @@ -76,7 +76,7 @@ const getVisibleServices = (task: any) => { :alt="getPrimaryImageAttachment(task)?.content?.other || task.task" class="self-serve-task-image is-clickable" :data-testid="`self-serve-task-${task.id}-attachment-${getPrimaryImageAttachment(task)?.id}`" - @click="emit('download-attachment', task.id, getPrimaryImageAttachment(task)?.id)" + @click="emit('download-attachment', task.id, getPrimaryImageAttachment(task)?.id, getPrimaryImageAttachment(task))" />
@@ -109,7 +109,7 @@ const getVisibleServices = (task: any) => { {{ attachment.content?.other || $t("self_wash.attached_file") }} @@ -145,7 +145,7 @@ const getVisibleServices = (task: any) => { style="max-width: 100%; height: auto; border-radius: 4px; display: block;" class="mb-1 is-clickable" :data-testid="`self-serve-task-${task.id}-attachment-${attachment.id}`" - @click="emit('download-attachment', task.id, attachment.id)" + @click="emit('download-attachment', task.id, attachment.id, attachment)" />
@@ -153,7 +153,7 @@ const getVisibleServices = (task: any) => { {{ attachment.content?.other || $t("self_wash.attached_file") }} diff --git a/src/components/displays/selfServe/SelfServeTasksStep.vue b/src/components/displays/selfServe/SelfServeTasksStep.vue index 8cd46ef0..12d853ad 100644 --- a/src/components/displays/selfServe/SelfServeTasksStep.vue +++ b/src/components/displays/selfServe/SelfServeTasksStep.vue @@ -13,7 +13,7 @@ defineProps<{ const emit = defineEmits<{ (e: "toggle-task", taskId: number, value: boolean): void; - (e: "download-attachment", taskId: number, attachmentId: number): void; + (e: "download-attachment", taskId: number, attachmentId: number | undefined, attachment?: any): void; (e: "clear-dynamic-image"): void; }>(); @@ -21,8 +21,8 @@ const emitToggleTask = (taskId: number, value: boolean) => { emit("toggle-task", taskId, value); }; -const emitDownloadAttachment = (taskId: number, attachmentId: number) => { - emit("download-attachment", taskId, attachmentId); +const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined, attachment?: any) => { + emit("download-attachment", taskId, attachmentId, attachment); }; diff --git a/src/components/displays/selfServe/SelfServeVehicleTypeSelector.vue b/src/components/displays/selfServe/SelfServeVehicleTypeSelector.vue index 997a51e8..8c857cb2 100644 --- a/src/components/displays/selfServe/SelfServeVehicleTypeSelector.vue +++ b/src/components/displays/selfServe/SelfServeVehicleTypeSelector.vue @@ -166,6 +166,15 @@ watch(() => props.selectedVehicleTypeId, (newId) => { padding: 5px; } +.vehicle-type-content :deep(.box), +.vehicle-type-content :deep(.card) { + cursor: pointer; +} + +.vehicle-type-content img { + pointer-events: none; +} + .vehicle-type-content:nth-child(3n + 1) { padding-left: 0; } diff --git a/src/composables/useSelfServeLogic.js b/src/composables/useSelfServeLogic.js index 31b055c3..ddf33c32 100644 --- a/src/composables/useSelfServeLogic.js +++ b/src/composables/useSelfServeLogic.js @@ -48,6 +48,7 @@ const normalizeTask = (task) => ({ services: Array.isArray(task?.services) ? task.services : [], buttons: Array.isArray(task?.buttons) ? task.buttons : [], attachments: Array.isArray(task?.attachments) ? task.attachments : [], + _attachmentsLoaded: Array.isArray(task?.attachments), dynamic_images_vehicle_type: task?.dynamic_images_vehicle_type ?? null, }); @@ -279,10 +280,18 @@ export function useSelfServeLogic() { return Promise.all(taskList.map(async (task) => { const taskId = extractTaskId(task); + if (task._attachmentsLoaded) { + return { + ...task, + attachments: Array.isArray(task.attachments) ? task.attachments : [], + }; + } + if (!taskId) { return { ...task, attachments: Array.isArray(task.attachments) ? task.attachments : [], + _attachmentsLoaded: true, }; } @@ -310,12 +319,14 @@ export function useSelfServeLogic() { return { ...task, attachments, + _attachmentsLoaded: true, }; } catch (error) { console.error(`Error loading attachments for task ${taskId}:`, error); return { ...task, attachments: [], + _attachmentsLoaded: true, }; } })); @@ -435,13 +446,19 @@ export function useSelfServeLogic() { return true; }; - const downloadAttachment = async (taskId, attachmentId) => { + const downloadAttachment = async (taskId, attachmentId, attachment = null) => { + const existingDownloadLink = attachment?.download_link || null; + if (existingDownloadLink) { + window.open(existingDownloadLink, "_blank", "noopener"); + return; + } + try { const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId); if (response?.data?.download_link) { - window.open(response.data.download_link, "_blank"); + window.open(response.data.download_link, "_blank", "noopener"); } else if (response?.download_link) { - window.open(response.download_link, "_blank"); + window.open(response.download_link, "_blank", "noopener"); } } catch (error) { console.error("Error downloading attachment:", error); diff --git a/src/main.js b/src/main.js index 08dec247..d2b054a4 100644 --- a/src/main.js +++ b/src/main.js @@ -21,13 +21,10 @@ import { installAxiosRequestQueue } from '@/services/installAxiosRequestQueue.js import { API_URL, IS_DEV } from './config'; const VITE_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || ''; const VITE_COMMIT_HASH = import.meta.env.VITE_COMMIT_HASH || ''; -const VITE_IS_PLAYWRIGHT = import.meta.env.VITE_IS_PLAYWRIGHT === true || import.meta.env.VITE_IS_PLAYWRIGHT === 'true'; const LAST_VERSION_CHECK_STORAGE_KEY = 'lastVersionCheck'; -if (!VITE_IS_PLAYWRIGHT) { - await import('bulma/css/bulma.min.css'); - await import('buefy/dist/css/buefy.css'); -} +void import('bulma/css/bulma.min.css'); +void import('buefy/dist/css/buefy.css'); const TRUCK_WASH_ASCII = [ ' _____ _ _ _ _ \n' + @@ -113,7 +110,7 @@ if (import.meta.env.DEV && typeof window !== 'undefined') { // window.location.href = '/redirect'; // } applyMiddleware(Router) -createApp(App) +const app = createApp(App) .use(Router) .use(createHead()) .use(store) @@ -121,9 +118,12 @@ createApp(App) .use(Buefy) .use(i18n) .use(VueApexCharts) - .provide('Colors', Colors) + .provide('Colors', Colors) .provide('IS_DEV', IS_DEV) .provide('API_URL', API_URL) - .mount('#app'); -initializeAutoTableExports(); +app.mount('#app'); + +void Router.isReady().finally(() => { + initializeAutoTableExports(); +}); diff --git a/src/middleware/adminMiddleware.js b/src/middleware/adminMiddleware.js index 7c0e1ec1..2613d249 100644 --- a/src/middleware/adminMiddleware.js +++ b/src/middleware/adminMiddleware.js @@ -1,11 +1,10 @@ /** * @description Middleware to check if user is admin */ -import store from '@/store/user.vue'; -import { SessionUser } from "@/components/session/token/SessionUser.vue"; +import { hasStoredSessionToken } from "@/services/sessionStorage.js"; export default function adminMiddleware({ next, router }) { - if (!SessionUser.hasToken()) { + if (!hasStoredSessionToken()) { return router.push({ // Redirect to login page name: 'login', @@ -15,4 +14,4 @@ export default function adminMiddleware({ next, router }) { } return next(); -} \ No newline at end of file +} diff --git a/src/middleware/superUserMiddleware.js b/src/middleware/superUserMiddleware.js index 3146cafe..dc042e8a 100644 --- a/src/middleware/superUserMiddleware.js +++ b/src/middleware/superUserMiddleware.js @@ -1,11 +1,10 @@ /** * @description Middleware to check if the user is a superuser */ -import store from '@/store/user.vue'; -import { SessionUser } from "@/components/session/token/SessionUser.vue"; +import { hasStoredSessionToken } from "@/services/sessionStorage.js"; export default function superUserMiddleware({ next, router }) { - if (!SessionUser.hasToken()) { + if (!hasStoredSessionToken()) { return router.push({ // Redirect to login page name: 'login', diff --git a/src/router.js b/src/router.js index eb842e5f..22551949 100644 --- a/src/router.js +++ b/src/router.js @@ -7,6 +7,7 @@ import authMiddleware from './middleware/authMiddleware.js'; import guestMiddleware from "@/middleware/guestMiddleware.js"; import adminMiddleware from './middleware/adminMiddleware.js'; import superUserMiddleware from "@/middleware/superUserMiddleware.js"; +import GuestBookExteriorWash from "@/views/guest/book/GuestBookExteriorWash.vue"; const lazyModules = import.meta.glob('/src/views/**/*.vue'); @@ -115,7 +116,6 @@ const UserOther = lazyView('@/views/dashboards/superUserDashboard/user/UserOther //const MyBookingsNew = lazyView('@/views/dashboards/userDashboard/bookings/MyBookingsNew.vue'); const DepartmentCompleteBooking = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentCompleteBooking.vue'); const GuestHome = lazyView('@/views/guest/GuestHome.vue'); -const GuestBookExteriorWash = lazyView('@/views/guest/book/GuestBookExteriorWash.vue'); const DepartmentProfile = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentProfile.vue'); const DepartmentGatewaysWorkspacePage = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue'); const ConfigurationFXRatesAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue'); @@ -1220,6 +1220,7 @@ export const router = createRouter({ name: 'guestBookWash', path: '/guest/book/wash', component: GuestBookExteriorWash, + meta: { template: 'clear-main', titleKey: 'book_wash.title' } }, { name: 'outdated-installation', diff --git a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue index 3a12b46b..c52fba01 100644 --- a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue +++ b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue @@ -1,6 +1,6 @@ \ No newline at end of file +.guest-page__inner { + width: min(100%, 1180px); + margin: 0 auto; +} + +.guest-page__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.guest-page__heading { + min-width: 0; +} + +.guest-page__actions { + flex: 0 0 auto; +} + +.guest-page__content { + min-width: 0; +} + +@media screen and (max-width: 768px) { + .guest-page { + padding: 1rem 0.75rem 2rem; + } + + .guest-page__header { + display: block; + } + + .guest-page__actions { + margin-top: 1rem; + } +} + diff --git a/src/views/guest/book/GuestBookExteriorWash.vue b/src/views/guest/book/GuestBookExteriorWash.vue index 77f7ec29..10c34ab7 100644 --- a/src/views/guest/book/GuestBookExteriorWash.vue +++ b/src/views/guest/book/GuestBookExteriorWash.vue @@ -1,26 +1,163 @@ \ No newline at end of file +.guest-booking-controls { + background: #ffffff; + border: 1px solid rgba(10, 10, 10, 0.08); + border-radius: 8px; + padding: 1rem; +} + diff --git a/tests/e2e/booking-selfserve.smoke.spec.js b/tests/e2e/booking-selfserve.smoke.spec.js index 2d7c3546..f6f9b13e 100644 --- a/tests/e2e/booking-selfserve.smoke.spec.js +++ b/tests/e2e/booking-selfserve.smoke.spec.js @@ -4,10 +4,19 @@ import { mockApi } from "./support/network.js"; test.describe("Booking/self-serve smoke", () => { test("@smoke guest booking route loads and keeps key controls available", async ({ page }) => { await mockApi(page); - await page.goto("/guest/book/wash"); + await page.goto("/guest/book/wash", { waitUntil: "domcontentloaded" }); await expect(page).toHaveURL(/\/guest\/book\/wash$/); await expect(page.locator("body")).not.toContainText("404"); - await expect(page.locator("html")).toContainText(/.+/); + await expect(page.getByTestId("guest-dashboard-page")).toBeVisible({ timeout: 30000 }); + await expect(page.getByTestId("guest-book-wash-flow")).toBeVisible(); + + const departmentSelect = page.getByTestId("guest-department-select"); + await expect(departmentSelect).toBeVisible(); + await departmentSelect.selectOption({ index: 1 }); + await page.getByTestId("guest-registration-input").fill("AB12345"); + await page.getByTestId("guest-customer-number-input").fill("1001"); + await expect(page.getByTestId("guest-requested-at-input")).toBeVisible(); + await expect(page.getByTestId("guest-booking-continue")).toBeEnabled(); }); }); diff --git a/tests/e2e/self-serve-studio-flow.spec.js b/tests/e2e/self-serve-studio-flow.spec.js index fb371cd5..ed2c7737 100644 --- a/tests/e2e/self-serve-studio-flow.spec.js +++ b/tests/e2e/self-serve-studio-flow.spec.js @@ -7,6 +7,28 @@ const json = (body, status = 200) => ({ body: JSON.stringify(body), }); +const mirrorTaskAttachments = [ + { + id: 201, + content: { other: "mirror-guide.pdf", document: "tasks/mirror-guide.pdf" }, + download_link: "https://cdn.example.test/mirror-guide.pdf", + }, + { + id: 202, + content: { other: "mirror-photo.jpg", image: "tasks/mirror-photo.jpg" }, + download_link: "https://cdn.example.test/mirror-photo.jpg", + }, +]; + +const graphTaskRaw = (graph, taskId) => graph.nodes.find((node) => node.id === `task:${taskId}`)?.data?.raw || null; + +const setGraphTaskAttachments = (graph, taskId, attachments) => { + const raw = graphTaskRaw(graph, taskId); + if (raw) { + raw.attachments = attachments; + } +}; + function buildStudioGraph() { const lookups = { departments: [{ id: 6, label: "Roskilde" }], @@ -74,8 +96,14 @@ function buildStudioGraph() { ], actions: [{ id: 81, label: "Open entry on start" }], gateways: [{ id: 701, label: "Roskilde Edge 01", status: "ONLINE" }], - relays: [{ id: "M-7", label: "Machine relay" }], - bindings: [{ id: "701:M-7:0", label: "Machine relay", role: "MACHINE", services: ["MACHINE"] }], + relays: [ + { id: "M-7", label: "Machine relay" }, + { id: "IN-7", label: "Entry relay" }, + ], + bindings: [ + { id: "701:M-7:0", label: "Machine relay", role: "MACHINE", services: ["MACHINE"] }, + { id: "701:IN-7:1", label: "Entry relay", role: "ENTRY", services: ["ENTRY"] }, + ], labels: { departments: { 6: "Roskilde" }, lanes: { 7: "Lane 7" }, @@ -89,8 +117,8 @@ function buildStudioGraph() { tasks: { 41: "Fold mirrors", 48: "Start bus wash" }, actions: { 81: "Open entry on start" }, gateways: { 701: "Roskilde Edge 01" }, - relays: { "M-7": "Machine relay" }, - bindings: { "701:M-7:0": "Machine relay" }, + relays: { "M-7": "Machine relay", "IN-7": "Entry relay" }, + bindings: { "701:M-7:0": "Machine relay", "701:IN-7:1": "Entry relay" }, }, }; @@ -247,6 +275,7 @@ function buildStudioGraph() { services: ["MACHINE"], buttons: ["reset", 1, "start"], dynamic_images_vehicle_type: 5, + attachments: mirrorTaskAttachments, }, }, }, @@ -258,8 +287,8 @@ function buildStudioGraph() { kind: "action", object_id: 81, label: "Open entry on start", - subtitle: "On self-serve wash start command / Both / Open lane entrance port", - event_label: "On self-serve wash start command", + subtitle: "When wash starts / Both / Open lane entrance port", + event_label: "When wash starts", action_label: "Open lane entrance port", relay_role: "ENTRY", raw: { @@ -382,6 +411,18 @@ function buildStudioGraph() { raw: { relay_id: "M-7", label: "Machine relay", role: "MACHINE", services: ["MACHINE"], channel: 0 }, }, }, + { + id: "binding:701:IN-7:1", + type: "default", + position: { x: 1600, y: 300 }, + data: { + kind: "relay_binding", + object_id: "IN-7", + label: "Entry relay", + subtitle: "ENTRY", + raw: { relay_id: "IN-7", label: "Entry relay", role: "ENTRY", services: ["ENTRY"], channel: 1 }, + }, + }, { id: "relay:M-7", type: "default", @@ -394,6 +435,18 @@ function buildStudioGraph() { raw: { relay_id: "M-7", name: "Machine relay", status: "ONLINE" }, }, }, + { + id: "relay:IN-7", + type: "default", + position: { x: 1920, y: 300 }, + data: { + kind: "relay", + object_id: "IN-7", + label: "Entry relay", + subtitle: "Hardware relay", + raw: { relay_id: "IN-7", name: "Entry relay", status: "ONLINE" }, + }, + }, ], edges: [ { @@ -596,6 +649,30 @@ function buildStudioGraph() { label: "controls", data: { kind: "relay_binding" }, }, + { + id: "gateway-binding:701:IN-7:1", + source: "gateway:701", + target: "binding:701:IN-7:1", + type: "smoothstep", + label: "binds", + data: { kind: "gateway_binding" }, + }, + { + id: "binding-relay:701:IN-7:1", + source: "binding:701:IN-7:1", + target: "relay:IN-7", + type: "smoothstep", + label: "controls", + data: { kind: "relay_binding" }, + }, + { + id: "action-relay:81:IN-7:ENTRY:7", + source: "action:81", + target: "relay:IN-7", + type: "smoothstep", + label: "ENTRY", + data: { kind: "action_relay" }, + }, { id: "task-service:41:MACHINE:701:M-7:0", source: "task:41", @@ -629,20 +706,27 @@ function buildStudioGraph() { id: 701, label: "Roskilde Edge 01", status: "ONLINE", - bindings: [{ relay_id: "M-7", label: "Machine relay", role: "MACHINE", services: ["MACHINE"], channel: 0 }], + bindings: [ + { relay_id: "M-7", label: "Machine relay", role: "MACHINE", services: ["MACHINE"], channel: 0 }, + { relay_id: "IN-7", label: "Entry relay", role: "ENTRY", services: ["ENTRY"], channel: 1 }, + ], }, ], - relays: [{ relay_id: "M-7", name: "Machine relay" }], + relays: [ + { relay_id: "M-7", name: "Machine relay" }, + { relay_id: "IN-7", name: "Entry relay" }, + ], lanes: [ { id: 7, name: "Lane 7", relay_slots: [ + { slot: "ENTRY", relay_id: "IN-7", coverage: { covered: true, status: "BOUND" } }, { slot: "MACHINE", relay_id: "M-7", coverage: { covered: true, status: "BOUND" } }, { slot: "EXIT", relay_id: "EXIT-7", coverage: { covered: false, status: "MISSING" } }, { slot: "CLEANER", relay_id: "CLEAN-7", coverage: { covered: false, status: "MISSING" } }, ], - binding_coverage: { required: 3, bound: 1, missing: 2, state: "MISSING" }, + binding_coverage: { required: 4, bound: 2, missing: 2, state: "MISSING" }, }, ], issues: [], @@ -684,6 +768,7 @@ function buildSimulationResponse() { services: ["MACHINE"], buttons: ["reset", 1, "start"], dynamic_images_vehicle_type: 5, + attachments: mirrorTaskAttachments, }, ], allowed_services: [], @@ -782,6 +867,7 @@ function buildSimulationResponse() { services: ["MACHINE"], buttons: ["reset", 1, "start"], dynamic_images_vehicle_type: 5, + attachments: mirrorTaskAttachments, reason: "Task gate did not pass.", }, ], @@ -1006,6 +1092,139 @@ function buildSimulationResponse() { }; } +function buildPathOutcomesResponse(request = {}) { + const vehicleTypeId = request.vehicle_type_id ?? null; + const vehicleType = vehicleTypeId === 8 ? "Bus" : vehicleTypeId === 2 ? "Forvogn" : "All current vehicle types"; + return { + scope: { + department_id: 6, + department: "Roskilde", + lane_id: request.lane_id ?? 7, + lane: "Lane 7", + vehicle_type_id: vehicleTypeId, + vehicle_type: vehicleType, + vehicle_type_count: vehicleTypeId ? 1 : 2, + registration: "TEST123", + config_source: request.config_source || "draft", + hardware_mode: request.hardware_mode || "studio", + max_states: 2048, + }, + summary: { + state_count: 5, + terminal_path_count: 3, + outcome_count: 2, + question_count: 2, + question_ids: [11, 12], + max_states: 2048, + }, + outcomes: [ + { + id: "outcome-1", + summary: "Allowed / MACHINE / 1 task / 2 signals", + path_count: 1, + allowed: true, + services: ["MACHINE"], + tasks: [ + { + id: 41, + node_id: "task:41", + label: "Fold mirrors", + services: ["MACHINE"], + buttons: ["reset", 1, "start"], + order_priority: 1, + }, + ], + signals: [ + { + sequence: 1, + runtime_stage: "eligibility_sync", + signal_type: "session_event", + relay_role: "SESSION", + source: "none", + virtual: false, + predicted_status: "sent", + payload: { allowed: true, allowed_services: ["MACHINE"] }, + }, + { + sequence: 2, + runtime_stage: "machine_start_signal", + signal_type: "shelly_event", + relay_role: "MACHINE", + relay_id: "M-7", + target_gateway_label: "Virtual Studio Gateway", + target_binding: "binding:virtual-main:M-7:0", + source: "virtual", + virtual: true, + predicted_status: "virtual_only", + payload: { event: "input.toggle_on", bill_machine_wash: true }, + }, + ], + sample_chains: [ + { + scope: { lane: "Lane 7", vehicle_type: vehicleType }, + answers: [ + { + question_id: 11, + question: "Are mirrors folded?", + node_id: "question:11", + answer: true, + answer_label: "Yes", + }, + { + question_id: 12, + question: "Is the lift lowered?", + node_id: "question:12", + answer: true, + answer_label: "Yes", + }, + ], + }, + ], + scopes: [{ lane: "Lane 7", vehicle_type: vehicleType }], + node_ids: ["question:11", "question:12", "task:41", "binding:virtual-main:M-7:0"], + }, + { + id: "outcome-2", + summary: "Blocked / No services / 0 tasks / 1 signal", + path_count: 2, + allowed: false, + services: [], + tasks: [], + signals: [ + { + sequence: 1, + runtime_stage: "eligibility_sync", + signal_type: "session_event", + relay_role: "SESSION", + source: "none", + virtual: false, + predicted_status: "skipped", + payload: { allowed: false, allowed_services: [] }, + }, + ], + sample_chains: [ + { + scope: { lane: "Lane 7", vehicle_type: vehicleType }, + answers: [ + { + question_id: 11, + question: "Are mirrors folded?", + node_id: "question:11", + answer: false, + answer_label: "No", + }, + ], + }, + ], + scopes: [{ lane: "Lane 7", vehicle_type: vehicleType }], + node_ids: ["question:11"], + }, + ], + warnings: [], + truncated: false, + }; +} + function applyVirtualHardwareToGraph(graph) { if (!graph.gateway_workspace.virtual?.has_virtual_hardware) { graph.gateway_workspace.virtual = { @@ -1228,8 +1447,8 @@ async function installStudioRoutes(page, graph, captured) { kind: "action", object_id: 99, label: operation.data.name, - subtitle: "On self-serve wash start command / Both / Open lane entrance port", - event_label: "On self-serve wash start command", + subtitle: "When wash starts / Both / Open lane entrance port", + event_label: "When wash starts", action_label: "Open lane entrance port", relay_role: "ENTRY", raw: { id: 99, ...operation.data }, @@ -1285,6 +1504,52 @@ async function installStudioRoutes(page, graph, captured) { await route.fulfill(json({ data: graph })); }); + await page.route(/\/department\/selfserve\/tasks\/attachments\/upload(?:\?.*)?$/i, async (route, request) => { + const body = request.postDataJSON?.() || {}; + captured.taskAttachmentUploads ||= []; + captured.taskAttachmentUploads.push(body); + const taskId = Number(body.task_id); + const attachments = [...(graphTaskRaw(graph, taskId)?.attachments || [])]; + const attachment = { + id: 900 + attachments.length, + object_type: "department_selfserve_tasks", + object_id: taskId, + content: { other: body.file_name, document: `tasks/${body.file_name}` }, + download_link: `https://cdn.example.test/${body.file_name}`, + }; + setGraphTaskAttachments(graph, taskId, [...attachments, attachment]); + await route.fulfill(json({ data: attachment })); + }); + + await page.route(/\/department\/selfserve\/tasks\/attachments\/download(?:\?.*)?$/i, async (route, request) => { + const url = new URL(request.url()); + captured.taskAttachmentDownloads ||= []; + captured.taskAttachmentDownloads.push({ + task_id: Number(url.searchParams.get("task_id")), + attachment_id: Number(url.searchParams.get("attachment_id")), + }); + await route.fulfill(json({ data: { download_link: "https://cdn.example.test/generated-task-file.pdf" } })); + }); + + await page.route(/\/department\/selfserve\/tasks\/attachments(?:\?.*)?$/i, async (route, request) => { + const body = request.postDataJSON?.() || {}; + const url = new URL(request.url()); + const taskId = Number(body.task_id || url.searchParams.get("id")); + if (request.method() === "DELETE") { + captured.taskAttachmentDeletes ||= []; + captured.taskAttachmentDeletes.push(body); + const attachmentId = Number(body.attachment_id); + setGraphTaskAttachments( + graph, + taskId, + (graphTaskRaw(graph, taskId)?.attachments || []).filter((attachment) => Number(attachment.id) !== attachmentId) + ); + await route.fulfill(json({ data: { message: "Attachment deleted successfully" } })); + return; + } + await route.fulfill(json({ data: graphTaskRaw(graph, taskId)?.attachments || [] })); + }); + await page.route(/\/products(?:\?.*)?$/i, async (route, request) => { if (request.method() !== "PUT") { await route.fulfill(json({ data: graph.lookups.products })); @@ -1362,6 +1627,12 @@ async function installStudioRoutes(page, graph, captured) { await route.fulfill(json({ data: buildSimulationResponse() })); }); + await page.route(/\/department\/selfserve\/studio\/path-outcomes(?:\?.*)?$/i, async (route, request) => { + const body = request.postDataJSON?.() || {}; + captured.pathOutcomes.push(body); + await route.fulfill(json({ data: buildPathOutcomesResponse(body) })); + }); + await page.route(/\/department\/lanes\/dynamic-image(?:\?.*)?$/i, async (route, request) => { captured.dynamicImages.push(new URL(request.url())); await route.fulfill({ @@ -1407,6 +1678,7 @@ test.describe("All-in-one self-serve studio", () => { graphSaves: [], layouts: [], simulations: [], + pathOutcomes: [], dynamicImages: [], gatewayActions: [], virtualHardware: [], @@ -1429,13 +1701,16 @@ test.describe("All-in-one self-serve studio", () => { await expect(page.getByTestId("studio-custom-node-rule")).toHaveCount(0); await expect(page.getByTestId("studio-custom-node-task").first()).toContainText("services"); await expect(page.getByTestId("studio-custom-node-task").first()).toContainText("MACHINE"); + const foldMirrorsNode = page.locator('.vue-flow__node[data-id="task:41"]'); + await expect(foldMirrorsNode.getByTestId("studio-task-node-attachments")).toContainText("mirror-guide.pdf"); + await expect(foldMirrorsNode.getByTestId("studio-task-node-attachments")).toContainText("mirror-photo.jpg"); + await expect(foldMirrorsNode).toContainText("2 attachments"); await expect(page.getByTestId("studio-palette-action")).toBeVisible(); - await expect(page.getByTestId("studio-custom-node-action").first()).toContainText( - "On self-serve wash start command" - ); + await expect(page.getByTestId("studio-custom-node-action").first()).toContainText("When wash starts"); await expect(page.getByTestId("studio-custom-node-action").first()).toContainText("Open lane entrance port"); await expect(page.getByTestId("studio-custom-node-gateway").first()).toContainText("ONLINE"); await expect(page.getByTestId("studio-custom-node-binding").first()).toContainText("MACHINE"); + await expect(page.locator('[data-id="action-relay:81:IN-7:ENTRY:7"]')).toHaveCount(1); await expect(page.getByTestId("studio-custom-node-scope").first()).toContainText("Forvogn"); await expect(page.getByTestId("studio-filter-machine-type")).toContainText("Portal"); await expect(page.getByTestId("studio-filter-dynamic-image")).toContainText("Machine 1"); @@ -1587,6 +1862,22 @@ test.describe("All-in-one self-serve studio", () => { await expect(page.getByTestId("studio-task-button-picker")).toContainText("Start"); await expect(page.getByTestId("studio-task-button-picker").locator('input[value="reset"]')).toBeChecked(); await expect(page.getByTestId("studio-task-button-picker").locator('input[value="start"]')).toBeChecked(); + await expect(page.getByTestId("studio-task-attachment-manager")).toContainText("mirror-guide.pdf"); + await expect(page.getByTestId("studio-task-attachment-manager")).toContainText("mirror-photo.jpg"); + await page.getByTestId("studio-task-attachment-upload").setInputFiles({ + name: "driver-checklist.txt", + mimeType: "text/plain", + buffer: Buffer.from("Check mirrors before wash"), + }); + await expect.poll(() => captured.taskAttachmentUploads?.at(-1)?.file_name).toBe("driver-checklist.txt"); + await expect(page.getByTestId("studio-task-attachment-manager")).toContainText("driver-checklist.txt"); + await page + .locator(".studio-task-attachment-item") + .filter({ hasText: "driver-checklist.txt" }) + .getByTitle("Remove file") + .click(); + await expect.poll(() => captured.taskAttachmentDeletes?.at(-1)?.attachment_id).toBe(902); + await expect(page.getByTestId("studio-task-attachment-manager")).not.toContainText("driver-checklist.txt"); await page.getByTestId("studio-service-picker").locator('input[value="MACHINE"]').uncheck(); await page.getByRole("button", { name: "Save" }).click(); await expect @@ -1595,7 +1886,8 @@ test.describe("All-in-one self-serve studio", () => { (save) => save.operations?.[0]?.entity === "task" && Array.isArray(save.operations?.[0]?.data?.services) && - save.operations[0].data.services.length === 0 + save.operations[0].data.services.length === 0 && + !Object.prototype.hasOwnProperty.call(save.operations[0].data, "attachments") ) ) .toBe(true); @@ -1603,7 +1895,7 @@ test.describe("All-in-one self-serve studio", () => { await page.getByTestId("studio-panel-tasks").click(); await expect(page.locator(".studio-task-row").filter({ hasText: "Open entry on start" })).toContainText( - "On self-serve wash start command" + "When wash starts" ); await page.locator(".studio-task-row").filter({ hasText: "Open entry on start" }).getByRole("button").click(); await expect(page.getByTestId("studio-inspector-action-event")).toHaveValue("wash_start_command"); @@ -1666,12 +1958,86 @@ test.describe("All-in-one self-serve studio", () => { await expect(page.locator('.vue-flow__node[data-id="question:11"]')).toContainText("Are loose straps secured?"); }); + test("edits if/else and case condition operators from the inspector", async ({ page }) => { + const graph = buildStudioGraph(); + const captured = { + graphSaves: [], + layouts: [], + simulations: [], + pathOutcomes: [], + dynamicImages: [], + gatewayActions: [], + virtualHardware: [], + productUpdates: [], + rollbacks: [], + published: 0, + }; + await installStudioRoutes(page, graph, captured); + + await page.goto("/admin/6/modules/self-serve/studio", { waitUntil: "domcontentloaded", timeout: 90_000 }); + await page.getByTestId("studio-panel-conditions").click(); + await page.locator(".studio-condition-card").filter({ hasText: "Trailer present" }).click(); + await expect(page.getByTestId("condition-expression-builder")).toBeVisible(); + + await page.getByTestId("condition-expression-type").selectOption("branch"); + await expect(page.getByTestId("condition-expression-branch")).toHaveCount(2); + const firstBranch = page.getByTestId("condition-expression-branch").first(); + await firstBranch.getByRole("button", { name: "Add then" }).click(); + const branchThen = firstBranch.getByTestId("condition-expression-branch-then-row").last(); + await branchThen.locator("select").nth(1).selectOption({ label: "Is the lift lowered?" }); + await branchThen.locator("select").nth(2).selectOption("IS_FALSE"); + await page.getByTestId("condition-expression-add-elseif").click(); + await expect(page.getByTestId("condition-expression-summary")).toContainText("Else if"); + await page.getByRole("button", { name: "Save" }).click(); + await expect + .poll(() => { + const expression = captured.graphSaves.findLast((save) => save.operations?.[0]?.entity === "condition") + ?.operations?.[0]?.data?.expression; + return ( + expression?.type === "branch" && + expression.branches?.[0]?.kind === "if" && + expression.branches?.[0]?.when?.children?.[0]?.subject_id === 11 && + expression.branches?.[0]?.then?.children?.[0]?.subject_id === 12 && + expression.branches?.[0]?.then?.children?.[0]?.operator === "IS_FALSE" && + expression.branches?.some((branch) => branch.kind === "else_if") && + expression.branches?.some((branch) => branch.kind === "else") + ); + }) + .toBe(true); + + await page.getByTestId("condition-expression-type").selectOption("case"); + await page.getByTestId("condition-expression-case-subject").selectOption({ label: "Are mirrors folded?" }); + const firstCase = page.getByTestId("condition-expression-case").first(); + await firstCase.getByTestId("condition-expression-case-value").selectOption({ label: "False" }); + await firstCase.getByRole("button", { name: "Add then" }).click(); + const caseThen = firstCase.getByTestId("condition-expression-case-then-row").last(); + await caseThen.locator("select").nth(1).selectOption({ label: "Is the lift lowered?" }); + await caseThen.locator("select").nth(2).selectOption("IS_TRUE_OR_NOT_SET"); + await expect(page.getByTestId("condition-expression-summary")).toContainText("Case Are mirrors folded?"); + await page.getByRole("button", { name: "Save" }).click(); + await expect + .poll(() => { + const expression = captured.graphSaves.findLast((save) => save.operations?.[0]?.entity === "condition") + ?.operations?.[0]?.data?.expression; + return ( + expression?.type === "case" && + expression.subject_type === "question" && + expression.subject_id === 11 && + expression.cases?.[0]?.value === false && + expression.cases?.[0]?.then?.children?.[0]?.subject_id === 12 && + expression.cases?.[0]?.then?.children?.[0]?.operator === "IS_TRUE_OR_NOT_SET" + ); + }) + .toBe(true); + }); + test("supports drag-drop quick add, gateway action, simulator, publish, and rollback panels", async ({ page }) => { const graph = buildStudioGraph(); const captured = { graphSaves: [], layouts: [], simulations: [], + pathOutcomes: [], dynamicImages: [], gatewayActions: [], virtualHardware: [], @@ -1768,6 +2134,26 @@ test.describe("All-in-one self-serve studio", () => { await expect(page.getByTestId("studio-simulator-auto-decision")).toContainText("Auto decided: Forvogn (#2)."); await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" }); + await page.getByTestId("studio-panel-paths").click(); + await expect(page.getByTestId("studio-path-outcomes")).toBeVisible(); + await expect.poll(() => captured.pathOutcomes[0]?.lane_id).toBe(7); + await expect.poll(() => captured.pathOutcomes[0]?.vehicle_type_id).toBe(8); + await expect(page.getByTestId("studio-path-outcomes-summary")).toContainText("2"); + await expect(page.getByTestId("studio-path-outcome-detail")).toContainText("MACHINE"); + await expect(page.getByTestId("studio-path-signal-timeline")).toContainText("machine_start_signal / MACHINE"); + await expect(page.getByTestId("studio-path-signal-timeline")).toContainText('"bill_machine_wash":true'); + await page.getByTestId("studio-path-outcome-focus").click(); + await expect(page.getByTestId("studio-panel-flow")).toHaveClass(/is-active/); + await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible(); + await page.getByTestId("studio-filter-vehicle-type").selectOption(""); + await page.getByTestId("studio-panel-paths").click(); + await expect(page.getByTestId("studio-path-outcomes-stale")).toBeVisible(); + await page.getByTestId("studio-path-outcomes-refresh").click(); + await expect.poll(() => captured.pathOutcomes.at(-1)?.vehicle_type_id).toBeNull(); + await page.getByTestId("studio-panel-flow").click(); + + await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" }); + await page.getByTestId("studio-panel-simulator").click(); await expect(page.getByTestId("studio-simulator-scope")).toContainText("Bus"); await expect(page.locator(".studio-simulator-form select")).toHaveCount(0); await page.getByLabel("Registration").fill("AB12345"); @@ -1804,6 +2190,9 @@ test.describe("All-in-one self-serve studio", () => { 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(); + const foldMirrorsDebugTask = page.locator(".studio-debug-task").filter({ hasText: "Fold mirrors" }); + await expect(foldMirrorsDebugTask.locator(".studio-debug-attachment-list")).toContainText("mirror-guide.pdf"); + await expect(foldMirrorsDebugTask.locator(".studio-debug-attachment-list")).toContainText("mirror-photo.jpg"); await expect(page.getByText("Gateway Readiness")).toBeVisible(); await expect(page.getByTestId("studio-signal-timeline")).toContainText("Signals"); await expect(page.getByTestId("studio-signal-timeline")).toContainText("start / ENTRY"); @@ -1844,6 +2233,7 @@ test.describe("All-in-one self-serve studio", () => { graphSaves: [], layouts: [], simulations: [], + pathOutcomes: [], dynamicImages: [], gatewayActions: [], virtualHardware: [], diff --git a/tests/e2e/self-serve-wash.spec.js b/tests/e2e/self-serve-wash.spec.js index 97640fb6..5e878b48 100644 --- a/tests/e2e/self-serve-wash.spec.js +++ b/tests/e2e/self-serve-wash.spec.js @@ -127,6 +127,18 @@ function captureSelfServeGatewayRequests(page) { return captured; } +function waitForQuestionAnswerRequest(page, question, value) { + return page.waitForRequest((request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith("/department/selfserve/vehicle/conditions") || request.method() !== "POST") { + return false; + } + + const body = request.postDataJSON?.() || {}; + return Number(body.question) === Number(question) && body.value === value; + }); +} + test.describe("Self-serve wash", () => { test.describe.configure({ timeout: 120_000 }); @@ -485,6 +497,68 @@ test.describe("Self-serve wash", () => { expect(requests.commands).toHaveLength(0); }); + test("question answers stay clickable while answer sync refreshes in the background", async ({ page }) => { + const requests = captureSelfServeGatewayRequests(page); + + const api = await mockApi(page, { + authenticated: true, + permissions: ["user"], + selfServe: true, + }); + api.selfServe.answerResponseDelayMs = 1000; + const followUpQuestion = { + id: 21, + question: "Are the side mirrors folded?", + description: "Can be answered while the first answer syncs.", + answer: null, + order_priority: 2, + }; + const preview = api.selfServe.previewByKey["7:AB12345"]; + const initialQuestions = [...preview.questions, followUpQuestion]; + preview.questions = initialQuestions; + api.selfServe.summaryByKey["7:AB12345"].questions = initialQuestions; + api.selfServe.answerResponseByKey["7:AB12345:11:true"].questions = [ + { ...initialQuestions[0], answer: true }, + followUpQuestion, + ]; + api.selfServe.answerResponseByKey["7:AB12345:21:false"] = { + ...api.selfServe.answerResponseByKey["7:AB12345:11:true"], + questions: [initialQuestions[0], { ...followUpQuestion, answer: false }], + }; + await primeSession(page, { + token: "self-serve-background-answer-token", + permissions: ["user"], + }); + + await page.goto("/user/wash/start"); + await fillRegistration(page, "ab12345"); + await selectVehicleType(page, 2); + await page.getByTestId("self-serve-nav-next").click(); + + await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("self-serve-question-21")).toBeVisible(); + + const firstAnswerRequest = waitForQuestionAnswerRequest(page, 11, true); + await page.getByTestId("self-serve-question-11-yes").click(); + await firstAnswerRequest; + + await expect(page.getByTestId("self-serve-questions-inline-loading")).toBeVisible(); + await expect(page.getByTestId("self-serve-question-11-no")).toBeEnabled(); + await expect(page.getByTestId("self-serve-question-21-no")).toBeEnabled(); + + const secondAnswerRequest = waitForQuestionAnswerRequest(page, 21, false); + await page.getByTestId("self-serve-question-21-no").click(); + await secondAnswerRequest; + + await expect.poll(() => requests.answers.length).toBe(2); + expect(requests.answers.map((entry) => entry.body)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ question: 11, value: true }), + expect.objectContaining({ question: 21, value: false }), + ]) + ); + }); + test("allowed-services edge gateway timeout stays on questions and can be retried", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); @@ -547,6 +621,7 @@ test.describe("Self-serve wash", () => { licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, + radioWashType: "Machine", customerNumberInput: "12345679", completedTasks: {}, }); @@ -591,6 +666,43 @@ test.describe("Self-serve wash", () => { await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); }); + test("manual wash hides machine tasks when the machine service is allowed", async ({ page }) => { + await seedSavedProgress(page, { + washInProgress: true, + washLaneId: 7, + washStartTime: Date.now() - 30_000, + currentStep: 3, + licensePlateInput: "AB12345", + vehicleTypeSelect: 2, + radioLaneOption: 7, + radioWashType: "Manual", + customerNumberInput: "12345679", + completedTasks: {}, + }); + + const api = await mockApi(page, { + authenticated: true, + permissions: ["user"], + selfServe: true, + }); + const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"]; + api.selfServe.previewByKey["7:AB12345"].questions = answeredSummary.questions; + api.selfServe.summaryBySessionId[501] = answeredSummary; + api.selfServe.summaryByKey["7:AB12345"] = answeredSummary; + await primeSession(page, { + token: "self-serve-manual-task-token", + permissions: ["user"], + }); + + await page.goto("/user/wash/start"); + + await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("self-serve-dynamic-image")).toBeHidden(); + await expect(page.getByTestId("self-serve-task-9001")).toBeHidden(); + await expect(page.getByTestId("self-serve-task-9002")).toBeHidden(); + await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled(); + }); + test("in-progress flow opens property access and exit gates via lane command endpoint", async ({ page }) => { await seedSavedProgress(page, { washInProgress: true, @@ -651,6 +763,9 @@ test.describe("Self-serve wash", () => { await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-start-page")).toBeVisible(); + const selfServeSteps = page.getByTestId("self-serve-steps"); + await expect(selfServeSteps.locator(".steps.mobile-compact")).toBeVisible(); + await expect.poll(async () => selfServeSteps.locator(".step-item:visible").count()).toBeGreaterThanOrEqual(4); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await expect(page.getByTestId("self-serve-nav-next")).toBeVisible(); diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index 4f18e3ed..f705a9e6 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -6108,6 +6108,7 @@ export async function mockApi(page, options = {}) { const key = `${body.lane}:${String(body.reg || "").toUpperCase()}:${body.question}:${body.value}`; const responseSummary = selfServe.answerResponseByKey[key] || null; + await maybeDelayFixtureResponse(selfServe.answerResponseDelayMs); await route.fulfill( json({ data: { @@ -6908,7 +6909,7 @@ export async function primeMockSession(page, { token = "e2e-token", bootPath = " (response) => { return response.request().method() === "GET" && response.url().includes("/auth/session"); }, - { timeout: 10_000 } + { timeout: 30_000 } ) .catch(() => null); await page.goto(bootPath, { waitUntil: "domcontentloaded" }); diff --git a/tests/unit/my-wash-start.spec.js b/tests/unit/my-wash-start.spec.js index c4764400..adc6b6f3 100644 --- a/tests/unit/my-wash-start.spec.js +++ b/tests/unit/my-wash-start.spec.js @@ -288,14 +288,36 @@ const stubComponents = { }, SelfServeQuestionsStep: { emits: ["answer-question"], - template: "", + template: ` +
+ + +
+ `, }, SelfServeLaneStep: { - emits: ["update:selected-lane-id"], - template: "", + emits: ["update:selected-lane-id", "update:washType"], + template: ` +
+ + +
+ `, }, SelfServeTasksStep: { - template: "
", + props: ["activeTasks", "dynamicImageUrl"], + template: ` +
+ + + {{ task.task }} + +
+ `, }, SelfServeGuidedInstructions: { template: "
", @@ -322,6 +344,13 @@ describe("MyWashStart", () => { }; mocks.guestDepartments.value = [mocks.nearestDepartment.value]; mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled.value = true; + mocks.answers.value = {}; + mocks.completedTasks.value = {}; + mocks.visibleQuestions.value = [{ id: 11, question: "Question 11" }]; + mocks.activeTasks.value = []; + mocks.allowedServices.value = ["MACHINE"]; + mocks.conditions.value = []; + mocks.rules.value = []; mocks.fetchDepartments.mockClear(); mocks.startAutoRefresh.mockClear(); mocks.stopAutoRefresh.mockClear(); @@ -397,6 +426,93 @@ describe("MyWashStart", () => { }); }); + it("hides machine tasks while manual wash is selected", async () => { + mocks.activeTasks.value = [ + { id: 31, task: "Machine checklist", services: ["MACHINE"] }, + { id: 32, task: "Manual bay prep", services: ["GATE"] }, + { id: 33, task: "General instruction", services: [] }, + ]; + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + }); + + await flushPromises(); + + expect(wrapper.find('[data-testid="rendered-task-31"]').exists()).toBe(false); + expect(wrapper.get('[data-testid="rendered-task-32"]').text()).toContain("Manual bay prep"); + expect(wrapper.get('[data-testid="rendered-task-33"]').text()).toContain("General instruction"); + expect(wrapper.find('[data-testid="tasks-dynamic-image-stub"]').exists()).toBe(false); + }); + + it("shows machine tasks after machine wash is selected", async () => { + mocks.activeTasks.value = [ + { id: 31, task: "Machine checklist", services: ["MACHINE"] }, + { id: 32, task: "Manual bay prep", services: ["GATE"] }, + ]; + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + }); + + await flushPromises(); + expect(wrapper.find('[data-testid="rendered-task-31"]').exists()).toBe(false); + + await wrapper.get('[data-testid="emit-machine-wash"]').trigger("click"); + await nextTick(); + + expect(wrapper.get('[data-testid="rendered-task-31"]').text()).toContain("Machine checklist"); + expect(wrapper.get('[data-testid="rendered-task-32"]').text()).toContain("Manual bay prep"); + expect(wrapper.find('[data-testid="tasks-dynamic-image-stub"]').exists()).toBe(true); + }); + + it("accepts another question answer while the previous answer sync is still pending", async () => { + let resolveFirstSync; + mocks.syncVehicleAnswer.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstSync = resolve; + }) + ); + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + }); + + await flushPromises(); + await wrapper.get('[data-testid="emit-lane"]').trigger("click"); + await wrapper.get('[data-testid="emit-registration"]').trigger("click"); + await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click"); + await nextTick(); + + await wrapper.get('[data-testid="emit-answer"]').trigger("click"); + await nextTick(); + await wrapper.get('[data-testid="emit-answer-21"]').trigger("click"); + await flushPromises(); + + expect(mocks.answerQuestion).toHaveBeenCalledWith(11, true); + expect(mocks.answerQuestion).toHaveBeenCalledWith(21, false); + expect(mocks.syncVehicleAnswer).toHaveBeenCalledTimes(2); + expect(mocks.syncVehicleAnswer).toHaveBeenNthCalledWith(2, { + departmentId: 6, + laneId: 7, + customerNumber: 12345679, + reg: "AB12345", + questionId: 21, + value: false, + vehicleTypeId: 2, + }); + + resolveFirstSync?.(); + await flushPromises(); + }); + it("shows disabled warning without showing loading state when department self-serve is unavailable", async () => { mocks.nearestDepartment.value = { ...mocks.nearestDepartment.value, diff --git a/tests/unit/self-serve-questions-step.spec.js b/tests/unit/self-serve-questions-step.spec.js index aa6b759f..30626148 100644 --- a/tests/unit/self-serve-questions-step.spec.js +++ b/tests/unit/self-serve-questions-step.spec.js @@ -6,8 +6,6 @@ import { mountWithApp } from "./helpers/mountWithApp.js"; describe("SelfServeQuestionsStep", () => { const baseProps = { isLoading: false, - loadingQuestionId: null, - loadingAnswerValue: null, visibleQuestions: [{ id: 11, question: "Is the trailer closed?" }], answers: { 11: true }, editAnswers: true, @@ -51,21 +49,23 @@ describe("SelfServeQuestionsStep", () => { expect(wrapper.find('[data-testid="self-serve-question-cards"]').exists()).toBe(false); }); - it("keeps the question list visible and only locks the question being synced", () => { + it("keeps the question list visible and answer buttons enabled while loading in the background", async () => { const wrapper = mountWithApp(SelfServeQuestionsStep, { props: { ...baseProps, isLoading: true, - loadingQuestionId: 11, - loadingAnswerValue: true, }, }); expect(wrapper.find('[data-testid="self-serve-questions-inline-loading"]').exists()).toBe(true); expect(wrapper.find('[data-testid="self-serve-question-cards"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="self-serve-question-11-yes"]').classes()).toContain("is-loading"); - expect(wrapper.find('[data-testid="self-serve-question-11-no"]').classes()).not.toContain("is-loading"); - expect(wrapper.find('[data-testid="self-serve-question-11-yes"]').attributes("disabled")).toBeDefined(); - expect(wrapper.find('[data-testid="self-serve-question-11-no"]').attributes("disabled")).toBeDefined(); + + const yesButton = wrapper.get('[data-testid="self-serve-question-11-yes"]'); + const noButton = wrapper.get('[data-testid="self-serve-question-11-no"]'); + expect(yesButton.attributes("disabled")).toBeUndefined(); + expect(noButton.attributes("disabled")).toBeUndefined(); + + await noButton.trigger("click"); + expect(wrapper.emitted("answer-question")).toEqual([[11, false]]); }); }); diff --git a/tests/unit/self-serve-studio-task-scope.spec.js b/tests/unit/self-serve-studio-task-scope.spec.js index 9292fcd1..26136284 100644 --- a/tests/unit/self-serve-studio-task-scope.spec.js +++ b/tests/unit/self-serve-studio-task-scope.spec.js @@ -51,6 +51,21 @@ describe("self-serve studio task editing", () => { expect(source).toContain('@error="onSimulatorDynamicImageError"'); }); + it("renders task attachments in the flow nodes and simulator without saving them into draft tasks", () => { + const source = studioSource(); + + expect(source).toContain("const taskAttachmentList = (dataOrRaw) => {"); + expect(source).toContain("const selectedTaskAttachments = computed(() => ("); + expect(source).toContain("const uploadSelectedTaskAttachment = async (event) => {"); + expect(source).toContain("const deleteSelectedTaskAttachment = async (attachment) => {"); + expect(source).toContain('data-testid="studio-task-node-attachments"'); + expect(source).toContain('data-testid="studio-task-attachment-manager"'); + expect(source).toContain('data-testid="studio-task-attachment-upload"'); + expect(source).toContain('class="studio-debug-attachment-list"'); + expect(source).toContain('@click.stop="openTaskAttachment(attachment)"'); + expect(source).toContain("delete data.attachments"); + }); + it("renders machine type and dynamic image filters in the studio toolbar", () => { const source = studioSource(); diff --git a/tests/unit/self-serve-task-list.spec.js b/tests/unit/self-serve-task-list.spec.js index e67f4767..650daeef 100644 --- a/tests/unit/self-serve-task-list.spec.js +++ b/tests/unit/self-serve-task-list.spec.js @@ -19,6 +19,10 @@ const BCheckboxStub = { describe("SelfServeTaskList", () => { it("emits task toggles and attachment downloads", async () => { + const attachments = [ + { id: 101, content: { other: "instruction.pdf" } }, + { id: 102, content: { other: "mirror.jpg" }, download_link: "https://cdn.example.test/mirror.jpg" }, + ]; const wrapper = mountWithApp(SelfServeTaskList, { props: { tasks: [ @@ -28,10 +32,7 @@ describe("SelfServeTaskList", () => { description: "Use the soft brush", services: ["MACHINE"], buttons: [1, 2], - attachments: [ - { id: 101, content: { other: "instruction.pdf" } }, - { id: 102, content: { other: "mirror.jpg" }, download_link: "https://cdn.example.test/mirror.jpg" }, - ], + attachments, }, ], completedTasks: { @@ -56,8 +57,8 @@ describe("SelfServeTaskList", () => { [5, true], ]); expect(wrapper.emitted("download-attachment")).toEqual([ - [5, 101], - [5, 102], + [5, 101, attachments[0]], + [5, 102, attachments[1]], ]); expect(wrapper.text()).not.toContain("MACHINE"); expect(wrapper.text()).not.toContain("Button 1"); diff --git a/tests/unit/self-serve-try-modal.spec.js b/tests/unit/self-serve-try-modal.spec.js index aa63b24a..9a277c67 100644 --- a/tests/unit/self-serve-try-modal.spec.js +++ b/tests/unit/self-serve-try-modal.spec.js @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ fetchSelfServeData: vi.fn(), syncVehicleAnswer: vi.fn(), clearVehicleAnswers: vi.fn(), + downloadAttachment: vi.fn(), swalFire: vi.fn(), getLanes: vi.fn(), getVehicleTypeOptions: vi.fn(), @@ -78,6 +79,7 @@ vi.mock("@/composables/useSelfServeLogic", () => ({ syncVehicleAnswer: mocks.syncVehicleAnswer, clearVehicleAnswers: mocks.clearVehicleAnswers, evaluateCondition: mocks.evaluateCondition, + downloadAttachment: mocks.downloadAttachment, }), })); @@ -89,6 +91,7 @@ describe("SelfServeTryModal", () => { mocks.fetchSelfServeData.mockReset(); mocks.syncVehicleAnswer.mockReset(); mocks.clearVehicleAnswers.mockReset(); + mocks.downloadAttachment.mockReset(); mocks.swalFire.mockReset(); mocks.getLanes.mockReset(); mocks.getVehicleTypeOptions.mockReset(); @@ -146,6 +149,44 @@ describe("SelfServeTryModal", () => { expect(mocks.fetchSelfServeData).toHaveBeenCalledWith(9, 3, 3, "AB12345"); }); + it("passes simulator task attachment downloads to self-serve logic", async () => { + const attachment = { + id: 201, + content: { other: "manual.pdf" }, + download_link: "https://cdn.example.test/manual.pdf", + }; + mocks.activeTasks.value = [ + { + id: 5, + task: "Prepare", + attachments: [attachment], + }, + ]; + + const wrapper = mountWithApp(SelfServeTryModal, { + props: { + departmentId: 9, + laneId: 3, + }, + global: { + stubs: { + SelfServeQuestionCards: true, + SelfServeTaskList: { + props: ["tasks"], + emits: ["download-attachment"], + template: + "", + }, + }, + }, + }); + + await flushPromises(); + await wrapper.get("[data-testid='emit-task-download']").trigger("click"); + + expect(mocks.downloadAttachment).toHaveBeenCalledWith(5, 201, attachment); + }); + it("syncs answers with the selected vehicle type context", async () => { mocks.visibleQuestions.value = [{ id: 77, question: "Question 77" }]; mocks.currentQuestion.value = null; diff --git a/tests/unit/use-self-serve-logic.spec.js b/tests/unit/use-self-serve-logic.spec.js index f9616c66..4cad43f4 100644 --- a/tests/unit/use-self-serve-logic.spec.js +++ b/tests/unit/use-self-serve-logic.spec.js @@ -46,16 +46,21 @@ describe("useSelfServeLogic", () => { mocks.attachmentsList.mockReset(); mocks.attachmentsDownload.mockReset(); mocks.request.mockReset(); + vi.unstubAllGlobals(); }); - it("hydrates preview and summary data, including attachment download links", async () => { + it("uses task attachments supplied by preview and summary data", async () => { + const taskAttachments = [ + { id: 201, content: { other: "manual.pdf" }, download_link: "https://cdn.example.test/manual.pdf" }, + { id: 202, content: { other: "photo.jpg" }, download_link: "https://cdn.example.test/photo.jpg" }, + ]; mocks.previewAllowed.mockResolvedValue({ allowed: true, machine_available: true, lane: { id: 7, name: "Lane 7" }, session: { id: 91, status: "IN_PROGRESS" }, questions: [{ id: 1, question: "Machine wash?", answer: null, order_priority: 1 }], - tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }], + tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"], attachments: taskAttachments }], conditions: [], rules: [], allowed_services: ["MACHINE"], @@ -64,20 +69,9 @@ describe("useSelfServeLogic", () => { session: { id: 91, status: "IN_PROGRESS" }, lane: { id: 7, name: "Lane 7" }, questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }], - tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }], + tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"], attachments: taskAttachments }], events: [{ id: 1, type: "STARTED", created_at: "2026-01-01T00:00:00.000Z" }], }); - mocks.attachmentsList.mockResolvedValue({ - data: [ - { id: 201, content: { other: "manual.pdf" } }, - { id: 202, content: { other: "photo.jpg" } }, - ], - }); - mocks.attachmentsDownload.mockImplementation(async (_taskId, attachmentId) => - attachmentId === 202 - ? { download_link: "https://cdn.example.test/photo.jpg" } - : { download_link: "https://cdn.example.test/manual.pdf" } - ); const logic = useSelfServeLogic(); @@ -89,12 +83,29 @@ describe("useSelfServeLogic", () => { expect(logic.machineAvailable.value).toBe(true); expect(logic.answers.value[1]).toBe(true); expect(logic.events.value).toHaveLength(1); + expect(mocks.attachmentsList).not.toHaveBeenCalled(); + expect(mocks.attachmentsDownload).not.toHaveBeenCalled(); expect(logic.tasks.value[0].attachments[1]).toMatchObject({ id: 202, download_link: "https://cdn.example.test/photo.jpg", }); }); + it("opens an existing task attachment link without requesting a new download link", async () => { + const openSpy = vi.fn(); + vi.stubGlobal("window", { open: openSpy }); + const logic = useSelfServeLogic(); + + await logic.downloadAttachment(5, 202, { + id: 202, + download_link: "https://cdn.example.test/photo.jpg", + content: { other: "photo.jpg" }, + }); + + expect(mocks.attachmentsDownload).not.toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith("https://cdn.example.test/photo.jpg", "_blank", "noopener"); + }); + it("falls back to lane/reg summary when session summary does not include questions", async () => { mocks.previewAllowed.mockResolvedValue({ allowed: true, diff --git a/vite.config.js b/vite.config.js index 485d1502..afee72ea 100644 --- a/vite.config.js +++ b/vite.config.js @@ -203,6 +203,7 @@ export default defineConfig(({ mode }) => { 'import.meta.env.VITE_BUILD_DATE': JSON.stringify(new Date().toISOString()), 'import.meta.env.VITE_APP_VERSION': JSON.stringify(process.env.APP_VERSION || '0.0.0'), 'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(commit), + 'import.meta.env.VITE_IS_PLAYWRIGHT': JSON.stringify(isPlaywrightRuntime), // Tip: import.meta.env.DEV/PROD are available at runtime 'import.meta.env.VITE_IS_DEV': JSON.stringify(!isProd), },