Merge branch 'master' into create-playwright-test-suite-for-mywashstart-6chtnh

This commit is contained in:
Jeppe B
2026-06-02 13:50:24 +02:00
committed by GitHub
13 changed files with 1470 additions and 70 deletions
@@ -0,0 +1,33 @@
# User wash start production-readiness QA matrix
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
## Unit matrix
| Area | Required scenario | Coverage |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
## E2E mocked matrix
| Required scenario | Coverage |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
## Optional live smoke
| Optional scenario | Coverage |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
+42 -3
View File
@@ -297,6 +297,30 @@ export function useSelfServeLogic() {
const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({});
const latestFetchRequestId = ref(0);
const latestSuccessfulFetchKey = ref(null);
const inFlightFetchKey = ref(null);
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
const normalizedDepartmentId = parseInt(departmentId);
const normalizedLaneId = parseInt(laneId);
const normalizedReg = String(reg || "").trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
? normalizedVehicleTypeId
: "";
if (
Number.isNaN(normalizedDepartmentId)
|| normalizedDepartmentId <= 0
|| Number.isNaN(normalizedLaneId)
|| normalizedLaneId <= 0
|| normalizedReg.length < 2
) {
return null;
}
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
};
const beginFetchRequest = () => {
latestFetchRequestId.value += 1;
@@ -575,10 +599,17 @@ export function useSelfServeLogic() {
}
};
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
return null;
}
const requestId = beginFetchRequest();
if (!laneId || !reg || reg.trim().length < 2) {
latestSuccessfulFetchKey.value = null;
inFlightFetchKey.value = null;
preview.value = null;
summary.value = null;
session.value = null;
@@ -606,6 +637,7 @@ export function useSelfServeLogic() {
loading.value = true;
requestError.value = null;
inFlightFetchKey.value = fetchKey;
try {
const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -687,6 +719,10 @@ export function useSelfServeLogic() {
setSummaryVisibleQuestions(questions.value);
}
if (isFetchRequestActive(requestId)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData;
} catch (error) {
console.error("Error fetching self-serve preview:", error);
@@ -695,6 +731,9 @@ export function useSelfServeLogic() {
}
return null;
} finally {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
if (isFetchRequestActive(requestId)) {
loading.value = false;
}
@@ -749,7 +788,7 @@ export function useSelfServeLogic() {
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -814,7 +853,7 @@ export function useSelfServeLogic() {
? normalizedRefreshVehicleTypeId
: null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
+22 -5
View File
@@ -14,6 +14,9 @@ const isLaneSelfServeEnabled = (lane) =>
)
);
const hasSelfServeEnabledLane = (department) =>
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
const toDepartmentViewModel = (department, distance = null) => ({
id: department.id,
distance,
@@ -47,6 +50,7 @@ export function useWashDepartments(options = {}) {
const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null);
const departmentSelectionStrategy = ref(null);
let refreshInterval = null;
@@ -73,6 +77,12 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value.self_serve_enabled === true;
});
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
const fetchDepartments = async () => {
@@ -110,20 +120,24 @@ export function useWashDepartments(options = {}) {
const forcedDepartment = getForcedDepartment();
if (forcedDepartment) {
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
departmentSelectionStrategy.value = "forced";
return nearestDepartment.value;
}
}
if (!hasLocationCoordinates(locationValue)) {
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
return nearestDepartment.value;
}
let currentNearestDepartment = {
id: null,
distance: Infinity,
};
guestDepartments.value.forEach((department) => {
if (!locationValue?.coords) {
return;
}
const from = {
latitude: locationValue.coords.latitude,
longitude: locationValue.coords.longitude,
@@ -174,7 +188,7 @@ export function useWashDepartments(options = {}) {
};
const startDepartmentSearch = () => {
if (!canAccessSuperUser()) {
if (!canAccessSuperUser() && hasLocationCoordinates()) {
return;
}
@@ -252,6 +266,9 @@ export function useWashDepartments(options = {}) {
isSearchingDepartments,
lastDepartmentFetchTime,
departmentFetchError,
departmentSelectionStrategy,
isDepartmentSelectionDistanceBased,
isDepartmentSelectionFallbackBased,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
@@ -1,6 +1,6 @@
<script setup>
import { computed, onMounted } from "vue";
import { BLoading } from "buefy";
import { BLoading, BMessage } from "buefy";
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
@@ -10,12 +10,61 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
const {
guestDepartments,
nearestDepartment,
isDepartmentSelectionFallbackBased,
fetchDepartments,
evaluateLocationDepartments,
orderDepartmentsByDistance,
} = useWashDepartments();
selectDepartment,
} = useWashDepartments({ includeLanes: true });
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true;
const isLaneSelfServeEnabled = (lane) =>
!(
lane?.selfserve_enabled === false ||
lane?.selfserve_enabled === 0 ||
lane?.selfserve_enabled === "0" ||
["false", "off", "no"].includes(
String(lane?.selfserve_enabled ?? "")
.trim()
.toLowerCase()
)
);
const isLaneReadyForSelfServe = (lane) => lane?.status === "AVAILABLE" && isLaneSelfServeEnabled(lane);
const canDepartmentStartSelfServe = (department) =>
isDepartmentSelfServeEnabled(department) && (department?.lanes || []).some(isLaneReadyForSelfServe);
const nearestDepartmentCanStartSelfServe = computed(() => canDepartmentStartSelfServe(nearestDepartment.value));
const eligibleDepartments = computed(() => guestDepartments.value.filter(canDepartmentStartSelfServe));
const eligibleAlternativeDepartments = computed(() =>
eligibleDepartments.value.filter((department) => department.id !== nearestDepartment.value?.id)
);
const hasEligibleAlternativeDepartment = computed(() => eligibleAlternativeDepartments.value.length > 0);
const selfServeUnavailableMessage = computed(() => {
if (!nearestDepartment.value) {
return "Vi kan ikke finde din nærmeste afdeling uden en placering. Vælg en selvvask-klar afdeling nedenfor.";
}
if (!isDepartmentSelfServeEnabled(nearestDepartment.value)) {
return `${nearestDepartment.value.name} er ikke aktiveret til selvvask lige nu.`;
}
if (!(nearestDepartment.value.lanes || []).some(isLaneReadyForSelfServe)) {
return `${nearestDepartment.value.name} har ingen ledige selvvask-baner lige nu.`;
}
return null;
});
const getDepartmentDistance = (department) => {
if (!locations.location.value?.coords) {
@@ -46,27 +95,56 @@ onMounted(async () => {
<div class="column is-12">
<WhiteBoxCard :force-state="true" :defaultOpen="true" :toggleable="false" :loading="false">
<template #header>
<div class="card-header-title has-text-link has-text-weight-bold" data-testid="self-serve-home-nearest-name">
{{ nearestDepartment ? nearestDepartment.name : $t("common.loading") }}
<div
class="card-header-title has-text-link has-text-weight-bold"
data-testid="self-serve-home-nearest-name"
>
{{ nearestDepartment ? nearestDepartment.name : $t("global.search_departments") }}
</div>
<div class="card-header-icon has-text-link">
<i class="fas fa-map-marker-alt"></i>
</div>
</template>
<template #content>
{{ $t("user_dashboard.wash.nearest_department", { name: nearestDepartment ? nearestDepartment.name : "-", distance: nearestDepartment ? nearestDepartment.distance.toFixed(2) : "-" }) }}
<b-loading :is-full-page="false" :model-value="!nearestDepartment" :can-cancel="true" />
<template v-if="nearestDepartment && isDepartmentSelectionFallbackBased">
Vælg en afdeling for at fortsætte. Vi har valgt den første tilgængelige selvvaskeafdeling, fordi din
placering ikke er tilgængelig.
</template>
<template v-else-if="nearestDepartment">
{{
$t("user_dashboard.wash.nearest_department", {
name: nearestDepartment.name,
distance: nearestDepartment.distance !== null ? nearestDepartment.distance.toFixed(2) : "-",
})
}}
</template>
<template v-else-if="shouldShowChooseDepartmentMessage">
Vælg en afdeling for at starte vask, fordi din placering ikke er tilgængelig.
</template>
<template v-else>
{{ $t("common.loading") }}
<b-loading :is-full-page="false" :model-value="true" :can-cancel="true" />
</template>
</template>
<template #footer>
<div class="card-footer-item">
<router-link
v-if="nearestDepartment"
v-if="nearestDepartment && nearestDepartmentCanStartSelfServe"
to="/user/wash/start"
class="button is-link is-fullwidth"
data-testid="self-serve-home-start"
>
{{ $t("user_dashboard.wash.start_wash") }}
</router-link>
<button
v-else
type="button"
class="button is-link is-light is-fullwidth"
data-testid="self-serve-home-start-disabled"
disabled
>
Selvvask er ikke tilgængelig her
</button>
</div>
</template>
</WhiteBoxCard>
@@ -74,7 +152,7 @@ onMounted(async () => {
<template v-for="department in orderedDepartments" :key="department.id">
<div
v-if="nearestDepartment && department.id !== nearestDepartment.id"
v-if="!nearestDepartment || department.id !== nearestDepartment.id"
class="column is-12-mobile is-6-tablet is-4-desktop"
:data-testid="`self-serve-home-department-${department.id}`"
>
@@ -94,7 +172,20 @@ onMounted(async () => {
</template>
<template #footer>
<div class="card-footer-item">
<router-link :to="{ name: 'pos', params: { departmentId: department.id } }" class="button is-light is-fullwidth">
<button
v-if="canDepartmentStartSelfServe(department) && !nearestDepartmentCanStartSelfServe"
type="button"
class="button is-link is-fullwidth"
:data-testid="`self-serve-home-select-department-${department.id}`"
@click="selectDepartment(department.id)"
>
Vælg denne afdeling
</button>
<router-link
v-else
:to="{ name: 'pos', params: { departmentId: department.id } }"
class="button is-light is-fullwidth"
>
<span class="icon"><i class="fas fa-map-marker-alt"></i></span>
<span>{{ department.address }}</span>
</router-link>
@@ -48,7 +48,9 @@ const currentStep = ref(0);
const hideDynamicImage = ref(false);
const vehicleStepError = ref<string | null>(null);
const washActionError = ref<string | null>(null);
const answerSyncError = ref<string | null>(null);
const questionStepError = ref<string | null>(null);
const pendingQuestionSyncs = ref<number[]>([]);
const failedQuestionSyncs = ref<number[]>([]);
const isSelfServeRetrying = ref(false);
const isCompletingWash = ref(false);
const isVehicleStepNextLoading = ref(false);
@@ -61,6 +63,7 @@ const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";
const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;
const ACTIVE_WASH_REFRESH_MS = 5 * 1000;
const WASH_START_SERVER_SYNC_GRACE_MS = 10 * 1000;
const SELF_SERVE_FETCH_DEBOUNCE_MS = 300;
const normalizePositiveInteger = (value: any) => {
const parsed = parseInt(String(value ?? ""), 10);
@@ -85,6 +88,7 @@ const {
forceNearestDepartmentEvaluationId,
isSearchingDepartments,
departmentFetchError,
isDepartmentSelectionFallbackBased,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
@@ -297,6 +301,38 @@ const {
isServiceAllowed,
});
const normalizeQuestionId = (questionId: any) => Number.parseInt(String(questionId ?? 0), 10);
const visibleQuestionIds = computed(
() =>
new Set(
visibleQuestions.value
.map((question: any) => normalizeQuestionId(question?.id))
.filter((questionId: number) => Number.isInteger(questionId) && questionId > 0)
)
);
const hasVisiblePendingQuestionSync = computed(() =>
pendingQuestionSyncs.value.some((questionId) => visibleQuestionIds.value.has(questionId))
);
const hasVisibleFailedQuestionSync = computed(() =>
failedQuestionSyncs.value.some((questionId) => visibleQuestionIds.value.has(questionId))
);
const hasBlockingVisibleQuestionSync = computed(
() => hasVisiblePendingQuestionSync.value || hasVisibleFailedQuestionSync.value
);
const allVisibleQuestionsAnswered = computed(
() =>
!hasBlockingVisibleQuestionSync.value &&
(selfServeQuestionsAnswered.value ||
visibleQuestions.value.every(
(question) => answers.value[question.id] === true || answers.value[question.id] === false
))
);
const { steps, clickableSteps, isNextButtonDisabled, handleConfirmNext, targetStepForStart } = useWashFlowState({
currentStep,
washInProgress,
@@ -307,14 +343,7 @@ const { steps, clickableSteps, isNextButtonDisabled, handleConfirmNext, targetSt
radioLaneOption,
radioWashType,
nearestDepartment,
allVisibleQuestionsAnswered: computed(
() =>
!answerSyncError.value &&
(selfServeQuestionsAnswered.value ||
visibleQuestions.value.every(
(question) => answers.value[question.id] === true || answers.value[question.id] === false
))
),
allVisibleQuestionsAnswered,
isLoadingSelfServeData,
activeTasks: displayedActiveTasks,
completedTasks,
@@ -382,6 +411,8 @@ const completeGuidedWash = async () => {
const unregisterBeforeUnload = ref<null | (() => void)>(null);
const activeWashRefreshInterval = ref<ReturnType<typeof window.setInterval> | null>(null);
const activeWashRestoreTimeout = ref<ReturnType<typeof window.setTimeout> | null>(null);
const isMyWashStartUnmounted = ref(false);
const registrationOptions = computed(() =>
customerVehicles.value
@@ -446,15 +477,6 @@ watch(dynamicImageUrl, () => {
hideDynamicImage.value = false;
});
const allVisibleQuestionsAnswered = computed(
() =>
!answerSyncError.value &&
(selfServeQuestionsAnswered.value ||
visibleQuestions.value.every(
(question) => answers.value[question.id] === true || answers.value[question.id] === false
))
);
const hasAllowedVehicleTypeSelection = computed(() => {
const selectedVehicleTypeId = vehicleTypeSelect.value;
if (selectedVehicleTypeId === null || selectedVehicleTypeId === undefined) {
@@ -467,7 +489,19 @@ const hasAllowedVehicleTypeSelection = computed(() => {
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
const shouldShowLoadingDataMessage = computed(() => !nearestDepartment.value);
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
const canUseDepartmentHeaderSelection = computed(
() =>
SessionUser.canAccessSuperUser() ||
SessionUser.canAccessDeveloper() ||
!hasLocationCoordinates.value ||
isDepartmentSelectionFallbackBased.value
);
const shouldShowChooseDepartmentMessage = computed(() => !nearestDepartment.value && !hasLocationCoordinates.value);
const shouldShowLoadingDataMessage = computed(() => !nearestDepartment.value && hasLocationCoordinates.value);
const vehicleStepGuidanceKey = computed(() => {
if (!nearestDepartment.value) {
@@ -641,24 +675,129 @@ const fetchVehicleTypes = async () => {
}
};
const fetchSelfServeData = async () => {
if (isRestoring.value || !nearestDepartment.value) {
return;
type SelfServeFetchRequest = {
key: string;
departmentId: number;
laneId: number;
registration: string;
vehicleTypeId: number | null;
};
let selfServeFetchDebounceTimer: ReturnType<typeof window.setTimeout> | null = null;
let selfServeFetchResolvers: Array<(value: any) => void> = [];
let latestSuccessfulSelfServeFetchKey: string | null = null;
let inFlightSelfServeFetchKey: string | null = null;
let selfServeFetchSequence = 0;
let isSelfServeFetchUnmounted = false;
const resolvePendingSelfServeFetches = (value: any = null) => {
const resolvers = selfServeFetchResolvers;
selfServeFetchResolvers = [];
resolvers.forEach((resolve) => resolve(value));
};
const clearScheduledSelfServeFetch = (resolveValue: any = null) => {
if (selfServeFetchDebounceTimer) {
window.clearTimeout(selfServeFetchDebounceTimer);
selfServeFetchDebounceTimer = null;
}
const normalizedReg = normalizeLicensePlate(licensePlateInput.value);
if (normalizedReg.length < 2) {
return;
if (selfServeFetchResolvers.length > 0) {
resolvePendingSelfServeFetches(resolveValue);
}
};
const createSelfServeFetchRequest = (): SelfServeFetchRequest | null => {
if (isRestoring.value || !nearestDepartment.value) {
return null;
}
const departmentId = normalizePositiveInteger(nearestDepartment.value.id);
if (!departmentId) {
return null;
}
const registration = normalizeLicensePlate(licensePlateInput.value);
if (registration.length < 2) {
return null;
}
const laneId = ensureEffectiveLaneSelection();
if (!laneId) {
return;
return null;
}
const departmentId = nearestDepartment.value.id;
const vehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value);
const key = [departmentId, laneId, registration, vehicleTypeId ?? ""].join("|");
await fetchSelfServeDataInternal(departmentId, vehicleTypeSelect.value || null, laneId, normalizedReg);
return {
key,
departmentId,
laneId,
registration,
vehicleTypeId,
};
};
const executeSelfServeFetch = async (request: SelfServeFetchRequest | null = createSelfServeFetchRequest()) => {
if (!request || isSelfServeFetchUnmounted) {
return null;
}
if (request.key === latestSuccessfulSelfServeFetchKey || request.key === inFlightSelfServeFetchKey) {
return null;
}
const requestSequence = ++selfServeFetchSequence;
inFlightSelfServeFetchKey = request.key;
try {
const result = await fetchSelfServeDataInternal(
request.departmentId,
request.vehicleTypeId,
request.laneId,
request.registration
);
if (!isSelfServeFetchUnmounted && requestSequence === selfServeFetchSequence) {
latestSuccessfulSelfServeFetchKey = request.key;
}
return result;
} finally {
if (inFlightSelfServeFetchKey === request.key) {
inFlightSelfServeFetchKey = null;
}
}
};
const fetchSelfServeData = async (options: { immediate?: boolean } = {}) => {
const request = createSelfServeFetchRequest();
if (options.immediate) {
clearScheduledSelfServeFetch(null);
return executeSelfServeFetch(request);
}
if (!request || isSelfServeFetchUnmounted) {
clearScheduledSelfServeFetch(null);
return null;
}
return new Promise((resolve) => {
selfServeFetchResolvers.push(resolve);
if (selfServeFetchDebounceTimer) {
window.clearTimeout(selfServeFetchDebounceTimer);
}
selfServeFetchDebounceTimer = window.setTimeout(async () => {
selfServeFetchDebounceTimer = null;
const latestRequest = createSelfServeFetchRequest();
const result = await executeSelfServeFetch(latestRequest);
resolvePendingSelfServeFetches(result);
}, SELF_SERVE_FETCH_DEBOUNCE_MS);
});
};
const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";
@@ -905,12 +1044,27 @@ const restoreServerActiveWash = async () => {
}
};
const clearServerActiveWashRestoreTimeout = () => {
if (activeWashRestoreTimeout.value !== null) {
window.clearTimeout(activeWashRestoreTimeout.value);
activeWashRestoreTimeout.value = null;
}
};
const scheduleServerActiveWashRestore = (delayMs = 0) => {
if (!shouldRestoreServerActiveWash.value || washInProgress.value) {
clearServerActiveWashRestoreTimeout();
if (!shouldRestoreServerActiveWash.value || washInProgress.value || isMyWashStartUnmounted.value) {
return;
}
window.setTimeout(() => {
activeWashRestoreTimeout.value = window.setTimeout(() => {
activeWashRestoreTimeout.value = null;
if (isMyWashStartUnmounted.value) {
return;
}
restoreServerActiveWash();
}, delayMs);
};
@@ -1027,7 +1181,7 @@ const retrySelfServeData = async () => {
isSelfServeRetrying.value = true;
try {
await fetchSelfServeData();
await fetchSelfServeData({ immediate: true });
} finally {
isSelfServeRetrying.value = false;
}
@@ -1102,7 +1256,7 @@ const onVehicleStepNext = async () => {
return;
}
await fetchSelfServeData();
await fetchSelfServeData({ immediate: true });
await nextTick();
markQuestionReviewRequired();
currentStep.value = visibleQuestions.value.length > 0 ? steps.QUESTIONS : steps.SELECT_LANE;
@@ -1141,29 +1295,84 @@ const onToggleTask = (taskId: number, value: boolean) => {
};
};
const submitQuestionAnswer = async (questionId: number, value: boolean) => {
answerSyncError.value = null;
answerQuestion(questionId, value);
const addQuestionSyncId = (target: typeof pendingQuestionSyncs, questionId: number) => {
if (!target.value.includes(questionId)) {
target.value = [...target.value, questionId];
}
};
const removeQuestionSyncId = (target: typeof pendingQuestionSyncs, questionId: number) => {
target.value = target.value.filter((candidateId) => candidateId !== questionId);
};
const buildQuestionSyncPayload = (questionId: number, value: boolean) => {
const laneId = getEffectiveLaneId();
if (!nearestDepartment.value || !laneId || !licensePlateInput.value?.trim()) {
return null;
}
return {
departmentId: nearestDepartment.value.id,
laneId,
customerNumber: getNumericCustomerNumber(),
reg: licensePlateInput.value,
questionId,
value,
vehicleTypeId: vehicleTypeSelect.value || null,
};
};
const syncQuestionAnswer = async (questionId: number, value: boolean) => {
const normalizedQuestionId = normalizeQuestionId(questionId);
if (!normalizedQuestionId) {
return false;
}
const payload = buildQuestionSyncPayload(normalizedQuestionId, value);
if (!payload) {
return true;
}
addQuestionSyncId(pendingQuestionSyncs, normalizedQuestionId);
removeQuestionSyncId(failedQuestionSyncs, normalizedQuestionId);
try {
await syncVehicleAnswer(payload);
removeQuestionSyncId(failedQuestionSyncs, normalizedQuestionId);
if (!hasVisibleFailedQuestionSync.value) {
questionStepError.value = null;
}
return true;
} catch (error) {
console.error("Error synchronizing self-serve answer:", error);
addQuestionSyncId(failedQuestionSyncs, normalizedQuestionId);
questionStepError.value = extractErrorMessage(error, "Kunne ikke gemme dit svar. Prøv igen, før du fortsætter.");
return false;
} finally {
removeQuestionSyncId(pendingQuestionSyncs, normalizedQuestionId);
}
};
const submitQuestionAnswer = async (questionId: number, value: boolean) => {
answerQuestion(questionId, value);
await syncQuestionAnswer(questionId, value);
};
const retryFailedQuestionSyncs = async () => {
const retryQuestionIds = failedQuestionSyncs.value.filter((questionId) => visibleQuestionIds.value.has(questionId));
if (retryQuestionIds.length === 0) {
questionStepError.value = null;
return;
}
try {
await syncVehicleAnswer({
departmentId: nearestDepartment.value.id,
laneId,
customerNumber: getNumericCustomerNumber(),
reg: licensePlateInput.value,
questionId,
value,
vehicleTypeId: vehicleTypeSelect.value || null,
});
} catch (error) {
console.error("Error synchronizing self-serve answer:", error);
removeAnswer(questionId);
answerSyncError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen.");
for (const questionId of retryQuestionIds) {
const value = answers.value[questionId];
if (value !== true && value !== false) {
removeQuestionSyncId(failedQuestionSyncs, questionId);
continue;
}
await syncQuestionAnswer(questionId, value);
}
};
@@ -1171,7 +1380,14 @@ const toggleEditAnswers = () => {
editAnswers.value = !editAnswers.value;
};
const isCurrentStepNextButtonDisabled = () =>
isNextButtonDisabled() || (currentStep.value === steps.QUESTIONS && hasBlockingVisibleQuestionSync.value);
const onConfirmNext = async () => {
if (isCurrentStepNextButtonDisabled()) {
return;
}
const wasQuestionsStep = currentStep.value === steps.QUESTIONS;
const previousQuestionReviewKey = confirmedQuestionReviewKey.value;
@@ -1210,6 +1426,7 @@ const onClearDynamicImage = () => {
};
onMounted(async () => {
isMyWashStartUnmounted.value = false;
setShowFooterInContent(false);
unregisterBeforeUnload.value = registerBeforeUnload();
@@ -1225,10 +1442,16 @@ onMounted(async () => {
});
onUnmounted(() => {
isMyWashStartUnmounted.value = true;
clearServerActiveWashRestoreTimeout();
if (unregisterBeforeUnload.value) {
unregisterBeforeUnload.value();
}
isSelfServeFetchUnmounted = true;
clearScheduledSelfServeFetch(null);
selfServeFetchSequence += 1;
stopAutoRefresh();
stopActiveWashRefresh();
markDestroying();
@@ -1448,7 +1671,7 @@ watch(
:is-searching-departments="isSearchingDepartments"
:is-searching-loading="guestDepartments.length === 0"
:guest-departments="guestDepartments"
:can-access-super-user="SessionUser.canAccessSuperUser() || SessionUser.canAccessDeveloper()"
:can-access-super-user="canUseDepartmentHeaderSelection"
:show-progress="currentStep === steps.TASKS || currentStep === steps.WASH_IN_PROGRESS"
:progress-label="$t('self_wash.wash_in_progress')"
:progress-value="formattedElapsed"
@@ -1589,6 +1812,29 @@ watch(
currentStep === steps.COMPLETED
"
>
<b-message
v-if="questionStepError"
type="is-danger"
has-icon
:closable="false"
data-testid="self-serve-question-sync-error"
>
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ questionStepError }}</span>
<b-button
size="is-small"
type="is-danger is-light"
icon-pack="fas"
icon-left="sync-alt"
data-testid="self-serve-question-sync-retry"
:loading="hasVisiblePendingQuestionSync"
@click="retryFailedQuestionSyncs"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
<SelfServeQuestionsStep
:is-loading="isLoadingSelfServeData"
:visible-questions="visibleQuestions"
@@ -1723,7 +1969,7 @@ watch(
icon-right="arrow-right"
data-testid="self-serve-nav-next"
:loading="isVehicleStepNextLoading"
:disabled="isVehicleStepNextLoading || isNextButtonDisabled()"
:disabled="isVehicleStepNextLoading || isCurrentStepNextButtonDisabled()"
@click.prevent="onVehicleStepNext"
>
{{ $t("common.next") }}
@@ -1753,7 +1999,7 @@ watch(
icon-right="arrow-right"
data-testid="self-serve-nav-confirm"
:loading="isStartingWash"
:disabled="isStartingWash || isNextButtonDisabled()"
:disabled="isStartingWash || isCurrentStepNextButtonDisabled()"
@click="onConfirmNext"
>
{{ $t(confirmActionLabelKey) }}
@@ -1851,6 +2097,16 @@ watch(
</div>
</template>
<b-message
v-else-if="shouldShowChooseDepartmentMessage"
type="is-info"
:aria-close-label="$t('common.close')"
data-testid="self-serve-choose-department-message"
>
Vælg en afdeling for at starte vask. Din placering er ikke tilgængelig, så vi kan ikke finde den nærmeste
afdeling automatisk.
</b-message>
<b-message v-else-if="shouldShowLoadingDataMessage" type="is-info" :aria-close-label="$t('common.close')">
{{ $t("self_wash.loading_data") }}
</b-message>
+100 -1
View File
@@ -264,6 +264,53 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden();
});
test("start route lets regular users choose a department when geolocation permission is denied", async ({
page,
context,
}) => {
await context.clearPermissions();
await page.addInitScript(() => {
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: {
getCurrentPosition: (_success, error) =>
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 }),
watchPosition: (_success, error) => {
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 });
return 1;
},
clearWatch: () => {},
},
});
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: true,
});
await primeSession(page, {
token: "self-serve-denied-geolocation-token",
permissions: ["user"],
});
await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-start-page")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("self-serve-department-name").click();
await expect(page.getByTestId("self-serve-department-search")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-department-search").locator("input").fill("Odense");
await page.getByText("Odense", { exact: true }).click();
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
});
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
const requests = captureSelfServeGatewayRequests(page);
@@ -1144,7 +1191,9 @@ test.describe("Self-serve wash", () => {
expect(requests.commands).toHaveLength(0);
});
test("tasks flow renders dynamic image, requires completion, and restores after reload", async ({ page }) => {
test("@dynamic-image tasks flow renders dynamic image, requires completion, and restores after reload", async ({
page,
}) => {
const requests = captureSelfServeGatewayRequests(page);
await seedSavedProgress(page, {
@@ -1510,6 +1559,56 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
});
test("mocked polling marks active wash completed when backend reports it ended", async ({ page }) => {
await seedSavedProgress(page, {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 30_000,
currentStep: 4,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioLaneOption: 7,
radioWashType: "Manual",
customerNumberInput: "12345679",
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: {
inProgressByLaneId: {
7: {
lane_id: 7,
in_progress: false,
session: {
id: 801,
lane_id: 7,
reg: "AB12345",
customer_number: 12345679,
vehicle_type_id: 2,
status: "COMPLETED",
},
customer: { customer_number: 12345679 },
vehicle: { reg: "AB12345", type: 2 },
},
},
},
});
await primeSession(page, {
token: "self-serve-polling-completed-token",
permissions: ["user"],
});
await page.goto("/user/wash/start");
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
await expect.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))).toBeNull();
});
test("admin preview modal reuses shared question/task rendering", async ({ page }) => {
await mockApi(page, {
authenticated: true,
+183
View File
@@ -0,0 +1,183 @@
// @vitest-environment jsdom
import { flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const mocks = vi.hoisted(() => {
const asMockRef = (value) => ({ value, __v_isRef: true });
return {
guestDepartments: asMockRef([]),
nearestDepartment: asMockRef(null),
fetchDepartments: vi.fn(async () => []),
evaluateLocationDepartments: vi.fn(),
orderDepartmentsByDistance: vi.fn((departments) => departments),
selectDepartment: vi.fn(),
useWashDepartments: vi.fn(),
locationRef: asMockRef({
coords: {
latitude: 55.5,
longitude: 12.4,
},
}),
getDistance: vi.fn(() => 3),
};
});
vi.mock("@/composables/useWashDepartments", () => ({
useWashDepartments: mocks.useWashDepartments,
}));
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
locations: {
location: mocks.locationRef,
getDistance: mocks.getDistance,
},
}));
import MyWash from "@/views/dashboards/userDashboard/wash/MyWash.vue";
const eligibleLane = { id: 10, name: "10", status: "AVAILABLE", selfserve_enabled: true };
const makeDepartment = (overrides = {}) => ({
id: 1,
name: "Glostrup",
address: "Fabriksparken 18",
latitude: 55.5,
longitude: 12.4,
distance: 1,
self_serve_enabled: true,
lanes: [eligibleLane],
...overrides,
});
const mountMyWash = async () => {
const wrapper = mountWithApp(MyWash, {
global: {
stubs: {
PosDepartmentStepMobile1Location: {
emits: ["location-updated"],
template: "<button data-testid='location-stub' @click=\"$emit('location-updated', {})\">location</button>",
},
BLoading: {
props: ["modelValue"],
template: "<div v-if='modelValue' data-testid='loading-stub' />",
},
BMessage: {
template: "<div class='b-message-stub' v-bind='$attrs'><slot /></div>",
},
},
},
});
await flushPromises();
await nextTick();
return wrapper;
};
describe("MyWash entry", () => {
beforeEach(() => {
mocks.guestDepartments.value = [];
mocks.nearestDepartment.value = null;
mocks.locationRef.value = {
coords: {
latitude: 55.5,
longitude: 12.4,
},
};
mocks.fetchDepartments.mockReset();
mocks.fetchDepartments.mockImplementation(async () => mocks.guestDepartments.value);
mocks.evaluateLocationDepartments.mockReset();
mocks.orderDepartmentsByDistance.mockReset();
mocks.orderDepartmentsByDistance.mockImplementation((departments) => departments);
mocks.selectDepartment.mockReset();
mocks.selectDepartment.mockImplementation((departmentId) => {
mocks.nearestDepartment.value =
mocks.guestDepartments.value.find((department) => department.id === departmentId) || null;
});
mocks.useWashDepartments.mockReset();
mocks.useWashDepartments.mockImplementation(() => ({
guestDepartments: mocks.guestDepartments,
nearestDepartment: mocks.nearestDepartment,
fetchDepartments: mocks.fetchDepartments,
evaluateLocationDepartments: mocks.evaluateLocationDepartments,
orderDepartmentsByDistance: mocks.orderDepartmentsByDistance,
selectDepartment: mocks.selectDepartment,
}));
mocks.getDistance.mockReset();
mocks.getDistance.mockReturnValue(3);
});
it("enables the start CTA when the nearest department is self-serve enabled with an available lane", async () => {
const nearest = makeDepartment();
mocks.guestDepartments.value = [nearest];
mocks.nearestDepartment.value = nearest;
const wrapper = await mountMyWash();
expect(mocks.useWashDepartments).toHaveBeenCalledWith({ includeLanes: true });
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-home-start"]').attributes("href")).toBe("/user/wash/start");
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').exists()).toBe(false);
});
it("replaces the start CTA when the nearest department is disabled and lets users select another eligible department", async () => {
const nearest = makeDepartment({ self_serve_enabled: false });
const alternative = makeDepartment({ id: 2, name: "Roskilde", distance: 5 });
mocks.guestDepartments.value = [nearest, alternative];
mocks.nearestDepartment.value = nearest;
const wrapper = await mountMyWash();
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
"Glostrup er ikke aktiveret til selvvask lige nu."
);
await wrapper.find('[data-testid="self-serve-home-select-department-2"]').trigger("click");
await nextTick();
expect(mocks.selectDepartment).toHaveBeenCalledWith(2);
});
it("replaces the start CTA when the nearest department has no enabled and available lanes", async () => {
const nearest = makeDepartment({
lanes: [
{ id: 10, name: "10", status: "MAINTENANCE", selfserve_enabled: true },
{ id: 11, name: "11", status: "AVAILABLE", selfserve_enabled: false },
],
});
const alternative = makeDepartment({ id: 3, name: "Køge", distance: 7 });
mocks.guestDepartments.value = [nearest, alternative];
mocks.nearestDepartment.value = nearest;
const wrapper = await mountMyWash();
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
"Glostrup har ingen ledige selvvask-baner lige nu."
);
expect(wrapper.find('[data-testid="self-serve-home-select-department-3"]').exists()).toBe(true);
});
it("handles missing geolocation by disabling start and offering eligible departments", async () => {
const eligible = makeDepartment({ id: 4, name: "Odense" });
mocks.locationRef.value = null;
mocks.guestDepartments.value = [eligible];
mocks.nearestDepartment.value = null;
const wrapper = await mountMyWash();
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
"Vi kan ikke finde din nærmeste afdeling uden en placering."
);
expect(wrapper.find('[data-testid="self-serve-home-select-department-4"]').exists()).toBe(true);
});
});
@@ -0,0 +1,55 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
const source = fs.readFileSync("src/views/dashboards/userDashboard/wash/MyWashStart.vue", "utf8");
describe("MyWashStart.vue production recovery contracts", () => {
it("restores local progress before scheduling a server active-wash restore", () => {
expect(source).toContain("const restoredProgress = restoreProgress();");
expect(source).toContain("shouldRestoreServerActiveWash.value = !restoredProgress?.washInProgress;");
expect(source).toContain("scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0);");
expect(source.indexOf("const restoredProgress = restoreProgress();")).toBeLessThan(
source.indexOf("scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0);")
);
});
it("restores authenticated server active washes into in-progress state and summary data", () => {
expect(source).toContain('const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";');
expect(source).toContain("if (!isAuthenticatedCustomerActiveWash(details, customerNumber))");
expect(source).toContain("await applyServerActiveWash(activeWash);");
expect(source).toContain("washInProgress.value = true;");
expect(source).toContain("currentStep.value = steps.WASH_IN_PROGRESS;");
expect(source).toContain("await fetchWashSummary(summaryParams, false);");
expect(source).toContain('saveProgress("serverActiveWash");');
});
it("suppresses recent completions so polling and restore do not resurrect a just-finished wash", () => {
expect(source).toContain('const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";');
expect(source).toContain("const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;");
expect(source).toContain(
"markRecentlyCompletedWash(completingLaneId, licensePlateInput.value, getNumericCustomerNumber());"
);
expect(source).toContain("if (isRecentlyCompletedActiveWash(activeWash))");
expect(source).toContain(
"if (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId }))"
);
});
it("cleans up unload handlers, department refresh, active-wash polling, and footer state on unmount", () => {
expect(source).toContain("unregisterBeforeUnload.value();");
expect(source).toContain("stopAutoRefresh();");
expect(source).toContain("stopActiveWashRefresh();");
expect(source).toContain("markDestroying();");
expect(source).toContain("setShowFooterInContent(true);");
});
it("prevents duplicate fetches and overlapping active-wash sync requests", () => {
expect(source).toContain("if (isVehicleStepNextLoading.value)");
expect(source).toContain("isVehicleStepNextLoading.value = true;");
expect(source).toContain("if (isSyncingActiveWash.value || !washInProgress.value || !washLaneId.value)");
expect(source).toContain("isSyncingActiveWash.value = true;");
expect(source).toContain("isSyncingActiveWash.value = false;");
expect(source).toContain("stopActiveWashRefresh();");
expect(source).toContain("activeWashRefreshInterval.value = window.setInterval");
});
});
+121
View File
@@ -85,6 +85,7 @@ vi.mock("@/components/session/token/SessionUser.vue", async () => {
customer_number: mocks.sessionCustomerNumber,
},
canAccessSuperUser: () => true,
canAccessDeveloper: () => false,
request: vi.fn(async (...args) => {
const response = await mocks.sessionRequest(...args);
return response ?? { status: 200, data: { data: {} } };
@@ -144,6 +145,8 @@ vi.mock("@/composables/useWashDepartments", () => ({
isForcingNearestDepartment: { value: false },
forceNearestDepartmentEvaluationId: { value: 0 },
isSearchingDepartments: { value: false },
departmentFetchError: { value: null },
isDepartmentSelectionFallbackBased: { value: false },
availableProductIds: { value: [2] },
doesCurrentDepartmentSelectionHaveSelfServeEnabled: mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments: mocks.fetchDepartments,
@@ -250,6 +253,7 @@ vi.mock("@/composables/useWashSessionActions", async () => {
};
});
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import MyWashStart from "@/views/dashboards/userDashboard/wash/MyWashStart.vue";
import { mountWithApp } from "./helpers/mountWithApp.js";
@@ -387,6 +391,9 @@ describe("MyWashStart", () => {
mocks.addVehicle.mockClear();
mocks.sessionRequest.mockClear();
mocks.sessionRequest.mockResolvedValue(undefined);
mocks.handleConfirmNext.mockClear();
mocks.isNextButtonDisabled.mockClear();
mocks.isNextButtonDisabled.mockReturnValue(false);
mocks.onStartWash.mockClear();
mocks.onStopWash.mockReset();
mocks.onStopWash.mockResolvedValue(true);
@@ -419,6 +426,8 @@ describe("MyWashStart", () => {
});
it("wires child updates back into the self-serve runtime", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
@@ -430,6 +439,7 @@ describe("MyWashStart", () => {
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
@@ -449,6 +459,34 @@ describe("MyWashStart", () => {
});
});
it("debounces registration, lane, and vehicle type updates into one self-serve fetch", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
mocks.fetchSelfServeDataInternal.mockClear();
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-lane"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await vi.advanceTimersByTimeAsync(299);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledTimes(1);
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
});
it("hides machine tasks while manual wash is selected", async () => {
mocks.activeTasks.value = [
{ id: 31, task: "Machine checklist", services: ["MACHINE"] },
@@ -537,6 +575,51 @@ describe("MyWashStart", () => {
await flushPromises();
});
it("surfaces failed question answer syncs and blocks question confirmation until retry succeeds", async () => {
mocks.syncVehicleAnswer.mockRejectedValueOnce(new Error("Question sync failed")).mockResolvedValueOnce(undefined);
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(wrapper.get('[data-testid="self-serve-question-sync-error"]').text()).toContain("Question sync failed");
const confirmButton = wrapper.get('[data-testid="self-serve-nav-confirm"]');
expect(confirmButton.attributes("disabled")).toBeDefined();
await confirmButton.trigger("click");
expect(mocks.handleConfirmNext).not.toHaveBeenCalled();
await wrapper.get('[data-testid="self-serve-question-sync-retry"]').trigger("click");
await flushPromises();
expect(mocks.syncVehicleAnswer).toHaveBeenCalledTimes(2);
expect(mocks.syncVehicleAnswer).toHaveBeenNthCalledWith(2, {
departmentId: 6,
laneId: 7,
customerNumber: 12345679,
reg: "AB12345",
questionId: 11,
value: true,
vehicleTypeId: 2,
});
expect(wrapper.find('[data-testid="self-serve-question-sync-error"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-nav-confirm"]').attributes("disabled")).toBeUndefined();
});
it("shows disabled warning without showing loading state when department self-serve is unavailable", async () => {
mocks.nearestDepartment.value = {
...mocks.nearestDepartment.value,
@@ -597,6 +680,8 @@ describe("MyWashStart", () => {
});
it("falls back to available lane when restored lane is stale for the selected department", async () => {
vi.useFakeTimers();
mocks.restoredProgressPayload = {
washInProgress: false,
washLaneId: null,
@@ -619,6 +704,8 @@ describe("MyWashStart", () => {
},
});
await flushPromises();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
@@ -823,6 +910,40 @@ describe("MyWashStart", () => {
wrapper.unmount();
});
it("does not call SessionUser.request for delayed server restore after unmount", async () => {
vi.useFakeTimers();
mocks.restoredProgressPayload = {
washInProgress: false,
washLaneId: null,
washStartTime: null,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
completedTasks: {},
currentStep: 0,
};
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
expect(SessionUser.request).not.toHaveBeenCalled();
wrapper.unmount();
await vi.advanceTimersByTimeAsync(1600);
await flushPromises();
expect(SessionUser.request).not.toHaveBeenCalled();
});
it("retries server active wash restore when the authenticated customer number arrives after mount", async () => {
mocks.sessionCustomerNumber.value = null;
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sessionUserMock = vi.hoisted(() => ({
request: vi.fn(),
objects: {
self_serve_vehicle_conditions: {
get: {
previewAllowed: vi.fn(),
washSummary: vi.fn(),
all: vi.fn(),
},
add: vi.fn(),
delete: vi.fn(),
},
self_serve_tasks: {
attachments: {
list: vi.fn(),
download: vi.fn(),
},
},
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: sessionUserMock,
}));
import { useSelfServeLogic } from "@/composables/useSelfServeLogic.js";
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const previewPayload = (overrides = {}) => ({
lane: { id: 7, name: "7" },
session: { id: 501, vehicle_type_id: 2 },
allowed_services: ["manual"],
questions: [{ id: 11, question: "Clean?", answer: null, order_priority: 1 }],
conditions: [],
rules: [],
tasks: [{ id: 91, task: "Manual prep", order_priority: 1, services: ["manual"] }],
...overrides,
});
const summaryPayload = (overrides = {}) => ({
session: { id: 501, vehicle_type_id: 2, allowed: true },
lane: { id: 7, name: "7" },
allowed_services: ["machine", "manual"],
questions: [{ id: 21, question: "Roof?", answer: true, order_priority: 2 }],
conditions: [],
rules: [],
tasks: [{ id: 92, task: "Machine start", order_priority: 2, services: ["machine"] }],
events: [],
...overrides,
});
describe("useSelfServeLogic production behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionUserMock.objects.self_serve_tasks.attachments.list.mockResolvedValue([]);
sessionUserMock.objects.self_serve_tasks.attachments.download.mockResolvedValue({ data: { download_link: "" } });
});
it("merges preview and summary questions while preserving preview answers until summary replaces them", async () => {
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue(previewPayload());
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(summaryPayload());
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(6, 2, 7, "ab12345");
expect(logic.questions.value.map((question) => question.id)).toEqual([11, 21]);
expect(logic.answers.value).toEqual({ 11: null, 21: true });
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([21]);
expect(logic.tasks.value.map((task) => task.id)).toEqual([92]);
expect(logic.allowedServices.value).toEqual(["MACHINE", "MANUAL"]);
});
it("ignores stale preview and summary responses when a newer request wins the race", async () => {
const slowPreview = deferred();
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed
.mockReturnValueOnce(slowPreview.promise)
.mockResolvedValueOnce(
previewPayload({ session: { id: 502 }, questions: [{ id: 31, question: "Newest?", answer: false }] })
);
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(
summaryPayload({ session: { id: 502 }, questions: [{ id: 32, question: "Newest summary?", answer: true }] })
);
const logic = useSelfServeLogic();
const stale = logic.fetchSelfServeData(6, 2, 7, "old123");
const fresh = await logic.fetchSelfServeData(6, 2, 7, "new123");
slowPreview.resolve(previewPayload({ session: { id: 501 }, questions: [{ id: 11, question: "Old?" }] }));
await stale;
expect(fresh.session.id).toBe(502);
expect(logic.questions.value.map((question) => question.id)).toEqual([31, 32]);
expect(logic.preview.value.session.id).toBe(502);
expect(logic.loading.value).toBe(false);
});
it("updates allowed services from the lane endpoint and falls back to active task services on first failure", async () => {
const logic = useSelfServeLogic();
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue(
previewPayload({ allowed_services: [], tasks: [{ id: 91, task: "Machine", services: ["MACHINE", "manual"] }] })
);
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(
summaryPayload({ questions: [] })
);
await logic.fetchSelfServeData(6, 2, 7, "ab12345");
sessionUserMock.request.mockResolvedValueOnce({
data: { success: true, data: { allowed_services: ["relay", "machine"] } },
});
await logic.updateLaneAllowedServices(7);
expect(logic.allowedServices.value).toEqual(["RELAY", "MACHINE"]);
sessionUserMock.request.mockRejectedValueOnce(new Error("edge offline"));
await expect(logic.updateLaneAllowedServices(7)).rejects.toThrow("edge offline");
expect(logic.allowedServices.value).toEqual(["RELAY", "MACHINE"]);
expect(logic.error.value).toBe("edge offline");
});
it("keeps the local answer optimistic only through successful answer sync and reports failures", async () => {
sessionUserMock.objects.self_serve_vehicle_conditions.add.mockRejectedValueOnce(new Error("answer sync failed"));
const logic = useSelfServeLogic();
logic.answerQuestion(11, true);
await expect(
logic.syncVehicleAnswer({
departmentId: 6,
laneId: 7,
reg: "AB12345",
questionId: 11,
value: false,
vehicleTypeId: 2,
})
).rejects.toThrow("answer sync failed");
expect(logic.answers.value[11]).toBe(true);
expect(logic.error.value).toBe("answer sync failed");
expect(logic.loading.value).toBe(false);
});
});
+134
View File
@@ -165,4 +165,138 @@ describe("useWashDepartments", () => {
lanes: [{ id: 7 }],
});
});
it("uses the first self-serve department with an enabled lane as fallback when coordinates are missing", async () => {
mocks.locationRef.value = null;
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Disabled module",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: false,
},
{
id: 2,
name: "Disabled lane",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: false }],
self_serve_enabled: true,
},
{
id: 3,
name: "Fallback",
address: "C",
latitude: 55.8,
longitude: 12.7,
lanes: [
{ id: 12, selfserve_enabled: "off" },
{ id: 13, selfserve_enabled: true, products: [4] },
],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(mocks.getDistance).not.toHaveBeenCalled();
expect(departments.nearestDepartment.value).toMatchObject({
id: 3,
name: "Fallback",
distance: null,
lanes: [{ id: 13 }],
});
expect(departments.departmentSelectionStrategy.value).toBe("fallback");
expect(departments.isDepartmentSelectionFallbackBased.value).toBe(true);
expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false);
});
it("clears the selected department for empty department responses", async () => {
mocks.getDepartmentsGuest.mockResolvedValueOnce([
{ id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true },
]);
const departments = mountDepartments();
await departments.fetchDepartments();
expect(departments.nearestDepartment.value).toMatchObject({ id: 1 });
mocks.getDepartmentsGuest.mockResolvedValueOnce([]);
await departments.fetchDepartments();
expect(departments.guestDepartments.value).toEqual([]);
expect(departments.nearestDepartment.value).toBeNull();
expect(departments.departmentSelectionStrategy.value).toBeNull();
});
it("does not fallback to disabled departments when coordinates are missing", async () => {
mocks.locationRef.value = { value: null };
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Module disabled",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: false,
},
{
id: 2,
name: "Lane disabled",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: "no" }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(departments.nearestDepartment.value).toBeNull();
expect(departments.departmentSelectionStrategy.value).toBeNull();
});
it("allows regular users to force a department when coordinates are missing", async () => {
mocks.locationRef.value = {};
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Fallback",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10 }],
self_serve_enabled: true,
},
{
id: 2,
name: "Manual choice",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11 }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ canAccessSuperUser: () => false });
await departments.fetchDepartments();
departments.startDepartmentSearch();
departments.selectDepartment(2);
expect(departments.isSearchingDepartments.value).toBe(false);
expect(departments.isForcingNearestDepartment.value).toBe(true);
expect(departments.nearestDepartment.value).toMatchObject({ id: 2, name: "Manual choice" });
expect(departments.departmentSelectionStrategy.value).toBe("forced");
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { useWashFlowState, WASH_STEPS } from "@/composables/useWashFlowState.js";
function createFlow(overrides = {}) {
const state = {
currentStep: ref(WASH_STEPS.VEHICLE),
washInProgress: ref(false),
customerNumberInput: ref("12345679"),
licensePlateInput: ref("AB12345"),
vehicleTypeSelect: ref(2),
availableProductIds: ref([2]),
radioLaneOption: ref(7),
radioWashType: ref("Manual"),
nearestDepartment: ref({ lanes: [{ id: 7, status: "AVAILABLE", machine_available: true }] }),
allVisibleQuestionsAnswered: ref(true),
isLoadingSelfServeData: ref(false),
activeTasks: ref([]),
completedTasks: ref({}),
editAnswers: ref(true),
isLaneAvailable: vi.fn((lane) => lane.status === "AVAILABLE"),
isMachineAvailable: vi.fn(() => true),
onStartWash: vi.fn(async () => true),
updateLaneAllowedServices: vi.fn(async () => true),
...overrides,
};
return { state, flow: useWashFlowState(state) };
}
describe("useWashFlowState production transitions", () => {
it("gates vehicle, question, lane, task, in-progress, and completed transitions", async () => {
const { state, flow } = createFlow({ activeTasks: ref([{ id: 91 }]) });
state.vehicleTypeSelect.value = null;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
expect(flow.isNextButtonDisabled()).toBe(true);
state.vehicleTypeSelect.value = 2;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true);
state.currentStep.value = WASH_STEPS.QUESTIONS;
state.allVisibleQuestionsAnswered.value = false;
expect(flow.isNextButtonDisabled()).toBe(true);
state.allVisibleQuestionsAnswered.value = true;
await flow.handleConfirmNext();
expect(state.editAnswers.value).toBe(false);
expect(state.currentStep.value).toBe(WASH_STEPS.SELECT_LANE);
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
expect(flow.clickableSteps[WASH_STEPS.SELECT_LANE]()).toBe(true);
state.currentStep.value = WASH_STEPS.SELECT_LANE;
await flow.handleConfirmNext();
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345679", WASH_STEPS.TASKS);
state.currentStep.value = WASH_STEPS.TASKS;
expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true);
await flow.handleConfirmNext();
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
state.washInProgress.value = true;
expect(flow.clickableSteps[WASH_STEPS.WASH_IN_PROGRESS]()).toBe(true);
expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true);
expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true);
expect(flow.clickableSteps[WASH_STEPS.COMPLETED]).toBeUndefined();
});
it("blocks lane confirmation for unavailable lanes and unavailable machine service", () => {
const { state, flow } = createFlow();
state.currentStep.value = WASH_STEPS.SELECT_LANE;
state.nearestDepartment.value.lanes[0].status = "OCCUPIED";
expect(flow.isNextButtonDisabled()).toBe(true);
state.nearestDepartment.value.lanes[0].status = "AVAILABLE";
state.radioWashType.value = "Machine";
state.isMachineAvailable.mockReturnValue(false);
expect(flow.isNextButtonDisabled()).toBe(true);
});
it("stays on questions when lane allowed-service refresh fails", async () => {
const { state, flow } = createFlow({
updateLaneAllowedServices: vi.fn(async () => {
throw new Error("edge down");
}),
});
state.currentStep.value = WASH_STEPS.QUESTIONS;
await flow.handleConfirmNext();
expect(state.currentStep.value).toBe(WASH_STEPS.QUESTIONS);
});
});
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { useWashSessionActions } from "@/composables/useWashSessionActions.js";
import { WASH_STEPS } from "@/composables/useWashFlowState.js";
function createActions(overrides = {}) {
const calls = [];
const request = vi.fn(async (_url, _method, body) => {
calls.push(body.command);
return { data: { success: true } };
});
const state = {
request,
alertFn: vi.fn(),
nearestDepartment: ref({ id: 6, lanes: [{ id: 7 }] }),
vehicleTypeSelect: ref(2),
washLaneId: ref(null),
washInProgress: ref(false),
washStartTime: ref(null),
completedDurationMs: ref(null),
now: ref(10_000),
currentStep: ref(WASH_STEPS.SELECT_LANE),
steps: WASH_STEPS,
radioWashType: ref("Manual"),
activeTasks: ref([]),
completedTasks: ref({}),
saveProgress: vi.fn(),
clearProgress: vi.fn(),
startElapsedTimer: vi.fn(),
stopElapsedTimer: vi.fn(),
updateLaneAllowedServices: vi.fn(async () => true),
fetchWashSummary: vi.fn(async () => ({})),
enableMachineRelay: vi.fn(async () => ({ data: { success: true } })),
isServiceAllowed: vi.fn(() => true),
...overrides,
};
return { state, actions: useWashSessionActions(state), calls };
}
describe("useWashSessionActions production commands", () => {
it("starts a manual wash successfully", async () => {
const { state, actions } = createActions();
await expect(actions.onStartWash(7, "ab12345", "12345679")).resolves.toBe(true);
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
expect(state.request).toHaveBeenCalledWith(
"/modules/self-serve/lane/command",
"post",
expect.objectContaining({ command: "START", license_plate: "AB12345" })
);
expect(state.washInProgress.value).toBe(true);
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
expect(state.saveProgress).toHaveBeenCalledWith("onStartWash");
});
it("reports START failure without mutating active wash state", async () => {
const { state, actions } = createActions({
request: vi.fn(async () => ({ data: { success: false, message: "START failed" } })),
});
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false);
expect(state.washInProgress.value).toBe(false);
expect(state.alertFn).toHaveBeenCalledWith("START failed");
expect(state.saveProgress).not.toHaveBeenCalled();
});
it("enables machine relay after START when machine service is allowed", async () => {
const { state, actions } = createActions({ radioWashType: ref("Machine") });
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(true);
expect(state.enableMachineRelay).toHaveBeenCalledWith(7);
expect(state.washInProgress.value).toBe(true);
});
it("rolls back with STOP when machine relay enable fails", async () => {
const { state, actions, calls } = createActions({
radioWashType: ref("Machine"),
enableMachineRelay: vi.fn(async () => {
throw new Error("relay down");
}),
});
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false);
expect(calls).toEqual(["START", "STOP"]);
expect(state.washInProgress.value).toBe(false);
expect(state.alertFn).toHaveBeenCalledWith("relay down");
});
it("keeps active state and alerts when STOP fails", async () => {
const request = vi.fn(async (_url, _method, body) =>
body.command === "STOP" ? { data: { success: false, message: "STOP failed" } } : { data: { success: true } }
);
const { state, actions } = createActions({
request,
washInProgress: ref(true),
washLaneId: ref(7),
washStartTime: ref(1_000),
});
await expect(actions.onStopWash(7)).resolves.toBe(false);
expect(state.washInProgress.value).toBe(true);
expect(state.alertFn).toHaveBeenCalledWith("STOP failed");
expect(state.clearProgress).not.toHaveBeenCalled();
});
it("recovers local state when STOP says the lane is already not occupied", async () => {
const request = vi.fn(async () => ({ data: { success: false, message: "Cannot stop lane 7: not occupied" } }));
const { state, actions } = createActions({
request,
washInProgress: ref(true),
washLaneId: ref(7),
washStartTime: ref(1_000),
now: ref(6_000),
});
await expect(actions.onStopWash(7)).resolves.toBe(true);
expect(state.washInProgress.value).toBe(false);
expect(state.washLaneId.value).toBeNull();
expect(state.completedDurationMs.value).toBe(5_000);
expect(state.stopElapsedTimer).toHaveBeenCalled();
expect(state.clearProgress).toHaveBeenCalled();
});
});