132 lines
4.6 KiB
JavaScript
132 lines
4.6 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
||
import QRCode from "qrcode";
|
||
import {
|
||
extractUrl,
|
||
decodeQrFromImageData,
|
||
scaleImageData,
|
||
} from "../src/qrscan.js";
|
||
|
||
/**
|
||
* 用 qrcode 库把文本渲染成 RGBA 像素数据(放大每个模块以贴近真实拍摄清晰度)。
|
||
* @param {string} text 待编码文本。
|
||
* @param {number} scale 每个二维码模块放大的像素倍数。
|
||
* @param {number} quiet 静区(模块数)。
|
||
* @returns {{data:Uint8ClampedArray,width:number,height:number}} 画布像素数据。
|
||
*/
|
||
function renderQrImageData(text, scale = 8, quiet = 4) {
|
||
const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
|
||
const size = qr.modules.size;
|
||
const modules = qr.modules.data;
|
||
const dim = (size + quiet * 2) * scale;
|
||
const data = new Uint8ClampedArray(dim * dim * 4);
|
||
data.fill(255);
|
||
for (let row = 0; row < size; row += 1) {
|
||
for (let col = 0; col < size; col += 1) {
|
||
if (!modules[row * size + col]) continue;
|
||
const x0 = (col + quiet) * scale;
|
||
const y0 = (row + quiet) * scale;
|
||
for (let dy = 0; dy < scale; dy += 1) {
|
||
for (let dx = 0; dx < scale; dx += 1) {
|
||
const idx = ((y0 + dy) * dim + (x0 + dx)) * 4;
|
||
data[idx] = 0;
|
||
data[idx + 1] = 0;
|
||
data[idx + 2] = 0;
|
||
data[idx + 3] = 255;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { data, width: dim, height: dim };
|
||
}
|
||
|
||
/**
|
||
* 将小二维码嵌入大画布中央,模拟手机拍摄的远景照片(二维码占比小)。
|
||
* @param {string} text 二维码内容。
|
||
* @param {number} canvasW 大画布宽度。
|
||
* @param {number} canvasH 大画布高度。
|
||
* @returns {ImageData} 大画布像素数据。
|
||
*/
|
||
function embedSmallQr(text, canvasW = 600, canvasH = 400) {
|
||
const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
|
||
const size = qr.modules.size;
|
||
const modules = qr.modules.data;
|
||
const scale = 3; // 每个模块 3px,二维码约 80px
|
||
const quiet = 1;
|
||
const qrW = (size + quiet * 2) * scale;
|
||
const qrH = qrW;
|
||
const ox = Math.floor((canvasW - qrW) / 2);
|
||
const oy = Math.floor((canvasH - qrH) / 2);
|
||
|
||
const data = new Uint8ClampedArray(canvasW * canvasH * 4);
|
||
data.fill(255);
|
||
for (let row = 0; row < size; row += 1) {
|
||
for (let col = 0; col < size; col += 1) {
|
||
if (!modules[row * size + col]) continue;
|
||
const x0 = ox + (col + quiet) * scale;
|
||
const y0 = oy + (row + quiet) * scale;
|
||
for (let dy = 0; dy < scale; dy += 1) {
|
||
for (let dx = 0; dx < scale; dx += 1) {
|
||
const idx = ((y0 + dy) * canvasW + (x0 + dx)) * 4;
|
||
data[idx] = 0;
|
||
data[idx + 1] = 0;
|
||
data[idx + 2] = 0;
|
||
data[idx + 3] = 255;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { data, width: canvasW, height: canvasH };
|
||
}
|
||
|
||
describe("qrscan", () => {
|
||
// 4-1 网址校验:接受 http/https,拒绝其它内容。
|
||
it("4-1 extractUrl 仅接受 http/https 网址", () => {
|
||
expect(extractUrl("https://example.com/inv?id=1")).toBe(
|
||
"https://example.com/inv?id=1"
|
||
);
|
||
expect(extractUrl(" http://a.b/c ")).toBe("http://a.b/c");
|
||
expect(extractUrl("ftp://x")).toBe("");
|
||
expect(extractUrl("hello")).toBe("");
|
||
expect(extractUrl(null)).toBe("");
|
||
});
|
||
|
||
// 4-2 从生成的清晰二维码像素中解码,验证扫描核心逻辑正确。
|
||
it("4-2 decodeQrFromImageData 能识别清晰二维码", () => {
|
||
const url = "https://fapiao.example.com/open?id=20240701";
|
||
const imageData = renderQrImageData(url);
|
||
expect(decodeQrFromImageData(imageData)).toBe(url);
|
||
});
|
||
|
||
// 4-3 无法识别时返回 null,而不是抛错。
|
||
it("4-3 无二维码时返回 null", () => {
|
||
const blank = {
|
||
data: new Uint8ClampedArray(64 * 64 * 4).fill(255),
|
||
width: 64,
|
||
height: 64,
|
||
};
|
||
expect(decodeQrFromImageData(blank)).toBeNull();
|
||
expect(decodeQrFromImageData(null)).toBeNull();
|
||
});
|
||
|
||
// 4-4 多级缩放回退:大画布中小二维码,原始尺寸失败但缩小后成功。
|
||
it("4-4 多级缩放回退能识别大图中的小二维码", () => {
|
||
const url = "https://inv.example.com/small";
|
||
const imageData = embedSmallQr(url, 800, 600);
|
||
const result = decodeQrFromImageData(imageData);
|
||
expect(result).toBe(url);
|
||
});
|
||
|
||
// 4-5 scaleImageData 等比缩放正确。
|
||
it("4-5 scaleImageData 等比缩放", () => {
|
||
const src = renderQrImageData("test", 4);
|
||
const half = scaleImageData(src, 0.5);
|
||
expect(half.width).toBe(Math.floor(src.width * 0.5));
|
||
expect(half.height).toBe(Math.floor(src.height * 0.5));
|
||
expect(half.data).toBeInstanceOf(Uint8ClampedArray);
|
||
expect(half.data.length).toBe(half.width * half.height * 4);
|
||
expect(half.height).toBe(Math.floor(src.height * 0.5));
|
||
});
|
||
});
|
||
|
||
|