From 3960daa556c9ed0271a439fb1d5abebfa78be916 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 13:42:22 +0200 Subject: [PATCH] Add production wash start test matrix --- docs/user-wash-start-production-readiness.md | 33 ++++ tests/e2e/self-serve-wash.spec.js | 54 ++++++- tests/unit/my-wash-start-production.spec.js | 55 +++++++ .../use-self-serve-logic-production.spec.js | 149 ++++++++++++++++++ .../use-wash-flow-state-production.spec.js | 94 +++++++++++ ...se-wash-session-actions-production.spec.js | 129 +++++++++++++++ 6 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 docs/user-wash-start-production-readiness.md create mode 100644 tests/unit/my-wash-start-production.spec.js create mode 100644 tests/unit/use-self-serve-logic-production.spec.js create mode 100644 tests/unit/use-wash-flow-state-production.spec.js create mode 100644 tests/unit/use-wash-session-actions-production.spec.js diff --git a/docs/user-wash-start-production-readiness.md b/docs/user-wash-start-production-readiness.md new file mode 100644 index 00000000..e972a12e --- /dev/null +++ b/docs/user-wash-start-production-readiness.md @@ -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. | diff --git a/tests/e2e/self-serve-wash.spec.js b/tests/e2e/self-serve-wash.spec.js index 26ea0b78..8a47a241 100644 --- a/tests/e2e/self-serve-wash.spec.js +++ b/tests/e2e/self-serve-wash.spec.js @@ -1144,7 +1144,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 +1512,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, diff --git a/tests/unit/my-wash-start-production.spec.js b/tests/unit/my-wash-start-production.spec.js new file mode 100644 index 00000000..33f3c056 --- /dev/null +++ b/tests/unit/my-wash-start-production.spec.js @@ -0,0 +1,55 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = fs.readFileSync("src/views/dashboards/userDashboard/wash/MyWashStart.vue", "utf8"); + +describe("MyWashStart.vue production recovery contracts", () => { + it("restores local progress before scheduling a server active-wash restore", () => { + expect(source).toContain("const restoredProgress = restoreProgress();"); + expect(source).toContain("shouldRestoreServerActiveWash.value = !restoredProgress?.washInProgress;"); + expect(source).toContain("scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0);"); + expect(source.indexOf("const restoredProgress = restoreProgress();")).toBeLessThan( + source.indexOf("scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0);") + ); + }); + + it("restores authenticated server active washes into in-progress state and summary data", () => { + expect(source).toContain('const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";'); + expect(source).toContain("if (!isAuthenticatedCustomerActiveWash(details, customerNumber))"); + expect(source).toContain("await applyServerActiveWash(activeWash);"); + expect(source).toContain("washInProgress.value = true;"); + expect(source).toContain("currentStep.value = steps.WASH_IN_PROGRESS;"); + expect(source).toContain("await fetchWashSummary(summaryParams, false);"); + expect(source).toContain('saveProgress("serverActiveWash");'); + }); + + it("suppresses recent completions so polling and restore do not resurrect a just-finished wash", () => { + expect(source).toContain('const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";'); + expect(source).toContain("const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;"); + expect(source).toContain( + "markRecentlyCompletedWash(completingLaneId, licensePlateInput.value, getNumericCustomerNumber());" + ); + expect(source).toContain("if (isRecentlyCompletedActiveWash(activeWash))"); + expect(source).toContain( + "if (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId }))" + ); + }); + + it("cleans up unload handlers, department refresh, active-wash polling, and footer state on unmount", () => { + expect(source).toContain("unregisterBeforeUnload.value();"); + expect(source).toContain("stopAutoRefresh();"); + expect(source).toContain("stopActiveWashRefresh();"); + expect(source).toContain("markDestroying();"); + expect(source).toContain("setShowFooterInContent(true);"); + }); + + it("prevents duplicate fetches and overlapping active-wash sync requests", () => { + expect(source).toContain("if (isVehicleStepNextLoading.value)"); + expect(source).toContain("isVehicleStepNextLoading.value = true;"); + expect(source).toContain("if (isSyncingActiveWash.value || !washInProgress.value || !washLaneId.value)"); + expect(source).toContain("isSyncingActiveWash.value = true;"); + expect(source).toContain("isSyncingActiveWash.value = false;"); + expect(source).toContain("stopActiveWashRefresh();"); + expect(source).toContain("activeWashRefreshInterval.value = window.setInterval"); + }); +}); diff --git a/tests/unit/use-self-serve-logic-production.spec.js b/tests/unit/use-self-serve-logic-production.spec.js new file mode 100644 index 00000000..86389706 --- /dev/null +++ b/tests/unit/use-self-serve-logic-production.spec.js @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sessionUserMock = vi.hoisted(() => ({ + request: vi.fn(), + objects: { + self_serve_vehicle_conditions: { + get: { + previewAllowed: vi.fn(), + washSummary: vi.fn(), + all: vi.fn(), + }, + add: vi.fn(), + delete: vi.fn(), + }, + self_serve_tasks: { + attachments: { + list: vi.fn(), + download: vi.fn(), + }, + }, + }, +})); + +vi.mock("@/components/session/token/SessionUser.vue", () => ({ + SessionUser: sessionUserMock, +})); + +import { useSelfServeLogic } from "@/composables/useSelfServeLogic.js"; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const previewPayload = (overrides = {}) => ({ + lane: { id: 7, name: "7" }, + session: { id: 501, vehicle_type_id: 2 }, + allowed_services: ["manual"], + questions: [{ id: 11, question: "Clean?", answer: null, order_priority: 1 }], + conditions: [], + rules: [], + tasks: [{ id: 91, task: "Manual prep", order_priority: 1, services: ["manual"] }], + ...overrides, +}); + +const summaryPayload = (overrides = {}) => ({ + session: { id: 501, vehicle_type_id: 2, allowed: true }, + lane: { id: 7, name: "7" }, + allowed_services: ["machine", "manual"], + questions: [{ id: 21, question: "Roof?", answer: true, order_priority: 2 }], + conditions: [], + rules: [], + tasks: [{ id: 92, task: "Machine start", order_priority: 2, services: ["machine"] }], + events: [], + ...overrides, +}); + +describe("useSelfServeLogic production behavior", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionUserMock.objects.self_serve_tasks.attachments.list.mockResolvedValue([]); + sessionUserMock.objects.self_serve_tasks.attachments.download.mockResolvedValue({ data: { download_link: "" } }); + }); + + it("merges preview and summary questions while preserving preview answers until summary replaces them", async () => { + sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue(previewPayload()); + sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(summaryPayload()); + const logic = useSelfServeLogic(); + + await logic.fetchSelfServeData(6, 2, 7, "ab12345"); + + expect(logic.questions.value.map((question) => question.id)).toEqual([11, 21]); + expect(logic.answers.value).toEqual({ 11: null, 21: true }); + expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([21]); + expect(logic.tasks.value.map((task) => task.id)).toEqual([92]); + expect(logic.allowedServices.value).toEqual(["MACHINE", "MANUAL"]); + }); + + it("ignores stale preview and summary responses when a newer request wins the race", async () => { + const slowPreview = deferred(); + sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed + .mockReturnValueOnce(slowPreview.promise) + .mockResolvedValueOnce( + previewPayload({ session: { id: 502 }, questions: [{ id: 31, question: "Newest?", answer: false }] }) + ); + sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue( + summaryPayload({ session: { id: 502 }, questions: [{ id: 32, question: "Newest summary?", answer: true }] }) + ); + const logic = useSelfServeLogic(); + + const stale = logic.fetchSelfServeData(6, 2, 7, "old123"); + const fresh = await logic.fetchSelfServeData(6, 2, 7, "new123"); + slowPreview.resolve(previewPayload({ session: { id: 501 }, questions: [{ id: 11, question: "Old?" }] })); + await stale; + + expect(fresh.session.id).toBe(502); + expect(logic.questions.value.map((question) => question.id)).toEqual([31, 32]); + expect(logic.preview.value.session.id).toBe(502); + expect(logic.loading.value).toBe(false); + }); + + it("updates allowed services from the lane endpoint and falls back to active task services on first failure", async () => { + const logic = useSelfServeLogic(); + sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue( + previewPayload({ allowed_services: [], tasks: [{ id: 91, task: "Machine", services: ["MACHINE", "manual"] }] }) + ); + sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue( + summaryPayload({ questions: [] }) + ); + await logic.fetchSelfServeData(6, 2, 7, "ab12345"); + + sessionUserMock.request.mockResolvedValueOnce({ + data: { success: true, data: { allowed_services: ["relay", "machine"] } }, + }); + await logic.updateLaneAllowedServices(7); + expect(logic.allowedServices.value).toEqual(["RELAY", "MACHINE"]); + + sessionUserMock.request.mockRejectedValueOnce(new Error("edge offline")); + await expect(logic.updateLaneAllowedServices(7)).rejects.toThrow("edge offline"); + expect(logic.allowedServices.value).toEqual(["RELAY", "MACHINE"]); + expect(logic.error.value).toBe("edge offline"); + }); + + it("keeps the local answer optimistic only through successful answer sync and reports failures", async () => { + sessionUserMock.objects.self_serve_vehicle_conditions.add.mockRejectedValueOnce(new Error("answer sync failed")); + const logic = useSelfServeLogic(); + logic.answerQuestion(11, true); + + await expect( + logic.syncVehicleAnswer({ + departmentId: 6, + laneId: 7, + reg: "AB12345", + questionId: 11, + value: false, + vehicleTypeId: 2, + }) + ).rejects.toThrow("answer sync failed"); + + expect(logic.answers.value[11]).toBe(true); + expect(logic.error.value).toBe("answer sync failed"); + expect(logic.loading.value).toBe(false); + }); +}); diff --git a/tests/unit/use-wash-flow-state-production.spec.js b/tests/unit/use-wash-flow-state-production.spec.js new file mode 100644 index 00000000..62b36666 --- /dev/null +++ b/tests/unit/use-wash-flow-state-production.spec.js @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import { ref } from "vue"; +import { useWashFlowState, WASH_STEPS } from "@/composables/useWashFlowState.js"; + +function createFlow(overrides = {}) { + const state = { + currentStep: ref(WASH_STEPS.VEHICLE), + washInProgress: ref(false), + customerNumberInput: ref("12345679"), + licensePlateInput: ref("AB12345"), + vehicleTypeSelect: ref(2), + availableProductIds: ref([2]), + radioLaneOption: ref(7), + radioWashType: ref("Manual"), + nearestDepartment: ref({ lanes: [{ id: 7, status: "AVAILABLE", machine_available: true }] }), + allVisibleQuestionsAnswered: ref(true), + isLoadingSelfServeData: ref(false), + activeTasks: ref([]), + completedTasks: ref({}), + editAnswers: ref(true), + isLaneAvailable: vi.fn((lane) => lane.status === "AVAILABLE"), + isMachineAvailable: vi.fn(() => true), + onStartWash: vi.fn(async () => true), + updateLaneAllowedServices: vi.fn(async () => true), + ...overrides, + }; + + return { state, flow: useWashFlowState(state) }; +} + +describe("useWashFlowState production transitions", () => { + it("gates vehicle, question, lane, task, in-progress, and completed transitions", async () => { + const { state, flow } = createFlow({ activeTasks: ref([{ id: 91 }]) }); + + state.vehicleTypeSelect.value = null; + expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false); + expect(flow.isNextButtonDisabled()).toBe(true); + + state.vehicleTypeSelect.value = 2; + expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true); + expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true); + + state.currentStep.value = WASH_STEPS.QUESTIONS; + state.allVisibleQuestionsAnswered.value = false; + expect(flow.isNextButtonDisabled()).toBe(true); + state.allVisibleQuestionsAnswered.value = true; + await flow.handleConfirmNext(); + expect(state.editAnswers.value).toBe(false); + expect(state.currentStep.value).toBe(WASH_STEPS.SELECT_LANE); + expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7); + + expect(flow.clickableSteps[WASH_STEPS.SELECT_LANE]()).toBe(true); + state.currentStep.value = WASH_STEPS.SELECT_LANE; + await flow.handleConfirmNext(); + expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345679", WASH_STEPS.TASKS); + + state.currentStep.value = WASH_STEPS.TASKS; + expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true); + await flow.handleConfirmNext(); + expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS); + + state.washInProgress.value = true; + expect(flow.clickableSteps[WASH_STEPS.WASH_IN_PROGRESS]()).toBe(true); + expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true); + expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true); + expect(flow.clickableSteps[WASH_STEPS.COMPLETED]).toBeUndefined(); + }); + + it("blocks lane confirmation for unavailable lanes and unavailable machine service", () => { + const { state, flow } = createFlow(); + state.currentStep.value = WASH_STEPS.SELECT_LANE; + + state.nearestDepartment.value.lanes[0].status = "OCCUPIED"; + expect(flow.isNextButtonDisabled()).toBe(true); + + state.nearestDepartment.value.lanes[0].status = "AVAILABLE"; + state.radioWashType.value = "Machine"; + state.isMachineAvailable.mockReturnValue(false); + expect(flow.isNextButtonDisabled()).toBe(true); + }); + + it("stays on questions when lane allowed-service refresh fails", async () => { + const { state, flow } = createFlow({ + updateLaneAllowedServices: vi.fn(async () => { + throw new Error("edge down"); + }), + }); + state.currentStep.value = WASH_STEPS.QUESTIONS; + + await flow.handleConfirmNext(); + + expect(state.currentStep.value).toBe(WASH_STEPS.QUESTIONS); + }); +}); diff --git a/tests/unit/use-wash-session-actions-production.spec.js b/tests/unit/use-wash-session-actions-production.spec.js new file mode 100644 index 00000000..427f847c --- /dev/null +++ b/tests/unit/use-wash-session-actions-production.spec.js @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; +import { ref } from "vue"; +import { useWashSessionActions } from "@/composables/useWashSessionActions.js"; +import { WASH_STEPS } from "@/composables/useWashFlowState.js"; + +function createActions(overrides = {}) { + const calls = []; + const request = vi.fn(async (_url, _method, body) => { + calls.push(body.command); + return { data: { success: true } }; + }); + const state = { + request, + alertFn: vi.fn(), + nearestDepartment: ref({ id: 6, lanes: [{ id: 7 }] }), + vehicleTypeSelect: ref(2), + washLaneId: ref(null), + washInProgress: ref(false), + washStartTime: ref(null), + completedDurationMs: ref(null), + now: ref(10_000), + currentStep: ref(WASH_STEPS.SELECT_LANE), + steps: WASH_STEPS, + radioWashType: ref("Manual"), + activeTasks: ref([]), + completedTasks: ref({}), + saveProgress: vi.fn(), + clearProgress: vi.fn(), + startElapsedTimer: vi.fn(), + stopElapsedTimer: vi.fn(), + updateLaneAllowedServices: vi.fn(async () => true), + fetchWashSummary: vi.fn(async () => ({})), + enableMachineRelay: vi.fn(async () => ({ data: { success: true } })), + isServiceAllowed: vi.fn(() => true), + ...overrides, + }; + return { state, actions: useWashSessionActions(state), calls }; +} + +describe("useWashSessionActions production commands", () => { + it("starts a manual wash successfully", async () => { + const { state, actions } = createActions(); + + await expect(actions.onStartWash(7, "ab12345", "12345679")).resolves.toBe(true); + + expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7); + expect(state.request).toHaveBeenCalledWith( + "/modules/self-serve/lane/command", + "post", + expect.objectContaining({ command: "START", license_plate: "AB12345" }) + ); + expect(state.washInProgress.value).toBe(true); + expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS); + expect(state.saveProgress).toHaveBeenCalledWith("onStartWash"); + }); + + it("reports START failure without mutating active wash state", async () => { + const { state, actions } = createActions({ + request: vi.fn(async () => ({ data: { success: false, message: "START failed" } })), + }); + + await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false); + + expect(state.washInProgress.value).toBe(false); + expect(state.alertFn).toHaveBeenCalledWith("START failed"); + expect(state.saveProgress).not.toHaveBeenCalled(); + }); + + it("enables machine relay after START when machine service is allowed", async () => { + const { state, actions } = createActions({ radioWashType: ref("Machine") }); + + await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(true); + + expect(state.enableMachineRelay).toHaveBeenCalledWith(7); + expect(state.washInProgress.value).toBe(true); + }); + + it("rolls back with STOP when machine relay enable fails", async () => { + const { state, actions, calls } = createActions({ + radioWashType: ref("Machine"), + enableMachineRelay: vi.fn(async () => { + throw new Error("relay down"); + }), + }); + + await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false); + + expect(calls).toEqual(["START", "STOP"]); + expect(state.washInProgress.value).toBe(false); + expect(state.alertFn).toHaveBeenCalledWith("relay down"); + }); + + it("keeps active state and alerts when STOP fails", async () => { + const request = vi.fn(async (_url, _method, body) => + body.command === "STOP" ? { data: { success: false, message: "STOP failed" } } : { data: { success: true } } + ); + const { state, actions } = createActions({ + request, + washInProgress: ref(true), + washLaneId: ref(7), + washStartTime: ref(1_000), + }); + + await expect(actions.onStopWash(7)).resolves.toBe(false); + + expect(state.washInProgress.value).toBe(true); + expect(state.alertFn).toHaveBeenCalledWith("STOP failed"); + expect(state.clearProgress).not.toHaveBeenCalled(); + }); + + it("recovers local state when STOP says the lane is already not occupied", async () => { + const request = vi.fn(async () => ({ data: { success: false, message: "Cannot stop lane 7: not occupied" } })); + const { state, actions } = createActions({ + request, + washInProgress: ref(true), + washLaneId: ref(7), + washStartTime: ref(1_000), + now: ref(6_000), + }); + + await expect(actions.onStopWash(7)).resolves.toBe(true); + + expect(state.washInProgress.value).toBe(false); + expect(state.washLaneId.value).toBeNull(); + expect(state.completedDurationMs.value).toBe(5_000); + expect(state.stopElapsedTimer).toHaveBeenCalled(); + expect(state.clearProgress).toHaveBeenCalled(); + }); +});