cabloy 5.1.208 → 5.1.210

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 (97) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/package.json +1 -1
  4. package/repo-agent-governance/managed-assets.json +9 -9
  5. package/repo-agent-governance/policies/repository.md +1 -1
  6. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +4 -2
  7. package/repo-agent-governance/skills/cabloy-workflow/evals/evals.json +13 -1
  8. package/repo-docs/backend/unit-testing.md +1 -1
  9. package/repo-docs/frontend/form-guide.md +17 -1
  10. package/repo-docs/frontend/model-architecture.md +4 -0
  11. package/repo-docs/frontend/model-state-guide.md +23 -2
  12. package/repo-docs/frontend/resource-entry-page-deep-dive.md +119 -0
  13. package/repo-docs/frontend/scripts.md +3 -3
  14. package/repo-docs/frontend/zova-form-under-the-hood.md +5 -0
  15. package/repo-docs/fullstack/parallel-worktree-environment.md +3 -3
  16. package/repo-docs/reference/repo-scripts.md +19 -5
  17. package/repo-e2e/specs/cabloy-basic.spec.ts +1577 -0
  18. package/vona/packages-vona/vona/package.json +1 -1
  19. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  20. package/vona/src/suite/a-home/modules/home-user/src/dto/passport.ts +4 -0
  21. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +172 -0
  22. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +1 -0
  23. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  24. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  25. package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
  26. package/vona/src/suite-vendor/a-vona/modules/a-openapi/src/types/rest.ts +1 -0
  27. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  29. package/zova/packages-cli/cli/package.json +2 -2
  30. package/zova/packages-cli/cli-set-front/cli/templates/rest/render.ts +2 -0
  31. package/zova/packages-cli/cli-set-front/cli/templates/rest/rest.ts +9 -0
  32. package/zova/packages-cli/cli-set-front/package.json +1 -1
  33. package/zova/packages-zova/zova/package.json +2 -2
  34. package/zova/src/suite/a-home/modules/home-api/src/.metadata/index.ts +17 -17
  35. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +68 -52
  36. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +524 -343
  37. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +45 -11
  38. package/zova/src/suite/a-home/modules/home-passport/test/lib/persistenceIdentity.test.ts +229 -0
  39. package/zova/src/suite/cabloy-basic/modules/basic-input/src/component/formFieldInput/controller.tsx +2 -1
  40. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/package.json +9 -1
  41. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/.metadata/index.ts +81 -5
  42. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/bean/bean.draftCoordinator.ts +158 -0
  43. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockForm/controller.tsx +11 -3
  44. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +438 -8
  45. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/config.ts +11 -0
  46. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/en-us.ts +9 -0
  47. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/zh-cn.ts +9 -0
  48. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/lib/draftSchema.ts +649 -0
  49. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/model/draft.ts +59 -0
  50. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/monkey.ts +11 -0
  51. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/types/draft.ts +49 -0
  52. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftDate.test.ts +262 -0
  53. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLifecycle.test.ts +1073 -0
  54. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLoader.mjs +13 -0
  55. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftModel.test.ts +572 -0
  56. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchema.test.ts +427 -0
  57. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchemaSafety.test.ts +200 -0
  58. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  59. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/entry/controller.tsx +6 -1
  60. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  61. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  62. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +86 -1
  63. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/formField/controller.tsx +87 -13
  64. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +751 -0
  65. package/zova/src/suite-vendor/a-zova/modules/a-form/src/types/formField.ts +2 -0
  66. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefresh.test.ts +637 -0
  67. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefreshLifecycle.test.ts +1098 -0
  68. package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
  69. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.persister.ts +331 -87
  70. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.query.ts +11 -6
  71. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useQuery.ts +3 -1
  72. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useState.ts +17 -1
  73. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/index.ts +1 -0
  74. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/modelPersistence.ts +204 -0
  75. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/persistenceQueue.ts +50 -0
  76. package/zova/src/suite-vendor/a-zova/modules/a-model/src/monkey.ts +5 -0
  77. package/zova/src/suite-vendor/a-zova/modules/a-model/src/service/storage.ts +47 -2
  78. package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +3 -0
  79. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelLoader.mjs +34 -0
  80. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelPersistence.test.ts +727 -0
  81. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceQueue.test.ts +137 -0
  82. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceTransition.test.ts +129 -0
  83. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  84. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/monkey.ts +7 -5
  85. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts +2 -0
  86. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  87. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/bean.router.ts +14 -0
  88. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routeDiscard.ts +26 -0
  89. package/zova/src/suite-vendor/a-zova/modules/a-router/src/service/routerGuards.ts +6 -3
  90. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/index.ts +1 -0
  91. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/routeDiscard.ts +24 -0
  92. package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/routeDiscard.test.ts +105 -0
  93. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/package.json +1 -1
  94. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/src/model/tabs.ts +202 -30
  95. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabs.test.ts +524 -0
  96. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabsLoader.mjs +35 -0
  97. package/zova/src/suite-vendor/a-zova/package.json +6 -6
@@ -28,6 +28,8 @@ export type HTMLInputElementType =
28
28
  | 'tel'
29
29
  | 'email';
30
30
  export const constFieldProps = '$$FieldProps';
31
+ // Internal: the renderer supplies its own form-value binding slot.
32
+ export const constFieldBindingSlot = '$$FieldBindingSlot';
31
33
 
32
34
  export interface IFormFieldScope<TParentData extends {} = {}> {
33
35
  name: string;
@@ -0,0 +1,637 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import type { ISchemaRefreshContext } from '../../src/lib/schemaRefresh.js';
5
+
6
+ import {
7
+ createSchemaRefreshContract,
8
+ schemaRefreshFingerprint,
9
+ SchemaRefreshTracker,
10
+ } from '../../src/lib/schemaRefresh.js';
11
+
12
+ function contract(
13
+ name: Record<string, unknown> = {},
14
+ level: Record<string, unknown> = {},
15
+ root: Record<string, unknown> = {},
16
+ ) {
17
+ const properties = { name: { type: 'string', ...name }, level: { type: 'number', ...level } };
18
+ return createSchemaRefreshContract(
19
+ { type: 'object', properties, ...root },
20
+ Object.entries(properties).map(([key, property]) => ({ key, ...property })),
21
+ () => undefined,
22
+ );
23
+ }
24
+
25
+ function context(overrides: Partial<ISchemaRefreshContext> = {}): ISchemaRefreshContext {
26
+ return {
27
+ locale: 'en-us',
28
+ identity: 'student:edit:1',
29
+ epoch: 1,
30
+ scene: 'form',
31
+ contract: contract({ title: 'Name', errorMessage: { minLength: 'Too short' } }),
32
+ configuration: 'default',
33
+ enabled: true,
34
+ eligible: true,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ const chinese = () => contract({ title: '姓名', errorMessage: { minLength: '太短' } });
40
+
41
+ test('normalizes only display strings on schema nodes, retaining message keys and raw validation', () => {
42
+ const before = contract({
43
+ title: 'Name',
44
+ description: 'Student name',
45
+ errorMessage: { minLength: 'Short' },
46
+ });
47
+ const after = contract({
48
+ title: '姓名',
49
+ description: '学生姓名',
50
+ errorMessage: { minLength: '太短' },
51
+ });
52
+ assert.equal(before.ready, true);
53
+ assert.equal(before.validation, after.validation);
54
+ assert.notEqual(before.validationRaw, after.validationRaw);
55
+ assert.notEqual(before.fields.name.raw, after.fields.name.raw);
56
+ assert.equal(before.fields.name.compatible, after.fields.name.compatible);
57
+ assert.notEqual(before.validation, contract({ errorMessage: { maximum: 'Short' } }).validation);
58
+ for (const keyword of ['default', 'const', 'enum', 'examples', 'options', 'x-unknown']) {
59
+ const a = contract({ [keyword]: { title: 'Name', description: 'English' } });
60
+ const b = contract({ [keyword]: { title: '姓名', description: '中文' } });
61
+ assert.notEqual(a.validation, b.validation, keyword);
62
+ assert.notEqual(a.fields.name.compatible, b.fields.name.compatible, keyword);
63
+ }
64
+ assert.notEqual(contract({ title: 1 }).validation, contract({ title: 2 }).validation);
65
+ assert.notEqual(
66
+ contract({ errorMessage: 'English' }).validation,
67
+ contract({ errorMessage: '中文' }).validation,
68
+ );
69
+ });
70
+
71
+ test('serialization is sorted, detached, and distinguishes undefined from absent or null', () => {
72
+ assert.equal(schemaRefreshFingerprint({ b: 2, a: 1 }), schemaRefreshFingerprint({ a: 1, b: 2 }));
73
+ for (const other of [{}, { a: null }, { a: 'undefined' }]) {
74
+ assert.notEqual(schemaRefreshFingerprint({ a: undefined }), schemaRefreshFingerprint(other));
75
+ }
76
+ const property = { key: 'name', type: 'string', default: { title: 'Original' } };
77
+ const schema = { type: 'object', properties: { name: property } };
78
+ const before = createSchemaRefreshContract(schema, [property], () => undefined);
79
+ const signature = before.fields.name.raw;
80
+ property.default.title = 'Changed';
81
+ assert.equal(before.fields.name.raw, signature);
82
+ assert.notEqual(
83
+ before.validation,
84
+ createSchemaRefreshContract(schema, [property], () => undefined).validation,
85
+ );
86
+ assert.notEqual(contract({ default: undefined }).validation, contract().validation);
87
+ });
88
+
89
+ test('parent required and unknown validation keywords invalidate every field', () => {
90
+ const baseline = contract({}, {}, { required: ['name'] });
91
+ assert.notEqual(baseline.validation, contract({}, {}, { required: ['level'] }).validation);
92
+ assert.notEqual(baseline.validationRaw, contract({}, {}, { required: ['level'] }).validationRaw);
93
+ assert.notEqual(
94
+ contract({}, {}, { 'x-rule': 1 }).validation,
95
+ contract({}, {}, { 'x-rule': 2 }).validation,
96
+ );
97
+ const tracker = new SchemaRefreshTracker();
98
+ tracker.consume('level', context({ contract: baseline }));
99
+ assert.deepEqual(
100
+ tracker.consume(
101
+ 'level',
102
+ context({ locale: 'zh-cn', contract: contract({}, {}, { required: ['level'] }) }),
103
+ ),
104
+ { changed: true, preserve: false },
105
+ );
106
+ });
107
+
108
+ function studentRest(title: string) {
109
+ return {
110
+ render: {
111
+ $$typeof: 'zova-jsx:component',
112
+ type: 'training-student:formFieldLevel',
113
+ props: {
114
+ items: [{ value: 1, title }],
115
+ placeholder: title,
116
+ },
117
+ },
118
+ };
119
+ }
120
+
121
+ test('Student static JSX rest is strict per-field and does not block an independent name', () => {
122
+ const before = contract({ title: 'Name' }, { rest: studentRest('Level') });
123
+ const after = contract({ title: '姓名' }, { rest: studentRest('级别') });
124
+ assert.equal(before.fields.name.eligible, true);
125
+ assert.equal(before.validation, after.validation);
126
+ assert.equal(before.behavior, after.behavior);
127
+ assert.notEqual(before.fields.level.compatible, after.fields.level.compatible);
128
+ const tracker = new SchemaRefreshTracker();
129
+ tracker.consume('name', context({ contract: before }));
130
+ tracker.consume('level', context({ contract: before }));
131
+ assert.deepEqual(tracker.consume('name', context({ locale: 'zh-cn', contract: after })), {
132
+ changed: true,
133
+ preserve: true,
134
+ });
135
+ assert.deepEqual(tracker.consume('level', context({ locale: 'zh-cn', contract: after })), {
136
+ changed: true,
137
+ preserve: false,
138
+ });
139
+ assert.notEqual(
140
+ contract({}, {}, { rest: { readonly: false } }).behavior,
141
+ contract({}, {}, { rest: { readonly: true } }).behavior,
142
+ );
143
+ const ancestor = (readonly: boolean) =>
144
+ createSchemaRefreshContract(
145
+ {
146
+ properties: {
147
+ content: { type: 'object', rest: { readonly }, properties: { name: { type: 'string' } } },
148
+ },
149
+ },
150
+ [{ key: 'content.name', type: 'string' }],
151
+ () => undefined,
152
+ );
153
+ assert.equal(ancestor(false).validation, ancestor(true).validation);
154
+ assert.notEqual(ancestor(false).behavior, ancestor(true).behavior);
155
+ });
156
+
157
+ test('opaque cross-field behavior or unknown rest fails preservation globally', () => {
158
+ const cases = [
159
+ { onEffect: 'handler' },
160
+ { onEffect: () => {} },
161
+ { evaluate: 'value + 1' },
162
+ { commands: [] },
163
+ { behaviors: {} },
164
+ { unknown: 'opaque' },
165
+ { visible: { unknown: 'opaque' } },
166
+ { options: { unknown: 'opaque' } },
167
+ { visible: 'cel://value.name != ""' },
168
+ { render: { type: 'training-student:formFieldLevel', props: { onChange: 'handler' } } },
169
+ { render: { type: 'component', props: { items: [{ title: 'cel://value.name' }] } } },
170
+ ];
171
+ for (const rest of cases) {
172
+ const result = contract({}, { rest });
173
+ assert.equal(result.ready, true);
174
+ assert.equal(result.fields.name.eligible, false);
175
+ assert.equal(result.fields.level.eligible, false);
176
+ }
177
+ });
178
+
179
+ test('resolves supported schema positions, retaining ref identity and target changes', () => {
180
+ const schema: Record<string, unknown> = {
181
+ type: 'object',
182
+ properties: { name: { $ref: '#/$defs/Name' } },
183
+ $defs: { Name: { type: 'string', title: 'Name' } },
184
+ };
185
+ const build = () =>
186
+ createSchemaRefreshContract(schema, [{ key: 'name', $ref: '#/$defs/Name' }], () => undefined);
187
+ const before = build();
188
+ (schema.$defs as any).Name.title = '姓名';
189
+ const after = build();
190
+ assert.equal(before.ready, true);
191
+ assert.equal(before.validation, after.validation);
192
+ assert.equal(before.fields.name.compatible, after.fields.name.compatible);
193
+ assert.notEqual(before.fields.name.raw, after.fields.name.raw);
194
+ (schema.$defs as any).Name.minLength = 3;
195
+ assert.notEqual(after.validation, build().validation);
196
+ const target = { type: 'string' };
197
+ const ref = (name: string) =>
198
+ createSchemaRefreshContract(
199
+ { properties: { name: { $ref: name } } },
200
+ [{ key: 'name', $ref: name }],
201
+ () => target,
202
+ );
203
+ assert.notEqual(ref('A').validation, ref('B').validation);
204
+ for (const keyword of [
205
+ 'items',
206
+ 'additionalProperties',
207
+ 'unevaluatedProperties',
208
+ 'contains',
209
+ 'not',
210
+ 'if',
211
+ 'then',
212
+ 'else',
213
+ 'propertyNames',
214
+ 'contentSchema',
215
+ ]) {
216
+ const result = contract({ [keyword]: { $ref: 'missing' } });
217
+ assert.equal(result.ready, false, keyword);
218
+ }
219
+ for (const keyword of ['allOf', 'anyOf', 'oneOf', 'prefixItems']) {
220
+ assert.equal(contract({ [keyword]: [{ $ref: 'missing' }] }).ready, false, keyword);
221
+ }
222
+ for (const keyword of [
223
+ 'properties',
224
+ 'patternProperties',
225
+ '$defs',
226
+ 'definitions',
227
+ 'dependentSchemas',
228
+ 'dependencies',
229
+ ]) {
230
+ assert.equal(contract({ [keyword]: { child: { $ref: 'missing' } } }).ready, false, keyword);
231
+ }
232
+ // A business object containing $ref/title is data, not another schema node.
233
+ assert.equal(contract({ default: { $ref: 'missing', title: 'Data' } }).ready, true);
234
+ });
235
+
236
+ test('unresolved refs defer, while cycles and runtime metadata remain changed but ineligible', () => {
237
+ assert.equal(createSchemaRefreshContract({ $ref: 'missing' }, [], () => undefined).ready, false);
238
+ const result = createSchemaRefreshContract(
239
+ { $ref: 'A' },
240
+ [{ key: 'name', type: 'string' }],
241
+ () => ({ $ref: 'A' }),
242
+ );
243
+ assert.equal(result.ready, true);
244
+ assert.equal(result.fields.name.eligible, false);
245
+ const cyclic: any = { type: 'object' };
246
+ cyclic.properties = { self: cyclic };
247
+ const cycle = createSchemaRefreshContract(
248
+ cyclic,
249
+ [{ key: 'name', type: 'string' }],
250
+ () => undefined,
251
+ );
252
+ assert.equal(cycle.ready, true);
253
+ assert.equal(cycle.fields.name.eligible, false);
254
+ const callback = () => {};
255
+ assert.equal(contract({ default: { callback } }).fields.name.eligible, false);
256
+ assert.equal(contract({ 'x-runtime': callback }).fields.name.eligible, false);
257
+ assert.equal(schemaRefreshFingerprint(callback), schemaRefreshFingerprint(callback));
258
+ assert.notEqual(
259
+ schemaRefreshFingerprint(callback),
260
+ schemaRefreshFingerprint(() => {}),
261
+ );
262
+ let invoked = false;
263
+ const getter = {
264
+ get title() {
265
+ invoked = true;
266
+ return 'Never read';
267
+ },
268
+ };
269
+ schemaRefreshFingerprint(getter);
270
+ const opaque = contract({ 'x-runtime': getter });
271
+ assert.equal(invoked, false);
272
+ assert.equal(opaque.fields.name.eligible, false);
273
+ });
274
+
275
+ test('canonical keys and aliases remain strict; duplicates fail closed', () => {
276
+ const build = (aliases: Record<string, unknown>) =>
277
+ createSchemaRefreshContract(
278
+ { type: 'object' },
279
+ [{ key: 'content.name', type: 'string', ...aliases }],
280
+ () => undefined,
281
+ );
282
+ const a = build({ schemaKey: '_name', schemaKeys: ['alias'] });
283
+ const b = build({ schemaKey: '_name', schemaKeys: ['other'] });
284
+ assert.deepEqual(Object.keys(a.fields), ['content.name']);
285
+ assert.notEqual(a.fields['content.name'].compatible, b.fields['content.name'].compatible);
286
+ const duplicate = createSchemaRefreshContract(
287
+ {},
288
+ [{ key: 'name' }, { key: 'name' }],
289
+ () => undefined,
290
+ );
291
+ assert.equal(duplicate.fields.name.eligible, false);
292
+ assert.deepEqual(Object.keys(build({ key: '__proto__.name' }).fields), []);
293
+ });
294
+
295
+ test('tracker preserves eligible locale refresh only and does not overwrite on a loading gap', () => {
296
+ const tracker = new SchemaRefreshTracker();
297
+ assert.equal(tracker.has('name'), false);
298
+ assert.deepEqual(tracker.consume('name', context()), { changed: false, preserve: false });
299
+ assert.equal(tracker.has('name'), true);
300
+ assert.deepEqual(
301
+ tracker.consume('name', context({ locale: 'zh-cn', contract: { ready: false, fields: {} } })),
302
+ { changed: false, preserve: false },
303
+ );
304
+ assert.deepEqual(tracker.consume('name', context({ locale: 'zh-cn', contract: chinese() })), {
305
+ changed: true,
306
+ preserve: true,
307
+ });
308
+ assert.deepEqual(tracker.consume('name', context({ locale: 'zh-cn', contract: chinese() })), {
309
+ changed: false,
310
+ preserve: false,
311
+ });
312
+ tracker.clear();
313
+ assert.equal(tracker.has('name'), false);
314
+ });
315
+
316
+ test('tracker rejects epoch drift, absent identities, owner config and eligibility changes', () => {
317
+ for (const overrides of [
318
+ { epoch: 2 },
319
+ { identity: undefined },
320
+ { identity: 'student:create' },
321
+ { scene: 'form-view' },
322
+ { configuration: 'custom' },
323
+ { enabled: false },
324
+ { eligible: false },
325
+ { contract: contract({ title: '姓名', minLength: 1 }) },
326
+ ]) {
327
+ const tracker = new SchemaRefreshTracker();
328
+ tracker.consume('name', context());
329
+ assert.deepEqual(
330
+ tracker.consume('name', context({ locale: 'zh-cn', contract: chinese(), ...overrides })),
331
+ { changed: true, preserve: false },
332
+ );
333
+ }
334
+ const tracker = new SchemaRefreshTracker();
335
+ tracker.consume('name', context());
336
+ assert.deepEqual(tracker.consume('name', context({ epoch: 2 })), {
337
+ changed: false,
338
+ preserve: false,
339
+ });
340
+ assert.deepEqual(
341
+ tracker.consume('name', context({ epoch: 2, locale: 'zh-cn', contract: chinese() })),
342
+ { changed: true, preserve: true },
343
+ );
344
+ });
345
+
346
+ test('tracker observes parent raw validation, field removal and manual field configuration', () => {
347
+ const tracker = new SchemaRefreshTracker();
348
+ tracker.consume(
349
+ 'level',
350
+ context({ contract: contract({}, {}, { errorMessage: { required: 'Required' } }) }),
351
+ );
352
+ assert.deepEqual(
353
+ tracker.consume(
354
+ 'level',
355
+ context({ contract: contract({}, {}, { errorMessage: { required: '必须' } }) }),
356
+ ),
357
+ { changed: true, preserve: false },
358
+ );
359
+ tracker.consume('name', context());
360
+ const empty = { ready: true, fields: {} };
361
+ assert.deepEqual(tracker.consume('name', context({ contract: empty })), {
362
+ changed: true,
363
+ preserve: false,
364
+ });
365
+ assert.deepEqual(tracker.consume('manual', context({ contract: empty })), {
366
+ changed: false,
367
+ preserve: false,
368
+ });
369
+ assert.deepEqual(
370
+ tracker.consume('manual', context({ contract: empty, configuration: 'other' })),
371
+ { changed: true, preserve: false },
372
+ );
373
+ });
374
+
375
+ function studentBlocks(title: string, permission = 'update') {
376
+ return [
377
+ {
378
+ $$typeof: 'zova-jsx:block',
379
+ render: 'basic-pageentry:blockPageEntry',
380
+ options: {
381
+ blocks: [
382
+ {
383
+ $$typeof: 'zova-jsx:block',
384
+ render: 'basic-pageentry:blockForm',
385
+ options: {
386
+ blocks: [
387
+ {
388
+ $$typeof: 'zova-jsx:block',
389
+ render: 'basic-form:blockFormLayout',
390
+ options: {
391
+ formLayout: {
392
+ children: [
393
+ {
394
+ type: 'tabs',
395
+ children: [
396
+ {
397
+ type: 'tab',
398
+ title,
399
+ children: [
400
+ {
401
+ type: 'group',
402
+ title,
403
+ children: [
404
+ {
405
+ type: 'section',
406
+ columns: { default: 1, md: 2 },
407
+ children: [{ type: 'field', name: 'name' }],
408
+ },
409
+ ],
410
+ },
411
+ ],
412
+ },
413
+ ],
414
+ },
415
+ ],
416
+ },
417
+ },
418
+ },
419
+ ],
420
+ },
421
+ },
422
+ {
423
+ $$typeof: 'zova-jsx:block',
424
+ render: 'basic-pageentry:blockToolbarRow',
425
+ options: {
426
+ actions: [
427
+ {
428
+ $$typeof: 'zova-jsx:actionRow',
429
+ name: 'submit',
430
+ render: 'basic-form:actionSubmit',
431
+ options: {
432
+ permission: { actionInherit: permission, formScene: ['create', 'edit'] },
433
+ },
434
+ },
435
+ ],
436
+ },
437
+ },
438
+ ],
439
+ },
440
+ },
441
+ ];
442
+ }
443
+
444
+ test('actual Student static blocks and table columnProps allow independent name across localized layout titles', () => {
445
+ const student = (title: string, permission = 'update') =>
446
+ contract(
447
+ { title },
448
+ {
449
+ rest: {
450
+ order: 5,
451
+ form: {
452
+ render: 'training-student:formFieldLevel',
453
+ options: { items: [{ value: 1, title }], placeholder: title },
454
+ },
455
+ table: {
456
+ render: 'training-student:level',
457
+ columnProps: { items: [{ value: 1, title }] },
458
+ },
459
+ },
460
+ },
461
+ { rest: { blocks: studentBlocks(title, permission) } },
462
+ );
463
+ const before = student('Name');
464
+ const after = student('姓名');
465
+ assert.equal(before.fields.name.eligible, true);
466
+ assert.equal(before.behavior, after.behavior);
467
+ assert.notEqual(before.behaviorRaw, after.behaviorRaw);
468
+ assert.notEqual(before.fields.level.compatible, after.fields.level.compatible);
469
+ const tracker = new SchemaRefreshTracker();
470
+ tracker.consume('name', context({ contract: before }));
471
+ assert.deepEqual(tracker.consume('name', context({ locale: 'zh-cn', contract: after })), {
472
+ changed: true,
473
+ preserve: true,
474
+ });
475
+ assert.notEqual(after.behavior, student('姓名', 'create').behavior);
476
+ const unknown = studentBlocks('Name');
477
+ (unknown[0].options as any).unknown = { title: 'Name' };
478
+ assert.equal(contract({}, {}, { rest: { blocks: unknown } }).fields.name.eligible, false);
479
+ const effect = studentBlocks('Name');
480
+ (effect[0].options as any).onEffect = 'callback';
481
+ assert.equal(contract({}, {}, { rest: { blocks: effect } }).fields.name.eligible, false);
482
+ const expression = studentBlocks('cel://value.name');
483
+ assert.equal(contract({}, {}, { rest: { blocks: expression } }).fields.name.eligible, false);
484
+ });
485
+
486
+ test('layout exception never normalizes descriptions, actions, field names, or arbitrary titles', () => {
487
+ const signature = (change: (blocks: any[]) => void) => {
488
+ const blocks = studentBlocks('Name');
489
+ change(blocks);
490
+ return contract({}, {}, { rest: { blocks } });
491
+ };
492
+ const base = signature(() => {});
493
+ const description = signature(blocks => {
494
+ blocks[0].options.blocks[0].options.blocks[0].options.formLayout.children[0].children[0].children[0].description =
495
+ 'Translated';
496
+ });
497
+ assert.notEqual(base.behavior, description.behavior);
498
+ const changedName = signature(blocks => {
499
+ blocks[0].options.blocks[0].options.blocks[0].options.formLayout.children[0].children[0].children[0].children[0].children[0].name =
500
+ 'level';
501
+ });
502
+ assert.notEqual(base.behavior, changedName.behavior);
503
+ const arbitraryTitle = signature(blocks => {
504
+ blocks[0].options.title = 'Unknown';
505
+ });
506
+ assert.equal(arbitraryTitle.fields.name.eligible, false);
507
+ });
508
+
509
+ test('prune removes vanished schema fields only after readiness, retaining manual fields', () => {
510
+ const tracker = new SchemaRefreshTracker();
511
+ tracker.consume('name', context());
512
+ tracker.consume('manual', context());
513
+ tracker.prune({ ready: false, fields: {} });
514
+ assert.equal(tracker.has('name'), true);
515
+ tracker.prune({ ready: true, fields: {} });
516
+ assert.equal(tracker.has('name'), false);
517
+ assert.equal(tracker.has('manual'), true);
518
+ });
519
+
520
+ test('full Student static image options and nested details effects preserve independent name only', () => {
521
+ const effect = () => {};
522
+ const build = (
523
+ title: string,
524
+ details = true,
525
+ direct = false,
526
+ auxiliary: Record<string, unknown> = {},
527
+ ) => {
528
+ const fields = {
529
+ _trainingRecords: {
530
+ type: ['array', 'null'],
531
+ rest: { visible: false },
532
+ items: { $ref: '#/components/schemas/DetailRecord' },
533
+ ...auxiliary,
534
+ },
535
+ name: {
536
+ type: 'string',
537
+ title,
538
+ rest: {
539
+ order: 1,
540
+ table: {
541
+ render: 'basic-table:actionView',
542
+ align: 'left',
543
+ width: 240,
544
+ fixed: 'left',
545
+ enableSorting: true,
546
+ sortDescFirst: true,
547
+ },
548
+ },
549
+ },
550
+ level: {
551
+ type: 'number',
552
+ title,
553
+ rest: {
554
+ form: {
555
+ render: 'training-student:formFieldLevel',
556
+ options: { items: [{ value: 1, title }], placeholder: title },
557
+ },
558
+ table: {
559
+ render: 'training-student:level',
560
+ columnProps: { items: [{ value: 1, title }] },
561
+ },
562
+ },
563
+ },
564
+ imageId: {
565
+ type: 'number',
566
+ rest: {
567
+ form: {
568
+ render: 'basic-image:formFieldImage',
569
+ options: {
570
+ imageScene: 'training-student:studentImage',
571
+ enableCrop: true,
572
+ cropAspectRatio: 1,
573
+ relationName: 'image',
574
+ resize: { width: 512, height: 512, fit: 'cover', format: 'jpeg', quality: 90 },
575
+ },
576
+ },
577
+ table: { render: 'basic-image:image', columnProps: { relationName: 'image' } },
578
+ },
579
+ },
580
+ trainingRecords: {
581
+ type: 'array',
582
+ rest: {
583
+ form: { render: details ? 'basic-details:formFieldDetails' : 'other:formFieldObject' },
584
+ ...(direct ? { onEffect: effect } : {}),
585
+ },
586
+ items: {
587
+ type: 'object',
588
+ properties: {
589
+ trainingRecordSubjects: {
590
+ type: 'array',
591
+ rest: { form: { render: 'basic-details:formFieldDetails' }, onEffect: effect },
592
+ items: { type: 'string' },
593
+ },
594
+ },
595
+ },
596
+ },
597
+ };
598
+ return createSchemaRefreshContract(
599
+ { type: 'object', properties: fields, rest: { blocks: studentBlocks(title) } },
600
+ Object.entries(fields).map(([key, property]) => ({ key, ...property })),
601
+ () => ({
602
+ type: 'object',
603
+ rest: { blocks: [{ render: 'basic-details:blockForm', options: { dialogOptions: {} } }] },
604
+ properties: {
605
+ nested: { type: 'string', rest: { onEffect: effect } },
606
+ },
607
+ }),
608
+ );
609
+ };
610
+ const before = build('Name');
611
+ const after = build('姓名');
612
+ assert.equal(before.fields.name.eligible, true);
613
+ assert.equal(before.fields.imageId.eligible, true);
614
+ assert.equal(before.fields.trainingRecords.eligible, false);
615
+ assert.equal(before.fields._trainingRecords.eligible, false);
616
+ assert.equal(build('Name', true, false, { type: 'array' }).fields.name.eligible, true);
617
+ assert.equal(build('Name', true, false, { type: ['null', 'array'] }).fields.name.eligible, true);
618
+ for (const type of [['array', 'string'], ['array', 'null', 'string'], ['array'], 'object']) {
619
+ assert.equal(build('Name', true, false, { type }).fields.name.eligible, false);
620
+ }
621
+ assert.notEqual(before.validation, build('Name', true, false, { type: 'array' }).validation);
622
+ assert.equal(build('Name', true, false, { rest: { visible: true } }).fields.name.eligible, false);
623
+ assert.equal(build('Name', true, false, { key: '_unrelated' }).fields.name.eligible, false);
624
+ assert.equal(
625
+ build('Name', true, false, { items: { type: 'object', rest: { onEffect: effect } } }).fields
626
+ .name.eligible,
627
+ false,
628
+ );
629
+ const tracker = new SchemaRefreshTracker();
630
+ tracker.consume('name', context({ contract: before }));
631
+ assert.deepEqual(tracker.consume('name', context({ locale: 'zh-cn', contract: after })), {
632
+ changed: true,
633
+ preserve: true,
634
+ });
635
+ assert.equal(build('Name', false).fields.name.eligible, false);
636
+ assert.equal(build('Name', true, true).fields.name.eligible, false);
637
+ });