// @vitest-environment jsdom import { flushPromises } from "@vue/test-utils"; import { nextTick } from "vue"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const asMockRef = (value) => ({ value, __v_isRef: true }); const nearestDepartment = { value: { id: 1, name: "Copenhagen", address: "Main street 1", self_serve_enabled: true, lanes: [{ id: 7, name: "7", status: "AVAILABLE", products: [2], machine_available: true }], }, }; const guestDepartments = { value: [nearestDepartment.value] }; const doesCurrentDepartmentSelectionHaveSelfServeEnabled = asMockRef(true); const answers = { value: {} }; const completedTasks = { value: {} }; const visibleQuestions = { value: [{ id: 11, question: "Question 11" }] }; const activeTasks = { value: [] }; const conditions = { value: [] }; const rules = { value: [] }; const allowedServices = { value: ["MACHINE"] }; return { nearestDepartment, guestDepartments, doesCurrentDepartmentSelectionHaveSelfServeEnabled, answers, completedTasks, visibleQuestions, activeTasks, conditions, rules, allowedServices, fetchDepartments: vi.fn(async () => guestDepartments.value), startAutoRefresh: vi.fn(), stopAutoRefresh: vi.fn(), startDepartmentSearch: vi.fn(), selectDepartment: vi.fn(), clearForcedDepartment: vi.fn(), evaluateLocationDepartments: vi.fn(), fetchSelfServeDataInternal: vi.fn(), fetchWashSummary: vi.fn(), syncVehicleAnswer: vi.fn(), updateLaneAllowedServices: vi.fn(), enableMachineRelay: vi.fn(), downloadAttachment: vi.fn(), answerQuestion: vi.fn((questionId, value) => { answers.value = { ...answers.value, [questionId]: value }; }), restoredProgressPayload: null, saveProgress: vi.fn(), clearProgress: vi.fn(), restoreProgress: vi.fn(), startElapsedTimer: vi.fn(), stopElapsedTimer: vi.fn(), registerBeforeUnload: vi.fn(() => vi.fn()), markDestroying: vi.fn(), handleConfirmNext: vi.fn(), isNextButtonDisabled: vi.fn(() => false), onStartWash: vi.fn(), onStopWash: vi.fn(), openPropertyAccessGate: vi.fn(), openPropertyExitGate: vi.fn(), setShowFooterInContent: vi.fn(), fetchCustomerVehicles: vi.fn(), fetchVehicleTypeOptions: vi.fn(), addVehicle: vi.fn(), sessionRequest: vi.fn(), }; }); vi.mock("@/components/session/token/SessionUser.vue", () => ({ SessionUser: { user: { customer_number: { value: 12345 }, }, canAccessSuperUser: () => true, request: vi.fn(async (...args) => { mocks.sessionRequest(...args); return { status: 200, data: { data: {} } }; }), functions: { contact: { onClickCallPhoneNumber: vi.fn(), }, }, objects: { vehicles: { add: vi.fn(async (...args) => { mocks.addVehicle(...args); return { status: 200, data: { data: {} } }; }), get: { all: vi.fn(async () => { mocks.fetchCustomerVehicles(); return [{ reg: "AB12345", type: 2 }]; }), }, columns: { type: { options: vi.fn(async () => { mocks.fetchVehicleTypeOptions(); return [{ id: 2, name: "Truck", product: { id: 2, description: "Truck" } }]; }), }, }, }, }, }, })); vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({ locations: { location: { value: { coords: { latitude: 55.6, longitude: 12.5, }, }, }, }, })); vi.mock("@/components/viewport/conditions/ViewPortFooterOptions.vue", () => ({ setShowFooterInContent: mocks.setShowFooterInContent, })); vi.mock("@/composables/useWashDepartments", () => ({ useWashDepartments: () => ({ guestDepartments: mocks.guestDepartments, nearestDepartment: mocks.nearestDepartment, isForcingNearestDepartment: { value: false }, forceNearestDepartmentEvaluationId: { value: 0 }, isSearchingDepartments: { value: false }, availableProductIds: { value: [2] }, doesCurrentDepartmentSelectionHaveSelfServeEnabled: mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled, fetchDepartments: mocks.fetchDepartments, evaluateLocationDepartments: mocks.evaluateLocationDepartments, startDepartmentSearch: mocks.startDepartmentSearch, selectDepartment: mocks.selectDepartment, clearForcedDepartment: mocks.clearForcedDepartment, startAutoRefresh: mocks.startAutoRefresh, stopAutoRefresh: mocks.stopAutoRefresh, }), })); vi.mock("@/composables/useSelfServeLogic", () => ({ useSelfServeLogic: () => ({ loading: { value: false }, lane: { value: { id: 7 } }, questions: { value: [] }, conditions: mocks.conditions, rules: mocks.rules, tasks: { value: [] }, answers: mocks.answers, completedTasks: mocks.completedTasks, allowedServices: mocks.allowedServices, visibleQuestions: mocks.visibleQuestions, activeTasks: mocks.activeTasks, allVisibleQuestionsAnswered: { value: false }, machineAvailable: { value: true }, fetchSelfServeData: mocks.fetchSelfServeDataInternal, fetchWashSummary: mocks.fetchWashSummary, syncVehicleAnswer: mocks.syncVehicleAnswer, evaluateRule: () => true, evaluateCondition: () => true, isQuestionVisible: () => true, isServiceAllowed: () => true, updateLaneAllowedServices: mocks.updateLaneAllowedServices, enableMachineRelay: mocks.enableMachineRelay, downloadAttachment: mocks.downloadAttachment, answerQuestion: mocks.answerQuestion, }), })); vi.mock("@/composables/useWashProgress", () => ({ useWashProgress: (options = {}) => ({ completedDurationMs: { value: null }, now: { value: Date.now() }, isRestoring: { value: false }, formattedElapsed: { value: "00:00" }, saveProgress: mocks.saveProgress, clearProgress: mocks.clearProgress, restoreProgress: vi.fn(() => { mocks.restoreProgress(); if (mocks.restoredProgressPayload && typeof options.applyRestoredState === "function") { options.applyRestoredState(mocks.restoredProgressPayload); } return mocks.restoredProgressPayload || null; }), startElapsedTimer: mocks.startElapsedTimer, stopElapsedTimer: mocks.stopElapsedTimer, registerBeforeUnload: mocks.registerBeforeUnload, markDestroying: mocks.markDestroying, }), })); vi.mock("@/composables/useWashFlowState", () => ({ useWashFlowState: () => ({ steps: { VEHICLE: 0, QUESTIONS: 1, SELECT_LANE: 2, TASKS: 3, WASH_IN_PROGRESS: 4, COMPLETED: 5, }, clickableSteps: { 0: () => true, 1: () => true, 2: () => true, 3: () => true, 4: () => false, }, isNextButtonDisabled: mocks.isNextButtonDisabled, handleConfirmNext: mocks.handleConfirmNext, targetStepForStart: () => 3, }), })); vi.mock("@/composables/useWashSessionActions", async () => { const { ref } = await import("vue"); return { useWashSessionActions: () => ({ dynamicImageUrl: ref("https://cdn.example.test/dynamic.png"), onStartWash: mocks.onStartWash, onStopWash: mocks.onStopWash, openPropertyAccessGate: mocks.openPropertyAccessGate, openPropertyExitGate: mocks.openPropertyExitGate, openingPropertyAccessGate: ref(false), openingPropertyExitGate: ref(false), }), }; }); import MyWashStart from "@/views/dashboards/userDashboard/wash/MyWashStart.vue"; import { mountWithApp } from "./helpers/mountWithApp.js"; const BStepsStub = { template: `
`, }; const BStepItemStub = { template: "
", }; const BButtonStub = { emits: ["click"], template: "", }; const stubComponents = { PosDepartmentStepMobile1Location: { template: "
", }, SelfServeDepartmentHeader: { template: "
", }, SelfServeVehicleStep: { emits: ["update:registration-number", "update:customer-number", "select-vehicle-type"], template: `
`, }, SelfServeQuestionsStep: { emits: ["answer-question"], template: "", }, SelfServeLaneStep: { emits: ["update:selected-lane-id"], template: "", }, SelfServeTasksStep: { template: "
", }, SelfServeGuidedInstructions: { template: "
", }, SelfServeCompletedStep: { template: "
", }, BSteps: BStepsStub, BStepItem: BStepItemStub, BButton: BButtonStub, BMessage: { template: "
", }, }; describe("MyWashStart", () => { beforeEach(() => { mocks.nearestDepartment.value = { id: 1, name: "Copenhagen", address: "Main street 1", self_serve_enabled: true, lanes: [{ id: 7, name: "7", status: "AVAILABLE", products: [2], machine_available: true }], }; mocks.guestDepartments.value = [mocks.nearestDepartment.value]; mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled.value = true; mocks.fetchDepartments.mockClear(); mocks.startAutoRefresh.mockClear(); mocks.stopAutoRefresh.mockClear(); mocks.restoreProgress.mockClear(); mocks.registerBeforeUnload.mockClear(); mocks.markDestroying.mockClear(); mocks.fetchCustomerVehicles.mockClear(); mocks.fetchVehicleTypeOptions.mockClear(); mocks.fetchSelfServeDataInternal.mockClear(); mocks.restoredProgressPayload = null; mocks.syncVehicleAnswer.mockClear(); mocks.answerQuestion.mockClear(); mocks.setShowFooterInContent.mockClear(); mocks.addVehicle.mockClear(); mocks.sessionRequest.mockClear(); mocks.openPropertyAccessGate.mockClear(); mocks.openPropertyExitGate.mockClear(); }); it("loads initial wash context and tears down refresh state on unmount", async () => { const wrapper = mountWithApp(MyWashStart, { global: { stubs: stubComponents, }, }); await flushPromises(); expect(mocks.setShowFooterInContent).toHaveBeenCalledWith(false); expect(mocks.fetchDepartments).toHaveBeenCalledTimes(1); expect(mocks.startAutoRefresh).toHaveBeenCalledTimes(1); expect(mocks.fetchCustomerVehicles).toHaveBeenCalledTimes(1); expect(mocks.fetchVehicleTypeOptions).toHaveBeenCalledTimes(1); expect(mocks.restoreProgress).toHaveBeenCalledTimes(1); expect(mocks.registerBeforeUnload).toHaveBeenCalledTimes(1); wrapper.unmount(); expect(mocks.stopAutoRefresh).toHaveBeenCalledTimes(1); expect(mocks.markDestroying).toHaveBeenCalledTimes(1); expect(mocks.setShowFooterInContent).toHaveBeenLastCalledWith(true); }); it("wires child updates back into the self-serve runtime", async () => { 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 flushPromises(); expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(1, 2, 7, "AB12345"); await wrapper.get('[data-testid="emit-answer"]').trigger("click"); await flushPromises(); expect(mocks.answerQuestion).toHaveBeenCalledWith(11, true); expect(mocks.syncVehicleAnswer).toHaveBeenCalledWith({ departmentId: 1, laneId: 7, customerNumber: 12345, reg: "AB12345", questionId: 11, value: true, }); }); it("shows disabled warning without showing loading state when department self-serve is unavailable", async () => { mocks.nearestDepartment.value = { ...mocks.nearestDepartment.value, self_serve_enabled: false, }; mocks.guestDepartments.value = [mocks.nearestDepartment.value]; mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled.value = false; const wrapper = mountWithApp(MyWashStart, { global: { stubs: stubComponents, }, }); await flushPromises(); expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(true); expect(wrapper.text()).not.toContain("self_wash.loading_data"); }); it("adds an unknown registration as customer vehicle before continuing from vehicle step", async () => { 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-unknown"]').trigger("click"); await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click"); await nextTick(); await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click"); await flushPromises(); expect(mocks.addVehicle).toHaveBeenCalledWith(2, "ZZ99999", false, 12345); expect(mocks.fetchCustomerVehicles.mock.calls.length).toBeGreaterThan(1); expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(1, 2, 7, "ZZ99999"); }); it("uses an effective lane when moving from vehicle to questions without manual lane selection", async () => { const wrapper = mountWithApp(MyWashStart, { global: { stubs: stubComponents, }, }); await flushPromises(); 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="self-serve-nav-next"]').trigger("click"); await flushPromises(); expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(1, 2, 7, "AB12345"); }); it("falls back to available lane when restored lane is stale for the selected department", async () => { mocks.restoredProgressPayload = { washInProgress: false, washLaneId: null, washStartTime: null, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioWashType: "Manual", radioLaneOption: 999, customerNumberInput: 12345, isForcingNearestDepartment: false, forceNearestDepartmentEvaluationId: 0, answers: {}, completedTasks: {}, currentStep: 1, }; const wrapper = mountWithApp(MyWashStart, { global: { stubs: stubComponents, }, }); await flushPromises(); expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(1, 2, 7, "AB12345"); wrapper.unmount(); }); it("does not auto-start wash when only the first visible question is answered", async () => { 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="self-serve-nav-next"]').trigger("click"); await flushPromises(); await wrapper.get('[data-testid="emit-answer"]').trigger("click"); await flushPromises(); expect(mocks.onStartWash).not.toHaveBeenCalled(); }); it("wires property gate buttons in the in-progress step", async () => { mocks.restoredProgressPayload = { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 20_000, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioWashType: "Manual", radioLaneOption: 7, customerNumberInput: 12345, isForcingNearestDepartment: false, forceNearestDepartmentEvaluationId: 0, answers: {}, completedTasks: {}, currentStep: 4, }; const wrapper = mountWithApp(MyWashStart, { global: { stubs: stubComponents, }, }); await flushPromises(); await wrapper.get('[data-testid="self-serve-nav-open-property-access-gate"]').trigger("click"); await wrapper.get('[data-testid="self-serve-nav-open-property-exit-gate"]').trigger("click"); expect(mocks.openPropertyAccessGate).toHaveBeenCalledWith(7); expect(mocks.openPropertyExitGate).toHaveBeenCalledWith(7); }); });