Compare commits

..
80 changed files with 1033 additions and 4886 deletions
+2 -3
View File
@@ -19,10 +19,9 @@ jobs:
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
-4
View File
@@ -1,4 +0,0 @@
{
"version": 1,
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
}
@@ -1,33 +0,0 @@
# 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. |
+4 -13
View File
@@ -2,19 +2,10 @@
<html lang="" class="theme-light" data-theme="light">
<head>
<meta charset="UTF-8">
<script>
(function () {
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
var base = document.createElement('base');
base.href = href;
document.currentScript.after(base);
})();
</script>
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
+1 -1
View File
@@ -29,7 +29,7 @@ server {
try_files /$static_asset_path =404;
}
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files /$static_file_path =404;
}
+29 -10
View File
@@ -41,6 +41,7 @@
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
@@ -5069,7 +5070,6 @@
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
@@ -5085,7 +5085,6 @@
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"dev": true,
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
@@ -5737,7 +5736,6 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
@@ -6425,7 +6423,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
@@ -8988,7 +8985,6 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9571,7 +9567,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"dev": true,
"license": "MIT"
},
"node_modules/mkdirp": {
@@ -10109,7 +10104,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"dev": true,
"license": "MIT"
},
"node_modules/performance-now": {
@@ -10171,6 +10165,34 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pixelmatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
@@ -10728,7 +10750,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT"
},
"node_modules/rgbcolor": {
@@ -11646,7 +11667,6 @@
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -11918,7 +11938,6 @@
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
+1
View File
@@ -86,6 +86,7 @@
"dayspan-vuetify": "^0.4.0",
"flow": "^0.2.3",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sass": "^1.81.1",
"sweetalert2": "11.22.4",
+4 -4
View File
@@ -23,17 +23,17 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
@@ -41,7 +41,7 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
-1
View File
@@ -35,7 +35,6 @@ export const ownedFilesByRole = {
"userHome.spec.ts",
"userInvoices.spec.ts",
"userMyWashStart.spec.ts",
"userMyWashStartFlow.spec.ts",
"userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts",
@@ -1,72 +0,0 @@
<!--
ErrorBanner.vue reusable self-serve error notification bar.
Props:
message error text to display (blank hidden)
type Bulma tint: is-danger | is-warning (default is-danger)
iconLeft Font Awesome icon name
:loading whether retry button is in loading state
:showRetry whether to show the retry button
Emit:
retry fired when retry button is clicked
-->
<script setup lang="ts">
interface Props {
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
}
withDefaults(
defineProps<{
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
}>(),
{
type: "is-danger",
iconLeft: "sync-alt",
loading: false,
showRetry: true,
}
);
const emit = defineEmits<{
retry: [];
}>();
</script>
<template>
<b-message
v-if="message"
:type="type"
has-icon
:closable="false"
class="self-serve-error-banner"
>
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ message }}</span>
<b-button
v-if="showRetry"
size="is-small"
:type="`${type} is-light`"
icon-pack="fas"
:icon-left="iconLeft"
:loading="loading"
@click="emit('retry')"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
</template>
<style scoped>
.self-serve-error-banner {
margin-bottom: 1rem;
}
</style>
@@ -1,59 +0,0 @@
<!--
LaneSelectionSection.vue bane-valg radio buttons and lane status display.
Props:
lanes array of lane objects from the department
selectedLaneId currently selected lane id
washType "Manual" | "Machine"
departmentName human-readable department name
isLaneAvailableFn function(lane) => boolean
isMachineAvailableFn function(laneId) => boolean
Emit:
update:selectedLaneId new lane id selected
update:washType "Manual" | "Machine"
-->
<script setup lang="ts">
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
interface Props {
lanes: any[];
selectedLaneId: number | string | null;
washType: string;
departmentName: string | null;
isLaneAvailableFn: (lane: any) => boolean;
isMachineAvailableFn: (laneId: number | string | null) => boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:selectedLaneId": [id: number];
"update:washType": [type: string];
}>();
function handleLaneUpdate(id: number) {
emit("update:selectedLaneId", id);
}
function handleWashTypeUpdate(type: string) {
emit("update:washType", type);
}
</script>
<template>
<SelfServeLaneStep
:lanes="lanes"
:selected-lane-id="selectedLaneId"
:wash-type="washType"
:department-name="departmentName"
:is-lane-available="isLaneAvailableFn"
:is-machine-available="isMachineAvailableFn"
@update:selected-lane-id="handleLaneUpdate"
@update:wash-type="handleWashTypeUpdate"
/>
</template>
<style scoped>
/* Lane selection styling is inherited from SelfServeLaneStep */
</style>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { BSkeleton } from "buefy";
const props = defineProps<{
src: string | null;
alt?: string;
}>();
const emit = defineEmits<{
(e: "error"): void;
(e: "load"): void;
}>();
const isLoading = ref(false);
watch(
() => props.src,
(src) => {
isLoading.value = !!src;
},
{ immediate: true }
);
const onLoad = () => {
isLoading.value = false;
emit("load");
};
const onError = () => {
isLoading.value = false;
emit("error");
};
</script>
<template>
<div
v-if="src"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="src"
:src="src"
:alt="alt || 'Machine status'"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isLoading }"
@load="onLoad"
@error="onError"
/>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from "vue";
import { BIcon, BMessage, BRadioButton } from "buefy";
defineProps<{
const props = defineProps<{
lanes: Array<any>;
selectedLaneId: number | string | null;
washType: string;
@@ -14,6 +15,8 @@ const emit = defineEmits<{
(e: "update:selectedLaneId", value: number): void;
(e: "update:washType", value: string): void;
}>();
const isSelectedMachineAvailable = computed(() => props.isMachineAvailable(props.selectedLaneId));
</script>
<template>
@@ -92,16 +95,16 @@ const emit = defineEmits<{
:model-value="washType"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable(selectedLaneId)"
:title="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable(selectedLaneId) ? $t('self_wash.machine_unavailable_for_lane') : null"
:disabled="!isSelectedMachineAvailable"
:title="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
:aria-label="!isSelectedMachineAvailable ? $t('self_wash.unavailable') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:washType', 'Machine')"
@input="emit('update:washType', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable(selectedLaneId)">
<span v-if="!isSelectedMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
@@ -117,14 +120,6 @@ const emit = defineEmits<{
</b-radio-button>
</div>
</div>
<b-message
v-if="!isMachineAvailable(selectedLaneId)"
type="is-warning"
aria-close-label="Luk besked"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</section>
</div>
</template>
@@ -21,6 +21,16 @@ const isImageAttachment = (attachment: any) => (
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
);
const hasTaskDescription = (task: any) => {
const description = task?.description;
if (typeof description !== "string") {
return !!description;
}
const trimmedDescription = description.trim();
return trimmedDescription.length > 0 && trimmedDescription !== "-";
};
const getImageAttachments = (task: any) => {
if (!Array.isArray(task?.attachments)) {
return [];
@@ -73,7 +83,7 @@ const formatTaskTitle = (task: any) => {
const title = String(task?.task || "");
const programWheelSelection = getProgramWheelSelection(task);
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
if (programWheelSelection === null) {
return title;
}
@@ -117,11 +127,13 @@ const formatTaskTitle = (task: any) => {
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="self-serve-task-image-overlay-text">
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
</div>
</div>
</div>
@@ -144,21 +156,22 @@ const formatTaskTitle = (task: any) => {
</div>
</div>
</template>
<div v-else class="self-serve-task-row">
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
<div v-else class="columns is-mobile is-vcentered">
<div v-if="props.showCheckboxes" class="column is-narrow">
<b-field>
<b-checkbox
class="mr-0 pr-0"
size="is-large"
:model-value="completedTasks[task.id] === true"
type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/>
</b-field>
</div>
<div class="self-serve-task-content">
<div class="column">
<p><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
</div>
@@ -226,7 +239,7 @@ const formatTaskTitle = (task: any) => {
padding: 0.65rem 0.75rem;
display: flex;
align-items: flex-start;
gap: 0.35rem;
gap: 0.6rem;
pointer-events: auto;
}
@@ -234,30 +247,12 @@ const formatTaskTitle = (task: any) => {
margin-bottom: 0;
}
.self-serve-task-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.self-serve-task-checkbox {
flex: 0 0 auto;
}
.self-serve-task-checkbox :deep(.field) {
margin-bottom: 0;
}
.self-serve-task-content {
flex: 1 1 auto;
min-width: 0;
}
.self-serve-task-content p {
margin-bottom: 0;
}
.self-serve-task-title {
margin: 0;
}
.self-serve-task-description {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { BIcon } from "buefy";
import SelfServeDynamicImageFrame from "@/components/displays/selfServe/SelfServeDynamicImageFrame.vue";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -18,19 +18,6 @@ const emit = defineEmits<{
(e: "clear-dynamic-image"): void;
}>();
const isDynamicImageLoading = ref(false);
const failedDynamicImageUrl = ref<string | null>(null);
const showDynamicImageFrame = computed(
() => !!props.dynamicImageUrl
&& !isDynamicImageLoading.value
&& failedDynamicImageUrl.value !== props.dynamicImageUrl
);
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
isDynamicImageLoading.value = !!dynamicImageUrl;
failedDynamicImageUrl.value = null;
}, { immediate: true });
const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value);
};
@@ -39,13 +26,7 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number | undefined
emit("download-attachment", taskId, attachmentId, attachment);
};
const onDynamicImageLoad = () => {
isDynamicImageLoading.value = false;
};
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
failedDynamicImageUrl.value = props.dynamicImageUrl;
emit("clear-dynamic-image");
};
</script>
@@ -66,32 +47,11 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="dynamicImageUrl && isDynamicImageLoading"
:key="`${dynamicImageUrl}:preload`"
<SelfServeDynamicImageFrame
:src="dynamicImageUrl"
alt=""
data-testid="self-serve-dynamic-image-preload"
class="self-serve-dynamic-image-preload"
@load="onDynamicImageLoad"
alt="Machine status"
@error="onDynamicImageError"
/>
<div
v-if="showDynamicImageFrame"
class="self-serve-dynamic-image-frame mb-4"
data-testid="self-serve-dynamic-image-frame"
>
<img
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
</div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
@@ -105,38 +65,3 @@ const onDynamicImageError = () => {
</div>
</div>
</template>
<style scoped>
.self-serve-dynamic-image-frame {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto;
margin-right: auto;
overflow: hidden;
}
.self-serve-dynamic-image-preload {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
border-radius: 4px;
display: block;
object-fit: contain;
transition: opacity 120ms ease;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
@@ -21,11 +21,8 @@ const props = defineProps<{
const emit = defineEmits<{
(e: "update:customerNumber", value: string): void;
(e: "update:customer-number", value: string): void;
(e: "update:registrationNumber", value: string): void;
(e: "update:registration-number", value: string): void;
(e: "select-vehicle-type", selection: any): void;
(e: "selected", selection: any): void;
}>();
const extractRegistrationValue = (value: unknown): string => {
@@ -52,19 +49,6 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
const filteredRegistrationOptions = computed(() => {
const query = normalizeRegistration(typedRegistration.value);
const options = props.registrationOptions || [];
if (!query) {
return options;
}
return options.filter((option) => {
const normalizedOption = normalizeRegistration(option);
return normalizedOption !== query && normalizedOption.includes(query);
});
});
watch(() => props.registrationNumber, (newValue) => {
typedRegistration.value = normalizeRegistration(newValue);
});
@@ -73,18 +57,6 @@ const emitRegistration = (value: unknown) => {
const normalized = normalizeRegistration(value);
typedRegistration.value = normalized;
emit("update:registrationNumber", normalized);
emit("update:registration-number", normalized);
};
const emitCustomerNumber = (value: unknown) => {
const normalized = String(value || "");
emit("update:customerNumber", normalized);
emit("update:customer-number", normalized);
};
const emitVehicleTypeSelection = (selection: any) => {
emit("select-vehicle-type", selection);
emit("selected", selection);
};
</script>
@@ -96,41 +68,38 @@ const emitVehicleTypeSelection = (selection: any) => {
:model-value="customerNumber || ''"
data-testid="self-serve-customer-number"
:placeholder="$t('self_wash.enter_customer_number')"
@input="emitCustomerNumber($event)"
@update:modelValue="emitCustomerNumber($event)"
@input="emit('update:customerNumber', String($event || ''))"
/>
</b-field>
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field">
<div class="self-serve-registration-control">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="filteredRegistrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
icon-pack="fas"
icon="car-side"
clearable
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
>
</b-autocomplete>
<p
v-if="typedRegistration"
class="help self-serve-registration-guidance"
data-testid="self-serve-registration-guidance"
>
<b-field :label="$t('self_wash.registration_number')">
<b-autocomplete
:model-value="typedRegistration"
data-testid="self-serve-registration"
:data="registrationOptions"
:placeholder="$t('self_wash.enter_registration_number')"
:debounce="300"
:min-length="1"
:filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
icon-pack="fas"
icon="car-side"
clearable
:loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
:selectable-header="true"
@select="emitRegistration($event)"
@typing="emitRegistration($event)"
@update:modelValue="emitRegistration($event)"
@select-header="emitRegistration(typedRegistration)"
>
<template #header>
<template v-if="hasMatchingVehicle">
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
</template>
<template v-else>
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
</template>
</p>
</div>
</template>
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
</b-autocomplete>
</b-field>
<b-message
v-if="props.vehicleStepError"
@@ -155,7 +124,7 @@ const emitVehicleTypeSelection = (selection: any) => {
<SelfServeVehicleTypeSelector
:selectedVehicleTypeId="selectedVehicleTypeId"
:restrictToProductIds="availableProductIds"
@selected="emitVehicleTypeSelection"
@selected="emit('select-vehicle-type', $event)"
/>
</template>
<template v-else-if="vehicleTypes.length === 0">
@@ -171,34 +140,3 @@ const emitVehicleTypeSelection = (selection: any) => {
</b-field>
</div>
</template>
<style scoped>
.self-serve-registration-control {
display: block;
width: 100%;
min-width: 0;
}
.self-serve-registration-guidance {
display: block;
margin-top: 0.5rem;
line-height: 1.35;
white-space: normal;
}
.self-serve-registration-field :deep(.dropdown),
.self-serve-registration-field :deep(.autocomplete) {
width: 100%;
}
.self-serve-registration-field :deep(.dropdown-menu) {
left: 0;
right: 0;
width: 100%;
max-width: 100%;
}
.self-serve-registration-field :deep(.dropdown-item) {
white-space: normal;
}
</style>
@@ -1,81 +0,0 @@
<!--
VehicleInputSection.vue customer number, license plate, and vehicle type inputs.
Props:
showCustomerNumberInput whether to render the customer number field
customerNumber current customer number string
registrationNumber current license plate string
registrationOptions autocomplete options for existing vehicles
selectedVehicleTypeId currently selected vehicle type id
selectedVehicleName human-readable name of selected vehicle type
availableProductIds allowed product ids for the department
vehicleTypes list of vehicle type options
Emit:
update:customerNumber new customer number string
update:registrationNumber new license plate string
selectVehicleType selected VehicleTypeTemplate object
-->
<script setup lang="ts">
import { computed } from "vue";
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
interface Props {
showCustomerNumberInput: boolean;
customerNumber: string | null;
registrationNumber: string | null;
registrationOptions: string[];
selectedVehicleTypeId: number | null;
selectedVehicleName: string | null;
availableProductIds: (number | string)[];
vehicleTypes: any[];
}
defineProps<Props>();
const emit = defineEmits<{
"update:customerNumber": [value: string];
"update:registrationNumber": [value: string];
selectVehicleType: [type: { id: number }];
}>();
function handleCustomerNumberUpdate(value: string) {
emit("update:customerNumber", value);
}
function handleRegistrationNumberUpdate(value: string) {
emit("update:registrationNumber", value);
}
function handleSelectVehicleType(selection: any) {
emit("selectVehicleType", selection);
}
</script>
<template>
<SelfServeVehicleStep
:customer-number="customerNumber"
:show-customer-number-input="showCustomerNumberInput"
:registration-number="registrationNumber"
:registration-options="registrationOptions"
:is-customer-vehicles-loading="false"
:has-matching-vehicle="false"
:selected-vehicle-type-id="selectedVehicleTypeId"
:selected-vehicle-name="selectedVehicleName"
:selected-vehicle-description="null"
:available-product-ids="availableProductIds"
:vehicle-types="vehicleTypes"
:vehicle-step-error="null"
:vehicle-step-guidance="null"
@update:customer-number="handleCustomerNumberUpdate"
@update:customerNumber="handleCustomerNumberUpdate"
@update:registration-number="handleRegistrationNumberUpdate"
@update:registrationNumber="handleRegistrationNumberUpdate"
@select-vehicle-type="handleSelectVehicleType"
@selected="handleSelectVehicleType"
/>
</template>
<style scoped>
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
</style>
@@ -1,255 +0,0 @@
<!--
WashProgressCard.vue timer, guided instructions, and bottom actions during active wash.
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
Props:
currentGuidedWashStep index into guidedWashFlowSteps
guidedWashFlowSteps array of instruction step objects
formattedElapsed human-readable elapsed time string
isCompletingWash whether the wash is finishing right now
openingPropertyAccessGate loading state for access gate button
openingPropertyExitGate loading state for exit gate button
Emit:
update:currentGuidedWashStep new step index
goPreviousGuidedWashStep navigate to previous instruction
goNextGuidedWashStep navigate to next instruction
completeWash finish the wash session
openPropertyAccessGate open the property access gate (laneId)
openPropertyExitGate open the property exit gate (laneId)
requestAssistance call for assistance
-->
<script setup lang="ts">
import { computed } from "vue";
import { BButton } from "buefy";
interface Props {
currentGuidedWashStep: number;
guidedWashFlowSteps: any[];
formattedElapsed: string;
isCompletingWash: boolean;
openingPropertyAccessGate: boolean;
openingPropertyExitGate: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:currentGuidedWashStep": [step: number];
goPreviousGuidedWashStep: [];
goNextGuidedWashStep: [];
completeWash: [];
openPropertyAccessGate: [laneId: number | null];
openPropertyExitGate: [laneId: number | null];
requestAssistance: [];
}>();
const isLastGuidedWashStep = computed(
() =>
typeof guidedWashFlowSteps !== "undefined" &&
guidedWashFlowSteps.length > 0 &&
currentGuidedWashStep >= guidedWashFlowSteps.length - 1
);
function handleGoPrevious() {
emit("goPreviousGuidedWashStep");
}
function handleGoNext() {
emit("goNextGuidedWashStep");
}
function handleComplete() {
emit("completeWash");
}
function handleOpenAccessGate(laneId: number | null) {
emit("openPropertyAccessGate", laneId);
}
function handleOpenExitGate(laneId: number | null) {
emit("openPropertyExitGate", laneId);
}
function handleRequestAssistance() {
emit("requestAssistance");
}
</script>
<template>
<div data-testid="self-serve-wash-progress">
<!-- Finishing screen -->
<div
v-if="isCompletingWash"
class="notification is-info is-light self-serve-finishing-screen"
data-testid="self-serve-finishing-wash"
>
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
</div>
<!-- Guided wash instructions (stepped walkthrough) -->
<template v-if="!isCompletingWash">
<!--
Accept a callback slot for the guided-instructions component so the
parent can plug in SelfServeGuidedInstructions or its own implementation.
-->
<slot
name="guided-instructions"
:current-step="currentGuidedWashStep"
:steps="guidedWashFlowSteps"
:is-last-step="isLastGuidedWashStep"
@update:current-step="emit('update:currentGuidedWashStep', $event)"
>
<!-- Default: render nothing (parent provides via slot) -->
</slot>
<!-- Bottom action bar while wash is in progress -->
<div
class="self-serve-bottom-actions"
data-testid="self-serve-bottom-actions"
>
<!-- Help / assistance -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-right="info-circle"
data-testid="self-serve-nav-help"
@click.prevent="handleRequestAssistance"
>
{{ $t("self_wash.assistance") }}
</b-button>
</div>
<!-- Property gate controls -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-in-alt"
data-testid="self-serve-nav-open-property-access-gate"
:loading="openingPropertyAccessGate"
:disabled="openingPropertyAccessGate"
@click.prevent="$emit('openPropertyAccessGate', $props)"
>
{{ $t("self_wash.open_property_access_gate") }}
</b-button>
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-out-alt"
data-testid="self-serve-nav-open-property-exit-gate"
:loading="openingPropertyExitGate"
:disabled="openingPropertyExitGate"
@click.prevent="$emit('openPropertyExitGate', $props)"
>
{{ $t("self_wash.open_property_exit_gate") }}
</b-button>
</div>
<!-- Guided step navigation -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="arrow-left"
data-testid="self-serve-guided-prev"
:disabled="currentGuidedWashStep === 0"
@click.prevent="handleGoPrevious"
>
{{ $t("common.previous") }}
</b-button>
<b-button
v-if="!isLastGuidedWashStep"
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="arrow-right"
data-testid="self-serve-guided-next"
@click.prevent="handleGoNext"
>
{{ $t("common.next") }}
</b-button>
<b-button
v-else
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="check"
data-testid="self-serve-nav-complete"
:loading="isCompletingWash"
:disabled="isCompletingWash"
@click.prevent="handleComplete"
>
{{ $t("common.done") }}
</b-button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.self-serve-finishing-screen {
margin: 0 auto;
max-width: 32rem;
text-align: left;
}
.self-serve-bottom-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
justify-content: center;
margin: 0.75rem auto 0;
max-width: 30rem;
}
.self-serve-bottom-actions__row {
display: flex;
gap: 0.75rem;
justify-content: center;
width: 100%;
}
.self-serve-bottom-actions__button {
flex: 1 1 0;
font-weight: 700;
line-height: 1.15;
max-width: 14rem;
min-height: 2.75rem;
min-width: 0;
white-space: normal;
}
@media screen and (max-width: 768px) {
.self-serve-bottom-actions {
background: #ffffff;
border-top: 1px solid #dfe5f0;
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
left: 0;
margin: 0;
max-width: none;
padding: 0.5rem 0.75rem;
position: fixed;
right: 0;
z-index: 41;
}
.self-serve-bottom-actions__button {
font-size: 0.88rem;
max-width: none;
min-height: 2.65rem;
padding-left: 0.55rem;
padding-right: 0.55rem;
}
}
</style>
@@ -1,170 +0,0 @@
<!--
WashTypeSelector.vue manual vs machine wash toggle.
Renders two radio-style cards: "Manual" (always available) and
"Machine" (available when the lane supports it).
Props:
modelValue current wash type ("Manual" | "Machine")
isMachineAvailable whether machine wash is available for this lane
Emit:
update:modelValue new wash type string
-->
<script setup lang="ts">
import { BRadioButton } from "buefy";
interface Props {
modelValue: string;
isMachineAvailable: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:modelValue": [type: string];
}>();
</script>
<template>
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
<div
class="self-serve-choice-grid self-serve-choice-grid--centered"
data-testid="self-serve-wash-type-options"
>
<!-- Manual option (always available) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Manual"
type="is-link"
data-testid="self-serve-wash-type-manual"
@update:model-value="emit('update:modelValue', 'Manual')"
@input="emit('update:modelValue', 'Manual')"
>
<span>
<span>{{ $t("self_wash.manual") }}<br /></span>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</b-radio-button>
</div>
<!-- Machine option (conditioned) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable"
:title="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
:aria-label="!isMachineAvailable ? $t('self_wash.machine_unavailable_for_lane') : null"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:modelValue', 'Machine')"
@input="emit('update:modelValue', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
</small>
</span>
<span v-else>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</span>
</b-radio-button>
</div>
</div>
<b-message
v-if="!isMachineAvailable"
type="is-warning"
aria-close-label="Luk besked"
data-testid="self-serve-machine-unavailable-guidance"
>
{{ $t("self_wash.machine_unavailable_for_lane") }}
</b-message>
</section>
</template>
<style scoped>
.self-serve-choice-group {
margin-bottom: 1.25rem;
}
.self-serve-choice-label {
color: #303440;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.self-serve-choice-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0.75rem;
width: 100%;
}
.self-serve-choice-grid--centered {
justify-content: center;
}
.self-serve-choice-grid__item {
min-width: 0;
}
.self-serve-choice-card {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button) {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button),
.self-serve-choice-card span,
.self-serve-choice-card :deep(.button span) {
min-width: 0;
}
.self-serve-choice-card small,
.self-serve-choice-card :deep(.button small) {
display: inline-flex;
align-items: center;
line-height: 1.25;
}
@media screen and (min-width: 769px) {
.self-serve-choice-grid {
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
}
}
</style>
@@ -252,7 +252,7 @@ const onResendInvite = async (subuser) => {
<td>
<div class="buttons is-justify-content-flex-end action-buttons">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)" display-actions-directly>
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
@@ -13,58 +13,6 @@ const getSelectedCustomerNumber = () => {
return stored ? parseInt(stored) : null;
};
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
const parseDateTimeMs = (value) => {
if (!value) {
return null;
}
const timestamp = Date.parse(String(value));
return Number.isFinite(timestamp) ? timestamp : null;
};
const hasActiveWashStartEvidence = (details) => {
const session = details?.session || {};
const status = normalizeStatus(session?.status ?? details?.status);
return (
ACTIVE_WASH_STARTED_STATUSES.has(status) ||
session?.machine_relay_enabled === true ||
session?.machine_start_triggered === true ||
details?.machine_relay_enabled === true ||
details?.machine_start_triggered === true ||
parseDateTimeMs(session?.wash_started_at) !== null ||
parseDateTimeMs(session?.machine_start_triggered_at) !== null ||
parseDateTimeMs(session?.machine_relay_enabled_at) !== null
);
};
const normalizeActiveWashResponse = (url, method, response) => {
if (String(method || '').toUpperCase() !== 'GET' || !String(url || '').includes(MY_ACTIVE_WASH_ENDPOINT)) {
return response;
}
const details = response?.data?.data;
if (!details?.in_progress || hasActiveWashStartEvidence(details)) {
return response;
}
return {
...response,
data: {
...response.data,
data: {
...details,
in_progress: false,
},
},
};
};
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null) => {
const token = localStorage.getItem('token');
if (!token) {
@@ -115,7 +63,6 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
throw error;
})
.then((response) => {
response = normalizeActiveWashResponse(url, method, response);
if (thenCallable) {
// Call the then callable
thenCallable(response);
+67 -113
View File
@@ -1,4 +1,5 @@
import { computed, ref } from "vue";
import { createPinia, defineStore, getActivePinia, setActivePinia, storeToRefs } from "pinia";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
@@ -271,56 +272,26 @@ const mergeByNumericId = (existingItems, incomingItems) => {
return merged;
};
const taskStableKey = (task) =>
String(task?.task ?? "")
.trim()
.toLowerCase();
let selfServeLogicStoreInstance = 0;
let fallbackSelfServePinia = null;
const mergeTaskSets = (existingTasks, incomingTasks) => {
if (!Array.isArray(existingTasks) || existingTasks.length === 0) {
return Array.isArray(incomingTasks) ? [...incomingTasks] : [];
const ensureSelfServePinia = () => {
if (typeof document === "undefined") {
fallbackSelfServePinia ||= createPinia();
setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
}
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) {
return [...existingTasks];
const activePinia = getActivePinia();
if (activePinia) {
return activePinia;
}
const usedExistingIndexes = new Set();
return incomingTasks.map((incomingTask) => {
const incomingId = extractTaskId(incomingTask);
let existingIndex = -1;
if (incomingId) {
existingIndex = existingTasks.findIndex((task) => extractTaskId(task) === incomingId);
} else {
const incomingKey = taskStableKey(incomingTask);
existingIndex = existingTasks.findIndex(
(task, index) => !usedExistingIndexes.has(index) && taskStableKey(task) === incomingKey
);
}
if (existingIndex === -1) {
return incomingTask;
}
usedExistingIndexes.add(existingIndex);
const existingTask = existingTasks[existingIndex];
return {
...existingTask,
...incomingTask,
id: incomingTask.id || existingTask.id,
task_id: incomingTask.task_id || existingTask.task_id,
attachments: Array.isArray(incomingTask.attachments) && incomingTask.attachments.length > 0
? incomingTask.attachments
: existingTask.attachments,
_attachmentsLoaded: incomingTask._attachmentsLoaded || existingTask._attachmentsLoaded,
};
});
fallbackSelfServePinia ||= createPinia();
setActivePinia(fallbackSelfServePinia);
return fallbackSelfServePinia;
};
export function useSelfServeLogic() {
const createSelfServeLogicStore = (storeId) => defineStore(storeId, () => {
const loading = ref(false);
const requestError = ref(null);
const preview = ref(null);
@@ -346,30 +317,6 @@ 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;
@@ -498,10 +445,6 @@ export function useSelfServeLogic() {
machineType.value = previewData?.machine_type || machineType.value;
vehicle.value = previewData?.vehicle || null;
session.value = previewData?.session || session.value;
if (!previewData?.session?.id) {
summary.value = null;
events.value = [];
}
if (Object.prototype.hasOwnProperty.call(previewData || {}, "config_version_id")) {
configVersionId.value = previewData?.config_version_id ?? null;
}
@@ -520,7 +463,6 @@ export function useSelfServeLogic() {
questions.value = nextQuestions;
answers.value = buildAnswersMap(nextQuestions);
setSummaryVisibleQuestions(nextQuestions);
const normalizedConditions = Array.isArray(previewData?.conditions)
? previewData.conditions.map(normalizeCondition)
@@ -540,15 +482,13 @@ export function useSelfServeLogic() {
? previewData.tasks.map(normalizeTask).sort((a, b) => a.order_priority - b.order_priority)
: [];
tasks.value = normalizedTasks;
applyAllowedServices(previewData);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchRequestActive(requestId)) {
return false;
}
tasks.value = hydratedTasks;
applyAllowedServices(previewData);
return true;
};
@@ -593,9 +533,8 @@ export function useSelfServeLogic() {
const normalizedTasks = summaryData.tasks
.map(normalizeTask)
.sort((a, b) => a.order_priority - b.order_priority);
const mergedTasks = mergeTaskSets(tasks.value, normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
const hydratedTasks = await hydrateTaskAttachments(normalizedTasks);
if (!isFetchRequestActive(requestId)) {
return false;
}
@@ -656,17 +595,10 @@ export function useSelfServeLogic() {
}
};
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 fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = 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;
@@ -694,7 +626,6 @@ export function useSelfServeLogic() {
loading.value = true;
requestError.value = null;
inFlightFetchKey.value = fetchKey;
try {
const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -776,10 +707,6 @@ export function useSelfServeLogic() {
setSummaryVisibleQuestions(questions.value);
}
if (isFetchRequestActive(requestId)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData;
} catch (error) {
console.error("Error fetching self-serve preview:", error);
@@ -788,9 +715,6 @@ export function useSelfServeLogic() {
}
return null;
} finally {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
if (isFetchRequestActive(requestId)) {
loading.value = false;
}
@@ -845,7 +769,7 @@ export function useSelfServeLogic() {
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -910,7 +834,7 @@ export function useSelfServeLogic() {
? normalizedRefreshVehicleTypeId
: null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
@@ -1082,22 +1006,7 @@ export function useSelfServeLogic() {
const updateLaneAllowedServices = async (laneId) => {
if (!laneId) return;
const activeTaskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
const taskIds = activeTaskIds.length > 0
? activeTaskIds
: tasks.value.map((task) => extractTaskId(task)).filter(Boolean);
if (taskIds.length === 0) {
requestError.value = null;
return {
success: true,
data: {
lane_id: parseInt(laneId),
allowed_services: allowedServices.value,
skipped_empty_task_sync: true,
},
};
}
const taskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
try {
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
@@ -1160,7 +1069,6 @@ export function useSelfServeLogic() {
answers,
completedTasks,
allowedServices,
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
configVersionId,
evaluationTrace,
visibleQuestions,
@@ -1187,4 +1095,50 @@ export function useSelfServeLogic() {
answerQuestion,
removeAnswer,
};
});
export function useSelfServeLogic(options = {}) {
const pinia = ensureSelfServePinia();
const explicitStoreId = typeof options === "string" ? options : options?.storeId;
const storeId = explicitStoreId || `selfServeLogic:${++selfServeLogicStoreInstance}`;
const store = createSelfServeLogicStore(storeId)(pinia);
const refs = storeToRefs(store);
const {
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
} = store;
return {
...refs,
fetchSelfServeData,
fetchWashSummary,
syncVehicleAnswer,
clearVehicleAnswers,
evaluateRule,
evaluateCondition,
isQuestionVisible,
isTaskActive,
isServiceAllowed,
updateLaneAllowedServices,
enableMachineRelay,
isImage,
downloadAttachment,
reset,
answerQuestion,
removeAnswer,
};
}
+5 -40
View File
@@ -14,9 +14,6 @@ const isLaneSelfServeEnabled = (lane) =>
)
);
const hasSelfServeEnabledLane = (department) =>
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
const toDepartmentViewModel = (department, distance = null) => ({
id: department.id,
distance,
@@ -50,7 +47,6 @@ export function useWashDepartments(options = {}) {
const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null);
const departmentSelectionStrategy = ref(null);
let refreshInterval = null;
@@ -77,12 +73,6 @@ 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 () => {
@@ -120,30 +110,20 @@ export function useWashDepartments(options = {}) {
const forcedDepartment = getForcedDepartment();
if (forcedDepartment) {
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
departmentSelectionStrategy.value = "forced";
return nearestDepartment.value;
}
}
if (guestDepartments.value.length === 0) {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
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,
@@ -161,18 +141,6 @@ export function useWashDepartments(options = {}) {
if (currentNearestDepartment.id) {
nearestDepartment.value = currentNearestDepartment;
departmentSelectionStrategy.value = "distance";
return nearestDepartment.value;
}
const fallbackDepartment =
guestDepartments.value.find((department) => department.self_serve_enabled === true) || guestDepartments.value[0];
if (fallbackDepartment) {
nearestDepartment.value = toDepartmentViewModel(fallbackDepartment);
departmentSelectionStrategy.value = "fallback";
} else {
nearestDepartment.value = null;
departmentSelectionStrategy.value = null;
}
return nearestDepartment.value;
@@ -199,7 +167,7 @@ export function useWashDepartments(options = {}) {
};
const startDepartmentSearch = () => {
if (!canAccessSuperUser() && hasLocationCoordinates()) {
if (!canAccessSuperUser()) {
return;
}
@@ -277,9 +245,6 @@ export function useWashDepartments(options = {}) {
isSearchingDepartments,
lastDepartmentFetchTime,
departmentFetchError,
departmentSelectionStrategy,
isDepartmentSelectionDistanceBased,
isDepartmentSelectionFallbackBased,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
+11 -28
View File
@@ -7,11 +7,6 @@ export const WASH_STEPS = {
COMPLETED: 5,
};
const normalizeLaneId = (value) => {
const normalized = parseInt(String(value ?? ""), 10);
return Number.isNaN(normalized) || normalized <= 0 ? null : normalized;
};
export function useWashFlowState(options) {
const {
currentStep,
@@ -19,7 +14,7 @@ export function useWashFlowState(options) {
customerNumberInput,
licensePlateInput,
vehicleTypeSelect,
isCustomerNumberRequired,
availableProductIds,
radioLaneOption,
nearestDepartment,
allVisibleQuestionsAnswered,
@@ -39,17 +34,6 @@ export function useWashFlowState(options) {
activeTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS
);
const getSelectedLaneId = () => normalizeLaneId(radioLaneOption.value);
const getSelectedLane = () => {
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId === null) {
return null;
}
return nearestDepartment.value?.lanes.find((lane) => normalizeLaneId(lane.id) === selectedLaneId) || null;
};
const clickableSteps = {
[steps.VEHICLE]: () => {
if (washInProgress.value) {
@@ -57,15 +41,16 @@ export function useWashFlowState(options) {
}
const selectedVehicleTypeId = vehicleTypeSelect.value;
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined;
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
const hasAllowedVehicleType = selectedVehicleTypeId !== null
&& selectedVehicleTypeId !== undefined
&& allowedProductIds.has(String(selectedVehicleTypeId));
const customerNumberValue = customerNumberInput.value;
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false;
const customerNumberValid = !requiresCustomerNumber
|| customerNumberValue === null
const customerNumberValid = customerNumberValue === null
|| customerNumberValue === undefined
|| String(customerNumberValue).trim() !== "";
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
if (!hasSelectedVehicleType) {
if (!hasAllowedVehicleType) {
return false;
}
@@ -118,7 +103,7 @@ export function useWashFlowState(options) {
}
if (currentStep.value === steps.SELECT_LANE) {
const selectedLane = getSelectedLane();
const selectedLane = nearestDepartment.value?.lanes.find((lane) => lane.id === radioLaneOption.value);
if (!selectedLane || !isLaneAvailable(selectedLane)) {
return true;
}
@@ -148,10 +133,9 @@ export function useWashFlowState(options) {
const handleConfirmNext = async () => {
if (currentStep.value === steps.QUESTIONS) {
editAnswers.value = false;
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId !== null) {
if (radioLaneOption.value && radioLaneOption.value !== "Any") {
try {
await updateLaneAllowedServices(selectedLaneId);
await updateLaneAllowedServices(radioLaneOption.value);
} catch {
return;
}
@@ -166,9 +150,8 @@ export function useWashFlowState(options) {
return;
}
const selectedLaneId = getSelectedLaneId() ?? radioLaneOption.value;
await onStartWash(
selectedLaneId,
radioLaneOption.value,
licensePlateInput.value,
customerNumberInput.value,
targetStepForStart()
+2 -5
View File
@@ -70,10 +70,7 @@ export function useWashSessionActions(options) {
const isAlreadyStoppedStopError = (message) => {
const normalized = String(message || "").toLowerCase();
return (
normalized.includes("not occupied") &&
(normalized.includes("cannot stop lane") || normalized.includes("cannot invoice"))
);
return normalized.includes("cannot stop lane") && normalized.includes("not occupied");
};
const clearActiveWashState = () => {
@@ -253,7 +250,7 @@ export function useWashSessionActions(options) {
const startResponse = await executeSelfServeCommand(laneId, "START", {
customer_number: parseInt(customerNumber),
license_plate: licensePlate.trim().toUpperCase(),
defer_relay_side_effects: true,
defer_relay_side_effects: false,
});
if (!startResponse) {
return false;
-1
View File
@@ -66,7 +66,6 @@ export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
export const ALLOWED_ORIGINS = [
"https://truckwash.io",
"https://www.truckwash.io",
new URL(DEFAULT_PUBLIC_GATEWAY_API_URL).origin,
"http://localhost:5173",
"http://localhost:4173",
"http://localhost:4174",
-1
View File
@@ -4595,7 +4595,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.indlæser'} @:{'words.generated.tilgængelige'} @:{'words.generated.køretøjstyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "@.capitalize:{'words.generated.maskine'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Optaget",
"open_property_access_gate": "@:{'words.generated.abn'} adgangsport",
-1
View File
@@ -4705,7 +4705,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.verf'}?@:{'words.generated.gbare'} @:{'words.generated.fahrzeugtypen'} @:{'words.generated.werden'} @:{'words.generated.geladen'}...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "@:{'words.generated.maschine'}",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Belegt",
"open_property_access_gate": "Grundstueckszugangstor @:{'words.generated.oeffnen'}",
-1
View File
@@ -4429,7 +4429,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.loading'} @:{'words.generated.available'} @:{'words.generated.vehicle'} @:{'words.generated.types'}...",
"lane_unavailable": "Lane unavailable",
"machine": "@.capitalize:{'words.generated.machine'}",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Occupied",
"open_property_access_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.access'} @:{'words.generated.gate'}",
-1
View File
@@ -4224,7 +4224,6 @@
"loading_vehicle_types": "@:{'templates.generated.compat.self_wash.loading_vehicle_types'}",
"lane_unavailable": "@:{'templates.generated.compat.self_wash.lane_unavailable'}",
"machine": "@:{'templates.generated.compat.self_wash.machine'}",
"machine_unavailable_for_lane": "@:{'templates.generated.compat.self_wash.machine_unavailable_for_lane'}",
"no_departments_found": "@:{'templates.generated.compat.self_wash.no_departments_found'}",
"no_vehicles_found": "@:vehicles.no_vehicles",
"occupied": "@:{'templates.generated.compat.self_wash.occupied'}",
-1
View File
@@ -4706,7 +4706,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.laster'} @:{'words.generated.inn'} @:{'words.generated.tilgjengelige'} @:{'words.generated.kjøretøytyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "@.capitalize:{'words.generated.maskin'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"no_departments_found": "@:{'templates.generated.compat.departments.no_departments'}",
"occupied": "Okkupert",
"open_property_access_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} inngangsport",
-1
View File
@@ -4756,7 +4756,6 @@
"loading_vehicle_types": "@.capitalize:{'words.generated.laser'} @:{'words.generated.in'} @:{'words.generated.tillgangliga'} @:{'words.generated.fordonstyper'}...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "@.capitalize:{'words.generated.machine'}",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"no_departments_found": "@.capitalize:{'words.generated.inga'} @:{'words.generated.avdelningar'} @:{'words.generated.hittades'}",
"occupied": "Upptagen",
"open_property_access_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} ingangsgrind",
-1
View File
@@ -3663,7 +3663,6 @@
"loading_vehicle_types": "Indlæser tilgængelige køretøjstyper...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "Maskine",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
"vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
"vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
-1
View File
@@ -3663,7 +3663,6 @@
"loading_vehicle_types": "Verf?gbare Fahrzeugtypen werden geladen...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "Maschine",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"vehicle_step_missing_customer_number": "Geben Sie Ihre Kundennummer ein, um fortzufahren.",
"vehicle_step_missing_department": "Warten Sie, bis die nachste Abteilung geladen ist, bevor Sie fortfahren.",
"vehicle_step_missing_registration": "Geben Sie das Kennzeichen ein, um fortzufahren.",
-1
View File
@@ -3662,7 +3662,6 @@
"loading_vehicle_types": "Loading available vehicle types...",
"lane_unavailable": "Lane unavailable",
"machine": "Machine",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"vehicle_step_missing_customer_number": "Enter your customer number to continue.",
"vehicle_step_missing_department": "Wait for the nearest department to load before continuing.",
"vehicle_step_missing_registration": "Enter your registration number to continue.",
-1
View File
@@ -3663,7 +3663,6 @@
"loading_vehicle_types": "Laster inn tilgjengelige kjøretøytyper...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "Maskin",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"vehicle_step_missing_customer_number": "Skriv inn kundenummer for a fortsette.",
"vehicle_step_missing_department": "Vent til naermeste avdeling er lastet inn for du fortsetter.",
"vehicle_step_missing_registration": "Skriv inn registreringsnummer for a fortsette.",
-1
View File
@@ -3663,7 +3663,6 @@
"loading_vehicle_types": "Läser in tillgängliga fordonstyper...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "Machine",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"vehicle_step_missing_customer_number": "Ange kundnummer for att fortsatta.",
"vehicle_step_missing_department": "Vanta tills narmaste avdelning har laddats innan du fortsatter.",
"vehicle_step_missing_registration": "Ange registreringsnummer for att fortsatta.",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.indlæser'} @:{'terms.glossary.tilgængelige'} @:{'terms.glossary.køretøjstyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgængelig",
"machine": "@.capitalize:{'terms.glossary.maskine'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Optaget",
"open_property_access_gate": "@:{'terms.glossary.abn'} adgangsport",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.verf'}?@:{'terms.glossary.gbare'} @:{'terms.glossary.fahrzeugtypen'} @:{'terms.glossary.werden'} @:{'terms.glossary.geladen'}...",
"lane_unavailable": "Waschspur nicht verfugbar",
"machine": "@:{'terms.glossary.maschine'}",
"machine_unavailable_for_lane": "Maschinenwasche ist fur die ausgewahlte Spur nicht verfugbar. Wahlen Sie manuelle Wasche oder eine andere freie Spur.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Belegt",
"open_property_access_gate": "Grundstueckszugangstor @:{'terms.glossary.oeffnen'}",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.loading'} @:{'terms.glossary.available'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.types'}...",
"lane_unavailable": "Lane unavailable",
"machine": "@.capitalize:{'terms.glossary.machine'}",
"machine_unavailable_for_lane": "Machine wash is unavailable for the selected lane. Choose manual wash or another available lane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Occupied",
"open_property_access_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.access'} @:{'terms.glossary.gate'}",
@@ -21,7 +21,6 @@
"loading_vehicle_types": "@:{'phrases.compat.self_wash.loading_vehicle_types'}",
"lane_unavailable": "@:{'phrases.compat.self_wash.lane_unavailable'}",
"machine": "@:{'phrases.compat.self_wash.machine'}",
"machine_unavailable_for_lane": "@:{'phrases.compat.self_wash.machine_unavailable_for_lane'}",
"no_departments_found": "@:{'phrases.compat.self_wash.no_departments_found'}",
"no_vehicles_found": "@:vehicles.no_vehicles",
"occupied": "@:{'phrases.compat.self_wash.occupied'}",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laster'} @:{'terms.glossary.inn'} @:{'terms.glossary.tilgjengelige'} @:{'terms.glossary.kjøretøytyper'}...",
"lane_unavailable": "Vaskebanen er ikke tilgjengelig",
"machine": "@.capitalize:{'terms.glossary.maskin'}",
"machine_unavailable_for_lane": "Maskinvask er ikke tilgjengelig for valgt bane. Velg manuell vask eller en annen ledig bane.",
"no_departments_found": "@:{'phrases.compat.departments.no_departments'}",
"occupied": "Okkupert",
"open_property_access_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} inngangsport",
@@ -18,7 +18,6 @@
"loading_vehicle_types": "@.capitalize:{'terms.glossary.laser'} @:{'terms.glossary.in'} @:{'terms.glossary.tillgangliga'} @:{'terms.glossary.fordonstyper'}...",
"lane_unavailable": "Tvattbanan ar inte tillganglig",
"machine": "@.capitalize:{'terms.glossary.machine'}",
"machine_unavailable_for_lane": "Maskintvatt ar inte tillganglig for vald bana. Valj manuell tvatt eller en annan ledig bana.",
"no_departments_found": "@.capitalize:{'terms.glossary.inga'} @:{'terms.glossary.avdelningar'} @:{'terms.glossary.hittades'}",
"occupied": "Upptagen",
"open_property_access_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} ingangsgrind",
+2
View File
@@ -10,6 +10,7 @@ import applyMiddleware from '@/middleware/index';
import store from './store/user.vue'
import { createHead } from "@vueuse/head";
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Router from "@/router";
import App from "@/App.vue";
import Buefy from 'buefy'
@@ -108,6 +109,7 @@ applyMiddleware(Router)
const app = createApp(App)
.use(Router)
.use(createHead())
.use(createPinia())
.use(store)
.use(ToastPlugin)
.use(Buefy)
+1 -16
View File
@@ -84,23 +84,8 @@ export const sanitizeRedirectPath = (redirectPath) => {
return redirectPath;
};
export const releaseFrontendPathBase = (pathname = '') => {
const match = String(pathname || '').match(/^\/[^/]+\/frontend(?:\/|$)/);
return match ? match[0].replace(/\/+$/, '') : '';
};
export const applyReleaseFrontendBase = (redirectPath, currentPath = '') => {
export const buildMigrationUrl = (migrationOrigin, redirectPath) => {
const safePath = sanitizeRedirectPath(redirectPath);
const releaseBase = releaseFrontendPathBase(currentPath);
if (!releaseBase || safePath === releaseBase || safePath.startsWith(`${releaseBase}/`)) {
return safePath;
}
return safePath === '/' ? `${releaseBase}/` : `${releaseBase}${safePath}`;
};
export const buildMigrationUrl = (migrationOrigin, redirectPath, currentPath = '') => {
const safePath = applyReleaseFrontendBase(redirectPath, currentPath);
try {
return new URL(safePath, migrationOrigin).toString();
} catch (_error) {
+1 -6
View File
@@ -190,14 +190,9 @@ const EconomicQueuePlaywrightHarness = lazyView('@/views/testing/EconomicQueuePl
* departmentSelection: true if the department selection should be possible for the route
*/
export const releaseFrontendRouterBase = (pathname = globalThis?.location?.pathname || '/') => {
const match = String(pathname || '/').match(/^\/[^/]+\/frontend(?:\/|$)/);
return match ? `${match[0].replace(/\/+$/, '')}/` : '/';
};
// Export the router as router
export const router = createRouter({
history: createWebHistory(releaseFrontendRouterBase()),
history: createWebHistory(),
routes: [
...((import.meta.env.DEV || import.meta.env.MODE === 'test') ? [
{
-2
View File
@@ -6,7 +6,6 @@ export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
export const RELEASE_SOURCE_OVERRIDE_STORAGE_KEY = "release_source_override";
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
export const RELEASE_BOOTSTRAP_REVISION = "2026-06-03-dev-asset-republish";
export const RELEASE_SOURCE_MODES = Object.freeze({
LOCAL: "local",
DEPLOYMENT: "deployment",
@@ -266,7 +265,6 @@ const localReleaseRuntime = ({ requestedSource = RELEASE_SOURCE_MODES.LOCAL, mis
return {
source: RELEASE_SOURCE_MODES.LOCAL,
requested_source: requestedSource,
bootstrap_revision: RELEASE_BOOTSTRAP_REVISION,
generated_at: new Date().toISOString(),
channel: {
slug: "stable",
+152
View File
@@ -0,0 +1,152 @@
import { defineStore } from "pinia";
const createValidationState = () => ({ valid: true, items: [], errors: [], warnings: [] });
const createGatewayWorkspaceState = () => ({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
const createSimulatorForm = () => ({
lane_id: null,
reg: "TEST123",
customer_number: null,
config_source: "draft",
include_hardware: true,
hardware_mode: "studio",
});
const createPathEditorQuestionDraft = () => ({
question: "New question",
description: "",
scope: "department_lane",
vehicle_mode: "current",
vehicle_type_values: [],
});
const createPathEditorForm = () => ({
path_key: "",
previous_path_key: "",
answers: {},
machine_allowed: true,
task: "Start machine",
description: "",
services: ["MACHINE"],
program_button: "",
press_reset: false,
press_start: true,
extra_buttons: [],
dynamic_images_vehicle_type: "0",
});
const createVirtualHardwareForm = () => ({
gateway_key: "virtual-main",
gateway_label: "Virtual Studio Gateway",
relay_id: "",
role: "MACHINE",
label: "",
});
const createScopeFilters = () => ({
lane_id: "",
vehicle_type_id: "",
});
const createCanvasFilters = () => ({
...createScopeFilters(),
machine_type_id: "",
dynamic_image_id: "",
});
export const useSelfServeStudioStore = defineStore("selfServeStudio", {
state: () => ({
flowNodes: [],
flowEdges: [],
graphPayload: null,
lookups: {},
validation: createValidationState(),
gatewayWorkspace: createGatewayWorkspaceState(),
versions: [],
activePanel: "flow",
selectedNodeId: null,
inspectorForm: {},
simulatorForm: createSimulatorForm(),
simulatorResult: null,
simulatorAnswerOverrides: {},
simulatorShowJson: false,
simulatorDynamicImageHidden: false,
simulatorDynamicImageError: "",
simulatorCompletedTasks: {},
isSimulatorRunning: false,
simulatorRunReason: "",
simulatorRequestSequence: 0,
simulatorLastRequestKey: "",
simulatorLastError: "",
isRestoringSimulatorStorage: false,
pathOutcomes: null,
isLoadingPathOutcomes: false,
pathOutcomesError: "",
pathOutcomesRequestKey: "",
selectedPathOutcomeId: null,
selectedPathCaseSignature: "",
selectedPathCaseIndex: 0,
pathCaseRunSignatures: {},
pathCaseConfirmingSignature: "",
pathEditorDirty: false,
isHydratingPathEditor: false,
pathEditorTaskTitleOverrides: {},
editingPathEditorTaskTitleKey: "",
editingPathEditorTaskTitle: "",
pathVerificationDynamicImageHidden: false,
pathVerificationDynamicImageError: "",
isPathVerificationDynamicImageLoading: false,
pathEditorQuestionDraftVisible: false,
pathEditorQuestionDraft: createPathEditorQuestionDraft(),
pathEditorForm: createPathEditorForm(),
simulatorCustomerSearchQuery: "",
selectedSimulatorCustomer: null,
showSimulatorCustomerResults: false,
scopeGuideDraft: createScopeFilters(),
isScopeGuideManuallyOpen: false,
isSyncingScopeFromRoute: false,
isWritingScopeToRoute: false,
virtualHardwareForm: createVirtualHardwareForm(),
laneEditorMode: "edit",
selectedLaneManagementId: null,
laneForm: {},
lanePreviewHidden: false,
inspectorLanePreviewHidden: false,
inspectorTaskDynamicImageHidden: false,
inspectorTaskDynamicImageError: "",
searchTerm: "",
filters: createCanvasFilters(),
isLoading: false,
isSaving: false,
isUploadingTaskAttachment: false,
deletingTaskAttachmentId: null,
isStudioFullscreen: false,
hasNativeStudioFullscreen: false,
suppressLayoutSave: false,
exportText: "",
importText: "",
draggedTemplateKind: null,
unfilteredNodePositions: null,
}),
actions: {
resetPathEditorQuestionDraft() {
this.pathEditorQuestionDraft = createPathEditorQuestionDraft();
},
resetPathEditorForm() {
this.pathEditorForm = createPathEditorForm();
},
applyRouteScope(scopeFilters = {}) {
const nextScope = {
...createScopeFilters(),
...scopeFilters,
};
this.scopeGuideDraft = nextScope;
this.filters = {
...this.filters,
...nextScope,
};
},
},
});
@@ -95,6 +95,25 @@ const getDominantWeather = (entries) => {
return dominant;
};
const getProductivityStatus = (washes, hours) => {
if (hours <= 0) {
return "unknown";
}
const productivity = washes / hours;
return productivity >= 1.3 ? "healthy" : productivity >= 1.0 ? "degraded" : "unhealthy";
};
const getNormalizedTime = (entry) => {
const timeValue = typeof entry?.time === "string" ? entry.time : "";
const normalizedTime = timeValue.slice(0, 5);
return /^\d{2}:\d{2}$/.test(normalizedTime) ? normalizedTime : null;
};
const formatMergedNumber = (value) => {
return Number.isInteger(value) ? value : Number(value.toFixed(2));
};
const summarizeEntriesByDay = (entries) => {
const groupedByDate = new Map();
@@ -120,14 +139,48 @@ const summarizeEntriesByDay = (entries) => {
time: "00:00",
current: dayEntries.some((entry) => entry?.current === true),
weather: getDominantWeather(dayEntries),
washes: Number.isInteger(washes) ? washes : Number(washes.toFixed(2)),
washes: formatMergedNumber(washes),
hours: Number(hours.toFixed(2)),
/**
* Below 1.0 cars/workhour = unhealthy (red)
* 1.0-1.3 cars/workhour = degraded (yellow)
* Above 1.3 cars/workhour = healthy (green)
*/
status: hours > 0 ? (washes / hours >= 1.3 ? "healthy" : washes / hours >= 1.0 ? "degraded" : "unhealthy") : "unknown",
status: getProductivityStatus(washes, hours),
};
});
};
const summarizeEntriesByHourOfDay = (entries) => {
const groupedByTime = new Map();
entries.forEach((entry) => {
const time = getNormalizedTime(entry);
if (!time) {
return;
}
if (!groupedByTime.has(time)) {
groupedByTime.set(time, []);
}
groupedByTime.get(time).push(entry);
});
return Array.from(groupedByTime.entries())
.sort(([timeA], [timeB]) => timeA.localeCompare(timeB))
.map(([time, hourEntries]) => {
const washes = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.washes), 0);
const hours = hourEntries.reduce((sum, entry) => sum + toNumberOrZero(entry?.hours), 0);
return {
date: null,
test_id_date: "multi-day",
time,
current: hourEntries.some((entry) => entry?.current === true),
weather: getDominantWeather(hourEntries),
washes: formatMergedNumber(washes),
hours: Number(hours.toFixed(2)),
status: getProductivityStatus(washes, hours),
};
});
};
@@ -136,6 +189,10 @@ const hourlyDisplayedState = computed(() => {
return state.value.filter((entry) => hasOperationalData(entry));
});
const hourlyMergedDisplayedState = computed(() => {
return summarizeEntriesByHourOfDay(hourlyDisplayedState.value).filter((entry) => hasOperationalData(entry));
});
const dailyDisplayedState = computed(() => {
return summarizeEntriesByDay(state.value).filter((entry) => hasOperationalData(entry));
});
@@ -144,6 +201,9 @@ const displayedState = computed(() => {
if (summarizeDaily.value && isMultiDaySelection.value) {
return dailyDisplayedState.value;
}
if (isMultiDaySelection.value) {
return hourlyMergedDisplayedState.value;
}
return hourlyDisplayedState.value;
});
@@ -167,7 +227,9 @@ const getEntryKey = (entry, index) => {
};
const getEntryTestId = (prefix, entry) => {
const date = isDateString(entry?.date) ? entry.date : "no-date";
const date = typeof entry?.test_id_date === "string"
? entry.test_id_date
: isDateString(entry?.date) ? entry.date : "no-date";
const time = typeof entry?.time === "string" && entry.time !== "" ? entry.time : "no-time";
return `department-weather-${prefix}-${date}-${time}`;
};
@@ -177,9 +239,8 @@ const getHourDetailsSlotKey = (entry) => {
return null;
}
const timeValue = typeof entry?.time === "string" ? entry.time : "";
const normalizedTime = timeValue.slice(0, 5);
if (!/^\d{2}:\d{2}$/.test(normalizedTime)) {
const normalizedTime = getNormalizedTime(entry);
if (!normalizedTime) {
return null;
}
@@ -1,5 +1,6 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router";
import { Handle, Position, VueFlow, useVueFlow } from "@vue-flow/core";
import "@vue-flow/core/dist/style.css";
@@ -34,6 +35,7 @@ import {
formatSelfServeTaskButtons,
normalizeSelfServeTaskButtons,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
import { useSelfServeStudioStore } from "@/stores/selfServeStudioStore.js";
import { resolveAuditIssueNodeIds } from "./auditIssueNavigation.js";
const currentRoute = useRoute();
@@ -367,111 +369,88 @@ const AUTO_LAYOUT_RUNTIME_STAGE_ORDER = {
finish: 2,
};
const flowNodes = ref([]);
const flowEdges = ref([]);
const graphPayload = ref(null);
const lookups = ref({});
const validation = ref({ valid: true, items: [], errors: [], warnings: [] });
const gatewayWorkspace = ref({ gateways: [], relays: [], lanes: [], issues: [], actions: [] });
const versions = ref([]);
const activePanel = ref(panelFromRoute() || "flow");
const selectedNodeId = ref(null);
const inspectorForm = ref({});
const simulatorForm = ref({
lane_id: null,
reg: "TEST123",
customer_number: null,
config_source: "draft",
include_hardware: true,
hardware_mode: "studio",
});
const simulatorResult = ref(null);
const simulatorAnswerOverrides = ref({});
const simulatorShowJson = ref(false);
const simulatorDynamicImageHidden = ref(false);
const simulatorDynamicImageError = ref("");
const simulatorCompletedTasks = ref({});
const isSimulatorRunning = ref(false);
const simulatorRunReason = ref("");
const studioStore = useSelfServeStudioStore();
const {
flowNodes,
flowEdges,
graphPayload,
lookups,
validation,
gatewayWorkspace,
versions,
activePanel,
selectedNodeId,
inspectorForm,
simulatorForm,
simulatorResult,
simulatorAnswerOverrides,
simulatorShowJson,
simulatorDynamicImageHidden,
simulatorDynamicImageError,
simulatorCompletedTasks,
isSimulatorRunning,
simulatorRunReason,
simulatorRequestSequence,
simulatorLastRequestKey,
simulatorLastError,
isRestoringSimulatorStorage,
pathOutcomes,
isLoadingPathOutcomes,
pathOutcomesError,
pathOutcomesRequestKey,
selectedPathOutcomeId,
selectedPathCaseSignature,
selectedPathCaseIndex,
pathCaseRunSignatures,
pathCaseConfirmingSignature,
pathEditorDirty,
isHydratingPathEditor,
pathEditorTaskTitleOverrides,
editingPathEditorTaskTitleKey,
editingPathEditorTaskTitle,
pathVerificationDynamicImageHidden,
pathVerificationDynamicImageError,
isPathVerificationDynamicImageLoading,
pathEditorQuestionDraftVisible,
pathEditorQuestionDraft,
pathEditorForm,
simulatorCustomerSearchQuery,
selectedSimulatorCustomer,
showSimulatorCustomerResults,
scopeGuideDraft,
isScopeGuideManuallyOpen,
isSyncingScopeFromRoute,
isWritingScopeToRoute,
virtualHardwareForm,
laneEditorMode,
selectedLaneManagementId,
laneForm,
lanePreviewHidden,
inspectorLanePreviewHidden,
inspectorTaskDynamicImageHidden,
inspectorTaskDynamicImageError,
searchTerm,
filters,
isLoading,
isSaving,
isUploadingTaskAttachment,
deletingTaskAttachmentId,
isStudioFullscreen,
hasNativeStudioFullscreen,
suppressLayoutSave,
exportText,
importText,
draggedTemplateKind,
unfilteredNodePositions,
} = storeToRefs(studioStore);
activePanel.value = panelFromRoute() || "flow";
studioStore.applyRouteScope(scopeFiltersFromRoute());
const simulatorRunTimer = ref(null);
const simulatorRequestSequence = ref(0);
const simulatorLastRequestKey = ref("");
const simulatorLastError = ref("");
const isRestoringSimulatorStorage = ref(false);
const pathOutcomes = ref(null);
const isLoadingPathOutcomes = ref(false);
const pathOutcomesError = ref("");
const pathOutcomesRequestKey = ref("");
const pathOutcomesAbortController = ref(null);
const selectedPathOutcomeId = ref(null);
const selectedPathCaseSignature = ref("");
const selectedPathCaseIndex = ref(0);
const pathCaseRunSignatures = ref({});
const pathCaseConfirmingSignature = ref("");
const pathEditorDirty = ref(false);
const isHydratingPathEditor = ref(false);
const pathEditorTaskTitleOverrides = ref({});
const editingPathEditorTaskTitleKey = ref("");
const editingPathEditorTaskTitle = ref("");
const pathVerificationDynamicImageHidden = ref(false);
const pathVerificationDynamicImageError = ref("");
const isPathVerificationDynamicImageLoading = ref(false);
const pathEditorQuestionDraftVisible = ref(false);
const pathEditorQuestionDraft = ref(emptyPathEditorQuestionDraft());
const pathEditorForm = ref({
path_key: "",
previous_path_key: "",
answers: {},
machine_allowed: true,
task: "Start machine",
description: "",
services: ["MACHINE"],
program_button: "",
press_reset: false,
press_start: true,
extra_buttons: [],
dynamic_images_vehicle_type: "0",
});
const simulatorCustomerSearchQuery = ref("");
const selectedSimulatorCustomer = ref(null);
const showSimulatorCustomerResults = ref(false);
const scopeGuideDraft = ref(scopeFiltersFromRoute());
const isScopeGuideManuallyOpen = ref(false);
const isSyncingScopeFromRoute = ref(false);
const isWritingScopeToRoute = ref(false);
const virtualHardwareForm = ref({
gateway_key: "virtual-main",
gateway_label: "Virtual Studio Gateway",
relay_id: "",
role: "MACHINE",
label: "",
});
const laneEditorMode = ref("edit");
const selectedLaneManagementId = ref(null);
const laneForm = ref({});
const lanePreviewHidden = ref(false);
const inspectorLanePreviewHidden = ref(false);
const inspectorTaskDynamicImageHidden = ref(false);
const inspectorTaskDynamicImageError = ref("");
const searchTerm = ref("");
const filters = ref({
...scopeFiltersFromRoute(),
machine_type_id: "",
dynamic_image_id: "",
});
const isLoading = ref(false);
const isSaving = ref(false);
const isUploadingTaskAttachment = ref(false);
const deletingTaskAttachmentId = ref(null);
const isStudioFullscreen = ref(false);
const hasNativeStudioFullscreen = ref(false);
const layoutSaveTimer = ref(null);
const suppressLayoutSave = ref(false);
const exportText = ref("");
const importText = ref("");
const draggedTemplateKind = ref(null);
const studioRoot = ref(null);
const unfilteredNodePositions = ref(null);
const graphStats = computed(() => {
const counts = {};
@@ -1,6 +1,6 @@
<script setup>
import { computed, onMounted } from "vue";
import { BLoading, BMessage } from "buefy";
import { BLoading } 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,61 +10,12 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
const {
guestDepartments,
nearestDepartment,
isDepartmentSelectionFallbackBased,
fetchDepartments,
evaluateLocationDepartments,
orderDepartmentsByDistance,
selectDepartment,
} = useWashDepartments({ includeLanes: true });
} = useWashDepartments();
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) {
@@ -95,65 +46,27 @@ 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("global.search_departments") }}
<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>
<div class="card-header-icon has-text-link">
<i class="fas fa-map-marker-alt"></i>
</div>
</template>
<template #content>
<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>
<BMessage
v-if="selfServeUnavailableMessage"
type="is-warning"
size="is-small"
class="mt-3"
data-testid="self-serve-home-unavailable-message"
>
{{ selfServeUnavailableMessage }}
</BMessage>
{{ $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>
<template #footer>
<div class="card-footer-item">
<router-link
v-if="nearestDepartment && nearestDepartmentCanStartSelfServe"
v-if="nearestDepartment"
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>
@@ -161,7 +74,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}`"
>
@@ -181,20 +94,7 @@ onMounted(async () => {
</template>
<template #footer>
<div class="card-footer-item">
<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"
>
<router-link :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>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@ const redirectPath = computed(() => {
});
const destinationUrl = computed(() => {
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value, window.location.pathname);
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value);
});
const clearTimers = () => {
+8 -8
View File
@@ -265,11 +265,11 @@ test.describe("Admin daily report", () => {
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await expect(page.getByTestId("department-weather-hours-2026-03-23-06:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-2026-03-23-19:00")).toHaveText("19");
await expect(page.getByTestId("department-weather-hours-2026-03-23-20:00")).toHaveText("21");
await expect(page.getByTestId("department-weather-productivity-2026-03-23-06:00")).toHaveClass(/red/);
await expect(page.getByTestId("department-weather-productivity-2026-03-24-08:00")).toHaveClass(/gray/);
await expect(page.getByTestId("department-weather-hours-multi-day-06:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-multi-day-19:00")).toHaveText("19");
await expect(page.getByTestId("department-weather-hours-multi-day-20:00")).toHaveText("21");
await expect(page.getByTestId("department-weather-productivity-multi-day-06:00")).toHaveClass(/red/);
await expect(page.getByTestId("department-weather-productivity-multi-day-08:00")).toHaveClass(/gray/);
await page.getByTestId("department-weather-summary-toggle").click();
@@ -319,9 +319,9 @@ test.describe("Admin daily report", () => {
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-24");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await expect(page.getByTestId("department-weather-hours-2026-03-23-12:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-2026-03-24-13:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-2026-03-24-14:00")).toHaveCount(0);
await expect(page.getByTestId("department-weather-hours-multi-day-12:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-multi-day-13:00")).toHaveText("1");
await expect(page.getByTestId("department-weather-hours-multi-day-14:00")).toHaveCount(0);
await page.getByTestId("department-weather-summary-toggle").click();
-2
View File
@@ -31,8 +31,6 @@ const expectedFuldfValues = {
const expectedSelfWashValues = {
"self_wash.lane_unavailable": "Vaskebanen er ikke tilgængelig",
"self_wash.machine_unavailable_for_lane":
"Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane.",
"self_wash.vehicle_step_missing_customer_number": "Indtast kundenummer for at fortsætte.",
"self_wash.vehicle_step_missing_department": "Vent på, at nærmeste afdeling er indlæst, før du fortsætter.",
"self_wash.vehicle_step_missing_registration": "Indtast registreringsnummer for at fortsætte.",
@@ -13,7 +13,6 @@ const NGINX_CONFIG_PATH = path.join(PROJECT_ROOT, "nginx.coolify-frontend.conf")
const STATIC_DIRECTORIES = ["assets", "resources", "favicons", "icons", "img", "sounds", ".well-known"];
const STATIC_FILES = [
"index.html",
"manifest.json",
"manifest.webmanifest",
"favicon.ico",
@@ -24,13 +23,12 @@ const STATIC_FILES = [
"registerSW.js",
"sw.js",
];
const RELEASE_FRONTEND_BASE_PATH = "/master/frontend";
const GENERATED_HTML_STATIC_PATHS = [
"./assets/favicons/favicon-96x96.png",
"./assets/favicons/favicon.svg",
"./assets/favicons/favicon.ico",
"./assets/favicons/apple-touch-icon.png",
"./assets/manifest.webmanifest",
"/assets/favicons/favicon-96x96.png",
"/assets/favicons/favicon.svg",
"/assets/favicons/favicon.ico",
"/assets/favicons/apple-touch-icon.png",
"/assets/manifest.webmanifest",
];
const MIME_TYPES = new Map([
@@ -91,22 +89,6 @@ function safePublicPath(relativePath: string) {
return safePath(PUBLIC_DIR, relativePath);
}
function releaseRootStaticPath(staticPath: string) {
if (staticPath === RELEASE_FRONTEND_BASE_PATH) {
return "/";
}
if (staticPath.startsWith(`${RELEASE_FRONTEND_BASE_PATH}/`)) {
return staticPath.slice(RELEASE_FRONTEND_BASE_PATH.length);
}
if (staticPath.startsWith("./")) {
return `/${staticPath.slice(2)}`;
}
if (!staticPath.startsWith("/") && !/^[a-z][a-z\d+.-]*:/i.test(staticPath)) {
return `/${staticPath}`;
}
return staticPath;
}
async function fileExists(filePath: string) {
try {
const stat = await fs.stat(filePath);
@@ -205,7 +187,7 @@ async function distStaticPaths() {
const cssFile = bundledStaticFiles.find((fileName) => fileName.endsWith(".css"));
const indexStaticPaths = Array.from(indexHtml.matchAll(/\b(?:href|src)="([^"]+)"/g))
.map((match) => match[1])
.filter((url) => (url.startsWith("/") && !url.startsWith("//")) || url.startsWith("./"));
.filter((url) => url.startsWith("/") && !url.startsWith("//"));
const manifestIconPaths = (pwaManifest.icons || []).map((icon: { src?: string }) => {
if (!icon.src) {
return "";
@@ -217,8 +199,8 @@ async function distStaticPaths() {
return Array.from(
new Set(
[
...indexStaticPaths.map(releaseRootStaticPath),
...GENERATED_HTML_STATIC_PATHS.map(releaseRootStaticPath),
...indexStaticPaths,
...GENERATED_HTML_STATIC_PATHS,
...manifestIconPaths,
"/manifest.json",
"/manifest.webmanifest",
@@ -232,13 +214,13 @@ async function distStaticPaths() {
workboxFile ? `/${workboxFile}` : "",
releaseEntry.entry ? `/${releaseEntry.entry}` : "",
...(releaseEntry.css || []).map((fileName: string) => `/${fileName}`),
...(releaseManifest.index_asset_urls || []).map(releaseRootStaticPath),
...(releaseManifest.pwa_asset_urls || []).map(releaseRootStaticPath),
...(releaseManifest.asset_urls || []).map(releaseRootStaticPath),
...(releaseManifest.index_asset_urls || []),
...(releaseManifest.pwa_asset_urls || []),
...(releaseManifest.asset_urls || []),
cssFile || "",
].filter(Boolean)
)
);
).filter((staticPath) => staticPath !== "/index.html");
}
test.beforeAll(async () => {
@@ -305,7 +287,6 @@ test.describe("public .htaccess static fallback", () => {
test("emits favicon and manifest links from generated assets", async () => {
const indexHtml = await fs.readFile(path.join(DIST_DIR, "index.html"), "utf8");
const releaseManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "release-manifest.json"), "utf8"));
const legacyManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.json"), "utf8"));
const rootWebManifest = JSON.parse(await fs.readFile(path.join(DIST_DIR, "manifest.webmanifest"), "utf8"));
@@ -313,42 +294,17 @@ test.describe("public .htaccess static fallback", () => {
expect(indexHtml).toContain(`href="${staticPath}"`);
}
expect(indexHtml).toContain('src="./assets/');
expect(indexHtml).toContain('src="./registerSW.js"');
expect(indexHtml).toContain("window.location.pathname.match(/^\\/[^/]+\\/frontend");
expect(indexHtml).not.toContain('href="/assets/');
expect(indexHtml).not.toContain('src="/assets/');
expect(indexHtml).not.toContain('src="/registerSW.js"');
expect(indexHtml).not.toContain(`href="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/assets/`);
expect(indexHtml).not.toContain(`src="${RELEASE_FRONTEND_BASE_PATH}/registerSW.js"`);
expect(indexHtml).not.toContain('href="/favicons/');
expect(indexHtml).not.toContain('href="/favicon.ico"');
expect(indexHtml).not.toContain('href="/manifest.json"');
for (const icon of [...(legacyManifest.icons || []), ...(rootWebManifest.icons || [])]) {
expect(icon.src).toMatch(/^(?:assets|\/master\/frontend\/assets)\/favicons\//);
}
for (const assetPath of [
releaseManifest.entry,
...(releaseManifest.css || []),
...(releaseManifest.index_asset_urls || []),
...(releaseManifest.pwa_asset_urls || []),
...(releaseManifest.asset_urls || []),
].filter(Boolean)) {
expect(assetPath, "release manifest asset paths must be base-relative").not.toMatch(/^\/(?!master\/frontend\/)/);
expect(icon.src).toMatch(/^assets\/favicons\//);
}
});
test("serves the app shell from root, deep links, and release-prefixed paths", async ({ request }) => {
for (const appPath of [
"/",
"/guest/book/wash",
"/canary/frontend",
"/canary/frontend/index.html",
"/canary/frontend/guest/book/wash",
]) {
for (const appPath of ["/", "/guest/book/wash", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
const response = await request.get(`${serverBaseUrl}${appPath}`);
expect(response.status(), appPath).toBe(200);
expect(response.headers()["content-type"], appPath).toContain("text/html");
@@ -363,12 +319,7 @@ test.describe("public .htaccess static fallback", () => {
for (const prefix of ["", "/canary/frontend", "/canary/frontend/guest/book/wash"]) {
const response = await request.get(`${serverBaseUrl}${prefix}${staticPath}`);
expect(response.status(), `${prefix}${staticPath}`).toBe(200);
if (staticPath === "/index.html") {
expect(response.headers()["content-type"], `${prefix}${staticPath}`).toContain("text/html");
expect(await response.text(), `${prefix}${staticPath}`).toContain('<div id="app"></div>');
} else {
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
}
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
}
}
});
+5 -7
View File
@@ -14,11 +14,9 @@ type ReleaseManifest = {
};
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "";
const staticBaseURL = process.env.PLAYWRIGHT_RELEASE_STATIC_BASE_URL || process.env.RELEASE_BASE_URL || baseURL;
const appBaseURL = process.env.PLAYWRIGHT_APP_BASE_URL || baseURL;
function liveUrl(assetPath: string, base = staticBaseURL) {
const normalizedBase = base.endsWith("/") ? base : `${base}/`;
function liveUrl(assetPath: string) {
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
}
@@ -128,7 +126,7 @@ test("@public-live release manifest, shell, and static assets are available", as
for (const assetPath of assetPaths) {
const hashKey = assetPath.startsWith("/") ? assetPath : `/${assetPath}`;
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[assetPath] || manifest.asset_hashes?.[hashKey]);
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[hashKey]);
}
});
@@ -144,9 +142,9 @@ test("@public-live api-v2 gateway and channel API prefixes serve JSON ping respo
});
test("@public-live guest flow renders on the deployed environment", async ({ page }) => {
const guards = attachPageHealthGuards(page, appBaseURL);
const guards = attachPageHealthGuards(page, baseURL);
await page.goto(liveUrl("/guest/book/wash", appBaseURL));
await page.goto("/guest/book/wash");
await settlePage(page);
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
await expectBodyHasContent(page);
@@ -15,7 +15,7 @@ const requireLiveCredentials = process.env.PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS =
function getLiveSettings() {
return {
baseURL: process.env.PLAYWRIGHT_APP_BASE_URL || requiredEnv("PLAYWRIGHT_BASE_URL"),
baseURL: requiredEnv("PLAYWRIGHT_BASE_URL"),
customerCredentials: {
customerNumber: requiredEnv("PLAYWRIGHT_USER_CUSTOMER_NUMBER"),
password: requiredEnv("PLAYWRIGHT_USER_PASSWORD"),
+2 -101
View File
@@ -264,53 +264,6 @@ 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);
@@ -773,7 +726,7 @@ test.describe("Self-serve wash", () => {
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
command: "START",
customer_number: 12345679,
defer_relay_side_effects: true,
defer_relay_side_effects: false,
});
await page.waitForTimeout(250);
expect(orderFilters.some((filters) => filters.includes("customer_id:777"))).toBe(false);
@@ -1191,9 +1144,7 @@ test.describe("Self-serve wash", () => {
expect(requests.commands).toHaveLength(0);
});
test("@dynamic-image tasks flow renders dynamic image, requires completion, and restores after reload", async ({
page,
}) => {
test("tasks flow renders dynamic image, requires completion, and restores after reload", async ({ page }) => {
const requests = captureSelfServeGatewayRequests(page);
await seedSavedProgress(page, {
@@ -1559,56 +1510,6 @@ 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,
-378
View File
@@ -1,378 +0,0 @@
import { expect, test } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
async function suppressVueDevtoolsOverlay(page) {
await page.addInitScript(() => {
const appendStyle = () => {
const parent = document.documentElement || document.head || document.body;
if (!parent) {
document.addEventListener("DOMContentLoaded", appendStyle, { once: true });
return;
}
const style = document.createElement("style");
style.textContent =
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
parent.appendChild(style);
};
appendStyle();
});
}
async function seedGeolocation(context) {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 55.6415, longitude: 12.0803 });
}
function buildSavedProgress(overrides = {}) {
return {
washInProgress: false,
washLaneId: null,
washStartTime: null,
currentStep: 0,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: "12345679",
answers: {},
completedTasks: {},
nearestDepartmentId: 6,
forceNearestDepartmentEvaluationId: 0,
isForcingNearestDepartment: false,
savedAt: Date.now(),
...overrides,
};
}
async function seedSavedProgress(page, overrides = {}) {
await page.addInitScript((savedProgress) => {
window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress));
}, buildSavedProgress(overrides));
}
async function fillRegistration(page, value = "ab12345") {
const registration = page.locator('input[placeholder*="registreringsnummer"]').first();
await registration.fill(value);
await expect(registration).toHaveValue(value.toUpperCase());
await registration.press("Escape");
}
async function fillRegistrationWithoutClosingAutocomplete(page, value = "ab12345") {
const registration = page.locator('input[placeholder*="registreringsnummer"]').first();
await registration.fill(value);
await expect(registration).toHaveValue(value.toUpperCase());
}
async function selectVehicleType(page, vehicleTypeId = 2) {
await page.getByTestId(`self-serve-vehicle-type-${vehicleTypeId}`).click();
await expect(page.getByTestId("self-serve-vehicle-selector-description")).toContainText(/Truck|Van|Car/);
}
async function answerRequiredQuestions(page) {
await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-question-11-yes").click();
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
}
async function completeGuidedWash(page) {
for (let index = 0; index < 5; index += 1) {
await page.getByTestId("self-serve-guided-next").click();
}
}
function collectSelfServeRequests(page) {
const requests = {
departments: [],
vehicleTypeProducts: [],
customerVehicles: [],
allowed: [],
conditions: [],
commands: [],
relays: [],
summaries: [],
activeWash: [],
inProgress: [],
};
page.on("request", (request) => {
const url = new URL(request.url());
const entry = {
method: request.method(),
url,
body: request.method() === "GET" ? null : request.postDataJSON?.() || null,
};
if (url.pathname.endsWith("/guest/departments")) requests.departments.push(entry);
if (url.pathname.endsWith("/products")) requests.vehicleTypeProducts.push(entry);
if (url.pathname.endsWith("/vehicles")) requests.customerVehicles.push(entry);
if (url.pathname.endsWith("/department/selfserve/vehicle/allowed")) requests.allowed.push(entry);
if (url.pathname.endsWith("/department/selfserve/vehicle/conditions")) requests.conditions.push(entry);
if (url.pathname.endsWith("/modules/self-serve/lane/command")) requests.commands.push(entry);
if (url.pathname.endsWith("/modules/self-serve/lane/relay/machine/enable")) requests.relays.push(entry);
if (url.pathname.endsWith("/department/selfserve/washes/summary")) requests.summaries.push(entry);
if (url.pathname.endsWith("/modules/self-serve/lane/wash/my-active-wash")) requests.activeWash.push(entry);
if (url.pathname.endsWith("/modules/self-serve/lane/wash/in-progress")) requests.inProgress.push(entry);
});
return requests;
}
test.describe("User MyWashStart mocked flow", () => {
test.describe.configure({ timeout: 120_000 });
test.skip(({ browserName }) => browserName !== "chromium", "MyWashStart flow coverage is maintained on Chromium.");
test.beforeEach(async ({ page, context }) => {
await suppressVueDevtoolsOverlay(page);
await seedGeolocation(context);
});
test("manual wash selects vehicle, answers questions, starts, stops, and shows completed step", async ({ page }) => {
const requests = collectSelfServeRequests(page);
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: true,
});
await primeMockSession(page, { token: "user-my-wash-manual-token", bootPath: "/user/wash/start" });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
await fillRegistration(page);
await selectVehicleType(page, 2);
await page.getByTestId("self-serve-nav-next").click();
await answerRequiredQuestions(page);
await page.getByTestId("self-serve-nav-confirm").click();
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-lane-option-7").click();
await page.getByTestId("self-serve-wash-type-manual").click();
await page.getByTestId("self-serve-nav-confirm").click();
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
await completeGuidedWash(page);
await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible();
await page.getByTestId("self-serve-nav-complete").click();
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true");
expect(requests.vehicleTypeProducts.length).toBeGreaterThan(0);
expect(requests.customerVehicles.length).toBeGreaterThan(0);
expect(requests.allowed.length).toBeGreaterThan(0);
expect(requests.conditions.some((request) => request.body?.question === 11 && request.body?.value === true)).toBe(
true
);
expect(requests.summaries.length).toBeGreaterThan(0);
expect(requests.activeWash.length).toBeGreaterThan(0);
expect(requests.inProgress.length).toBeGreaterThan(0);
expect(requests.commands.map((request) => request.body?.command)).toEqual(
expect.arrayContaining(["START", "STOP", "OPEN_PROPERTY_EXIT_GATE"])
);
});
test("registration autocomplete does not cover vehicle type selection", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: true,
});
await primeMockSession(page, { token: "user-my-wash-registration-overlay-token", bootPath: "/user/wash/start" });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
await fillRegistrationWithoutClosingAutocomplete(page);
await selectVehicleType(page, 2);
await expect(page.getByTestId("self-serve-nav-next")).toBeEnabled();
});
test("machine wash rolls back when relay enable fails after START", async ({ page }) => {
const requests = collectSelfServeRequests(page);
await seedSavedProgress(page, {
currentStep: 2,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioLaneOption: 7,
radioWashType: "Machine",
customerNumberInput: "12345679",
answers: { 11: true },
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: {
relayResponses: [{ success: false, data: { message: "Machine relay unavailable" } }],
},
});
await primeMockSession(page, { token: "user-my-wash-machine-token", bootPath: "/user/wash/start" });
const needsQuestionReview = await page
.getByTestId("self-serve-question-11")
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (needsQuestionReview) {
await page.getByTestId("self-serve-question-11-yes").click();
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
await page.getByTestId("self-serve-nav-confirm").click();
}
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-wash-type-machine").click();
await page.getByTestId("self-serve-nav-confirm").click();
await expect(page.getByTestId("self-serve-action-error")).toContainText("Machine relay unavailable");
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
expect(requests.relays).toHaveLength(1);
expect(requests.commands.map((request) => request.body?.command)).toEqual(
expect.arrayContaining(["START", "STOP"])
);
});
test("active wash restore reloads start route and resumes at WASH_IN_PROGRESS", async ({ page }) => {
const requests = collectSelfServeRequests(page);
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: {
inProgressByLaneId: {
7: {
lane_id: 7,
in_progress: true,
session: {
id: 801,
lane_id: 7,
department_id: 6,
reg: "AB12345",
customer_number: 12345679,
vehicle_type_id: 2,
machine_relay_enabled: false,
wash_started_at: "2026-06-02 08:00:00",
status: "IN_PROGRESS",
},
customer: { customer_number: 12345679 },
vehicle: { reg: "AB12345", type: 2 },
},
},
summaryBySessionId: {
801: {
session: { id: 801, lane_id: 7, reg: "AB12345", status: "IN_PROGRESS", vehicle_type_id: 2 },
lane: { id: 7, name: "7", department: 6 },
questions: [],
conditions: [],
rules: [],
tasks: [],
events: [{ id: 8011, type: "STARTED", created_at: "2026-06-02T08:00:00.000Z" }],
},
},
},
});
await primeMockSession(page, { token: "user-my-wash-active-token", bootPath: "/user/wash/start" });
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
await expect(page.getByTestId("self-serve-vehicle-step")).toBeHidden();
expect(requests.activeWash.length).toBeGreaterThan(0);
expect(requests.commands).toHaveLength(0);
});
test("server-completed wash refresh keeps local in-progress state until customer completion", async ({ page }) => {
const requests = collectSelfServeRequests(page);
await seedSavedProgress(page, {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 60_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: null,
customer: null,
vehicle: null,
},
},
},
});
await primeMockSession(page, { token: "user-my-wash-server-completed-token", bootPath: "/user/wash/start" });
await expect.poll(() => requests.inProgress.length, { timeout: 15_000 }).toBeGreaterThan(0);
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible();
await expect(page.getByTestId("self-serve-completed-step")).toBeHidden();
});
test("answer-sync failure shows an error and blocks progression", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: true,
});
await page.route(/\/department\/selfserve\/vehicle\/conditions$/i, async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "Answer sync failed" }),
});
return;
}
await route.fallback();
});
await primeMockSession(page, { token: "user-my-wash-answer-sync-token", bootPath: "/user/wash/start" });
await fillRegistration(page);
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 page.getByTestId("self-serve-question-11-yes").click();
await expect(page.getByText("Answer sync failed").first()).toBeVisible();
await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled();
await expect(page.getByTestId("self-serve-lane-step")).toBeHidden();
});
test("geolocation unavailable still provides a department-selection fallback", async ({ browser }) => {
const context = await browser.newContext({ permissions: [] });
const page = await context.newPage();
await suppressVueDevtoolsOverlay(page);
const requests = collectSelfServeRequests(page);
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: { customer_number: 12345679 },
selfServe: true,
});
await primeMockSession(page, { token: "user-my-wash-no-geolocation-token", bootPath: "/user/wash/start" });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 });
await fillRegistration(page);
await selectVehicleType(page, 2);
await expect(page.getByTestId("self-serve-nav-next")).toBeEnabled();
expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true");
await context.close();
});
});
-67
View File
@@ -124,73 +124,6 @@ describe("authenticatedRequest", () => {
);
});
it("does not expose preview-only active wash reservations as in-progress washes", async () => {
const response = {
status: 200,
data: {
data: {
lane_id: 3,
in_progress: true,
status: "READY_FOR_MACHINE_START",
session: {
id: 379,
status: "READY_FOR_MACHINE_START",
machine_relay_enabled: false,
machine_start_triggered: false,
wash_started_at: null,
machine_start_triggered_at: null,
machine_relay_enabled_at: null,
},
},
},
};
const thenCallable = vi.fn();
axiosMock.mockResolvedValueOnce(response);
const result = await authenticatedRequest(
"/modules/self-serve/lane/wash/my-active-wash",
"GET",
undefined,
null,
thenCallable
);
expect(result).not.toBe(response);
expect(result.data.data).toMatchObject({
lane_id: 3,
in_progress: false,
status: "READY_FOR_MACHINE_START",
});
expect(result.data.data.session).toBe(response.data.data.session);
expect(thenCallable).toHaveBeenCalledWith(result);
});
it("keeps relay-enabled active wash responses in progress", async () => {
const response = {
status: 200,
data: {
data: {
lane_id: 3,
in_progress: true,
status: "MACHINE_RELAY_ENABLED",
session: {
id: 380,
status: "MACHINE_RELAY_ENABLED",
machine_relay_enabled: true,
},
},
},
};
axiosMock.mockResolvedValueOnce(response);
const result = await authenticatedRequest("/modules/self-serve/lane/wash/my-active-wash", "GET");
expect(result).toBe(response);
expect(result.data.data.in_progress).toBe(true);
});
it("queues requests and executes them sequentially", async () => {
const first = createDeferred();
const second = createDeferred();
+35 -10
View File
@@ -278,11 +278,11 @@ describe("DepartmentWeather", () => {
await flushAll();
expect(getCellByTestId(wrapper, "department-weather-hours-2026-03-23-06:00").text()).toBe("1");
expect(getCellByTestId(wrapper, "department-weather-hours-2026-03-23-19:00").text()).toBe("19");
expect(getCellByTestId(wrapper, "department-weather-hours-2026-03-23-20:00").text()).toBe("21");
expect(getCellByTestId(wrapper, "department-weather-productivity-2026-03-23-06:00").classes()).toContain("red");
expect(getCellByTestId(wrapper, "department-weather-productivity-2026-03-24-08:00").classes()).toContain("gray");
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-06:00").text()).toBe("1");
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-19:00").text()).toBe("19");
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-20:00").text()).toBe("21");
expect(getCellByTestId(wrapper, "department-weather-productivity-multi-day-06:00").classes()).toContain("red");
expect(getCellByTestId(wrapper, "department-weather-productivity-multi-day-08:00").classes()).toContain("gray");
await wrapper.get('[data-testid="department-weather-summary-toggle"]').trigger("click");
await flushAll();
@@ -325,11 +325,11 @@ describe("DepartmentWeather", () => {
]);
});
it("starts in hourly mode and lets users toggle to daily summary in multi-day mode", async () => {
it("merges matching hours into one column for multi-day hourly mode", async () => {
getObjectsMock.mockResolvedValueOnce([
makeEntry({ date: "2026-03-23", time: "06:00", washes: 1, hours: 1 }),
makeEntry({ date: "2026-03-23", time: "07:00", washes: 2, hours: 2 }),
makeEntry({ date: "2026-03-24", time: "08:00", washes: 3, hours: 3 }),
makeEntry({ date: "2026-03-23", time: "06:00", weather: "clear", washes: 1, hours: 0.5, status: "unhealthy" }),
makeEntry({ date: "2026-03-24", time: "06:00", weather: "rain", washes: 2, hours: 0.6, status: "healthy" }),
makeEntry({ date: "2026-03-24", time: "07:00", weather: "rain", washes: 4, hours: 2, status: "healthy" }),
]);
const wrapper = mount(DepartmentWeather, {
@@ -341,7 +341,32 @@ describe("DepartmentWeather", () => {
});
await flushAll();
expect(getRenderedHours(wrapper)).toEqual(["06", "07", "08"]);
expect(getRenderedHours(wrapper)).toEqual(["06", "07"]);
expect(getRowValues(wrapper, 1)).toEqual(["3", "4"]);
expect(getCellByTestId(wrapper, "department-weather-hours-multi-day-06:00").text()).toBe("2");
expect(getCellByTestId(wrapper, "department-weather-productivity-multi-day-06:00").classes()).toContain("green");
expect(wrapper.find('[data-testid="department-weather-hours-2026-03-23-06:00"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="department-weather-hours-2026-03-24-06:00"]').exists()).toBe(false);
});
it("starts in hourly mode and lets users toggle to daily summary in multi-day mode", async () => {
getObjectsMock.mockResolvedValueOnce([
makeEntry({ date: "2026-03-23", time: "06:00", washes: 1, hours: 1 }),
makeEntry({ date: "2026-03-23", time: "07:00", washes: 2, hours: 2 }),
makeEntry({ date: "2026-03-24", time: "06:00", washes: 3, hours: 3 }),
]);
const wrapper = mount(DepartmentWeather, {
props: {
department_ids: [1],
date_from: "2026-03-23",
date_to: "2026-03-24",
},
});
await flushAll();
expect(getRenderedHours(wrapper)).toEqual(["06", "07"]);
await wrapper.get('[data-testid="department-weather-summary-toggle"]').trigger("click");
await flushAll();
-183
View File
@@ -1,183 +0,0 @@
// @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);
});
});
-134
View File
@@ -1,134 +0,0 @@
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 = shouldRestoreTaskStep ? steps.TASKS : steps.WASH_IN_PROGRESS;");
expect(source).toContain("await fetchWashSummary(summaryParams, false);");
expect(source).toContain('saveProgress("serverActiveWash");');
});
it("restores relay-enabled server sessions to the task checklist", () => {
expect(source).toContain('const SERVER_TASK_RESTORE_STATUSES = new Set(["MACHINE_RELAY_ENABLED"]);');
expect(source).toContain(
'const SERVER_MACHINE_WASH_STATUSES = new Set(["MACHINE_RELAY_ENABLED", "MACHINE_STARTED"]);'
);
expect(source).toContain("const shouldRestoreTaskStep = SERVER_TASK_RESTORE_STATUSES.has(sessionStatus);");
expect(source).toContain("if (shouldRestoreTaskStep && hasMachineTasks.value)");
expect(source).toContain(
"currentStep.value = displayedActiveTasks.value.length > 0 ? steps.TASKS : steps.WASH_IN_PROGRESS;"
);
});
it("does not restore preview-only ready sessions without start evidence", () => {
expect(source).toContain("const hasServerActiveWashStartEvidence = (details: any) =>");
expect(source).toContain("SERVER_MACHINE_WASH_STATUSES.has(sessionStatus)");
expect(source).toContain("session?.machine_relay_enabled === true");
expect(source).toContain("session?.machine_start_triggered === true");
expect(source).toContain("parseServerDateTimeMs(session?.wash_started_at) !== null");
expect(source).toContain("if (!hasServerActiveWashStartEvidence(details))");
});
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("const finalizeGuidedWashCompletion = (laneId: number | string | null) =>");
expect(source).toContain("markRecentlyCompletedWash(laneId, licensePlateInput.value, getNumericCustomerNumber());");
expect(source).toContain("if (isRecentlyCompletedActiveWash(activeWash))");
expect(source).toContain(
"const recentlyCompletedCurrentWash = isRecentlyCompletedActiveWash({ details, laneId });"
);
expect(source).toContain("if (recentlyCompletedCurrentWash) {");
expect(source).toContain("if (!serverStillMatchesCurrentWash) {");
expect(source).toContain("if (isRestoring.value || isCompletedWashState.value || !nearestDepartment.value)");
});
it("verifies server completion before recovering from a failed stop command", () => {
expect(source).toContain("const waitForStopFailureServerCompletion = async () =>");
expect(source).toContain("const serverCompleted = await waitForStopFailureServerCompletion();");
expect(source).toContain("if (serverCompleted) {");
expect(source).toContain("finalizeGuidedWashCompletion(completingLaneId);");
});
it("keeps active and completed washes visible even if department availability changes", () => {
expect(source).toContain("const shouldShowWashFlow = computed(");
expect(source).toContain("const isCompletedWashState = computed(");
expect(source).toContain("const hasCompletedGuidedWash = ref(false);");
expect(source).toContain("const hasRecentlyCompletedWash = ref(false);");
expect(source).toContain("washInProgress.value ||");
expect(source).toContain("isCompletingWash.value ||");
expect(source).toContain("isCompletedWashState.value");
expect(source).toContain("hasRecentlyCompletedWash.value");
expect(source).toContain('data-testid="self-serve-completed-fallback"');
expect(source).toContain('v-show="isCompletedWashState"');
expect(source).toContain('<template v-if="shouldShowWashFlow && !isCompletedWashState">');
expect(source).toContain('v-model="displayedStep"');
expect(source).toContain("currentStep.value < steps.COMPLETED");
});
it("does not treat the customer's active occupied lane as unavailable", () => {
expect(source).toContain("session: selfServeSession,");
expect(source).toContain("const isCurrentActiveWashLane = (lane: any) =>");
expect(source).toContain("const isSelfServeSessionAllowed = () =>");
expect(source).toContain("const isCurrentAllowedSelfServeLane = (lane: any) =>");
expect(source).toContain("isAllowedSelfServeValue(selfServeSession.value?.allowed)");
expect(source).toContain("selfServeSession.value?.lane_id");
expect(source).toContain("getAllowedSelfServeLaneIds().has(laneId)");
expect(source).toContain(
'(isCurrentActiveWashLane(lane) || isCurrentAllowedSelfServeLane(lane) || lane.status === "AVAILABLE")'
);
});
it("does not auto-complete active washes from polling before the customer completes them", () => {
expect(source).toContain(
"const recentlyCompletedCurrentWash = isRecentlyCompletedActiveWash({ details, laneId });"
);
expect(source).toContain("if (recentlyCompletedCurrentWash) {");
expect(source).toContain("!isCompletingWash.value");
expect(source.indexOf("!isCompletingWash.value")).toBeLessThan(
source.indexOf("clearLocalActiveWashFromServer();", source.indexOf("if (!serverStillMatchesCurrentWash)"))
);
});
it("defaults to machine wash when configured tasks are machine-only and the customer has not chosen manually", () => {
expect(source).toContain("const hasExplicitWashTypeSelection = ref(false);");
expect(source).toContain(
"const hasMachineTasks = computed(() => activeTasks.value.some((task: any) => isMachineTask(task)));"
);
expect(source).toContain("const shouldDefaultToMachineWash = computed(");
expect(source).toContain('radioWashType.value = "Machine";');
});
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");
});
});
+42 -604
View File
@@ -24,10 +24,6 @@ const mocks = vi.hoisted(() => {
const conditions = { value: [] };
const rules = { value: [] };
const allowedServices = { value: ["MACHINE"] };
const selfServeAllowed = { value: false };
const selfServeLane = { value: { id: 7 } };
const selfServeSession = { value: null };
const resolvedVehicleTypeId = { value: null };
return {
nearestDepartment,
@@ -40,10 +36,6 @@ const mocks = vi.hoisted(() => {
conditions,
rules,
allowedServices,
selfServeAllowed,
selfServeLane,
selfServeSession,
resolvedVehicleTypeId,
fetchDepartments: vi.fn(async () => guestDepartments.value),
startAutoRefresh: vi.fn(),
stopAutoRefresh: vi.fn(),
@@ -93,7 +85,6 @@ 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: {} } };
@@ -153,8 +144,6 @@ 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,
@@ -167,43 +156,35 @@ vi.mock("@/composables/useWashDepartments", () => ({
}),
}));
vi.mock("@/composables/useSelfServeLogic", async () => {
const { ref } = await import("vue");
mocks.resolvedVehicleTypeId = ref(mocks.resolvedVehicleTypeId.value);
return {
useSelfServeLogic: () => ({
loading: { value: false },
error: { value: null },
lane: mocks.selfServeLane,
questions: { value: [] },
conditions: mocks.conditions,
rules: mocks.rules,
tasks: { value: [] },
answers: mocks.answers,
completedTasks: mocks.completedTasks,
allowedServices: mocks.allowedServices,
resolvedVehicleTypeId: mocks.resolvedVehicleTypeId,
allowed: mocks.selfServeAllowed,
session: mocks.selfServeSession,
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/useSelfServeLogic", () => ({
useSelfServeLogic: () => ({
loading: { value: false },
error: { value: null },
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 = {}) => ({
@@ -269,7 +250,6 @@ 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";
@@ -323,11 +303,9 @@ const stubComponents = {
`,
},
SelfServeLaneStep: {
props: ["isLaneAvailable"],
emits: ["update:selected-lane-id", "update:washType"],
template: `
<div>
<span data-testid="lane-7-available">{{ String(isLaneAvailable({ id: 7, status: 'OCCUPIED', selfserve_enabled: true })) }}</span>
<button data-testid="emit-lane" @click="$emit('update:selected-lane-id', 7)">lane</button>
<button data-testid="emit-machine-wash" @click="$emit('update:washType', 'Machine')">machine wash</button>
</div>
@@ -385,10 +363,6 @@ describe("MyWashStart", () => {
mocks.visibleQuestions.value = [{ id: 11, question: "Question 11" }];
mocks.activeTasks.value = [];
mocks.allowedServices.value = ["MACHINE"];
mocks.selfServeAllowed.value = false;
mocks.selfServeLane.value = { id: 7 };
mocks.selfServeSession.value = null;
mocks.resolvedVehicleTypeId.value = null;
mocks.conditions.value = [];
mocks.rules.value = [];
mocks.sessionCustomerNumber.value = 12345679;
@@ -400,8 +374,7 @@ describe("MyWashStart", () => {
mocks.markDestroying.mockClear();
mocks.fetchCustomerVehicles.mockClear();
mocks.fetchVehicleTypeOptions.mockClear();
mocks.fetchSelfServeDataInternal.mockReset();
mocks.fetchSelfServeDataInternal.mockResolvedValue(undefined);
mocks.fetchSelfServeDataInternal.mockClear();
mocks.fetchWashSummary.mockClear();
mocks.saveProgress.mockClear();
mocks.clearProgress.mockClear();
@@ -414,10 +387,6 @@ describe("MyWashStart", () => {
mocks.addVehicle.mockClear();
mocks.sessionRequest.mockClear();
mocks.sessionRequest.mockResolvedValue(undefined);
SessionUser.request.mockClear();
mocks.handleConfirmNext.mockClear();
mocks.isNextButtonDisabled.mockClear();
mocks.isNextButtonDisabled.mockReturnValue(false);
mocks.onStartWash.mockClear();
mocks.onStopWash.mockReset();
mocks.onStopWash.mockResolvedValue(true);
@@ -450,8 +419,6 @@ describe("MyWashStart", () => {
});
it("wires child updates back into the self-serve runtime", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
@@ -463,7 +430,6 @@ 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");
@@ -483,107 +449,6 @@ 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("adopts the server-resolved vehicle type after an untyped eligibility lookup", async () => {
vi.useFakeTimers();
mocks.fetchSelfServeDataInternal.mockImplementation(async (_departmentId, vehicleTypeId) => {
if (!vehicleTypeId) {
mocks.resolvedVehicleTypeId.value = 2;
}
return { vehicle_type_id: 2 };
});
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
mocks.fetchSelfServeDataInternal.mockClear();
await wrapper.get('[data-testid="emit-registration-unknown"]').trigger("click");
await nextTick();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, null, 7, "ZZ99999");
await nextTick();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "ZZ99999");
});
it("treats the current allowed occupied lane as selectable", async () => {
mocks.nearestDepartment.value = {
...mocks.nearestDepartment.value,
lanes: [{ id: 7, name: "7", status: "OCCUPIED", products: [2], machine_available: true }],
};
mocks.guestDepartments.value = [mocks.nearestDepartment.value];
mocks.selfServeLane.value = { id: 7, status: "OCCUPIED", selfserve_enabled: true };
mocks.selfServeAllowed.value = true;
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
expect(wrapper.get('[data-testid="lane-7-available"]').text()).toBe("true");
});
it("uses the allowed session lane when the lane payload is not populated yet", async () => {
mocks.nearestDepartment.value = {
...mocks.nearestDepartment.value,
lanes: [{ id: 7, name: "7", status: "OCCUPIED", products: [2], machine_available: true }],
};
mocks.guestDepartments.value = [mocks.nearestDepartment.value];
mocks.selfServeLane.value = null;
mocks.selfServeAllowed.value = false;
mocks.selfServeSession.value = { lane_id: 7, allowed: true };
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
expect(wrapper.get('[data-testid="lane-7-available"]').text()).toBe("true");
});
it("hides machine tasks while manual wash is selected", async () => {
mocks.activeTasks.value = [
{ id: 31, task: "Machine checklist", services: ["MACHINE"] },
@@ -672,51 +537,6 @@ 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,
@@ -737,221 +557,6 @@ describe("MyWashStart", () => {
expect(wrapper.text()).not.toContain("self_wash.loading_data");
});
it("keeps the completed step visible when the department becomes unavailable after completion", async () => {
mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled.value = false;
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: 5,
};
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
});
it("shows a recently completed wash before startup refreshes finish", async () => {
localStorage.setItem(
"mywash_recent_completed_v1",
JSON.stringify({
laneId: 7,
reg: "AB12345",
customerNumber: 12345679,
completedAt: Date.now(),
})
);
mocks.fetchDepartments.mockReturnValueOnce(new Promise(() => {}));
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await nextTick();
expect(mocks.fetchDepartments).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
wrapper.unmount();
});
it("picks up a recently completed wash marker written just after mount", async () => {
vi.useFakeTimers();
mocks.fetchDepartments.mockReturnValueOnce(new Promise(() => {}));
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await nextTick();
expect(wrapper.find('[data-testid="completed-step-stub"]').isVisible()).toBe(false);
localStorage.setItem(
"mywash_recent_completed_v1",
JSON.stringify({
laneId: 7,
reg: "AB12345",
customerNumber: 12345679,
completedAt: Date.now(),
})
);
await vi.advanceTimersByTimeAsync(250);
await nextTick();
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
wrapper.unmount();
});
it("restores a recently completed wash marker written after startup refreshes finish", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await vi.advanceTimersByTimeAsync(5500);
await nextTick();
expect(wrapper.find('[data-testid="completed-step-stub"]').isVisible()).toBe(false);
localStorage.setItem(
"mywash_recent_completed_v1",
JSON.stringify({
laneId: 7,
reg: "AB12345",
customerNumber: 12345679,
completedAt: Date.now(),
})
);
await vi.advanceTimersByTimeAsync(1000);
await nextTick();
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
expect(wrapper.vm.currentStep).toBe(5);
expect(mocks.clearProgress).toHaveBeenCalled();
wrapper.unmount();
});
it("does not fetch self-serve eligibility after a recent completion marker is restored", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
mocks.fetchSelfServeDataInternal.mockClear();
localStorage.setItem(
"mywash_recent_completed_v1",
JSON.stringify({
laneId: 7,
reg: "AB12345",
customerNumber: 12345679,
completedAt: Date.now(),
})
);
await vi.advanceTimersByTimeAsync(1000);
await nextTick();
wrapper.vm.licensePlateInput = "CD98765";
wrapper.vm.vehicleTypeSelect = 2;
await nextTick();
await vi.advanceTimersByTimeAsync(400);
expect(wrapper.vm.currentStep).toBe(5);
expect(mocks.fetchSelfServeDataInternal).not.toHaveBeenCalled();
wrapper.unmount();
});
it("does not let a late lane refresh move a completed wash back to questions", async () => {
mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled.value = false;
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 20_000,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
completedTasks: {},
currentStep: 4,
};
const pendingFetchResolvers = [];
mocks.fetchSelfServeDataInternal.mockImplementation(
() =>
new Promise((resolve) => {
pendingFetchResolvers.push(resolve);
})
);
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
const guidedActions = wrapper
.get('[data-testid="self-serve-bottom-actions"]')
.get('[data-testid="self-serve-guided-bottom-actions"]');
for (let index = 0; index < 5; index += 1) {
await guidedActions.get('[data-testid="self-serve-guided-next"]').trigger("click");
}
await guidedActions.get('[data-testid="self-serve-nav-complete"]').trigger("click");
await new Promise((resolve) => setTimeout(resolve, 260));
await flushPromises();
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
pendingFetchResolvers.forEach((resolve) => resolve(null));
await flushPromises();
await nextTick();
expect(wrapper.find('[data-testid="self-serve-disabled-warning"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
});
it("adds an unknown registration as customer vehicle before continuing from vehicle step", async () => {
const wrapper = mountWithApp(MyWashStart, {
global: {
@@ -992,8 +597,6 @@ 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,
@@ -1016,8 +619,6 @@ describe("MyWashStart", () => {
},
});
await flushPromises();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
@@ -1222,40 +823,6 @@ 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) => {
@@ -1448,11 +1015,6 @@ describe("MyWashStart", () => {
expect(wrapper.find('[data-testid="self-serve-nav-close"]').exists()).toBe(true);
});
wrapper.vm.currentStep = 0;
await nextTick();
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
await wrapper.get('[data-testid="self-serve-nav-close"]').trigger("click");
await nextTick();
@@ -1533,7 +1095,6 @@ describe("MyWashStart", () => {
});
it("keeps the guided wash in progress when stop fails", async () => {
vi.useFakeTimers();
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
@@ -1567,7 +1128,7 @@ describe("MyWashStart", () => {
}
await guidedActions.get('[data-testid="self-serve-nav-complete"]').trigger("click");
await vi.advanceTimersByTimeAsync(4000);
await new Promise((resolve) => setTimeout(resolve, 260));
await flushPromises();
expect(mocks.onStopWash).toHaveBeenCalledWith(7);
@@ -1575,71 +1136,6 @@ describe("MyWashStart", () => {
expect(bottomActions.isVisible()).toBe(true);
});
it("shows completed when stop fails but the server confirms the wash is no longer in progress", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 20_000,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
completedTasks: {},
currentStep: 4,
};
mocks.onStopWash.mockResolvedValue(false);
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 7) {
return {
data: {
data: {
lane_id: 7,
in_progress: false,
session: null,
customer: null,
vehicle: null,
},
},
};
}
return undefined;
});
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
const guidedActions = wrapper
.get('[data-testid="self-serve-bottom-actions"]')
.get('[data-testid="self-serve-guided-bottom-actions"]');
for (let index = 0; index < 5; index += 1) {
await guidedActions.get('[data-testid="self-serve-guided-next"]').trigger("click");
}
await guidedActions.get('[data-testid="self-serve-nav-complete"]').trigger("click");
await new Promise((resolve) => setTimeout(resolve, 260));
await flushPromises();
expect(mocks.onStopWash).toHaveBeenCalledWith(7);
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 7,
});
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
expect(mocks.clearProgress).toHaveBeenCalled();
expect(mocks.openPropertyExitGate).toHaveBeenLastCalledWith(7, { suppressAlert: true });
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
});
it("restores an active wash from the server when local progress is missing", async () => {
mocks.nearestDepartment.value = {
id: 6,
@@ -1718,66 +1214,6 @@ describe("MyWashStart", () => {
expect(mocks.openPropertyAccessGate).toHaveBeenCalledWith(9);
});
it("does not restore a preview-only ready session as an active wash", async () => {
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
if (path === "/modules/self-serve/lane/wash/my-active-wash" && method === "GET") {
return {
data: {
data: {
lane_id: 7,
in_progress: true,
status: "READY_FOR_MACHINE_START",
session: {
id: 901,
status: "READY_FOR_MACHINE_START",
reg: "AB12345",
customer_number: 12345679,
vehicle_type_id: 2,
machine_relay_enabled: false,
machine_start_triggered: false,
machine_relay_enabled_at: null,
machine_start_triggered_at: null,
wash_started_at: null,
},
customer: { customer_number: 12345679 },
vehicle: { reg: "AB12345", type: 2 },
},
},
};
}
return {
data: {
data: {
lane_id: payload?.lane_id,
in_progress: false,
session: null,
customer: null,
vehicle: null,
},
},
};
});
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await new Promise((resolve) => setTimeout(resolve, 0));
await flushPromises();
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/my-active-wash", "GET");
expect(mocks.fetchWashSummary).not.toHaveBeenCalled();
expect(mocks.startElapsedTimer).not.toHaveBeenCalled();
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWash");
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
"display: none"
);
});
it("does not restore a recently completed wash from the server on revisit", async () => {
localStorage.setItem(
"mywash_recent_completed_v1",
@@ -1837,10 +1273,12 @@ describe("MyWashStart", () => {
expect(mocks.fetchWashSummary).not.toHaveBeenCalled();
expect(mocks.startElapsedTimer).not.toHaveBeenCalled();
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWash");
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
"display: none"
);
});
it("keeps restored in-progress state when active wash refresh cannot verify authenticated ownership before completion", async () => {
it("clears restored in-progress state when active wash refresh cannot verify authenticated ownership", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
@@ -1896,16 +1334,16 @@ describe("MyWashStart", () => {
});
expect(mocks.fetchWashSummary).not.toHaveBeenCalledWith({ session_id: 909, vehicle_type: 2 }, false);
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWashRefresh");
expect(mocks.stopElapsedTimer).not.toHaveBeenCalled();
expect(mocks.clearProgress).not.toHaveBeenCalled();
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").not.toContain(
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
expect(mocks.clearProgress).toHaveBeenCalled();
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
"display: none"
);
wrapper.unmount();
});
it("keeps restored in-progress state when active wash refresh says the lane is no longer in progress before completion", async () => {
it("clears restored in-progress state when active wash refresh says the lane is no longer in progress", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
@@ -1952,10 +1390,10 @@ describe("MyWashStart", () => {
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 7,
});
expect(mocks.stopElapsedTimer).not.toHaveBeenCalled();
expect(mocks.clearProgress).not.toHaveBeenCalled();
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
expect(mocks.clearProgress).toHaveBeenCalled();
expect(mocks.fetchWashSummary).not.toHaveBeenCalled();
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").not.toContain(
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
"display: none"
);
-17
View File
@@ -2,14 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
OUTDATED_GATEWAY_ROUTE_NAME,
OUTDATED_INSTALLATION_ROUTE_NAME,
applyReleaseFrontendBase,
buildMigrationUrl,
isOriginAllowed,
resolveOriginMigrationRoute,
sanitizeRedirectPath,
startRedirectCountdown,
} from "@/middleware/originMigration";
import { ALLOWED_ORIGINS } from "@/config";
describe("origin migration route guard", () => {
const allowedOrigins = ["https://truckwash.io", "https://www.truckwash.io"];
@@ -37,10 +34,6 @@ describe("origin migration route guard", () => {
expect(target).toBeNull();
});
it("allows the public release gateway origin", () => {
expect(isOriginAllowed("https://api-v2.truckwash.io", ALLOWED_ORIGINS)).toBe(true);
});
it("routes to outdated installation for disallowed standalone context", () => {
const target = resolveOriginMigrationRoute({
to: route,
@@ -85,16 +78,6 @@ describe("origin migration redirect url", () => {
expect(redirectUrl).toBe("https://truckwash.io/user/bookings?tab=open#latest");
});
it("preserves release frontend base when redirecting from a release-prefixed route", () => {
expect(applyReleaseFrontendBase("/", "/master/frontend/outdated-gateway")).toBe("/master/frontend/");
expect(applyReleaseFrontendBase("/user/wash/start", "/master/frontend/outdated-gateway")).toBe(
"/master/frontend/user/wash/start"
);
expect(buildMigrationUrl("https://truckwash.io", "/", "/master/frontend/outdated-gateway")).toBe(
"https://truckwash.io/master/frontend/"
);
});
it("sanitizes invalid redirect paths to root", () => {
expect(sanitizeRedirectPath("https://malicious.example")).toBe("/");
});
-18
View File
@@ -1,18 +0,0 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { releaseFrontendRouterBase } from "@/router.js";
describe("router release base", () => {
it("uses root routing for normal frontend hosts", () => {
expect(releaseFrontendRouterBase("/")).toBe("/");
expect(releaseFrontendRouterBase("/user/my-wash/start")).toBe("/");
expect(releaseFrontendRouterBase("/login/qr")).toBe("/");
});
it("uses the channel frontend prefix only for release artifact URLs", () => {
expect(releaseFrontendRouterBase("/master/frontend")).toBe("/master/frontend/");
expect(releaseFrontendRouterBase("/master/frontend/")).toBe("/master/frontend/");
expect(releaseFrontendRouterBase("/master/frontend/login/qr")).toBe("/master/frontend/");
expect(releaseFrontendRouterBase("/stable/frontend/guest/book/wash")).toBe("/stable/frontend/");
});
});
@@ -11,9 +11,6 @@ describe("Danish self-serve labels", () => {
it("uses Danish self-serve validation and availability text", () => {
expect(da.self_wash.lane_unavailable).toBe("Vaskebanen er ikke tilgængelig");
expect(da.self_wash.machine_unavailable_for_lane).toBe(
"Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane."
);
expect(da.self_wash.vehicle_step_missing_customer_number).toBe("Indtast kundenummer for at fortsætte.");
expect(da.self_wash.vehicle_step_missing_department).toBe(
"Vent på, at nærmeste afdeling er indlæst, før du fortsætter."
+5 -10
View File
@@ -78,21 +78,16 @@ describe("SelfServeLaneStep", () => {
expect(wrapper.text()).toContain("Tilgængelig");
});
it("shows machine unavailable guidance for the selected lane", () => {
it("keeps machine wash disabled without rendering extra guidance", () => {
const wrapper = mountLaneStep({
isMachineAvailable: () => false,
});
const machineButton = wrapper.get('[data-testid="self-serve-wash-type-machine"]');
const washTypeGroup = wrapper.get('[data-testid="self-serve-wash-type-group"]');
const washTypeOptions = wrapper.get('[data-testid="self-serve-wash-type-options"]');
const guidance = wrapper.get('[data-testid="self-serve-machine-unavailable-guidance"]');
expect(machineButton.attributes("disabled")).toBeDefined();
expect(machineButton.attributes("title")).toBe("self_wash.machine_unavailable_for_lane");
expect(guidance.text()).toContain("self_wash.machine_unavailable_for_lane");
expect(washTypeOptions.find('[data-testid="self-serve-machine-unavailable-guidance"]').exists()).toBe(false);
expect(Array.from(washTypeGroup.element.children).indexOf(washTypeOptions.element)).toBeLessThan(
Array.from(washTypeGroup.element.children).indexOf(guidance.element)
);
expect(machineButton.attributes("title")).toBe("self_wash.unavailable");
expect(machineButton.text()).toContain("self_wash.unavailable");
expect(wrapper.find('[data-testid="self-serve-machine-unavailable-guidance"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain("self_wash.machine_unavailable_for_lane");
});
});
+5 -13
View File
@@ -52,17 +52,19 @@ describe("SelfServeTaskList", () => {
await wrapper.get('[data-testid="self-serve-task-5-attachment-101"]').trigger("click");
await wrapper.get('[data-testid="self-serve-task-5-attachment-102"]').trigger("click");
expect(wrapper.emitted("toggle-task")).toEqual([[5, true]]);
expect(wrapper.emitted("toggle-task")).toEqual([
[5, true],
[5, true],
]);
expect(wrapper.emitted("download-attachment")).toEqual([
[5, 101, attachments[0]],
[5, 102, attachments[1]],
]);
expect(wrapper.text()).not.toContain("MACHINE");
expect(wrapper.text()).not.toContain("Button 1");
expect(wrapper.text()).not.toContain("Use the soft brush");
});
it("adds the configured wheel selection only when the program title does not already include it", () => {
it("prefixes only program picker task titles with the configured wheel selection", () => {
const wrapper = mountWithApp(SelfServeTaskList, {
props: {
tasks: [
@@ -74,14 +76,6 @@ describe("SelfServeTaskList", () => {
buttons: [],
dynamic_images_vehicle_type: 4,
},
{
id: 10,
task: "Vælg program #1 på dial",
description: "-",
services: ["PROGRAM_PICKER"],
buttons: [],
dynamic_images_vehicle_type: 1,
},
{
id: 7,
task: "Tryk reset",
@@ -119,8 +113,6 @@ describe("SelfServeTaskList", () => {
});
expect(wrapper.get('[data-testid="self-serve-task-6"]').text()).toContain("Choose program #4");
expect(wrapper.get('[data-testid="self-serve-task-10"]').text()).toContain("Vælg program #1 på dial");
expect(wrapper.get('[data-testid="self-serve-task-10"]').text()).not.toContain("program #1 #1");
expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).toContain("Tryk reset");
expect(wrapper.get('[data-testid="self-serve-task-7"]').text()).not.toContain("#");
expect(wrapper.get('[data-testid="self-serve-task-9"]').text()).toContain("Choose by button #3");
+17 -23
View File
@@ -25,37 +25,32 @@ const mountTasksStep = (props = {}) =>
});
describe("SelfServeTasksStep", () => {
it("preloads the dynamic image without reserving frame space until it loads", async () => {
it("shows a Buefy skeleton until the dynamic image loads", async () => {
const wrapper = mountTasksStep();
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').attributes("src")).toBe(
"https://cdn.example.test/dynamic.png"
);
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').classes()).toContain("is-loading");
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').classes()).toContain("is-loading");
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("load");
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("load");
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-dynamic-image-preload"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
"https://cdn.example.test/dynamic.png"
);
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').classes()).not.toContain("is-loading");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').classes()).not.toContain("is-loading");
});
it("preloads again without showing a frame when the dynamic image URL changes", async () => {
it("shows the skeleton again when the dynamic image URL changes", async () => {
const wrapper = mountTasksStep();
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("load");
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
"https://cdn.example.test/dynamic.png"
);
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("load");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
await wrapper.setProps({
dynamicImageUrl: "https://cdn.example.test/dynamic-step-2.png",
});
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').attributes("src")).toBe(
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
"https://cdn.example.test/dynamic-step-2.png"
);
});
@@ -63,19 +58,18 @@ describe("SelfServeTasksStep", () => {
it("clears loading state and emits clear-dynamic-image on image errors", async () => {
const wrapper = mountTasksStep();
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("error");
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("error");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.emitted("clear-dynamic-image")).toEqual([[]]);
});
it("does not render dynamic image elements when there is no dynamic image URL", () => {
it("does not render a skeleton when there is no dynamic image URL", () => {
const wrapper = mountTasksStep({
dynamicImageUrl: null,
});
expect(wrapper.find('[data-testid="self-serve-dynamic-image-preload"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image"]').exists()).toBe(false);
});
});
@@ -12,14 +12,12 @@ vi.mock("@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue", () =
}));
const AutocompleteStub = {
props: ["data"],
emits: ["select", "typing", "update:modelValue", "select-header"],
template: `
<div>
<button data-testid="auto-update-object" @click="$emit('update:modelValue', { registration_number: 'ec21235' })">object</button>
<button data-testid="auto-select-string" @click="$emit('select', 'ab12345')">select</button>
<button data-testid="auto-select-header" @click="$emit('select-header', $event)">header</button>
<div data-testid="auto-options">{{ (data || []).join(",") }}</div>
<slot name="header" />
<slot name="empty" />
</div>
@@ -146,47 +144,4 @@ describe("SelfServeVehicleStep", () => {
);
expect(wrapper.find('[data-testid="selector-stub"]').exists()).toBe(true);
});
it("filters exact registration matches out of autocomplete overlay content", () => {
const wrapper = mount(SelfServeVehicleStep, {
props: {
customerNumber: "12345",
showCustomerNumberInput: false,
registrationNumber: "EC21235",
registrationOptions: ["AB12345", "EC21235", "ZZ99999"],
isCustomerVehiclesLoading: false,
hasMatchingVehicle: true,
selectedVehicleTypeId: null,
selectedVehicleName: null,
selectedVehicleDescription: null,
availableProductIds: [2],
vehicleTypes: [{ id: 2 }],
},
global: {
mocks: {
$t: (value) => value,
},
stubs: {
BField: {
template: "<div><slot /></div>",
},
BAutocomplete: AutocompleteStub,
BInput: true,
BMessage: {
template: "<div v-bind='$attrs'><slot /></div>",
},
},
},
});
expect(wrapper.get('[data-testid="auto-options"]').text()).toBe("");
expect(wrapper.get('[data-testid="self-serve-registration-guidance"]').text()).toContain(
"self_wash.select_from_vehicles"
);
expect(wrapper.get(".self-serve-registration-control").exists()).toBe(true);
expect(wrapper.get('[data-testid="self-serve-registration-guidance"]').classes()).toContain(
"self-serve-registration-guidance"
);
expect(wrapper.find('[data-testid="selector-stub"]').exists()).toBe(true);
});
});
@@ -1,174 +0,0 @@
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("keeps preview questions visible while session summary is still loading", async () => {
const slowSummary = deferred();
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue(
previewPayload({
questions: [{ id: 11, question: "Clean?", answer: true, order_priority: 1 }],
tasks: [],
})
);
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockReturnValue(slowSummary.promise);
const logic = useSelfServeLogic();
const fetchPromise = logic.fetchSelfServeData(6, 2, 7, "ab12345");
await vi.waitFor(() => {
expect(logic.loading.value).toBe(true);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([11]);
});
slowSummary.resolve(summaryPayload());
await fetchPromise;
expect(logic.loading.value).toBe(false);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([21]);
});
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);
});
});
-220
View File
@@ -91,225 +91,6 @@ describe("useSelfServeLogic", () => {
});
});
it("clears stale wash summary machine availability when a fresh preview has no session", async () => {
mocks.previewAllowed
.mockResolvedValueOnce({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: { id: 91, status: "READY_FOR_MACHINE_START" },
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
})
.mockResolvedValueOnce({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
});
mocks.washSummary.mockResolvedValueOnce({
machine_available: false,
session: { id: 91, status: "READY_FOR_MACHINE_START" },
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"] }],
events: [],
allowed_services: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(logic.machineAvailable.value).toBe(false);
await logic.fetchSelfServeData(1, 9, 7, "cd12345");
expect(logic.summary.value).toBeNull();
expect(logic.machineAvailable.value).toBe(true);
expect(logic.allowedServices.value).toEqual(["MACHINE"]);
});
it("does not clear server-provided allowed services when task ids are temporarily empty", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
allowed_services: ["MACHINE", "PROGRAM_PICKER"],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
const response = await logic.updateLaneAllowedServices(7);
expect(mocks.request).not.toHaveBeenCalled();
expect(response).toMatchObject({
success: true,
data: {
lane_id: 7,
allowed_services: ["MACHINE", "PROGRAM_PICKER"],
skipped_empty_task_sync: true,
},
});
expect(logic.allowedServices.value).toEqual(["MACHINE", "PROGRAM_PICKER"]);
expect(logic.machineAvailable.value).toBe(true);
});
it("does not post empty task ids to the lane allowed-services endpoint", async () => {
const logic = useSelfServeLogic();
const response = await logic.updateLaneAllowedServices(7);
expect(mocks.request).not.toHaveBeenCalled();
expect(response).toMatchObject({
success: true,
data: {
lane_id: 7,
allowed_services: [],
skipped_empty_task_sync: true,
},
});
expect(logic.allowedServices.value).toEqual([]);
});
it("allows a forced preview refresh for an already successful request key", async () => {
mocks.previewAllowed
.mockResolvedValueOnce({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
})
.mockResolvedValueOnce({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [],
tasks: [{ id: 6, task: "Pick program", order_priority: 1, services: ["PROGRAM_PICKER"] }],
conditions: [],
rules: [],
allowed_services: ["PROGRAM_PICKER"],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(mocks.previewAllowed).toHaveBeenCalledTimes(1);
expect(logic.tasks.value.map((task) => task.id)).toEqual([5]);
await logic.fetchSelfServeData(1, 9, 7, "ab12345", { force: true });
expect(mocks.previewAllowed).toHaveBeenCalledTimes(2);
expect(logic.tasks.value.map((task) => task.id)).toEqual([6]);
expect(logic.allowedServices.value).toEqual(["PROGRAM_PICKER"]);
});
it("publishes preview task ids before attachment hydration finishes", async () => {
let resolveAttachments;
mocks.previewAllowed.mockResolvedValue({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
});
mocks.attachmentsList.mockReturnValue(
new Promise((resolve) => {
resolveAttachments = resolve;
})
);
mocks.request.mockResolvedValue({
data: { success: true, data: { allowed_services: ["MACHINE"] } },
});
const logic = useSelfServeLogic();
const fetchPromise = logic.fetchSelfServeData(1, 9, 7, "ab12345");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(logic.tasks.value.map((task) => task.id)).toEqual([5]);
expect(logic.allowedServices.value).toEqual(["MACHINE"]);
await logic.updateLaneAllowedServices(7);
expect(mocks.request).toHaveBeenCalledWith("/modules/self-serve/lane/services/allowed", "post", {
lane_id: 7,
task_ids: [5],
});
resolveAttachments([]);
await fetchPromise;
});
it("preserves preview task ids when wash summary omits task ids", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: { id: 91, status: "READY_FOR_MACHINE_START" },
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
tasks: [
{
id: 802001,
task: "Vælg program #2 på dial",
condition_id: 702001,
order_priority: 10,
services: ["MACHINE"],
},
{
id: 802006,
task: "Tryk knap #6 (tagbørste program)",
condition_id: 702003,
order_priority: 36,
services: ["MACHINE"],
},
],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
});
mocks.washSummary.mockResolvedValue({
session: { id: 91, status: "READY_FOR_MACHINE_START" },
lane: { id: 7, name: "Lane 7" },
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
tasks: [
{ task: "Vælg program #2 på dial", condition_id: 702001, order_priority: 10, services: ["MACHINE"] },
{ task: "Tryk knap #6 (tagbørste program)", condition_id: 702003, order_priority: 36, services: ["MACHINE"] },
],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(logic.tasks.value.map((task) => task.id)).toEqual([802001, 802006]);
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([802001, 802006]);
});
it("does not infer machine permission from summary tasks when allowed services are explicitly empty", async () => {
const machineButtonTask = {
id: 5,
@@ -729,7 +510,6 @@ describe("useSelfServeLogic", () => {
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, null, 7, "ab12345");
expect(logic.resolvedVehicleTypeId.value).toBe(5);
expect(mocks.washSummary).toHaveBeenNthCalledWith(1, { session_id: 91 });
expect(mocks.washSummary).toHaveBeenNthCalledWith(2, { lane_id: 7, reg: "AB12345", vehicle_type: 5 });
});
-134
View File
@@ -165,138 +165,4 @@ 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");
});
});
@@ -1,105 +0,0 @@
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("keeps lane confirmation enabled when the selected lane id is a string", async () => {
const { state, flow } = createFlow({ radioLaneOption: ref("7") });
state.currentStep.value = WASH_STEPS.SELECT_LANE;
expect(flow.isNextButtonDisabled()).toBe(false);
await flow.handleConfirmNext();
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345679", WASH_STEPS.WASH_IN_PROGRESS);
});
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);
});
});
+1 -49
View File
@@ -35,7 +35,7 @@ describe("useWashFlowState", () => {
};
};
it("validates the vehicle step against customer, registration, and selected vehicle type", () => {
it("validates the vehicle step against customer, registration, and allowed product ids", () => {
const { flow, state } = createState();
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
@@ -48,40 +48,9 @@ describe("useWashFlowState", () => {
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
state.vehicleTypeSelect.value = 9;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
state.vehicleTypeSelect.value = null;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
});
it("allows the selected vehicle type when the department has no product allow-list", () => {
const { flow, state } = createState({
availableProductIds: ref([]),
vehicleTypeSelect: ref(3),
});
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
expect(flow.isNextButtonDisabled()).toBe(false);
state.vehicleTypeSelect.value = null;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
expect(flow.isNextButtonDisabled()).toBe(true);
});
it("does not require a customer number when the customer number input is hidden", () => {
const { flow, state } = createState({
customerNumberInput: ref(""),
isCustomerNumberRequired: ref(false),
});
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
expect(flow.isNextButtonDisabled()).toBe(false);
state.isCustomerNumberRequired.value = true;
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
expect(flow.isNextButtonDisabled()).toBe(true);
});
it("moves from questions to lane selection after updating allowed services", async () => {
const { flow, state } = createState({
currentStep: ref(WASH_STEPS.QUESTIONS),
@@ -128,21 +97,4 @@ describe("useWashFlowState", () => {
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345", WASH_STEPS.TASKS);
});
it("matches string lane selections against numeric lane ids", async () => {
const { flow, state } = createState({
currentStep: ref(WASH_STEPS.QUESTIONS),
radioLaneOption: ref("7"),
});
await flow.handleConfirmNext();
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
expect(state.currentStep.value).toBe(WASH_STEPS.SELECT_LANE);
expect(flow.isNextButtonDisabled()).toBe(false);
await flow.handleConfirmNext();
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345", WASH_STEPS.WASH_IN_PROGRESS);
});
});
@@ -1,150 +0,0 @@
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();
});
it("recovers local state when STOP says an unoccupied lane cannot invoice", async () => {
const request = vi.fn(async () => ({
data: { success: false, message: "Failed to execute command: Lane ID 7 is not occupied; cannot invoice." },
}));
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();
});
});
+3 -48
View File
@@ -207,7 +207,7 @@ describe("useWashSessionActions property gate commands", () => {
command: "START",
customer_number: 12345679,
license_plate: "AB12345",
defer_relay_side_effects: true,
defer_relay_side_effects: false,
});
consoleErrorSpy.mockRestore();
@@ -286,7 +286,7 @@ describe("useWashSessionActions property gate commands", () => {
command: "START",
customer_number: 12345679,
license_plate: "AB12345",
defer_relay_side_effects: true,
defer_relay_side_effects: false,
});
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
lane_id: 7,
@@ -346,7 +346,7 @@ describe("useWashSessionActions property gate commands", () => {
command: "START",
customer_number: 12345679,
license_plate: "AB12345",
defer_relay_side_effects: true,
defer_relay_side_effects: false,
});
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
lane_id: 7,
@@ -446,51 +446,6 @@ describe("useWashSessionActions property gate commands", () => {
expect(clearProgress).toHaveBeenCalledTimes(1);
});
it("clears active wash state when stop reports the lane cannot invoice because it is unoccupied", async () => {
const washInProgress = ref(true);
const washLaneId = ref(7);
const washStartTime = ref(2_000);
const completedDurationMs = ref(null);
const now = ref(47_000);
const clearProgress = vi.fn();
const stopElapsedTimer = vi.fn();
const request = vi.fn(async (_url, _method, body) => {
if (body.command === "STOP") {
return {
data: {
success: false,
data: {
message: "Failed to execute command: Lane ID 7 is not occupied; cannot invoice.",
},
},
};
}
return { data: { success: true } };
});
const { actions, alertFn } = createActions({
request,
washInProgress,
washLaneId,
washStartTime,
completedDurationMs,
now,
clearProgress,
stopElapsedTimer,
});
const result = await actions.onStopWash(7);
expect(result).toBe(true);
expect(alertFn).not.toHaveBeenCalled();
expect(completedDurationMs.value).toBe(45_000);
expect(washInProgress.value).toBe(false);
expect(washLaneId.value).toBeNull();
expect(stopElapsedTimer).toHaveBeenCalledTimes(1);
expect(clearProgress).toHaveBeenCalledTimes(1);
});
it("clears active wash state only after the stop command succeeds", async () => {
const washInProgress = ref(true);
const washLaneId = ref(7);
+11 -96
View File
@@ -35,60 +35,6 @@ const PUBLIC_ASSET_ALIASES = [
['favicons/web-app-manifest-192x192.png', 'assets/favicons/web-app-manifest-192x192.png'],
['favicons/web-app-manifest-512x512.png', 'assets/favicons/web-app-manifest-512x512.png'],
]
const DEFAULT_RELEASE_FRONTEND_BASE_PATH = './'
function normalizeReleaseFrontendBasePath(value) {
const rawValue = String(value || '').trim()
let basePath = rawValue
if (/^https?:\/\//i.test(rawValue)) {
try {
basePath = new URL(rawValue).pathname
} catch {
basePath = ''
}
}
if (!basePath || basePath === '/') {
return '/'
}
if (basePath === './') {
return './'
}
return `/${basePath.replace(/^\/+|\/+$/g, '')}/`
}
function releaseFrontendBasePath() {
return normalizeReleaseFrontendBasePath(
process.env.VITE_FRONTEND_BASE_PATH ||
process.env.VITE_BASE_PATH ||
process.env.RELEASE_FRONTEND_BASE_PATH ||
DEFAULT_RELEASE_FRONTEND_BASE_PATH
)
}
function releaseManifestPath(pathname) {
const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`
const basePath = releaseFrontendBasePath()
if (basePath === '/' || basePath === './') {
return normalizedPathname
}
const basePrefix = basePath.replace(/\/+$/g, '')
if (normalizedPathname === basePrefix) {
return '/'
}
if (normalizedPathname.startsWith(`${basePrefix}/`)) {
return normalizedPathname.slice(basePrefix.length) || '/'
}
return normalizedPathname
}
function releaseManifestAssetPath(value) {
return String(value || '').replace(/^\/+/, '')
}
function getGitCommit() {
const envCommit = firstReleaseCommitEnv()
@@ -152,7 +98,7 @@ function releaseUrlPath(value, basePath = '/') {
return ''
}
return releaseManifestPath(url.pathname)
return url.pathname
} catch {
return ''
}
@@ -160,9 +106,7 @@ function releaseUrlPath(value, basePath = '/') {
function releaseManifestDistPath(outputDirectory, urlPath) {
try {
const normalizedPath = decodeURIComponent(
releaseManifestPath(new URL(urlPath, 'https://release.local').pathname)
).replace(/^\/+/, '')
const normalizedPath = decodeURIComponent(new URL(urlPath, 'https://release.local').pathname).replace(/^\/+/, '')
const resolvedPath = path.resolve(outputDirectory, normalizedPath)
const resolvedOutputDirectory = path.resolve(outputDirectory)
if (
@@ -186,32 +130,6 @@ function releaseManifestHash(filePath) {
}
}
function releaseDistAssetUrls(outputDirectory) {
const assetUrls = []
const walk = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, entry.name)
const relativePath = path.relative(outputDirectory, absolutePath).replace(/\\/g, '/')
if (entry.isDirectory()) {
walk(absolutePath)
continue
}
if (!entry.isFile()) {
continue
}
if (relativePath === 'release-manifest.json' || relativePath === '.htaccess') {
continue
}
assetUrls.push(relativePath)
}
}
walk(outputDirectory)
return assetUrls
}
function patchBuefyCssMediaQuery() {
return {
name: 'patch-buefy-css-media-query',
@@ -232,7 +150,7 @@ function patchBuefyCssMediaQuery() {
nextCode = nextCode.replaceAll(invalidSnippet, fixedSnippet)
didPatch = true
}
if (!didPatch) {
return null
}
@@ -357,14 +275,12 @@ function releaseMetadataManifest() {
const releaseEntry = JSON.parse(fs.readFileSync(releaseEntryPath, 'utf8'))
const indexHtml = fs.readFileSync(indexPath, 'utf8')
const rootFiles = fs.readdirSync(outputDirectory)
const emittedAssetUrls = releaseDistAssetUrls(outputDirectory)
const indexAssetUrls = Array.from(indexHtml.matchAll(/\b(?:href|src)=["']([^"']+)["']/g))
.map((match) => releaseUrlPath(match[1], '/index.html'))
.map(releaseManifestAssetPath)
.filter(Boolean)
const releaseEntryAssetUrls = [
releaseEntry.entry ? releaseManifestAssetPath(releaseEntry.entry) : '',
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map(releaseManifestAssetPath) : [])
releaseEntry.entry ? `/${releaseEntry.entry}` : '',
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map((fileName) => `/${fileName}`) : [])
].filter(Boolean)
const pwaAssetUrls = [
'manifest.json',
@@ -378,7 +294,7 @@ function releaseMetadataManifest() {
...rootFiles.filter((fileName) => /^workbox-[^/]+\.js$/.test(fileName))
]
.filter((fileName) => fs.existsSync(path.join(outputDirectory, fileName)))
.map((fileName) => releaseManifestAssetPath(fileName.replace(/\\/g, '/')))
.map((fileName) => `/${fileName.replace(/\\/g, '/')}`)
for (const manifestPath of ['manifest.webmanifest', 'assets/manifest.webmanifest']) {
const absoluteManifestPath = path.join(outputDirectory, manifestPath)
@@ -391,7 +307,7 @@ function releaseMetadataManifest() {
for (const icon of pwaManifest.icons || []) {
const iconPath = releaseUrlPath(icon.src || '', `/${manifestPath}`)
if (iconPath) {
pwaAssetUrls.push(releaseManifestAssetPath(iconPath))
pwaAssetUrls.push(iconPath)
}
}
} catch {
@@ -400,9 +316,8 @@ function releaseMetadataManifest() {
}
const criticalAssetUrls = Array.from(new Set([
'index.html',
'release-entry.json',
...emittedAssetUrls,
'/index.html',
'/release-entry.json',
...indexAssetUrls,
...releaseEntryAssetUrls,
...pwaAssetUrls
@@ -524,8 +439,8 @@ export default defineConfig(({ mode }) => {
process.env.APP_VERSION = version
process.env.IS_DEV = !isProd ? 'true' : 'false'
// Server version updates happen after deploy verification, not during asset builds.
// Keep production assets host-agnostic. Release channel prefixes are resolved at runtime.
const base = isProd ? releaseFrontendBasePath() : '/'
// Keep a relative base for static hosting; align manifest with base
const base = '/'
const pwaScope = base === './' ? '.' : base
// Enable single-file build only when explicitly requested (disabled by default for PWA)