Compare commits

...
8 changed files with 592 additions and 49 deletions
@@ -51,6 +51,7 @@ type LPRResponse = {
};
const latestLPRResponse = ref<LPRResponse | null>(null);
const isLPRRequestInFlight = ref(false);
const LPR_IMAGE_MAX_WIDTH = 1280;
const LPR_IMAGE_MAX_HEIGHT = 720;
const LPR_IMAGE_JPEG_QUALITY = 0.72;
@@ -116,6 +117,9 @@ const handleLPRResult = () => {
};
const parseImage = async (image: string) => {
if (isLPRRequestInFlight.value) {
return;
}
// Check if the time since the last successful parse is enough
if (!camera.hasDelayAfterSuccessPassed()) {
return;
@@ -126,31 +130,35 @@ const parseImage = async (image: string) => {
}
lastParsedImage.value = image; // Update the last parsed image
camera.setLatestImage(image); // Update the latest image in the camera object
// Function to parse the image data
const compressedImage = await compressImageForLPR(image);
SessionUser.request("/modules/scanner/lpr", "POST", {
base64_image: compressedImage,
})
.then((response) => {
if (debug_mode.value) {
debug_request_results.value.push(response);
}
// If the response is not successful, stop here.
if (!response.data.success) {
return;
}
latestLPRResponse.value = response.data.data as LPRResponse;
// Set the last successful capture time
camera.setLastSuccess();
// Handle parsed result.
handleLPRResult();
})
.catch((error) => {
if (debug_mode.value) {
debug_request_results.value.push(error);
}
//console.error("Error parsing image:", error);
isLPRRequestInFlight.value = true;
try {
// Function to parse the image data
const compressedImage = await compressImageForLPR(image);
const response = await SessionUser.request("/modules/scanner/lpr", "POST", {
base64_image: compressedImage,
});
if (debug_mode.value) {
debug_request_results.value.push(response);
}
// If the response is not successful, stop here.
if (!response.data.success) {
return;
}
latestLPRResponse.value = response.data.data as LPRResponse;
// Set the last successful capture time
camera.setLastSuccess();
// Handle parsed result.
handleLPRResult();
} catch (error) {
if (debug_mode.value) {
debug_request_results.value.push(error);
}
//console.error("Error parsing image:", error);
} finally {
isLPRRequestInFlight.value = false;
}
};
watch(manualInput, (newValue) => {
@@ -15,6 +15,29 @@ const getSelectedCustomerNumber = () => {
const MY_ACTIVE_WASH_ENDPOINT = '/modules/self-serve/lane/wash/my-active-wash';
const ACTIVE_WASH_STARTED_STATUSES = new Set(['MACHINE_RELAY_ENABLED', 'MACHINE_STARTED']);
const SELF_SERVE_HARDWARE_QUEUE_GROUP = 'SELF_SERVE_HARDWARE';
const POS_SCANNER_QUEUE_GROUP = 'POS_SCANNER';
const POS_STRIPE_QUEUE_GROUP = 'POS_STRIPE';
const SELF_SERVE_HARDWARE_ENDPOINTS = [
'/modules/self-serve/lane/command',
'/modules/self-serve/lane/relay/',
'/modules/self-serve/lane/gate/open',
'/modules/self-serve/lane/force/machine',
];
const POS_LATENCY_QUEUE_RULES = [
{
endpoints: ['/modules/scanner/lpr'],
queueGroup: POS_SCANNER_QUEUE_GROUP,
concurrencyLimit: 2,
retryByStatusCode: {},
},
{
endpoints: ['/modules/stripe/invoice'],
queueGroup: POS_STRIPE_QUEUE_GROUP,
concurrencyLimit: 2,
retryByStatusCode: {},
},
];
const normalizeStatus = (status) => String(status || '').trim().toUpperCase();
@@ -65,6 +88,52 @@ const normalizeActiveWashResponse = (url, method, response) => {
};
};
const isSelfServeHardwareMutation = (url, method) => {
const normalizedMethod = String(method || '').trim().toUpperCase();
if (normalizedMethod === 'GET') {
return false;
}
const normalizedUrl = String(url || '');
return SELF_SERVE_HARDWARE_ENDPOINTS.some((endpoint) => normalizedUrl.includes(endpoint));
};
const findPosLatencyQueueRule = (url, method) => {
const normalizedMethod = String(method || '').trim().toUpperCase();
if (normalizedMethod === 'GET') {
return null;
}
const normalizedUrl = String(url || '');
return POS_LATENCY_QUEUE_RULES.find((rule) =>
rule.endpoints.some((endpoint) => normalizedUrl.includes(endpoint))
) || null;
};
const buildRequestQueueOptions = (url, method, options = {}) => {
const queueOptions = {
retryByStatusCode: options?.retryByStatusCode,
shouldRetry: options?.shouldRetry,
queueGroup: options?.queueGroup,
concurrencyLimit: options?.concurrencyLimit,
};
if (isSelfServeHardwareMutation(url, method)) {
queueOptions.retryByStatusCode ??= {};
queueOptions.queueGroup ??= SELF_SERVE_HARDWARE_QUEUE_GROUP;
queueOptions.concurrencyLimit ??= 1;
}
const posQueueRule = findPosLatencyQueueRule(url, method);
if (posQueueRule) {
queueOptions.retryByStatusCode ??= posQueueRule.retryByStatusCode;
queueOptions.queueGroup ??= posQueueRule.queueGroup;
queueOptions.concurrencyLimit ??= posQueueRule.concurrencyLimit;
}
return queueOptions;
};
export const authenticatedRequest = (url, method, data, catchCallable = null, thenCallable = null, options = {}) => {
const token = localStorage.getItem('token');
if (!token) {
@@ -105,7 +174,8 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
params: method === 'GET' ? data : null,
data: method === 'GET' ? null : data,
headers,
}
},
...buildRequestQueueOptions(requestUrl, method, options),
}
)
.catch((error) => {
@@ -9,6 +9,7 @@ const canvasRef = ref<HTMLCanvasElement | null>(null);
const cameraStream = ref<MediaStream | null>(null);
const isCameraActive = ref(false);
const cameraErrorKey = ref('pos.camera_permission_denied');
let captureIntervalId: ReturnType<typeof window.setInterval> | null = null;
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
const getCameraErrorKey = (err: unknown) => {
@@ -22,6 +23,10 @@ const getCameraErrorKey = (err: unknown) => {
};
function startCamera() {
if (cameraStream.value || isCameraActive.value) {
return;
}
const constraints = {
video: {
facingMode: 'environment',
@@ -49,12 +54,14 @@ function startCamera() {
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
isCameraActive.value = true;
isCameraMounted.value = true;
cameraStream.value = stream;
if (videoRef.value) {
videoRef.value.srcObject = stream;
videoRef.value.setAttribute('playsinline', '');
videoRef.value.play();
}
startCaptureInterval();
})
.catch((err) => {
isCameraActive.value = false;
@@ -63,7 +70,24 @@ function startCamera() {
});
}
function clearCaptureInterval() {
if (captureIntervalId !== null) {
window.clearInterval(captureIntervalId);
captureIntervalId = null;
}
}
function startCaptureInterval() {
clearCaptureInterval();
captureIntervalId = window.setInterval(() => {
if (isCameraActive.value) {
getFrame();
}
}, camera.getImageCaptureDelay(false));
}
function stopCamera() {
clearCaptureInterval();
if (cameraStream.value) {
cameraStream.value.getTracks().forEach(track => track.stop());
cameraStream.value = null;
@@ -139,16 +163,8 @@ watch(() => camera.getZoom(), (newZoom) => {
});
onMounted(() => {
// Emit a picture every 10 seconds
if (!isCameraMounted.value) {
isCameraMounted.value = true;
setInterval(() => {
if (isCameraActive.value) {
getFrame();
}
}, camera.getImageCaptureDelay(false)); // Implement a method to get the delay based on camera settings
startCamera();
}
isCameraMounted.value = true;
startCamera();
});
onUnmounted(() => {
+5 -5
View File
@@ -82,11 +82,11 @@ export const REQUEST_QUEUE_CONFIG = Object.freeze({
// Per-method concurrency limits
concurrency: Object.freeze({
GET: 10,
POST: 1,
PATCH: 1,
PUT: 1,
DELETE: 1,
DEFAULT: 1,
POST: 4,
PATCH: 4,
PUT: 4,
DELETE: 4,
DEFAULT: 4,
}),
// Delay between queue starts (0 = no pacing delay)
spacingMs: 0,
+43 -9
View File
@@ -38,7 +38,7 @@ const requestQueueStateMutable = reactive({
});
const requestQueue = [];
const activeWorkersByMethod = {};
const activeWorkersByKey = {};
let activeWorkers = 0;
let requestIdCounter = 0;
let drainTimer = null;
@@ -71,6 +71,14 @@ const normalizeMethod = (value) => {
return value.trim().toUpperCase();
};
const normalizeQueueGroup = (value) => {
if (typeof value !== "string") {
return "";
}
return value.trim().toUpperCase();
};
const normalizeUrl = (value) => {
if (typeof value !== "string" || value.trim().length === 0) {
return "(unknown endpoint)";
@@ -231,10 +239,28 @@ const getMethodConcurrencyLimit = (method) => {
return Math.max(1, Number(configuredLimit) || 1);
};
const getJobConcurrencyKey = (job) => {
const queueGroup = normalizeQueueGroup(job.queueGroup);
if (queueGroup) {
return `GROUP:${queueGroup}`;
}
return `METHOD:${normalizeMethod(job.method)}`;
};
const getJobConcurrencyLimit = (job) => {
const configuredLimit = Number.parseInt(String(job.concurrencyLimit ?? ""), 10);
if (Number.isInteger(configuredLimit) && configuredLimit > 0) {
return configuredLimit;
}
return getMethodConcurrencyLimit(job.method);
};
const canRunJob = (job) => {
const method = normalizeMethod(job.method);
const activeForMethod = Number(activeWorkersByMethod[method] || 0);
return activeForMethod < getMethodConcurrencyLimit(method);
const concurrencyKey = getJobConcurrencyKey(job);
const activeForKey = Number(activeWorkersByKey[concurrencyKey] || 0);
return activeForKey < getJobConcurrencyLimit(job);
};
const getNextRunnableJobIndex = () => {
@@ -361,6 +387,7 @@ const upsertActiveRequest = (job, startedAt) => {
const nextActive = [...requestQueueStateMutable.activeRequests, {
id: job.id,
method: normalizeMethod(job.method),
queueGroup: normalizeQueueGroup(job.queueGroup) || null,
url: job.url,
queuedAt: job.enqueuedAt,
startedAt,
@@ -474,10 +501,12 @@ export const reportComponentMissingPermission = (permission, options = {}) => {
const runJob = (job) => {
const method = normalizeMethod(job.method);
const concurrencyKey = getJobConcurrencyKey(job);
const queueGroup = normalizeQueueGroup(job.queueGroup) || null;
const startedAt = Date.now();
activeWorkers += 1;
activeWorkersByMethod[method] = Number(activeWorkersByMethod[method] || 0) + 1;
activeWorkersByKey[concurrencyKey] = Number(activeWorkersByKey[concurrencyKey] || 0) + 1;
lastRequestStartedAt = startedAt;
upsertActiveRequest(job, startedAt);
syncQueueCounters();
@@ -490,6 +519,7 @@ const runJob = (job) => {
pushRecentRequest({
id: job.id,
method,
queueGroup,
url: job.url,
success: true,
statusCode: getResponseStatusCode(response),
@@ -524,6 +554,7 @@ const runJob = (job) => {
pushRecentRequest({
id: job.id,
method,
queueGroup,
url: job.url,
success: false,
statusCode,
@@ -537,6 +568,7 @@ const runJob = (job) => {
pushErrorRequest({
id: job.id,
method,
queueGroup,
url: job.url,
statusCode,
attemptCount: Math.max(1, Number(error?.__queueAttemptCount) || 1),
@@ -573,9 +605,9 @@ const runJob = (job) => {
})
.finally(() => {
activeWorkers -= 1;
activeWorkersByMethod[method] = Math.max(0, Number(activeWorkersByMethod[method] || 1) - 1);
if (activeWorkersByMethod[method] === 0) {
delete activeWorkersByMethod[method];
activeWorkersByKey[concurrencyKey] = Math.max(0, Number(activeWorkersByKey[concurrencyKey] || 1) - 1);
if (activeWorkersByKey[concurrencyKey] === 0) {
delete activeWorkersByKey[concurrencyKey];
}
removeActiveRequest(job.id);
syncQueueCounters();
@@ -625,6 +657,8 @@ export const enqueueRequest = (requestFactory, options = {}) => {
requestData: options.requestData || null,
retryByStatusCode: options.retryByStatusCode || null,
shouldRetry: typeof options.shouldRetry === "function" ? options.shouldRetry : null,
queueGroup: normalizeQueueGroup(options.queueGroup),
concurrencyLimit: options.concurrencyLimit || null,
});
requestQueueStateMutable.batchTotal += 1;
syncQueueCounters();
@@ -649,7 +683,7 @@ export const __resetRequestQueueForTests = () => {
requestQueue.length = 0;
activeWorkers = 0;
requestIdCounter = 0;
Object.keys(activeWorkersByMethod).forEach((method) => delete activeWorkersByMethod[method]);
Object.keys(activeWorkersByKey).forEach((key) => delete activeWorkersByKey[key]);
lastRequestStartedAt = 0;
clearDrainTimer();
+154
View File
@@ -238,4 +238,158 @@ describe("authenticatedRequest", () => {
expect(requestQueueState.batchCompleted).toBe(3);
expect(requestQueueState.batchFailed).toBe(0);
});
it("keeps self-serve hardware commands serial without blocking ordinary POST requests", async () => {
const hardwareOne = createDeferred();
const hardwareTwo = createDeferred();
const ordinaryPost = createDeferred();
axiosMock.mockImplementation(({ url }) => {
if (url.includes("/modules/self-serve/lane/command")) {
return hardwareOne.promise;
}
if (url.includes("/modules/self-serve/lane/relay/machine/enable")) {
return hardwareTwo.promise;
}
if (url.endsWith("/orders")) {
return ordinaryPost.promise;
}
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
});
const request1 = authenticatedRequest("/modules/self-serve/lane/command", "post", {
lane_id: 7,
command: "STOP",
});
const request2 = authenticatedRequest("/modules/self-serve/lane/relay/machine/enable", "post", {
lane_id: 7,
});
const request3 = authenticatedRequest("/orders", "post", {
reference: "ordinary-post",
});
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(2);
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
expect.stringContaining("/modules/self-serve/lane/command"),
expect.stringMatching(/\/orders$/),
]);
expect(requestQueueState.active).toBe(2);
expect(requestQueueState.pending).toBe(1);
ordinaryPost.resolve({ status: 200, data: { id: 1 } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(2);
hardwareOne.resolve({ status: 200, data: { ok: true } });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(3);
expect(axiosMock.mock.calls[2][0].url).toContain("/modules/self-serve/lane/relay/machine/enable");
hardwareTwo.resolve({ status: 200, data: { ok: true } });
await expect(Promise.all([request1, request2, request3])).resolves.toHaveLength(3);
});
it("keeps POS scanner and Stripe invoice requests from blocking ordinary POS order mutations", async () => {
const scannerOne = createDeferred();
const scannerTwo = createDeferred();
const stripeInvoice = createDeferred();
const orderCreate = createDeferred();
const scannerResponses = [scannerOne, scannerTwo];
axiosMock.mockImplementation(({ url }) => {
if (url.includes("/modules/scanner/lpr")) {
return scannerResponses.shift()?.promise;
}
if (url.includes("/modules/stripe/invoice")) {
return stripeInvoice.promise;
}
if (url.endsWith("/orders")) {
return orderCreate.promise;
}
return Promise.reject(new Error(`Unexpected request URL: ${url}`));
});
const request1 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-one" });
const request2 = authenticatedRequest("/modules/scanner/lpr", "post", { base64_image: "image-two" });
const request3 = authenticatedRequest("/modules/stripe/invoice", "post", { order_id: 42, email: "a@example.test" });
const request4 = authenticatedRequest("/orders", "post", { department_id: 3, reference: "pos-order" });
await flushManyMicrotasks();
expect(axiosMock).toHaveBeenCalledTimes(4);
expect(axiosMock.mock.calls.map(([config]) => config.url)).toEqual([
expect.stringContaining("/modules/scanner/lpr"),
expect.stringContaining("/modules/scanner/lpr"),
expect.stringContaining("/modules/stripe/invoice"),
expect.stringMatching(/\/orders$/),
]);
expect(requestQueueState.active).toBe(4);
expect(requestQueueState.pending).toBe(0);
expect(requestQueueState.activeRequests.map((request) => request.queueGroup)).toEqual([
"POS_SCANNER",
"POS_SCANNER",
"POS_STRIPE",
null,
]);
scannerOne.resolve({ status: 200, data: { success: true } });
scannerTwo.resolve({ status: 200, data: { success: true } });
stripeInvoice.resolve({ status: 200, data: { id: "in_1" } });
orderCreate.resolve({ status: 200, data: { id: 42 } });
await expect(Promise.all([request1, request2, request3, request4])).resolves.toHaveLength(4);
});
it("does not retry POS Stripe invoice mutations", async () => {
__configureRequestQueueForTests({
retryByStatusCode: { 500: 1 },
retryDelayBaseMs: 0,
retryDelayMaxMs: 0,
retryDelayJitterMs: 0,
});
axiosMock.mockRejectedValue({
response: {
status: 500,
data: { message: "temporary Stripe failure" },
},
message: "Request failed",
});
await expect(
authenticatedRequest("/modules/stripe/invoice", "post", {
order_id: 42,
email: "a@example.test",
})
).rejects.toMatchObject({ response: { status: 500 } });
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.batchFailed).toBe(1);
});
it("does not retry non-idempotent self-serve hardware mutations", async () => {
__configureRequestQueueForTests({
retryByStatusCode: { 500: 1 },
retryDelayBaseMs: 0,
retryDelayMaxMs: 0,
retryDelayJitterMs: 0,
});
axiosMock.mockRejectedValue({
response: {
status: 500,
data: { message: "temporary backend failure" },
},
message: "Request failed",
});
await expect(
authenticatedRequest("/modules/self-serve/lane/command", "post", {
lane_id: 7,
command: "STOP",
})
).rejects.toMatchObject({ response: { status: 500 } });
expect(axiosMock).toHaveBeenCalledTimes(1);
expect(requestQueueState.batchFailed).toBe(1);
});
});
+38
View File
@@ -228,6 +228,44 @@ describe("axios request queue interceptor", () => {
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5, 6]);
});
it("allows multiple ordinary POST requests by default to use available PHP workers", async () => {
__resetAxiosRequestQueueInstallerForTests();
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
installAxiosRequestQueue();
const allDeferred = Array.from({ length: 5 }, () => createDeferred());
const pendingAdapters = [...allDeferred];
const adapter = () => pendingAdapters.shift()?.promise;
const requests = Array.from({ length: 5 }, (_, index) =>
axios({
url: `/queue-post-${index + 1}`,
method: "POST",
adapter,
})
);
await flushMicrotasks();
expect(requestQueueState.active).toBe(4);
expect(requestQueueState.pending).toBe(1);
for (let index = 0; index < 4; index += 1) {
allDeferred[index].resolve(createResponse({ id: index + 1 }));
}
await flushManyMicrotasks();
expect(requestQueueState.active).toBe(1);
expect(requestQueueState.pending).toBe(0);
allDeferred[4].resolve(createResponse({ id: 5 }));
const responses = await Promise.all(requests);
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5]);
});
it("retries configured response status codes", async () => {
__configureRequestQueueForTests({
maxConcurrentGet: 1,
+223
View File
@@ -0,0 +1,223 @@
// @vitest-environment jsdom
import { flushPromises } from "@vue/test-utils";
import { 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";
const mocks = vi.hoisted(() => ({
request: vi.fn(),
setTransparency: vi.fn(),
setBackgroundColor: vi.fn(),
setOverflow: vi.fn(),
cameraSetLatestImage: vi.fn(),
cameraSetLastSuccess: vi.fn(),
cameraHasDelayAfterSuccessPassed: vi.fn(() => true),
}));
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 activeVehicleIndex = ref(1);
const activeVehicle = ref({ reg: "" });
const latestImage = ref(null);
return {
attachments: {
base64: ref([]),
},
camera: {
latestImage,
setLatestImage: mocks.cameraSetLatestImage,
hasDelayAfterSuccessPassed: mocks.cameraHasDelayAfterSuccessPassed,
setLastSuccess: mocks.cameraSetLastSuccess,
},
manualInput,
sounds: {
list: ref({ onAfterSuccessfulScan: "scan" }),
play: vi.fn(),
},
transactionHistoryView,
vehicles: {
activeVehicleIndex,
getActiveVehicle: () => activeVehicle.value,
select: vi.fn(),
setActiveVehicleIndex: vi.fn((nextIndex) => {
activeVehicleIndex.value = nextIndex;
}),
vehicle_1: activeVehicle,
vehicle_2: ref({ reg: "" }),
vehicle_3: ref({ reg: "" }),
},
views: {
attachmentView: ref(false),
isAnyActive: ref(false),
},
};
});
vi.mock("@/components/viewport/page/templates/scanner/graphics/ScannerCamera.vue", () => ({
default: {
name: "ScannerCamera",
emits: ["update:frame"],
template: `
<div>
<button data-testid="camera-frame-a" @click="$emit('update:frame', 'frame-a')" />
<button data-testid="camera-frame-b" @click="$emit('update:frame', 'frame-b')" />
<button data-testid="camera-frame-c" @click="$emit('update:frame', 'frame-c')" />
</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: { template: "<div><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);
};
describe("POS mobile camera LPR", () => {
beforeEach(() => {
mocks.request.mockReset();
mocks.cameraSetLatestImage.mockReset();
mocks.cameraSetLastSuccess.mockReset();
mocks.cameraHasDelayAfterSuccessPassed.mockReset();
mocks.cameraHasDelayAfterSuccessPassed.mockReturnValue(true);
resolveImageImmediately();
});
it("does not enqueue overlapping scanner requests while a camera frame is still being parsed", async () => {
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);
expect(mocks.request).toHaveBeenLastCalledWith("/modules/scanner/lpr", "POST", {
base64_image: "frame-a",
});
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
resolveFirstRequest({ data: { success: false } });
await flushPromises();
await wrapper.get('[data-testid="camera-frame-c"]').trigger("click");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
expect(mocks.request).toHaveBeenLastCalledWith("/modules/scanner/lpr", "POST", {
base64_image: "frame-c",
});
});
});