Skip to content

Commit 179f6fe

Browse files
fix(app-shell): the translation preview draws the groups of the schema the designer validates against (objectui#11765) (#11767)
Fixes #11765 Clause-②: no The translation designer's preview now draws one card per group of the schema the designer validates a bundle against, in that schema's order, and divides coverage by that count. A spec-valid bundle of only `pages`, `picklists`, `datasets`, `flows` or `settingsCommon` no longer reads "This bundle is empty", and a spec-valid bundle can reach full coverage. A key the schema refuses (`validationMessages`, per-app `settings`) is neither drawn nor counted. ## What changed - `TranslationPreview`: the file-local `CATEGORIES` table is built from `CATEGORY_DEFS`, one row per group of the per-app `TranslationDataSchema`, in the schema's declaration order: `objects`, `picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`, `pages`, `flows`, `metadataForms`, `settingsCommon`. The `validationMessages` and `settings` rows are gone. The coverage denominator is still `CATEGORIES.length`, which is now the schema's group count. - `CATEGORY_DEFS` is typed with `satisfies Record` over `keyof (typeof TranslationDataSchema)['shape']` (a type-only import), so it is exhaustive in both directions at compile time. - The nested key-count row reads the singular for one key. `engine.translationPreview.keyCount` is split into `keyCountOne` and `keyCountOther`, the One/Other convention this table already uses (`engine.datasetPreview.measureOne`, `engine.screenPreview.hiddenOne`). - `metadata-admin/i18n.ts`, both `ENGINE_STRINGS_EN` and `ENGINE_STRINGS_ZH`: five heading rows added (Picklists / 选项列表, Datasets / 数据集, Pages / 页面, Flows / 流程, Settings UI / 设置界面); the `category.validationMessages` and `category.settings` rows removed; `keyCount` replaced by `keyCountOne` + `keyCountOther`. The removed rows had no other reader: `git grep` at HEAD finds neither key anywhere, against 4 files that still name the kept `category.objects` row. - The module docblock says which schema the categories follow, that a refused key is neither drawn nor counted, and how `messages` (flat), the nested groups and `settingsCommon` are read. - Tests beside them: the new pin file `TranslationPreview.categories-11765.test.tsx`; `adminPreviews.i18n-10862-s2.test.tsx` lists the eleven headings, reads `keyCountOne` and the `2/11 (18%)` coverage; `TranslationPreview.globalActions-11755.test.tsx` reads `keyCountOther`. - `.changeset/11765-translation-preview-categories.md`, `patch` on `@object-ui/app-shell`. - All six files are on the amended claim surface (comment 6032111854). No export, prop, type member or language-pack key is added: `CATEGORY_DEFS`, `CategoryDef` and both dictionaries are module-local, and the package entry re-exports neither `TranslationPreview` nor the dictionary module. Measured on the emitted declarations as well: `tsc --declaration --emitDeclarationOnly` over the package at HEAD emits `TranslationPreview.d.ts` with only the unchanged `TranslationPreview` signature, and none of the 501 emitted declaration files names `CATEGORY_DEFS`, `keyCountOne` or a new heading row. So `Clause-②: no` holds. ## Zone 2 hypotheses, measured (objectui `d50f724`, installed `@objectstack/spec` 17.7.0) 1. **Which schema the designer applies: the per-app one.** `clientValidation.ts`'s `LOADERS.translation` binds `TranslationItemSchema`. Its shape keys are the eleven `TranslationDataSchema` groups plus `locale`, `name`, `label` and the seven envelope keys. Only `PlatformTranslationDataSchema` declares `settings`, so `settings` goes. `TranslationDataSchema` refuses `validationMessages` and `settings` with `unrecognized_keys`, and so does the designer; the pin file asserts both refusals through `validateMetadataDraft('translation', …)` before it mounts them. 2. **Flat or nested.** The probe `TranslationDataSchema.shape[group].safeParse({ probe: 'x' })` succeeds for `messages` alone, the one record of strings. `picklists`, `datasets`, `pages` and `flows` are records of strict nodes and read nested, like `objects`. `settingsCommon` is one strict object, not a record; its only member today is `sourceLabels` (the five settings source-badge labels). Its card reads it through the same nested branch: the card counts and samples its members (`sourceLabels`, with its inner key count). No special branch, because it needs none to read right, it stays right if the spec adds a member beside `sourceLabels`, and the count means what it means on every other card. A `settingsCommon: { sourceLabels: {} }` counts 1 and reads `{0 keys}`, as an empty object node does on any nested card. 3. **Derive or pin: pinned, two ways.** The schema's shape can supply the keys and their order, but not a group's icon or its heading row in two languages. A list derived at runtime would draw a group the spec adds with an echoed key for a heading, or would need a renderer-side fallback, which is the tolerance AGENTS.md #0.1 rules out. So the rows stay hand-written, and drift is made loud instead. `satisfies Record` fails type-check when the spec adds a group without a row here, or when a row names a key the spec does not declare (ablations A and C below show both). The pin file compares the drawn headings, their order and the denominator with `Object.keys(TranslationDataSchema.shape)` at runtime. 4. **Rows in both dictionaries.** `pnpm check:i18n-designer-parity`, the parity gate over these two tables, is green. The zh case of `adminPreviews.i18n-10862-s2.test.tsx` reads each new heading as a real zh row (not the echoed key, not the en row). 5. **The plural.** Folded in, as the card allows. With the five new nested groups, a node of one key (`home: { label }`) is the common case. ## Measured before the change The committed pin file was run against BASE's `TranslationPreview.tsx` and `i18n.ts`. Both were checked out from `d50f724` into the worktree under a trap, then restored from HEAD. The restore was proven by blob == HEAD for both files and an empty `git diff HEAD`. Result: `Tests 15 failed | 2 passed (17)`. - A bundle of only `picklists`, `datasets`, `pages`, `flows` or `settingsCommon` rendered the empty-bundle message, where the pin expects none. Each bundle is spec-valid, and `validateMetadataDraft` accepts each one. That is the card's reach, reproduced for all five groups. - A bundle of only `messages` or `globalActions` read `1/8`, not `1/11`. The full bundle never reached `11/11 (100%)`. The bundle with the refused groups drew 8 headings, not 11. - The one-key nodes of `objects`, `apps`, `dashboards` and `metadataForms` read `{1 keys}`. - Green: the fixture-coverage test and the `messages` control. ## Pins: `TranslationPreview.categories-11765.test.tsx` (17 tests) Every spec-valid fixture is parsed by the installed `TranslationDataSchema` and passed through the designer's own `validateMetadataDraft('translation', …)` (asserted `{ ok: true, issues: [] }`) before it is mounted. Expected groups, order and denominator are read from `TranslationDataSchema`; headings and count rows are read back through `t` and `tFormat`. 1. Every group the spec declares has a fixture in the file. 2. One card per schema group, in schema order; the denominator is the schema's group count. 3. to 13. One test per group, the five new ones included: a bundle of only that group reads non-empty, shows the group's card with its sample row, and reads `1/11`. 14. A bundle carrying every group reaches `11/11 (100%)`, and no card reads empty. 15. Refused groups: `validationMessages` and `settings` are refused by the spec and by the designer, are not drawn, and are not counted. 16. A nested node of one key reads `keyCountOne`, of two keys `keyCountOther`. 17. Control: the `messages` card renders `"Bienvenue"` as before. ## Ablations All on the committed fix `7da7729`, through objectstack's `scripts/ablation-replace.mjs` (WRAP mode: it mutates, proves the anchor count and the blob moved, runs the command, restores, and proves blob == HEAD and an empty `git diff HEAD`). The test imports `./TranslationPreview` relatively, so it is source-resolved and needs no build. Each count below was predicted before its run, and each matched. - **A: `validationMessages` back in the table**, inserted ahead of the `dashboards` row. The anchor went x1 to x0 and the blob `06de276d84d6` to `81562e4424ae`. Result: `Tests 14 failed | 3 passed (17)`. Every pin that reads the denominator or the headings went red: `1/11 (9%)` was not found, `11/11 (100%)` was not found, and 12 headings were drawn for 11 groups. The fixture-coverage test, the plural pin and the control stayed green. On the same mutation, `tsc --noEmit` exited 2 with `TS2353: Object literal may only specify known properties, and 'validationMessages' does not exist in type 'Record...` over the eleven group keys. Restored: blob == HEAD and `git diff HEAD` empty. - **C: the `pages` row deleted.** The anchor went x1 to x0 and the blob `06de276d84d6` to `9da6d764abdf`. Result: `Tests 15 failed | 2 passed (17)`. As on BASE, the pages-only test rendered the empty-bundle message. `tsc --noEmit` exited 2 with `TS1360 ... does not satisfy the expected type` and `Property 'pages' is missing`. Together, A and C show that `satisfies` holds in both directions. Restored the same way. - **D: the singular row unread.** The head `n === 1 ? 'engine.translationPreview.keyCountOne' : ` was deleted, and the blob went `06de276d84d6` to `d146e8c54a23`. Result: `Tests 7 failed | 10 passed (17)`: the six groups whose fixture node has one key, plus the plural pin. Restored the same way. - The first attempt at A and D was refused by the tool as a no-op, because the anchor overlapped the replacement, so the counts could not move. The tool restored the file (blob == HEAD), and both ablations were re-run with non-overlapping anchors. The results above come from the re-run. ## Gates (run from the worktree root) All on HEAD `7da7729`, the only commit. Heavy runs went through objectstack's `os-verify-lock.sh`. Wall-clock figures are from a shared box. | command | exit | verdict line | |---|---|---| | `pnpm exec vitest run packages/app-shell/` | 0 | `Test Files 1056 passed, 1 skipped (1057)`, `Tests 10338 passed, 9 skipped (10347)` | | `pnpm exec vitest run --maxWorkers=2` on the pin, the objectui#11755 pin, `adminPreviews.i18n-10862-s2`, `emptyCollection-8526`, `clientValidation.optOuts`, `StudioDesignSurface.standardNavEntries-11201` | 0 | `Test Files 6 passed (6)`, `Tests 121 passed (121)` | | `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` | 0 | dependency closure built for the type-check | | `pnpm --filter @object-ui/app-shell type-check` | 0 | `tsc --noEmit && tsc -p tsconfig.test.json`; `--listFilesOnly` on the test project lists the new pin | | `pnpm exec eslint --format json` on the five touched `.ts` / `.tsx` files | 0 | 5 files, 0 errors, 0 warnings | | `pnpm check:i18n-designer-parity` | 0 | `Every en row has a zh row, and every shared row carries the same placeholders.` | | `pnpm check:i18n-keys` | 0 | `Every in-scope call-site key resolves against the en pack (3297 keys), …` | | `pnpm check:i18n-drift` | 0 | `No designer-table en value changed in this range.` (designer table: 7 keys added, 3 removed) | | `pnpm check:i18n-dead-keys` (a report) | 0 | no `engine.translationPreview.*` candidate; the designer leg is live (it lists other `engine.*` keys) | | `pnpm check:control-bytes` | 0 | `check-control-bytes: OK` | | `pnpm check:test-path-roots` | 0 | `check-test-path-roots: OK` | | `pnpm check:changeset-claims` | 0 | `No pending changeset names a file this change touches.` | | `pnpm check:pending-changeset-literals` | 0 | `No test source names a pending changeset.` | | `pnpm check:new-line-citations` | 0 | `0 new citation(s), enforcement report-only` | | `pnpm check:spec-symbols` | 0 | `none renders an instruction its anchor card can no longer receive.` | | `node scripts/check-changeset-presence.mjs` | 0 | `5 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)` | | `node scripts/check-changeset-no-major.mjs`, `check-changeset-overwrite.mjs`, `check-changeset-fixed.mjs` | 0 | each OK | Lint is narrowed to the touched files, and the narrowing rests on three facts. First, the population is eslint's own result set for the five files: the JSON output counts 5, and none is ignored. Second, `eslint.config.js` enables no type-aware linting: it has no `projectService` and no `parserOptions.project`, and a control grep on `rules` in the same file hits 13 times. Third, no rule under `eslint-rules/` reads another file from disk. So this diff cannot move an untouched file's verdict. The repo-wide `pnpm lint` is CI's. Not run locally: - `check:readme-exports`: no export changed, which the declaration emit above measures. - The `apps/console` project: no console file is touched. The full farm is CI's. ## Acceptance notes - `nodeLabel` stays `globalActions`-only. The four new record groups (`picklists`, `datasets`, `pages`, `flows`) declare `label` on their nodes but read their key count, as `objects`, `apps`, `dashboards` and `metadataForms` already do. This is objectui#11755's named gap, now over four more groups. Not filed. - `engine.datasourcePreview.keyCount`, a different preview's row in the same table, also reads `{1 keys}` for a single key. Polish; not filed. - The preview reads three keys that the designer's schema refuses, through `d.data ?? d`, `d.language` and `d.description`. Measured: `TranslationItemSchema` answers `unrecognized_keys` on `data`, `language` and `description`. The console preview gallery's `translation` sample (`apps/console/src/preview-samples.ts`) is written in that refused record form, and `preview-samples-spec-valid.test.ts` already records that row as a mapping mismatch to settle. Outside this claim's surface; not touched and not filed. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1bc17da commit 179f6fe

6 files changed

Lines changed: 261 additions & 40 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The translation designer's preview draws the groups of the schema it validates a bundle against (objectui#11765).
6+
7+
The designer judges a translation draft by the per-app `TranslationDataSchema` groups, but the preview drew a hand-kept list that had drifted from them. It left out `picklists`, `datasets`, `pages`, `flows` and `settingsCommon`, so a spec-valid bundle of only one of those groups read as empty. It also listed `validationMessages`, which the spec removed, and `settings`, which only the platform schema declares. Both counted toward the coverage denominator, so a spec-valid bundle could never reach full coverage.
8+
9+
The preview now draws one card per schema group, in the schema's order, and divides coverage by that count. A key the schema refuses is not drawn and not counted. A nested entry of a single key now reads `{1 key}` rather than `{1 keys}`.
10+
11+
Nothing is added to the package entry: no export, prop, type member or language-pack key. The new headings are rows of the designer's own string table.

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1626,19 +1626,24 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
16261626
'The installed packages could not be read, so the extensions of this picklist are unknown.',
16271627
'engine.translationPreview.empty':
16281628
'This bundle is empty — add at least one translated string to see the coverage report.',
1629+
// One heading per group of the per-app `TranslationDataSchema`, in its order (objectui#11765).
16291630
'engine.translationPreview.category.objects': 'Objects',
1631+
'engine.translationPreview.category.picklists': 'Picklists',
16301632
'engine.translationPreview.category.apps': 'Apps',
16311633
'engine.translationPreview.category.messages': 'Messages',
1632-
'engine.translationPreview.category.validationMessages': 'Validation Messages',
16331634
'engine.translationPreview.category.globalActions': 'Global Actions',
16341635
'engine.translationPreview.category.dashboards': 'Dashboards',
1635-
'engine.translationPreview.category.settings': 'Settings',
1636+
'engine.translationPreview.category.datasets': 'Datasets',
1637+
'engine.translationPreview.category.pages': 'Pages',
1638+
'engine.translationPreview.category.flows': 'Flows',
16361639
'engine.translationPreview.category.metadataForms': 'Metadata Forms',
1640+
'engine.translationPreview.category.settingsCommon': 'Settings UI',
16371641
'engine.translationPreview.coverage': 'Category coverage:',
16381642
'engine.translationPreview.totalKeys': '{count} total keys',
16391643
'engine.translationPreview.categoryEmpty': 'empty',
16401644
'engine.translationPreview.more': '+{count} more…',
1641-
'engine.translationPreview.keyCount': '{{count} keys}',
1645+
'engine.translationPreview.keyCountOne': '{{count} key}',
1646+
'engine.translationPreview.keyCountOther': '{{count} keys}',
16421647
// objectui#10862 (slice 2) — the two advisories `celAuthoring.lintCelPredicate`
16431648
// words itself, shown beneath the CEL editors: the wrong-layer root (the
16441649
// local instrument's sentence; a slot `@objectstack/lint` covers ships that
@@ -4645,18 +4650,22 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
46454650
'engine.picklistPreview.extensionsFailed': '无法读取已安装的软件包,因此不知道该选项列表有哪些扩展。',
46464651
'engine.translationPreview.empty': '此语言包为空 —— 至少添加一条译文,即可查看覆盖率报告。',
46474652
'engine.translationPreview.category.objects': '对象',
4653+
'engine.translationPreview.category.picklists': '选项列表',
46484654
'engine.translationPreview.category.apps': '应用',
46494655
'engine.translationPreview.category.messages': '消息',
4650-
'engine.translationPreview.category.validationMessages': '校验消息',
46514656
'engine.translationPreview.category.globalActions': '全局操作',
46524657
'engine.translationPreview.category.dashboards': '仪表盘',
4653-
'engine.translationPreview.category.settings': '设置',
4658+
'engine.translationPreview.category.datasets': '数据集',
4659+
'engine.translationPreview.category.pages': '页面',
4660+
'engine.translationPreview.category.flows': '流程',
46544661
'engine.translationPreview.category.metadataForms': '元数据表单',
4662+
'engine.translationPreview.category.settingsCommon': '设置界面',
46554663
'engine.translationPreview.coverage': '分类覆盖率:',
46564664
'engine.translationPreview.totalKeys': '共 {count} 个键',
46574665
'engine.translationPreview.categoryEmpty': '空',
46584666
'engine.translationPreview.more': '另有 {count} 项…',
4659-
'engine.translationPreview.keyCount': '{{count} 个键}',
4667+
'engine.translationPreview.keyCountOne': '{{count} 个键}',
4668+
'engine.translationPreview.keyCountOther': '{{count} 个键}',
46604669
'engine.celLint.notTheRow':
46614670
'`{identifier}` 在此处不是记录行:行条件只把记录绑定为 `{canonical}`,别无其他。CEL 作用域词汇仍接受 `{identifier}`,所以这里不会阻止保存,但运行时表达式会以 `Unknown variable: {identifier}` 报错,规则永远不会触发。请改为以 `{canonical}` 为根引用。',
46624671
'engine.celLint.notPushdownable':
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11765 — the translation preview's categories are the groups of the
5+
* schema the designer validates against.
6+
*
7+
* The designer judges a translation draft with `validateMetadataDraft`, which
8+
* binds `TranslationItemSchema`: the groups of the per-app
9+
* `TranslationDataSchema` plus the item's own keys. The preview used to draw a
10+
* hand-kept list that had drifted from those groups. It omitted `picklists`,
11+
* `datasets`, `pages`, `flows` and `settingsCommon`, so a spec-valid bundle of
12+
* only one of them read as empty. It listed `validationMessages` (removed by
13+
* the spec) and `settings` (a platform-only group the per-app schema refuses),
14+
* so both counted toward the coverage denominator and a spec-valid bundle could
15+
* never reach full coverage.
16+
*
17+
* Every spec-valid fixture here is parsed by the installed spec's
18+
* `TranslationDataSchema` and passed through the designer's own
19+
* `validateMetadataDraft('translation', …)` before it is mounted. The expected
20+
* groups, their order and their count are read from `TranslationDataSchema`
21+
* itself, so a spec group added or dropped turns these pins red instead of
22+
* drifting. Headings and count rows are read back through `t` / `tFormat`
23+
* rather than restated.
24+
*/
25+
26+
import * as React from 'react';
27+
import { describe, it, expect, afterEach } from 'vitest';
28+
import { render, cleanup, screen } from '@testing-library/react';
29+
import { TranslationDataSchema } from '@objectstack/spec/system';
30+
31+
import { validateMetadataDraft } from '../clientValidation';
32+
import { t, tFormat } from '../i18n';
33+
import { TranslationPreview } from './TranslationPreview';
34+
35+
afterEach(cleanup);
36+
37+
const LOCALE = 'en-US';
38+
39+
/** The groups the spec declares, in its own order. */
40+
const GROUPS = Object.keys(TranslationDataSchema.shape);
41+
42+
/** One spec-valid entry per group, the entry's key, and how its sample row reads. */
43+
const ONE_ENTRY: Record<string, { data: Record<string, unknown>; key: string; row: () => string }> = {
44+
objects: { data: { account: { label: 'Compte' } }, key: 'account', row: () => keys(1) },
45+
picklists: {
46+
data: { stage: { label: 'Étape', options: { open: 'Ouvert', won: 'Gagné' } } },
47+
key: 'stage',
48+
row: () => keys(2),
49+
},
50+
apps: { data: { crm: { label: 'CRM' } }, key: 'crm', row: () => keys(1) },
51+
messages: { data: { welcome: 'Bienvenue' }, key: 'welcome', row: () => '"Bienvenue"' },
52+
globalActions: { data: { portfolio_snapshot: { label: 'Instantané' } }, key: 'portfolio_snapshot', row: () => '"Instantané"' },
53+
dashboards: { data: { pipeline: { label: 'Pipeline' } }, key: 'pipeline', row: () => keys(1) },
54+
datasets: {
55+
data: { revenue: { label: 'Revenu', measures: { amount: { label: 'Montant' } } } },
56+
key: 'revenue',
57+
row: () => keys(2),
58+
},
59+
pages: { data: { home: { label: 'Accueil' } }, key: 'home', row: () => keys(1) },
60+
flows: { data: { onboarding: { label: 'Intégration' } }, key: 'onboarding', row: () => keys(1) },
61+
metadataForms: { data: { object: { label: 'Objet' } }, key: 'object', row: () => keys(1) },
62+
// `settingsCommon` is one strict object, not a record of author-named nodes:
63+
// its members (`sourceLabels`) are what the card counts and samples.
64+
settingsCommon: {
65+
data: { sourceLabels: { env: 'Environnement', tenant: 'Locataire' } },
66+
key: 'sourceLabels',
67+
row: () => keys(2),
68+
},
69+
};
70+
71+
/** The nested sample row for a node of `n` keys, in the row the preview picks for `n`. */
72+
function keys(n: number): string {
73+
return tFormat(n === 1 ? 'engine.translationPreview.keyCountOne' : 'engine.translationPreview.keyCountOther', LOCALE, {
74+
count: n,
75+
});
76+
}
77+
78+
function heading(group: string): string {
79+
return t(`engine.translationPreview.category.${group}`, LOCALE);
80+
}
81+
82+
/** A bundle the spec and the designer both accept, mounted in the real preview as the designer hands it a draft. */
83+
async function mountSpecValid(data: Record<string, unknown>) {
84+
const parsed = TranslationDataSchema.safeParse(data);
85+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
86+
const draft = { name: 'fr', locale: 'fr-FR', ...data };
87+
const gate = await validateMetadataDraft('translation', draft);
88+
expect(gate, 'the designer accepts the draft').toEqual({ ok: true, issues: [] });
89+
render(<TranslationPreview type="translation" name="fr" draft={draft} locale={LOCALE} />);
90+
}
91+
92+
/** The heading of every category card, in the order the preview draws them. */
93+
function cardHeadings(): string[] {
94+
const grid = document.querySelector('.grid');
95+
expect(grid, 'the category grid').toBeTruthy();
96+
return Array.from(grid!.children).map((card) => card.querySelector('span')?.textContent ?? '');
97+
}
98+
99+
/** The card headed by `group`'s row. */
100+
function card(group: string): HTMLElement {
101+
const el = screen.getByText(heading(group)).closest('.rounded.border') as HTMLElement | null;
102+
expect(el, `${group}: the category card`).toBeTruthy();
103+
return el!;
104+
}
105+
106+
/** The value text of the sample row for `key` in `group`'s card. */
107+
function sampleValue(group: string, key: string): string {
108+
const row = Array.from(card(group).querySelectorAll('li')).find((li) => li.querySelector('code')?.textContent === key);
109+
expect(row, `${group}: a sample row for ${key}`).toBeTruthy();
110+
return row!.querySelector('span')?.textContent ?? '';
111+
}
112+
113+
function coverage(populated: number): string {
114+
return `${populated}/${GROUPS.length} (${Math.round((populated / GROUPS.length) * 100)}%)`;
115+
}
116+
117+
describe('TranslationPreview draws the groups of the schema the designer validates against (objectui#11765)', () => {
118+
it('every group the spec declares has a spec-valid fixture here', () => {
119+
expect(Object.keys(ONE_ENTRY).sort()).toEqual([...GROUPS].sort());
120+
});
121+
122+
it('draws one card per schema group, in the schema order, and divides coverage by their count', async () => {
123+
await mountSpecValid({ messages: ONE_ENTRY.messages.data });
124+
expect(cardHeadings()).toEqual(GROUPS.map(heading));
125+
expect(screen.getByText(coverage(1))).toBeTruthy();
126+
});
127+
128+
for (const group of GROUPS) {
129+
it(`a spec-valid bundle of only ${group} reads non-empty and shows its card`, async () => {
130+
const entry = ONE_ENTRY[group];
131+
await mountSpecValid({ [group]: entry.data });
132+
expect(screen.queryByText(t('engine.translationPreview.empty', LOCALE))).toBeNull();
133+
expect(sampleValue(group, entry.key)).toBe(entry.row());
134+
expect(screen.getByText(coverage(1))).toBeTruthy();
135+
});
136+
}
137+
138+
it('a bundle carrying every group reaches full coverage', async () => {
139+
await mountSpecValid(Object.fromEntries(GROUPS.map((g) => [g, ONE_ENTRY[g].data])));
140+
expect(screen.getByText(coverage(GROUPS.length))).toBeTruthy();
141+
expect(screen.queryByText(t('engine.translationPreview.categoryEmpty', LOCALE))).toBeNull();
142+
});
143+
144+
it('a group the schema refuses is neither drawn as a category nor counted', async () => {
145+
const refused = { validationMessages: { required: 'Requis' }, settings: { theme: 'Thème' } };
146+
for (const [group, value] of Object.entries(refused)) {
147+
expect(TranslationDataSchema.safeParse({ [group]: value }).success, `${group} is refused by the spec`).toBe(false);
148+
const gate = await validateMetadataDraft('translation', { name: 'fr', locale: 'fr-FR', [group]: value });
149+
expect(gate.ok, `${group} is refused by the designer`).toBe(false);
150+
}
151+
render(
152+
<TranslationPreview
153+
type="translation"
154+
name="fr"
155+
draft={{ name: 'fr', locale: 'fr-FR', messages: ONE_ENTRY.messages.data, ...refused }}
156+
locale={LOCALE}
157+
/>,
158+
);
159+
expect(cardHeadings()).toEqual(GROUPS.map(heading));
160+
expect(screen.getByText(coverage(1))).toBeTruthy();
161+
expect(screen.queryByText('required')).toBeNull();
162+
expect(screen.queryByText('theme')).toBeNull();
163+
});
164+
165+
it('a nested node of one key reads the singular row, and of two keys the plural row', async () => {
166+
expect(keys(1), 'the singular row is not the plural row read with 1').not.toBe(keys(2).replace('2', '1'));
167+
await mountSpecValid({ pages: { home: { label: 'Accueil' }, about: { label: 'À propos', title: 'À propos de nous' } } });
168+
expect(sampleValue('pages', 'home')).toBe(keys(1));
169+
expect(sampleValue('pages', 'about')).toBe(keys(2));
170+
});
171+
172+
it('control: the messages card renders as before', async () => {
173+
await mountSpecValid({ messages: { welcome: 'Bienvenue', saved: 'Enregistré' } });
174+
expect(sampleValue('messages', 'welcome')).toBe('"Bienvenue"');
175+
expect(sampleValue('messages', 'saved')).toBe('"Enregistré"');
176+
});
177+
});

‎packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.globalActions-11755.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe('TranslationPreview draws a globalActions node by its label (objectui#1
6767
it('a node without a label renders its inner key count', () => {
6868
mountSpecValid({ globalActions: { archive: { confirmText: 'Archive?', successMessage: 'Archived' } } });
6969
expect(sampleValue(GLOBAL_ACTIONS, 'archive')).toBe(
70-
tFormat('engine.translationPreview.keyCount', LOCALE, { count: 2 }),
70+
tFormat('engine.translationPreview.keyCountOther', LOCALE, { count: 2 }),
7171
);
7272
expect(document.body.textContent).not.toContain('[object Object]');
7373
});
@@ -80,7 +80,7 @@ describe('TranslationPreview draws a globalActions node by its label (objectui#1
8080
expect(sampleValue('engine.translationPreview.category.messages', 'welcome')).toBe('"Bienvenue"');
8181
// An object node keeps its key count: only the globalActions category reads a node's label.
8282
expect(sampleValue('engine.translationPreview.category.objects', 'account')).toBe(
83-
tFormat('engine.translationPreview.keyCount', LOCALE, { count: 2 }),
83+
tFormat('engine.translationPreview.keyCountOther', LOCALE, { count: 2 }),
8484
);
8585
});
8686
});

0 commit comments

Comments
 (0)