openxiangda-cli 2.0.0-alpha.85 → 2.0.0-alpha.89

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 (65) hide show
  1. package/package.json +4 -4
  2. package/template/AGENTS.md +4 -4
  3. package/template/README.md +4 -4
  4. package/template/apps/server/package.json +1 -1
  5. package/template/apps/server/src/app.module.ts +14 -1
  6. package/template/apps/server/test/smoke.test.ts +3 -1
  7. package/template/apps/web/data-api-live.e2e.html +15 -0
  8. package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
  9. package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
  10. package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
  11. package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
  12. package/template/apps/web/e2e/resources.spec.ts +258 -20
  13. package/template/apps/web/field-protocol.e2e.html +12 -0
  14. package/template/apps/web/package.json +1 -1
  15. package/template/apps/web/playwright.config.ts +3 -1
  16. package/template/apps/web/scripts/check.mjs +8 -5
  17. package/template/apps/web/scripts/verify-build.mjs +1 -1
  18. package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
  19. package/template/apps/web/src/FilePreviewPage.tsx +30 -9
  20. package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
  21. package/template/apps/web/src/Shell.tsx +93 -6
  22. package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
  23. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
  24. package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
  25. package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
  26. package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
  27. package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
  28. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
  29. package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
  30. package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
  31. package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
  32. package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
  33. package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
  34. package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
  35. package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
  36. package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
  37. package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
  38. package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
  39. package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
  40. package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
  41. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
  42. package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
  43. package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
  44. package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
  45. package/template/apps/web/src/components/resource/resource-import.ts +4 -4
  46. package/template/apps/web/src/data-provider.ts +59 -34
  47. package/template/apps/web/src/platform-client.ts +341 -136
  48. package/template/apps/web/src/runtime.tsx +156 -17
  49. package/template/apps/web/src/styles.css +264 -0
  50. package/template/apps/web/test/address-field.test.ts +66 -0
  51. package/template/apps/web/test/cascade-value.test.ts +68 -0
  52. package/template/apps/web/test/contracts.test.ts +29 -5
  53. package/template/apps/web/test/directory-value.test.ts +55 -0
  54. package/template/apps/web/test/field-bounds.test.ts +17 -0
  55. package/template/apps/web/test/field-form-codec.test.ts +85 -0
  56. package/template/apps/web/test/location-field.test.ts +83 -0
  57. package/template/apps/web/test/resource-import.test.ts +8 -7
  58. package/template/apps/web/test/resource-query.test.ts +144 -0
  59. package/template/apps/web/test/rich-json-field.test.ts +53 -0
  60. package/template/apps/web/test/signature-serial-field.test.ts +40 -0
  61. package/template/apps/web/test/subtable-value.test.ts +144 -0
  62. package/template/openxiangda.config.ts +1 -1
  63. package/template/package.json +3 -3
  64. package/template/packages/contracts/src/generated.ts +95 -0
  65. package/template/scripts/verify-template-budget.mjs +11 -4
@@ -17,30 +17,53 @@ test('clean template is resource-driven and contains no HGY fixture', () => {
17
17
  test('standard surfaces keep authoritative directory/resource selectors and mobile routes', () => {
18
18
  const surface = readFileSync(new URL('../src/components/resource/SurfaceFields.tsx', import.meta.url), 'utf8');
19
19
  const selector = readFileSync(new URL('../src/AuthoritativeSelector.tsx', import.meta.url), 'utf8');
20
+ const directoryPicker = readFileSync(new URL('../src/components/platform-fields/PlatformDirectoryPicker.tsx', import.meta.url), 'utf8');
21
+ const directoryValue = readFileSync(new URL('../src/components/platform-fields/directory-value.ts', import.meta.url), 'utf8');
22
+ const resourceSelector = readFileSync(new URL('../src/components/platform-fields/ResourceReferenceField.tsx', import.meta.url), 'utf8');
20
23
  const crud = readFileSync(new URL('../src/components/resource/GeneratedResourceCrud.tsx', import.meta.url), 'utf8');
21
24
  assert.match(surface, /PlatformDirectoryPicker/);
22
- assert.match(surface, /source="resource"/);
23
- assert.match(surface, /MobileAuthoritativeSelector/);
25
+ assert.match(resourceSelector, /source="resource"/);
26
+ assert.match(resourceSelector, /MobileAuthoritativeSelector/);
24
27
  assert.match(surface, /oxa-mobile-file-field/);
25
28
  assert.match(surface, /mobilePending/);
26
29
  assert.match(surface, /上传中/);
27
30
  assert.match(surface, /重试/);
28
31
  assert.match(surface, /AttachmentFileList files=\{refs\}/);
29
- assert.match(selector, /searchResource|resolveResource/);
32
+ assert.match(selector, /searchResource/);
33
+ assert.doesNotMatch(selector, /resolveResource/);
34
+ assert.match(selector, /business fields never use this path/);
35
+ assert.match(selector, /storedReferenceOptions/);
36
+ assert.doesNotMatch(directoryPicker, /resolveDirectory/);
37
+ assert.match(directoryValue, /entry\.snapshot/);
38
+ assert.match(selector, /ResourceReferenceValue/);
30
39
  assert.match(selector, /role="dialog"/);
31
40
  assert.match(selector, /oxa-mobile-reference-sheet/);
32
41
  assert.match(crud, /MobileResourceListPage/);
33
42
  assert.match(crud, /fieldWritable/);
34
43
  assert.match(crud, /exportResourceRows/);
35
- assert.match(crud, /text\/csv;charset=utf-8/);
44
+ assert.doesNotMatch(crud, /rows\.push|pageSize = 500|csvCell/);
36
45
  assert.match(crud, /数据读取失败/);
37
46
  assert.match(crud, /state\.list\.query\.refetch/);
38
47
  assert.match(crud, /key=\{pageKey\}/);
39
48
  assert.match(crud, /setQuery\(\{\}\)/);
40
49
  const client = readFileSync(new URL('../src/platform-client.ts', import.meta.url), 'utf8');
50
+ const resourceQuery = readFileSync(new URL('../src/components/platform-fields/resource-query.ts', import.meta.url), 'utf8');
41
51
  assert.match(client, /declaredFields\.has\(requestedSort\.field\)/);
42
- assert.match(client, /if \(!declaredFields\.has\(fieldCode\)\) continue/);
52
+ assert.match(client, /SCHEMA_VERSIONS\.dataFieldSourceQuery/);
53
+ assert.match(client, /SCHEMA_VERSIONS\.dataExportRequest/);
54
+ assert.match(client, /`\$\{base\}\/export`/);
55
+ assert.match(client, /accept', 'text\/csv'/);
56
+ assert.match(client, /\/source\/query/);
57
+ assert.doesNotMatch(client, /operator:\s*'ilike'|operator:\s*'cs'|filters:/);
58
+ assert.match(resourceQuery, /if \(!declaredFields\.has\(fieldCode\)\) continue/);
43
59
  assert.match(client, /\/api\/auth\/logout/);
60
+ assert.match(client, /x-openxiangda-role-session-id/);
61
+ assert.match(client, /requestApplicationApi/);
62
+ assert.doesNotMatch(client, /\/native\/current-user/);
63
+ const runtime = readFileSync(new URL('../src/runtime.tsx', import.meta.url), 'utf8');
64
+ assert.match(runtime, /selection_required/);
65
+ assert.match(runtime, /identityEpoch/);
66
+ assert.doesNotMatch(runtime, /focused_role|authorization-mode/);
44
67
  });
45
68
 
46
69
  test('admin appearance is a bounded Ant Design theme with compact route chrome', () => {
@@ -60,6 +83,7 @@ test('admin appearance is a bounded Ant Design theme with compact route chrome',
60
83
  assert.match(shell, /<Segmented/);
61
84
  assert.match(shell, /跟随系统/);
62
85
  assert.match(shell, /退出登录/);
86
+ assert.match(shell, /切换应用角色/);
63
87
  assert.match(shell, /<Header className="oxa-topbar">\s*<Breadcrumb/);
64
88
  assert.doesNotMatch(shell, /oxa-topbar-page/);
65
89
  });
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import type {
4
+ DepartmentReferenceValue,
5
+ DirectoryEntry,
6
+ UserReferenceValue,
7
+ } from 'openxiangda-contracts/browser';
8
+ import {
9
+ directoryEntryFromStored,
10
+ directoryStoredValueFromEntries,
11
+ } from '../src/components/platform-fields/directory-value';
12
+
13
+ test('preserves complete user snapshots through directory selection', () => {
14
+ const user: UserReferenceValue = {
15
+ value: 'user-1',
16
+ label: '张老师',
17
+ avatarUrl: 'https://cdn.example/avatar.png',
18
+ employeeNo: 'T001',
19
+ mobile: '13800000000',
20
+ email: 'teacher@example.test',
21
+ departments: [{
22
+ value: 'department-1',
23
+ label: '理学院',
24
+ fullPath: '学校 / 理学院',
25
+ }],
26
+ };
27
+ const entry = directoryEntryFromStored('user', user);
28
+ assert.equal(entry.description, 'T001 · 理学院');
29
+ assert.equal(directoryStoredValueFromEntries([entry], false), user);
30
+ });
31
+
32
+ test('preserves department path and parent snapshots without resolving again', () => {
33
+ const department: DepartmentReferenceValue = {
34
+ value: 'department-2',
35
+ label: '实验中心',
36
+ fullPath: '学校 / 理学院 / 实验中心',
37
+ path: [
38
+ { value: 'root', label: '学校' },
39
+ { value: 'department-1', label: '理学院' },
40
+ { value: 'department-2', label: '实验中心' },
41
+ ],
42
+ parent: { value: 'department-1', label: '理学院' },
43
+ };
44
+ const entry = directoryEntryFromStored('department', department);
45
+ assert.equal(entry.parentId, 'department-1');
46
+ assert.deepEqual(entry.path?.map(item => item.id), [
47
+ 'root',
48
+ 'department-1',
49
+ 'department-2',
50
+ ]);
51
+ assert.deepEqual(
52
+ directoryStoredValueFromEntries([entry, entry] as DirectoryEntry[], true),
53
+ [department, department]
54
+ );
55
+ });
@@ -0,0 +1,17 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+
5
+ test('standard controls honor declared numeric, text, and file bounds', () => {
6
+ const source = readFileSync(
7
+ new URL('../src/components/resource/SurfaceFields.tsx', import.meta.url),
8
+ 'utf8'
9
+ );
10
+ assert.match(source, /field\.type === 'number\.integer' \? 0 : field\.scale/);
11
+ assert.match(source, /10 \*\* -scale/);
12
+ assert.match(source, /field\.widget === 'money'/);
13
+ assert.match(source, /field\.widget === 'percent'/);
14
+ assert.match(source, /maxLength: field\.maxLength/);
15
+ assert.match(source, /file\.size > maxSizeMb \* 1024 \* 1024/);
16
+ assert.doesNotMatch(source, /<InputNumber min=\{0\}/);
17
+ });
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import type { DataFieldSurface, DataResourceSurface } from 'openxiangda-contracts/browser';
4
+ import {
5
+ fieldValueForData,
6
+ fieldValueForForm,
7
+ } from '../src/components/platform-fields/field-form-codec';
8
+
9
+ function field(
10
+ type: DataFieldSurface['type'],
11
+ widget: DataFieldSurface['widget'],
12
+ input: Partial<DataFieldSurface> = {}
13
+ ): DataFieldSurface {
14
+ return {
15
+ label: type,
16
+ type,
17
+ widget,
18
+ readCapabilities: [],
19
+ createCapabilities: [],
20
+ updateCapabilities: [],
21
+ ...input,
22
+ };
23
+ }
24
+
25
+ test('round-trips date, time and datetime values through form controls', () => {
26
+ const date = field('date', 'date');
27
+ const minute = field('time', 'time', { timePrecision: 'minute' });
28
+ const second = field('time', 'time', { timePrecision: 'second' });
29
+ const datetime = field('datetime', 'datetime');
30
+ assert.equal(fieldValueForData(date, fieldValueForForm(date, '2026-08-24')), '2026-08-24');
31
+ assert.equal(fieldValueForData(minute, fieldValueForForm(minute, '09:30:00')), '09:30');
32
+ assert.equal(fieldValueForData(second, fieldValueForForm(second, '09:30:45')), '09:30:45');
33
+ assert.equal(
34
+ fieldValueForData(datetime, fieldValueForForm(datetime, '2026-08-24T08:30:45.000Z')),
35
+ '2026-08-24T08:30:45.000Z'
36
+ );
37
+ });
38
+
39
+ test('round-trips date and datetime ranges as closed objects', () => {
40
+ const dateRange = field('date-range', 'date-range');
41
+ const datetimeRange = field('datetime-range', 'datetime-range');
42
+ assert.deepEqual(
43
+ fieldValueForData(
44
+ dateRange,
45
+ fieldValueForForm(dateRange, { start: '2026-08-01', end: '2026-08-31' })
46
+ ),
47
+ { start: '2026-08-01', end: '2026-08-31' }
48
+ );
49
+ assert.deepEqual(
50
+ fieldValueForData(
51
+ datetimeRange,
52
+ fieldValueForForm(datetimeRange, {
53
+ start: '2026-08-01T00:00:00.000Z',
54
+ end: '2026-08-31T00:00:00.000Z',
55
+ })
56
+ ),
57
+ {
58
+ start: '2026-08-01T00:00:00.000Z',
59
+ end: '2026-08-31T00:00:00.000Z',
60
+ }
61
+ );
62
+ });
63
+
64
+ test('preserves explicit empty values for nullable fields', () => {
65
+ const surface = {
66
+ fields: { observedAt: field('datetime', 'datetime') },
67
+ } satisfies DataResourceSurface;
68
+ assert.equal(fieldValueForData(surface.fields.observedAt, null), null);
69
+ assert.equal(fieldValueForForm(surface.fields.observedAt, null), undefined);
70
+ });
71
+
72
+ test('keeps query-facing date values free of Dayjs transport objects', () => {
73
+ const date = field('date', 'date');
74
+ const range = field('datetime-range', 'datetime-range');
75
+ const dateValue = fieldValueForData(date, fieldValueForForm(date, '2026-08-24'));
76
+ const rangeValue = fieldValueForData(
77
+ range,
78
+ fieldValueForForm(range, {
79
+ start: '2026-08-01T00:00:00.000Z',
80
+ end: '2026-08-31T00:00:00.000Z',
81
+ })
82
+ );
83
+ assert.equal(typeof dateValue, 'string');
84
+ assert.deepEqual(JSON.parse(JSON.stringify(rangeValue)), rangeValue);
85
+ });
@@ -0,0 +1,83 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import {
5
+ browserLocationValue,
6
+ dingTalkLocationValue,
7
+ } from '../src/components/platform-fields/location-value';
8
+
9
+ test('normalizes browser coordinates into an exact snapshot', () => {
10
+ assert.deepEqual(
11
+ browserLocationValue({
12
+ longitude: 120.1234567,
13
+ latitude: 30.7654321,
14
+ accuracy: 8.5,
15
+ timestamp: Date.parse('2026-08-24T08:00:00.000Z'),
16
+ }),
17
+ {
18
+ source: 'browser',
19
+ longitude: 120.1234567,
20
+ latitude: 30.7654321,
21
+ accuracy: 8.5,
22
+ capturedAt: '2026-08-24T08:00:00.000Z',
23
+ }
24
+ );
25
+ });
26
+
27
+ test('normalizes DingTalk coordinates and optional reverse-geocoded display data', () => {
28
+ assert.deepEqual(
29
+ dingTalkLocationValue(
30
+ {
31
+ longitude: '120.1234567',
32
+ latitude: '30.7654321',
33
+ accuracy: '12',
34
+ address: ' 浙江省杭州市西湖区 ',
35
+ poiName: '测试大楼',
36
+ province: '浙江省',
37
+ city: '杭州市',
38
+ district: '西湖区',
39
+ },
40
+ Date.parse('2026-08-24T08:00:00.000Z')
41
+ ),
42
+ {
43
+ source: 'dingTalk',
44
+ longitude: 120.1234567,
45
+ latitude: 30.7654321,
46
+ accuracy: 12,
47
+ address: '浙江省杭州市西湖区',
48
+ name: '测试大楼',
49
+ province: '浙江省',
50
+ city: '杭州市',
51
+ district: '西湖区',
52
+ capturedAt: '2026-08-24T08:00:00.000Z',
53
+ }
54
+ );
55
+ });
56
+
57
+ test('rejects missing and out-of-range coordinates', () => {
58
+ assert.throws(
59
+ () => dingTalkLocationValue({ latitude: 30 }),
60
+ /有效经度/
61
+ );
62
+ assert.throws(
63
+ () => browserLocationValue({ longitude: 181, latitude: 30 }),
64
+ /有效经度/
65
+ );
66
+ assert.throws(
67
+ () => browserLocationValue({ longitude: 120, latitude: -91 }),
68
+ /有效纬度/
69
+ );
70
+ });
71
+
72
+ test('location control captures coordinates without a manual address input', () => {
73
+ const source = readFileSync(
74
+ new URL('../src/components/platform-fields/LocationField.tsx', import.meta.url),
75
+ 'utf8'
76
+ );
77
+ assert.match(source, /coordinate: 0/);
78
+ assert.match(source, /withReGeocode: true/);
79
+ assert.match(source, /browserLocationValue/);
80
+ assert.match(source, /dingTalkLocationValue/);
81
+ assert.match(source, /LocationValueDisplay/);
82
+ assert.doesNotMatch(source, /<Input|<textarea|contentEditable/);
83
+ });
@@ -6,19 +6,20 @@ import { parseResourceImportMatrix } from '../src/components/resource/resource-i
6
6
  const surface = {
7
7
  generated: true,
8
8
  fields: {
9
- visitorName: { label: '访客姓名', widget: 'text', requiredHint: true },
10
- visitorCount: { label: '来访人数', widget: 'number' },
11
- approved: { label: '是否通过', widget: 'boolean' },
9
+ visitorName: { label: '访客姓名', type: 'text.short', widget: 'text', requiredHint: true },
10
+ visitorCount: { label: '来访人数', type: 'number.integer', widget: 'number' },
11
+ approved: { label: '是否通过', type: 'boolean', widget: 'switch' },
12
12
  status: {
13
13
  label: '状态',
14
+ type: 'option.single',
14
15
  widget: 'select',
15
16
  options: [
16
17
  { label: '待审核', value: 'pending' },
17
18
  { label: '已通过', value: 'approved' },
18
19
  ],
19
20
  },
20
- visitDate: { label: '来访日期', widget: 'date' },
21
- attachments: { label: '附件', widget: 'file' },
21
+ visitDate: { label: '来访日期', type: 'date', widget: 'date' },
22
+ attachments: { label: '附件', type: 'file', widget: 'attachment' },
22
23
  },
23
24
  } as unknown as DataResourceSurface;
24
25
 
@@ -44,7 +45,7 @@ test('maps exact labels and codes into one canonical create transaction', () =>
44
45
  visitorName: '张三',
45
46
  visitorCount: 2,
46
47
  approved: true,
47
- status: 'pending',
48
+ status: { label: '待审核', value: 'pending' },
48
49
  },
49
50
  },
50
51
  {
@@ -54,7 +55,7 @@ test('maps exact labels and codes into one canonical create transaction', () =>
54
55
  visitorName: '李四',
55
56
  visitorCount: 3,
56
57
  approved: false,
57
- status: 'approved',
58
+ status: { label: '已通过', value: 'approved' },
58
59
  },
59
60
  },
60
61
  ]);
@@ -0,0 +1,144 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import type { DataFieldSurface, DataResourceSurface } from 'openxiangda-contracts/browser';
4
+ import { buildResourceWhere } from '../src/components/platform-fields/resource-query';
5
+
6
+ function field(
7
+ type: DataFieldSurface['type'],
8
+ widget: DataFieldSurface['widget']
9
+ ): DataFieldSurface {
10
+ return {
11
+ label: type,
12
+ type,
13
+ widget,
14
+ readCapabilities: [],
15
+ createCapabilities: [],
16
+ updateCapabilities: [],
17
+ };
18
+ }
19
+
20
+ const surface = {
21
+ fields: {
22
+ name: field('text.short', 'text'),
23
+ status: field('option.single', 'select'),
24
+ tags: field('option.multiple', 'multi-select'),
25
+ category: field('cascade.single', 'cascade'),
26
+ categories: field('cascade.multiple', 'cascade'),
27
+ address: field('address', 'address'),
28
+ metadata: field('json', 'json'),
29
+ period: field('date-range', 'date-range'),
30
+ },
31
+ list: { searchableFields: ['name'] },
32
+ } satisfies DataResourceSurface;
33
+
34
+ test('builds canonical snapshot and text predicates', () => {
35
+ assert.deepEqual(
36
+ buildResourceWhere('records', surface, {
37
+ page: 1,
38
+ pageSize: 20,
39
+ keyword: ' 显微镜 ',
40
+ filters: {
41
+ status: { value: 'enabled', label: '启用' },
42
+ tags: [
43
+ { value: 'precision', label: '精密' },
44
+ { value: 'shared', label: '共享' },
45
+ ],
46
+ },
47
+ }),
48
+ {
49
+ and: [
50
+ { field: 'name', operator: 'contains', value: '显微镜' },
51
+ { field: 'status', operator: 'eq', value: 'enabled' },
52
+ { field: 'tags', operator: 'hasAny', value: ['precision', 'shared'] },
53
+ ],
54
+ }
55
+ );
56
+ });
57
+
58
+ test('builds cascade membership and semantic range predicates', () => {
59
+ assert.deepEqual(
60
+ buildResourceWhere('records', surface, {
61
+ page: 1,
62
+ pageSize: 20,
63
+ filters: {
64
+ category: [
65
+ { value: 'equipment', label: '设备' },
66
+ { value: 'microscope', label: '显微镜' },
67
+ ],
68
+ categories: [
69
+ [
70
+ { value: 'equipment', label: '设备' },
71
+ { value: 'microscope', label: '显微镜' },
72
+ ],
73
+ [{ value: 'rooms', label: '场地' }],
74
+ ],
75
+ period: { start: '2026-08-01', end: '2026-08-31' },
76
+ },
77
+ }),
78
+ {
79
+ and: [
80
+ { field: 'category', operator: 'has', value: 'microscope' },
81
+ { field: 'categories', operator: 'hasAny', value: ['microscope', 'rooms'] },
82
+ {
83
+ field: 'period',
84
+ operator: 'overlaps',
85
+ value: { start: '2026-08-01', end: '2026-08-31' },
86
+ },
87
+ ],
88
+ }
89
+ );
90
+ });
91
+
92
+ test('ignores stale-route and empty filters without emitting legacy operators', () => {
93
+ assert.equal(
94
+ buildResourceWhere('records', surface, {
95
+ page: 1,
96
+ pageSize: 20,
97
+ filters: { stale: 'value', tags: [] },
98
+ }),
99
+ undefined
100
+ );
101
+ });
102
+
103
+ test('filters an address by its deepest stable administrative code', () => {
104
+ assert.deepEqual(
105
+ buildResourceWhere('records', surface, {
106
+ page: 1,
107
+ pageSize: 20,
108
+ filters: {
109
+ address: {
110
+ country: { label: '中国', value: '100000' },
111
+ province: { label: '浙江省', value: '330000' },
112
+ city: { label: '杭州市', value: '330100' },
113
+ district: { label: '西湖区', value: '330106' },
114
+ fullAddress: '浙江省杭州市西湖区',
115
+ },
116
+ },
117
+ }),
118
+ {
119
+ and: [{
120
+ field: 'address',
121
+ operator: 'jsonContains',
122
+ path: 'district.value',
123
+ value: '330106',
124
+ }],
125
+ }
126
+ );
127
+ });
128
+
129
+ test('filters bounded JSON with canonical containment', () => {
130
+ assert.deepEqual(
131
+ buildResourceWhere('records', surface, {
132
+ page: 1,
133
+ pageSize: 20,
134
+ filters: { metadata: { enabled: true, level: 2 } },
135
+ }),
136
+ {
137
+ and: [{
138
+ field: 'metadata',
139
+ operator: 'jsonContains',
140
+ value: { enabled: true, level: 2 },
141
+ }],
142
+ }
143
+ );
144
+ });
@@ -0,0 +1,53 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import { parseJsonEditorValue } from '../src/components/platform-fields/JsonField';
5
+ import { richTextPlainText } from '../src/components/platform-fields/rich-text-value';
6
+
7
+ test('JSON editor submits structured values and rejects invalid text', () => {
8
+ assert.deepEqual(parseJsonEditorValue('{"enabled":true,"items":[1,2]}'), {
9
+ enabled: true,
10
+ items: [1, 2],
11
+ });
12
+ assert.equal(parseJsonEditorValue(' '), undefined);
13
+ assert.throws(() => parseJsonEditorValue('{broken'), SyntaxError);
14
+ });
15
+
16
+ test('rich text plain fallback does not expose markup in compact contexts', () => {
17
+ assert.equal(
18
+ richTextPlainText('<h2>Heading</h2><p>Body <strong>text</strong></p>'),
19
+ 'Heading Body text'
20
+ );
21
+ });
22
+
23
+ test('rich text editor mirrors the platform allowlist and JSON has readonly formatting', () => {
24
+ const richValue = readFileSync(
25
+ new URL('../src/components/platform-fields/rich-text-value.ts', import.meta.url),
26
+ 'utf8'
27
+ );
28
+ const richField = readFileSync(
29
+ new URL('../src/components/platform-fields/RichTextField.tsx', import.meta.url),
30
+ 'utf8'
31
+ );
32
+ const jsonField = readFileSync(
33
+ new URL('../src/components/platform-fields/JsonField.tsx', import.meta.url),
34
+ 'utf8'
35
+ );
36
+ const attachmentList = readFileSync(
37
+ new URL('../src/components/platform-fields/AttachmentFileList.tsx', import.meta.url),
38
+ 'utf8'
39
+ );
40
+ assert.match(richValue, /MANAGED_IMAGE/);
41
+ assert.match(richValue, /disposition=inline/);
42
+ assert.match(richValue, /noopener noreferrer/);
43
+ assert.match(richField, /contentEditable/);
44
+ assert.match(richField, /dangerouslySetInnerHTML/);
45
+ assert.match(richField, /FileImageOutlined/);
46
+ assert.match(richField, /dataRichTextImageSource/);
47
+ assert.match(richField, /10 \* 1024 \* 1024/);
48
+ assert.match(richField, /INLINE_IMAGE_MAX_COUNT = 20/);
49
+ assert.match(attachmentList, /file\.thumbnailUrl/);
50
+ assert.match(attachmentList, /oxa-file-thumbnail/);
51
+ assert.match(jsonField, /JSON\.stringify\(value, null, 2\)/);
52
+ assert.match(jsonField, /JSON 格式无效/);
53
+ });
@@ -0,0 +1,40 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+
5
+ test('signature canvas emits managed PNG, SHA-256 and business metadata', () => {
6
+ const field = readFileSync(
7
+ new URL('../src/components/platform-fields/SignatureField.tsx', import.meta.url),
8
+ 'utf8'
9
+ );
10
+ assert.match(field, /toBlob\(resolve, 'image\/png'\)/);
11
+ assert.match(field, /digest\('SHA-256'/);
12
+ assert.match(field, /signedAt: new Date\(\)\.toISOString\(\)/);
13
+ assert.match(field, /points: points\.current\.slice\(\)/);
14
+ assert.match(field, /file: managedFile\(uploaded\)/);
15
+ assert.doesNotMatch(field, /dataURL|base64/);
16
+ });
17
+
18
+ test('signature has separate desktop/mobile dialogs and serial stays server-owned', () => {
19
+ const signature = readFileSync(
20
+ new URL('../src/components/platform-fields/SignatureField.tsx', import.meta.url),
21
+ 'utf8'
22
+ );
23
+ const surface = readFileSync(
24
+ new URL('../src/components/resource/SurfaceFields.tsx', import.meta.url),
25
+ 'utf8'
26
+ );
27
+ const crud = readFileSync(
28
+ new URL('../src/components/resource/GeneratedResourceCrud.tsx', import.meta.url),
29
+ 'utf8'
30
+ );
31
+ assert.match(signature, /<Drawer/);
32
+ assert.match(signature, /<Modal/);
33
+ assert.match(surface, /保存后由系统自动生成/);
34
+ assert.match(
35
+ crud,
36
+ /field\.widget !== 'readonly' && fieldWriteAuthorized\(field, mode/
37
+ );
38
+ assert.match(crud, /canWrite: \(fieldCode, childField, childOperation\) =>\s+fieldWriteAuthorized\(/);
39
+ assert.match(crud, /signatureSigner\(identity\.subjectProfile\)/);
40
+ });