cabloy 5.1.207 → 5.1.209

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 +13 -0
  3. package/README.md +1 -0
  4. package/package.json +1 -1
  5. package/repo-agent-governance/managed-assets.json +9 -9
  6. package/repo-agent-governance/policies/repository.md +1 -1
  7. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +4 -2
  8. package/repo-agent-governance/skills/cabloy-workflow/evals/evals.json +13 -1
  9. package/repo-docs/backend/unit-testing.md +1 -1
  10. package/repo-docs/frontend/form-guide.md +17 -1
  11. package/repo-docs/frontend/model-architecture.md +4 -0
  12. package/repo-docs/frontend/model-state-guide.md +23 -2
  13. package/repo-docs/frontend/resource-entry-page-deep-dive.md +119 -0
  14. package/repo-docs/frontend/scripts.md +3 -3
  15. package/repo-docs/frontend/zova-form-under-the-hood.md +5 -0
  16. package/repo-docs/fullstack/parallel-worktree-environment.md +3 -3
  17. package/repo-docs/fullstack/quickstart.md +11 -1
  18. package/repo-docs/reference/repo-scripts.md +19 -5
  19. package/repo-e2e/specs/cabloy-basic.spec.ts +1577 -0
  20. package/vona/packages-vona/vona/package.json +1 -1
  21. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  22. package/vona/src/suite/a-home/modules/home-user/src/dto/passport.ts +4 -0
  23. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +172 -0
  24. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +1 -0
  25. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  26. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  27. package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/modules/a-openapi/src/types/rest.ts +1 -0
  29. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  30. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  31. package/zova/packages-cli/cli/package.json +2 -2
  32. package/zova/packages-cli/cli-set-front/cli/templates/rest/render.ts +2 -0
  33. package/zova/packages-cli/cli-set-front/cli/templates/rest/rest.ts +9 -0
  34. package/zova/packages-cli/cli-set-front/package.json +1 -1
  35. package/zova/packages-zova/zova/package.json +2 -2
  36. package/zova/src/suite/a-home/modules/home-api/src/.metadata/index.ts +17 -17
  37. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +68 -52
  38. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +524 -343
  39. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +45 -11
  40. package/zova/src/suite/a-home/modules/home-passport/test/lib/persistenceIdentity.test.ts +229 -0
  41. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/package.json +9 -1
  42. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/.metadata/index.ts +81 -5
  43. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/bean/bean.draftCoordinator.ts +158 -0
  44. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockForm/controller.tsx +11 -3
  45. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +438 -8
  46. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/config.ts +11 -0
  47. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/en-us.ts +9 -0
  48. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/zh-cn.ts +9 -0
  49. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/lib/draftSchema.ts +649 -0
  50. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/model/draft.ts +59 -0
  51. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/monkey.ts +11 -0
  52. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/types/draft.ts +49 -0
  53. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftDate.test.ts +262 -0
  54. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLifecycle.test.ts +1073 -0
  55. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLoader.mjs +13 -0
  56. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftModel.test.ts +572 -0
  57. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchema.test.ts +427 -0
  58. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchemaSafety.test.ts +200 -0
  59. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  60. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/entry/controller.tsx +6 -1
  61. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  62. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  63. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +86 -1
  64. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/formField/controller.tsx +88 -13
  65. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +751 -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 +994 -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
@@ -0,0 +1,994 @@
1
+ import { FieldApi, FormApi, getBy, revalidateLogic } from '@tanstack/form-core';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import test from 'node:test';
5
+ import ts from 'typescript';
6
+ import { nextTick, ref, toRaw } from 'vue';
7
+ import { z } from 'zod';
8
+
9
+ import {
10
+ createSchemaRefreshContract,
11
+ schemaRefreshFingerprint,
12
+ SchemaRefreshTracker,
13
+ } from '../../src/lib/schemaRefresh.js';
14
+
15
+ function deferred<T = void>() {
16
+ let resolve!: (value: T) => void;
17
+ const promise = new Promise<T>(done => {
18
+ resolve = done;
19
+ });
20
+ return { promise, resolve };
21
+ }
22
+
23
+ // Like basic-pageentry's draftModel harness, execute the current source rather than a
24
+ // copied refresh algorithm. TSX requires transpileModule instead of native stripping.
25
+ function evaluateClass(path: string, name: string, bindings: Record<string, unknown>) {
26
+ const source = readFileSync(new URL(path, import.meta.url), 'utf8')
27
+ .replace(/^import[\s\S]*?;\n/gm, '')
28
+ .replace(/^\s*@(Controller|UseScope|Use)\([^\n]*\)\s*\n/gm, '');
29
+ const { outputText, diagnostics } = ts.transpileModule(source, {
30
+ fileName: path,
31
+ reportDiagnostics: true,
32
+ compilerOptions: {
33
+ target: ts.ScriptTarget.ES2022,
34
+ module: ts.ModuleKind.ESNext,
35
+ jsx: ts.JsxEmit.Preserve,
36
+ },
37
+ });
38
+ assert.deepEqual(
39
+ diagnostics?.filter(item => item.category === ts.DiagnosticCategory.Error),
40
+ [],
41
+ );
42
+ const javascript = outputText.replace(`export class ${name}`, `class ${name}`);
43
+ return new Function(...Object.keys(bindings), `${javascript}\nreturn ${name};`)(
44
+ ...Object.values(bindings),
45
+ );
46
+ }
47
+
48
+ const computedValue = Symbol('computedValue');
49
+ class ControllerBase {
50
+ $props: any = {};
51
+ app: any;
52
+ bean = { _setBean() {}, _newBeanSimple: () => ({}) };
53
+ scope = { config: { formFieldLayout: { bordered: true } } };
54
+ watchers: { source: () => any; callback: (...args: any[]) => any }[] = [];
55
+ mountedCallbacks: (() => void)[] = [];
56
+
57
+ constructor() {
58
+ // Zova computed fields are unwrapped by their owner; retain that behavior
59
+ // without booting a Vue component/container or replacing controller methods.
60
+ return new Proxy(this, {
61
+ set(target, property, value) {
62
+ if (value?.[computedValue]) {
63
+ Object.defineProperty(target, property, { configurable: true, get: value.get });
64
+ return true;
65
+ }
66
+ return Reflect.set(target, property, value);
67
+ },
68
+ });
69
+ }
70
+
71
+ $computed(get: () => any) {
72
+ return { [computedValue]: true, get };
73
+ }
74
+
75
+ $watch(source: () => any, callback: (...args: any[]) => any) {
76
+ this.watchers.push({ source, callback });
77
+ }
78
+
79
+ $controllerMounted(callback: () => void) {
80
+ this.mountedCallbacks.push(callback);
81
+ }
82
+
83
+ $useForm(options: any) {
84
+ return new FormApi(options);
85
+ }
86
+ }
87
+
88
+ const constFieldProps = Symbol('constFieldProps');
89
+ const commonBindings = {
90
+ markRaw: (value: unknown) => value,
91
+ useSelector: (store: any, selector: (state: any) => any) => ({
92
+ [computedValue]: true,
93
+ get: () => selector(store.state),
94
+ }),
95
+ isNil: (value: unknown) => value === undefined || value === null,
96
+ schemaRefreshFingerprint,
97
+ constFieldProps,
98
+ };
99
+
100
+ const ControllerForm = evaluateClass('../../src/component/form/controller.tsx', 'ControllerForm', {
101
+ ...commonBindings,
102
+ BeanControllerFormBase: ControllerBase,
103
+ createSchemaRefreshContract,
104
+ SchemaRefreshTracker,
105
+ nextTick,
106
+ revalidateLogic,
107
+ getBy,
108
+ ZodMetadata: { getFieldSchema: (schema: any, name: string) => schema?.shape[name] },
109
+ deepEqual: (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b),
110
+ deepExtend: Object.assign,
111
+ celEnvBase: { clone: () => ({ registerFunction() {} }) },
112
+ ZovaJsx: class {},
113
+ objectAssignReactive: Object.assign,
114
+ isJsxComponent: (value: any) => value?.$$typeof === 'zova-jsx:component',
115
+ cast: (value: unknown) => value,
116
+ });
117
+
118
+ interface Event {
119
+ kind: string;
120
+ value?: unknown;
121
+ options?: any;
122
+ validators?: any;
123
+ meta?: any;
124
+ }
125
+
126
+ function schema(locale = 'en-us', overrides: Record<string, unknown> = {}, root: any = {}) {
127
+ return {
128
+ type: 'object',
129
+ properties: {
130
+ name: {
131
+ type: 'string',
132
+ title: locale === 'en-us' ? 'Name' : '姓名',
133
+ minLength: 3,
134
+ errorMessage: { minLength: locale === 'en-us' ? 'Too short' : '太短' },
135
+ ...overrides,
136
+ },
137
+ },
138
+ ...root,
139
+ };
140
+ }
141
+
142
+ function sdk(locale: string) {
143
+ const validators = new WeakMap<object, any>();
144
+ return {
145
+ selector: locale,
146
+ loadSchemaProperties(value: any) {
147
+ return (
148
+ value &&
149
+ Object.entries(value.properties).map(([key, field]) => ({ key, ...(field as any) }))
150
+ );
151
+ },
152
+ getSchema: () => ({ data: undefined }),
153
+ schemaToZodSchema(value: any) {
154
+ if (!validators.has(value)) {
155
+ const field = value.properties.name;
156
+ let name: any = z.string().min(field.minLength, field.errorMessage.minLength);
157
+ if (!value.required?.includes('name')) name = name.optional();
158
+ validators.set(value, z.object({ name }));
159
+ }
160
+ return validators.get(value);
161
+ },
162
+ };
163
+ }
164
+
165
+ async function createRuntime(options: { data?: any; schema?: any; policy?: boolean } = {}) {
166
+ const events: Event[] = [];
167
+ const changed: any[] = [];
168
+ const form = new ControllerForm();
169
+ form.app = { meta: { locale: { current: 'en-us' } } };
170
+ form.$props = {
171
+ ...ControllerForm.$propsDefault,
172
+ data: options.data ?? { name: 'baseline' },
173
+ schema: options.schema ?? schema(),
174
+ localePresentationRefresh:
175
+ options.policy === false ? undefined : { identity: 'student:edit:1' },
176
+ onChanged: (data: any) => changed.push(structuredClone(data)),
177
+ };
178
+ form.$$scopeOpenapi = { config: { formProvider: {} } };
179
+ form.$sdk = sdk('en-us');
180
+ await form.__init__();
181
+ const unmountForm = form.form.mount();
182
+ const actualResetField = form.form.resetField;
183
+ form.form.resetField = (name: string) => {
184
+ events.push({ kind: 'reset', value: form.form.getFieldValue(name) });
185
+ return actualResetField(name);
186
+ };
187
+ const actualSet = form.form.setFieldValue;
188
+ form.form.setFieldValue = (name: string, value: any, opts: any) => {
189
+ events.push({ kind: 'set', value, options: opts });
190
+ return actualSet(name, value, opts);
191
+ };
192
+ const fields: any[] = [];
193
+ const cleanups: (() => void)[] = [];
194
+ const createdOptions: any[] = [];
195
+ let mountingApi = false;
196
+ const ControllerFormField = evaluateClass(
197
+ '../../src/component/formField/controller.tsx',
198
+ 'ControllerFormField',
199
+ {
200
+ ...commonBindings,
201
+ BeanControllerBase: ControllerBase,
202
+ useField(fieldOptions: any) {
203
+ createdOptions.push(fieldOptions);
204
+ const api: any = new FieldApi(fieldOptions);
205
+ const actualUpdate = api.update;
206
+ api.update = (next: any) => {
207
+ if (!mountingApi) events.push({ kind: 'update', validators: next.validators });
208
+ return actualUpdate(next);
209
+ };
210
+ const actualValidateSync = api.validateSync;
211
+ api.validateSync = (...args: any[]) => {
212
+ events.push({ kind: 'validate', validators: api.options.validators });
213
+ return actualValidateSync(...args);
214
+ };
215
+ return { api };
216
+ },
217
+ },
218
+ );
219
+ const ready = (field: any) => {
220
+ events.push({
221
+ kind: 'ready',
222
+ value: form.form.getFieldValue(field.name),
223
+ meta: structuredClone(field.field.api.state.meta),
224
+ });
225
+ };
226
+ return {
227
+ form,
228
+ events,
229
+ changed,
230
+ createdOptions,
231
+ ready,
232
+ select(locale: string, next = schema(locale)) {
233
+ form.app.meta.locale.current = locale;
234
+ form.$sdk = sdk(locale);
235
+ form.$props.schema = next;
236
+ },
237
+ beginField(props: any = {}, behaviorWait: Promise<void> = Promise.resolve()) {
238
+ const field = new ControllerFormField();
239
+ field.$$form = form;
240
+ field.$props = {
241
+ name: 'name',
242
+ validators: { onChange: true },
243
+ [constFieldProps]: true,
244
+ onFieldStateReady: ready,
245
+ ...props,
246
+ };
247
+ field.$$beanBehaviorsHolder = {
248
+ async initialize(options: any) {
249
+ options.behaviors();
250
+ await behaviorWait;
251
+ },
252
+ };
253
+ fields.push(field);
254
+ const initialized = field.__init__();
255
+ let mounted = false;
256
+ return {
257
+ field,
258
+ initialized,
259
+ mount() {
260
+ assert.equal(mounted, false);
261
+ mounted = true;
262
+ // FieldApi.mount internally updates its provisional options; only log
263
+ // controller-originated updates in the refresh ordering assertions.
264
+ mountingApi = true;
265
+ try {
266
+ cleanups.push(field.field.api.mount());
267
+ } finally {
268
+ mountingApi = false;
269
+ }
270
+ for (const callback of field.mountedCallbacks) callback();
271
+ },
272
+ refresh() {
273
+ // Drive the registered post-flush watcher, not a replacement algorithm.
274
+ field.watchers[0].callback();
275
+ },
276
+ dispose() {
277
+ field.__dispose__();
278
+ if (mounted) cleanups.pop()?.();
279
+ },
280
+ };
281
+ },
282
+ dispose() {
283
+ for (const field of fields) field.__dispose__();
284
+ for (const cleanup of cleanups.reverse()) cleanup();
285
+ form.__dispose__();
286
+ unmountForm();
287
+ },
288
+ };
289
+ }
290
+
291
+ async function withRuntime(
292
+ options: Parameters<typeof createRuntime>[0],
293
+ run: (runtime: Awaited<ReturnType<typeof createRuntime>>) => Promise<void>,
294
+ ) {
295
+ const environment = process.env as Record<string, string | undefined>;
296
+ const previousClient = environment.CLIENT;
297
+ environment.CLIENT = 'true';
298
+ const runtime = await createRuntime(options);
299
+ try {
300
+ await run(runtime);
301
+ } finally {
302
+ runtime.dispose();
303
+ if (previousClient === undefined) delete environment.CLIENT;
304
+ else environment.CLIENT = previousClient;
305
+ }
306
+ }
307
+
308
+ async function initializedField(runtime: Awaited<ReturnType<typeof createRuntime>>, props = {}) {
309
+ const owner = runtime.beginField(props);
310
+ owner.mount();
311
+ await owner.initialized;
312
+ runtime.events.length = 0;
313
+ return owner;
314
+ }
315
+
316
+ function dirty(runtime: Awaited<ReturnType<typeof createRuntime>>, value: unknown) {
317
+ runtime.form.form.setFieldValue('name', value, { dontValidate: true });
318
+ runtime.form.form.setFieldMeta('name', (previous: any) => ({
319
+ ...previous,
320
+ isTouched: true,
321
+ isBlurred: true,
322
+ errorMap: { onServer: 'stale server error' },
323
+ errorSourceMap: { onServer: 'form' },
324
+ }));
325
+ runtime.events.length = 0;
326
+ }
327
+
328
+ function lifecycle(events: Event[]) {
329
+ return events.filter(event => event.kind !== 'set').map(event => event.kind);
330
+ }
331
+
332
+ function eventIndex(events: Event[], kind: string, after = -1): number {
333
+ return events.findIndex((event, index) => index > after && event.kind === kind);
334
+ }
335
+
336
+ function eventAt(events: Event[], index: number): Event {
337
+ return events.at(index)!;
338
+ }
339
+
340
+ function lastEvent(events: Event[]): Event {
341
+ return eventAt(events, -1);
342
+ }
343
+
344
+ function assertResetMeta(meta: any) {
345
+ assert.equal(meta.isTouched, false);
346
+ assert.equal(meta.isBlurred, false);
347
+ assert.equal(meta.errorMap.onServer, undefined);
348
+ assert.equal(meta.errorSourceMap.onServer, undefined);
349
+ }
350
+
351
+ test(
352
+ 'locale refresh executes update, reset, silent live restore, fresh validation, then ready',
353
+ { concurrency: false },
354
+ async () => {
355
+ await withRuntime({}, async runtime => {
356
+ const owner = await initializedField(runtime);
357
+ dirty(runtime, 'x');
358
+ owner.field.field.api.validateSync('change', {});
359
+ assert.equal(owner.field.field.api.state.meta.errors[0].message, 'Too short');
360
+ const oldValidator = owner.field.field.api.options.validators.onChange;
361
+ runtime.events.length = 0;
362
+ runtime.select('zh-cn');
363
+ owner.refresh();
364
+ assert.deepEqual(
365
+ runtime.events.map(event => event.kind),
366
+ ['update', 'reset', 'set', 'validate', 'ready'],
367
+ );
368
+ assert.notEqual(runtime.events[0].validators.onChange, oldValidator);
369
+ assert.equal(runtime.events[0].validators.onChange, runtime.form.getFieldZodSchema('name'));
370
+ const restore = runtime.events[2];
371
+ assert.equal(restore.value, 'x');
372
+ assert.deepEqual(restore.options, {
373
+ dontUpdateMeta: true,
374
+ dontRunListeners: true,
375
+ dontValidate: true,
376
+ });
377
+ const ready = lastEvent(runtime.events);
378
+ assert.equal(ready.value, 'x');
379
+ assertResetMeta(ready.meta);
380
+ assert.equal(ready.meta.errors[0].message, '太短');
381
+ assert.equal(ready.meta.errorSourceMap.onChange, 'field');
382
+ assert.deepEqual(runtime.changed, []);
383
+ runtime.events.length = 0;
384
+ owner.refresh();
385
+ assert.deepEqual(runtime.events, []);
386
+ });
387
+ },
388
+ );
389
+
390
+ test(
391
+ 'locale replay preserves exact raw empty and falsy values',
392
+ { concurrency: false },
393
+ async () => {
394
+ for (const value of [undefined, null, '', false, 0]) {
395
+ await withRuntime({}, async runtime => {
396
+ const owner = await initializedField(runtime);
397
+ dirty(runtime, value);
398
+ runtime.select('zh-cn');
399
+ owner.refresh();
400
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'validate', 'ready']);
401
+ assert.equal(runtime.form.form.getFieldValue('name'), value);
402
+ assert.equal(lastEvent(runtime.events).value, value);
403
+ assert.deepEqual(runtime.changed, []);
404
+ });
405
+ }
406
+ },
407
+ );
408
+
409
+ test(
410
+ 'controller silent setter skips onChanged while ordinary setter and ready overrides remain active',
411
+ { concurrency: false },
412
+ async () => {
413
+ await withRuntime({}, async runtime => {
414
+ const notifications: unknown[] = [];
415
+ const ready = (field: any) => notifications.push(field.form.getFieldValue(field.name));
416
+ const owner = await initializedField(runtime, { onFieldStateReady: ready });
417
+ assert.deepEqual(notifications, ['baseline']);
418
+ owner.field.setValue('silent edit', true);
419
+ assert.deepEqual(runtime.changed, []);
420
+ owner.field.setValue('notified edit', false);
421
+ assert.deepEqual(runtime.changed, [{ name: 'notified edit' }]);
422
+ runtime.events.length = 0;
423
+ runtime.select('zh-cn');
424
+ owner.refresh();
425
+ assert.equal(owner.field.$props.onFieldStateReady, ready);
426
+ assert.deepEqual(notifications, ['baseline', 'notified edit']);
427
+ assert.deepEqual(runtime.changed, [{ name: 'notified edit' }]);
428
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'validate']);
429
+ });
430
+ },
431
+ );
432
+
433
+ test(
434
+ 'public consumeSchemaRefresh uses detached facts and cold contracts retain the baseline',
435
+ { concurrency: false },
436
+ async () => {
437
+ await withRuntime({}, async runtime => {
438
+ const { form } = runtime;
439
+ assert.deepEqual(form.consumeSchemaRefresh('name', 'configuration', true), {
440
+ changed: false,
441
+ preserve: false,
442
+ });
443
+ assert.equal(form.hasSchemaRefresh('name'), true);
444
+ runtime.select('zh-cn');
445
+ form.$props.schema = undefined;
446
+ assert.equal(form.schemaRefresh.contract.ready, false);
447
+ assert.deepEqual(form.consumeSchemaRefresh('name', 'configuration', true), {
448
+ changed: false,
449
+ preserve: false,
450
+ });
451
+ form.$props.schema = schema('zh-cn');
452
+ assert.deepEqual(form.consumeSchemaRefresh('name', 'configuration', true), {
453
+ changed: true,
454
+ preserve: true,
455
+ });
456
+ form.__dispose__();
457
+ assert.equal(form.hasSchemaRefresh('name'), false);
458
+ });
459
+ },
460
+ );
461
+
462
+ test(
463
+ 'same-locale display metadata and constraint changes reset without replay',
464
+ { concurrency: false },
465
+ async () => {
466
+ for (const next of [schema('en-us', { title: 'Renamed' }), schema('en-us', { minLength: 5 })]) {
467
+ await withRuntime({}, async runtime => {
468
+ const owner = await initializedField(runtime);
469
+ dirty(runtime, 'live edit');
470
+ runtime.form.$props.schema = next;
471
+ owner.refresh();
472
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'ready']);
473
+ assert.equal(
474
+ runtime.events.some(event => event.kind === 'set'),
475
+ false,
476
+ );
477
+ assert.equal(lastEvent(runtime.events).value, 'baseline');
478
+ assertResetMeta(lastEvent(runtime.events).meta);
479
+ assert.deepEqual(runtime.changed, []);
480
+ });
481
+ }
482
+ },
483
+ );
484
+
485
+ test(
486
+ 'changed own renderer options reset even without an opaque slot',
487
+ { concurrency: false },
488
+ async () => {
489
+ const rest = (title: string) => ({
490
+ render: {
491
+ $$typeof: 'zova-jsx:component',
492
+ type: 'training-student:formFieldLevel',
493
+ props: { items: [{ value: 1, title }] },
494
+ },
495
+ });
496
+ await withRuntime({ schema: schema('en-us', { rest: rest('Foundation') }) }, async runtime => {
497
+ const owner = await initializedField(runtime);
498
+ dirty(runtime, 'live edit');
499
+ runtime.select('zh-cn', schema('zh-cn', { rest: rest('基础') }));
500
+ owner.refresh();
501
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'ready']);
502
+ assert.equal(runtime.form.form.getFieldValue('name'), 'baseline');
503
+ assert.equal(
504
+ runtime.events.some(event => event.kind === 'set'),
505
+ false,
506
+ );
507
+ assert.deepEqual(runtime.changed, []);
508
+ });
509
+ },
510
+ );
511
+
512
+ test(
513
+ 'parent required changes reset even when canonical field metadata is identical',
514
+ { concurrency: false },
515
+ async () => {
516
+ await withRuntime({}, async runtime => {
517
+ const owner = await initializedField(runtime);
518
+ const property = runtime.form.getFieldProperty('name');
519
+ dirty(runtime, 'live edit');
520
+ runtime.form.$props.schema = schema('en-us', {}, { required: ['name'] });
521
+ assert.deepEqual(runtime.form.getFieldProperty('name'), property);
522
+ owner.refresh();
523
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'ready']);
524
+ assert.equal(lastEvent(runtime.events).value, 'baseline');
525
+ });
526
+ },
527
+ );
528
+
529
+ test(
530
+ 'cold schema skips refresh and ready until current SDK and schema are available',
531
+ { concurrency: false },
532
+ async () => {
533
+ await withRuntime({}, async runtime => {
534
+ const owner = await initializedField(runtime);
535
+ dirty(runtime, 'live edit');
536
+ runtime.form.app.meta.locale.current = 'zh-cn';
537
+ runtime.form.$props.schema = undefined;
538
+ owner.refresh();
539
+ assert.deepEqual(runtime.events, []);
540
+ assert.equal(runtime.form.form.getFieldValue('name'), 'live edit');
541
+ runtime.form.$props.schema = schema('zh-cn');
542
+ owner.refresh();
543
+ assert.equal(runtime.form.schemaRefresh.contract.ready, false);
544
+ assert.deepEqual(runtime.events, []);
545
+ runtime.form.$sdk = sdk('zh-cn');
546
+ owner.refresh();
547
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'validate', 'ready']);
548
+ assert.equal(lastEvent(runtime.events).value, 'live edit');
549
+ });
550
+ },
551
+ );
552
+
553
+ test(
554
+ 'remount omits provisional default, waits for behaviors and TanStack mount, and replays exact null/undefined',
555
+ { concurrency: false },
556
+ async () => {
557
+ for (const value of [undefined, null]) {
558
+ for (const mountBeforeBehaviors of [true, false]) {
559
+ await withRuntime(
560
+ { schema: schema('en-us', { default: 'schema default' }) },
561
+ async runtime => {
562
+ const original = await initializedField(runtime);
563
+ // Untouched state is important: TanStack update/mount otherwise does not seed defaults.
564
+ runtime.form.form.setFieldValue('name', value, {
565
+ dontUpdateMeta: true,
566
+ dontValidate: true,
567
+ dontRunListeners: true,
568
+ });
569
+ original.dispose();
570
+ runtime.form.app.meta.locale.current = 'zh-cn';
571
+ runtime.form.$props.schema = undefined;
572
+ const wait = deferred();
573
+ const remount = runtime.beginField({}, wait.promise);
574
+ assert.equal(Object.hasOwn(runtime.createdOptions.at(-1), 'defaultValue'), false);
575
+ assert.equal(runtime.form.form.getFieldValue('name'), value);
576
+ runtime.events.length = 0;
577
+ remount.refresh();
578
+ assert.deepEqual(runtime.events, []);
579
+ runtime.select('zh-cn', schema('zh-cn', { default: 'schema default' }));
580
+ if (mountBeforeBehaviors) remount.mount();
581
+ remount.refresh();
582
+ assert.deepEqual(runtime.events, []);
583
+ assert.equal(runtime.form.form.getFieldValue('name'), value);
584
+ wait.resolve();
585
+ await remount.initialized;
586
+ if (!mountBeforeBehaviors) {
587
+ assert.deepEqual(runtime.events, []);
588
+ assert.equal(runtime.form.form.getFieldValue('name'), value);
589
+ remount.mount();
590
+ }
591
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'validate', 'ready']);
592
+ const resetIndex = eventIndex(runtime.events, 'reset');
593
+ const restoreIndex = eventIndex(runtime.events, 'set', resetIndex);
594
+ assert.ok(resetIndex > eventIndex(runtime.events, 'update'));
595
+ assert.ok(restoreIndex > resetIndex);
596
+ assert.equal(eventAt(runtime.events, restoreIndex).value, value);
597
+ assert.deepEqual(eventAt(runtime.events, restoreIndex).options, {
598
+ dontUpdateMeta: true,
599
+ dontRunListeners: true,
600
+ dontValidate: true,
601
+ });
602
+ assert.equal(runtime.form.form.getFieldValue('name'), value);
603
+ assert.equal(lastEvent(runtime.events).value, value);
604
+ assertResetMeta(lastEvent(runtime.events).meta);
605
+ assert.deepEqual(runtime.changed, []);
606
+ },
607
+ );
608
+ }
609
+ }
610
+ },
611
+ );
612
+
613
+ test(
614
+ 'reset epoch prevents locale replay even when the reset value is subsequently edited',
615
+ { concurrency: false },
616
+ async () => {
617
+ await withRuntime({}, async runtime => {
618
+ const owner = await initializedField(runtime);
619
+ runtime.form.reset({ name: 'reset baseline' });
620
+ dirty(runtime, 'edit after reset');
621
+ runtime.select('zh-cn');
622
+ owner.refresh();
623
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'ready']);
624
+ assert.equal(lastEvent(runtime.events).value, 'reset baseline');
625
+ assert.equal(
626
+ runtime.events.some(event => event.kind === 'set'),
627
+ false,
628
+ );
629
+ });
630
+ },
631
+ );
632
+
633
+ test(
634
+ 'record, scene, disabled policy and custom Zod truth do not authorize replay',
635
+ { concurrency: false },
636
+ async () => {
637
+ for (const change of [
638
+ (form: any) => {
639
+ form.$props.localePresentationRefresh = { identity: 'student:edit:2' };
640
+ },
641
+ (form: any) => {
642
+ form.$props.schemaScene = 'form-view';
643
+ },
644
+ (form: any) => {
645
+ form.$props.localePresentationRefresh = undefined;
646
+ },
647
+ (form: any) => {
648
+ form.$props.zodSchema = z.object({ name: z.string() });
649
+ },
650
+ ]) {
651
+ await withRuntime({}, async runtime => {
652
+ const owner = await initializedField(runtime);
653
+ dirty(runtime, 'live edit');
654
+ runtime.select('zh-cn');
655
+ change(runtime.form);
656
+ owner.refresh();
657
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'ready']);
658
+ assert.equal(lastEvent(runtime.events).value, 'baseline');
659
+ assert.deepEqual(runtime.changed, []);
660
+ });
661
+ }
662
+ },
663
+ );
664
+
665
+ test(
666
+ 'disabled policy from initial render still refreshes metadata without replay',
667
+ { concurrency: false },
668
+ async () => {
669
+ await withRuntime({ policy: false }, async runtime => {
670
+ const owner = await initializedField(runtime);
671
+ dirty(runtime, 'live edit');
672
+ runtime.select('zh-cn');
673
+ owner.refresh();
674
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'ready']);
675
+ assert.equal(lastEvent(runtime.events).value, 'baseline');
676
+ });
677
+ },
678
+ );
679
+
680
+ test(
681
+ 'callback validator and sys overrides survive reset and are not replay-eligible',
682
+ { concurrency: false },
683
+ async () => {
684
+ const callback = () => 'custom validation';
685
+ for (const props of [
686
+ { validators: { onChange: callback } },
687
+ { sys: { defaultValue: 'custom default', validators: { onChange: callback } } },
688
+ ]) {
689
+ await withRuntime({}, async runtime => {
690
+ const owner = await initializedField(runtime, props);
691
+ assert.equal(owner.field.field.api.options.validators.onChange, callback);
692
+ dirty(runtime, 'live edit');
693
+ runtime.select('zh-cn');
694
+ owner.refresh();
695
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'ready']);
696
+ assert.equal(owner.field.field.api.options.validators.onChange, callback);
697
+ assert.equal(runtime.events[0].validators.onChange, callback);
698
+ assert.equal(lastEvent(runtime.events).value, props.sys?.defaultValue ?? 'baseline');
699
+ assertResetMeta(lastEvent(runtime.events).meta);
700
+ });
701
+ }
702
+ },
703
+ );
704
+
705
+ test(
706
+ 'behavior/effect/slot customizations preserve callbacks while disabling automatic replay',
707
+ { concurrency: false },
708
+ async () => {
709
+ for (const props of [{ behaviors: {} }, { onEffect() {} }, { slotDefault() {} }]) {
710
+ await withRuntime({}, async runtime => {
711
+ const owner = await initializedField(runtime, props);
712
+ dirty(runtime, 'live edit');
713
+ runtime.select('zh-cn');
714
+ owner.refresh();
715
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'ready']);
716
+ for (const [key, value] of Object.entries(props))
717
+ assert.equal(owner.field.$props[key], value);
718
+ assert.equal(lastEvent(runtime.events).value, 'baseline');
719
+ });
720
+ }
721
+ },
722
+ );
723
+
724
+ test(
725
+ 'replacing inline slot and ready callbacks does not reset; schema changes use the latest ready callback',
726
+ { concurrency: false },
727
+ async () => {
728
+ await withRuntime({}, async runtime => {
729
+ runtime.form.$$scopeOpenapi.config.formProvider = {
730
+ components: { Input: 'basic-input:formFieldInput' },
731
+ };
732
+ const before: unknown[] = [];
733
+ const after: unknown[] = [];
734
+ const owner = await initializedField(runtime, {
735
+ slotDefault: () => 'initial slot',
736
+ onFieldStateReady: (field: any) => before.push(field.form.getFieldValue(field.name)),
737
+ });
738
+ assert.deepEqual(before, ['baseline']);
739
+ dirty(runtime, 'typed name');
740
+ owner.field.$props.slotDefault = () => 'replacement slot';
741
+ owner.field.$props.onFieldStateReady = (field: any) =>
742
+ after.push(field.form.getFieldValue(field.name));
743
+ owner.refresh();
744
+ assert.deepEqual(runtime.events, []);
745
+ assert.equal(runtime.form.form.getFieldValue('name'), 'typed name');
746
+ assert.deepEqual(after, []);
747
+ runtime.select('zh-cn');
748
+ owner.refresh();
749
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'validate']);
750
+ assert.deepEqual(before, ['baseline']);
751
+ assert.deepEqual(after, ['typed name']);
752
+ assert.deepEqual(runtime.changed, []);
753
+ });
754
+ },
755
+ );
756
+
757
+ test(
758
+ 'canonical Basic input binding slots preserve live names across locale refresh',
759
+ { concurrency: false },
760
+ async () => {
761
+ for (const render of [
762
+ undefined,
763
+ 'Input',
764
+ 'basic-input:formFieldInput',
765
+ { $$typeof: 'zova-jsx:component', type: 'basic-input:formFieldInput' },
766
+ ]) {
767
+ await withRuntime({}, async runtime => {
768
+ runtime.form.$$scopeOpenapi.config.formProvider = {
769
+ components: { Input: 'basic-input:formFieldInput' },
770
+ };
771
+ const owner = await initializedField(runtime, {
772
+ render,
773
+ slotDefault: () => 'binding slot',
774
+ });
775
+ dirty(runtime, 'typed name');
776
+ runtime.select('zh-cn');
777
+ owner.refresh();
778
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'validate', 'ready']);
779
+ assert.equal(lastEvent(runtime.events).value, 'typed name');
780
+ assertResetMeta(lastEvent(runtime.events).meta);
781
+ assert.deepEqual(runtime.changed, []);
782
+ });
783
+ }
784
+ },
785
+ );
786
+
787
+ test(
788
+ 'manual Basic input and canonical custom provider slots remain replay-ineligible',
789
+ { concurrency: false },
790
+ async () => {
791
+ for (const props of [
792
+ { [constFieldProps]: false, render: 'basic-input:formFieldInput' },
793
+ { render: 'custom:formFieldInput' },
794
+ { render: 'Input' },
795
+ ]) {
796
+ await withRuntime({}, async runtime => {
797
+ runtime.form.$$scopeOpenapi.config.formProvider = {
798
+ components: { Input: 'custom:formFieldInput' },
799
+ };
800
+ const owner = await initializedField(runtime, {
801
+ ...props,
802
+ slotDefault: () => 'custom slot',
803
+ });
804
+ dirty(runtime, 'typed name');
805
+ runtime.select('zh-cn');
806
+ owner.refresh();
807
+ assert.deepEqual(lifecycle(runtime.events), ['update', 'reset', 'ready']);
808
+ assert.equal(lastEvent(runtime.events).value, 'baseline');
809
+ assert.deepEqual(runtime.changed, []);
810
+ });
811
+ }
812
+ },
813
+ );
814
+
815
+ test(
816
+ 'disposal during awaited behavior initialization suppresses refresh and ready callbacks',
817
+ { concurrency: false },
818
+ async () => {
819
+ await withRuntime({}, async runtime => {
820
+ const wait = deferred();
821
+ const owner = runtime.beginField({}, wait.promise);
822
+ owner.mount();
823
+ owner.dispose();
824
+ runtime.events.length = 0;
825
+ runtime.select('zh-cn');
826
+ wait.resolve();
827
+ await owner.initialized;
828
+ owner.refresh();
829
+ assert.deepEqual(runtime.events, []);
830
+ assert.equal(runtime.form.hasSchemaRefresh('name'), false);
831
+ });
832
+ },
833
+ );
834
+
835
+ test(
836
+ 'schema completion callback coalesces after ready and reads current values without onChanged',
837
+ { concurrency: false },
838
+ async () => {
839
+ await withRuntime({}, async runtime => {
840
+ const notifications: unknown[] = [];
841
+ runtime.form.$props.onSchemaRefreshed = (values: any) =>
842
+ notifications.push(structuredClone(values));
843
+ const owner = await initializedField(runtime);
844
+ dirty(runtime, 'live edit');
845
+ runtime.select('zh-cn');
846
+ owner.refresh();
847
+ assert.deepEqual(notifications, []);
848
+ assert.equal(lastEvent(runtime.events).kind, 'ready');
849
+ await nextTick();
850
+ assert.deepEqual(notifications, [{ name: 'live edit' }]);
851
+ notifications.length = 0;
852
+ runtime.form.schemaFieldRefreshed();
853
+ runtime.form.schemaFieldRefreshed();
854
+ runtime.form.form.setFieldValue('name', 'latest edit', {
855
+ dontUpdateMeta: true,
856
+ dontRunListeners: true,
857
+ dontValidate: true,
858
+ });
859
+ await nextTick();
860
+ assert.deepEqual(notifications, [{ name: 'latest edit' }]);
861
+ assert.deepEqual(runtime.changed, []);
862
+ });
863
+ },
864
+ );
865
+
866
+ test(
867
+ 'queued schema completion is suppressed by reset, locale drift, cold truth, and disposal',
868
+ { concurrency: false },
869
+ async () => {
870
+ for (const invalidate of [
871
+ (runtime: any) => runtime.form.reset({ name: 'reset' }),
872
+ (runtime: any) => {
873
+ runtime.form.app.meta.locale.current = 'fr-fr';
874
+ },
875
+ (runtime: any) => {
876
+ runtime.form.$props.schema = undefined;
877
+ },
878
+ (runtime: any) => runtime.form.__dispose__(),
879
+ ]) {
880
+ await withRuntime({}, async runtime => {
881
+ const notifications: unknown[] = [];
882
+ runtime.form.$props.onSchemaRefreshed = (values: any) => notifications.push(values);
883
+ const owner = await initializedField(runtime);
884
+ runtime.select('zh-cn');
885
+ owner.refresh();
886
+ invalidate(runtime);
887
+ await nextTick();
888
+ assert.deepEqual(notifications, []);
889
+ assert.deepEqual(runtime.changed, []);
890
+ });
891
+ }
892
+ },
893
+ );
894
+
895
+ test(
896
+ 'queued schema completion is invalidated by record identity drift',
897
+ { concurrency: false },
898
+ async () => {
899
+ await withRuntime({}, async runtime => {
900
+ const notifications: unknown[] = [];
901
+ runtime.form.$props.onSchemaRefreshed = (values: any) => notifications.push(values);
902
+ const owner = await initializedField(runtime);
903
+ runtime.select('zh-cn');
904
+ owner.refresh();
905
+ runtime.form.$props.localePresentationRefresh = { identity: 'student:edit:2' };
906
+ await nextTick();
907
+ assert.deepEqual(notifications, []);
908
+ });
909
+ },
910
+ );
911
+
912
+ test('queued schema completion is invalidated by scene drift', { concurrency: false }, async () => {
913
+ await withRuntime({}, async runtime => {
914
+ const notifications: unknown[] = [];
915
+ runtime.form.$props.onSchemaRefreshed = (values: any) => notifications.push(values);
916
+ const owner = await initializedField(runtime);
917
+ runtime.select('zh-cn');
918
+ owner.refresh();
919
+ runtime.form.$props.schemaScene = 'form-view';
920
+ await nextTick();
921
+ assert.deepEqual(notifications, []);
922
+ });
923
+ });
924
+
925
+ test(
926
+ 'a-openapi monkey suppresses obsolete rapid SDK selection completions',
927
+ { concurrency: false },
928
+ async () => {
929
+ const selections: { locale: string; result: ReturnType<typeof deferred<any>> }[] = [];
930
+ const watchers: (() => Promise<void>)[] = [];
931
+ class BeanSimple {
932
+ app = {
933
+ meta: { locale: { current: 'en-us' } },
934
+ bean: {
935
+ _getBeanSelector(name: string, cached: boolean, locale: string) {
936
+ assert.equal(name, 'a-openapi.model.sdk');
937
+ assert.equal(cached, true);
938
+ const result = deferred<any>();
939
+ selections.push({ locale, result });
940
+ return result.promise;
941
+ },
942
+ },
943
+ };
944
+
945
+ ctx = { util: { instanceScope: (run: () => void) => run() } };
946
+ }
947
+ const Monkey = evaluateClass('../../../a-openapi/src/monkey.ts', 'Monkey', {
948
+ BeanSimple,
949
+ ref,
950
+ watch: (_source: () => string, callback: () => Promise<void>) => watchers.push(callback),
951
+ });
952
+ const monkey = new Monkey();
953
+ const consumer: any = {};
954
+ await monkey.beanInit({ defineProperty: Object.defineProperty }, consumer);
955
+ const initialize = monkey.appInitialize();
956
+ const english = { selector: 'en-us' };
957
+ selections[0].result.resolve(english);
958
+ await initialize;
959
+ assert.equal(toRaw(consumer.$sdk.value), english);
960
+ assert.equal(watchers.length, 1);
961
+
962
+ monkey.app.meta.locale.current = 'zh-cn';
963
+ const first = watchers[0]();
964
+ monkey.app.meta.locale.current = 'fr-fr';
965
+ const second = watchers[0]();
966
+ const french = { selector: 'fr-fr' };
967
+ selections[2].result.resolve(french);
968
+ await second;
969
+ selections[1].result.resolve({ selector: 'zh-cn' });
970
+ await first;
971
+ assert.equal(toRaw(consumer.$sdk.value), french);
972
+
973
+ // Returning to the same locale must not resurrect an earlier generation.
974
+ monkey.app.meta.locale.current = 'en-us';
975
+ const third = watchers[0]();
976
+ monkey.app.meta.locale.current = 'zh-cn';
977
+ const fourth = watchers[0]();
978
+ monkey.app.meta.locale.current = 'en-us';
979
+ const fifth = watchers[0]();
980
+ selections[3].result.resolve({ selector: 'en-us', obsolete: true });
981
+ await third;
982
+ assert.equal(toRaw(consumer.$sdk.value), french);
983
+ const latest = { selector: 'en-us', latest: true };
984
+ selections[5].result.resolve(latest);
985
+ await fifth;
986
+ selections[4].result.resolve({ selector: 'zh-cn' });
987
+ await fourth;
988
+ assert.equal(toRaw(consumer.$sdk.value), latest);
989
+ assert.deepEqual(
990
+ selections.map(item => item.locale),
991
+ ['en-us', 'zh-cn', 'fr-fr', 'en-us', 'zh-cn', 'en-us'],
992
+ );
993
+ },
994
+ );