Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot] 51b996d8cb Initial plan 2026-06-07 20:16:58 +00:00
Jeppe Bundgaard 880680d7f8 Add reusable ErrorBanner component and integrate into MyWashStart 2026-06-07 22:07:56 +02:00
Jeppe Bundgaard 7d5ed772c2 Avoid empty MyWash lane service sync 2026-06-04 07:40:33 +02:00
Jeppe Bundgaard 83fbcdeaa8 Refresh MyWash eligibility before service sync 2026-06-04 07:31:41 +02:00
Jeppe Bundgaard 55a761d3a5 Publish MyWash tasks before hydration 2026-06-04 07:27:12 +02:00
Jeppe Bundgaard abe8e7750f Preserve MyWash machine services during sync 2026-06-04 07:21:07 +02:00
Jeppe Bundgaard aeef75b831 Clear stale MyWash summary after preview 2026-06-04 07:13:00 +02:00
Jeppe Bundgaard d6356d0244 Stop MyWash eligibility refresh after completion 2026-06-04 06:35:26 +02:00
Jeppe Bundgaard bbf2aeb95f Keep MyWash completion visible after stop 2026-06-04 06:05:41 +02:00
Jeppe Bundgaard 3a7d78ae05 Serve release-prefixed index shell 2026-06-04 04:08:33 +02:00
Jeppe Bundgaard 2a2f752692 Refresh recent completion marker during startup 2026-06-04 03:51:43 +02:00
Jeppe Bundgaard 8883bf8a07 Seed recently completed wash state before startup awaits 2026-06-04 03:44:29 +02:00
Jeppe Bundgaard 6ec80e2796 Recover recently completed self-serve screen 2026-06-04 03:29:55 +02:00
Jeppe Bundgaard 849a7b77cf Adopt resolved self-serve vehicle type 2026-06-04 03:16:59 +02:00
Jeppe Bundgaard 36ccd0c381 Keep completed wash fallback visible 2026-06-04 03:01:09 +02:00
Jeppe Bundgaard 58e7c8d6c9 Keep completed wash after lane refresh 2026-06-04 02:44:48 +02:00
Jeppe Bundgaard d04fc8cc0f Keep completed self-serve wash visible 2026-06-04 02:29:16 +02:00
Jeppe Bundgaard 339a1b1452 Recover completed wash after stop timeout 2026-06-04 02:15:15 +02:00
Jeppe Bundgaard 2d2d63d09e Merge remote-tracking branch 'origin/master' 2026-06-04 02:02:26 +02:00
Jeppe B 8241f7fd2f Treat unoccupied stop invoice errors as completed washes 2026-06-04 02:01:35 +02:00
Jeppe Bundgaard 985571af87 Treat unoccupied stop invoice errors as completed washes 2026-06-04 01:55:35 +02:00
Jeppe Bundgaard 82e1e4a466 Ignore preview-only active wash reservations 2026-06-04 01:21:25 +02:00
Jeppe Bundgaard 9a7f7bbb78 Prevent preview-only self-serve sessions from auto-restoring 2026-06-04 01:08:04 +02:00
Jeppe Bundgaard 359f29b75e Keep self-serve preview questions visible during summary load 2026-06-04 00:45:20 +02:00
Jeppe B ceced7cbaa Show MyWash completion when Buefy hides step panel 2026-06-04 00:21:33 +02:00
Jeppe Bundgaard 6b5cd00b10 Show completed wash outside step panels 2026-06-04 00:10:36 +02:00
Jeppe Bundgaard 02eae47d34 Refactor toggle-task event emission in SelfServeTaskList component 2026-06-04 00:00:28 +02:00
Jeppe Bundgaard 3f01d38676 Enhance self-serve image loading with preload and error handling 2026-06-03 23:53:54 +02:00
Jeppe Bundgaard 390a29ef55 Merge remote-tracking branch 'origin/master' 2026-06-03 23:34:05 +02:00
Jeppe Bundgaard b7f5786747 Add vehicle type selection logic for departments without product allow-list 2026-06-03 23:33:54 +02:00
Jeppe B 814c535757 Use relative release manifest asset paths 2026-06-03 23:13:26 +02:00
Jeppe Bundgaard ed0e27e4fd Use relative release manifest asset paths 2026-06-03 23:07:17 +02:00
Jeppe Bundgaard 659df480d7 Merge remote-tracking branch 'origin/master' 2026-06-03 23:04:43 +02:00
Jeppe B d0c45f6180 Fix release frontend origin routing 2026-06-03 22:45:32 +02:00
Jeppe Bundgaard 5a20461bd4 Fix release frontend origin routing 2026-06-03 22:40:49 +02:00
Jeppe Bundgaard 849f52fb8e Refactor error message display and update test descriptions for clarity 2026-06-03 22:16:27 +02:00
Jeppe Bundgaard da0c699b88 Add self-serve session handling and lane selection normalization 2026-06-03 22:07:47 +02:00
Jeppe Bundgaard af5514c982 Implement dynamic base path handling for frontend assets and update routing logic 2026-06-03 22:00:18 +02:00
Jeppe Bundgaard cc689e80a9 Use release base for frontend HTML assets 2026-06-03 21:45:25 +02:00
Jeppe Bundgaard 3b51374678 Add dynamic release frontend base path handling and update static asset references 2026-06-03 21:44:27 +02:00
Jeppe Bundgaard f07569db98 Allow active self-serve lane selection 2026-06-03 21:32:58 +02:00
Jeppe Bundgaard c5fcb90683 Include emitted chunks in release manifest 2026-06-03 21:12:08 +02:00
Jeppe Bundgaard 270f87dee1 Split release smoke static and app targets 2026-06-03 21:03:03 +02:00
Jeppe Bundgaard 765b8f3b35 Fix self-serve entry regressions 2026-06-03 20:51:06 +02:00
Jeppe Bundgaard 3ff695899c Restore self-serve task phase from active sessions 2026-06-03 20:32:40 +02:00
Jeppe Bundgaard be35baa838 Refactor self-serve registration field for improved layout and accessibility 2026-06-03 20:06:31 +02:00
Jeppe B 470d4d7314 Normalize release bootstrap cleanup 2026-06-03 19:38:54 +02:00
Jeppe B 0976608a25 Refresh release bootstrap asset hash 2026-06-03 19:34:48 +02:00
Jeppe B e7f4dfa07e Trigger frontend release after asset mismatch 2026-06-03 19:23:01 +02:00
Jeppe B 2c68f335c6 Trigger frontend release for self-serve vehicle fix 2026-06-03 18:08:25 +02:00
Jeppe Bundgaard cf29589e29 Fix self-serve vehicle step progression 2026-06-03 17:51:46 +02:00
Jeppe Bundgaard 05cabd8edc Add margin and padding adjustments to self-serve task checkbox 2026-06-02 20:09:18 +02:00
Jeppe Bundgaard 2f4ecba51a Merge remote-tracking branch 'origin/master' 2026-06-02 20:04:11 +02:00
Jeppe Bundgaard 74a7c9f23f Refactor self-serve task list to improve task title handling and remove unnecessary description checks 2026-06-02 20:03:55 +02:00
Jeppe B b075a717f7 Cover hidden customer number self-serve flow 2026-06-02 19:59:30 +02:00
Jeppe B a3dcb888d3 Cover self-serve task title presentation 2026-06-02 19:59:12 +02:00
Jeppe B 02706af508 Allow QR self-serve vehicle step without customer field 2026-06-02 19:58:50 +02:00
Jeppe B 1f2c007776 Tighten self-serve task presentation 2026-06-02 19:58:28 +02:00
Jeppe Bundgaard 42dfdfef13 Add mocks for department daily report components and update Vitest configuration for SCSS 2026-06-02 17:54:37 +02:00
Jeppe B b40bf0b9e1 Merge pull request #110 from copenhagentruckwash/create-playwright-test-suite-for-mywashstart
Add mocked Playwright tests for MyWashStart flow and improve answer-sync & department fallback
2026-06-02 13:52:00 +02:00
Jeppe B cc15a9c468 Merge branch 'master' into create-playwright-test-suite-for-mywashstart 2026-06-02 13:51:52 +02:00
Jeppe B 5d8b1eecb4 Merge pull request #111 from copenhagentruckwash/create-playwright-test-suite-for-mywashstart-6chtnh
Add mocked Playwright tests for MyWashStart flow and improve answer-sync & department fallback
2026-06-02 13:50:32 +02:00
Jeppe B 093d82d762 Merge branch 'master' into create-playwright-test-suite-for-mywashstart-6chtnh 2026-06-02 13:50:24 +02:00
Jeppe B 686577c209 Merge pull request #114 from copenhagentruckwash/update-mywashstart.vue-with-timeout-handling
Fix delayed active wash restore cleanup
2026-06-02 13:48:47 +02:00
Jeppe B c2b333a78b Merge branch 'master' into update-mywashstart.vue-with-timeout-handling 2026-06-02 13:48:33 +02:00
Jeppe B cef8b8bf0f Merge pull request #115 from copenhagentruckwash/update-userdashboard-to-inspect-lane-readiness
Guard wash entry self-serve CTA
2026-06-02 13:47:39 +02:00
Jeppe B 62a46710ba Merge branch 'master' into update-userdashboard-to-inspect-lane-readiness 2026-06-02 13:47:31 +02:00
Jeppe B a3e6feb07e Merge pull request #113 from copenhagentruckwash/refactor-self-serve-data-loading-with-fetch-scheduler
Debounce self-serve wash data fetches
2026-06-02 13:43:40 +02:00
Jeppe B 2ad093d4f1 Merge pull request #112 from copenhagentruckwash/update-mywashstart.vue-for-answer-sync-state
Block wash question flow on failed answer sync
2026-06-02 13:43:22 +02:00
Jeppe B 6017d576fc Merge pull request #116 from copenhagentruckwash/update-wash-department-handling-and-ui
Handle self-serve wash when geolocation is unavailable
2026-06-02 13:42:51 +02:00
Jeppe B 08ac38dc87 Merge pull request #117 from copenhagentruckwash/add-and-extend-unit-and-e2e-tests
Add production-ready tests for user self-serve wash start (unit + mocked e2e)
2026-06-02 13:42:36 +02:00
Jeppe B 3960daa556 Add production wash start test matrix 2026-06-02 13:42:22 +02:00
Jeppe B 395cbd87dd Handle self-serve wash without geolocation 2026-06-02 13:42:16 +02:00
Jeppe B 23429c047f Guard wash entry self-serve CTA 2026-06-02 13:42:09 +02:00
Jeppe B af68748c16 Fix active wash restore timeout cleanup 2026-06-02 13:42:02 +02:00
Jeppe B 18ab007f87 Debounce self-serve wash data fetches 2026-06-02 13:41:55 +02:00
Jeppe B 63942045be Block wash question flow on failed answer sync 2026-06-02 13:41:51 +02:00
Jeppe B a4cdc6e4f3 Add mocked MyWashStart flow coverage 2026-06-02 13:41:44 +02:00
Jeppe B 07c611bdbe Add mocked MyWashStart flow coverage 2026-06-02 13:41:32 +02:00
51 changed files with 4725 additions and 478 deletions
+3 -2
View File
@@ -19,9 +19,10 @@ jobs:
runs-on: [self-hosted, Linux, X64, default]
env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_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_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/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
@@ -0,0 +1,4 @@
{
"version": 1,
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
}
@@ -0,0 +1,33 @@
# User wash start production-readiness QA matrix
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
## Unit matrix
| Area | Required scenario | Coverage |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
## E2E mocked matrix
| Required scenario | Coverage |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
## Optional live smoke
| Optional scenario | Coverage |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
+13 -4
View File
@@ -2,10 +2,19 @@
<html lang="" class="theme-light" data-theme="light">
<head>
<meta charset="UTF-8">
<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" />
<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" />
<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>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>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)$ {
try_files /$static_file_path =404;
}
+4 -4
View File
@@ -23,17 +23,17 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
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]
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]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
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]
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]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
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]
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]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
@@ -41,7 +41,7 @@
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
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]
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]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
+1
View File
@@ -35,6 +35,7 @@ export const ownedFilesByRole = {
"userHome.spec.ts",
"userInvoices.spec.ts",
"userMyWashStart.spec.ts",
"userMyWashStartFlow.spec.ts",
"userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts",
@@ -0,0 +1,72 @@
<!--
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>
@@ -0,0 +1,59 @@
<!--
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>
@@ -21,16 +21,6 @@ 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 [];
@@ -83,7 +73,7 @@ const formatTaskTitle = (task: any) => {
const title = String(task?.task || "");
const programWheelSelection = getProgramWheelSelection(task);
if (programWheelSelection === null) {
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) {
return title;
}
@@ -127,13 +117,11 @@ 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>
@@ -156,22 +144,21 @@ const formatTaskTitle = (task: any) => {
</div>
</div>
</template>
<div v-else class="columns is-mobile is-vcentered">
<div v-if="props.showCheckboxes" class="column is-narrow">
<div v-else class="self-serve-task-row">
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox">
<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="column">
<div class="self-serve-task-content">
<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>
@@ -239,7 +226,7 @@ const formatTaskTitle = (task: any) => {
padding: 0.65rem 0.75rem;
display: flex;
align-items: flex-start;
gap: 0.6rem;
gap: 0.35rem;
pointer-events: auto;
}
@@ -247,12 +234,30 @@ 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 { ref, watch } from "vue";
import { BIcon, BSkeleton } from "buefy";
import { computed, ref, watch } from "vue";
import { BIcon } from "buefy";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{
@@ -19,9 +19,16 @@ const emit = defineEmits<{
}>();
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) => {
@@ -38,6 +45,7 @@ const onDynamicImageLoad = () => {
const onDynamicImageError = () => {
isDynamicImageLoading.value = false;
failedDynamicImageUrl.value = props.dynamicImageUrl;
emit("clear-dynamic-image");
};
</script>
@@ -58,26 +66,28 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<img
v-if="dynamicImageUrl && isDynamicImageLoading"
:key="`${dynamicImageUrl}:preload`"
:src="dynamicImageUrl"
alt=""
data-testid="self-serve-dynamic-image-preload"
class="self-serve-dynamic-image-preload"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
<div
v-if="dynamicImageUrl"
v-if="showDynamicImageFrame"
class="self-serve-dynamic-image-frame mb-4"
:class="{ 'is-loading': isDynamicImageLoading }"
data-testid="self-serve-dynamic-image-frame"
>
<b-skeleton
v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton"
/>
<img
:key="dynamicImageUrl"
:src="dynamicImageUrl"
alt="Machine status"
data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
@@ -107,19 +117,20 @@ const onDynamicImageError = () => {
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-skeleton {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 4px;
}
.self-serve-dynamic-image {
width: 100%;
height: 100%;
@@ -128,8 +139,4 @@ const onDynamicImageError = () => {
object-fit: contain;
transition: opacity 120ms ease;
}
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
@@ -21,8 +21,11 @@ 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 => {
@@ -49,6 +52,19 @@ 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);
});
@@ -57,6 +73,18 @@ 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>
@@ -68,38 +96,41 @@ const emitRegistration = (value: unknown) => {
:model-value="customerNumber || ''"
data-testid="self-serve-customer-number"
:placeholder="$t('self_wash.enter_customer_number')"
@input="emit('update:customerNumber', String($event || ''))"
@input="emitCustomerNumber($event)"
@update:modelValue="emitCustomerNumber($event)"
/>
</b-field>
<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>
<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"
>
<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>
</template>
<template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
</b-autocomplete>
</p>
</div>
</b-field>
<b-message
v-if="props.vehicleStepError"
@@ -124,7 +155,7 @@ const emitRegistration = (value: unknown) => {
<SelfServeVehicleTypeSelector
:selectedVehicleTypeId="selectedVehicleTypeId"
:restrictToProductIds="availableProductIds"
@selected="emit('select-vehicle-type', $event)"
@selected="emitVehicleTypeSelection"
/>
</template>
<template v-else-if="vehicleTypes.length === 0">
@@ -140,3 +171,34 @@ const emitRegistration = (value: unknown) => {
</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>
@@ -0,0 +1,81 @@
<!--
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>
@@ -0,0 +1,255 @@
<!--
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>
@@ -0,0 +1,170 @@
<!--
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>
@@ -13,6 +13,58 @@ 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) {
@@ -63,6 +115,7 @@ 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);
+118 -6
View File
@@ -271,6 +271,55 @@ const mergeByNumericId = (existingItems, incomingItems) => {
return merged;
};
const taskStableKey = (task) =>
String(task?.task ?? "")
.trim()
.toLowerCase();
const mergeTaskSets = (existingTasks, incomingTasks) => {
if (!Array.isArray(existingTasks) || existingTasks.length === 0) {
return Array.isArray(incomingTasks) ? [...incomingTasks] : [];
}
if (!Array.isArray(incomingTasks) || incomingTasks.length === 0) {
return [...existingTasks];
}
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,
};
});
};
export function useSelfServeLogic() {
const loading = ref(false);
const requestError = ref(null);
@@ -297,6 +346,30 @@ export function useSelfServeLogic() {
const summaryVisibleQuestionIds = ref([]);
const summaryQuestionOrder = ref({});
const latestFetchRequestId = ref(0);
const latestSuccessfulFetchKey = ref(null);
const inFlightFetchKey = ref(null);
const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => {
const normalizedDepartmentId = parseInt(departmentId);
const normalizedLaneId = parseInt(laneId);
const normalizedReg = String(reg || "").trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(vehicleTypeId);
const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0
? normalizedVehicleTypeId
: "";
if (
Number.isNaN(normalizedDepartmentId)
|| normalizedDepartmentId <= 0
|| Number.isNaN(normalizedLaneId)
|| normalizedLaneId <= 0
|| normalizedReg.length < 2
) {
return null;
}
return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|");
};
const beginFetchRequest = () => {
latestFetchRequestId.value += 1;
@@ -425,6 +498,10 @@ 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;
}
@@ -443,6 +520,7 @@ export function useSelfServeLogic() {
questions.value = nextQuestions;
answers.value = buildAnswersMap(nextQuestions);
setSummaryVisibleQuestions(nextQuestions);
const normalizedConditions = Array.isArray(previewData?.conditions)
? previewData.conditions.map(normalizeCondition)
@@ -462,13 +540,15 @@ 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;
};
@@ -513,8 +593,9 @@ 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(normalizedTasks);
const hydratedTasks = await hydrateTaskAttachments(mergedTasks);
if (!isFetchRequestActive(requestId)) {
return false;
}
@@ -575,10 +656,17 @@ export function useSelfServeLogic() {
}
};
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => {
const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => {
const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg);
if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) {
return null;
}
const requestId = beginFetchRequest();
if (!laneId || !reg || reg.trim().length < 2) {
latestSuccessfulFetchKey.value = null;
inFlightFetchKey.value = null;
preview.value = null;
summary.value = null;
session.value = null;
@@ -606,6 +694,7 @@ export function useSelfServeLogic() {
loading.value = true;
requestError.value = null;
inFlightFetchKey.value = fetchKey;
try {
const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -687,6 +776,10 @@ export function useSelfServeLogic() {
setSummaryVisibleQuestions(questions.value);
}
if (isFetchRequestActive(requestId)) {
latestSuccessfulFetchKey.value = fetchKey;
}
return previewData;
} catch (error) {
console.error("Error fetching self-serve preview:", error);
@@ -695,6 +788,9 @@ export function useSelfServeLogic() {
}
return null;
} finally {
if (inFlightFetchKey.value === fetchKey) {
inFlightFetchKey.value = null;
}
if (isFetchRequestActive(requestId)) {
loading.value = false;
}
@@ -749,7 +845,7 @@ export function useSelfServeLogic() {
updateResolvedVehicleTypeId(responseSummary, responseSummary?.session);
}
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true });
answers.value = {
...answers.value,
[parseInt(questionId)]: value,
@@ -814,7 +910,7 @@ export function useSelfServeLogic() {
? normalizedRefreshVehicleTypeId
: null;
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg);
await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true });
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
@@ -986,7 +1082,22 @@ export function useSelfServeLogic() {
const updateLaneAllowedServices = async (laneId) => {
if (!laneId) return;
const taskIds = activeTasks.value.map((task) => extractTaskId(task)).filter(Boolean);
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,
},
};
}
try {
const response = await SessionUser.request('/modules/self-serve/lane/services/allowed', 'post', {
@@ -1049,6 +1160,7 @@ export function useSelfServeLogic() {
answers,
completedTasks,
allowedServices,
resolvedVehicleTypeId: lastResolvedVehicleTypeId,
configVersionId,
evaluationTrace,
visibleQuestions,
+40 -5
View File
@@ -14,6 +14,9 @@ const isLaneSelfServeEnabled = (lane) =>
)
);
const hasSelfServeEnabledLane = (department) =>
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
const toDepartmentViewModel = (department, distance = null) => ({
id: department.id,
distance,
@@ -47,6 +50,7 @@ export function useWashDepartments(options = {}) {
const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null);
const departmentSelectionStrategy = ref(null);
let refreshInterval = null;
@@ -73,6 +77,12 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value.self_serve_enabled === true;
});
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
const fetchDepartments = async () => {
@@ -110,20 +120,30 @@ 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,
@@ -141,6 +161,18 @@ 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;
@@ -167,7 +199,7 @@ export function useWashDepartments(options = {}) {
};
const startDepartmentSearch = () => {
if (!canAccessSuperUser()) {
if (!canAccessSuperUser() && hasLocationCoordinates()) {
return;
}
@@ -245,6 +277,9 @@ export function useWashDepartments(options = {}) {
isSearchingDepartments,
lastDepartmentFetchTime,
departmentFetchError,
departmentSelectionStrategy,
isDepartmentSelectionDistanceBased,
isDepartmentSelectionFallbackBased,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
+28 -11
View File
@@ -7,6 +7,11 @@ 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,
@@ -14,7 +19,7 @@ export function useWashFlowState(options) {
customerNumberInput,
licensePlateInput,
vehicleTypeSelect,
availableProductIds,
isCustomerNumberRequired,
radioLaneOption,
nearestDepartment,
allVisibleQuestionsAnswered,
@@ -34,6 +39,17 @@ 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) {
@@ -41,16 +57,15 @@ export function useWashFlowState(options) {
}
const selectedVehicleTypeId = vehicleTypeSelect.value;
const allowedProductIds = new Set((availableProductIds.value || []).map((productId) => String(productId)));
const hasAllowedVehicleType = selectedVehicleTypeId !== null
&& selectedVehicleTypeId !== undefined
&& allowedProductIds.has(String(selectedVehicleTypeId));
const hasSelectedVehicleType = selectedVehicleTypeId !== null && selectedVehicleTypeId !== undefined;
const customerNumberValue = customerNumberInput.value;
const customerNumberValid = customerNumberValue === null
const requiresCustomerNumber = isCustomerNumberRequired?.value ?? false;
const customerNumberValid = !requiresCustomerNumber
|| customerNumberValue === null
|| customerNumberValue === undefined
|| String(customerNumberValue).trim() !== "";
const licensePlateValid = !!(licensePlateInput.value && licensePlateInput.value.trim() !== "");
if (!hasAllowedVehicleType) {
if (!hasSelectedVehicleType) {
return false;
}
@@ -103,7 +118,7 @@ export function useWashFlowState(options) {
}
if (currentStep.value === steps.SELECT_LANE) {
const selectedLane = nearestDepartment.value?.lanes.find((lane) => lane.id === radioLaneOption.value);
const selectedLane = getSelectedLane();
if (!selectedLane || !isLaneAvailable(selectedLane)) {
return true;
}
@@ -133,9 +148,10 @@ export function useWashFlowState(options) {
const handleConfirmNext = async () => {
if (currentStep.value === steps.QUESTIONS) {
editAnswers.value = false;
if (radioLaneOption.value && radioLaneOption.value !== "Any") {
const selectedLaneId = getSelectedLaneId();
if (selectedLaneId !== null) {
try {
await updateLaneAllowedServices(radioLaneOption.value);
await updateLaneAllowedServices(selectedLaneId);
} catch {
return;
}
@@ -150,8 +166,9 @@ export function useWashFlowState(options) {
return;
}
const selectedLaneId = getSelectedLaneId() ?? radioLaneOption.value;
await onStartWash(
radioLaneOption.value,
selectedLaneId,
licensePlateInput.value,
customerNumberInput.value,
targetStepForStart()
+4 -1
View File
@@ -70,7 +70,10 @@ export function useWashSessionActions(options) {
const isAlreadyStoppedStopError = (message) => {
const normalized = String(message || "").toLowerCase();
return normalized.includes("cannot stop lane") && normalized.includes("not occupied");
return (
normalized.includes("not occupied") &&
(normalized.includes("cannot stop lane") || normalized.includes("cannot invoice"))
);
};
const clearActiveWashState = () => {
+1
View File
@@ -66,6 +66,7 @@ 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",
+16 -1
View File
@@ -84,8 +84,23 @@ export const sanitizeRedirectPath = (redirectPath) => {
return redirectPath;
};
export const buildMigrationUrl = (migrationOrigin, redirectPath) => {
export const releaseFrontendPathBase = (pathname = '') => {
const match = String(pathname || '').match(/^\/[^/]+\/frontend(?:\/|$)/);
return match ? match[0].replace(/\/+$/, '') : '';
};
export const applyReleaseFrontendBase = (redirectPath, currentPath = '') => {
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) {
+6 -1
View File
@@ -190,9 +190,14 @@ 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(),
history: createWebHistory(releaseFrontendRouterBase()),
routes: [
...((import.meta.env.DEV || import.meta.env.MODE === 'test') ? [
{
+2
View File
@@ -6,6 +6,7 @@ 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",
@@ -265,6 +266,7 @@ 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",
@@ -1,6 +1,6 @@
<script setup>
import { computed, onMounted } from "vue";
import { BLoading } from "buefy";
import { BLoading, BMessage } from "buefy";
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
@@ -10,12 +10,61 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
const {
guestDepartments,
nearestDepartment,
isDepartmentSelectionFallbackBased,
fetchDepartments,
evaluateLocationDepartments,
orderDepartmentsByDistance,
} = useWashDepartments();
selectDepartment,
} = useWashDepartments({ includeLanes: true });
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true;
const isLaneSelfServeEnabled = (lane) =>
!(
lane?.selfserve_enabled === false ||
lane?.selfserve_enabled === 0 ||
lane?.selfserve_enabled === "0" ||
["false", "off", "no"].includes(
String(lane?.selfserve_enabled ?? "")
.trim()
.toLowerCase()
)
);
const isLaneReadyForSelfServe = (lane) => lane?.status === "AVAILABLE" && isLaneSelfServeEnabled(lane);
const canDepartmentStartSelfServe = (department) =>
isDepartmentSelfServeEnabled(department) && (department?.lanes || []).some(isLaneReadyForSelfServe);
const nearestDepartmentCanStartSelfServe = computed(() => canDepartmentStartSelfServe(nearestDepartment.value));
const eligibleDepartments = computed(() => guestDepartments.value.filter(canDepartmentStartSelfServe));
const eligibleAlternativeDepartments = computed(() =>
eligibleDepartments.value.filter((department) => department.id !== nearestDepartment.value?.id)
);
const hasEligibleAlternativeDepartment = computed(() => eligibleAlternativeDepartments.value.length > 0);
const selfServeUnavailableMessage = computed(() => {
if (!nearestDepartment.value) {
return "Vi kan ikke finde din nærmeste afdeling uden en placering. Vælg en selvvask-klar afdeling nedenfor.";
}
if (!isDepartmentSelfServeEnabled(nearestDepartment.value)) {
return `${nearestDepartment.value.name} er ikke aktiveret til selvvask lige nu.`;
}
if (!(nearestDepartment.value.lanes || []).some(isLaneReadyForSelfServe)) {
return `${nearestDepartment.value.name} har ingen ledige selvvask-baner lige nu.`;
}
return null;
});
const getDepartmentDistance = (department) => {
if (!locations.location.value?.coords) {
@@ -46,27 +95,65 @@ onMounted(async () => {
<div class="column is-12">
<WhiteBoxCard :force-state="true" :defaultOpen="true" :toggleable="false" :loading="false">
<template #header>
<div class="card-header-title has-text-link has-text-weight-bold" data-testid="self-serve-home-nearest-name">
{{ nearestDepartment ? nearestDepartment.name : $t("common.loading") }}
<div
class="card-header-title has-text-link has-text-weight-bold"
data-testid="self-serve-home-nearest-name"
>
{{ nearestDepartment ? nearestDepartment.name : $t("global.search_departments") }}
</div>
<div class="card-header-icon has-text-link">
<i class="fas fa-map-marker-alt"></i>
</div>
</template>
<template #content>
{{ $t("user_dashboard.wash.nearest_department", { name: nearestDepartment ? nearestDepartment.name : "-", distance: nearestDepartment ? nearestDepartment.distance.toFixed(2) : "-" }) }}
<b-loading :is-full-page="false" :model-value="!nearestDepartment" :can-cancel="true" />
<template v-if="nearestDepartment && isDepartmentSelectionFallbackBased">
Vælg en afdeling for at fortsætte. Vi har valgt den første tilgængelige selvvaskeafdeling, fordi din
placering ikke er tilgængelig.
</template>
<template v-else-if="nearestDepartment">
{{
$t("user_dashboard.wash.nearest_department", {
name: nearestDepartment.name,
distance: nearestDepartment.distance !== null ? nearestDepartment.distance.toFixed(2) : "-",
})
}}
</template>
<template v-else-if="shouldShowChooseDepartmentMessage">
Vælg en afdeling for at starte vask, fordi din placering ikke er tilgængelig.
</template>
<template v-else>
{{ $t("common.loading") }}
<b-loading :is-full-page="false" :model-value="true" :can-cancel="true" />
</template>
<BMessage
v-if="selfServeUnavailableMessage"
type="is-warning"
size="is-small"
class="mt-3"
data-testid="self-serve-home-unavailable-message"
>
{{ selfServeUnavailableMessage }}
</BMessage>
</template>
<template #footer>
<div class="card-footer-item">
<router-link
v-if="nearestDepartment"
v-if="nearestDepartment && nearestDepartmentCanStartSelfServe"
to="/user/wash/start"
class="button is-link is-fullwidth"
data-testid="self-serve-home-start"
>
{{ $t("user_dashboard.wash.start_wash") }}
</router-link>
<button
v-else
type="button"
class="button is-link is-light is-fullwidth"
data-testid="self-serve-home-start-disabled"
disabled
>
Selvvask er ikke tilgængelig her
</button>
</div>
</template>
</WhiteBoxCard>
@@ -74,7 +161,7 @@ onMounted(async () => {
<template v-for="department in orderedDepartments" :key="department.id">
<div
v-if="nearestDepartment && department.id !== nearestDepartment.id"
v-if="!nearestDepartment || department.id !== nearestDepartment.id"
class="column is-12-mobile is-6-tablet is-4-desktop"
:data-testid="`self-serve-home-department-${department.id}`"
>
@@ -94,7 +181,20 @@ onMounted(async () => {
</template>
<template #footer>
<div class="card-footer-item">
<router-link :to="{ name: 'pos', params: { departmentId: department.id } }" class="button is-light is-fullwidth">
<button
v-if="canDepartmentStartSelfServe(department) && !nearestDepartmentCanStartSelfServe"
type="button"
class="button is-link is-fullwidth"
:data-testid="`self-serve-home-select-department-${department.id}`"
@click="selectDepartment(department.id)"
>
Vælg denne afdeling
</button>
<router-link
v-else
:to="{ name: 'pos', params: { departmentId: department.id } }"
class="button is-light is-fullwidth"
>
<span class="icon"><i class="fas fa-map-marker-alt"></i></span>
<span>{{ department.address }}</span>
</router-link>
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);
return buildMigrationUrl(MIGRATION_ORIGIN, redirectPath.value, window.location.pathname);
});
const clearTimers = () => {
@@ -13,6 +13,7 @@ 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",
@@ -23,12 +24,13 @@ 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([
@@ -89,6 +91,22 @@ 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);
@@ -187,7 +205,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("//"));
.filter((url) => (url.startsWith("/") && !url.startsWith("//")) || url.startsWith("./"));
const manifestIconPaths = (pwaManifest.icons || []).map((icon: { src?: string }) => {
if (!icon.src) {
return "";
@@ -199,8 +217,8 @@ async function distStaticPaths() {
return Array.from(
new Set(
[
...indexStaticPaths,
...GENERATED_HTML_STATIC_PATHS,
...indexStaticPaths.map(releaseRootStaticPath),
...GENERATED_HTML_STATIC_PATHS.map(releaseRootStaticPath),
...manifestIconPaths,
"/manifest.json",
"/manifest.webmanifest",
@@ -214,13 +232,13 @@ async function distStaticPaths() {
workboxFile ? `/${workboxFile}` : "",
releaseEntry.entry ? `/${releaseEntry.entry}` : "",
...(releaseEntry.css || []).map((fileName: string) => `/${fileName}`),
...(releaseManifest.index_asset_urls || []),
...(releaseManifest.pwa_asset_urls || []),
...(releaseManifest.asset_urls || []),
...(releaseManifest.index_asset_urls || []).map(releaseRootStaticPath),
...(releaseManifest.pwa_asset_urls || []).map(releaseRootStaticPath),
...(releaseManifest.asset_urls || []).map(releaseRootStaticPath),
cssFile || "",
].filter(Boolean)
)
).filter((staticPath) => staticPath !== "/index.html");
);
}
test.beforeAll(async () => {
@@ -287,6 +305,7 @@ 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"));
@@ -294,17 +313,42 @@ 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\/favicons\//);
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\/)/);
}
});
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/guest/book/wash"]) {
for (const appPath of [
"/",
"/guest/book/wash",
"/canary/frontend",
"/canary/frontend/index.html",
"/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");
@@ -319,7 +363,12 @@ 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);
expect(response.headers()["content-type"], `${prefix}${staticPath}`).not.toContain("text/html");
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");
}
}
}
});
+7 -5
View File
@@ -14,9 +14,11 @@ 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) {
const normalizedBase = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
function liveUrl(assetPath: string, base = staticBaseURL) {
const normalizedBase = base.endsWith("/") ? base : `${base}/`;
return new URL(assetPath.replace(/^\/+/, ""), normalizedBase).toString();
}
@@ -126,7 +128,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?.[hashKey]);
await expectStaticAsset(request, assetPath, manifest.asset_hashes?.[assetPath] || manifest.asset_hashes?.[hashKey]);
}
});
@@ -142,9 +144,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, baseURL);
const guards = attachPageHealthGuards(page, appBaseURL);
await page.goto("/guest/book/wash");
await page.goto(liveUrl("/guest/book/wash", appBaseURL));
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: requiredEnv("PLAYWRIGHT_BASE_URL"),
baseURL: process.env.PLAYWRIGHT_APP_BASE_URL || requiredEnv("PLAYWRIGHT_BASE_URL"),
customerCredentials: {
customerNumber: requiredEnv("PLAYWRIGHT_USER_CUSTOMER_NUMBER"),
password: requiredEnv("PLAYWRIGHT_USER_PASSWORD"),
+100 -1
View File
@@ -264,6 +264,53 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden();
});
test("start route lets regular users choose a department when geolocation permission is denied", async ({
page,
context,
}) => {
await context.clearPermissions();
await page.addInitScript(() => {
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: {
getCurrentPosition: (_success, error) =>
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 }),
watchPosition: (_success, error) => {
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 });
return 1;
},
clearWatch: () => {},
},
});
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: true,
});
await primeSession(page, {
token: "self-serve-denied-geolocation-token",
permissions: ["user"],
});
await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-start-page")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("self-serve-department-name").click();
await expect(page.getByTestId("self-serve-department-search")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-department-search").locator("input").fill("Odense");
await page.getByText("Odense", { exact: true }).click();
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
});
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
const requests = captureSelfServeGatewayRequests(page);
@@ -1144,7 +1191,9 @@ test.describe("Self-serve wash", () => {
expect(requests.commands).toHaveLength(0);
});
test("tasks flow renders dynamic image, requires completion, and restores after reload", async ({ page }) => {
test("@dynamic-image tasks flow renders dynamic image, requires completion, and restores after reload", async ({
page,
}) => {
const requests = captureSelfServeGatewayRequests(page);
await seedSavedProgress(page, {
@@ -1510,6 +1559,56 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
});
test("mocked polling marks active wash completed when backend reports it ended", async ({ page }) => {
await seedSavedProgress(page, {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 30_000,
currentStep: 4,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioLaneOption: 7,
radioWashType: "Manual",
customerNumberInput: "12345679",
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: {
inProgressByLaneId: {
7: {
lane_id: 7,
in_progress: false,
session: {
id: 801,
lane_id: 7,
reg: "AB12345",
customer_number: 12345679,
vehicle_type_id: 2,
status: "COMPLETED",
},
customer: { customer_number: 12345679 },
vehicle: { reg: "AB12345", type: 2 },
},
},
},
});
await primeSession(page, {
token: "self-serve-polling-completed-token",
permissions: ["user"],
});
await page.goto("/user/wash/start");
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
await expect.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))).toBeNull();
});
test("admin preview modal reuses shared question/task rendering", async ({ page }) => {
await mockApi(page, {
authenticated: true,
+378
View File
@@ -0,0 +1,378 @@
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,6 +124,73 @@ 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();
@@ -64,6 +64,22 @@ vi.mock("@/components/pagination/departmentTabs.vue", async () => {
};
});
vi.mock("@/components/page/wrappers/RestrictedPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/page/wrappers/NotFoundFallBackPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/displays/boxes/ElementTabsBox.vue", () => ({
default: { template: "<div><slot name='form' /><slot name='history' /></div>" },
}));
vi.mock("@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue", () => ({
default: { template: "<div><slot /></div>" },
}));
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue", async () => {
const { ref } = await import("vue");
@@ -104,6 +120,52 @@ vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentD
};
});
vi.mock("@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentWeather.vue", () => ({
default: { template: "<div />" },
}));
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportCount.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportComplaints.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportHistory.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportNavigation.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportProductSales.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock(
"@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportTodayForm.vue",
() => ({
default: { template: "<div />" },
})
);
import DepartmentDailyReport from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue";
const flushAll = async () => {
+183
View File
@@ -0,0 +1,183 @@
// @vitest-environment jsdom
import { flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const mocks = vi.hoisted(() => {
const asMockRef = (value) => ({ value, __v_isRef: true });
return {
guestDepartments: asMockRef([]),
nearestDepartment: asMockRef(null),
fetchDepartments: vi.fn(async () => []),
evaluateLocationDepartments: vi.fn(),
orderDepartmentsByDistance: vi.fn((departments) => departments),
selectDepartment: vi.fn(),
useWashDepartments: vi.fn(),
locationRef: asMockRef({
coords: {
latitude: 55.5,
longitude: 12.4,
},
}),
getDistance: vi.fn(() => 3),
};
});
vi.mock("@/composables/useWashDepartments", () => ({
useWashDepartments: mocks.useWashDepartments,
}));
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
locations: {
location: mocks.locationRef,
getDistance: mocks.getDistance,
},
}));
import MyWash from "@/views/dashboards/userDashboard/wash/MyWash.vue";
const eligibleLane = { id: 10, name: "10", status: "AVAILABLE", selfserve_enabled: true };
const makeDepartment = (overrides = {}) => ({
id: 1,
name: "Glostrup",
address: "Fabriksparken 18",
latitude: 55.5,
longitude: 12.4,
distance: 1,
self_serve_enabled: true,
lanes: [eligibleLane],
...overrides,
});
const mountMyWash = async () => {
const wrapper = mountWithApp(MyWash, {
global: {
stubs: {
PosDepartmentStepMobile1Location: {
emits: ["location-updated"],
template: "<button data-testid='location-stub' @click=\"$emit('location-updated', {})\">location</button>",
},
BLoading: {
props: ["modelValue"],
template: "<div v-if='modelValue' data-testid='loading-stub' />",
},
BMessage: {
template: "<div class='b-message-stub' v-bind='$attrs'><slot /></div>",
},
},
},
});
await flushPromises();
await nextTick();
return wrapper;
};
describe("MyWash entry", () => {
beforeEach(() => {
mocks.guestDepartments.value = [];
mocks.nearestDepartment.value = null;
mocks.locationRef.value = {
coords: {
latitude: 55.5,
longitude: 12.4,
},
};
mocks.fetchDepartments.mockReset();
mocks.fetchDepartments.mockImplementation(async () => mocks.guestDepartments.value);
mocks.evaluateLocationDepartments.mockReset();
mocks.orderDepartmentsByDistance.mockReset();
mocks.orderDepartmentsByDistance.mockImplementation((departments) => departments);
mocks.selectDepartment.mockReset();
mocks.selectDepartment.mockImplementation((departmentId) => {
mocks.nearestDepartment.value =
mocks.guestDepartments.value.find((department) => department.id === departmentId) || null;
});
mocks.useWashDepartments.mockReset();
mocks.useWashDepartments.mockImplementation(() => ({
guestDepartments: mocks.guestDepartments,
nearestDepartment: mocks.nearestDepartment,
fetchDepartments: mocks.fetchDepartments,
evaluateLocationDepartments: mocks.evaluateLocationDepartments,
orderDepartmentsByDistance: mocks.orderDepartmentsByDistance,
selectDepartment: mocks.selectDepartment,
}));
mocks.getDistance.mockReset();
mocks.getDistance.mockReturnValue(3);
});
it("enables the start CTA when the nearest department is self-serve enabled with an available lane", async () => {
const nearest = makeDepartment();
mocks.guestDepartments.value = [nearest];
mocks.nearestDepartment.value = nearest;
const wrapper = await mountMyWash();
expect(mocks.useWashDepartments).toHaveBeenCalledWith({ includeLanes: true });
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-home-start"]').attributes("href")).toBe("/user/wash/start");
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').exists()).toBe(false);
});
it("replaces the start CTA when the nearest department is disabled and lets users select another eligible department", async () => {
const nearest = makeDepartment({ self_serve_enabled: false });
const alternative = makeDepartment({ id: 2, name: "Roskilde", distance: 5 });
mocks.guestDepartments.value = [nearest, alternative];
mocks.nearestDepartment.value = nearest;
const wrapper = await mountMyWash();
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
"Glostrup er ikke aktiveret til selvvask lige nu."
);
await wrapper.find('[data-testid="self-serve-home-select-department-2"]').trigger("click");
await nextTick();
expect(mocks.selectDepartment).toHaveBeenCalledWith(2);
});
it("replaces the start CTA when the nearest department has no enabled and available lanes", async () => {
const nearest = makeDepartment({
lanes: [
{ id: 10, name: "10", status: "MAINTENANCE", selfserve_enabled: true },
{ id: 11, name: "11", status: "AVAILABLE", selfserve_enabled: false },
],
});
const alternative = makeDepartment({ id: 3, name: "Køge", distance: 7 });
mocks.guestDepartments.value = [nearest, alternative];
mocks.nearestDepartment.value = nearest;
const wrapper = await mountMyWash();
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
"Glostrup har ingen ledige selvvask-baner lige nu."
);
expect(wrapper.find('[data-testid="self-serve-home-select-department-3"]').exists()).toBe(true);
});
it("handles missing geolocation by disabling start and offering eligible departments", async () => {
const eligible = makeDepartment({ id: 4, name: "Odense" });
mocks.locationRef.value = null;
mocks.guestDepartments.value = [eligible];
mocks.nearestDepartment.value = null;
const wrapper = await mountMyWash();
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
"Vi kan ikke finde din nærmeste afdeling uden en placering."
);
expect(wrapper.find('[data-testid="self-serve-home-select-department-4"]').exists()).toBe(true);
});
});
+134
View File
@@ -0,0 +1,134 @@
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");
});
});
+604 -42
View File
@@ -24,6 +24,10 @@ 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,
@@ -36,6 +40,10 @@ const mocks = vi.hoisted(() => {
conditions,
rules,
allowedServices,
selfServeAllowed,
selfServeLane,
selfServeSession,
resolvedVehicleTypeId,
fetchDepartments: vi.fn(async () => guestDepartments.value),
startAutoRefresh: vi.fn(),
stopAutoRefresh: vi.fn(),
@@ -85,6 +93,7 @@ vi.mock("@/components/session/token/SessionUser.vue", async () => {
customer_number: mocks.sessionCustomerNumber,
},
canAccessSuperUser: () => true,
canAccessDeveloper: () => false,
request: vi.fn(async (...args) => {
const response = await mocks.sessionRequest(...args);
return response ?? { status: 200, data: { data: {} } };
@@ -144,6 +153,8 @@ vi.mock("@/composables/useWashDepartments", () => ({
isForcingNearestDepartment: { value: false },
forceNearestDepartmentEvaluationId: { value: 0 },
isSearchingDepartments: { value: false },
departmentFetchError: { value: null },
isDepartmentSelectionFallbackBased: { value: false },
availableProductIds: { value: [2] },
doesCurrentDepartmentSelectionHaveSelfServeEnabled: mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments: mocks.fetchDepartments,
@@ -156,35 +167,43 @@ vi.mock("@/composables/useWashDepartments", () => ({
}),
}));
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/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/useWashProgress", () => ({
useWashProgress: (options = {}) => ({
@@ -250,6 +269,7 @@ vi.mock("@/composables/useWashSessionActions", async () => {
};
});
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import MyWashStart from "@/views/dashboards/userDashboard/wash/MyWashStart.vue";
import { mountWithApp } from "./helpers/mountWithApp.js";
@@ -303,9 +323,11 @@ 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>
@@ -363,6 +385,10 @@ 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;
@@ -374,7 +400,8 @@ describe("MyWashStart", () => {
mocks.markDestroying.mockClear();
mocks.fetchCustomerVehicles.mockClear();
mocks.fetchVehicleTypeOptions.mockClear();
mocks.fetchSelfServeDataInternal.mockClear();
mocks.fetchSelfServeDataInternal.mockReset();
mocks.fetchSelfServeDataInternal.mockResolvedValue(undefined);
mocks.fetchWashSummary.mockClear();
mocks.saveProgress.mockClear();
mocks.clearProgress.mockClear();
@@ -387,6 +414,10 @@ 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);
@@ -419,6 +450,8 @@ describe("MyWashStart", () => {
});
it("wires child updates back into the self-serve runtime", async () => {
vi.useFakeTimers();
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
@@ -430,6 +463,7 @@ describe("MyWashStart", () => {
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
@@ -449,6 +483,107 @@ 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"] },
@@ -537,6 +672,51 @@ describe("MyWashStart", () => {
await flushPromises();
});
it("surfaces failed question answer syncs and blocks question confirmation until retry succeeds", async () => {
mocks.syncVehicleAnswer.mockRejectedValueOnce(new Error("Question sync failed")).mockResolvedValueOnce(undefined);
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await wrapper.get('[data-testid="emit-lane"]').trigger("click");
await wrapper.get('[data-testid="emit-registration"]').trigger("click");
await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click");
await nextTick();
await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click");
await flushPromises();
await wrapper.get('[data-testid="emit-answer"]').trigger("click");
await flushPromises();
expect(wrapper.get('[data-testid="self-serve-question-sync-error"]').text()).toContain("Question sync failed");
const confirmButton = wrapper.get('[data-testid="self-serve-nav-confirm"]');
expect(confirmButton.attributes("disabled")).toBeDefined();
await confirmButton.trigger("click");
expect(mocks.handleConfirmNext).not.toHaveBeenCalled();
await wrapper.get('[data-testid="self-serve-question-sync-retry"]').trigger("click");
await flushPromises();
expect(mocks.syncVehicleAnswer).toHaveBeenCalledTimes(2);
expect(mocks.syncVehicleAnswer).toHaveBeenNthCalledWith(2, {
departmentId: 6,
laneId: 7,
customerNumber: 12345679,
reg: "AB12345",
questionId: 11,
value: true,
vehicleTypeId: 2,
});
expect(wrapper.find('[data-testid="self-serve-question-sync-error"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="self-serve-nav-confirm"]').attributes("disabled")).toBeUndefined();
});
it("shows disabled warning without showing loading state when department self-serve is unavailable", async () => {
mocks.nearestDepartment.value = {
...mocks.nearestDepartment.value,
@@ -557,6 +737,221 @@ 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: {
@@ -597,6 +992,8 @@ describe("MyWashStart", () => {
});
it("falls back to available lane when restored lane is stale for the selected department", async () => {
vi.useFakeTimers();
mocks.restoredProgressPayload = {
washInProgress: false,
washLaneId: null,
@@ -619,6 +1016,8 @@ describe("MyWashStart", () => {
},
});
await flushPromises();
await vi.advanceTimersByTimeAsync(300);
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
@@ -823,6 +1222,40 @@ describe("MyWashStart", () => {
wrapper.unmount();
});
it("does not call SessionUser.request for delayed server restore after unmount", async () => {
vi.useFakeTimers();
mocks.restoredProgressPayload = {
washInProgress: false,
washLaneId: null,
washStartTime: null,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
completedTasks: {},
currentStep: 0,
};
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
expect(SessionUser.request).not.toHaveBeenCalled();
wrapper.unmount();
await vi.advanceTimersByTimeAsync(1600);
await flushPromises();
expect(SessionUser.request).not.toHaveBeenCalled();
});
it("retries server active wash restore when the authenticated customer number arrives after mount", async () => {
mocks.sessionCustomerNumber.value = null;
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
@@ -1015,6 +1448,11 @@ 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();
@@ -1095,6 +1533,7 @@ describe("MyWashStart", () => {
});
it("keeps the guided wash in progress when stop fails", async () => {
vi.useFakeTimers();
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
@@ -1128,7 +1567,7 @@ describe("MyWashStart", () => {
}
await guidedActions.get('[data-testid="self-serve-nav-complete"]').trigger("click");
await new Promise((resolve) => setTimeout(resolve, 260));
await vi.advanceTimersByTimeAsync(4000);
await flushPromises();
expect(mocks.onStopWash).toHaveBeenCalledWith(7);
@@ -1136,6 +1575,71 @@ 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,
@@ -1214,6 +1718,66 @@ 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",
@@ -1273,12 +1837,10 @@ describe("MyWashStart", () => {
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"
);
expect(wrapper.find('[data-testid="completed-step-stub"]').exists()).toBe(true);
});
it("clears restored in-progress state when active wash refresh cannot verify authenticated ownership", async () => {
it("keeps restored in-progress state when active wash refresh cannot verify authenticated ownership before completion", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
@@ -1334,16 +1896,16 @@ describe("MyWashStart", () => {
});
expect(mocks.fetchWashSummary).not.toHaveBeenCalledWith({ session_id: 909, vehicle_type: 2 }, false);
expect(mocks.saveProgress).not.toHaveBeenCalledWith("serverActiveWashRefresh");
expect(mocks.stopElapsedTimer).toHaveBeenCalled();
expect(mocks.clearProgress).toHaveBeenCalled();
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
expect(mocks.stopElapsedTimer).not.toHaveBeenCalled();
expect(mocks.clearProgress).not.toHaveBeenCalled();
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").not.toContain(
"display: none"
);
wrapper.unmount();
});
it("clears restored in-progress state when active wash refresh says the lane is no longer in progress", async () => {
it("keeps restored in-progress state when active wash refresh says the lane is no longer in progress before completion", async () => {
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
@@ -1390,10 +1952,10 @@ describe("MyWashStart", () => {
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.stopElapsedTimer).not.toHaveBeenCalled();
expect(mocks.clearProgress).not.toHaveBeenCalled();
expect(mocks.fetchWashSummary).not.toHaveBeenCalled();
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").toContain(
expect(wrapper.get('[data-testid="self-serve-bottom-actions"]').attributes("style") || "").not.toContain(
"display: none"
);
+17
View File
@@ -2,11 +2,14 @@ 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"];
@@ -34,6 +37,10 @@ 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,
@@ -78,6 +85,16 @@ 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
@@ -0,0 +1,18 @@
// @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/");
});
});
+13 -5
View File
@@ -52,19 +52,17 @@ 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],
[5, true],
]);
expect(wrapper.emitted("toggle-task")).toEqual([[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("prefixes only program picker task titles with the configured wheel selection", () => {
it("adds the configured wheel selection only when the program title does not already include it", () => {
const wrapper = mountWithApp(SelfServeTaskList, {
props: {
tasks: [
@@ -76,6 +74,14 @@ 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",
@@ -113,6 +119,8 @@ 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");
+23 -17
View File
@@ -25,32 +25,37 @@ const mountTasksStep = (props = {}) =>
});
describe("SelfServeTasksStep", () => {
it("shows a Buefy skeleton until the dynamic image loads", async () => {
it("preloads the dynamic image without reserving frame space until it loads", async () => {
const wrapper = mountTasksStep();
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");
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"
);
await wrapper.get('[data-testid="self-serve-dynamic-image"]').trigger("load");
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("load");
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");
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"
);
});
it("shows the skeleton again when the dynamic image URL changes", async () => {
it("preloads again without showing a frame when the dynamic image URL changes", async () => {
const wrapper = mountTasksStep();
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.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.setProps({
dynamicImageUrl: "https://cdn.example.test/dynamic-step-2.png",
});
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(
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-step-2.png"
);
});
@@ -58,18 +63,19 @@ 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"]').trigger("error");
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("error");
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
expect(wrapper.emitted("clear-dynamic-image")).toEqual([[]]);
});
it("does not render a skeleton when there is no dynamic image URL", () => {
it("does not render dynamic image elements when there is no dynamic image URL", () => {
const wrapper = mountTasksStep({
dynamicImageUrl: null,
});
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
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"]').exists()).toBe(false);
});
});
@@ -12,12 +12,14 @@ 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>
@@ -144,4 +146,47 @@ 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);
});
});
@@ -0,0 +1,174 @@
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,6 +91,225 @@ 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,
@@ -510,6 +729,7 @@ 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,4 +165,138 @@ describe("useWashDepartments", () => {
lanes: [{ id: 7 }],
});
});
it("uses the first self-serve department with an enabled lane as fallback when coordinates are missing", async () => {
mocks.locationRef.value = null;
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Disabled module",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: false,
},
{
id: 2,
name: "Disabled lane",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: false }],
self_serve_enabled: true,
},
{
id: 3,
name: "Fallback",
address: "C",
latitude: 55.8,
longitude: 12.7,
lanes: [
{ id: 12, selfserve_enabled: "off" },
{ id: 13, selfserve_enabled: true, products: [4] },
],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(mocks.getDistance).not.toHaveBeenCalled();
expect(departments.nearestDepartment.value).toMatchObject({
id: 3,
name: "Fallback",
distance: null,
lanes: [{ id: 13 }],
});
expect(departments.departmentSelectionStrategy.value).toBe("fallback");
expect(departments.isDepartmentSelectionFallbackBased.value).toBe(true);
expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false);
});
it("clears the selected department for empty department responses", async () => {
mocks.getDepartmentsGuest.mockResolvedValueOnce([
{ id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true },
]);
const departments = mountDepartments();
await departments.fetchDepartments();
expect(departments.nearestDepartment.value).toMatchObject({ id: 1 });
mocks.getDepartmentsGuest.mockResolvedValueOnce([]);
await departments.fetchDepartments();
expect(departments.guestDepartments.value).toEqual([]);
expect(departments.nearestDepartment.value).toBeNull();
expect(departments.departmentSelectionStrategy.value).toBeNull();
});
it("does not fallback to disabled departments when coordinates are missing", async () => {
mocks.locationRef.value = { value: null };
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Module disabled",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10, selfserve_enabled: true }],
self_serve_enabled: false,
},
{
id: 2,
name: "Lane disabled",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11, selfserve_enabled: "no" }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
expect(departments.nearestDepartment.value).toBeNull();
expect(departments.departmentSelectionStrategy.value).toBeNull();
});
it("allows regular users to force a department when coordinates are missing", async () => {
mocks.locationRef.value = {};
mocks.getDepartmentsGuest.mockResolvedValue([
{
id: 1,
name: "Fallback",
address: "A",
latitude: 55.6,
longitude: 12.5,
lanes: [{ id: 10 }],
self_serve_enabled: true,
},
{
id: 2,
name: "Manual choice",
address: "B",
latitude: 55.7,
longitude: 12.6,
lanes: [{ id: 11 }],
self_serve_enabled: true,
},
]);
const departments = mountDepartments({ canAccessSuperUser: () => false });
await departments.fetchDepartments();
departments.startDepartmentSearch();
departments.selectDepartment(2);
expect(departments.isSearchingDepartments.value).toBe(false);
expect(departments.isForcingNearestDepartment.value).toBe(true);
expect(departments.nearestDepartment.value).toMatchObject({ id: 2, name: "Manual choice" });
expect(departments.departmentSelectionStrategy.value).toBe("forced");
});
});
@@ -0,0 +1,105 @@
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);
});
});
+49 -1
View File
@@ -35,7 +35,7 @@ describe("useWashFlowState", () => {
};
};
it("validates the vehicle step against customer, registration, and allowed product ids", () => {
it("validates the vehicle step against customer, registration, and selected vehicle type", () => {
const { flow, state } = createState();
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
@@ -48,9 +48,40 @@ 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),
@@ -97,4 +128,21 @@ 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);
});
});
@@ -0,0 +1,150 @@
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();
});
});
@@ -446,6 +446,51 @@ 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);
+96 -11
View File
@@ -35,6 +35,60 @@ 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()
@@ -98,7 +152,7 @@ function releaseUrlPath(value, basePath = '/') {
return ''
}
return url.pathname
return releaseManifestPath(url.pathname)
} catch {
return ''
}
@@ -106,7 +160,9 @@ function releaseUrlPath(value, basePath = '/') {
function releaseManifestDistPath(outputDirectory, urlPath) {
try {
const normalizedPath = decodeURIComponent(new URL(urlPath, 'https://release.local').pathname).replace(/^\/+/, '')
const normalizedPath = decodeURIComponent(
releaseManifestPath(new URL(urlPath, 'https://release.local').pathname)
).replace(/^\/+/, '')
const resolvedPath = path.resolve(outputDirectory, normalizedPath)
const resolvedOutputDirectory = path.resolve(outputDirectory)
if (
@@ -130,6 +186,32 @@ 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',
@@ -150,7 +232,7 @@ function patchBuefyCssMediaQuery() {
nextCode = nextCode.replaceAll(invalidSnippet, fixedSnippet)
didPatch = true
}
if (!didPatch) {
return null
}
@@ -275,12 +357,14 @@ 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 ? `/${releaseEntry.entry}` : '',
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map((fileName) => `/${fileName}`) : [])
releaseEntry.entry ? releaseManifestAssetPath(releaseEntry.entry) : '',
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map(releaseManifestAssetPath) : [])
].filter(Boolean)
const pwaAssetUrls = [
'manifest.json',
@@ -294,7 +378,7 @@ function releaseMetadataManifest() {
...rootFiles.filter((fileName) => /^workbox-[^/]+\.js$/.test(fileName))
]
.filter((fileName) => fs.existsSync(path.join(outputDirectory, fileName)))
.map((fileName) => `/${fileName.replace(/\\/g, '/')}`)
.map((fileName) => releaseManifestAssetPath(fileName.replace(/\\/g, '/')))
for (const manifestPath of ['manifest.webmanifest', 'assets/manifest.webmanifest']) {
const absoluteManifestPath = path.join(outputDirectory, manifestPath)
@@ -307,7 +391,7 @@ function releaseMetadataManifest() {
for (const icon of pwaManifest.icons || []) {
const iconPath = releaseUrlPath(icon.src || '', `/${manifestPath}`)
if (iconPath) {
pwaAssetUrls.push(iconPath)
pwaAssetUrls.push(releaseManifestAssetPath(iconPath))
}
}
} catch {
@@ -316,8 +400,9 @@ function releaseMetadataManifest() {
}
const criticalAssetUrls = Array.from(new Set([
'/index.html',
'/release-entry.json',
'index.html',
'release-entry.json',
...emittedAssetUrls,
...indexAssetUrls,
...releaseEntryAssetUrls,
...pwaAssetUrls
@@ -439,8 +524,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 a relative base for static hosting; align manifest with base
const base = '/'
// Keep production assets host-agnostic. Release channel prefixes are resolved at runtime.
const base = isProd ? releaseFrontendBasePath() : '/'
const pwaScope = base === './' ? '.' : base
// Enable single-file build only when explicitly requested (disabled by default for PWA)
+9
View File
@@ -10,6 +10,15 @@ export default defineConfig({
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
quietDeps: true,
silenceDeprecations: ["import", "global-builtin", "legacy-js-api", "if-function"],
},
},
},
test: {
environment: "node",
include: ["tests/unit/**/*.spec.js"],