|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// Approval-status vocabulary pin (#7232 — problem 4 of #7213: "three |
| 4 | +// vocabularies for one request"). |
| 5 | +// |
| 6 | +// `sys_approval_request` is rendered by three unrelated surfaces — the generic |
| 7 | +// object views driven by this plugin's bundles, the Approvals Inbox (objectui |
| 8 | +// `approvalsInbox.*`), and the account-app navigation owned by |
| 9 | +// `@objectstack/platform-objects`. They share no keys, so nothing made them |
| 10 | +// agree, and they did not: zh said 待处理 for the status and 我的待办 for the |
| 11 | +// `my_pending` view while the other two said 待审批 / 待我审批, and the en bundle |
| 12 | +// shipped the raw enum values (`pending`, `approved`, …) as labels. |
| 13 | +// |
| 14 | +// What this file pins is the AGREEMENT, not the file contents. Two properties |
| 15 | +// it would be easy to assert and worthless to: |
| 16 | +// |
| 17 | +// • Re-reading `zhCNObjects.…options.pending` and expecting 待审批 restates |
| 18 | +// the line it is guarding. Every assertion here goes through the real |
| 19 | +// resolver (`translateObject` / `resolveViewLabel`) against the real |
| 20 | +// `SysApprovalRequest`, so it also proves the bundle is REACHED — the |
| 21 | +// declared option label is the bare enum value (see the guard-the-guard |
| 22 | +// case), so a bundle that stopped being consulted would surface here as |
| 23 | +// raw values rather than as a silent pass. |
| 24 | +// |
| 25 | +// • Pinning each locale in isolation lets the layers drift apart again, which |
| 26 | +// is the whole defect. The parity case compares this plugin's `my_pending` |
| 27 | +// view label against the account-app nav label in platform-objects, so |
| 28 | +// moving either side alone goes red. |
| 29 | +// |
| 30 | +// en is deliberately NOT in the parity set: its nav entry reads "Approvals" |
| 31 | +// (the destination) while the view reads "My Pending" (the filter), and #7232's |
| 32 | +// glossary keeps that split. Only the locales whose two layers say the same |
| 33 | +// thing are pinned to keep saying it. |
| 34 | + |
| 35 | +import { describe, it, expect } from 'vitest'; |
| 36 | +import { APPROVAL_STATUSES } from '@objectstack/spec/contracts'; |
| 37 | +import { translateObject, resolveViewLabel } from '@objectstack/spec/system'; |
| 38 | +import type { TranslationData } from '@objectstack/spec/system'; |
| 39 | +import { zhCN, jaJP, esES } from '@objectstack/platform-objects/apps'; |
| 40 | + |
| 41 | +import { ApprovalsTranslations } from './index.js'; |
| 42 | +import { SysApprovalRequest } from '../sys-approval-request.object.js'; |
| 43 | + |
| 44 | +/** Status option labels as a consumer sees them after i18n resolution. */ |
| 45 | +const resolvedStatusLabels = (locale: string): Record<string, string> => { |
| 46 | + const doc = translateObject(SysApprovalRequest as any, ApprovalsTranslations, { locale }); |
| 47 | + const options = (doc as any)?.fields?.status?.options as |
| 48 | + | Array<{ value: string; label?: string }> |
| 49 | + | undefined; |
| 50 | + return Object.fromEntries((options ?? []).map((o) => [o.value, o.label ?? ''])); |
| 51 | +}; |
| 52 | + |
| 53 | +const resolvedMyPendingLabel = (locale: string): string => |
| 54 | + resolveViewLabel(ApprovalsTranslations, (SysApprovalRequest as any).listViews.my_pending, { |
| 55 | + locale, |
| 56 | + }); |
| 57 | + |
| 58 | +const navApprovalsLabel = (data: TranslationData): string | undefined => |
| 59 | + (data as any)?.apps?.account?.navigation?.nav_account_approvals?.label; |
| 60 | + |
| 61 | +describe('approval status vocabulary (#7232)', () => { |
| 62 | + it('guard the guard: the DECLARED option label is the bare enum value', () => { |
| 63 | + // `Field.select([...APPROVAL_STATUSES])` normalizes each bare string to |
| 64 | + // `{ label: 'pending', value: 'pending' }` — the label IS the value. Every |
| 65 | + // humanized label below therefore comes from the bundle and nowhere else; |
| 66 | + // without this case a resolver that silently stopped consulting the bundle |
| 67 | + // could still satisfy an en expectation of "pending". |
| 68 | + const declared = (SysApprovalRequest as any).fields.status.options as Array<{ |
| 69 | + value: string; |
| 70 | + label?: string; |
| 71 | + }>; |
| 72 | + expect(declared.length).toBe(APPROVAL_STATUSES.length); |
| 73 | + expect(declared.map((o) => o.label)).toEqual(declared.map((o) => o.value)); |
| 74 | + }); |
| 75 | + |
| 76 | + it('en humanizes every status instead of shipping the raw enum value', () => { |
| 77 | + expect(resolvedStatusLabels('en')).toEqual({ |
| 78 | + pending: 'Pending', |
| 79 | + approved: 'Approved', |
| 80 | + rejected: 'Rejected', |
| 81 | + recalled: 'Recalled', |
| 82 | + returned: 'Returned', |
| 83 | + }); |
| 84 | + }); |
| 85 | + |
| 86 | + it('zh-CN says 待审批 for pending — the Approvals Inbox wording', () => { |
| 87 | + expect(resolvedStatusLabels('zh-CN')).toEqual({ |
| 88 | + pending: '待审批', |
| 89 | + approved: '已批准', |
| 90 | + rejected: '已拒绝', |
| 91 | + recalled: '已撤回', |
| 92 | + returned: '已退回修改', |
| 93 | + }); |
| 94 | + }); |
| 95 | + |
| 96 | + it('no locale leaks a raw enum value as a status label', () => { |
| 97 | + // Ratchet for statuses and locales added later: a new entry that reaches a |
| 98 | + // bundle un-translated is seeded from the source text, which is the enum |
| 99 | + // value itself, so this case catches it without naming it. |
| 100 | + for (const locale of ['en', 'zh-CN', 'ja-JP', 'es-ES']) { |
| 101 | + const labels = resolvedStatusLabels(locale); |
| 102 | + expect(Object.keys(labels).sort()).toEqual([...APPROVAL_STATUSES].sort()); |
| 103 | + const raw = Object.entries(labels) |
| 104 | + .filter(([value, label]) => value === label) |
| 105 | + .map(([value]) => value); |
| 106 | + expect(raw, `${locale} renders these statuses as their raw enum value`).toEqual([]); |
| 107 | + } |
| 108 | + }); |
| 109 | + |
| 110 | + it('the my_pending view carries the per-locale glossary wording', () => { |
| 111 | + expect(resolvedMyPendingLabel('en')).toBe('My Pending'); |
| 112 | + expect(resolvedMyPendingLabel('zh-CN')).toBe('待我审批'); |
| 113 | + expect(resolvedMyPendingLabel('ja-JP')).toBe('承認待ち'); |
| 114 | + expect(resolvedMyPendingLabel('es-ES')).toBe('Aprobaciones pendientes'); |
| 115 | + }); |
| 116 | + |
| 117 | + it('the my_pending view label matches the account-app nav label (problem 4)', () => { |
| 118 | + // The cross-layer half: the object view and the navigation entry are owned |
| 119 | + // by different packages and reached by different code paths. This is the |
| 120 | + // assertion that goes red when one of them is reworded alone. |
| 121 | + for (const [locale, nav] of [ |
| 122 | + ['zh-CN', zhCN], |
| 123 | + ['ja-JP', jaJP], |
| 124 | + ['es-ES', esES], |
| 125 | + ] as const) { |
| 126 | + const navLabel = navApprovalsLabel(nav); |
| 127 | + expect(navLabel, `platform-objects lost the ${locale} nav_account_approvals label`).toBeTruthy(); |
| 128 | + expect(resolvedMyPendingLabel(locale), `${locale} view/nav wording diverged`).toBe(navLabel); |
| 129 | + } |
| 130 | + }); |
| 131 | +}); |
0 commit comments