qrDecode.test.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. /**
  2. * The scanner's decoding never throws for a frame or photo it can't read, and
  3. * falls back to jsQR when the platform decoder is missing, can't be built or
  4. * fails (#1574 review).
  5. */
  6. import { describe, it, expect, vi, afterEach } from 'vitest';
  7. const jsQR = vi.hoisted(() => vi.fn());
  8. vi.mock('jsqr', () => ({ default: jsQR }));
  9. import { createFrameDecoder, createQrDetector, decodeCanvas, decodeImageBlob } from '../../utils/qrDecode';
  10. function readableContext() {
  11. return {
  12. drawImage: vi.fn(),
  13. getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
  14. };
  15. }
  16. function canvasWith(getContext: () => unknown): HTMLCanvasElement {
  17. const canvas = document.createElement('canvas');
  18. canvas.getContext = vi.fn(getContext) as unknown as HTMLCanvasElement['getContext'];
  19. return canvas;
  20. }
  21. const video = { videoWidth: 640, videoHeight: 480 } as unknown as HTMLVideoElement;
  22. function stubDetector(detect: () => Promise<{ rawValue: string }[]>) {
  23. const detectSpy = vi.fn(detect);
  24. vi.stubGlobal(
  25. 'BarcodeDetector',
  26. class {
  27. detect = detectSpy;
  28. },
  29. );
  30. return detectSpy;
  31. }
  32. function stubUnbuildableDetector() {
  33. vi.stubGlobal(
  34. 'BarcodeDetector',
  35. class {
  36. constructor() {
  37. throw new TypeError("Failed to construct 'BarcodeDetector': qr_code is not supported");
  38. }
  39. },
  40. );
  41. }
  42. afterEach(() => {
  43. vi.unstubAllGlobals();
  44. vi.restoreAllMocks();
  45. jsQR.mockReset();
  46. });
  47. describe('createQrDetector', () => {
  48. it('is null where the platform has no BarcodeDetector', () => {
  49. vi.stubGlobal('BarcodeDetector', undefined);
  50. expect(createQrDetector()).toBeNull();
  51. });
  52. it('is null where the platform decoder cannot read QR codes', () => {
  53. stubUnbuildableDetector();
  54. expect(createQrDetector()).toBeNull();
  55. });
  56. it('asks the platform decoder for QR codes', () => {
  57. const made: unknown[] = [];
  58. vi.stubGlobal(
  59. 'BarcodeDetector',
  60. class {
  61. constructor(opts: unknown) {
  62. made.push(opts);
  63. }
  64. },
  65. );
  66. expect(createQrDetector()).not.toBeNull();
  67. expect(made).toEqual([{ formats: ['qr_code'] }]);
  68. });
  69. });
  70. describe('decodeCanvas', () => {
  71. it("returns jsQR's reading of the frame", () => {
  72. jsQR.mockReturnValue({ data: 'spool=3' });
  73. expect(decodeCanvas(canvasWith(readableContext), video, 4, 4, 'dontInvert')).toBe('spool=3');
  74. });
  75. it('is undefined, not a throw, when the canvas cannot be read', () => {
  76. expect(decodeCanvas(canvasWith(() => { throw new Error('not implemented'); }), video, 4, 4, 'dontInvert')).toBeUndefined();
  77. expect(decodeCanvas(canvasWith(() => null), video, 4, 4, 'dontInvert')).toBeUndefined();
  78. const ctx = readableContext();
  79. ctx.drawImage.mockImplementation(() => {
  80. throw new DOMException('no frame yet', 'InvalidStateError');
  81. });
  82. expect(decodeCanvas(canvasWith(() => ctx), video, 4, 4, 'dontInvert')).toBeUndefined();
  83. });
  84. it('skips a frame with no size', () => {
  85. const canvas = canvasWith(readableContext);
  86. expect(decodeCanvas(canvas, video, 0, 0, 'dontInvert')).toBeUndefined();
  87. expect(canvas.getContext).not.toHaveBeenCalled();
  88. });
  89. });
  90. describe('createFrameDecoder', () => {
  91. it("uses the platform decoder's reading", async () => {
  92. stubDetector(async () => [{ rawValue: 'spool=1' }]);
  93. const decode = createFrameDecoder();
  94. expect(await decode(video, canvasWith(readableContext))).toBe('spool=1');
  95. expect(jsQR).not.toHaveBeenCalled();
  96. });
  97. it('drops a platform decoder that rejects, for jsQR on that frame and the next', async () => {
  98. const detect = stubDetector(async () => {
  99. throw new DOMException('decode failed', 'OperationError');
  100. });
  101. jsQR.mockReturnValue({ data: 'spool=2' });
  102. const decode = createFrameDecoder();
  103. const canvas = canvasWith(readableContext);
  104. expect(await decode(video, canvas)).toBe('spool=2');
  105. expect(await decode(video, canvas)).toBe('spool=2');
  106. expect(detect).toHaveBeenCalledTimes(1);
  107. });
  108. it('uses jsQR when the platform decoder cannot be built', async () => {
  109. stubUnbuildableDetector();
  110. jsQR.mockReturnValue({ data: 'spool=4' });
  111. const decode = createFrameDecoder();
  112. expect(await decode(video, canvasWith(readableContext))).toBe('spool=4');
  113. });
  114. it('resolves rather than rejects when nothing can read the frame', async () => {
  115. stubDetector(async () => {
  116. throw new Error('boom');
  117. });
  118. const decode = createFrameDecoder();
  119. await expect(decode(video, canvasWith(() => { throw new Error('no canvas'); }))).resolves.toBeUndefined();
  120. });
  121. });
  122. describe('decodeImageBlob', () => {
  123. const photo = new Blob(['qr'], { type: 'image/jpeg' });
  124. function stubBitmap() {
  125. const bitmap = { width: 3000, height: 2000, close: vi.fn() };
  126. vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap));
  127. vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
  128. () => readableContext() as unknown as RenderingContext,
  129. );
  130. return bitmap;
  131. }
  132. it('falls back to jsQR when the platform decoder cannot be built', async () => {
  133. const bitmap = stubBitmap();
  134. stubUnbuildableDetector();
  135. jsQR.mockReturnValue({ data: 'spool=5' });
  136. expect(await decodeImageBlob(photo)).toBe('spool=5');
  137. // Downscaled to 1600px on the long side before jsQR reads it.
  138. expect(jsQR).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 1600, 1067, { inversionAttempts: 'attemptBoth' });
  139. expect(bitmap.close).toHaveBeenCalled();
  140. });
  141. it('falls back to jsQR when the platform decoder rejects the photo', async () => {
  142. stubBitmap();
  143. stubDetector(async () => {
  144. throw new DOMException('decode failed', 'OperationError');
  145. });
  146. jsQR.mockReturnValue({ data: 'spool=6' });
  147. expect(await decodeImageBlob(photo)).toBe('spool=6');
  148. });
  149. });