@nocobase/client-v2 2.3.0-beta.7 → 2.3.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  2. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  3. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  4. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
  5. package/es/index.mjs +19 -19
  6. package/lib/index.js +38 -38
  7. package/package.json +9 -8
  8. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  9. package/src/collection-manager/field-configure.ts +2 -2
  10. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  11. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  12. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  13. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  14. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  15. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  16. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  17. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  18. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  19. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  20. package/src/flow/models/base/GridModel.tsx +0 -1
  21. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  22. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
  23. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  24. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  25. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  26. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  27. package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
  28. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
  29. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  30. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  31. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  32. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.3.0-beta.7",
3
+ "version": "2.3.0-beta.9",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.3.0-beta.7",
31
- "@nocobase/flow-engine": "2.3.0-beta.7",
32
- "@nocobase/sdk": "2.3.0-beta.7",
33
- "@nocobase/shared": "2.3.0-beta.7",
34
- "@nocobase/utils": "2.3.0-beta.7",
30
+ "@nocobase/evaluators": "2.3.0-beta.9",
31
+ "@nocobase/flow-engine": "2.3.0-beta.9",
32
+ "@nocobase/sdk": "2.3.0-beta.9",
33
+ "@nocobase/shared": "2.3.0-beta.9",
34
+ "@nocobase/utils": "2.3.0-beta.9",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -46,7 +46,8 @@
46
46
  "lodash": "4.17.21",
47
47
  "react-device-detect": "2.2.3",
48
48
  "react-i18next": "^11.15.1",
49
- "react-router-dom": "^6.30.1"
49
+ "react-router-dom": "^6.30.1",
50
+ "zxing-wasm": "^3.1.3"
50
51
  },
51
- "gitHead": "3485d95ce53290d6e74d68222a0568ea60420e23"
52
+ "gitHead": "c215e60ed704afe6c086bd96304f4b0d3b46d02e"
52
53
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { getCoreFieldConfigureState, reverseFieldConfigureItems } from '../field-configure';
12
+
13
+ describe('inverse relationship configuration', () => {
14
+ it.each(['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'])(
15
+ 'keeps the %s inverse relationship type disabled when creating or editing a field',
16
+ (type) => {
17
+ for (const createOnly of [true, false]) {
18
+ for (const showReverseFieldConfig of [true, false]) {
19
+ const values = {
20
+ autoCreateReverseField: true,
21
+ reverseField: { type, ...(createOnly ? {} : { key: 'existing-reverse-field' }) },
22
+ };
23
+
24
+ expect(
25
+ getCoreFieldConfigureState('reverseField.type', values, { createOnly, showReverseFieldConfig }),
26
+ ).toEqual({
27
+ disabled: true,
28
+ hidden: !showReverseFieldConfig,
29
+ });
30
+ }
31
+ }
32
+ },
33
+ );
34
+
35
+ it.each(['reverseField.name', 'reverseField.uiSchema.title'])(
36
+ 'allows editing %s only when the inverse configuration is visible',
37
+ (name) => {
38
+ for (const showReverseFieldConfig of [true, false]) {
39
+ expect(getCoreFieldConfigureState(name, {}, { showReverseFieldConfig })).toEqual({
40
+ disabled: !showReverseFieldConfig,
41
+ hidden: !showReverseFieldConfig,
42
+ });
43
+ }
44
+ },
45
+ );
46
+
47
+ it('also disables the inverse relationship type in explicit configure items', () => {
48
+ const item = reverseFieldConfigureItems().find(({ name }) => name === 'reverseField.type');
49
+
50
+ expect(item?.disabled).toBe(true);
51
+ });
52
+ });
@@ -271,7 +271,7 @@ export function getCoreFieldConfigureState(
271
271
 
272
272
  if (name.startsWith('reverseField.')) {
273
273
  return {
274
- disabled: !context.showReverseFieldConfig,
274
+ disabled: name === 'reverseField.type' || !context.showReverseFieldConfig,
275
275
  hidden: !context.showReverseFieldConfig,
276
276
  };
277
277
  }
@@ -514,7 +514,7 @@ export function reverseFieldConfigureItems(): FieldConfigureItem[] {
514
514
  { label: "{{t('BelongsToMany')}}", value: 'belongsToMany' },
515
515
  ],
516
516
  hidden: ({ context, values }) => !context.showReverseFieldConfig && !get(values, 'autoCreateReverseField'),
517
- disabled: ({ context }) => !context.showReverseFieldConfig,
517
+ disabled: true,
518
518
  },
519
519
  {
520
520
  name: 'reverseField.uiSchema.title',
@@ -41,6 +41,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
41
41
  const inputId = useId().replace(/:/g, '');
42
42
  const scannerElementId = `code-scanner-${inputId}`;
43
43
  const imgUploaderRef = useRef<HTMLInputElement>(null);
44
+ const scannerViewportRef = useRef<HTMLDivElement>(null);
44
45
  const [cameraAvailable, setCameraAvailable] = useState(false);
45
46
  const [scannerSize, setScannerSize] = useState({ width: 0, height: 0 });
46
47
  const [viewport, setViewport] = useState(getViewportSize);
@@ -84,6 +85,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
84
85
  elementId: scannerElementId,
85
86
  formatsToSupport,
86
87
  onScannerSizeChanged: setScannerSize,
88
+ scanViewportRef: scannerViewportRef,
87
89
  onScanSuccess: handleScanSuccess,
88
90
  onScanFailure: showScanFailure,
89
91
  onCameraStartFailure: showCameraStartFailure,
@@ -207,7 +209,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
207
209
 
208
210
  return (
209
211
  <div className={rootClass}>
210
- <div className={scannerWrapperClass}>
212
+ <div ref={scannerViewportRef} className={scannerWrapperClass}>
211
213
  <div
212
214
  className={scannerSurfaceClass}
213
215
  style={{
@@ -14,6 +14,7 @@ import { CodeScanner } from '../CodeScanner';
14
14
 
15
15
  const mocks = vi.hoisted(() => ({
16
16
  getCameras: vi.fn().mockResolvedValue([{ id: 'rear-camera' }]),
17
+ scanViewport: null as HTMLElement | null,
17
18
  }));
18
19
 
19
20
  vi.mock('html5-qrcode', () => ({
@@ -53,15 +54,18 @@ vi.mock('../useCodeScanner', async (importOriginal) => {
53
54
  useCodeScanner: ({
54
55
  enabled,
55
56
  onScannerSizeChanged,
57
+ scanViewportRef,
56
58
  }: {
57
59
  enabled: boolean;
58
60
  onScannerSizeChanged?: (size: { width: number; height: number }) => void;
61
+ scanViewportRef?: { current: HTMLElement | null };
59
62
  }) => {
60
63
  ReactModule.useEffect(() => {
61
64
  if (enabled) {
65
+ mocks.scanViewport = scanViewportRef?.current || null;
62
66
  onScannerSizeChanged?.({ width: 1280, height: 720 });
63
67
  }
64
- }, [enabled, onScannerSizeChanged]);
68
+ }, [enabled, onScannerSizeChanged, scanViewportRef]);
65
69
 
66
70
  return { startScanFile: vi.fn() };
67
71
  },
@@ -70,6 +74,7 @@ vi.mock('../useCodeScanner', async (importOriginal) => {
70
74
 
71
75
  describe('CodeScanner', () => {
72
76
  afterEach(() => {
77
+ mocks.scanViewport = null;
73
78
  vi.restoreAllMocks();
74
79
  });
75
80
 
@@ -82,6 +87,7 @@ describe('CodeScanner', () => {
82
87
  render(<CodeScanner visible onClose={() => undefined} onScanSuccess={() => undefined} />);
83
88
 
84
89
  await waitFor(() => expect(document.getElementById('code-scan-box')).toBeInTheDocument());
90
+ expect(mocks.scanViewport).toBeInstanceOf(HTMLElement);
85
91
 
86
92
  const scannerElement = document.querySelector<HTMLElement>('[id^="code-scanner-"]');
87
93
  const scannerSurface = scannerElement?.parentElement;
@@ -10,7 +10,14 @@
10
10
  import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
11
  import React, { useCallback } from 'react';
12
12
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
- import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, scanQrVideoFrame, useCodeScanner } from '../useCodeScanner';
13
+ import {
14
+ DEFAULT_CODE_FORMATS,
15
+ getCodeScanBoxSize,
16
+ isIOSBrowser,
17
+ scanQrVideoFrame,
18
+ scanQrVideoFrameWithZxingWasm,
19
+ useCodeScanner,
20
+ } from '../useCodeScanner';
14
21
 
15
22
  type MockScannerInstance = {
16
23
  applyVideoConstraints: ReturnType<typeof vi.fn>;
@@ -58,6 +65,10 @@ const jsQrMocks = vi.hoisted(() => {
58
65
  };
59
66
  });
60
67
 
68
+ const zxingWasmMocks = vi.hoisted(() => ({
69
+ decodeQrCodeWithZxingWasm: vi.fn(),
70
+ }));
71
+
61
72
  vi.mock('html5-qrcode', () => ({
62
73
  Html5Qrcode: mocks.Html5Qrcode,
63
74
  Html5QrcodeScannerState: {
@@ -81,6 +92,7 @@ vi.mock('html5-qrcode', () => ({
81
92
  }));
82
93
 
83
94
  vi.mock('jsqr', () => jsQrMocks);
95
+ vi.mock('../zxingWasmDecoder', () => zxingWasmMocks);
84
96
 
85
97
  function ScannerHost({
86
98
  onCameraStartFailure,
@@ -148,19 +160,71 @@ function stubImageElement(size: { width: number; height: number } = { width: 600
148
160
  });
149
161
  }
150
162
 
151
- function stubCanvas() {
163
+ function stubCanvas(imageData?: ImageData) {
152
164
  const canvasContext = {
153
165
  drawImage: vi.fn(),
154
- getImageData: vi.fn((_x: number, _y: number, width: number, height: number) => ({
155
- data: new Uint8ClampedArray(width * height * 4),
156
- height,
157
- width,
158
- })),
166
+ getImageData: vi.fn(
167
+ (_x: number, _y: number, width: number, height: number) =>
168
+ imageData || {
169
+ data: new Uint8ClampedArray(width * height * 4),
170
+ height,
171
+ width,
172
+ },
173
+ ),
159
174
  } as unknown as CanvasRenderingContext2D;
160
175
 
161
176
  vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext);
162
177
  }
163
178
 
179
+ function createCustomerQrFrame(width: number, height: number) {
180
+ const modules = [
181
+ '111111101101001111111',
182
+ '100000100101101000001',
183
+ '101110100111101011101',
184
+ '101110100111101011101',
185
+ '101110101100101011101',
186
+ '100000101000101000001',
187
+ '111111101010101111111',
188
+ '000000000111100000000',
189
+ '011111110001100110001',
190
+ '011010001101101111110',
191
+ '101101110010011000110',
192
+ '011000001011010110101',
193
+ '011110111011000100100',
194
+ '000000001100100001010',
195
+ '111111101001100101110',
196
+ '100000101001100001101',
197
+ '101110101010000101110',
198
+ '101110101011111001000',
199
+ '101110101110101011000',
200
+ '100000101101111010001',
201
+ '111111100110100010000',
202
+ ];
203
+ const data = new Uint8ClampedArray(width * height * 4);
204
+ data.fill(255);
205
+ const moduleSize = 12;
206
+ const left = Math.floor((width - modules.length * moduleSize) / 2);
207
+ const top = 60;
208
+
209
+ modules.forEach((row, rowIndex) => {
210
+ Array.from(row).forEach((module, columnIndex) => {
211
+ if (module !== '1') {
212
+ return;
213
+ }
214
+ for (let y = 0; y < moduleSize; y += 1) {
215
+ for (let x = 0; x < moduleSize; x += 1) {
216
+ const pixelIndex = ((top + rowIndex * moduleSize + y) * width + left + columnIndex * moduleSize + x) * 4;
217
+ data[pixelIndex] = 0;
218
+ data[pixelIndex + 1] = 0;
219
+ data[pixelIndex + 2] = 0;
220
+ }
221
+ }
222
+ });
223
+ });
224
+
225
+ return { data, height, width } as ImageData;
226
+ }
227
+
164
228
  describe('useCodeScanner', () => {
165
229
  beforeEach(() => {
166
230
  vi.clearAllMocks();
@@ -170,6 +234,7 @@ describe('useCodeScanner', () => {
170
234
  mocks.stop.mockResolvedValue(null);
171
235
  mocks.getState.mockReturnValue(1);
172
236
  jsQrMocks.default.mockReturnValue(undefined);
237
+ zxingWasmMocks.decodeQrCodeWithZxingWasm.mockResolvedValue(undefined);
173
238
  });
174
239
 
175
240
  afterEach(() => {
@@ -233,7 +298,7 @@ describe('useCodeScanner', () => {
233
298
  });
234
299
  });
235
300
 
236
- it('decodes a centered QR code from the raw camera frame fast path', () => {
301
+ it('keeps the existing centered scan region outside iOS', () => {
237
302
  jsQrMocks.default.mockReturnValueOnce({ data: 'FAST-QR' });
238
303
  stubCanvas();
239
304
  const video = document.createElement('video');
@@ -255,6 +320,115 @@ describe('useCodeScanner', () => {
255
320
  });
256
321
  });
257
322
 
323
+ it('decodes the camera preview visible on iOS without scanning offscreen content', async () => {
324
+ const actualJsQr = await vi.importActual<typeof import('jsqr')>('jsqr');
325
+ jsQrMocks.default.mockImplementationOnce(actualJsQr.default);
326
+ stubCanvas(createCustomerQrFrame(431, 933));
327
+ const video = document.createElement('video');
328
+ Object.defineProperties(video, {
329
+ getBoundingClientRect: {
330
+ value: () => ({ bottom: 864, height: 864, left: -573, right: 963, top: 0, width: 1536 }),
331
+ },
332
+ readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
333
+ videoHeight: { value: 1080 },
334
+ videoWidth: { value: 1920 },
335
+ });
336
+ const scanViewport = document.createElement('div');
337
+ vi.spyOn(scanViewport, 'getBoundingClientRect').mockReturnValue({
338
+ bottom: 844,
339
+ height: 844,
340
+ left: 0,
341
+ right: 390,
342
+ top: 0,
343
+ width: 390,
344
+ x: 0,
345
+ y: 0,
346
+ toJSON: () => undefined,
347
+ });
348
+ const canvas = document.createElement('canvas');
349
+
350
+ expect(scanQrVideoFrame(video, canvas, scanViewport)).toBe('TCBNVBY064J3');
351
+
352
+ const context = canvas.getContext('2d');
353
+ expect(context?.drawImage).toHaveBeenCalledWith(video, 716, 0, 488, 1055, 0, 0, 431, 933);
354
+ expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 431, 933, {
355
+ inversionAttempts: 'dontInvert',
356
+ });
357
+ });
358
+
359
+ it('uses ZXing WASM for high-resolution iOS camera frames', async () => {
360
+ const imageData = createCustomerQrFrame(431, 933);
361
+ stubCanvas(imageData);
362
+ zxingWasmMocks.decodeQrCodeWithZxingWasm.mockResolvedValueOnce('CUSTOMER-QR');
363
+ const video = document.createElement('video');
364
+ Object.defineProperties(video, {
365
+ getBoundingClientRect: {
366
+ value: () => ({ bottom: 864, height: 864, left: -573, right: 963, top: 0, width: 1536 }),
367
+ },
368
+ readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
369
+ videoHeight: { value: 1080 },
370
+ videoWidth: { value: 1920 },
371
+ });
372
+ const scanViewport = document.createElement('div');
373
+ vi.spyOn(scanViewport, 'getBoundingClientRect').mockReturnValue({
374
+ bottom: 844,
375
+ height: 844,
376
+ left: 0,
377
+ right: 390,
378
+ top: 0,
379
+ width: 390,
380
+ x: 0,
381
+ y: 0,
382
+ toJSON: () => undefined,
383
+ });
384
+
385
+ await expect(scanQrVideoFrameWithZxingWasm(video, document.createElement('canvas'), scanViewport)).resolves.toBe(
386
+ 'CUSTOMER-QR',
387
+ );
388
+ const context = document.createElement('canvas').getContext('2d');
389
+ expect(context?.drawImage).toHaveBeenCalledWith(video, 716, 0, 488, 1055, 0, 0, 488, 1055);
390
+ expect(jsQrMocks.default).not.toHaveBeenCalled();
391
+ expect(zxingWasmMocks.decodeQrCodeWithZxingWasm).toHaveBeenCalledWith(imageData);
392
+ });
393
+
394
+ it('detects iPhone browsers', () => {
395
+ vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0)');
396
+ expect(isIOSBrowser()).toBe(true);
397
+ });
398
+
399
+ it('detects iPad browsers using desktop mode', () => {
400
+ vi.stubGlobal('navigator', {
401
+ maxTouchPoints: 5,
402
+ platform: 'MacIntel',
403
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)',
404
+ });
405
+ expect(isIOSBrowser()).toBe(true);
406
+ });
407
+
408
+ it('keeps the iOS preview path disabled on other platforms', () => {
409
+ vi.stubGlobal('navigator', {
410
+ maxTouchPoints: 0,
411
+ platform: 'Linux x86_64',
412
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64)',
413
+ });
414
+ expect(isIOSBrowser()).toBe(false);
415
+ });
416
+
417
+ it('waits for a measurable iOS preview instead of scanning hidden camera content', () => {
418
+ stubCanvas();
419
+ const video = document.createElement('video');
420
+ Object.defineProperties(video, {
421
+ readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
422
+ videoHeight: { value: 1080 },
423
+ videoWidth: { value: 1920 },
424
+ });
425
+ const canvas = document.createElement('canvas');
426
+ const scanViewport = document.createElement('div');
427
+
428
+ expect(scanQrVideoFrame(video, canvas, scanViewport)).toBeUndefined();
429
+ expect(jsQrMocks.default).not.toHaveBeenCalled();
430
+ });
431
+
258
432
  it('enables continuous camera focus when the device supports it', async () => {
259
433
  mocks.getRunningTrackCapabilities.mockReturnValue({ focusMode: ['manual', 'continuous'] });
260
434
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { beforeEach, expect, it, vi } from 'vitest';
11
+ import { decodeQrCodeWithZxingWasm } from '../zxingWasmDecoder';
12
+
13
+ const mocks = vi.hoisted(() => ({
14
+ prepareZXingModule: vi.fn(),
15
+ purgeZXingModule: vi.fn(),
16
+ readBarcodes: vi.fn(),
17
+ }));
18
+
19
+ vi.mock('zxing-wasm/reader', () => mocks);
20
+ vi.mock('zxing-wasm/reader/zxing_reader.wasm', () => ({ default: '/assets/zxing_reader.wasm' }));
21
+
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ mocks.prepareZXingModule.mockResolvedValue(undefined);
25
+ mocks.readBarcodes.mockResolvedValue([]);
26
+ });
27
+
28
+ it('retries decoder loading and uses downscaled QR detection', async () => {
29
+ const imageData = { data: new Uint8ClampedArray(16), height: 2, width: 2 } as ImageData;
30
+ mocks.prepareZXingModule.mockRejectedValueOnce(new Error('decoder loading failed'));
31
+
32
+ await expect(decodeQrCodeWithZxingWasm(imageData)).rejects.toThrow('decoder loading failed');
33
+ expect(mocks.purgeZXingModule).toHaveBeenCalledTimes(1);
34
+
35
+ mocks.readBarcodes.mockResolvedValueOnce([{ text: 'CUSTOMER-QR' }]);
36
+
37
+ await expect(decodeQrCodeWithZxingWasm(imageData)).resolves.toBe('CUSTOMER-QR');
38
+ expect(mocks.prepareZXingModule).toHaveBeenCalledTimes(2);
39
+ const { overrides } = mocks.prepareZXingModule.mock.calls[1][0] as {
40
+ overrides: { locateFile: () => string };
41
+ };
42
+ const { locateFile } = overrides;
43
+ expect(locateFile()).toBe('/assets/zxing_reader.wasm');
44
+ expect(mocks.prepareZXingModule).toHaveBeenLastCalledWith({
45
+ fireImmediately: true,
46
+ overrides: { locateFile: expect.any(Function) },
47
+ });
48
+ expect(mocks.readBarcodes).toHaveBeenCalledWith(imageData, {
49
+ binarizer: 'GlobalHistogram',
50
+ downscaleThreshold: 300,
51
+ formats: ['QRCode'],
52
+ maxNumberOfSymbols: 1,
53
+ tryDenoise: true,
54
+ tryDownscale: true,
55
+ tryHarder: true,
56
+ tryInvert: true,
57
+ tryRotate: true,
58
+ });
59
+ });
60
+
61
+ it('retries reflective frames with local adaptive binarization', async () => {
62
+ const imageData = { data: new Uint8ClampedArray(16), height: 2, width: 2 } as ImageData;
63
+ mocks.readBarcodes.mockResolvedValueOnce([]).mockResolvedValueOnce([{ text: 'REFLECTIVE-QR' }]);
64
+
65
+ await expect(decodeQrCodeWithZxingWasm(imageData)).resolves.toBe('REFLECTIVE-QR');
66
+ expect(mocks.readBarcodes).toHaveBeenCalledTimes(2);
67
+ expect(mocks.readBarcodes).toHaveBeenNthCalledWith(2, imageData, {
68
+ binarizer: 'LocalAverage',
69
+ downscaleThreshold: 300,
70
+ formats: ['QRCode'],
71
+ maxNumberOfSymbols: 1,
72
+ tryDenoise: true,
73
+ tryDownscale: true,
74
+ tryHarder: true,
75
+ tryInvert: true,
76
+ tryRotate: true,
77
+ });
78
+ });