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,524 @@
1
+ import { extend } from '@cabloy/extend';
2
+ import { dehydrate, QueryClient, QueryObserver } from '@tanstack/query-core';
3
+ import localforage from 'localforage';
4
+ import { mutate } from 'mutate-on-copy';
5
+ import assert from 'node:assert/strict';
6
+ import { readFileSync } from 'node:fs';
7
+ import { createRequire, stripTypeScriptTypes } from 'node:module';
8
+ import test from 'node:test';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { isDeepStrictEqual } from 'node:util';
11
+ import { computed, customRef, effectScope, markRaw, reactive, shallowReactive, watch } from 'vue';
12
+ import { SymbolBeanFullName } from 'zova';
13
+
14
+ import { BeanModelUseState } from '../../../a-model/src/bean/bean.model/bean.model.useState.js';
15
+ import { ModelPersistenceContext } from '../../../a-model/src/lib/modelPersistence.js';
16
+
17
+ // Only imports/decorators are removed; every tabs/state/persister method below is
18
+ // production code. Real Vue refs and QueryObservers model the reactive IoC owner.
19
+ const source = readFileSync(new URL('../../src/model/tabs.ts', import.meta.url), 'utf8')
20
+ .replace(/^import[\s\S]*?;\n/gm, '')
21
+ .replace(/^@Model[\s\S]+?^\}\)\n/gm, '');
22
+ const javascript = stripTypeScriptTypes(source).replace(
23
+ 'export class ModelTabs',
24
+ 'class ModelTabs',
25
+ );
26
+ const ModelTabs = new Function(
27
+ 'BeanModelBase',
28
+ 'deepExtend',
29
+ 'deepEqual',
30
+ 'mutate',
31
+ 'shallowReactive',
32
+ 'process',
33
+ `${javascript}\nreturn ModelTabs;`,
34
+ )(
35
+ BeanModelUseState,
36
+ (...values: any[]) => extend(true, ...values),
37
+ isDeepStrictEqual,
38
+ mutate,
39
+ shallowReactive,
40
+ { env: { CLIENT: true } },
41
+ );
42
+
43
+ function deferred<T = void>() {
44
+ let resolve!: (value: T) => void;
45
+ const promise = new Promise<T>(done => {
46
+ resolve = done;
47
+ });
48
+ return { promise, resolve };
49
+ }
50
+ const tick = () => new Promise<void>(resolve => setTimeout(resolve, 5));
51
+ const records = new Map<string, string>();
52
+ const barriers = new Map<string, ReturnType<typeof deferred<void>>>();
53
+ const reads: string[] = [];
54
+ const writes: string[] = [];
55
+ Object.assign(localforage, {
56
+ async getItem(key: string) {
57
+ reads.push(key);
58
+ const value = records.get(key);
59
+ await barriers.get(key)?.promise;
60
+ return value;
61
+ },
62
+ async setItem(key: string, value: string) {
63
+ writes.push(key);
64
+ records.set(key, value);
65
+ },
66
+ async removeItem(key: string) {
67
+ records.delete(key);
68
+ },
69
+ });
70
+
71
+ function tab(key: string, dirty = false) {
72
+ return {
73
+ tabKey: key,
74
+ info: { title: key },
75
+ updatedAt: 1,
76
+ items: [{ componentKey: key, fullPath: key, updatedAt: 1, pageMeta: { pageDirty: dirty } }],
77
+ };
78
+ }
79
+ function route(key: string) {
80
+ return { path: key, fullPath: key, meta: {} };
81
+ }
82
+ function fixture(cache = true) {
83
+ records.clear();
84
+ barriers.clear();
85
+ reads.length = 0;
86
+ writes.length = 0;
87
+ const client = markRaw(new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } }));
88
+ const persistence = new ModelPersistenceContext(client, 'site');
89
+ const owner = effectScope();
90
+ const ctx = reactive({ disposed: false });
91
+ const errors: unknown[] = [];
92
+ const navigations: string[] = [];
93
+ const stops: (() => void)[] = [];
94
+ let initialKey = '/initial';
95
+ let defaults = 0;
96
+ let hmr: (data: unknown, next: () => void) => void;
97
+ let offHmr = 0;
98
+ const model = reactive(new ModelTabs()) as any;
99
+ const properties: PropertyDescriptorMap = {
100
+ ctx: { value: ctx },
101
+ bean: {
102
+ get() {
103
+ assert.equal(ctx.disposed, false, 'disposed container access');
104
+ return { _setBean() {} };
105
+ },
106
+ },
107
+ $queryClient: { value: client },
108
+ $modelPersistence: {
109
+ get() {
110
+ assert.equal(ctx.disposed, false, 'disposed persistence lookup');
111
+ return persistence;
112
+ },
113
+ },
114
+ scopeSelf: {
115
+ value: {
116
+ config: {
117
+ persister: { maxAge: { db: Infinity, local: Infinity }, refetchOnRestore: false },
118
+ },
119
+ },
120
+ },
121
+ sys: {
122
+ value: {
123
+ env: { APP_NAME: 'tabs-test', APP_VERSION: '1' },
124
+ config: { ssr: { hmr: true } },
125
+ meta: {
126
+ event: {
127
+ on(_name: string, fn: typeof hmr) {
128
+ hmr = fn;
129
+ return () => offHmr++;
130
+ },
131
+ },
132
+ },
133
+ },
134
+ },
135
+ app: { value: reactive({ meta: { locale: { current: 'en-us' } } }) },
136
+ $router: {
137
+ value: {
138
+ getRealRouteName: () => undefined,
139
+ push: async (path: string) => navigations.push(path),
140
+ notifyRouteDiscard: () => undefined,
141
+ },
142
+ },
143
+ $currentRoute: { value: undefined, writable: true },
144
+ [SymbolBeanFullName]: { value: 'a-routertabs.model.tabs' },
145
+ };
146
+ for (const property of Reflect.ownKeys(properties)) properties[property].configurable = true;
147
+ Object.defineProperties(model, properties);
148
+ model.$computed = (value: any) => owner.run(() => computed(value));
149
+ model.$customRef = (factory: any) => customRef(factory);
150
+ model.$watch = (...args: any[]) => owner.run(() => (watch as any)(...args));
151
+ model.$errorHandler = (error: unknown) => errors.push(error);
152
+ model.$useQuery = (options: any) => {
153
+ const observer = new QueryObserver(
154
+ client,
155
+ persistence.guardOptions({
156
+ ...options,
157
+ queryKey: model._forceQueryKeyPrefix(options.queryKey),
158
+ persister: model._createPersister(options.meta?.persister),
159
+ }),
160
+ );
161
+ const state = shallowReactive(observer.getCurrentResult());
162
+ stops.push(observer.subscribe(result => Object.assign(state, result)));
163
+ return {
164
+ get data() {
165
+ return state.data;
166
+ },
167
+ suspense: () => observer.refetch(),
168
+ };
169
+ };
170
+ const options = {
171
+ cache,
172
+ max: -1,
173
+ maxItems: -1,
174
+ getInitialTabs() {
175
+ defaults++;
176
+ return [tab(initialKey)];
177
+ },
178
+ getTabInfo: (key: string) => ({ title: `${key}:${model.app.meta.locale.current}` }),
179
+ };
180
+ function key(user: string | null) {
181
+ const options = model._adjustPersisterOptions();
182
+ return model._getPersisterStorageKey(options, undefined, ['tabs'], {
183
+ scope: [null, 'site', user],
184
+ epoch: 0,
185
+ });
186
+ }
187
+ function store(user: string | null, tabs: unknown) {
188
+ records.set(
189
+ key(user),
190
+ JSON.stringify({ state: { data: tabs, dataUpdatedAt: Date.now() }, buster: '1' }),
191
+ );
192
+ }
193
+ function block(user: string | null) {
194
+ const pending = deferred();
195
+ barriers.set(key(user), pending);
196
+ return pending;
197
+ }
198
+ return {
199
+ model,
200
+ ctx,
201
+ persistence,
202
+ errors,
203
+ navigations,
204
+ client,
205
+ key,
206
+ store,
207
+ block,
208
+ init: () => model.__init__('test', options),
209
+ defaults: () => defaults,
210
+ setInitial: (key: string) => {
211
+ initialKey = key;
212
+ },
213
+ reload: () => hmr?.(undefined, () => undefined),
214
+ offHmr: () => offHmr,
215
+ async dispose() {
216
+ model.__dispose__();
217
+ ctx.disposed = true;
218
+ owner.stop();
219
+ for (const pending of barriers.values()) pending.resolve();
220
+ await tick();
221
+ await model.$persisterFlush();
222
+ for (const stop of stops) stop();
223
+ client.clear();
224
+ },
225
+ };
226
+ }
227
+
228
+ test('cached tabs remain a writable current-scope array during a delayed identity restore', async () => {
229
+ const f = fixture();
230
+ try {
231
+ f.store(null, [tab('/old')]);
232
+ await f.init();
233
+ assert.equal(f.model.findTab('/old')[0], 0);
234
+ f.store('next', [tab('/saved')]);
235
+ const stored = records.get(f.key('next'));
236
+ const pending = f.block('next');
237
+ f.persistence.update({ siteId: 'site', userId: 'next' });
238
+ assert.deepEqual(
239
+ f.model.tabs.map((item: any) => item.tabKey),
240
+ [],
241
+ );
242
+ assert.deepEqual(f.model.keepAliveInclude, []);
243
+ assert.deepEqual(f.model.findTab('/old'), [-1, undefined]);
244
+ assert.deepEqual(f.model.findTabItemByFullPath('/old'), []);
245
+ for (let i = 0; i < 3; i++) assert.deepEqual([...f.model.tabs], []);
246
+ // Incidental mutations/assignments of the neutral projection cannot become
247
+ // canonical query data or overwrite the destination while storage is pending.
248
+ f.model.tabs.push(tab('/incidental'));
249
+ assert.deepEqual(f.model.tabs, []);
250
+ f.model.tabs = [];
251
+ await tick();
252
+ assert.equal(reads.filter(key => key === f.key('next')).length, 1);
253
+ assert.equal(writes.length, 0);
254
+ assert.equal(records.get(f.key('next')), stored);
255
+ pending.resolve();
256
+ await tick();
257
+ assert.equal(f.model.findTab('/saved')[0], 0);
258
+ assert.equal(f.model.findTab('/old')[0], -1);
259
+ assert.equal(
260
+ f.model.addTab({ tabKey: '/sync', componentKey: '/sync', fullPath: '/sync' }),
261
+ true,
262
+ );
263
+ assert.notEqual(f.model.findTab('/sync')[0], -1);
264
+ f.model.tabs = [tab('/replacement')];
265
+ assert.equal(f.model.tabs[0].tabKey, '/replacement');
266
+ } finally {
267
+ await f.dispose();
268
+ }
269
+ });
270
+
271
+ test('one restore boundary defers routes, page metadata, menu/locale/HMR and synchronous tab creation', async () => {
272
+ const f = fixture();
273
+ try {
274
+ await f.init();
275
+ f.store('next', [tab('/saved', true)]);
276
+ const pending = f.block('next');
277
+ f.persistence.update({ siteId: 'site', userId: 'next' });
278
+ f.model.$currentRoute = route('/current');
279
+ f.model.forwardRoute(route('/task'));
280
+ f.model.setPageMeta(route('/task'), { pageTitle: 'Deferred title', pageDirty: true });
281
+ f.model.updateAllTabInfos();
282
+ f.model.app.meta.locale.current = 'zh-cn';
283
+ f.reload();
284
+ const result = f.model.addTab({ tabKey: '/sync', componentKey: '/sync', fullPath: '/sync' });
285
+ assert.equal(typeof result, 'boolean');
286
+ assert.deepEqual(f.model.tabs, []);
287
+ await tick();
288
+ assert.equal(writes.length, 0);
289
+ pending.resolve();
290
+ await tick();
291
+ assert.notEqual(f.model.findTab('/saved')[0], -1);
292
+ assert.equal(f.model.findTab('/saved')[1].items[0].pageMeta.pageDirty, false);
293
+ assert.notEqual(f.model.findTab('/current')[0], -1);
294
+ assert.notEqual(f.model.findTab('/sync')[0], -1);
295
+ assert.equal(f.model.findTab('/task')[1].items[0].pageMeta.pageTitle, 'Deferred title');
296
+ assert.equal(f.model.findTab('/task')[1].info.title, '/task:zh-cn');
297
+ assert.equal(reads.filter(key => key === f.key('next')).length, 1);
298
+ } finally {
299
+ await f.dispose();
300
+ }
301
+ });
302
+
303
+ test('obsolete epochs never replay deferred work or current-route initialization into the destination', async () => {
304
+ const f = fixture();
305
+ try {
306
+ await f.init();
307
+ const old = f.block('old');
308
+ f.persistence.update({ siteId: 'site', userId: 'old' });
309
+ f.model.$currentRoute = route('/old-current');
310
+ f.model.forwardRoute(route('/obsolete'));
311
+ f.model.resetAllTabInfos();
312
+ f.model.backRoute(route('/keep'));
313
+ const latest = f.block('latest');
314
+ f.store('latest', [tab('/keep')]);
315
+ f.persistence.update({ siteId: 'site', userId: 'latest' });
316
+ f.model.$currentRoute = route('/latest-current');
317
+ f.model.forwardRoute(route('/latest'));
318
+ latest.resolve();
319
+ await tick();
320
+ old.resolve();
321
+ await tick();
322
+ assert.equal(f.model.findTab('/obsolete')[0], -1);
323
+ assert.equal(f.model.findTab('/old-current')[0], -1);
324
+ assert.notEqual(f.model.findTab('/keep')[0], -1);
325
+ assert.notEqual(f.model.findTab('/latest')[0], -1);
326
+ assert.notEqual(f.model.findTab('/latest-current')[0], -1);
327
+ assert.deepEqual(f.errors, []);
328
+ } finally {
329
+ await f.dispose();
330
+ }
331
+ });
332
+
333
+ test('initial restore uses a fresh default factory and initializes only the current live scope', async () => {
334
+ const f = fixture();
335
+ try {
336
+ const pending = f.block(null);
337
+ const init = f.init();
338
+ await tick();
339
+ assert.equal(f.defaults(), 0);
340
+ f.setInitial('/fresh');
341
+ f.model.$currentRoute = route('/current');
342
+ pending.resolve();
343
+ await init;
344
+ assert.equal(
345
+ f.model.tabs.some((item: any) => item.tabKey === '/fresh'),
346
+ true,
347
+ );
348
+ assert.equal(f.defaults(), 1);
349
+ assert.notEqual(f.model.findTab('/current')[0], -1);
350
+ const next = f.block('next');
351
+ f.persistence.update({ siteId: 'site', userId: 'next' });
352
+ void f.model.tabs;
353
+ f.setInitial('/new-scope');
354
+ next.resolve();
355
+ await tick();
356
+ assert.notEqual(f.model.findTab('/new-scope')[0], -1);
357
+ assert.equal(f.model.findTab('/fresh')[0], -1);
358
+ assert.equal(f.defaults(), 2);
359
+ } finally {
360
+ await f.dispose();
361
+ }
362
+ });
363
+
364
+ test('disposal during restore does not initialize routes, replay ingress or access disposed context', async () => {
365
+ const f = fixture();
366
+ try {
367
+ await f.init();
368
+ const pending = f.block('next');
369
+ f.persistence.update({ siteId: 'site', userId: 'next' });
370
+ f.model.forwardRoute(route('/late'));
371
+ f.model.__dispose__();
372
+ f.ctx.disposed = true;
373
+ pending.resolve();
374
+ await tick();
375
+ assert.deepEqual(f.model.tabs, []);
376
+ assert.deepEqual(f.errors, []);
377
+ assert.equal(f.offHmr(), 1);
378
+ assert.equal(writes.length, 0);
379
+ } finally {
380
+ await f.dispose();
381
+ }
382
+ });
383
+
384
+ test('async close completion cannot navigate an old tab after an identity transition', async () => {
385
+ const f = fixture();
386
+ try {
387
+ f.store(null, [tab('/first'), tab('/second')]);
388
+ await f.init();
389
+ f.model.tabKeyCurrent = '/first';
390
+ const cleanup = deferred();
391
+ f.model.$router.notifyRouteDiscard = () => cleanup.promise;
392
+ const closing = f.model.deleteTab('/first');
393
+ f.store('next', [tab('/second')]);
394
+ f.persistence.update({ siteId: 'site', userId: 'next' });
395
+ cleanup.resolve();
396
+ await closing;
397
+ assert.deepEqual(f.navigations, []);
398
+ } finally {
399
+ await f.dispose();
400
+ }
401
+ });
402
+
403
+ test('switch-away/back drops obsolete work even when the destination identity tuple repeats', async () => {
404
+ const f = fixture();
405
+ try {
406
+ await f.init();
407
+ const old = f.block('same');
408
+ f.store('same', [tab('/same-saved')]);
409
+ f.persistence.update({ siteId: 'site', userId: 'same' });
410
+ f.model.forwardRoute(route('/must-not-replay'));
411
+ f.persistence.update({ siteId: 'site', userId: 'away' });
412
+ const latest = f.block('same');
413
+ f.persistence.update({ siteId: 'site', userId: 'same' });
414
+ f.model.forwardRoute(route('/latest'));
415
+ old.resolve();
416
+ await tick();
417
+ assert.deepEqual(f.model.tabs, []);
418
+ latest.resolve();
419
+ await tick();
420
+ assert.equal(f.model.findTab('/must-not-replay')[0], -1);
421
+ assert.notEqual(f.model.findTab('/same-saved')[0], -1);
422
+ assert.notEqual(f.model.findTab('/latest')[0], -1);
423
+ } finally {
424
+ await f.dispose();
425
+ }
426
+ });
427
+
428
+ test('initial scope supersession and full clear each initialize the current route once', async () => {
429
+ const f = fixture();
430
+ try {
431
+ const old = f.block(null);
432
+ const init = f.init();
433
+ await tick();
434
+ const originalForward = f.model.forwardRoute;
435
+ const forwarded: string[] = [];
436
+ f.model.forwardRoute = function (value: any) {
437
+ forwarded.push(value.fullPath);
438
+ return originalForward.call(this, value);
439
+ };
440
+ f.model.$currentRoute = route('/outgoing');
441
+ const next = f.block('next');
442
+ f.persistence.update({ siteId: 'site', userId: 'next' });
443
+ f.model.$currentRoute = route('/current');
444
+ next.resolve();
445
+ await tick();
446
+ old.resolve();
447
+ await init;
448
+ assert.deepEqual(forwarded, ['/current']);
449
+ assert.equal(f.model.findTab('/outgoing')[0], -1);
450
+ f.model.tabs = [tab('/discard-on-clear')];
451
+ await f.model.$persisterFlush();
452
+ const cleared = f.block('next');
453
+ records.delete(f.key('next'));
454
+ f.persistence.clear();
455
+ assert.deepEqual(f.model.tabs, []);
456
+ cleared.resolve();
457
+ await tick();
458
+ assert.deepEqual(forwarded, ['/current', '/current']);
459
+ assert.equal(f.model.findTab('/discard-on-clear')[0], -1);
460
+ assert.notEqual(f.model.findTab('/current')[0], -1);
461
+ } finally {
462
+ await f.dispose();
463
+ }
464
+ });
465
+
466
+ test('disposal during the first restore never installs defaults, routes or listeners', async () => {
467
+ const f = fixture();
468
+ try {
469
+ const pending = f.block(null);
470
+ const init = f.init();
471
+ await tick();
472
+ f.model.$currentRoute = route('/late');
473
+ f.model.forwardRoute(route('/late-ingress'));
474
+ f.model.__dispose__();
475
+ f.ctx.disposed = true;
476
+ pending.resolve();
477
+ await init;
478
+ assert.deepEqual(f.model.tabs, []);
479
+ assert.equal(f.defaults(), 0);
480
+ assert.equal(f.offHmr(), 0);
481
+ assert.deepEqual(f.errors, []);
482
+ } finally {
483
+ await f.dispose();
484
+ }
485
+ });
486
+
487
+ test('memory-only tabs dehydrate into serializable SSR state including query metadata', async () => {
488
+ // Use the serializer owned by a-ssr, just like the rendered state payload.
489
+ const requireSsr = createRequire(new URL('../../../a-ssr/package.json', import.meta.url));
490
+ const { uneval } = await import(pathToFileURL(requireSsr.resolve('devalue')).href);
491
+ const f = fixture(false);
492
+ try {
493
+ f.model.$currentRoute = route('/current');
494
+ await f.init();
495
+ const state = dehydrate(f.client);
496
+ const serialized = uneval({ query: state });
497
+ assert.equal(typeof serialized, 'string');
498
+ const tabs = state.queries.find(query => query.queryKey.at(-1) === 'tabs');
499
+ assert.ok(tabs);
500
+ assert.ok(Array.isArray(tabs.meta?.defaultData));
501
+ assert.ok((tabs.state.data as any[]).some(item => item.tabKey === '/current'));
502
+ } finally {
503
+ await f.dispose();
504
+ }
505
+ });
506
+
507
+ test('memory-only tabs preserve immediate initialization, writes and boolean addTab', async () => {
508
+ const f = fixture(false);
509
+ try {
510
+ f.model.$currentRoute = route('/current');
511
+ await f.init();
512
+ assert.notEqual(f.model.findTab('/current')[0], -1);
513
+ assert.equal(
514
+ f.model.addTab({ tabKey: '/sync', componentKey: '/sync', fullPath: '/sync' }),
515
+ true,
516
+ );
517
+ f.model.tabs = [tab('/assigned')];
518
+ assert.equal(f.model.findTab('/assigned')[0], 0);
519
+ assert.equal(reads.length, 0);
520
+ assert.equal(writes.length, 0);
521
+ } finally {
522
+ await f.dispose();
523
+ }
524
+ });
@@ -0,0 +1,35 @@
1
+ import '../../../a-model/test/lib/modelLoader.mjs';
2
+ import { registerHooks } from 'node:module';
3
+
4
+ // Reuse the model fixture's container-free imports, but retain the real deep
5
+ // option merge needed by $useStateLocalAsync (including its default factory).
6
+ registerHooks({
7
+ resolve(specifier, context, nextResolve) {
8
+ if (specifier === 'zova') return { url: 'tabs-test:zova', shortCircuit: true };
9
+ if (specifier === 'localforage') {
10
+ return nextResolve(specifier, {
11
+ ...context,
12
+ parentURL: new URL('../../../a-model/test/lib/modelPersistence.test.ts', import.meta.url)
13
+ .href,
14
+ });
15
+ }
16
+ return nextResolve(specifier, context);
17
+ },
18
+ load(url, context, nextLoad) {
19
+ if (url === 'tabs-test:zova') {
20
+ return {
21
+ format: 'module',
22
+ shortCircuit: true,
23
+ source: `
24
+ import { extend } from ${JSON.stringify(import.meta.resolve('@cabloy/extend'))};
25
+ export class BeanBase {}
26
+ export class BeanSimple {}
27
+ export const SymbolBeanFullName = Symbol.for('model-test-bean');
28
+ export const cast = value => value;
29
+ export const deepExtend = (...values) => extend(true, ...values);
30
+ `,
31
+ };
32
+ }
33
+ return nextLoad(url, context);
34
+ },
35
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova-suite-a-zova",
3
- "version": "5.1.166",
3
+ "version": "5.1.167",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "zova",
6
6
  "license": "MIT",
@@ -15,16 +15,16 @@
15
15
  "zova-module-a-behaviors": "^5.1.22",
16
16
  "zova-module-a-command": "^5.1.35",
17
17
  "zova-module-a-fetch": "^5.1.25",
18
- "zova-module-a-form": "^5.1.51",
18
+ "zova-module-a-form": "^5.1.52",
19
19
  "zova-module-a-icon": "^5.1.27",
20
20
  "zova-module-a-interceptor": "^5.1.31",
21
21
  "zova-module-a-logger": "^5.1.28",
22
22
  "zova-module-a-meta": "^5.1.22",
23
- "zova-module-a-model": "^5.1.37",
24
- "zova-module-a-openapi": "^5.1.55",
25
- "zova-module-a-router": "^5.1.41",
23
+ "zova-module-a-model": "^5.1.38",
24
+ "zova-module-a-openapi": "^5.1.56",
25
+ "zova-module-a-router": "^5.1.42",
26
26
  "zova-module-a-routerstack": "^5.1.29",
27
- "zova-module-a-routertabs": "^5.1.34",
27
+ "zova-module-a-routertabs": "^5.1.35",
28
28
  "zova-module-a-ssr": "^5.1.40",
29
29
  "zova-module-a-ssrhmr": "^5.1.23",
30
30
  "zova-module-a-ssrserver": "^5.1.26",