@nocobase/client-v2 2.3.0-alpha.1 → 2.3.0-beta.11

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 (173) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  3. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  4. package/es/flow/actions/linkageRules.d.ts +25 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +6 -30
  6. package/es/flow/components/FlowRoute.d.ts +2 -2
  7. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  8. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +32 -0
  9. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  10. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  11. package/es/flow/components/filter/VariableFilterItem.d.ts +7 -0
  12. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  13. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  14. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  15. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  17. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  18. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  19. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  20. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  21. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  22. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  23. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  24. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  25. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  26. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  27. package/es/flow/models/blocks/table/TableColumnModel.d.ts +12 -0
  28. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  29. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  30. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  31. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  32. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +1 -0
  35. package/es/index.mjs +148 -145
  36. package/es/ui-operation/index.d.ts +15 -0
  37. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  38. package/lib/index.js +170 -167
  39. package/lib/locale/languageCodes.js +1 -0
  40. package/package.json +10 -8
  41. package/src/APIClient.ts +92 -0
  42. package/src/Application.tsx +3 -1
  43. package/src/__tests__/APIClient.test.tsx +58 -0
  44. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  45. package/src/__tests__/settings-center.test.tsx +270 -6
  46. package/src/acl/ACLProvider.tsx +2 -0
  47. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  48. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  49. package/src/collection-manager/field-configure.ts +2 -2
  50. package/src/components/form/ScanInput/CodeScanner.tsx +64 -35
  51. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +107 -0
  52. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +294 -24
  53. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  54. package/src/components/form/ScanInput/useCodeScanner.ts +281 -23
  55. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  56. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  57. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  58. package/src/components/form/table/Table.tsx +81 -6
  59. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  60. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  61. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  62. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  63. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  64. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  65. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  66. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  67. package/src/flow/actions/linkageRules.tsx +59 -16
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  69. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FieldAssignValueInput.tsx +42 -621
  74. package/src/flow/components/FlowRoute.tsx +71 -29
  75. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  76. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +335 -0
  77. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +389 -0
  78. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  79. package/src/flow/components/field-value-variable/index.ts +12 -0
  80. package/src/flow/components/filter/VariableFilterItem.tsx +23 -6
  81. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  82. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  83. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  84. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  85. package/src/flow/index.ts +2 -0
  86. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  87. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  88. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  89. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  90. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  91. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  92. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  93. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  94. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  95. package/src/flow/models/base/CollectionBlockModel.tsx +38 -3
  96. package/src/flow/models/base/GridModel.tsx +0 -1
  97. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  98. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  99. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  100. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  101. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  102. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  103. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  104. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +36 -50
  105. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  106. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  107. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  108. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  109. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  110. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  111. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  112. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  113. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  114. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  115. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  116. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  117. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  118. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  119. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  120. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  121. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  122. package/src/flow/models/blocks/form/FormBlockModel.tsx +73 -5
  123. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  124. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +153 -2
  125. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  126. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  127. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  128. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  129. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  130. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  131. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  132. package/src/flow/models/blocks/table/TableBlockModel.tsx +28 -2
  133. package/src/flow/models/blocks/table/TableColumnModel.tsx +48 -9
  134. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  135. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  136. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  137. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  138. package/src/flow/models/blocks/table/utils.ts +7 -0
  139. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  140. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +12 -3
  141. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  142. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  143. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +29 -3
  144. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  145. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +5 -1
  146. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +191 -3
  147. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  148. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  149. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  150. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  151. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  152. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  153. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  154. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  155. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  156. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  157. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  158. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  159. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  160. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  161. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  162. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  163. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  164. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  165. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  166. package/src/index.ts +1 -0
  167. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  168. package/src/layout-manager/LayoutRoute.tsx +2 -1
  169. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  170. package/src/locale/languageCodes.ts +1 -0
  171. package/src/settings-center/AdminSettingsLayout.tsx +23 -2
  172. package/src/ui-operation/index.ts +33 -0
  173. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -7,13 +7,22 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
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, 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 = {
23
+ applyVideoConstraints: ReturnType<typeof vi.fn>;
16
24
  clear: ReturnType<typeof vi.fn>;
25
+ getRunningTrackCapabilities: ReturnType<typeof vi.fn>;
17
26
  getState: ReturnType<typeof vi.fn>;
18
27
  scanFileV2: ReturnType<typeof vi.fn>;
19
28
  start: ReturnType<typeof vi.fn>;
@@ -24,18 +33,24 @@ const mocks = vi.hoisted(() => {
24
33
  const start = vi.fn().mockResolvedValue(null);
25
34
  const stop = vi.fn().mockResolvedValue(null);
26
35
  const clear = vi.fn();
36
+ const applyVideoConstraints = vi.fn().mockResolvedValue(undefined);
37
+ const getRunningTrackCapabilities = vi.fn(() => ({}));
27
38
  const getState = vi.fn(() => 1);
28
39
  const scanFileV2 = vi.fn();
29
40
  const Html5Qrcode = vi.fn(function MockHtml5Qrcode(this: MockScannerInstance) {
30
41
  this.start = start;
31
42
  this.stop = stop;
32
43
  this.clear = clear;
44
+ this.applyVideoConstraints = applyVideoConstraints;
45
+ this.getRunningTrackCapabilities = getRunningTrackCapabilities;
33
46
  this.getState = getState;
34
47
  this.scanFileV2 = scanFileV2;
35
48
  });
36
49
 
37
50
  return {
51
+ applyVideoConstraints,
38
52
  clear,
53
+ getRunningTrackCapabilities,
39
54
  getState,
40
55
  Html5Qrcode,
41
56
  scanFileV2,
@@ -50,6 +65,10 @@ const jsQrMocks = vi.hoisted(() => {
50
65
  };
51
66
  });
52
67
 
68
+ const zxingWasmMocks = vi.hoisted(() => ({
69
+ decodeQrCodeWithZxingWasm: vi.fn(),
70
+ }));
71
+
53
72
  vi.mock('html5-qrcode', () => ({
54
73
  Html5Qrcode: mocks.Html5Qrcode,
55
74
  Html5QrcodeScannerState: {
@@ -73,22 +92,20 @@ vi.mock('html5-qrcode', () => ({
73
92
  }));
74
93
 
75
94
  vi.mock('jsqr', () => jsQrMocks);
95
+ vi.mock('../zxingWasmDecoder', () => zxingWasmMocks);
76
96
 
77
97
  function ScannerHost({
78
98
  onCameraStartFailure,
79
99
  onScanFailure,
80
- scanBoxSize,
81
100
  }: {
82
101
  onCameraStartFailure?: (error: unknown) => void;
83
102
  onScanFailure?: () => void;
84
- scanBoxSize?: { width: number; height: number };
85
103
  } = {}) {
86
104
  const handleScanSuccess = useCallback(() => undefined, []);
87
105
 
88
106
  useCodeScanner({
89
107
  elementId: 'scanner',
90
108
  enabled: true,
91
- scanBoxSize,
92
109
  onCameraStartFailure,
93
110
  onScanFailure,
94
111
  onScanSuccess: handleScanSuccess,
@@ -143,23 +160,81 @@ function stubImageElement(size: { width: number; height: number } = { width: 600
143
160
  });
144
161
  }
145
162
 
146
- function stubCanvas() {
163
+ function stubCanvas(imageData?: ImageData) {
147
164
  const canvasContext = {
148
165
  drawImage: vi.fn(),
149
- getImageData: vi.fn((_x: number, _y: number, width: number, height: number) => ({
150
- data: new Uint8ClampedArray(width * height * 4),
151
- height,
152
- width,
153
- })),
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
+ ),
154
174
  } as unknown as CanvasRenderingContext2D;
155
175
 
156
176
  vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext);
157
177
  }
158
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
+
159
228
  describe('useCodeScanner', () => {
160
229
  beforeEach(() => {
161
230
  vi.clearAllMocks();
231
+ mocks.applyVideoConstraints.mockResolvedValue(undefined);
232
+ mocks.getRunningTrackCapabilities.mockReturnValue({});
233
+ mocks.start.mockResolvedValue(null);
234
+ mocks.stop.mockResolvedValue(null);
235
+ mocks.getState.mockReturnValue(1);
162
236
  jsQrMocks.default.mockReturnValue(undefined);
237
+ zxingWasmMocks.decodeQrCodeWithZxingWasm.mockResolvedValue(undefined);
163
238
  });
164
239
 
165
240
  afterEach(() => {
@@ -188,32 +263,182 @@ describe('useCodeScanner', () => {
188
263
  | { qrbox?: (width: number, height: number) => { width: number; height: number } }
189
264
  | undefined;
190
265
 
191
- expect(config?.qrbox?.(390, 844)).toEqual({ width: 319, height: 240 });
192
- expect(getCodeScanBoxSize(390, 844)).toEqual({ width: 319, height: 240 });
266
+ expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
267
+ expect(getCodeScanBoxSize(1280, 720)).toEqual({ width: 1152, height: 504 });
193
268
  });
194
269
 
195
- it('can use the visible viewport scan box when the camera video is wider than the viewport', async () => {
196
- render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
270
+ it('uses optimized camera constraints without requiring a user-selected scan mode', async () => {
271
+ render(<ScannerHost />);
197
272
 
198
273
  await waitFor(() => expect(mocks.start).toHaveBeenCalled());
199
274
 
200
275
  const config = mocks.start.mock.calls[0]?.[1] as
201
- | { qrbox?: (width: number, height: number) => { width: number; height: number } }
276
+ | {
277
+ disableFlip?: boolean;
278
+ fps?: number;
279
+ qrbox?: (width: number, height: number) => { width: number; height: number };
280
+ videoConstraints?: MediaTrackConstraints;
281
+ }
202
282
  | undefined;
203
283
 
204
- expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
284
+ expect(config?.qrbox?.(1280, 720)).toEqual({ width: 1152, height: 504 });
285
+ expect(config).toMatchObject({
286
+ disableFlip: false,
287
+ fps: 8,
288
+ videoConstraints: {
289
+ facingMode: { ideal: 'environment' },
290
+ width: { ideal: 1920 },
291
+ height: { ideal: 1080 },
292
+ frameRate: { ideal: 30 },
293
+ },
294
+ });
295
+ expect(mocks.Html5Qrcode).toHaveBeenCalledWith('scanner', {
296
+ formatsToSupport: DEFAULT_CODE_FORMATS,
297
+ verbose: false,
298
+ });
205
299
  });
206
300
 
207
- it('clamps the scan box to the camera viewfinder size', async () => {
208
- render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
301
+ it('keeps the existing centered scan region outside iOS', () => {
302
+ jsQrMocks.default.mockReturnValueOnce({ data: 'FAST-QR' });
303
+ stubCanvas();
304
+ const video = document.createElement('video');
305
+ Object.defineProperties(video, {
306
+ clientHeight: { value: 720 },
307
+ clientWidth: { value: 1280 },
308
+ readyState: { value: HTMLMediaElement.HAVE_CURRENT_DATA },
309
+ videoHeight: { value: 1080 },
310
+ videoWidth: { value: 1920 },
311
+ });
312
+ const canvas = document.createElement('canvas');
209
313
 
210
- await waitFor(() => expect(mocks.start).toHaveBeenCalled());
314
+ expect(scanQrVideoFrame(video, canvas)).toBe('FAST-QR');
211
315
 
212
- const config = mocks.start.mock.calls[0]?.[1] as
213
- | { qrbox?: (width: number, height: number) => { width: number; height: number } }
214
- | undefined;
316
+ const context = canvas.getContext('2d');
317
+ expect(context?.drawImage).toHaveBeenCalledWith(video, 96, 162, 1728, 756, 0, 0, 960, 420);
318
+ expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 960, 420, {
319
+ inversionAttempts: 'dontInvert',
320
+ });
321
+ });
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');
215
349
 
216
- expect(config?.qrbox?.(200, 160)).toEqual({ width: 200, height: 160 });
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
+
432
+ it('enables continuous camera focus when the device supports it', async () => {
433
+ mocks.getRunningTrackCapabilities.mockReturnValue({ focusMode: ['manual', 'continuous'] });
434
+
435
+ render(<ScannerHost />);
436
+
437
+ await waitFor(() =>
438
+ expect(mocks.applyVideoConstraints).toHaveBeenCalledWith({
439
+ advanced: [{ focusMode: 'continuous' }],
440
+ }),
441
+ );
217
442
  });
218
443
 
219
444
  it('reports camera start failures through the dedicated handler', async () => {
@@ -228,6 +453,51 @@ describe('useCodeScanner', () => {
228
453
  expect(handleScanFailure).not.toHaveBeenCalled();
229
454
  });
230
455
 
456
+ it('stops a camera that finishes starting after the scanner unmounts', async () => {
457
+ let resolveStart: ((value: null) => void) | undefined;
458
+ mocks.start.mockReturnValueOnce(
459
+ new Promise<null>((resolve) => {
460
+ resolveStart = resolve;
461
+ }),
462
+ );
463
+ mocks.getState.mockReturnValueOnce(1).mockReturnValue(2);
464
+
465
+ const { unmount } = render(<ScannerHost />);
466
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
467
+
468
+ unmount();
469
+ await act(async () => {
470
+ resolveStart?.(null);
471
+ });
472
+
473
+ await waitFor(() => expect(mocks.stop).toHaveBeenCalledTimes(1));
474
+ expect(mocks.clear).toHaveBeenCalled();
475
+ });
476
+
477
+ it('ignores a camera start failure after the scanner unmounts', async () => {
478
+ let rejectStart: ((error: Error) => void) | undefined;
479
+ mocks.start.mockReturnValueOnce(
480
+ new Promise<null>((_resolve, reject) => {
481
+ rejectStart = reject;
482
+ }),
483
+ );
484
+ const handleCameraStartFailure = vi.fn();
485
+ const handleScanFailure = vi.fn();
486
+
487
+ const { unmount } = render(
488
+ <ScannerHost onCameraStartFailure={handleCameraStartFailure} onScanFailure={handleScanFailure} />,
489
+ );
490
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
491
+
492
+ unmount();
493
+ await act(async () => {
494
+ rejectStart?.(new Error('Camera start canceled'));
495
+ });
496
+
497
+ expect(handleCameraStartFailure).not.toHaveBeenCalled();
498
+ expect(handleScanFailure).not.toHaveBeenCalled();
499
+ });
500
+
231
501
  it('uses jsQR first for Safari uploaded QR images', async () => {
232
502
  const handleScanSuccess = vi.fn();
233
503
  jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
@@ -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
+ });