QrSpoolScanner.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. /**
  2. * The shared spool QR scanner (#1574): its live loop keeps scanning after a
  3. * frame or a callback fails, hands a label over once while it stays in view
  4. * (other codes read in between or not), says why when there's no camera,
  5. * releases the camera when it closes, a photo that comes back while the
  6. * caller is busy is handed over once it's free, and a photo still decoding
  7. * when it closes is not handed to a caller that moved on.
  8. */
  9. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  10. import { fireEvent, frames, render, screen, waitFor } from '../utils';
  11. const jsQR = vi.hoisted(() => vi.fn());
  12. vi.mock('jsqr', () => ({ default: jsQR }));
  13. import { QrSpoolScanner } from '../../components/QrSpoolScanner';
  14. const PAYLOAD = 'https://bambuddy.local/inventory?spool=9';
  15. const OTHER_PAYLOAD = 'https://bambuddy.local/inventory?spool=10';
  16. function readableContext() {
  17. return {
  18. drawImage: vi.fn(),
  19. getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
  20. };
  21. }
  22. describe('QrSpoolScanner', () => {
  23. afterEach(() => {
  24. vi.unstubAllGlobals();
  25. vi.restoreAllMocks();
  26. jsQR.mockReset();
  27. });
  28. describe('live camera', () => {
  29. let track: { stop: ReturnType<typeof vi.fn>; applyConstraints: ReturnType<typeof vi.fn> };
  30. let ctx: ReturnType<typeof readableContext>;
  31. beforeEach(() => {
  32. track = { stop: vi.fn(), applyConstraints: vi.fn().mockResolvedValue(undefined) };
  33. const stream = { getTracks: () => [track], getVideoTracks: () => [track] };
  34. vi.stubGlobal('navigator', { ...navigator, mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) } });
  35. vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
  36. vi.spyOn(HTMLMediaElement.prototype, 'readyState', 'get').mockReturnValue(4); // HAVE_ENOUGH_DATA
  37. vi.spyOn(HTMLVideoElement.prototype, 'videoWidth', 'get').mockReturnValue(640);
  38. vi.spyOn(HTMLVideoElement.prototype, 'videoHeight', 'get').mockReturnValue(480);
  39. ctx = readableContext();
  40. vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => ctx as unknown as RenderingContext);
  41. jsQR.mockReturnValue({ data: PAYLOAD });
  42. });
  43. it('keeps scanning after a frame that could not be read and a callback that threw', async () => {
  44. // No platform decoder: frames go through the canvas and jsQR.
  45. vi.stubGlobal('BarcodeDetector', undefined);
  46. ctx.drawImage.mockImplementationOnce(() => {
  47. throw new DOMException('no frame yet', 'InvalidStateError');
  48. });
  49. const onDecode = vi.fn().mockImplementationOnce(() => {
  50. throw new Error('the caller failed');
  51. });
  52. render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  53. // Frame 1 can't be drawn, frame 2's callback throws, frame 3 brings the
  54. // same label again: the caller never had it.
  55. await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2), { timeout: 3000 });
  56. expect(onDecode.mock.calls).toEqual([[PAYLOAD], [PAYLOAD]]);
  57. });
  58. it('hands a label over once while it stays in view, a different one at once, the same again on Retry', async () => {
  59. vi.stubGlobal('BarcodeDetector', undefined);
  60. const onDecode = vi.fn();
  61. render(<QrSpoolScanner onDecode={onDecode} busy={false} error="Spool #9 is archived." />);
  62. await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(1), { timeout: 3000 });
  63. await frames(3);
  64. // The caller turned it down; the label staying in front of the camera is not sent again.
  65. expect(onDecode).toHaveBeenCalledTimes(1);
  66. await screen.findByRole('button', { name: /retry/i }).then((retry) => fireEvent.click(retry));
  67. await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2), { timeout: 3000 });
  68. jsQR.mockReturnValue({ data: OTHER_PAYLOAD });
  69. await waitFor(() => expect(onDecode).toHaveBeenLastCalledWith(OTHER_PAYLOAD), { timeout: 3000 });
  70. expect(onDecode).toHaveBeenCalledTimes(3);
  71. });
  72. it('hands a label over again once it was out of view a moment: showing it again is asking', async () => {
  73. vi.stubGlobal('BarcodeDetector', undefined);
  74. const onDecode = vi.fn();
  75. render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  76. await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(1), { timeout: 3000 });
  77. jsQR.mockReturnValue(null); // taken away...
  78. await frames(9);
  79. jsQR.mockReturnValue({ data: PAYLOAD }); // ...and shown again
  80. await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2), { timeout: 3000 });
  81. });
  82. it('does not hand a label over again while another code in view is read in between', async () => {
  83. // The label and, next to it, the maker's QR on the spool: frames read one, then the other.
  84. vi.stubGlobal('BarcodeDetector', undefined);
  85. let read = 0;
  86. jsQR.mockImplementation(() => ({ data: read++ % 2 === 0 ? PAYLOAD : OTHER_PAYLOAD }));
  87. const onDecode = vi.fn();
  88. render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  89. await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2), { timeout: 3000 });
  90. await frames(4);
  91. expect(onDecode.mock.calls).toEqual([[PAYLOAD], [OTHER_PAYLOAD]]);
  92. });
  93. it('offers no Retry while the caller is busy with the label', async () => {
  94. vi.stubGlobal('BarcodeDetector', undefined);
  95. const onDecode = vi.fn();
  96. const { rerender } = render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  97. await waitFor(() => expect(onDecode).toHaveBeenCalled(), { timeout: 3000 });
  98. rerender(<QrSpoolScanner onDecode={onDecode} busy error={null} />);
  99. expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
  100. });
  101. it('says why there is no live view when the camera cannot be opened', async () => {
  102. vi.stubGlobal('navigator', {
  103. ...navigator,
  104. mediaDevices: { getUserMedia: vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError')) },
  105. });
  106. render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error={null} />);
  107. expect(await screen.findByText(/No camera could be opened here/)).toBeInTheDocument();
  108. expect(document.querySelector('video')).toBeNull();
  109. });
  110. it('scans with jsQR where the platform decoder cannot be built for QR codes', async () => {
  111. vi.stubGlobal(
  112. 'BarcodeDetector',
  113. class {
  114. constructor() {
  115. throw new TypeError('qr_code is not supported');
  116. }
  117. },
  118. );
  119. const onDecode = vi.fn();
  120. render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  121. await waitFor(() => expect(onDecode).toHaveBeenCalledWith(PAYLOAD), { timeout: 3000 });
  122. });
  123. it('scans nothing while the caller is busy', async () => {
  124. vi.stubGlobal('BarcodeDetector', undefined);
  125. const onDecode = vi.fn();
  126. render(<QrSpoolScanner onDecode={onDecode} busy error={null} />);
  127. await new Promise((r) => setTimeout(r, 500));
  128. expect(onDecode).not.toHaveBeenCalled();
  129. });
  130. it('stops the camera when it closes', async () => {
  131. vi.stubGlobal('BarcodeDetector', undefined);
  132. const { unmount } = render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error={null} />);
  133. await waitFor(() => expect(HTMLMediaElement.prototype.play).toHaveBeenCalled());
  134. unmount();
  135. expect(track.stop).toHaveBeenCalled();
  136. });
  137. });
  138. describe('photo fallback', () => {
  139. function pickPhoto() {
  140. const input = document.querySelector('input[type="file"]') as HTMLInputElement;
  141. fireEvent.change(input, { target: { files: [new File(['qr'], 'qr.jpg', { type: 'image/jpeg' })] } });
  142. }
  143. beforeEach(() => {
  144. // No camera in jsdom: the live view hides itself and the photo path is all there is.
  145. vi.stubGlobal('BarcodeDetector', undefined);
  146. vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
  147. () => readableContext() as unknown as RenderingContext,
  148. );
  149. });
  150. it('hands a decoded photo to the caller', async () => {
  151. vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
  152. jsQR.mockReturnValue({ data: PAYLOAD });
  153. const onDecode = vi.fn();
  154. render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  155. pickPhoto();
  156. await waitFor(() => expect(onDecode).toHaveBeenCalledWith(PAYLOAD));
  157. });
  158. it('hands over every photo, even of the label it just handed over: taking one is asking', async () => {
  159. vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
  160. jsQR.mockReturnValue({ data: PAYLOAD });
  161. const onDecode = vi.fn();
  162. render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  163. pickPhoto();
  164. await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(1));
  165. pickPhoto();
  166. await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2));
  167. });
  168. it('hands over a photo once the caller is free, when it turned busy while the photo was taken', async () => {
  169. vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
  170. jsQR.mockReturnValue({ data: PAYLOAD });
  171. const onDecode = vi.fn();
  172. const { rerender } = render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  173. // The button was free when tapped; the caller turned busy (a list
  174. // refetching as the page got focus back) before the photo came back.
  175. rerender(<QrSpoolScanner onDecode={onDecode} busy error={null} />);
  176. pickPhoto();
  177. await new Promise((r) => setTimeout(r, 50));
  178. expect(onDecode).not.toHaveBeenCalled();
  179. rerender(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  180. await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(1));
  181. expect(onDecode).toHaveBeenCalledWith(PAYLOAD);
  182. });
  183. it('says the live camera needs HTTPS when the page is not a secure origin', async () => {
  184. vi.stubGlobal('isSecureContext', false);
  185. render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error={null} />);
  186. expect(await screen.findByText(/needs Bambuddy opened over HTTPS/)).toBeInTheDocument();
  187. // The photo fallback is still there.
  188. expect(screen.getByRole('button', { name: /take photo/i })).toBeEnabled();
  189. });
  190. it('on a secure origin without camera access, says no camera could be opened, not to use HTTPS', async () => {
  191. // jsdom offers no camera at all, as an old or embedded browser on HTTPS might.
  192. vi.stubGlobal('isSecureContext', true);
  193. render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error={null} />);
  194. expect(await screen.findByText(/No camera could be opened here/)).toBeInTheDocument();
  195. expect(screen.queryByText(/needs Bambuddy opened over HTTPS/)).not.toBeInTheDocument();
  196. });
  197. it('says so when the photo has no QR code', async () => {
  198. vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
  199. jsQR.mockReturnValue(null);
  200. const onDecode = vi.fn();
  201. render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  202. pickPhoto();
  203. expect(await screen.findByText(/No QR code found in the photo/)).toBeInTheDocument();
  204. expect(onDecode).not.toHaveBeenCalled();
  205. });
  206. it('drops a photo that finishes decoding after the scanner closed', async () => {
  207. let open: (bitmap: unknown) => void = () => {};
  208. vi.stubGlobal(
  209. 'createImageBitmap',
  210. vi.fn(() => new Promise((resolve) => { open = resolve; })),
  211. );
  212. jsQR.mockReturnValue({ data: PAYLOAD });
  213. const onDecode = vi.fn();
  214. const { unmount } = render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
  215. pickPhoto();
  216. unmount();
  217. open({ width: 10, height: 10, close: vi.fn() });
  218. await new Promise((r) => setTimeout(r, 50));
  219. expect(onDecode).not.toHaveBeenCalled();
  220. });
  221. it("shows the caller's message about the last scan", () => {
  222. render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error="That QR code is not a spool label." />);
  223. expect(screen.getByText('That QR code is not a spool label.')).toBeInTheDocument();
  224. });
  225. });
  226. });