Files
pleno-vue/tests/unit/pos-last-scanned-license-plates.spec.js
T
openclaw bugfix f22ca71558 feat(pos): show 'last washed' timestamp on license plate scan (TRU-78)
When the operator expands a row in the recent license plate scans panel
on the POS landing page, the inline details now include a 'Last wash'
field with the timestamp of the most recent wash for that plate (as
returned by the enriched /numberplatescans endpoint). The label falls
back to the existing 'recent_washes' / global.last_wash translation.

If the API reports last_wash = null, an inline 'Never washed' / 'Aldrig
vasket' note is shown instead (TRU-78 / DRIFT 17 — DHL trailer pick-up
use case: see at a glance whether the trailer needs another wash).

- New pos/never_washed i18n leaf in all 5 locales.
- 3 new vitest cases in pos-last-scanned-license-plates.spec.js:
  - renders the last washed timestamp when present
  - shows 'never washed' when the API returns null
  - falls back to 'never washed' when the field is missing entirely
2026-08-17 14:20:12 +00:00

421 lines
13 KiB
JavaScript

// @vitest-environment jsdom
import { nextTick, ref } from "vue";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js";
const paginationState = vi.hoisted(() => ({
list: null,
metaCurrentPage: null,
metaItemsPerPage: null,
metaTotalItems: null,
metaSearch: null,
loadList: vi.fn(),
setEndpoint: vi.fn(),
setMetaItemsPerPage: vi.fn(),
setPage: vi.fn(),
search: vi.fn(),
setFilter: vi.fn(),
setOrder: vi.fn(),
}));
const posState = vi.hoisted(() => ({
departmentId: null,
getDepartment: vi.fn(() => 12),
selectScan: vi.fn(),
}));
const requestMock = vi.hoisted(() => vi.fn());
const lookupMock = vi.hoisted(() => vi.fn());
vi.mock("@/components/pagination/paginatedList.vue", async () => {
paginationState.list = ref([]);
paginationState.metaCurrentPage = ref(1);
paginationState.metaItemsPerPage = ref(10);
paginationState.metaTotalItems = ref(0);
paginationState.metaSearch = ref("");
return {
list: paginationState.list,
metaCurrentPage: paginationState.metaCurrentPage,
metaItemsPerPage: paginationState.metaItemsPerPage,
metaTotalItems: paginationState.metaTotalItems,
metaSearch: paginationState.metaSearch,
loadList: paginationState.loadList,
setEndpoint: paginationState.setEndpoint,
setMetaItemsPerPage: paginationState.setMetaItemsPerPage,
setPage: paginationState.setPage,
search: paginationState.search,
setFilter: paginationState.setFilter,
setOrder: paginationState.setOrder,
};
});
vi.mock("@/components/shop/POSDepartmentProcess.vue", async () => {
posState.departmentId = ref(12);
return {
department_id: posState.departmentId,
getDepartment: posState.getDepartment,
selectScan: posState.selectScan,
};
});
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: requestMock,
functions: {
date: {
toLocalTimeHours: vi.fn((value) => String(value).slice(11, 16)),
},
},
objects: {
order_bookings: {
meta: {
endpoint: "/order-bookings",
},
},
orders: {
columns: {
reg_1: {
label: "License plate",
},
},
},
global: {
language: {
last_wash: "Last wash",
nothing_left_to_show: "Nothing left to show",
},
},
},
superUser: {
modules: {
motorapi: {
functions: {
lookup: lookupMock,
},
},
},
},
},
}));
vi.mock("@/ThemeConfig.vue", () => ({
Colors: {
inputs: {
default: {
backgroundColor: "#ffffff",
textColor: "#22364d",
},
},
},
}));
vi.mock("@vueuse/core", async () => {
const { ref } = await import("vue");
return {
useElementVisibility: vi.fn(() => ref(false)),
};
});
import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/PosLastScannedLicensePlatesV2.vue";
let consoleErrorSpy;
const messages = {
en: {
admin: {
pos: {
customer: "Customer",
customer_number: "Customer number",
booking: "Booking",
license_plate: "License plate",
loading: "Loading...",
make: "Make",
model: "Model",
no_plates_found: "No license plates found",
never_washed: "Never washed",
recent_scans: "Recent scans",
recent_scans_helper: "Select a license plate to view vehicle details.",
recent_scan_details_empty: "No vehicle details are available for this scan.",
recent_scan_details_error: "Vehicle details could not be loaded. Please try another scan.",
recent_scan_details_loading: "Loading vehicle details...",
scanner: "Scanner",
status_barred: "Barred",
status_booking: "Booking",
status_unknown: "New",
try_again: "Try again",
type: "Type",
use: "Use",
variant: "Variant",
},
},
global: {
search_license_plate: "Search for license plate",
},
},
};
const scans = [
{
id: 801,
plate: "AB12345",
plate_scanner_id: 1,
created_at: "2026-04-08 08:44:07",
customer_number: 12345679,
customer_name: "Pleno Logistics",
seen_before: true,
barred: false,
// TRU-78: enriched by the API so the landing page can show when
// the plate was last washed (DHL pick-up use case).
last_wash: "2026-04-07 17:12:33",
},
{
id: 802,
plate: "CD67890",
plate_scanner_id: 2,
created_at: "2026-04-08 08:31:05",
customer_number: null,
customer_name: "",
seen_before: false,
barred: false,
// TRU-78: explicit null means the API confirmed the plate has
// never been washed.
last_wash: null,
},
{
id: 803,
plate: "EF11111",
plate_scanner_id: 3,
created_at: "2026-04-08 08:30:00",
customer_number: null,
customer_name: "",
seen_before: false,
barred: false,
// TRU-78: missing field should be treated as "never washed" too,
// so older backends don't break the new UI.
// (no `last_wash` key on purpose)
},
];
const flushUi = async () => {
await Promise.resolve();
await Promise.resolve();
await nextTick();
};
function buildLookupResponse(overrides = {}) {
return {
data: {
data: {
make: "RENAULT",
model: "Captur",
variant: "dCi 90",
type: "Personbil",
use: "Privat personkørsel",
...overrides,
},
},
};
}
describe("PosLastScannedLicensePlatesV2", () => {
beforeEach(() => {
vi.useFakeTimers();
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
paginationState.list.value = [];
paginationState.metaCurrentPage.value = 1;
paginationState.metaItemsPerPage.value = 10;
paginationState.metaTotalItems.value = scans.length;
paginationState.metaSearch.value = "";
paginationState.loadList.mockClear();
paginationState.setEndpoint.mockClear();
paginationState.setMetaItemsPerPage.mockClear();
paginationState.setPage.mockClear();
paginationState.search.mockClear();
paginationState.setFilter.mockClear();
paginationState.setOrder.mockClear();
posState.selectScan.mockClear();
posState.getDepartment.mockReturnValue(12);
posState.departmentId.value = 12;
requestMock.mockReset();
requestMock.mockImplementation((url) => {
if (String(url).startsWith("/department/numberplatescanners")) {
return Promise.resolve({
data: {
data: [
{ id: 1, name: "North scanner" },
{ id: 2, name: "South scanner" },
],
},
});
}
return Promise.resolve({
data: {
data: [{ id: 501, reg_1: "AB12345", reg_2: "" }],
},
});
});
lookupMock.mockReset();
lookupMock.mockImplementation((plate) => {
if (plate === "AB12345") {
return Promise.resolve(buildLookupResponse());
}
return Promise.resolve(
buildLookupResponse({
make: "SCANIA",
model: "R500",
variant: "Highline",
type: "Lastbil",
use: "Godstransport",
})
);
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
consoleErrorSpy.mockRestore();
vi.useRealTimers();
});
it("clicking a row selects the scan and reveals inline details", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
await flushUi();
expect(posState.selectScan).toHaveBeenCalledWith(expect.objectContaining({ id: 801, plate: "AB12345" }));
expect(lookupMock).toHaveBeenCalledWith("AB12345");
expect(wrapper.get('[data-testid="pos-recent-scan-details-801"]').text()).toContain("RENAULT");
expect(wrapper.get('[data-testid="pos-recent-scan-details-801"]').text()).toContain("Customer number");
});
it("keeps only one inline detail panel expanded at a time", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
await flushUi();
expect(wrapper.find('[data-testid="pos-recent-scan-details-801"]').exists()).toBe(true);
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
await flushUi();
expect(wrapper.find('[data-testid="pos-recent-scan-details-801"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="pos-recent-scan-details-802"]').exists()).toBe(true);
});
it("caches MotorAPI lookups by plate when the user revisits a scan", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
await flushUi();
expect(lookupMock.mock.calls.filter(([plate]) => plate === "AB12345")).toHaveLength(1);
expect(lookupMock.mock.calls.filter(([plate]) => plate === "CD67890")).toHaveLength(1);
});
it("shows an inline error message when MotorAPI lookup fails", async () => {
lookupMock.mockImplementation((plate) => {
if (plate === "CD67890") {
return Promise.reject(new Error("Lookup failed"));
}
return Promise.resolve(buildLookupResponse());
});
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
await flushUi();
expect(wrapper.get('[data-testid="pos-recent-scan-details-802"]').text()).toContain(
"Vehicle details could not be loaded"
);
});
// TRU-78 / DRIFT 17: license plate scan should show "last washed" on the
// landing page so the operator can see at a glance when a trailer was
// last washed (DHL pick-up use case).
it("renders the last washed timestamp when the scan has a last_wash value", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-801"]').trigger("click");
await flushUi();
const lastWashField = wrapper.get('[data-testid="pos-recent-scan-detail-801-last-wash"]');
expect(lastWashField.exists()).toBe(true);
expect(lastWashField.text()).toContain("Last wash");
// The MySQL DATETIME "2026-04-07 17:12:33" is rendered in the user's
// locale. We accept both the raw DATETIME string (jsdom without full
// Intl support) and the locale-formatted output (e.g. 04/07/2026 or
// 2026-04-07 in browsers with Intl.DateTimeFormat).
expect(lastWashField.text()).toMatch(/2026/);
expect(lastWashField.text()).toMatch(/04[\/.\- ]07|07[\/.\- ]04|7[\/.\- ]4|2026-04-07/);
expect(lastWashField.text()).toMatch(/17:12/);
});
it("shows the 'never washed' message when the API confirms the plate has no last_wash", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-802"]').trigger("click");
await flushUi();
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-802"]');
expect(neverWashed.exists()).toBe(true);
expect(neverWashed.text()).toContain("Never washed");
});
it("falls back to 'never washed' when the scan has no last_wash key at all", async () => {
const wrapper = mountWithApp(PosLastScannedLicensePlatesV2, { messages });
await vi.advanceTimersByTimeAsync(1000);
paginationState.list.value = scans;
await flushUi();
await wrapper.get('[data-testid="pos-recent-scan-row-803"]').trigger("click");
await flushUi();
const neverWashed = wrapper.get('[data-testid="pos-recent-scan-never-washed-803"]');
expect(neverWashed.exists()).toBe(true);
expect(neverWashed.text()).toContain("Never washed");
});
});