Files
pleno-vue/tests/unit/lpr-frame-capture.spec.js

892 lines
26 KiB
JavaScript

// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import {
buildLPRFrameFingerprint,
buildLPRFrameFingerprintKey,
captureVideoFrameBlobForLPR,
getCenteredFocusSourceRect,
getConstrainedFrameSize,
getLPRFrameSourceRect,
getLPRFrameTargetSize,
getVisualFingerprintDistance,
getVisibleCoverSourceRect,
isVideoFrameReadyForLPR,
LPR_CAMERA_VIDEO_HEIGHT,
LPR_CAMERA_VIDEO_WIDTH,
LPR_FRAME_FILE_NAME,
LPR_FRAME_FOCUS_ASPECT_RATIO,
LPR_FRAME_JPEG_QUALITY,
LPR_FRAME_MIME_TYPE,
LPR_FRAME_SCANNER_FOCUS_SCALE,
LPR_FRAME_SCANNER_MAX_SIZE,
LPR_FRAME_SCANNER_JPEG_QUALITY,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
describe("LPR frame capture", () => {
it("constrains 16:9 camera frames before encoding", () => {
expect(getConstrainedFrameSize(1920, 1080)).toEqual({
width: 1024,
height: 576,
});
});
it("uses the full camera frame when rendered video dimensions are unavailable", () => {
expect(getVisibleCoverSourceRect(1280, 720, 0, 0)).toEqual({
height: 720,
width: 1280,
x: 0,
y: 0,
});
});
it("checks video readiness before camera LPR capture starts", () => {
expect(
isVideoFrameReadyForLPR({
readyState: 1,
videoHeight: 720,
videoWidth: 1280,
})
).toBe(false);
expect(
isVideoFrameReadyForLPR({
readyState: 2,
videoHeight: 0,
videoWidth: 1280,
})
).toBe(false);
expect(
isVideoFrameReadyForLPR({
readyState: 2,
videoHeight: 720,
videoWidth: 1280,
})
).toBe(true);
});
it("crops a landscape camera frame to the visible portrait preview before encoding", () => {
expect(getVisibleCoverSourceRect(1280, 720, 390, 844)).toEqual({
height: 720,
width: 333,
x: 474,
y: 0,
});
expect(getConstrainedFrameSize(333, 720)).toEqual({
width: 266,
height: 576,
});
});
it("crops the visible preview to the centered scanner focus area before encoding", () => {
expect(
getCenteredFocusSourceRect({
height: 1080,
width: 1920,
x: 0,
y: 0,
})
).toEqual({
height: 1080,
width: 1920,
x: 0,
y: 0,
});
expect(getLPRFrameSourceRect(1280, 720, 390, 844)).toEqual({
height: 159,
width: 283,
x: 499,
y: 281,
});
expect(getLPRFrameSourceRect(1280, 720, 390, 844, { focusCrop: false })).toEqual({
height: 720,
width: 333,
x: 474,
y: 0,
});
});
it("keeps the live stream cap aligned with attachment preview max while reducing scanner crop pixels", () => {
expect(LPR_CAMERA_VIDEO_WIDTH).toBe(1024);
expect(LPR_CAMERA_VIDEO_HEIGHT).toBe(576);
expect(getLPRFrameSourceRect(LPR_CAMERA_VIDEO_WIDTH, LPR_CAMERA_VIDEO_HEIGHT, 390, 844)).toEqual({
height: 128,
width: 226,
x: 399,
y: 224,
});
expect(
getLPRFrameTargetSize(
getLPRFrameSourceRect(LPR_CAMERA_VIDEO_WIDTH, LPR_CAMERA_VIDEO_HEIGHT, 390, 844, { focusCrop: false }),
{ focusCrop: false }
)
).toEqual({
height: 576,
width: 266,
});
});
it("can tighten the centered scanner crop while keeping a margin around the outline", () => {
expect(LPR_FRAME_FOCUS_ASPECT_RATIO).toBe(16 / 9);
expect(LPR_FRAME_SCANNER_FOCUS_SCALE).toBe(0.85);
expect(
getCenteredFocusSourceRect(
{
height: 720,
width: 333,
x: 474,
y: 0,
},
LPR_FRAME_FOCUS_ASPECT_RATIO,
LPR_FRAME_SCANNER_FOCUS_SCALE
)
).toEqual({
height: 159,
width: 283,
x: 499,
y: 281,
});
expect(getLPRFrameSourceRect(1280, 720, 390, 844, { focusScale: 1 })).toEqual({
height: 187,
width: 333,
x: 474,
y: 267,
});
});
it("anchors the scanner crop to a viewport focus rect when available", () => {
expect(
getLPRFrameSourceRect(1280, 720, 390, 844, {
focusViewportRect: {
height: 234,
width: 234,
x: 78,
y: 205,
},
})
).toEqual({
height: 159,
width: 283,
x: 499,
y: 195,
});
expect(
getLPRFrameSourceRect(1280, 720, 390, 844, {
focusViewportRect: {
height: 234,
width: 234,
x: 78,
y: -100,
},
})
).toEqual({
height: 159,
width: 283,
x: 499,
y: 0,
});
});
it("uses a smaller max size for scanner LPR frames than preview frames", () => {
expect(
getLPRFrameTargetSize({
height: 1080,
width: 1440,
x: 240,
y: 0,
})
).toEqual({
height: 288,
width: LPR_FRAME_SCANNER_MAX_SIZE,
});
expect(
getLPRFrameTargetSize(
{
height: 720,
width: 333,
x: 474,
y: 0,
},
{ focusCrop: false }
)
).toEqual({
height: 576,
width: 266,
});
});
it("captures a focused downscaled JPEG blob frame for multipart LPR uploads", async () => {
const blob = new Blob(["small-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const toBlob = vi.fn((callback) => callback(blob));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
captureTimings: {
drawMs: expect.any(Number),
encodeMs: expect.any(Number),
visualFingerprintMs: expect.any(Number),
},
filename: LPR_FRAME_FILE_NAME,
fingerprint: `384x216:${blob.size}`,
getContentFingerprint: expect.any(Function),
height: 216,
mimeType: LPR_FRAME_MIME_TYPE,
width: 384,
});
expect(canvas.width).toBe(384);
expect(canvas.height).toBe(216);
expect(frame.captureDurationMs).toBeGreaterThanOrEqual(0);
expect(frame.captureTimings.drawMs).toBeGreaterThanOrEqual(0);
expect(frame.captureTimings.encodeMs).toBeGreaterThanOrEqual(0);
expect(frame.captureTimings.visualFingerprintMs).toBeGreaterThanOrEqual(0);
expect(LPR_FRAME_SCANNER_JPEG_QUALITY).toBe(0.6);
expect(canvas.getContext).toHaveBeenCalledWith("2d", {
alpha: false,
desynchronized: true,
});
expect(drawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), LPR_FRAME_MIME_TYPE, LPR_FRAME_SCANNER_JPEG_QUALITY);
});
it("notifies after drawing the current video frame before JPEG encoding starts", async () => {
const blob = new Blob(["small-frame"], { type: LPR_FRAME_MIME_TYPE });
const events = [];
const drawImage = vi.fn(() => {
events.push("draw");
});
const toBlob = vi.fn((callback) => {
events.push("encode");
callback(blob);
});
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const onFrameDrawn = vi.fn(() => {
events.push("drawn-callback");
});
await captureVideoFrameBlobForLPR(video, canvas, { onFrameDrawn });
expect(onFrameDrawn).toHaveBeenCalledTimes(1);
expect(events).toEqual(["draw", "drawn-callback", "encode"]);
});
it("reuses the 2d encoding context for repeated captures on the same canvas", async () => {
const blob = new Blob(["cached-context-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
await captureVideoFrameBlobForLPR(video, canvas);
await captureVideoFrameBlobForLPR(video, canvas);
expect(canvas.getContext).toHaveBeenCalledTimes(1);
expect(drawImage).toHaveBeenCalledTimes(2);
});
it("uses a reusable OffscreenCanvas encoder when the browser supports it", async () => {
const blob = new Blob(["offscreen-frame"], { type: LPR_FRAME_MIME_TYPE });
const offscreenCanvases = [];
const offscreenConstructor = vi.fn(function FakeOffscreenCanvas(width, height) {
this.width = width;
this.height = height;
this.drawImage = vi.fn();
this.getContext = vi.fn(() => ({
drawImage: this.drawImage,
}));
this.convertToBlob = vi.fn(() => Promise.resolve(blob));
offscreenCanvases.push(this);
});
const fallbackDrawImage = vi.fn();
const fallbackToBlob = vi.fn();
const fallbackCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fallbackDrawImage,
})),
toBlob: fallbackToBlob,
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
vi.stubGlobal("OffscreenCanvas", offscreenConstructor);
try {
const frame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
const secondFrame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: `384x216:${blob.size}`,
height: 216,
mimeType: LPR_FRAME_MIME_TYPE,
width: 384,
});
expect(secondFrame).toMatchObject({
blob,
fingerprint: `384x216:${blob.size}`,
});
expect(offscreenConstructor).toHaveBeenCalledTimes(1);
expect(offscreenConstructor).toHaveBeenCalledWith(1, 1);
expect(offscreenCanvases[0].width).toBe(384);
expect(offscreenCanvases[0].height).toBe(216);
expect(offscreenCanvases[0].drawImage).toHaveBeenCalledTimes(2);
expect(offscreenCanvases[0].drawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
expect(offscreenCanvases[0].convertToBlob).toHaveBeenCalledWith({
type: LPR_FRAME_MIME_TYPE,
quality: LPR_FRAME_SCANNER_JPEG_QUALITY,
});
expect(fallbackCanvas.width).toBe(0);
expect(fallbackCanvas.height).toBe(0);
expect(fallbackCanvas.getContext).not.toHaveBeenCalled();
expect(fallbackDrawImage).not.toHaveBeenCalled();
expect(fallbackToBlob).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it("falls back to the DOM canvas when OffscreenCanvas cannot encode a blob", async () => {
const blob = new Blob(["fallback-frame"], { type: LPR_FRAME_MIME_TYPE });
const offscreenDrawImage = vi.fn();
const offscreenConstructor = vi.fn(function FakeOffscreenCanvas(width, height) {
this.width = width;
this.height = height;
this.getContext = vi.fn(() => ({
drawImage: offscreenDrawImage,
}));
});
const fallbackDrawImage = vi.fn();
const fallbackToBlob = vi.fn((callback) => callback(blob));
const fallbackCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fallbackDrawImage,
})),
toBlob: fallbackToBlob,
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
vi.stubGlobal("OffscreenCanvas", offscreenConstructor);
try {
const frame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
const secondFrame = await captureVideoFrameBlobForLPR(video, fallbackCanvas);
expect(frame).toMatchObject({
blob,
fingerprint: `384x216:${blob.size}`,
height: 216,
width: 384,
});
expect(secondFrame).toMatchObject({
blob,
fingerprint: `384x216:${blob.size}`,
});
expect(offscreenConstructor).toHaveBeenCalledTimes(1);
expect(offscreenDrawImage).toHaveBeenCalledTimes(1);
expect(offscreenDrawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
expect(fallbackCanvas.width).toBe(384);
expect(fallbackCanvas.height).toBe(216);
expect(fallbackDrawImage).toHaveBeenCalledTimes(2);
expect(fallbackDrawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
expect(fallbackToBlob).toHaveBeenCalledTimes(2);
expect(fallbackToBlob).toHaveBeenCalledWith(
expect.any(Function),
LPR_FRAME_MIME_TYPE,
LPR_FRAME_SCANNER_JPEG_QUALITY
);
} finally {
vi.unstubAllGlobals();
}
});
it("adds a stable visual fingerprint when canvas pixels can be sampled", async () => {
const blob = new Blob(["small-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const fingerprintDrawImage = vi.fn();
const pixels = new Uint8ClampedArray(8 * 8 * 4);
for (let index = 0; index < 8 * 8; index += 1) {
const offset = index * 4;
const value = index % 8 < 4 ? 30 : 220;
pixels[offset] = value;
pixels[offset + 1] = value;
pixels[offset + 2] = value;
pixels[offset + 3] = 255;
}
const getImageData = vi.fn(() => ({
data: pixels,
}));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const fingerprintCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fingerprintDrawImage,
getImageData,
})),
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, { visualFingerprintCanvas: fingerprintCanvas });
expect(frame.visualFingerprint).toMatch(/^[0-9a-f]{16}$/);
expect(frame.visualFingerprint).toBe("0f0f0f0f0f0f0f0f");
expect(fingerprintCanvas.width).toBe(8);
expect(fingerprintCanvas.height).toBe(8);
expect(fingerprintDrawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 8, 8);
expect(getImageData).toHaveBeenCalledTimes(1);
expect(getImageData).toHaveBeenCalledWith(0, 0, 8, 8);
expect(getVisualFingerprintDistance(frame.visualFingerprint, frame.visualFingerprint)).toBe(0);
expect(getVisualFingerprintDistance("0000000000000000", "000000000000000f")).toBe(4);
});
it("reuses the visual fingerprint context for repeated scene checks", async () => {
const blob = new Blob(["visual-context-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const fingerprintDrawImage = vi.fn();
const pixels = new Uint8ClampedArray(8 * 8 * 4).fill(200);
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const fingerprintCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fingerprintDrawImage,
getImageData: vi.fn(() => ({
data: pixels,
})),
})),
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
await captureVideoFrameBlobForLPR(video, canvas, { visualFingerprintCanvas: fingerprintCanvas });
await captureVideoFrameBlobForLPR(video, canvas, { visualFingerprintCanvas: fingerprintCanvas });
expect(fingerprintCanvas.getContext).toHaveBeenCalledTimes(1);
expect(fingerprintDrawImage).toHaveBeenCalledTimes(2);
});
it("can skip full canvas capture before drawing a visually unchanged candidate", async () => {
const drawImage = vi.fn();
const fingerprintDrawImage = vi.fn();
const pixels = new Uint8ClampedArray(8 * 8 * 4).fill(200);
const shouldEncode = vi.fn(() => false);
const toBlob = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const fingerprintCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fingerprintDrawImage,
getImageData: vi.fn(() => ({
data: pixels,
})),
})),
};
const video = {
videoHeight: 6,
videoWidth: 8,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
focusScale: 1,
shouldEncode,
visualFingerprintCanvas: fingerprintCanvas,
});
expect(frame).toBeNull();
expect(canvas.width).toBe(0);
expect(canvas.height).toBe(0);
expect(drawImage).not.toHaveBeenCalled();
expect(fingerprintDrawImage).toHaveBeenCalledWith(video, 0, 1, 8, 5, 0, 0, 8, 8);
expect(shouldEncode).toHaveBeenCalledWith({
height: 5,
visualFingerprint: expect.stringMatching(/^[0-9a-f]{16}$/),
width: 8,
});
expect(toBlob).not.toHaveBeenCalled();
});
it("can defer the visual fingerprint until a posted frame is known to need it", async () => {
const blob = new Blob(["deferred-visual"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const fingerprintDrawImage = vi.fn();
const pixels = new Uint8ClampedArray(8 * 8 * 4);
for (let index = 0; index < 8 * 8; index += 1) {
const offset = index * 4;
const value = index % 8 < 4 ? 30 : 220;
pixels[offset] = value;
pixels[offset + 1] = value;
pixels[offset + 2] = value;
pixels[offset + 3] = 255;
}
const getImageData = vi.fn(() => ({
data: pixels,
}));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const fingerprintCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage: fingerprintDrawImage,
getImageData,
})),
};
const shouldBuildVisualFingerprint = vi.fn(() => false);
const shouldEncode = vi.fn(() => true);
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
shouldBuildVisualFingerprint,
shouldEncode,
visualFingerprintCanvas: fingerprintCanvas,
});
expect(frame.visualFingerprint).toBeUndefined();
expect(frame.getVisualFingerprint).toEqual(expect.any(Function));
expect(shouldBuildVisualFingerprint).toHaveBeenCalledTimes(1);
expect(shouldEncode).toHaveBeenCalledWith({
height: 216,
width: 384,
});
expect(fingerprintDrawImage).not.toHaveBeenCalled();
expect(frame.getVisualFingerprint()).toBe("0f0f0f0f0f0f0f0f");
expect(fingerprintDrawImage).toHaveBeenCalledWith(canvas, 0, 0, 384, 216, 0, 0, 8, 8);
expect(getImageData).toHaveBeenCalledTimes(1);
expect(frame.getVisualFingerprint()).toBe("0f0f0f0f0f0f0f0f");
expect(getImageData).toHaveBeenCalledTimes(1);
});
it("captures only the centered focus area from the visible portrait preview for multipart LPR uploads", async () => {
const blob = new Blob(["portrait-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const toBlob = vi.fn((callback) => callback(blob));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const video = {
clientHeight: 844,
clientWidth: 390,
videoHeight: 720,
videoWidth: 1280,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: `283x159:${blob.size}`,
getContentFingerprint: expect.any(Function),
height: 159,
width: 283,
});
expect(canvas.width).toBe(283);
expect(canvas.height).toBe(159);
expect(drawImage).toHaveBeenCalledWith(video, 499, 281, 283, 159, 0, 0, 283, 159);
});
it("uses supplied viewport dimensions without reading rendered video layout", async () => {
const blob = new Blob(["cached-viewport-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const getClientHeight = vi.fn(() => 0);
const getClientWidth = vi.fn(() => 0);
const video = {
get clientHeight() {
return getClientHeight();
},
get clientWidth() {
return getClientWidth();
},
videoHeight: 720,
videoWidth: 1280,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
viewportHeight: 844,
viewportWidth: 390,
});
expect(frame).toMatchObject({
blob,
fingerprint: `283x159:${blob.size}`,
height: 159,
width: 283,
});
expect(getClientHeight).not.toHaveBeenCalled();
expect(getClientWidth).not.toHaveBeenCalled();
expect(drawImage.mock.calls[0][0]).toBe(video);
expect(drawImage.mock.calls[0].slice(1)).toEqual([499, 281, 283, 159, 0, 0, 283, 159]);
});
it("captures from the scanner outline center when a focus rect is supplied", async () => {
const blob = new Blob(["focused-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const video = {
clientHeight: 844,
clientWidth: 390,
videoHeight: 720,
videoWidth: 1280,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, {
focusViewportRect: {
height: 234,
width: 234,
x: 78,
y: 205,
},
});
expect(frame).toMatchObject({
blob,
fingerprint: `283x159:${blob.size}`,
height: 159,
width: 283,
});
expect(drawImage).toHaveBeenCalledWith(video, 499, 195, 283, 159, 0, 0, 283, 159);
});
it("can capture the full visible preview crop for non-LPR attachment frames", async () => {
const blob = new Blob(["attachment-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const toBlob = vi.fn((callback) => callback(blob));
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
const video = {
clientHeight: 844,
clientWidth: 390,
videoHeight: 720,
videoWidth: 1280,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas, { focusCrop: false });
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: `266x576:${blob.size}`,
getContentFingerprint: expect.any(Function),
height: 576,
width: 266,
});
expect(canvas.width).toBe(266);
expect(canvas.height).toBe(576);
expect(drawImage).toHaveBeenCalledWith(video, 474, 0, 333, 720, 0, 0, 266, 576);
expect(toBlob).toHaveBeenCalledWith(expect.any(Function), LPR_FRAME_MIME_TYPE, LPR_FRAME_JPEG_QUALITY);
});
it("does not reset canvas dimensions when the target frame size is unchanged", async () => {
const blob = new Blob(["same-size-frame"], { type: LPR_FRAME_MIME_TYPE });
const drawImage = vi.fn();
const toBlob = vi.fn((callback) => callback(blob));
const widthSetter = vi.fn();
const heightSetter = vi.fn();
const canvas = {
getContext: vi.fn(() => ({
drawImage,
})),
toBlob,
};
Object.defineProperty(canvas, "width", {
get: () => 384,
set: widthSetter,
});
Object.defineProperty(canvas, "height", {
get: () => 216,
set: heightSetter,
});
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: `384x216:${blob.size}`,
getContentFingerprint: expect.any(Function),
height: 216,
width: 384,
});
expect(widthSetter).not.toHaveBeenCalled();
expect(heightSetter).not.toHaveBeenCalled();
expect(drawImage).toHaveBeenCalledWith(video, 144, 81, 1632, 918, 0, 0, 384, 216);
});
it("does not wait for content fingerprint bytes before returning a captured frame", async () => {
const pendingArrayBuffer = new Promise(() => {});
const blob = {
size: 123,
type: LPR_FRAME_MIME_TYPE,
slice: vi.fn(() => ({
arrayBuffer: vi.fn(() => pendingArrayBuffer),
})),
};
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: vi.fn(() => ({
drawImage,
})),
toBlob: vi.fn((callback) => callback(blob)),
};
const video = {
videoWidth: 1920,
videoHeight: 1080,
};
const frame = await captureVideoFrameBlobForLPR(video, canvas);
expect(frame).toMatchObject({
blob,
captureDurationMs: expect.any(Number),
fingerprint: "384x216:123",
getContentFingerprint: expect.any(Function),
height: 216,
width: 384,
});
expect(blob.slice).not.toHaveBeenCalled();
const contentFingerprintPromise = frame.getContentFingerprint();
expect(blob.slice).toHaveBeenCalled();
expect(frame.getContentFingerprint()).toBe(contentFingerprintPromise);
});
it("samples duplicate content fingerprint offsets only once for small blobs", async () => {
const slice = vi.fn(() => ({
arrayBuffer: vi.fn(() => Promise.resolve(new Uint8Array([1, 2, 3, 4]).buffer)),
}));
const blob = {
size: 4,
slice,
};
await expect(buildLPRFrameFingerprint(blob, 300, 225)).resolves.toMatch(/^300x225:4:[0-9a-f]{8}$/);
expect(slice).toHaveBeenCalledTimes(1);
expect(slice).toHaveBeenCalledWith(0, 4);
});
it("builds stable blob fingerprints without using capture time", async () => {
const first = new Blob(["same-size-a"], { type: LPR_FRAME_MIME_TYPE });
const second = new Blob(["same-size-a"], { type: LPR_FRAME_MIME_TYPE });
const changed = new Blob(["same-size-b"], { type: LPR_FRAME_MIME_TYPE });
await expect(buildLPRFrameFingerprint(second, 1024, 576)).resolves.toBe(
await buildLPRFrameFingerprint(first, 1024, 576)
);
await expect(buildLPRFrameFingerprint(changed, 1024, 576)).resolves.not.toBe(
await buildLPRFrameFingerprint(first, 1024, 576)
);
expect(buildLPRFrameFingerprintKey(first, 1024, 576)).toBe(`1024x576:${first.size}`);
});
});