Files
pleno-vue/tests/unit/pos-mobile-camera-lpr.spec.js
T

1140 lines
39 KiB
JavaScript

// @vitest-environment jsdom
import { flushPromises } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { mountWithApp } from "./helpers/mountWithApp.js";
import PosDepartmentStepMobile1 from "@/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile1.vue";
import {
LPR_FRAME_CLIENT_BYTES_FIELD,
LPR_FRAME_CLIENT_CAPTURE_MS_FIELD,
LPR_FRAME_CLIENT_DRAW_MS_FIELD,
LPR_FRAME_CLIENT_ENCODE_MS_FIELD,
LPR_FRAME_CLIENT_HEIGHT_FIELD,
LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD,
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
LPR_FRAME_CLIENT_WIDTH_FIELD,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
const mocks = vi.hoisted(() => ({
request: vi.fn(),
setTransparency: vi.fn(),
setBackgroundColor: vi.fn(),
setOverflow: vi.fn(),
cameraSetLatestImage: vi.fn(),
cameraSetLatestImageBlob: vi.fn(),
cameraSetLastSuccess: vi.fn(),
cameraHasDelayAfterSuccessPassed: vi.fn(() => true),
cameraGetImageCaptureDelay: vi.fn(() => 1005),
cameraGetImageCaptureDelayAfterSuccess: vi.fn(() => 1005),
vehicleSelect: vi.fn(),
attachmentView: null,
manualInput: null,
transactionHistoryView: null,
activeVehicleIndex: null,
vehicle1: null,
vehicle2: null,
vehicle3: null,
}));
vi.mock("@/components/viewport/page/headers/ViewportHeaderSettings.vue", () => ({
backgroundColors: {
default: "default",
},
setBackgroundColor: mocks.setBackgroundColor,
setOverflow: mocks.setOverflow,
setTransparency: mocks.setTransparency,
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: mocks.request,
objects: {
global: {
language: {
scanning: "Scanning",
},
},
},
},
}));
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => {
const manualInput = ref(false);
const transactionHistoryView = ref(false);
const attachmentView = ref(false);
const activeVehicleIndex = ref(1);
const activeVehicle = ref({ reg: "" });
const vehicle2 = ref({ reg: "" });
const vehicle3 = ref({ reg: "" });
const latestImage = ref(null);
mocks.attachmentView = attachmentView;
mocks.manualInput = manualInput;
mocks.transactionHistoryView = transactionHistoryView;
mocks.activeVehicleIndex = activeVehicleIndex;
mocks.vehicle1 = activeVehicle;
mocks.vehicle2 = vehicle2;
mocks.vehicle3 = vehicle3;
return {
attachments: {
base64: ref([]),
},
camera: {
latestImage,
setLatestImage: mocks.cameraSetLatestImage,
setLatestImageBlob: mocks.cameraSetLatestImageBlob,
hasDelayAfterSuccessPassed: mocks.cameraHasDelayAfterSuccessPassed,
getImageCaptureDelay: mocks.cameraGetImageCaptureDelay,
getImageCaptureDelayAfterSuccess: mocks.cameraGetImageCaptureDelayAfterSuccess,
setLastSuccess: mocks.cameraSetLastSuccess,
},
manualInput,
sounds: {
list: ref({ onAfterSuccessfulScan: "scan" }),
play: vi.fn(),
},
transactionHistoryView,
vehicles: {
activeVehicleIndex,
getActiveVehicle: () => {
if (activeVehicleIndex.value === 2) {
return vehicle2.value;
}
if (activeVehicleIndex.value === 3) {
return vehicle3.value;
}
return activeVehicle.value;
},
get: (index) => {
if (index === 1) {
return activeVehicle.value;
}
if (index === 2) {
return vehicle2.value;
}
if (index === 3) {
return vehicle3.value;
}
return null;
},
select: mocks.vehicleSelect,
setActiveVehicleIndex: vi.fn((nextIndex) => {
activeVehicleIndex.value = nextIndex;
}),
vehicle_1: activeVehicle,
vehicle_2: vehicle2,
vehicle_3: vehicle3,
},
views: {
attachmentView,
isAnyActive: ref(false),
},
};
});
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerCamera.vue", () => ({
default: {
name: "ScannerCamera",
emits: ["update:frame"],
props: {
captureEnabled: {
type: Boolean,
default: true,
},
captureIntervalMs: {
type: Number,
default: null,
},
captureMode: {
type: String,
default: "lpr",
},
getFocusViewportRect: {
type: Function,
default: null,
},
pausePreview: {
type: Boolean,
default: false,
},
shouldBuildVisualFingerprint: {
type: Function,
default: null,
},
shouldEncodeFrame: {
type: Function,
default: null,
},
},
methods: {
shouldBuildVisualFingerprintNow() {
if (this.shouldBuildVisualFingerprint === null) {
return true;
}
return this.shouldBuildVisualFingerprint();
},
shouldEncodeCandidate(visualFingerprint = null) {
if (this.shouldEncodeFrame === null) {
return true;
}
return this.shouldEncodeFrame({
height: 1,
...(visualFingerprint !== null ? { visualFingerprint } : {}),
width: 1,
});
},
frame(name, fingerprint = name, contentFingerprint = name, visualFingerprint = null) {
return {
blob: new Blob([name], { type: "image/jpeg" }),
captureDurationMs: 12.345,
captureTimings: {
drawMs: 1.25,
encodeMs: 2.5,
visualFingerprintMs: 0.75,
},
filename: `${name}.jpg`,
fingerprint,
getContentFingerprint: vi.fn(() => Promise.resolve(contentFingerprint)),
height: 1,
mimeType: "image/jpeg",
...(visualFingerprint !== null ? { visualFingerprint } : {}),
width: 1,
};
},
frameWithLazyVisual(name, fingerprint = name, contentFingerprint = name, visualFingerprint = null) {
return {
blob: new Blob([name], { type: "image/jpeg" }),
captureDurationMs: 12.345,
captureTimings: {
drawMs: 1.25,
encodeMs: 2.5,
visualFingerprintMs: 0.75,
},
filename: `${name}.jpg`,
fingerprint,
getContentFingerprint: vi.fn(() => Promise.resolve(contentFingerprint)),
getVisualFingerprint: vi.fn(() => visualFingerprint),
height: 1,
mimeType: "image/jpeg",
width: 1,
};
},
emitFrame(name, fingerprint = name, contentFingerprint = name, visualFingerprint = null) {
if (this.captureEnabled) {
this.$emit("update:frame", this.frame(name, fingerprint, contentFingerprint, visualFingerprint));
}
},
},
template: `
<div>
<button data-testid="camera-frame-a" @click="emitFrame('frame-a')" />
<button data-testid="camera-frame-b" @click="emitFrame('frame-b')" />
<button data-testid="camera-frame-c" @click="emitFrame('frame-c')" />
<button data-testid="camera-frame-same-a" @click="emitFrame('same-a', 'same-size', 'same-content-a')" />
<button data-testid="camera-frame-same-a-duplicate" @click="emitFrame('same-a-copy', 'same-size', 'same-content-a')" />
<button data-testid="camera-frame-same-b" @click="emitFrame('same-b', 'same-size', 'same-content-b')" />
</div>
`,
},
}));
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerInstructions.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerOutline.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/components/models/pos/step1/RegistrationNumberSearchResult.vue", () => ({
default: { template: "<div />" },
}));
vi.mock(
"@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1RegistrationNumbers.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock("@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileManualInput.vue", () => ({
default: { template: "<div />" },
}));
vi.mock(
"@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue",
() => ({
default: { template: "<div />" },
})
);
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue", () => ({
default: { template: "<div />" },
}));
vi.mock(
"@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue",
() => ({
default: {
name: "PosDepartmentStepMobileFixedBottomControl",
props: {
useBackdropBlur: {
type: Boolean,
default: true,
},
},
template:
'<div data-testid="fixed-bottom-control" :data-use-backdrop-blur="String(useBackdropBlur)"><slot /></div>',
},
})
);
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue", () => ({
default: { template: "<div />" },
}));
vi.mock("@/components/viewport/elements/icons/UnknownCustomer.vue", () => ({
default: { template: "<span />" },
}));
vi.mock("@/components/viewport/elements/icons/VerifiedCustomer.vue", () => ({
default: { template: "<span />" },
}));
vi.mock("@/components/viewport/elements/icons/BookedCustomer.vue", () => ({
default: { template: "<span />" },
}));
vi.mock("@/components/viewport/elements/icons/KnownCustomer.vue", () => ({
default: { template: "<span />" },
}));
vi.mock("@/components/viewport/elements/icons/CardPaymentCustomer.vue", () => ({
default: { template: "<span />" },
}));
const resolveImageImmediately = () => {
class TestImage {
onerror = null;
set src(_value) {
this.onerror?.(new Error("skip image decode"));
}
}
vi.stubGlobal("Image", TestImage);
};
const setDocumentVisibility = (visibilityState) => {
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: visibilityState,
});
};
const expectRawLPRRequest = async (callIndex, expectedFrameText) => {
const url = mocks.request.mock.calls[callIndex][0];
const payload = mocks.request.mock.calls[callIndex][2];
const options = mocks.request.mock.calls[callIndex][5];
const parsedUrl = new URL(url, "https://scanner.test");
expect(parsedUrl.pathname).toBe("/modules/scanner/lpr");
expect(payload).toBeInstanceOf(Blob);
expect(payload.type).toBe("image/jpeg");
await expect(payload.text()).resolves.toBe(expectedFrameText);
expect(options.headers).toEqual({
"Content-Type": "image/jpeg",
});
expect(options.transport).toBe("fetch");
expect(parsedUrl.searchParams.get(LPR_FRAME_CLIENT_WIDTH_FIELD)).toBe("1");
expect(parsedUrl.searchParams.get(LPR_FRAME_CLIENT_HEIGHT_FIELD)).toBe("1");
expect(parsedUrl.searchParams.get(LPR_FRAME_CLIENT_BYTES_FIELD)).toBe(String(new Blob([expectedFrameText]).size));
return { payload, searchParams: parsedUrl.searchParams };
};
describe("POS mobile camera LPR", () => {
beforeEach(() => {
setDocumentVisibility("visible");
mocks.request.mockReset();
mocks.cameraSetLatestImage.mockReset();
mocks.cameraSetLatestImageBlob.mockReset();
mocks.cameraSetLastSuccess.mockReset();
mocks.cameraHasDelayAfterSuccessPassed.mockReset();
mocks.cameraHasDelayAfterSuccessPassed.mockReturnValue(true);
mocks.cameraGetImageCaptureDelay.mockReset();
mocks.cameraGetImageCaptureDelay.mockReturnValue(1005);
mocks.cameraGetImageCaptureDelayAfterSuccess.mockReset();
mocks.cameraGetImageCaptureDelayAfterSuccess.mockReturnValue(1005);
mocks.vehicleSelect.mockReset();
mocks.attachmentView.value = false;
mocks.manualInput.value = false;
mocks.transactionHistoryView.value = false;
mocks.activeVehicleIndex.value = 1;
mocks.vehicle1.value = { reg: "" };
mocks.vehicle2.value = { reg: "" };
mocks.vehicle3.value = { reg: "" };
resolveImageImmediately();
});
afterEach(() => {
setDocumentVisibility("visible");
vi.useRealTimers();
});
it("does not enqueue overlapping scanner requests while a camera frame is still being parsed", async () => {
vi.useFakeTimers();
let resolveFirstRequest;
mocks.request.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstRequest = resolve;
})
);
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
expect(cameraWrapper.props("pausePreview")).toBe(true);
let rawRequest = await expectRawLPRRequest(0, "frame-a");
expect(rawRequest.searchParams.get(LPR_FRAME_CLIENT_CAPTURE_MS_FIELD)).toBe("12.345");
expect(Number(rawRequest.searchParams.get(LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD))).toBeGreaterThanOrEqual(0);
expect(rawRequest.searchParams.get(LPR_FRAME_CLIENT_DRAW_MS_FIELD)).toBe("1.250");
expect(rawRequest.searchParams.get(LPR_FRAME_CLIENT_ENCODE_MS_FIELD)).toBe("2.500");
expect(rawRequest.searchParams.get(LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD)).toBe("0.750");
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
cameraWrapper.vm.$emit("update:frame", cameraWrapper.vm.frame("late-frame"));
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
resolveFirstRequest({ data: { success: false } });
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(true);
vi.advanceTimersByTime(1500);
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(false);
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
rawRequest = await expectRawLPRRequest(1, "frame-c");
expect(rawRequest.searchParams.get(LPR_FRAME_CLIENT_CAPTURE_MS_FIELD)).toBe("12.345");
});
it("omits zero-valued client timing query params from scanner raw image requests", async () => {
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
const frame = cameraWrapper.vm.frame("zero-timing-frame");
frame.captureDurationMs = 0;
frame.captureTimings = {
drawMs: 0,
encodeMs: 0,
visualFingerprintMs: 0,
};
cameraWrapper.vm.$emit("update:frame", frame);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
const { searchParams } = await expectRawLPRRequest(0, "zero-timing-frame");
expect(searchParams.has(LPR_FRAME_CLIENT_CAPTURE_MS_FIELD)).toBe(false);
expect(searchParams.has(LPR_FRAME_CLIENT_DRAW_MS_FIELD)).toBe(false);
expect(searchParams.has(LPR_FRAME_CLIENT_ENCODE_MS_FIELD)).toBe(false);
expect(searchParams.has(LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD)).toBe(false);
});
it("pauses the camera preview while scanner duplicate preflight is still running", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(cameraWrapper.props("pausePreview")).toBe(true);
vi.advanceTimersByTime(2500);
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(false);
let resolveContentFingerprint;
const slowPreflightFrame = cameraWrapper.vm.frame("frame-a-new-content", "frame-a", "unused-content");
slowPreflightFrame.getContentFingerprint = vi.fn(
() =>
new Promise((resolve) => {
resolveContentFingerprint = resolve;
})
);
cameraWrapper.vm.$emit("update:frame", slowPreflightFrame);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(cameraWrapper.props("pausePreview")).toBe(true);
resolveContentFingerprint("frame-a-new-content");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
expect(cameraWrapper.props("pausePreview")).toBe(true);
vi.advanceTimersByTime(2500);
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(false);
});
it("keeps the latest camera image without posting LPR requests while attachment view is active", async () => {
mocks.attachmentView.value = true;
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
expect(cameraWrapper.props("captureMode")).toBe("preview");
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).not.toHaveBeenCalled();
expect(mocks.cameraSetLatestImageBlob).toHaveBeenCalledTimes(1);
const blob = mocks.cameraSetLatestImageBlob.mock.calls[0][0];
expect(blob).toBeInstanceOf(Blob);
await expect(blob.text()).resolves.toBe("frame-a");
mocks.attachmentView.value = false;
await flushPromises();
expect(cameraWrapper.props("captureMode")).toBe("lpr");
});
it("disables fixed bottom backdrop blur in the live LPR scanner view", async () => {
const wrapper = mountWithApp(PosDepartmentStepMobile1);
expect(wrapper.get('[data-testid="fixed-bottom-control"]').attributes("data-use-backdrop-blur")).toBe("false");
mocks.attachmentView.value = true;
await flushPromises();
expect(wrapper.get('[data-testid="fixed-bottom-control"]').attributes("data-use-backdrop-blur")).toBe("true");
});
it("passes the scanner outline viewport rect provider to the camera", async () => {
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
const focusTarget = wrapper.get(".scanner-focus-target").element;
vi.spyOn(focusTarget, "getBoundingClientRect").mockReturnValue({
bottom: 439,
height: 234,
left: 78,
right: 312,
toJSON: () => ({}),
top: 205,
width: 234,
x: 78,
y: 205,
});
expect(cameraWrapper.props("getFocusViewportRect")()).toEqual({
height: 234,
width: 234,
x: 78,
y: 205,
});
mocks.attachmentView.value = true;
await flushPromises();
expect(cameraWrapper.props("getFocusViewportRect")()).toBeNull();
});
it("does not capture or post LPR frames once all registration slots are filled", async () => {
mocks.vehicle1.value = { reg: "AB12345" };
mocks.vehicle2.value = { reg: "CD67890" };
mocks.vehicle3.value = { reg: "EF54321" };
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).not.toHaveBeenCalled();
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
});
it("does not capture or post LPR frames while the active registration slot is already filled", async () => {
mocks.vehicle1.value = { reg: "AB12345" };
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).not.toHaveBeenCalled();
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
});
it("resumes camera LPR capture after the active registration slot moves to an empty slot", async () => {
mocks.vehicle1.value = { reg: "AB12345" };
mocks.activeVehicleIndex.value = 2;
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
});
it("pauses camera LPR capture briefly after no-plate responses", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
vi.advanceTimersByTime(1499);
await flushPromises();
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(false);
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
});
it("pauses camera LPR capture briefly after scanner request failures", async () => {
vi.useFakeTimers();
mocks.request.mockRejectedValue(new Error("temporary scanner failure"));
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
vi.advanceTimersByTime(1499);
await flushPromises();
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(false);
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
});
it("uses a failed scanner request frame to suppress unchanged scenes during backoff", async () => {
vi.useFakeTimers();
mocks.request.mockRejectedValue(new Error("temporary scanner failure"));
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
const failedFrame = cameraWrapper.vm.frameWithLazyVisual(
"failed-empty-frame",
"300x159:101",
"bytes-a",
"00000000000000ff"
);
cameraWrapper.vm.$emit("update:frame", failedFrame);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(failedFrame.getVisualFingerprint).toHaveBeenCalledTimes(1);
expect(cameraWrapper.props("captureEnabled")).toBe(true);
expect(cameraWrapper.props("captureIntervalMs")).toBe(700);
expect(cameraWrapper.props("pausePreview")).toBe(false);
expect(cameraWrapper.vm.shouldEncodeCandidate("00000000000000f0")).toBe(false);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(cameraWrapper.vm.shouldEncodeCandidate("ffffffffffffffff")).toBe(true);
});
it("pauses camera LPR capture after successful scans before encoding another frame", async () => {
vi.useFakeTimers();
mocks.vehicle2.value = { reg: "AB12345" };
mocks.request.mockResolvedValue({
data: {
data: {
license_plate_number: "AB12345",
success: true,
},
success: true,
},
});
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLastSuccess).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).toHaveBeenCalledTimes(1);
expect(mocks.vehicleSelect).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1004);
await flushPromises();
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(false);
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
expect(mocks.cameraSetLatestImageBlob).toHaveBeenCalledTimes(2);
});
it("clears no-plate capture backoff when the active registration slot changes", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
mocks.activeVehicleIndex.value = 2;
await flushPromises();
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
});
it("aborts stale LPR requests when the active registration slot changes", async () => {
mocks.request.mockImplementation(
(_url, _method, _payload, _catchCallable, _thenCallable, options) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => {
reject({ code: "ERR_CANCELED" });
});
})
);
const wrapper = mountWithApp(PosDepartmentStepMobile1);
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
const requestOptions = mocks.request.mock.calls[0][5];
expect(requestOptions.signal.aborted).toBe(false);
mocks.activeVehicleIndex.value = 2;
await flushPromises();
expect(requestOptions.signal.aborted).toBe(true);
});
it("ignores successful LPR responses that resolve after the active registration slot changes", async () => {
let resolveRequest;
mocks.request.mockReturnValueOnce(
new Promise((resolve) => {
resolveRequest = resolve;
})
);
const wrapper = mountWithApp(PosDepartmentStepMobile1);
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
const requestOptions = mocks.request.mock.calls[0][5];
mocks.activeVehicleIndex.value = 2;
await flushPromises();
expect(requestOptions.signal.aborted).toBe(true);
resolveRequest({
data: {
data: {
license_plate_number: "AB12345",
success: true,
},
success: true,
},
});
await flushPromises();
expect(mocks.cameraSetLastSuccess).not.toHaveBeenCalled();
expect(mocks.vehicleSelect).not.toHaveBeenCalled();
});
it("aborts active LPR requests when the scanner page becomes hidden", async () => {
mocks.request.mockImplementation(
(_url, _method, _payload, _catchCallable, _thenCallable, options) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => {
reject({ code: "ERR_CANCELED" });
});
})
);
const wrapper = mountWithApp(PosDepartmentStepMobile1);
await wrapper.get('[data-testid="camera-frame-same-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
const requestOptions = mocks.request.mock.calls[0][5];
expect(requestOptions.signal.aborted).toBe(false);
setDocumentVisibility("hidden");
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
expect(requestOptions.signal.aborted).toBe(true);
setDocumentVisibility("visible");
document.dispatchEvent(new Event("visibilitychange"));
await flushPromises();
await wrapper.get('[data-testid="camera-frame-same-a-duplicate"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
await expectRawLPRRequest(1, "same-a-copy");
});
it("uses lazy content fingerprints only when quick frame fingerprints match", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
await wrapper.get('[data-testid="camera-frame-same-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
await expectRawLPRRequest(0, "same-a");
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
await flushPromises();
await wrapper.get('[data-testid="camera-frame-same-a-duplicate"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
await wrapper.get('[data-testid="camera-frame-same-b"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
vi.advanceTimersByTime(699);
await flushPromises();
await wrapper.get('[data-testid="camera-frame-same-b"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
await wrapper.get('[data-testid="camera-frame-same-b"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
await expectRawLPRRequest(1, "same-b");
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
});
it("skips visually similar no-plate frames even when the encoded blob changes", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
cameraWrapper.vm.$emit(
"update:frame",
cameraWrapper.vm.frame("empty-frame-a", "576x576:101", "bytes-a", "00000000000000ff")
);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
await flushPromises();
cameraWrapper.vm.$emit(
"update:frame",
cameraWrapper.vm.frame("empty-frame-b", "576x576:117", "bytes-b", "00000000000000f0")
);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
vi.advanceTimersByTime(699);
await flushPromises();
cameraWrapper.vm.$emit(
"update:frame",
cameraWrapper.vm.frame("changed-frame-too-soon", "576x576:118", "bytes-c", "ffffffffffffffff")
);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await flushPromises();
cameraWrapper.vm.$emit(
"update:frame",
cameraWrapper.vm.frame("changed-frame", "576x576:118", "bytes-c", "ffffffffffffffff")
);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
});
it("skips visually similar no-plate frames before JPEG encoding", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
cameraWrapper.vm.$emit(
"update:frame",
cameraWrapper.vm.frame("empty-frame-a", "576x576:101", "bytes-a", "00000000000000ff")
);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
await flushPromises();
expect(cameraWrapper.vm.shouldEncodeCandidate("00000000000000f0")).toBe(false);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("captureEnabled")).toBe(false);
expect(cameraWrapper.props("pausePreview")).toBe(true);
vi.advanceTimersByTime(699);
await flushPromises();
expect(cameraWrapper.vm.shouldEncodeCandidate("ffffffffffffffff")).toBe(false);
vi.advanceTimersByTime(1);
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(false);
expect(cameraWrapper.vm.shouldEncodeCandidate("ffffffffffffffff")).toBe(true);
});
it("defers visual fingerprint work until a sent frame misses", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
expect(cameraWrapper.vm.shouldBuildVisualFingerprintNow()).toBe(false);
const lazyFrame = cameraWrapper.vm.frameWithLazyVisual(
"empty-frame-a",
"300x225:101",
"bytes-a",
"00000000000000ff"
);
cameraWrapper.vm.$emit("update:frame", lazyFrame);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(lazyFrame.getVisualFingerprint).toHaveBeenCalledTimes(1);
expect(cameraWrapper.vm.shouldBuildVisualFingerprintNow()).toBe(true);
expect(cameraWrapper.props("captureIntervalMs")).toBe(700);
expect(cameraWrapper.props("pausePreview")).toBe(false);
vi.advanceTimersByTime(1500);
await flushPromises();
expect(cameraWrapper.vm.shouldEncodeCandidate("00000000000000f0")).toBe(false);
});
it("uses visual changes to resume LPR during no-plate backoff without polling the same scene", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
const emptyFrame = cameraWrapper.vm.frameWithLazyVisual(
"empty-frame-a",
"300x225:101",
"bytes-a",
"00000000000000ff"
);
cameraWrapper.vm.$emit("update:frame", emptyFrame);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(emptyFrame.getVisualFingerprint).toHaveBeenCalledTimes(1);
expect(cameraWrapper.props("captureEnabled")).toBe(true);
expect(cameraWrapper.props("captureIntervalMs")).toBe(700);
expect(cameraWrapper.props("pausePreview")).toBe(false);
expect(cameraWrapper.vm.shouldEncodeCandidate("00000000000000f0")).toBe(false);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(cameraWrapper.vm.shouldEncodeCandidate("ffffffffffffffff")).toBe(true);
await flushPromises();
expect(cameraWrapper.props("captureIntervalMs")).toBeNull();
cameraWrapper.vm.$emit(
"update:frame",
cameraWrapper.vm.frame("plate-frame", "300x225:202", "bytes-b", "ffffffffffffffff")
);
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
await expectRawLPRRequest(1, "plate-frame");
});
it("clears duplicate frame suppression when the active registration slot changes", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
const wrapper = mountWithApp(PosDepartmentStepMobile1);
await wrapper.get('[data-testid="camera-frame-same-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1500);
await flushPromises();
mocks.activeVehicleIndex.value = 2;
await flushPromises();
await wrapper.get('[data-testid="camera-frame-same-a-duplicate"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
await expectRawLPRRequest(1, "same-a-copy");
});
it("passes an abort signal to LPR requests and cancels stale requests when attachment view opens", async () => {
mocks.request.mockImplementation(
(_url, _method, _payload, _catchCallable, _thenCallable, options) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => {
reject({ code: "ERR_CANCELED" });
});
})
);
const wrapper = mountWithApp(PosDepartmentStepMobile1);
await wrapper.get('[data-testid="camera-frame-a"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
const requestOptions = mocks.request.mock.calls[0][5];
expect(requestOptions.signal).toBeInstanceOf(AbortSignal);
expect(requestOptions.signal.aborted).toBe(false);
mocks.attachmentView.value = true;
await flushPromises();
expect(requestOptions.signal.aborted).toBe(true);
});
});