Add production wash start test matrix
This commit is contained in:
@@ -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. |
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useWashFlowState, WASH_STEPS } from "@/composables/useWashFlowState.js";
|
||||
|
||||
function createFlow(overrides = {}) {
|
||||
const state = {
|
||||
currentStep: ref(WASH_STEPS.VEHICLE),
|
||||
washInProgress: ref(false),
|
||||
customerNumberInput: ref("12345679"),
|
||||
licensePlateInput: ref("AB12345"),
|
||||
vehicleTypeSelect: ref(2),
|
||||
availableProductIds: ref([2]),
|
||||
radioLaneOption: ref(7),
|
||||
radioWashType: ref("Manual"),
|
||||
nearestDepartment: ref({ lanes: [{ id: 7, status: "AVAILABLE", machine_available: true }] }),
|
||||
allVisibleQuestionsAnswered: ref(true),
|
||||
isLoadingSelfServeData: ref(false),
|
||||
activeTasks: ref([]),
|
||||
completedTasks: ref({}),
|
||||
editAnswers: ref(true),
|
||||
isLaneAvailable: vi.fn((lane) => lane.status === "AVAILABLE"),
|
||||
isMachineAvailable: vi.fn(() => true),
|
||||
onStartWash: vi.fn(async () => true),
|
||||
updateLaneAllowedServices: vi.fn(async () => true),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { state, flow: useWashFlowState(state) };
|
||||
}
|
||||
|
||||
describe("useWashFlowState production transitions", () => {
|
||||
it("gates vehicle, question, lane, task, in-progress, and completed transitions", async () => {
|
||||
const { state, flow } = createFlow({ activeTasks: ref([{ id: 91 }]) });
|
||||
|
||||
state.vehicleTypeSelect.value = null;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
|
||||
state.vehicleTypeSelect.value = 2;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true);
|
||||
|
||||
state.currentStep.value = WASH_STEPS.QUESTIONS;
|
||||
state.allVisibleQuestionsAnswered.value = false;
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
state.allVisibleQuestionsAnswered.value = true;
|
||||
await flow.handleConfirmNext();
|
||||
expect(state.editAnswers.value).toBe(false);
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.SELECT_LANE);
|
||||
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
|
||||
|
||||
expect(flow.clickableSteps[WASH_STEPS.SELECT_LANE]()).toBe(true);
|
||||
state.currentStep.value = WASH_STEPS.SELECT_LANE;
|
||||
await flow.handleConfirmNext();
|
||||
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345679", WASH_STEPS.TASKS);
|
||||
|
||||
state.currentStep.value = WASH_STEPS.TASKS;
|
||||
expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true);
|
||||
await flow.handleConfirmNext();
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
|
||||
|
||||
state.washInProgress.value = true;
|
||||
expect(flow.clickableSteps[WASH_STEPS.WASH_IN_PROGRESS]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.COMPLETED]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks lane confirmation for unavailable lanes and unavailable machine service", () => {
|
||||
const { state, flow } = createFlow();
|
||||
state.currentStep.value = WASH_STEPS.SELECT_LANE;
|
||||
|
||||
state.nearestDepartment.value.lanes[0].status = "OCCUPIED";
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
|
||||
state.nearestDepartment.value.lanes[0].status = "AVAILABLE";
|
||||
state.radioWashType.value = "Machine";
|
||||
state.isMachineAvailable.mockReturnValue(false);
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("stays on questions when lane allowed-service refresh fails", async () => {
|
||||
const { state, flow } = createFlow({
|
||||
updateLaneAllowedServices: vi.fn(async () => {
|
||||
throw new Error("edge down");
|
||||
}),
|
||||
});
|
||||
state.currentStep.value = WASH_STEPS.QUESTIONS;
|
||||
|
||||
await flow.handleConfirmNext();
|
||||
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.QUESTIONS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useWashSessionActions } from "@/composables/useWashSessionActions.js";
|
||||
import { WASH_STEPS } from "@/composables/useWashFlowState.js";
|
||||
|
||||
function createActions(overrides = {}) {
|
||||
const calls = [];
|
||||
const request = vi.fn(async (_url, _method, body) => {
|
||||
calls.push(body.command);
|
||||
return { data: { success: true } };
|
||||
});
|
||||
const state = {
|
||||
request,
|
||||
alertFn: vi.fn(),
|
||||
nearestDepartment: ref({ id: 6, lanes: [{ id: 7 }] }),
|
||||
vehicleTypeSelect: ref(2),
|
||||
washLaneId: ref(null),
|
||||
washInProgress: ref(false),
|
||||
washStartTime: ref(null),
|
||||
completedDurationMs: ref(null),
|
||||
now: ref(10_000),
|
||||
currentStep: ref(WASH_STEPS.SELECT_LANE),
|
||||
steps: WASH_STEPS,
|
||||
radioWashType: ref("Manual"),
|
||||
activeTasks: ref([]),
|
||||
completedTasks: ref({}),
|
||||
saveProgress: vi.fn(),
|
||||
clearProgress: vi.fn(),
|
||||
startElapsedTimer: vi.fn(),
|
||||
stopElapsedTimer: vi.fn(),
|
||||
updateLaneAllowedServices: vi.fn(async () => true),
|
||||
fetchWashSummary: vi.fn(async () => ({})),
|
||||
enableMachineRelay: vi.fn(async () => ({ data: { success: true } })),
|
||||
isServiceAllowed: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
return { state, actions: useWashSessionActions(state), calls };
|
||||
}
|
||||
|
||||
describe("useWashSessionActions production commands", () => {
|
||||
it("starts a manual wash successfully", async () => {
|
||||
const { state, actions } = createActions();
|
||||
|
||||
await expect(actions.onStartWash(7, "ab12345", "12345679")).resolves.toBe(true);
|
||||
|
||||
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
|
||||
expect(state.request).toHaveBeenCalledWith(
|
||||
"/modules/self-serve/lane/command",
|
||||
"post",
|
||||
expect.objectContaining({ command: "START", license_plate: "AB12345" })
|
||||
);
|
||||
expect(state.washInProgress.value).toBe(true);
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
|
||||
expect(state.saveProgress).toHaveBeenCalledWith("onStartWash");
|
||||
});
|
||||
|
||||
it("reports START failure without mutating active wash state", async () => {
|
||||
const { state, actions } = createActions({
|
||||
request: vi.fn(async () => ({ data: { success: false, message: "START failed" } })),
|
||||
});
|
||||
|
||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false);
|
||||
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.alertFn).toHaveBeenCalledWith("START failed");
|
||||
expect(state.saveProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables machine relay after START when machine service is allowed", async () => {
|
||||
const { state, actions } = createActions({ radioWashType: ref("Machine") });
|
||||
|
||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(true);
|
||||
|
||||
expect(state.enableMachineRelay).toHaveBeenCalledWith(7);
|
||||
expect(state.washInProgress.value).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back with STOP when machine relay enable fails", async () => {
|
||||
const { state, actions, calls } = createActions({
|
||||
radioWashType: ref("Machine"),
|
||||
enableMachineRelay: vi.fn(async () => {
|
||||
throw new Error("relay down");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false);
|
||||
|
||||
expect(calls).toEqual(["START", "STOP"]);
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.alertFn).toHaveBeenCalledWith("relay down");
|
||||
});
|
||||
|
||||
it("keeps active state and alerts when STOP fails", async () => {
|
||||
const request = vi.fn(async (_url, _method, body) =>
|
||||
body.command === "STOP" ? { data: { success: false, message: "STOP failed" } } : { data: { success: true } }
|
||||
);
|
||||
const { state, actions } = createActions({
|
||||
request,
|
||||
washInProgress: ref(true),
|
||||
washLaneId: ref(7),
|
||||
washStartTime: ref(1_000),
|
||||
});
|
||||
|
||||
await expect(actions.onStopWash(7)).resolves.toBe(false);
|
||||
|
||||
expect(state.washInProgress.value).toBe(true);
|
||||
expect(state.alertFn).toHaveBeenCalledWith("STOP failed");
|
||||
expect(state.clearProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers local state when STOP says the lane is already not occupied", async () => {
|
||||
const request = vi.fn(async () => ({ data: { success: false, message: "Cannot stop lane 7: not occupied" } }));
|
||||
const { state, actions } = createActions({
|
||||
request,
|
||||
washInProgress: ref(true),
|
||||
washLaneId: ref(7),
|
||||
washStartTime: ref(1_000),
|
||||
now: ref(6_000),
|
||||
});
|
||||
|
||||
await expect(actions.onStopWash(7)).resolves.toBe(true);
|
||||
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.washLaneId.value).toBeNull();
|
||||
expect(state.completedDurationMs.value).toBe(5_000);
|
||||
expect(state.stopElapsedTimer).toHaveBeenCalled();
|
||||
expect(state.clearProgress).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user