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
@@ -5,6 +5,26 @@ import { expect, test } from '../scripts/managedTest.ts';
5
5
  const studentResourceUrl =
6
6
  /\/admin\/rest\/resource\/training-student(?:%3A|:|%253A)student(?:[/?#]|$)/;
7
7
 
8
+ interface IModelPersistenceIdentity {
9
+ appName: string;
10
+ siteId: string;
11
+ scope: readonly [string | null, string | null, string | null];
12
+ }
13
+
14
+ interface IStudentDraftSnapshot {
15
+ storageKey: string;
16
+ namespace: unknown[];
17
+ identity: unknown[];
18
+ queryKey: unknown[];
19
+ schemaSignature: string;
20
+ baselineSignature?: string;
21
+ mode: string;
22
+ id?: string;
23
+ values: Record<string, unknown>;
24
+ relations: Record<string, { id?: string | number; deleted?: boolean }[]>;
25
+ undefinedPaths: string[];
26
+ }
27
+
8
28
  interface IFieldGeometry {
9
29
  container: {
10
30
  display: string;
@@ -111,6 +131,312 @@ function installStudentCreateResponseCapture(page: Page) {
111
131
  };
112
132
  }
113
133
 
134
+ const studentDraftCreatePath = '/admin/rest/resource/training-student%3Astudent/create';
135
+
136
+ function studentDraftNameInput(page: Page) {
137
+ return page.getByRole('group', { name: 'Student Name *', exact: true }).getByRole('textbox');
138
+ }
139
+
140
+ function studentDraftBanner(page: Page) {
141
+ return page.getByTestId('resource-draft');
142
+ }
143
+
144
+ function collectDraftEffects(page: Page, includeNestedRecords = false) {
145
+ const mutationMethods = includeNestedRecords
146
+ ? ['POST', 'PUT', 'PATCH', 'DELETE']
147
+ : ['POST', 'PATCH'];
148
+ const resourcePaths = includeNestedRecords
149
+ ? /^\/api\/training\/(?:student|record)(?:\/|$)/
150
+ : /^\/api\/training\/student(?:\/|$)/;
151
+ const mutations: string[] = [];
152
+ const hydrationWarnings: string[] = [];
153
+ const consoleErrors: string[] = [];
154
+ const pageErrors = collectPageErrors(page);
155
+ const requestHandler = (request: Request) => {
156
+ if (
157
+ mutationMethods.includes(request.method()) &&
158
+ resourcePaths.test(new URL(request.url()).pathname) &&
159
+ !request.headers()['x-vona-openapi-schema']
160
+ ) {
161
+ mutations.push(`${request.method()} ${new URL(request.url()).pathname}`);
162
+ }
163
+ };
164
+ page.on('request', requestHandler);
165
+ page.on('console', message => {
166
+ if (message.type() === 'error') consoleErrors.push(message.text());
167
+ if (/hydration.*mismatch|mismatch.*hydration/i.test(message.text())) {
168
+ hydrationWarnings.push(message.text());
169
+ }
170
+ });
171
+ return { mutations, hydrationWarnings, consoleErrors, pageErrors };
172
+ }
173
+
174
+ async function readModelPersistenceIdentity(page: Page): Promise<IModelPersistenceIdentity> {
175
+ return page.evaluate(async (): Promise<IModelPersistenceIdentity> => {
176
+ // ZovaApplication attaches itself to Vue's app, whose mount root exposes __vue_app__.
177
+ const root = Array.from(document.querySelectorAll('*')).find(
178
+ element => (element as any).__vue_app__?.zova,
179
+ ) as any;
180
+ const app = root?.__vue_app__?.zova;
181
+ if (!app) throw new Error('Mounted Zova application not found');
182
+ const passport = await app.bean._getBean('home-passport.model.passport', true);
183
+ const scope: IModelPersistenceIdentity['scope'] = passport.$modelPersistence.capture().scope;
184
+ return {
185
+ appName: app.sys.env.APP_NAME ?? '',
186
+ siteId: app.sys.env.SITE_ID,
187
+ scope,
188
+ };
189
+ });
190
+ }
191
+
192
+ async function readStudentDrafts(page: Page, expectedIdentity?: IModelPersistenceIdentity) {
193
+ const current = expectedIdentity ?? (await readModelPersistenceIdentity(page));
194
+ const entries = await page.evaluate(async () => {
195
+ if (!(await indexedDB.databases()).some(database => database.name === 'localforage')) {
196
+ return [];
197
+ }
198
+ return await new Promise<{ key: IDBValidKey; value: unknown }[]>((resolve, reject) => {
199
+ const opening = indexedDB.open('localforage');
200
+ opening.onerror = () => reject(opening.error);
201
+ opening.onsuccess = () => {
202
+ const database = opening.result;
203
+ if (!database.objectStoreNames.contains('keyvaluepairs')) {
204
+ database.close();
205
+ resolve([]);
206
+ return;
207
+ }
208
+ const transaction = database.transaction('keyvaluepairs', 'readonly');
209
+ const entries: { key: IDBValidKey; value: unknown }[] = [];
210
+ const cursor = transaction.objectStore('keyvaluepairs').openCursor();
211
+ cursor.onsuccess = () => {
212
+ const entry = cursor.result;
213
+ if (!entry) return;
214
+ entries.push({ key: entry.key, value: entry.value });
215
+ entry.continue();
216
+ };
217
+ transaction.oncomplete = () => {
218
+ database.close();
219
+ resolve(entries);
220
+ };
221
+ transaction.onerror = () => {
222
+ database.close();
223
+ reject(transaction.error);
224
+ };
225
+ };
226
+ });
227
+ });
228
+ // Decode outside the app: its global JSON.parse revives ISO strings into Dates.
229
+ const drafts: IStudentDraftSnapshot[] = [];
230
+ for (const entry of entries) {
231
+ try {
232
+ if (typeof entry.key !== 'string') continue;
233
+ const namespace = JSON.parse(entry.key);
234
+ if (
235
+ !Array.isArray(namespace) ||
236
+ namespace.length !== 5 ||
237
+ namespace[0] !== 'zova-model-db' ||
238
+ namespace[1] !== 1 ||
239
+ namespace[2] !== current.appName ||
240
+ JSON.stringify(namespace[3]) !== JSON.stringify(current.scope) ||
241
+ typeof namespace[4] !== 'string'
242
+ ) {
243
+ continue;
244
+ }
245
+ const query = typeof entry.value === 'string' ? JSON.parse(entry.value) : entry.value;
246
+ if (query?.queryKey?.[0] !== 'basic-pageentry.model.draft') continue;
247
+ const identity = JSON.parse(query.queryKey[1]);
248
+ const snapshot = query.state?.data;
249
+ if (identity[0] === 'training-student:student' && snapshot?.projection?.values) {
250
+ drafts.push({
251
+ storageKey: entry.key,
252
+ namespace,
253
+ identity,
254
+ queryKey: query.queryKey,
255
+ schemaSignature: snapshot.schemaSignature,
256
+ baselineSignature: snapshot.baselineSignature,
257
+ mode: identity[1],
258
+ id: identity[2] ?? undefined,
259
+ values: snapshot.projection.values,
260
+ relations: snapshot.projection.relations,
261
+ undefinedPaths: snapshot.projection.undefinedPaths,
262
+ });
263
+ }
264
+ } catch {
265
+ // Other persisted models are not draft envelopes.
266
+ }
267
+ }
268
+ return drafts;
269
+ }
270
+
271
+ async function waitForStudentDraft(page: Page, name: string, id?: string | number) {
272
+ await expect
273
+ .poll(async () => {
274
+ return (await readStudentDrafts(page)).some(
275
+ draft =>
276
+ draft.mode === (id === undefined ? 'create' : 'edit') &&
277
+ draft.id === (id === undefined ? undefined : String(id)) &&
278
+ draft.values.name === name,
279
+ );
280
+ })
281
+ .toBe(true);
282
+ }
283
+
284
+ async function expectNoStudentDraft(page: Page, id?: string | number) {
285
+ await expect
286
+ .poll(async () => {
287
+ return (await readStudentDrafts(page)).filter(
288
+ draft =>
289
+ draft.mode === (id === undefined ? 'create' : 'edit') &&
290
+ draft.id === (id === undefined ? undefined : String(id)),
291
+ ).length;
292
+ })
293
+ .toBe(0);
294
+ }
295
+
296
+ async function reloadStudentDraft(page: Page) {
297
+ await page.reload({ waitUntil: 'load' });
298
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
299
+ await expect(studentDraftBanner(page)).toContainText('Unsaved draft available');
300
+ await expect(
301
+ studentDraftBanner(page).getByRole('button', { name: 'Recover draft', exact: true }),
302
+ ).toBeVisible();
303
+ }
304
+
305
+ async function recoverStudentDraft(page: Page) {
306
+ await studentDraftBanner(page)
307
+ .getByRole('button', { name: 'Recover draft', exact: true })
308
+ .click();
309
+ // Edit recovery rechecks the server baseline before replacing live values.
310
+ await expect(studentDraftBanner(page)).toHaveCount(0);
311
+ await expect(page.getByRole('button', { name: 'Discard draft', exact: true })).toHaveCount(0);
312
+ }
313
+
314
+ function studentDraftTrainingRecords(page: Page) {
315
+ return page.getByRole('group', { name: /^Student Training Records(?: \*)?$/ });
316
+ }
317
+
318
+ function studentDraftTrainingRow(page: Page, name: string) {
319
+ return studentDraftTrainingRecords(page)
320
+ .locator('tbody tr')
321
+ .filter({ has: page.getByRole('link', { name, exact: true }) });
322
+ }
323
+
324
+ function trainingTimeInput(dialog: Locator) {
325
+ return dialog
326
+ .getByRole('group', { name: 'Training Time', exact: true })
327
+ .locator('input[type="date"]');
328
+ }
329
+
330
+ async function browserLocalDateIso(page: Page, value: string) {
331
+ // Basic Date interprets a native date input as midnight in the browser's timezone.
332
+ return page.evaluate(date => new Date(`${date}T00:00:00`).toISOString(), value);
333
+ }
334
+
335
+ async function readStudentTrainingDraft(page: Page, id?: string | number) {
336
+ const draft = (await readStudentDrafts(page)).find(
337
+ draft =>
338
+ draft.mode === (id === undefined ? 'create' : 'edit') &&
339
+ draft.id === (id === undefined ? undefined : String(id)),
340
+ );
341
+ return {
342
+ rows: draft?.values.trainingRecords as Record<string, unknown>[] | undefined,
343
+ identities: draft?.relations[JSON.stringify(['trainingRecords'])],
344
+ trainingTimeCleared: draft?.undefinedPaths.includes(
345
+ JSON.stringify(['trainingRecords', 0, 'trainingTime']),
346
+ ),
347
+ };
348
+ }
349
+
350
+ async function openStudentDraftCreate(page: Page) {
351
+ await page.getByRole('link', { name: 'Student', exact: true }).click();
352
+ await expect(page).toHaveURL(studentResourceUrl);
353
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
354
+ await expect(page).toHaveURL(/training-student(?:%3A|:)student\/create/);
355
+ await expect(studentDraftNameInput(page)).toBeVisible();
356
+ }
357
+
358
+ async function openStudentDraftEdit(page: Page, name: string, id: string | number) {
359
+ await page.getByRole('link', { name: 'Student', exact: true }).click();
360
+ await expect(page).toHaveURL(studentResourceUrl);
361
+ await page.getByLabel('Student Name').fill(name);
362
+ const select = waitForStudentSelect(page);
363
+ await page.getByRole('button', { name: 'Search', exact: true }).click();
364
+ await select;
365
+ const row = page
366
+ .locator('tbody tr')
367
+ .filter({ has: page.getByRole('link', { name, exact: true }) });
368
+ await expect(row).toHaveCount(1);
369
+ await row.locator('button.btn-outline.btn-primary').click();
370
+ await expect(page).toHaveURL(
371
+ new RegExp(`/training-student(?:%3A|:)student/${id}/edit(?:[?#]|$)`),
372
+ );
373
+ await expect(studentDraftNameInput(page)).toHaveValue(name);
374
+ }
375
+
376
+ async function withDraftStudents(
377
+ page: Page,
378
+ count: number,
379
+ run: (
380
+ students: { id: string | number; name: string }[],
381
+ headers: Record<string, string>,
382
+ ) => Promise<void>,
383
+ ) {
384
+ const token = (await page.context().cookies()).find(cookie => cookie.name === 'token')?.value;
385
+ expect(token).toBeTruthy();
386
+ const headers = { Authorization: `Bearer ${token}` };
387
+ const students: { id: string | number; name: string }[] = [];
388
+ try {
389
+ for (let index = 0; index < count; index++) {
390
+ const name = `Draft E2E ${Date.now()} ${index}`;
391
+ const response = await page.request.post('/api/training/student', {
392
+ headers,
393
+ data: { name, mobile: '13812345678', level: 1 },
394
+ });
395
+ expect(response.ok()).toBeTruthy();
396
+ const id = (await response.json()).data;
397
+ expect(['string', 'number']).toContain(typeof id);
398
+ students.push({ id, name });
399
+ }
400
+ await run(students, headers);
401
+ } finally {
402
+ if (students.length) {
403
+ let cleanupToken = (await page.context().cookies()).find(
404
+ cookie => cookie.name === 'token',
405
+ )?.value;
406
+ if (!cleanupToken || page.url().includes('/admin/login')) {
407
+ await loginAsAdmin(page);
408
+ cleanupToken = (await page.context().cookies()).find(
409
+ cookie => cookie.name === 'token',
410
+ )?.value;
411
+ }
412
+ expect(cleanupToken).toBeTruthy();
413
+ let cleanupHeaders = { Authorization: `Bearer ${cleanupToken}` };
414
+ const failures: string[] = [];
415
+ for (const student of students.toReversed()) {
416
+ let response = await page.request.delete(
417
+ `/api/training/student/deleteForce/${student.id}`,
418
+ { headers: cleanupHeaders },
419
+ );
420
+ if (response.status() === 401) {
421
+ await loginAsAdmin(page);
422
+ cleanupToken = (await page.context().cookies()).find(
423
+ cookie => cookie.name === 'token',
424
+ )?.value;
425
+ expect(cleanupToken).toBeTruthy();
426
+ cleanupHeaders = { Authorization: `Bearer ${cleanupToken}` };
427
+ response = await page.request.delete(`/api/training/student/deleteForce/${student.id}`, {
428
+ headers: cleanupHeaders,
429
+ });
430
+ }
431
+ if (!response.ok() && response.status() !== 404) {
432
+ failures.push(`Draft fixture cleanup ${student.id}: ${response.status()}`);
433
+ }
434
+ }
435
+ expect(failures).toEqual([]);
436
+ }
437
+ }
438
+ }
439
+
114
440
  async function getFieldGeometry(locator: Locator): Promise<IFieldGeometry> {
115
441
  return locator.evaluate(element => {
116
442
  const field = element.closest('label')?.parentElement;
@@ -1148,6 +1474,1257 @@ test(
1148
1474
  },
1149
1475
  );
1150
1476
 
1477
+ test(
1478
+ 'ATP-BASIC-DRAFT-01: default-on create recovery restores ordinary and nested fields without submitting',
1479
+ { tag: ['@admin', '@flow', '@ssr'] },
1480
+ async ({ page }) => {
1481
+ test.setTimeout(120_000);
1482
+ const effects = collectDraftEffects(page);
1483
+ await loginAsAdmin(page);
1484
+ const name = `Create Draft E2E ${Date.now()}`;
1485
+ await page.goto(studentDraftCreatePath, { waitUntil: 'load' });
1486
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1487
+ await page.getByRole('tab', { name: 'Basic Information', exact: true }).click();
1488
+ await studentDraftNameInput(page).fill(name);
1489
+ await page
1490
+ .getByRole('group', { name: 'Mobile *', exact: true })
1491
+ .getByRole('textbox')
1492
+ .fill('13812345678');
1493
+ await page.locator('.ProseMirror').last().fill(`Nested draft ${name}`);
1494
+ await waitForStudentDraft(page, name);
1495
+ await expect
1496
+ .poll(async () => {
1497
+ const snapshot = (await readStudentDrafts(page)).find(draft => draft.mode === 'create');
1498
+ return (snapshot?.values.content as { descriptionMarkdown?: string } | undefined)
1499
+ ?.descriptionMarkdown;
1500
+ })
1501
+ .toContain(name);
1502
+ await page.getByRole('tab', { name: 'Student Training Records', exact: true }).click();
1503
+ const progress = page
1504
+ .getByRole('group', { name: 'Training Stage *', exact: true })
1505
+ .getByRole('button', { name: 'Progress Track', exact: true });
1506
+ await progress.click();
1507
+ await expect(progress).toHaveClass(/\bbtn-info\b/);
1508
+ await expect
1509
+ .poll(async () => {
1510
+ const snapshot = (await readStudentDrafts(page)).find(draft => draft.mode === 'create');
1511
+ return snapshot?.values.level;
1512
+ })
1513
+ .toBe(2);
1514
+
1515
+ const html = await (await page.request.get(studentDraftCreatePath)).text();
1516
+ expect(html).not.toContain('Unsaved draft available');
1517
+ expect(html).not.toContain(name);
1518
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1519
+ await expect(page.getByRole('button', { name: 'Discard draft', exact: true })).toHaveCount(0);
1520
+ await reloadStudentDraft(page);
1521
+ await page.getByRole('tab', { name: 'Basic Information', exact: true }).click();
1522
+ await expect(studentDraftNameInput(page)).not.toHaveValue(name);
1523
+ await recoverStudentDraft(page);
1524
+ await page.getByRole('tab', { name: 'Student Training Records', exact: true }).click();
1525
+ await expect(progress).toHaveClass(/\bbtn-info\b/);
1526
+ await page.getByRole('tab', { name: 'Basic Information', exact: true }).click();
1527
+ await expect(studentDraftNameInput(page)).toHaveValue(name);
1528
+ await expect(
1529
+ page.getByRole('group', { name: 'Mobile *', exact: true }).getByRole('textbox'),
1530
+ ).toHaveValue('13812345678');
1531
+ await expect(page.locator('.ProseMirror').last()).toContainText(name);
1532
+ await waitForStudentDraft(page, name);
1533
+ expect(effects.mutations).toEqual([]);
1534
+
1535
+ await reloadStudentDraft(page);
1536
+ await page.getByRole('tab', { name: 'Basic Information', exact: true }).click();
1537
+ await expect(studentDraftNameInput(page)).not.toHaveValue(name);
1538
+ await studentDraftBanner(page)
1539
+ .getByRole('button', { name: 'Discard draft', exact: true })
1540
+ .click();
1541
+ await expectNoStudentDraft(page);
1542
+ await expect(studentDraftNameInput(page)).not.toHaveValue(name);
1543
+ await page.reload({ waitUntil: 'load' });
1544
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1545
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1546
+ expect(effects.mutations).toEqual([]);
1547
+ expect(effects.hydrationWarnings).toEqual([]);
1548
+ expect(effects.pageErrors).toEqual([]);
1549
+ },
1550
+ );
1551
+
1552
+ test(
1553
+ 'ATP-BASIC-DRAFT-02: default-on edit recovery and candidate discard never PATCH the record',
1554
+ { tag: ['@admin', '@flow', '@ssr'] },
1555
+ async ({ page }) => {
1556
+ test.setTimeout(120_000);
1557
+ const effects = collectDraftEffects(page);
1558
+ await loginAsAdmin(page);
1559
+ await withDraftStudents(page, 1, async ([student], headers) => {
1560
+ const editedName = `${student.name} unsaved`;
1561
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${student.id}/edit`, {
1562
+ waitUntil: 'load',
1563
+ });
1564
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1565
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
1566
+ await studentDraftNameInput(page).fill(editedName);
1567
+ await waitForStudentDraft(page, editedName, student.id);
1568
+ await reloadStudentDraft(page);
1569
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
1570
+ await recoverStudentDraft(page);
1571
+ await expect(studentDraftNameInput(page)).toHaveValue(editedName);
1572
+ const response = await page.request.get(`/api/training/student/${student.id}`, { headers });
1573
+ expect(response.ok()).toBeTruthy();
1574
+ expect((await response.json()).name).toBe(student.name);
1575
+ expect(effects.mutations).toEqual([]);
1576
+
1577
+ await reloadStudentDraft(page);
1578
+ await studentDraftBanner(page)
1579
+ .getByRole('button', { name: 'Discard draft', exact: true })
1580
+ .click();
1581
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
1582
+ await expectNoStudentDraft(page, student.id);
1583
+ await page.reload({ waitUntil: 'load' });
1584
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
1585
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1586
+ expect(effects.mutations).toEqual([]);
1587
+ expect(effects.hydrationWarnings).toEqual([]);
1588
+ expect(effects.pageErrors).toEqual([]);
1589
+ });
1590
+ },
1591
+ );
1592
+
1593
+ test(
1594
+ 'ATP-BASIC-DRAFT-03: successful create and edit submission remove their exact draft identities',
1595
+ { tag: ['@admin', '@flow'] },
1596
+ async ({ page }) => {
1597
+ test.setTimeout(120_000);
1598
+ await loginAsAdmin(page);
1599
+ const capture = installStudentCreateResponseCapture(page);
1600
+ await capture.install();
1601
+ const token = (await page.context().cookies()).find(cookie => cookie.name === 'token')?.value;
1602
+ expect(token).toBeTruthy();
1603
+ const headers = { Authorization: `Bearer ${token}` };
1604
+ const name = `Submitted Draft E2E ${Date.now()}`;
1605
+ let studentId: string | number | undefined;
1606
+ try {
1607
+ await openStudentDraftCreate(page);
1608
+ await studentDraftNameInput(page).fill(name);
1609
+ await page
1610
+ .getByRole('group', { name: 'Mobile *', exact: true })
1611
+ .getByRole('textbox')
1612
+ .fill('13812345678');
1613
+ await page.getByRole('tab', { name: 'Student Training Records', exact: true }).click();
1614
+ await page.getByText('Foundation Track', { exact: true }).click();
1615
+ await waitForStudentDraft(page, name);
1616
+ const create = page.waitForResponse(
1617
+ response =>
1618
+ response.request().method() === 'POST' &&
1619
+ new URL(response.url()).pathname === '/api/training/student' &&
1620
+ !response.request().headers()['x-vona-openapi-schema'],
1621
+ );
1622
+ await page.getByRole('button', { name: 'Submit', exact: true }).click();
1623
+ expect((await create).ok()).toBeTruthy();
1624
+ studentId = capture.studentId;
1625
+ expect(['string', 'number']).toContain(typeof studentId);
1626
+ await expect(page).toHaveURL(studentResourceUrl);
1627
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1628
+ await expectNoStudentDraft(page);
1629
+ await openStudentDraftCreate(page);
1630
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1631
+ await expect(studentDraftNameInput(page)).not.toHaveValue(name);
1632
+
1633
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${studentId}/edit`, {
1634
+ waitUntil: 'load',
1635
+ });
1636
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1637
+ const updatedName = `${name} updated`;
1638
+ await studentDraftNameInput(page).fill(updatedName);
1639
+ await waitForStudentDraft(page, updatedName, studentId);
1640
+ const update = page.waitForResponse(
1641
+ response =>
1642
+ response.request().method() === 'PATCH' &&
1643
+ new URL(response.url()).pathname === `/api/training/student/${studentId}` &&
1644
+ !response.request().headers()['x-vona-openapi-schema'],
1645
+ );
1646
+ await page.getByRole('button', { name: 'Submit', exact: true }).click();
1647
+ expect((await update).ok()).toBeTruthy();
1648
+ await expect(page).toHaveURL(/training-student(?:%3A|:)student\/create/);
1649
+ await page.waitForLoadState('load');
1650
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1651
+ await expectNoStudentDraft(page, studentId);
1652
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${studentId}/edit`, {
1653
+ waitUntil: 'load',
1654
+ });
1655
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1656
+ await expect(studentDraftNameInput(page)).toHaveValue(updatedName);
1657
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1658
+ } finally {
1659
+ studentId ??= capture.studentId;
1660
+ try {
1661
+ if (studentId !== undefined) {
1662
+ const response = await page.request.delete(
1663
+ `/api/training/student/deleteForce/${studentId}`,
1664
+ { headers },
1665
+ );
1666
+ expect(response.ok() || response.status() === 404).toBeTruthy();
1667
+ }
1668
+ } finally {
1669
+ await capture.uninstall();
1670
+ }
1671
+ }
1672
+ },
1673
+ );
1674
+
1675
+ test(
1676
+ 'ATP-BASIC-DRAFT-04: ordinary Back and explicit task close delete drafts instead of recovering them',
1677
+ { tag: ['@admin', '@flow'] },
1678
+ async ({ page }) => {
1679
+ test.setTimeout(120_000);
1680
+ const effects = collectDraftEffects(page);
1681
+ await loginAsAdmin(page);
1682
+ await openStudentDraftCreate(page);
1683
+ const backName = `Back Draft E2E ${Date.now()}`;
1684
+ await studentDraftNameInput(page).fill(backName);
1685
+ await waitForStudentDraft(page, backName);
1686
+ await page.getByRole('button', { name: 'Back', exact: true }).click();
1687
+ await expect(page).toHaveURL(studentResourceUrl);
1688
+ await expectNoStudentDraft(page);
1689
+ await openStudentDraftCreate(page);
1690
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1691
+ await expect(studentDraftNameInput(page)).not.toHaveValue(backName);
1692
+
1693
+ const closeName = `Closed Draft E2E ${Date.now()}`;
1694
+ await studentDraftNameInput(page).fill(closeName);
1695
+ await waitForStudentDraft(page, closeName);
1696
+ const task = page.getByRole('tab', { name: closeName, exact: true });
1697
+ await expect(task).toBeVisible();
1698
+ await task.hover();
1699
+ await task.locator('.tab-close').click();
1700
+ await expect(task).toHaveCount(0);
1701
+ await expectNoStudentDraft(page);
1702
+ await openStudentDraftCreate(page);
1703
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1704
+ await expect(studentDraftNameInput(page)).not.toHaveValue(closeName);
1705
+ expect(effects.mutations).toEqual([]);
1706
+ expect(effects.pageErrors).toEqual([]);
1707
+ },
1708
+ );
1709
+
1710
+ test(
1711
+ 'ATP-BASIC-DRAFT-05: switching tasks retains drafts and workspace close removes inactive edit drafts',
1712
+ { tag: ['@admin', '@flow'] },
1713
+ async ({ page }) => {
1714
+ test.setTimeout(120_000);
1715
+ const effects = collectDraftEffects(page);
1716
+ await loginAsAdmin(page);
1717
+ await withDraftStudents(page, 2, async ([first, second]) => {
1718
+ await openStudentDraftEdit(page, first.name, first.id);
1719
+ const firstDraft = `${first.name} unsaved`;
1720
+ await studentDraftNameInput(page).fill(firstDraft);
1721
+ await waitForStudentDraft(page, firstDraft, first.id);
1722
+ await openStudentDraftEdit(page, second.name, second.id);
1723
+ const secondDraft = `${second.name} unsaved`;
1724
+ await studentDraftNameInput(page).fill(secondDraft);
1725
+ await waitForStudentDraft(page, secondDraft, second.id);
1726
+
1727
+ await page.getByRole('tab', { name: firstDraft, exact: true }).click();
1728
+ await expect(studentDraftNameInput(page)).toHaveValue(firstDraft);
1729
+ await waitForStudentDraft(page, firstDraft, first.id);
1730
+ await waitForStudentDraft(page, secondDraft, second.id);
1731
+ await page.getByRole('link', { name: 'Student', exact: true }).click();
1732
+ await expect(page).toHaveURL(studentResourceUrl);
1733
+ const workspace = page
1734
+ .locator('.tabs-lifted')
1735
+ .getByRole('tab', { name: 'Student', exact: true });
1736
+ await workspace.hover();
1737
+ await workspace.locator('.tab-close').click();
1738
+ await expect(workspace).toHaveCount(0);
1739
+ await expectNoStudentDraft(page, first.id);
1740
+ await expectNoStudentDraft(page, second.id);
1741
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${first.id}/edit`, {
1742
+ waitUntil: 'load',
1743
+ });
1744
+ await expect(studentDraftNameInput(page)).toHaveValue(first.name);
1745
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1746
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${second.id}/edit`, {
1747
+ waitUntil: 'load',
1748
+ });
1749
+ await expect(studentDraftNameInput(page)).toHaveValue(second.name);
1750
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1751
+ expect(effects.mutations).toEqual([]);
1752
+ expect(effects.pageErrors).toEqual([]);
1753
+ });
1754
+ },
1755
+ );
1756
+
1757
+ test(
1758
+ 'ATP-BASIC-DRAFT-06: edit recovery rejects a changed server baseline without overwriting it',
1759
+ { tag: ['@admin', '@flow'] },
1760
+ async ({ page }) => {
1761
+ test.setTimeout(120_000);
1762
+ const effects = collectDraftEffects(page);
1763
+ await loginAsAdmin(page);
1764
+ await withDraftStudents(page, 1, async ([student], headers) => {
1765
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${student.id}/edit`, {
1766
+ waitUntil: 'load',
1767
+ });
1768
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1769
+ const unsavedName = `${student.name} unsaved`;
1770
+ await studentDraftNameInput(page).fill(unsavedName);
1771
+ await waitForStudentDraft(page, unsavedName, student.id);
1772
+ const serverName = `${student.name} changed on server`;
1773
+ const change = await page.request.patch(`/api/training/student/${student.id}`, {
1774
+ headers,
1775
+ data: { name: serverName, mobile: '13812345678', level: 1 },
1776
+ });
1777
+ expect(change.ok()).toBeTruthy();
1778
+ await reloadStudentDraft(page);
1779
+ await expect(studentDraftNameInput(page)).toHaveValue(serverName);
1780
+ await studentDraftBanner(page)
1781
+ .getByRole('button', { name: 'Recover draft', exact: true })
1782
+ .click();
1783
+ await expect(studentDraftBanner(page)).toContainText(
1784
+ 'This draft cannot be recovered because the form or record has changed.',
1785
+ );
1786
+ await expect(studentDraftNameInput(page)).toHaveValue(serverName);
1787
+ await waitForStudentDraft(page, unsavedName, student.id);
1788
+ await studentDraftBanner(page)
1789
+ .getByRole('button', { name: 'Discard draft', exact: true })
1790
+ .click();
1791
+ await expectNoStudentDraft(page, student.id);
1792
+ const unchanged = await page.request.get(`/api/training/student/${student.id}`, { headers });
1793
+ expect(unchanged.ok()).toBeTruthy();
1794
+ expect((await unchanged.json()).name).toBe(serverName);
1795
+ expect(effects.mutations).toEqual([]);
1796
+ expect(effects.pageErrors).toEqual([]);
1797
+ });
1798
+ },
1799
+ );
1800
+
1801
+ test(
1802
+ 'ATP-BASIC-DRAFT-07: failed create validation retains recoverable incomplete input',
1803
+ { tag: ['@admin', '@flow'] },
1804
+ async ({ page }) => {
1805
+ test.setTimeout(120_000);
1806
+ const effects = collectDraftEffects(page);
1807
+ await loginAsAdmin(page);
1808
+ await openStudentDraftCreate(page);
1809
+ const name = `Invalid Draft E2E ${Date.now()}`;
1810
+ await studentDraftNameInput(page).fill(name);
1811
+ const mobile = page.getByRole('group', { name: 'Mobile *', exact: true });
1812
+ await mobile.getByRole('textbox').fill('123');
1813
+ await waitForStudentDraft(page, name);
1814
+ await page.getByRole('button', { name: 'Submit', exact: true }).click();
1815
+ await expect(mobile.locator('.text-error')).toBeVisible();
1816
+ await expect(page).toHaveURL(/training-student(?:%3A|:)student\/create/);
1817
+ await waitForStudentDraft(page, name);
1818
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1819
+ expect(effects.mutations).toEqual([]);
1820
+
1821
+ await reloadStudentDraft(page);
1822
+ await recoverStudentDraft(page);
1823
+ await expect(studentDraftNameInput(page)).toHaveValue(name);
1824
+ await expect(mobile.getByRole('textbox')).toHaveValue('123');
1825
+ await waitForStudentDraft(page, name);
1826
+ await reloadStudentDraft(page);
1827
+ await expect(studentDraftNameInput(page)).not.toHaveValue(name);
1828
+ await studentDraftBanner(page)
1829
+ .getByRole('button', { name: 'Discard draft', exact: true })
1830
+ .click();
1831
+ await expectNoStudentDraft(page);
1832
+ expect(effects.mutations).toEqual([]);
1833
+ expect(effects.pageErrors).toEqual([]);
1834
+ },
1835
+ );
1836
+
1837
+ test(
1838
+ 'ATP-BASIC-DRAFT-08: locale changes preserve recovered dirty input and current-schema persistence',
1839
+ { tag: ['@admin', '@flow'] },
1840
+ async ({ page }) => {
1841
+ test.setTimeout(120_000);
1842
+ const effects = collectDraftEffects(page);
1843
+ await loginAsAdmin(page);
1844
+ await openStudentDraftCreate(page);
1845
+ const name = `Locale Draft E2E ${Date.now()}`;
1846
+ await studentDraftNameInput(page).fill(name);
1847
+ await waitForStudentDraft(page, name);
1848
+ await reloadStudentDraft(page);
1849
+ await recoverStudentDraft(page);
1850
+ await expect(studentDraftNameInput(page)).toHaveValue(name);
1851
+
1852
+ const localeMenu = page
1853
+ .locator('details')
1854
+ .filter({ has: page.getByText('Chinese', { exact: true }) });
1855
+ await expect(localeMenu).toHaveCount(1);
1856
+ await localeMenu.locator('summary').click();
1857
+ await localeMenu.getByText('Chinese', { exact: true }).click();
1858
+ const chineseName = page
1859
+ .getByRole('group', { name: '学生姓名 *', exact: true })
1860
+ .getByRole('textbox');
1861
+ await expect(chineseName).toBeVisible();
1862
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1863
+ await expect(page.getByRole('button', { name: '丢弃草稿', exact: true })).toHaveCount(0);
1864
+ await waitForStudentDraft(page, name);
1865
+ await expect(chineseName).toHaveValue(name);
1866
+ const nextName = `${name} 中文`;
1867
+ await chineseName.fill(nextName);
1868
+ await waitForStudentDraft(page, nextName);
1869
+
1870
+ const chineseLocaleMenu = page
1871
+ .locator('details')
1872
+ .filter({ has: page.getByText('英语', { exact: true }) });
1873
+ await chineseLocaleMenu.locator('summary').click();
1874
+ await chineseLocaleMenu.getByText('英语', { exact: true }).click();
1875
+ await expect(studentDraftNameInput(page)).toHaveValue(nextName);
1876
+ await reloadStudentDraft(page);
1877
+ await recoverStudentDraft(page);
1878
+ await expect(studentDraftNameInput(page)).toHaveValue(nextName);
1879
+ await waitForStudentDraft(page, nextName);
1880
+ await reloadStudentDraft(page);
1881
+ await expect(studentDraftNameInput(page)).not.toHaveValue(nextName);
1882
+ await studentDraftBanner(page)
1883
+ .getByRole('button', { name: 'Discard draft', exact: true })
1884
+ .click();
1885
+ await expectNoStudentDraft(page);
1886
+ expect(effects.mutations).toEqual([]);
1887
+ expect(effects.hydrationWarnings).toEqual([]);
1888
+ expect(effects.pageErrors).toEqual([]);
1889
+ },
1890
+ );
1891
+
1892
+ test(
1893
+ 'ATP-BASIC-DRAFT-11: edit locale refresh preserves recovered names but resets translated level options to the server baseline',
1894
+ { tag: ['@admin', '@flow', '@ssr'] },
1895
+ async ({ page }) => {
1896
+ test.setTimeout(120_000);
1897
+ await loginAsAdmin(page);
1898
+ await withDraftStudents(page, 1, async ([student], headers) => {
1899
+ const effects = collectDraftEffects(page);
1900
+ const editedName = `${student.name} locale unsaved`;
1901
+ const selectBasic = async (locale = 'en') => {
1902
+ await page
1903
+ .getByRole('tab', {
1904
+ name: locale === 'en' ? 'Basic Information' : '基本信息',
1905
+ exact: true,
1906
+ })
1907
+ .click();
1908
+ };
1909
+ const selectTraining = async (locale = 'en') => {
1910
+ await page
1911
+ .getByRole('tab', {
1912
+ name: locale === 'en' ? 'Student Training Records' : '学生培训记录',
1913
+ exact: true,
1914
+ })
1915
+ .click();
1916
+ };
1917
+ const expectDraftValues = async (level: number) => {
1918
+ await expect
1919
+ .poll(async () => {
1920
+ const draft = (await readStudentDrafts(page)).find(
1921
+ draft => draft.mode === 'edit' && draft.id === String(student.id),
1922
+ );
1923
+ return { name: draft?.values.name, level: draft?.values.level };
1924
+ })
1925
+ .toEqual({ name: editedName, level });
1926
+ };
1927
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${student.id}/edit`, {
1928
+ waitUntil: 'load',
1929
+ });
1930
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1931
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
1932
+ const englishLevel = page.getByRole('group', { name: 'Training Stage *', exact: true });
1933
+ const foundation = englishLevel.getByRole('button', {
1934
+ name: 'Foundation Track',
1935
+ exact: true,
1936
+ });
1937
+ const progress = englishLevel.getByRole('button', { name: 'Progress Track', exact: true });
1938
+ await studentDraftNameInput(page).fill(editedName);
1939
+ await selectTraining();
1940
+ await expect(foundation).toHaveClass(/\bbtn-warning\b/);
1941
+ await progress.click();
1942
+ await expect(progress).toHaveClass(/\bbtn-info\b/);
1943
+ await expectDraftValues(2);
1944
+
1945
+ await reloadStudentDraft(page);
1946
+ await selectBasic();
1947
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
1948
+ await selectTraining();
1949
+ await expect(foundation).toHaveClass(/\bbtn-warning\b/);
1950
+ await expect(progress).toHaveClass(/\bbtn-outline\b/);
1951
+ await expectDraftValues(2);
1952
+ await recoverStudentDraft(page);
1953
+ await expect(progress).toHaveClass(/\bbtn-info\b/);
1954
+ await selectBasic();
1955
+ await expect(studentDraftNameInput(page)).toHaveValue(editedName);
1956
+
1957
+ const localeMenu = page
1958
+ .locator('details')
1959
+ .filter({ has: page.getByText('Chinese', { exact: true }) });
1960
+ await expect(localeMenu).toHaveCount(1);
1961
+ await localeMenu.locator('summary').click();
1962
+ await localeMenu.getByText('Chinese', { exact: true }).click();
1963
+ const chineseName = page
1964
+ .getByRole('group', { name: '学生姓名 *', exact: true })
1965
+ .getByRole('textbox');
1966
+ const chineseLevel = page.getByRole('group', { name: '培训阶段 *', exact: true });
1967
+ const chineseFoundation = chineseLevel.getByRole('button', { name: '基础班', exact: true });
1968
+ const chineseProgress = chineseLevel.getByRole('button', { name: '提升班', exact: true });
1969
+ await selectBasic('zh');
1970
+ await expect(chineseName).toHaveValue(editedName);
1971
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1972
+ await expect(page.getByRole('button', { name: '丢弃草稿', exact: true })).toHaveCount(0);
1973
+ // This custom renderer has localized options and an opaque slot, so it takes normal reset.
1974
+ await selectTraining('zh');
1975
+ await expect(chineseFoundation).toHaveClass(/\bbtn-warning\b/);
1976
+ await expect(chineseProgress).toHaveClass(/\bbtn-outline\b/);
1977
+ await expectDraftValues(1);
1978
+ expect(effects.mutations).toEqual([]);
1979
+
1980
+ // Make level dirty again to prove the reverse locale change also resets to server truth.
1981
+ await chineseProgress.click();
1982
+ await expect(chineseProgress).toHaveClass(/\bbtn-info\b/);
1983
+ await expectDraftValues(2);
1984
+ const chineseLocaleMenu = page
1985
+ .locator('details')
1986
+ .filter({ has: page.getByText('英语', { exact: true }) });
1987
+ await expect(chineseLocaleMenu).toHaveCount(1);
1988
+ await chineseLocaleMenu.locator('summary').click();
1989
+ await chineseLocaleMenu.getByText('英语', { exact: true }).click();
1990
+ await selectBasic();
1991
+ await expect(studentDraftNameInput(page)).toHaveValue(editedName);
1992
+ await expect(studentDraftBanner(page)).toHaveCount(0);
1993
+ await expect(page.getByRole('button', { name: 'Discard draft', exact: true })).toHaveCount(0);
1994
+ await selectTraining();
1995
+ await expect(foundation).toHaveClass(/\bbtn-warning\b/);
1996
+ await expect(progress).toHaveClass(/\bbtn-outline\b/);
1997
+ await expectDraftValues(1);
1998
+ expect(effects.mutations).toEqual([]);
1999
+ const unchanged = await page.request.get(`/api/training/student/${student.id}`, { headers });
2000
+ expect(unchanged.ok()).toBeTruthy();
2001
+ const persisted = await unchanged.json();
2002
+ expect(persisted.name).toBe(student.name);
2003
+ expect(persisted.level).toBe(1);
2004
+
2005
+ await reloadStudentDraft(page);
2006
+ await selectBasic();
2007
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2008
+ await expectDraftValues(1);
2009
+ await recoverStudentDraft(page);
2010
+ await expect(studentDraftNameInput(page)).toHaveValue(editedName);
2011
+ await selectTraining();
2012
+ await expect(foundation).toHaveClass(/\bbtn-warning\b/);
2013
+ await expect(progress).toHaveClass(/\bbtn-outline\b/);
2014
+ await expectDraftValues(1);
2015
+ await reloadStudentDraft(page);
2016
+ await selectBasic();
2017
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2018
+ await studentDraftBanner(page)
2019
+ .getByRole('button', { name: 'Discard draft', exact: true })
2020
+ .click();
2021
+ await expectNoStudentDraft(page, student.id);
2022
+ await selectBasic();
2023
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2024
+ expect(effects.mutations).toEqual([]);
2025
+ expect(effects.hydrationWarnings).toEqual([]);
2026
+ expect(effects.pageErrors).toEqual([]);
2027
+ });
2028
+ },
2029
+ );
2030
+
2031
+ test(
2032
+ 'ATP-BASIC-DRAFT-12: nested Training Time create drafts persist ISO and reopen the recovered date without submitting',
2033
+ { tag: ['@admin', '@flow', '@ssr'] },
2034
+ async ({ page }) => {
2035
+ test.setTimeout(120_000);
2036
+ await loginAsAdmin(page);
2037
+ const effects = collectDraftEffects(page, true);
2038
+ const capture = installStudentCreateResponseCapture(page);
2039
+ await capture.install();
2040
+ const token = (await page.context().cookies()).find(cookie => cookie.name === 'token')?.value;
2041
+ expect(token).toBeTruthy();
2042
+ const headers = { Authorization: `Bearer ${token}` };
2043
+ const name = `Training Time Draft E2E ${Date.now()}`;
2044
+ const recordName = `${name} record`;
2045
+ const date = '2026-03-10';
2046
+ const iso = await browserLocalDateIso(page, date);
2047
+ const expectNoBusinessRecords = async () => {
2048
+ for (const [resource, ownedName] of [
2049
+ ['student', name],
2050
+ ['record', recordName],
2051
+ ]) {
2052
+ const response = await page.request.get(`/api/training/${resource}`, {
2053
+ headers,
2054
+ params: { name: ownedName },
2055
+ });
2056
+ expect(response.ok()).toBeTruthy();
2057
+ expect((await response.json()).list).toEqual([]);
2058
+ }
2059
+ };
2060
+ try {
2061
+ await expectNoBusinessRecords();
2062
+ await page.goto(studentDraftCreatePath, { waitUntil: 'load' });
2063
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2064
+ await studentDraftNameInput(page).fill(name);
2065
+ await waitForStudentDraft(page, name);
2066
+ await page.getByRole('tab', { name: 'Student Training Records', exact: true }).click();
2067
+ const details = studentDraftTrainingRecords(page);
2068
+ await details.getByRole('button', { name: 'Add Detail', exact: true }).click();
2069
+ const dialog = page.getByRole('dialog');
2070
+ await expect(dialog).toHaveCount(1);
2071
+ await dialog
2072
+ .getByRole('group', { name: 'Training Record Name *', exact: true })
2073
+ .getByRole('textbox')
2074
+ .fill(recordName);
2075
+ await trainingTimeInput(dialog).fill(date);
2076
+ await expect(trainingTimeInput(dialog)).toHaveValue(date);
2077
+ // The child form is not part of the parent draft until its own OK boundary.
2078
+ expect((await readStudentTrainingDraft(page)).rows ?? []).toEqual([]);
2079
+ await dialog.getByRole('button', { name: 'OK', exact: true }).click();
2080
+ await expect(dialog).toHaveCount(0);
2081
+ await expect(studentDraftTrainingRow(page, recordName)).toHaveCount(1);
2082
+ const expectDateDraft = async () => {
2083
+ await expect
2084
+ .poll(async () => {
2085
+ const draft = await readStudentTrainingDraft(page);
2086
+ return {
2087
+ rows: draft.rows?.map(row => ({ name: row.name, trainingTime: row.trainingTime })),
2088
+ identities: draft.identities,
2089
+ };
2090
+ })
2091
+ // The create DTO omits row IDs, so this array has no relation identity metadata.
2092
+ .toEqual({ rows: [{ name: recordName, trainingTime: iso }], identities: undefined });
2093
+ };
2094
+ await expectDateDraft();
2095
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2096
+ await expect(page.getByRole('button', { name: 'Discard draft', exact: true })).toHaveCount(0);
2097
+ expect(effects.mutations).toEqual([]);
2098
+ const serverHtml = await page.request.get(studentDraftCreatePath);
2099
+ expect(serverHtml.ok()).toBeTruthy();
2100
+ const html = await serverHtml.text();
2101
+ expect(html).not.toContain(recordName);
2102
+ expect(html).not.toContain(iso);
2103
+
2104
+ await reloadStudentDraft(page);
2105
+ await page.getByRole('tab', { name: 'Student Training Records', exact: true }).click();
2106
+ await expect(studentDraftTrainingRow(page, recordName)).toHaveCount(0);
2107
+ await expectDateDraft();
2108
+ await recoverStudentDraft(page);
2109
+ const row = studentDraftTrainingRow(page, recordName);
2110
+ await expect(row).toHaveCount(1);
2111
+ await row.locator('button.btn-outline.btn-primary').click();
2112
+ await expect(dialog).toHaveCount(1);
2113
+ // Recovery intentionally supplies ISO strings; Basic Date accepts them without Date revival.
2114
+ await expect(trainingTimeInput(dialog)).toHaveValue(date);
2115
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
2116
+ await expect(dialog).toHaveCount(0);
2117
+ await expectDateDraft();
2118
+ await expectNoBusinessRecords();
2119
+
2120
+ await reloadStudentDraft(page);
2121
+ await page.getByRole('tab', { name: 'Student Training Records', exact: true }).click();
2122
+ await expect(row).toHaveCount(0);
2123
+ await studentDraftBanner(page)
2124
+ .getByRole('button', { name: 'Discard draft', exact: true })
2125
+ .click();
2126
+ await expectNoStudentDraft(page);
2127
+ await expect(row).toHaveCount(0);
2128
+ expect(effects.mutations).toEqual([]);
2129
+ expect(effects.hydrationWarnings).toEqual([]);
2130
+ expect(effects.pageErrors).toEqual([]);
2131
+ } finally {
2132
+ try {
2133
+ // Normally no business row exists; capture only this form's accidental create, if any.
2134
+ if (capture.studentId !== undefined) {
2135
+ const response = await page.request.delete(
2136
+ `/api/training/student/deleteForce/${capture.studentId}`,
2137
+ { headers },
2138
+ );
2139
+ expect(response.ok() || response.status() === 404).toBeTruthy();
2140
+ }
2141
+ } finally {
2142
+ await capture.uninstall();
2143
+ }
2144
+ }
2145
+ },
2146
+ );
2147
+
2148
+ test(
2149
+ 'ATP-BASIC-DRAFT-13: nested Training Time edit drafts recover changed and cleared dates then discard to the server baseline',
2150
+ { tag: ['@admin', '@flow', '@ssr'] },
2151
+ async ({ page }) => {
2152
+ test.setTimeout(120_000);
2153
+ await loginAsAdmin(page);
2154
+ await withDraftStudents(page, 1, async ([student], headers) => {
2155
+ const recordName = `${student.name} dated record`;
2156
+ const baselineDate = '2026-03-10';
2157
+ const changedDate = '2026-04-18';
2158
+ const baselineIso = await browserLocalDateIso(page, baselineDate);
2159
+ const changedIso = await browserLocalDateIso(page, changedDate);
2160
+ // API-only fixture setup; the tested UI never submits a business resource.
2161
+ const setup = await page.request.patch(`/api/training/student/${student.id}`, {
2162
+ headers,
2163
+ data: {
2164
+ name: student.name,
2165
+ mobile: '13812345678',
2166
+ level: 1,
2167
+ trainingRecords: [{ name: recordName, trainingTime: baselineIso }],
2168
+ },
2169
+ });
2170
+ expect(setup.ok()).toBeTruthy();
2171
+ const initial = await page.request.get(`/api/training/student/${student.id}`, { headers });
2172
+ expect(initial.ok()).toBeTruthy();
2173
+ const baseline = await initial.json();
2174
+ expect(baseline.trainingRecords).toHaveLength(1);
2175
+ const record = baseline.trainingRecords[0];
2176
+ expect(['string', 'number']).toContain(typeof record.id);
2177
+ expect(record.name).toBe(recordName);
2178
+ expect(new Date(record.trainingTime).toISOString()).toBe(baselineIso);
2179
+ const effects = collectDraftEffects(page, true);
2180
+ const expectServerBaseline = async () => {
2181
+ const response = await page.request.get(`/api/training/student/${student.id}`, { headers });
2182
+ expect(response.ok()).toBeTruthy();
2183
+ expect(await response.json()).toEqual(baseline);
2184
+ expect(effects.mutations).toEqual([]);
2185
+ };
2186
+ const expectDateDraft = async (trainingTime: string | undefined) => {
2187
+ await expect
2188
+ .poll(async () => {
2189
+ const draft = await readStudentTrainingDraft(page, student.id);
2190
+ return {
2191
+ rows: draft.rows?.map(row => ({ name: row.name, trainingTime: row.trainingTime })),
2192
+ identities: draft.identities,
2193
+ cleared: draft.trainingTimeCleared,
2194
+ };
2195
+ })
2196
+ .toEqual({
2197
+ rows: [{ name: recordName, trainingTime }],
2198
+ identities: [{ id: record.id }],
2199
+ cleared: trainingTime === undefined,
2200
+ });
2201
+ };
2202
+ const openRecord = async () => {
2203
+ await page.getByRole('tab', { name: 'Student Training Records', exact: true }).click();
2204
+ const row = studentDraftTrainingRow(page, recordName);
2205
+ await expect(row).toHaveCount(1);
2206
+ await row.locator('button.btn-outline.btn-primary').click();
2207
+ await expect(page.getByRole('dialog')).toHaveCount(1);
2208
+ };
2209
+ const dialog = page.getByRole('dialog');
2210
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${student.id}/edit`, {
2211
+ waitUntil: 'load',
2212
+ });
2213
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2214
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2215
+ await openRecord();
2216
+ await expect(trainingTimeInput(dialog)).toHaveValue(baselineDate);
2217
+ await trainingTimeInput(dialog).fill(changedDate);
2218
+ await expectNoStudentDraft(page, student.id);
2219
+ await dialog.getByRole('button', { name: 'OK', exact: true }).click();
2220
+ await expect(dialog).toHaveCount(0);
2221
+ await expectDateDraft(changedIso);
2222
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2223
+ await expect(page.getByRole('button', { name: 'Discard draft', exact: true })).toHaveCount(0);
2224
+
2225
+ await reloadStudentDraft(page);
2226
+ await openRecord();
2227
+ await expect(trainingTimeInput(dialog)).toHaveValue(baselineDate);
2228
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
2229
+ await expect(dialog).toHaveCount(0);
2230
+ await recoverStudentDraft(page);
2231
+ await openRecord();
2232
+ await expect(trainingTimeInput(dialog)).toHaveValue(changedDate);
2233
+ await expectServerBaseline();
2234
+ await trainingTimeInput(dialog).fill('');
2235
+ await expect(trainingTimeInput(dialog)).toHaveValue('');
2236
+ // Clearing the optional date is committed to the parent only on dialog OK.
2237
+ await expectDateDraft(changedIso);
2238
+ await dialog.getByRole('button', { name: 'OK', exact: true }).click();
2239
+ await expect(dialog).toHaveCount(0);
2240
+ await expectDateDraft(undefined);
2241
+
2242
+ await reloadStudentDraft(page);
2243
+ await openRecord();
2244
+ await expect(trainingTimeInput(dialog)).toHaveValue(baselineDate);
2245
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
2246
+ await expect(dialog).toHaveCount(0);
2247
+ await recoverStudentDraft(page);
2248
+ await openRecord();
2249
+ await expect(trainingTimeInput(dialog)).toHaveValue('');
2250
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
2251
+ await expect(dialog).toHaveCount(0);
2252
+ await expectDateDraft(undefined);
2253
+ await expectServerBaseline();
2254
+
2255
+ await reloadStudentDraft(page);
2256
+ await openRecord();
2257
+ await expect(trainingTimeInput(dialog)).toHaveValue(baselineDate);
2258
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
2259
+ await expect(dialog).toHaveCount(0);
2260
+ await studentDraftBanner(page)
2261
+ .getByRole('button', { name: 'Discard draft', exact: true })
2262
+ .click();
2263
+ await expectNoStudentDraft(page, student.id);
2264
+ await openRecord();
2265
+ await expect(trainingTimeInput(dialog)).toHaveValue(baselineDate);
2266
+ await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
2267
+ await expect(dialog).toHaveCount(0);
2268
+ await expectNoStudentDraft(page, student.id);
2269
+ await expectServerBaseline();
2270
+ expect(effects.hydrationWarnings).toEqual([]);
2271
+ expect(effects.pageErrors).toEqual([]);
2272
+ // withDraftStudents finally deletes this exact student and its nested record via include.
2273
+ });
2274
+ },
2275
+ );
2276
+
2277
+ test(
2278
+ 'ATP-BASIC-DRAFT-14: new create input replaces an unrecovered candidate without merging old values',
2279
+ { tag: ['@admin', '@flow', '@ssr'] },
2280
+ async ({ page }) => {
2281
+ test.setTimeout(120_000);
2282
+ await loginAsAdmin(page);
2283
+ const effects = collectDraftEffects(page, true);
2284
+ const oldName = `Create Takeover E2E ${Date.now()} old`;
2285
+ const nextName = `${oldName} replacement`;
2286
+ const mobile = page.getByRole('group', { name: 'Mobile *', exact: true }).getByRole('textbox');
2287
+ await page.goto(studentDraftCreatePath, { waitUntil: 'load' });
2288
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2289
+ await studentDraftNameInput(page).fill(oldName);
2290
+ await mobile.fill('13912345678');
2291
+ await expect
2292
+ .poll(async () => {
2293
+ const draft = (await readStudentDrafts(page)).find(draft => draft.mode === 'create');
2294
+ return { name: draft?.values.name, mobile: draft?.values.mobile };
2295
+ })
2296
+ .toEqual({ name: oldName, mobile: '13912345678' });
2297
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2298
+
2299
+ await reloadStudentDraft(page);
2300
+ await expect(studentDraftNameInput(page)).not.toHaveValue(oldName);
2301
+ await expect(mobile).toHaveValue('');
2302
+ // A parent onChanged takes over the complete current form, not the stored candidate.
2303
+ await studentDraftNameInput(page).fill(nextName);
2304
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2305
+ await expect(page.getByRole('button', { name: 'Discard draft', exact: true })).toHaveCount(0);
2306
+ await expect(mobile).toHaveValue('');
2307
+ await expect
2308
+ .poll(async () => {
2309
+ return (await readStudentDrafts(page))
2310
+ .filter(draft => draft.mode === 'create')
2311
+ .map(draft => ({ name: draft.values.name, mobile: draft.values.mobile ?? '' }));
2312
+ })
2313
+ .toEqual([{ name: nextName, mobile: '' }]);
2314
+
2315
+ await reloadStudentDraft(page);
2316
+ await expect(studentDraftNameInput(page)).not.toHaveValue(nextName);
2317
+ await expect(mobile).toHaveValue('');
2318
+ await recoverStudentDraft(page);
2319
+ await expect(studentDraftNameInput(page)).toHaveValue(nextName);
2320
+ await expect(mobile).toHaveValue('');
2321
+ await waitForStudentDraft(page, nextName);
2322
+ const token = (await page.context().cookies()).find(cookie => cookie.name === 'token')?.value;
2323
+ expect(token).toBeTruthy();
2324
+ for (const name of [oldName, nextName]) {
2325
+ const response = await page.request.get('/api/training/student', {
2326
+ headers: { Authorization: `Bearer ${token}` },
2327
+ params: { name },
2328
+ });
2329
+ expect(response.ok()).toBeTruthy();
2330
+ expect((await response.json()).list).toEqual([]);
2331
+ }
2332
+
2333
+ await reloadStudentDraft(page);
2334
+ await studentDraftBanner(page)
2335
+ .getByRole('button', { name: 'Discard draft', exact: true })
2336
+ .click();
2337
+ await expectNoStudentDraft(page);
2338
+ await expect(studentDraftNameInput(page)).not.toHaveValue(nextName);
2339
+ await expect(mobile).toHaveValue('');
2340
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2341
+ expect(effects.mutations).toEqual([]);
2342
+ expect(effects.hydrationWarnings).toEqual([]);
2343
+ expect(effects.pageErrors).toEqual([]);
2344
+ },
2345
+ );
2346
+
2347
+ test(
2348
+ 'ATP-BASIC-DRAFT-15: new edit input replaces an unrecovered candidate with the live server baseline',
2349
+ { tag: ['@admin', '@flow', '@ssr'] },
2350
+ async ({ page }) => {
2351
+ test.setTimeout(120_000);
2352
+ await loginAsAdmin(page);
2353
+ await withDraftStudents(page, 1, async ([student], headers) => {
2354
+ const initial = await page.request.get(`/api/training/student/${student.id}`, { headers });
2355
+ expect(initial.ok()).toBeTruthy();
2356
+ const baseline = await initial.json();
2357
+ const effects = collectDraftEffects(page, true);
2358
+ const oldName = `${student.name} old candidate`;
2359
+ const nextName = `${student.name} replacement`;
2360
+ const mobile = page
2361
+ .getByRole('group', { name: 'Mobile *', exact: true })
2362
+ .getByRole('textbox');
2363
+ const expectDraftValues = async (name: string, mobile: string) => {
2364
+ await expect
2365
+ .poll(async () => {
2366
+ return (await readStudentDrafts(page))
2367
+ .filter(draft => draft.mode === 'edit' && draft.id === String(student.id))
2368
+ .map(draft => ({ name: draft.values.name, mobile: draft.values.mobile }));
2369
+ })
2370
+ .toEqual([{ name, mobile }]);
2371
+ };
2372
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${student.id}/edit`, {
2373
+ waitUntil: 'load',
2374
+ });
2375
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2376
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2377
+ await studentDraftNameInput(page).fill(oldName);
2378
+ await mobile.fill('13912345678');
2379
+ await expectDraftValues(oldName, '13912345678');
2380
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2381
+
2382
+ await reloadStudentDraft(page);
2383
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2384
+ // The read contract masks mobile; takeover retains that live baseline, not the old draft.
2385
+ await expect(mobile).toHaveValue(baseline.mobile);
2386
+ await studentDraftNameInput(page).fill(nextName);
2387
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2388
+ await expect(page.getByRole('button', { name: 'Discard draft', exact: true })).toHaveCount(0);
2389
+ await expect(mobile).toHaveValue(baseline.mobile);
2390
+ await expectDraftValues(nextName, baseline.mobile);
2391
+
2392
+ await reloadStudentDraft(page);
2393
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2394
+ await expect(mobile).toHaveValue(baseline.mobile);
2395
+ await recoverStudentDraft(page);
2396
+ await expect(studentDraftNameInput(page)).toHaveValue(nextName);
2397
+ await expect(mobile).toHaveValue(baseline.mobile);
2398
+ await expectDraftValues(nextName, baseline.mobile);
2399
+ const unchanged = await page.request.get(`/api/training/student/${student.id}`, { headers });
2400
+ expect(unchanged.ok()).toBeTruthy();
2401
+ expect(await unchanged.json()).toEqual(baseline);
2402
+
2403
+ await reloadStudentDraft(page);
2404
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2405
+ await studentDraftBanner(page)
2406
+ .getByRole('button', { name: 'Discard draft', exact: true })
2407
+ .click();
2408
+ await expectNoStudentDraft(page, student.id);
2409
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2410
+ await expect(mobile).toHaveValue(baseline.mobile);
2411
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2412
+ expect(effects.mutations).toEqual([]);
2413
+ expect(effects.hydrationWarnings).toEqual([]);
2414
+ expect(effects.pageErrors).toEqual([]);
2415
+ });
2416
+ },
2417
+ );
2418
+
2419
+ for (const mode of ['edit', 'create'] as const) {
2420
+ test(
2421
+ `ATP-BASIC-DRAFT-${mode === 'edit' ? '16' : '17'}: inactive ${mode} task offers recovery after list reload`,
2422
+ { tag: ['@admin', '@flow', '@ssr'] },
2423
+ async ({ page }, testInfo) => {
2424
+ test.setTimeout(120_000);
2425
+ await loginAsAdmin(page);
2426
+ await withDraftStudents(page, mode === 'edit' ? 1 : 0, async ([student], headers) => {
2427
+ const effects = collectDraftEffects(page, true);
2428
+ const id = student?.id;
2429
+ const baseline = student
2430
+ ? await (await page.request.get(`/api/training/student/${id}`, { headers })).json()
2431
+ : undefined;
2432
+ if (student) await openStudentDraftEdit(page, student.name, student.id);
2433
+ else await openStudentDraftCreate(page);
2434
+ const entryUrl = page.url();
2435
+ const baselineName = await studentDraftNameInput(page).inputValue();
2436
+ const name = `Inactive ${mode} Draft ${Date.now()}`;
2437
+ await studentDraftNameInput(page).fill(name);
2438
+ await waitForStudentDraft(page, name, id);
2439
+ const stored = (await readStudentDrafts(page)).filter(draft => draft.mode === mode);
2440
+ const checkpoint = async (label: string) => {
2441
+ const drafts = (await readStudentDrafts(page)).filter(draft => draft.mode === mode);
2442
+ await testInfo.attach(label, {
2443
+ body: JSON.stringify(drafts, null, 2),
2444
+ contentType: 'application/json',
2445
+ });
2446
+ expect(drafts, label).toEqual(stored);
2447
+ };
2448
+ await checkpoint('before-switch');
2449
+ const workspace = page.locator('.tabs-lifted').getByRole('tab', {
2450
+ name: 'Student',
2451
+ exact: true,
2452
+ });
2453
+ await workspace.click();
2454
+ await expect(page).not.toHaveURL(entryUrl);
2455
+ await expect(page.getByRole('button', { name: 'Search', exact: true })).toBeVisible();
2456
+ await checkpoint('after-switch');
2457
+ await page.reload({ waitUntil: 'load' });
2458
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2459
+ // Restored task titles and dirty markers need not retain their live presentation.
2460
+ const task = page.locator('.tabs-border').getByRole('tab');
2461
+ await expect(task).toHaveCount(1);
2462
+ await checkpoint('after-list-reload');
2463
+ await task.click();
2464
+ await expect(page).toHaveURL(entryUrl);
2465
+ await expect(studentDraftNameInput(page)).toHaveValue(baselineName);
2466
+ await checkpoint('after-entry-activation');
2467
+ await expect(studentDraftBanner(page)).toContainText('Unsaved draft available');
2468
+ await expect(
2469
+ studentDraftBanner(page).getByRole('button', {
2470
+ name: 'Discard draft',
2471
+ exact: true,
2472
+ }),
2473
+ ).toBeVisible();
2474
+ await recoverStudentDraft(page);
2475
+ await expect(studentDraftNameInput(page)).toHaveValue(name);
2476
+ await workspace.click();
2477
+ await task.click();
2478
+ await expect(studentDraftNameInput(page)).toHaveValue(name);
2479
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2480
+ if (student) {
2481
+ const unchanged = await page.request.get(`/api/training/student/${id}`, { headers });
2482
+ expect(await unchanged.json()).toEqual(baseline);
2483
+ }
2484
+ expect(effects.mutations).toEqual([]);
2485
+ expect(effects.hydrationWarnings).toEqual([]);
2486
+ expect(effects.pageErrors).toEqual([]);
2487
+ });
2488
+ },
2489
+ );
2490
+ }
2491
+
2492
+ test(
2493
+ 'ATP-BASIC-DRAFT-09: logout removes persisted create and inactive edit drafts before relogin',
2494
+ { tag: ['@admin', '@flow'] },
2495
+ async ({ page }) => {
2496
+ test.setTimeout(120_000);
2497
+ const effects = collectDraftEffects(page);
2498
+ await loginAsAdmin(page);
2499
+ await withDraftStudents(page, 1, async ([student]) => {
2500
+ await openStudentDraftEdit(page, student.name, student.id);
2501
+ const editName = `${student.name} logout`;
2502
+ await studentDraftNameInput(page).fill(editName);
2503
+ await waitForStudentDraft(page, editName, student.id);
2504
+ await openStudentDraftCreate(page);
2505
+ const createName = `Logout Draft E2E ${Date.now()}`;
2506
+ await studentDraftNameInput(page).fill(createName);
2507
+ await waitForStudentDraft(page, createName);
2508
+ const authenticatedIdentity = await readModelPersistenceIdentity(page);
2509
+ expect(authenticatedIdentity.scope[2]).not.toBeNull();
2510
+ const userMenu = page
2511
+ .locator('details')
2512
+ .filter({ has: page.getByText('Logout', { exact: true }) });
2513
+ await expect(userMenu).toHaveCount(1);
2514
+ await userMenu.locator('summary').click();
2515
+ const outgoingProtectedRequests: string[] = [];
2516
+ const outgoingUnauthorizedResponses: string[] = [];
2517
+ let loggingOut = true;
2518
+ page.on('request', request => {
2519
+ const path = new URL(request.url()).pathname;
2520
+ if (
2521
+ loggingOut &&
2522
+ request.method() === 'GET' &&
2523
+ /^\/api\/(?:training\/student(?:\/|$)|home\/base\/permission\/)/.test(path)
2524
+ ) {
2525
+ outgoingProtectedRequests.push(`${request.method()} ${path}`);
2526
+ }
2527
+ });
2528
+ page.on('response', response => {
2529
+ const path = new URL(response.url()).pathname;
2530
+ if (loggingOut && path.startsWith('/api/') && response.status() === 401) {
2531
+ outgoingUnauthorizedResponses.push(`${response.status()} ${path}`);
2532
+ }
2533
+ });
2534
+ await userMenu.getByText('Logout', { exact: true }).click();
2535
+ await expect(page).toHaveURL(/\/admin\/login(?:\?|$)/);
2536
+ await expectNoStudentDraft(page);
2537
+ await expectNoStudentDraft(page, student.id);
2538
+ await expect
2539
+ .poll(async () => (await readStudentDrafts(page, authenticatedIdentity)).length)
2540
+ .toBe(0);
2541
+ const anonymousIdentity = await readModelPersistenceIdentity(page);
2542
+ expect(anonymousIdentity.scope).toEqual([null, authenticatedIdentity.siteId, null]);
2543
+ loggingOut = false;
2544
+ expect(outgoingProtectedRequests).toEqual([]);
2545
+ expect(outgoingUnauthorizedResponses).toEqual([]);
2546
+ expect(effects.consoleErrors).toEqual([]);
2547
+ await loginAsAdmin(page);
2548
+ expect(await readModelPersistenceIdentity(page)).toEqual(authenticatedIdentity);
2549
+ await page.goto(studentDraftCreatePath, { waitUntil: 'load' });
2550
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2551
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2552
+ await expect(studentDraftNameInput(page)).not.toHaveValue(createName);
2553
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${student.id}/edit`, {
2554
+ waitUntil: 'load',
2555
+ });
2556
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2557
+ await expect(studentDraftNameInput(page)).toHaveValue(student.name);
2558
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2559
+ expect(effects.mutations).toEqual([]);
2560
+ expect(effects.hydrationWarnings).toEqual([]);
2561
+ expect(effects.consoleErrors).toEqual([]);
2562
+ expect(effects.pageErrors).toEqual([]);
2563
+ });
2564
+ },
2565
+ );
2566
+
2567
+ test(
2568
+ 'ATP-BASIC-DRAFT-18: actual Passport namespaces survive another tab and global model clear prevents draft resurrection',
2569
+ { tag: ['@admin', '@flow', '@ssr'] },
2570
+ async ({ page }) => {
2571
+ test.setTimeout(120_000);
2572
+ const effects = collectDraftEffects(page);
2573
+ await loginAsAdmin(page);
2574
+ const identity = await readModelPersistenceIdentity(page);
2575
+ const token = (await page.context().cookies()).find(cookie => cookie.name === 'token')?.value;
2576
+ expect(token).toBeTruthy();
2577
+ const current = await page.request.get('/api/home/user/passport/current', {
2578
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
2579
+ });
2580
+ expect(current.ok()).toBeTruthy();
2581
+ const passport = (await current.json()).data;
2582
+ expect(Object.hasOwn(passport, 'instanceId')).toBe(true);
2583
+ expect(identity.scope).toEqual([
2584
+ passport.instanceId == null ? null : String(passport.instanceId),
2585
+ identity.siteId,
2586
+ String(passport.user.id),
2587
+ ]);
2588
+
2589
+ await page.goto(studentDraftCreatePath, { waitUntil: 'load' });
2590
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2591
+ const name = `Namespace Draft E2E ${Date.now()}`;
2592
+ await studentDraftNameInput(page).fill(name);
2593
+ await waitForStudentDraft(page, name);
2594
+ const draft = (await readStudentDrafts(page)).find(draft => draft.values.name === name)!;
2595
+ expect(draft.namespace).toEqual([
2596
+ 'zova-model-db',
2597
+ 1,
2598
+ identity.appName,
2599
+ identity.scope,
2600
+ expect.any(String),
2601
+ ]);
2602
+ expect(draft.identity).toEqual([
2603
+ 'training-student:student',
2604
+ 'create',
2605
+ null,
2606
+ expect.stringContaining('/training-student'),
2607
+ ]);
2608
+ expect(draft.queryKey).toEqual([
2609
+ 'basic-pageentry.model.draft',
2610
+ JSON.stringify(draft.identity),
2611
+ 'snapshot',
2612
+ ]);
2613
+
2614
+ const otherTab = await page.context().newPage();
2615
+ const otherEffects = collectDraftEffects(otherTab);
2616
+ try {
2617
+ await otherTab.goto(studentDraftCreatePath, { waitUntil: 'load' });
2618
+ await expect(otherTab.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2619
+ expect(await readModelPersistenceIdentity(otherTab)).toEqual(identity);
2620
+ await expect(studentDraftBanner(otherTab)).toContainText('Unsaved draft available');
2621
+ await expect(studentDraftNameInput(otherTab)).not.toHaveValue(name);
2622
+ await recoverStudentDraft(otherTab);
2623
+ await expect(studentDraftNameInput(otherTab)).toHaveValue(name);
2624
+ await waitForStudentDraft(otherTab, name);
2625
+ expect(
2626
+ (await readStudentDrafts(otherTab)).find(draft => draft.values.name === name)?.storageKey,
2627
+ ).toBe(draft.storageKey);
2628
+ } finally {
2629
+ // Cross-tab notification is not the contract; stop the second writer before clearing.
2630
+ await otherTab.close();
2631
+ }
2632
+ expect(otherEffects.mutations).toEqual([]);
2633
+ expect(otherEffects.hydrationWarnings).toEqual([]);
2634
+ expect(otherEffects.pageErrors).toEqual([]);
2635
+
2636
+ const draftKeyExistsAfterClear = await page.evaluate(async storageKey => {
2637
+ const root = Array.from(document.querySelectorAll('*')).find(
2638
+ element => (element as any).__vue_app__?.zova,
2639
+ ) as any;
2640
+ const app = root?.__vue_app__?.zova;
2641
+ if (!app) throw new Error('Mounted Zova application not found');
2642
+ const passport = await app.bean._getBean('home-passport.model.passport', true);
2643
+ await passport.$clear();
2644
+ return new Promise<boolean>((resolve, reject) => {
2645
+ const opening = indexedDB.open('localforage');
2646
+ opening.onerror = () => reject(opening.error);
2647
+ opening.onsuccess = () => {
2648
+ const database = opening.result;
2649
+ const transaction = database.transaction('keyvaluepairs', 'readonly');
2650
+ const request = transaction.objectStore('keyvaluepairs').getKey(storageKey);
2651
+ transaction.oncomplete = () => {
2652
+ database.close();
2653
+ resolve(request.result !== undefined);
2654
+ };
2655
+ transaction.onerror = () => {
2656
+ database.close();
2657
+ reject(transaction.error);
2658
+ };
2659
+ };
2660
+ });
2661
+ }, draft.storageKey);
2662
+ expect(draftKeyExistsAfterClear).toBe(false);
2663
+ await expect.poll(async () => (await readStudentDrafts(page, identity)).length).toBe(0);
2664
+ // Global clear resets query memory too; use real login if authentication was cleared.
2665
+ await loginAsAdmin(page);
2666
+ await page.goto(studentDraftCreatePath, { waitUntil: 'load' });
2667
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2668
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2669
+ await expect(studentDraftNameInput(page)).not.toHaveValue(name);
2670
+ await expectNoStudentDraft(page);
2671
+ expect(effects.mutations).toEqual([]);
2672
+ expect(effects.hydrationWarnings).toEqual([]);
2673
+ expect(effects.pageErrors).toEqual([]);
2674
+ },
2675
+ );
2676
+
2677
+ test(
2678
+ 'ATP-BASIC-DRAFT-10: deleted edit records retain reachable discard without recovering or submitting',
2679
+ { tag: ['@admin', '@flow'] },
2680
+ async ({ page }) => {
2681
+ test.setTimeout(120_000);
2682
+ const effects = collectDraftEffects(page);
2683
+ await loginAsAdmin(page);
2684
+ await withDraftStudents(page, 1, async ([student], headers) => {
2685
+ await page.goto(`/admin/rest/resource/training-student%3Astudent/${student.id}/edit`, {
2686
+ waitUntil: 'load',
2687
+ });
2688
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2689
+ const unsavedName = `${student.name} deleted record draft`;
2690
+ await studentDraftNameInput(page).fill(unsavedName);
2691
+ await waitForStudentDraft(page, unsavedName, student.id);
2692
+ const deletion = await page.request.delete(`/api/training/student/${student.id}`, {
2693
+ headers,
2694
+ });
2695
+ expect(deletion.ok()).toBeTruthy();
2696
+
2697
+ await page.reload({ waitUntil: 'load' });
2698
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2699
+ await expect(page.getByText('The entry does not exist', { exact: true })).toBeVisible();
2700
+ await expect(studentDraftNameInput(page)).toHaveCount(0);
2701
+ await expect(studentDraftBanner(page)).toContainText('Unsaved draft available');
2702
+ await expect(
2703
+ studentDraftBanner(page).getByRole('button', { name: 'Recover draft', exact: true }),
2704
+ ).toHaveCount(0);
2705
+ const discard = studentDraftBanner(page).getByRole('button', {
2706
+ name: 'Discard draft',
2707
+ exact: true,
2708
+ });
2709
+ await expect(discard).toBeVisible();
2710
+ await waitForStudentDraft(page, unsavedName, student.id);
2711
+ await discard.click();
2712
+ await expectNoStudentDraft(page, student.id);
2713
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2714
+
2715
+ await page.reload({ waitUntil: 'load' });
2716
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
2717
+ await expect(page.getByText('The entry does not exist', { exact: true })).toBeVisible();
2718
+ await expect(studentDraftNameInput(page)).toHaveCount(0);
2719
+ await expect(studentDraftBanner(page)).toHaveCount(0);
2720
+ await expectNoStudentDraft(page, student.id);
2721
+ expect(effects.mutations).toEqual([]);
2722
+ expect(effects.hydrationWarnings).toEqual([]);
2723
+ expect(effects.pageErrors).toEqual([]);
2724
+ });
2725
+ },
2726
+ );
2727
+
1151
2728
  test(
1152
2729
  'ATP-BASIC-FORM-01: Training Student content remains in the Basic Information tab',
1153
2730
  { tag: ['@admin', '@flow'] },