fix(scan): speed up QR recognition and keep scanning
This commit is contained in:
parent
f10020705c
commit
c8da9daf94
5 changed files with 290 additions and 88 deletions
|
|
@ -1,14 +1,73 @@
|
|||
import { StrictMode } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import ScanPage from "./page";
|
||||
import * as qrDetect from "@/lib/scan/qr-detect";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ScanPage", () => {
|
||||
it("keeps the latest scanned note when an earlier lookup finishes late", async () => {
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
vi.stubGlobal("navigator", {
|
||||
mediaDevices: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [] }) },
|
||||
});
|
||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
let onScan: ((raw: string) => void) | undefined;
|
||||
vi.spyOn(qrDetect, "scanVideoFrames").mockImplementation((_video, _canvas, onFound) => {
|
||||
onScan = onFound;
|
||||
return new Promise(() => {});
|
||||
});
|
||||
const pending = new Map<string, (response: Response) => void>();
|
||||
vi.stubGlobal("fetch", (path: string) => new Promise<Response>((resolve) => {
|
||||
pending.set(new URL(path, "http://localhost").searchParams.get("number")!, resolve);
|
||||
}));
|
||||
const resolved = (number: string) => new Response(JSON.stringify({
|
||||
id: number,
|
||||
delivery_note_number: number,
|
||||
status: "draft",
|
||||
customer_name: "Customer",
|
||||
company_name: "Company",
|
||||
customer_po: "",
|
||||
delivery_date: "",
|
||||
signed_copy: "",
|
||||
item_count: 1,
|
||||
}), { status: 200 });
|
||||
render(<ScanPage />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start camera" }));
|
||||
await waitFor(() => expect(onScan).toBeDefined());
|
||||
act(() => onScan!("DN26100401"));
|
||||
act(() => onScan!("DN26100402"));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Delivery note recognised");
|
||||
|
||||
await act(async () => pending.get("DN26100402")!(resolved("DN26100402")));
|
||||
expect(screen.getByTestId("scan-note")).toHaveTextContent("DN26100402");
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
await act(async () => pending.get("DN26100401")!(resolved("DN26100401")));
|
||||
expect(screen.getByTestId("scan-note")).toHaveTextContent("DN26100402");
|
||||
expect(screen.getByTestId("scan-note")).not.toHaveTextContent("DN26100401");
|
||||
});
|
||||
|
||||
it("requests a sharp rear-camera feed instead of relying on low-resolution defaults", async () => {
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
const getUserMedia = vi.fn().mockRejectedValue(new Error("Camera unavailable in test"));
|
||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||
render(<ScanPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start camera" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("scan-toast")).toHaveTextContent("Camera unavailable in test"));
|
||||
const constraints = getUserMedia.mock.calls[0][0];
|
||||
expect(constraints.video.facingMode).toEqual({ ideal: "environment" });
|
||||
expect(constraints.video.width.ideal).toBeGreaterThanOrEqual(1920);
|
||||
expect(constraints.video.height.ideal).toBeGreaterThanOrEqual(1080);
|
||||
expect(constraints.audio).toBe(false);
|
||||
});
|
||||
|
||||
it("renders camera controls without a render loop and remains usable after typing", () => {
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
vi.stubGlobal("navigator", {
|
||||
|
|
|
|||
|
|
@ -133,11 +133,13 @@ export default function ScanPage() {
|
|||
// Last code we accepted, and when — so holding the camera on one
|
||||
// note for 3s does not fire the lookup over and over.
|
||||
const lastCodeRef = React.useRef<{ code: string; at: number } | null>(null);
|
||||
const lookupSequenceRef = React.useRef(0);
|
||||
|
||||
// Stop the camera on unmount. Leaving a getUserMedia stream running
|
||||
// keeps the camera light on after the operator navigates away.
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
lookupSequenceRef.current++;
|
||||
abortRef.current?.abort();
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
void wakeRef.current?.release();
|
||||
|
|
@ -228,11 +230,13 @@ export default function ScanPage() {
|
|||
const last = lastCodeRef.current;
|
||||
if (last && last.code === number && now - last.at < DEBOUNCE_MS) return;
|
||||
lastCodeRef.current = { code: number, at: now };
|
||||
const sequence = ++lookupSequenceRef.current;
|
||||
setBusy("lookup");
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/v1/delivery-notes/resolve?number=${encodeURIComponent(number)}`,
|
||||
);
|
||||
if (sequence !== lookupSequenceRef.current) return;
|
||||
setNote(data as Resolved);
|
||||
// The camera deliberately KEEPS RUNNING. The operator often has
|
||||
// a stack of notes; scanning the next one should not need a
|
||||
|
|
@ -241,11 +245,12 @@ export default function ScanPage() {
|
|||
// the same paper must not re-fire, hence the debounce above.
|
||||
flash("ok", `${number} loaded`);
|
||||
} catch (err) {
|
||||
if (sequence !== lookupSequenceRef.current) return;
|
||||
const msg = err instanceof Error ? err.message : "Lookup failed";
|
||||
setNote(null);
|
||||
flash("err", msg);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
if (sequence === lookupSequenceRef.current) setBusy(null);
|
||||
}
|
||||
},
|
||||
[api, flash],
|
||||
|
|
@ -259,7 +264,12 @@ export default function ScanPage() {
|
|||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: "environment" } },
|
||||
video: {
|
||||
facingMode: { ideal: "environment" },
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
frameRate: { ideal: 30 },
|
||||
},
|
||||
audio: false,
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
|
@ -287,7 +297,7 @@ export default function ScanPage() {
|
|||
const msg = err instanceof Error ? err.message : String(err);
|
||||
flash("err", `Camera failed to start: ${msg}`);
|
||||
}
|
||||
}, [support, flash, lookup]);
|
||||
}, [support, flash, lookup, requestWakeLock]);
|
||||
|
||||
const runAction = React.useCallback(
|
||||
async (key: string, fn: () => Promise<void>) => {
|
||||
|
|
@ -374,6 +384,13 @@ export default function ScanPage() {
|
|||
</div>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
{busy === "lookup" ? (
|
||||
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Delivery note recognised — loading details…
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* ---- the note card, or the scanner controls ---- */}
|
||||
{note ? (
|
||||
<section className="flex flex-col gap-4" data-testid="scan-note">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
decodeQrFromCanvas,
|
||||
detectScannerSupport,
|
||||
MAX_SCAN_EDGE,
|
||||
SCAN_REGIONS,
|
||||
} from "./qr-detect";
|
||||
|
||||
/**
|
||||
|
|
@ -246,16 +245,3 @@ describe("scan resolution floor", () => {
|
|||
expect(pxPerModule).toBeGreaterThan(2.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SCAN_REGIONS", () => {
|
||||
it("checks the bottom band every frame and the whole frame periodically", () => {
|
||||
// The bottom band is where the delivery-note QR actually lives, so
|
||||
// it is the cheap common case. The full frame is the safety net
|
||||
// for a tilted or rotated page and must not run every frame.
|
||||
const [band, whole] = SCAN_REGIONS;
|
||||
expect(band.everyNthFrame).toBe(1);
|
||||
expect(band.region.sy).toBeGreaterThan(0.4);
|
||||
expect(whole.everyNthFrame).toBeGreaterThan(1);
|
||||
expect(whole.region).toEqual({ sx: 0, sy: 0, sw: 1, sh: 1 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -152,48 +152,38 @@ export async function decodeQrFromCanvas(
|
|||
return result?.data ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest edge, in pixels, that a region is rasterised to before
|
||||
* decoding.
|
||||
*
|
||||
* This number is the whole ballgame and it was badly wrong at 640.
|
||||
* The printed QR is 28pt on a 210mm page — 4.7% of the page width. So
|
||||
* a 640px-wide frame looking at a full A4 sheet renders that QR at
|
||||
* ~30px total, which is about 1 pixel per module. Measured against
|
||||
* the real rendered page, jsQR cannot read that at ANY crop:
|
||||
*
|
||||
* cap px/module whole frame bottom-45% ROI
|
||||
* 640 1.04 MISS MISS
|
||||
* 1280 2.08 miss miss
|
||||
* 1600 2.60 HIT 69ms HIT 28ms
|
||||
* 1920 3.12 HIT 98ms HIT 39ms
|
||||
* 2560 4.16 HIT 171ms HIT 39ms
|
||||
*
|
||||
* So a scanner that downscaled to 640 could simply never read its own
|
||||
* delivery notes at a normal framing. Above ~2.5px per module it is
|
||||
* reliable. 1920 keeps ~3.1px/module with room for a smaller print,
|
||||
* and caps the worst-case decode cost on a high-res sensor.
|
||||
*/
|
||||
// Keep enough detail in the whole-frame fallback to read older, smaller
|
||||
// printed QRs. Cropping the aiming box is cheaper than shrinking the
|
||||
// entire page and losing the QR's individual modules.
|
||||
export const MAX_SCAN_EDGE = 1920;
|
||||
const GUIDE_SCAN_EDGE = 768;
|
||||
const SCAN_INTERVAL_MS = 100;
|
||||
|
||||
/**
|
||||
* Where to look, cheapest first.
|
||||
*
|
||||
* The delivery-note QR sits in the bottom band of the page, so the
|
||||
* lower 45% of the frame finds it at roughly a third of the cost of
|
||||
* the full frame (39ms vs 98ms at 1920px). It is an ACCELERATOR, not a
|
||||
* restriction — a tilted or upside-down page puts the code outside it,
|
||||
* so the full frame is scanned on a slower rotation as the safety net.
|
||||
*/
|
||||
export type ScanRegion = { sx: number; sy: number; sw: number; sh: number };
|
||||
|
||||
export const SCAN_REGIONS: Array<{ region: ScanRegion; everyNthFrame: number }> = [
|
||||
// Bottom 45% — where the code actually is, scanned every frame.
|
||||
{ region: { sx: 0, sy: 0.55, sw: 1, sh: 0.45 }, everyNthFrame: 1 },
|
||||
// Whole frame, every 6th frame, so a tilted page still resolves
|
||||
// without paying the full-frame cost on every tick.
|
||||
{ region: { sx: 0, sy: 0, sw: 1, sh: 1 }, everyNthFrame: 6 },
|
||||
];
|
||||
function scanRegions(width: number, height: number) {
|
||||
// The preview uses object-cover in a square/4:3 viewfinder. A central
|
||||
// square based on the shorter source edge stays inside the visible
|
||||
// aiming box for both portrait and landscape camera streams.
|
||||
const side = Math.round(Math.min(width, height) * 0.64);
|
||||
return [
|
||||
{
|
||||
region: {
|
||||
sx: (width - side) / (2 * width),
|
||||
sy: (height - side) / (2 * height),
|
||||
sw: side / width,
|
||||
sh: side / height,
|
||||
},
|
||||
everyNthFrame: 1,
|
||||
maxEdge: GUIDE_SCAN_EDGE,
|
||||
},
|
||||
{
|
||||
region: { sx: 0, sy: 0, sw: 1, sh: 1 },
|
||||
everyNthFrame: 3,
|
||||
maxEdge: MAX_SCAN_EDGE,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Rasterise one region of the current video frame into the canvas. */
|
||||
function drawRegion(
|
||||
|
|
@ -201,12 +191,13 @@ function drawRegion(
|
|||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
region: ScanRegion,
|
||||
maxEdge: number,
|
||||
): { w: number; h: number } {
|
||||
const srcW = video.videoWidth;
|
||||
const srcH = video.videoHeight;
|
||||
const cropW = Math.max(1, Math.round(srcW * region.sw));
|
||||
const cropH = Math.max(1, Math.round(srcH * region.sh));
|
||||
const scale = Math.min(1, MAX_SCAN_EDGE / cropW);
|
||||
const scale = Math.min(1, maxEdge / Math.max(cropW, cropH));
|
||||
const w = Math.max(1, Math.round(cropW * scale));
|
||||
const h = Math.max(1, Math.round(cropH * scale));
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
|
|
@ -226,18 +217,11 @@ function drawRegion(
|
|||
}
|
||||
|
||||
/**
|
||||
* Run the detector over a video stream until it finds something.
|
||||
*
|
||||
* Driven by requestAnimationFrame rather than a timer, which matters
|
||||
* for three reasons:
|
||||
*
|
||||
* - rAF fires immediately after a painted frame, so the frame handed
|
||||
* to the decoder is a fresh one. A `setInterval` samples on its own
|
||||
* schedule and routinely decodes the same stale frame twice.
|
||||
* - There is no 120 ms quantum. The old loop put a floor of up to
|
||||
* 120 ms on every detection, before any decode time.
|
||||
* - `readyState` stalls recover on the next rAF instead of waiting
|
||||
* out a timer tick.
|
||||
* Keep scanning until aborted, reporting each new code immediately.
|
||||
* Sample at most ten times a second so decoding does not monopolise
|
||||
* the phone's main thread or prevent the camera preview from updating.
|
||||
* Start in the aiming box; periodically check the whole image on a
|
||||
* miss so off-centre and older small printed QRs are still recognised.
|
||||
*
|
||||
* Rejects nothing on `signal` — it resolves, so the caller can `await`
|
||||
* a clean stop. A single bad frame must never kill the loop.
|
||||
|
|
@ -256,6 +240,9 @@ export function scanVideoFrames(
|
|||
let stopped = false;
|
||||
let frame = 0;
|
||||
let rafId = 0;
|
||||
let lastScanAt = -Infinity;
|
||||
let lastRaw: string | null = null;
|
||||
let lastSeenAt = -Infinity;
|
||||
// A decode is async (the native detector is), so guard against
|
||||
// overlapping ticks stacking up on a slow device.
|
||||
let busy = false;
|
||||
|
|
@ -270,34 +257,41 @@ export function scanVideoFrames(
|
|||
const onAbort = () => finish();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const tick = async () => {
|
||||
const tick = async (timestamp: number) => {
|
||||
if (stopped) return;
|
||||
rafId = requestAnimationFrame(() => void tick());
|
||||
rafId = requestAnimationFrame((time) => void tick(time));
|
||||
if (busy || signal.aborted) return;
|
||||
if (video.readyState < 2 || !video.videoWidth) return;
|
||||
if (timestamp - lastScanAt < SCAN_INTERVAL_MS) return;
|
||||
lastScanAt = timestamp;
|
||||
|
||||
for (const entry of SCAN_REGIONS) {
|
||||
if (stopped) return;
|
||||
if (entry.everyNthFrame > 1 && frame % entry.everyNthFrame !== 0) continue;
|
||||
busy = true;
|
||||
try {
|
||||
const { w, h } = drawRegion(video, canvas, ctx, entry.region);
|
||||
const raw = await decodeQrFromCanvas(canvas, ctx, w, h);
|
||||
if (raw && !stopped) {
|
||||
onFound(raw);
|
||||
finish();
|
||||
return;
|
||||
busy = true;
|
||||
try {
|
||||
for (const entry of scanRegions(video.videoWidth, video.videoHeight)) {
|
||||
if (stopped) return;
|
||||
if (frame % entry.everyNthFrame !== 0) continue;
|
||||
try {
|
||||
const { w, h } = drawRegion(video, canvas, ctx, entry.region, entry.maxEdge);
|
||||
const raw = await decodeQrFromCanvas(canvas, ctx, w, h);
|
||||
if (raw && !stopped) {
|
||||
// Holding one paper still must not flood the API/toasts.
|
||||
// A different note is accepted on the very next sample.
|
||||
if (raw !== lastRaw || timestamp - lastSeenAt > 1000) onFound(raw);
|
||||
lastRaw = raw;
|
||||
lastSeenAt = timestamp;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* a single bad frame must not kill the loop */
|
||||
}
|
||||
} catch {
|
||||
/* a single bad frame must not kill the loop */
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
frame++;
|
||||
}
|
||||
frame++;
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(() => void tick());
|
||||
rafId = requestAnimationFrame((time) => void tick(time));
|
||||
if (signal.aborted) finish();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
146
src/lib/scan/scan-video.test.ts
Normal file
146
src/lib/scan/scan-video.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildQrMatrix } from "@/lib/pdf/qr";
|
||||
import { scanVideoFrames } from "./qr-detect";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
// A real QR raster passed through the production decoder. Only the
|
||||
// camera/canvas transport and animation clock are replaced.
|
||||
function scanner(width = 1920, height = 1080) {
|
||||
let scene = new Uint8ClampedArray(width * height * 4).fill(255);
|
||||
let decodedPixels = 0;
|
||||
let frameId = 0;
|
||||
let timestamp = 0;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.set(++frameId, callback);
|
||||
return frameId;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
|
||||
const canvas = { width: 0, height: 0 } as HTMLCanvasElement;
|
||||
let drawn = new Uint8ClampedArray();
|
||||
const ctx = {
|
||||
drawImage(_video: unknown, sx: number, sy: number, sw: number, sh: number, _dx: number, _dy: number, dw: number, dh: number) {
|
||||
drawn = new Uint8ClampedArray(dw * dh * 4);
|
||||
for (let y = 0; y < dh; y++) {
|
||||
for (let x = 0; x < dw; x++) {
|
||||
const from = (Math.min(height - 1, Math.floor(sy + y * sh / dh)) * width + Math.min(width - 1, Math.floor(sx + x * sw / dw))) * 4;
|
||||
drawn.set(scene.subarray(from, from + 4), (y * dw + x) * 4);
|
||||
}
|
||||
}
|
||||
},
|
||||
getImageData() {
|
||||
decodedPixels += canvas.width * canvas.height;
|
||||
return { data: drawn, width: canvas.width, height: canvas.height };
|
||||
},
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
canvas.getContext = (() => ctx) as unknown as typeof canvas.getContext;
|
||||
const video = { readyState: 2, videoWidth: width, videoHeight: height } as HTMLVideoElement;
|
||||
const found: string[] = [];
|
||||
const controller = new AbortController();
|
||||
const done = scanVideoFrames(video, canvas, (raw) => found.push(raw), controller.signal);
|
||||
|
||||
return {
|
||||
found,
|
||||
get decodedPixels() { return decodedPixels; },
|
||||
show(number: string, x = Math.round(width / 2 - 58), y = Math.round(height / 2 - 58)) {
|
||||
scene = new Uint8ClampedArray(width * height * 4).fill(255);
|
||||
const qr = buildQrMatrix(number);
|
||||
for (let i = 0; i < qr.modules.length; i++) {
|
||||
if (!qr.modules[i]) continue;
|
||||
const mx = (i % qr.size) + qr.quietZone;
|
||||
const my = Math.floor(i / qr.size) + qr.quietZone;
|
||||
for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 4; dx++) {
|
||||
const offset = ((y + my * 4 + dy) * width + x + mx * 4 + dx) * 4;
|
||||
scene[offset] = scene[offset + 1] = scene[offset + 2] = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
async frame(elapsed = 100) {
|
||||
timestamp += elapsed;
|
||||
const callbacks = [...frames.values()];
|
||||
frames.clear();
|
||||
callbacks.forEach((callback) => callback(timestamp));
|
||||
// Flush the native-detector probe and asynchronous decode continuation.
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
},
|
||||
async stop() { controller.abort(); await done; },
|
||||
};
|
||||
}
|
||||
|
||||
describe("live QR scanning", () => {
|
||||
it("leaves time for the preview instead of decoding every display refresh", async () => {
|
||||
const camera = scanner();
|
||||
try {
|
||||
camera.show("DN26100401");
|
||||
await camera.frame();
|
||||
const initialPixels = camera.decodedPixels;
|
||||
for (let i = 0; i < 4; i++) await camera.frame(16);
|
||||
expect(camera.decodedPixels).toBe(initialPixels);
|
||||
camera.show("DN26100402");
|
||||
await camera.frame();
|
||||
expect(camera.found).toEqual(["DN26100401", "DN26100402"]);
|
||||
} finally { await camera.stop(); }
|
||||
});
|
||||
|
||||
it("stops processing camera frames when aborted", async () => {
|
||||
const camera = scanner();
|
||||
camera.show("DN26100401");
|
||||
await camera.stop();
|
||||
await camera.frame();
|
||||
expect(camera.found).toEqual([]);
|
||||
expect(camera.decodedPixels).toBe(0);
|
||||
});
|
||||
|
||||
it("reads the QR inside the aiming box without decoding the whole HD frame", async () => {
|
||||
const camera = scanner();
|
||||
try {
|
||||
camera.show("DN26100401");
|
||||
await camera.frame();
|
||||
expect(camera.found).toEqual(["DN26100401"]);
|
||||
expect(camera.decodedPixels).toBeLessThan(600_000);
|
||||
} finally { await camera.stop(); }
|
||||
});
|
||||
|
||||
it("keeps reading subsequent notes without restarting the camera", async () => {
|
||||
const camera = scanner();
|
||||
try {
|
||||
camera.show("DN26100401");
|
||||
await camera.frame();
|
||||
camera.show("DN26100402");
|
||||
await camera.frame();
|
||||
expect(camera.found).toEqual(["DN26100401", "DN26100402"]);
|
||||
} finally { await camera.stop(); }
|
||||
});
|
||||
|
||||
it("does not repeatedly report the paper held in front of the camera", async () => {
|
||||
const camera = scanner();
|
||||
try {
|
||||
camera.show("DN26100401");
|
||||
for (let i = 0; i < 5; i++) await camera.frame();
|
||||
expect(camera.found).toEqual(["DN26100401"]);
|
||||
camera.show("DN26100402");
|
||||
await camera.frame();
|
||||
expect(camera.found).toEqual(["DN26100401", "DN26100402"]);
|
||||
} finally { await camera.stop(); }
|
||||
});
|
||||
|
||||
it("also finds a QR outside the aiming box", async () => {
|
||||
const camera = scanner();
|
||||
try {
|
||||
camera.show("DN26100401", 100, 80);
|
||||
for (let i = 0; i < 3; i++) await camera.frame();
|
||||
expect(camera.found).toEqual(["DN26100401"]);
|
||||
} finally { await camera.stop(); }
|
||||
});
|
||||
|
||||
it("reads a QR in the centre of a portrait camera feed", async () => {
|
||||
const camera = scanner(1080, 1920);
|
||||
try {
|
||||
camera.show("DN26100401");
|
||||
await camera.frame();
|
||||
expect(camera.found).toEqual(["DN26100401"]);
|
||||
expect(camera.decodedPixels).toBeLessThan(600_000);
|
||||
} finally { await camera.stop(); }
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue