Skip to content

Commit c27404f

Browse files
fix(spec,i18n): an object's embedded listViews are served translated from the _views keys the extractor writes (#21006) (#21072)
Fixes #21006 Clause-②: no ## What changed `translateObject` (`packages/spec/src/system/i18n-resolver.ts`) now translates each view in an object document's embedded `listViews`. It overlays the view's `label`, its `description` and its `bulkActionDefs[]` copy (def `label` / `confirmText` / `confirmLabel`, param `label` / `help` / `placeholder`). These are the same strings `translateView` overlays on a served view document. - **One key shape.** The address is `objects.OBJECT._views.VIEW.*`, where VIEW is the record key under `listViews`. `os i18n extract` writes exactly that: its `obj.listViews` loop hands `pushViewEntries` the record key (`packages/cli/src/utils/i18n-extract.ts`). The record key is already bare, so `viewTranslationKey`, which strips a served view's `OBJECT.` prefix, is not involved, and the view's own optional `name` is never read. `resolveViewLabel` / `resolveViewDescription` and the embedded channel now share one private lookup (`lookupViewText`) of `_views.VIEW.label` / `.description`. No second key function exists. - **Bulk-action copy** goes through the existing `translateBulkActionDefs`, reading `view.bulkActionDefs`. That is the authored address the extractor reads. A served view document nests the same list under `config`, and `translateView` keeps reading it there. - **Override order** follows ADR-0029 D9.2a, as `translateView` applies it (the rule from #20680 / #20730). Each string is judged by the existing `valueOverridesPackagedBase` against the same view in the packaged object's `listViews`. That packaged object is the `packagedBase` the REST read gate already supplies for objects (`getPackagedObjectBase`). A view the packaged base does not carry counts as authored. With no base supplied, the catalog applies. - **Not changed:** `emptyState` is not translated server-side on either channel. `translateView` never translated it, and the extractor's docblock names the client as its only reader. - `ObjectLike` gains an optional `listViews` member, typed as a string-keyed `Record` of `unknown`. It is a TS-only input shape: no export is added and no generated artefact moves. The only code that builds an `ObjectLike` in this repo (service-analytics' `{ name, fields }`) still type-checks. The reverse verification is below. The change lands where the claim said it would. `translateObject` produces the served labels, and the extractor is untouched. ## Measured **H1, the key shape.** An embedded `listViews` entry is an `ObjectListViewSchema` (`data/object.zod.ts`). Its copy-bearing keys that the extractor emits under `_views.VIEW` are `label`, `description`, `emptyState.title` / `.message` and `bulkActionDefs[].*`. All of them sit under the key shape the resolver already reads. Nothing needed a new key. **H2, the override order.** `translateView` and `translateDashboard` both judge with `valueOverridesPackagedBase` against `packagedBase` (#20731 / #20680). The embedded channel uses the same predicate and the same three edges: no base means the catalog applies, a non-string label is never an override, and an edit back to the shipped string is a no-op. **H3, the door.** `GET /api/v1/meta/object` goes `meta-item-read-gate.ts` `translateMetaDocument` / `translateMetaList`, then `translateMetadataDocument('object')`, then `translateObject`, with `packagedBase` from `getPackagedObjectBase`. `metadata-protocol/src/protocol.ts` names `translateObject` only in a docblock and does not call it. I measured the door once through the REST route handlers. The protocol double served the REAL `SysAccount` (`@objectstack/platform-objects/identity`), `getPackagedObjectBase` answered the same object, and the i18n service served the REAL shipped `en` / `zhCN` bundles (`@objectstack/platform-objects/apps`). It ran as a scratch test that is not committed. | read (zh-CN unless noted) | before (spec dist at `2f2fa11d7`) | after (spec dist at this branch) | |:--|:--|:--| | item `label` (object) | `身份链接` | `身份链接` | | item `listViews.mine.label` | `My Links` | `我的链接` | | item `listViews.by_provider.label` | `By Provider` | `按提供方` | | list `listViews.mine.label` | `My Links` | `我的链接` | | org overlay `mine` = `My Links (edited-21006)`, item | `My Links (edited-21006)` | `My Links (edited-21006)` (the overlay wins) | | same overlay, sibling `by_provider` | `By Provider` | `按提供方` | | `en`, item `listViews.mine.label` | `My Links` | `My Links` | The CLI's coverage still counts the same keys. `i18n-coverage` takes its key set from the extractor's `collectExpectedEntries`, which this PR does not touch. `pnpm check:i18n-coverage` exited 0 and the CLI's i18n unit tests passed (below), so extract and serve now agree. ## Tests New file `packages/spec/src/system/i18n-resolver.object-list-views.test.ts`, 21 cases. The `sys_account` fixture is transcribed from `identity/sys-account.object.ts`, with its `_views` leaves taken verbatim from the shipped `en` / `zh-CN` bundles. Every view is parsed by `ObjectListViewSchema` and the bundle by `TranslationBundleSchema`. - `sys_account`'s `mine` tab is served `我的链接` in zh-CN, and every sibling tab is translated too. - **Controls:** a view with no catalog entry keeps its authored label; an object with no `_views` gets the same `listViews` reference back; no bundle gets the same reference back; an object with no `listViews` gains none. - The view is keyed by its record key, never by its `name`. BCP-47 base-language matching applies, and a default-locale request does not walk the fallback chain. - `description` and bulk-action copy are translated from the embedded view's own address. - **Override order:** an edited label wins in zh-CN and `en`, each tab is judged separately, no base means the catalog applies, a reset restores the translation, and the edge cases hold (edit back to the shipped string, inline map). A tab the base does not ship keeps its label. The object-scalar rule and the tab rule are independent. - Neither input is mutated, and `translateMetadataDocument('object')` reaches the change. **Reverse verification.** The fix was committed first. Each mutation went through `scripts/ablation-replace.mjs`, which reported the anchor hit, the blob moved, and then restored to blob == HEAD with `git diff HEAD` empty. - Leg 1: replace the `translateObjectListViews(...)` call with `doc.listViews`. Result: **15 failed / 6 passed** of 21. The 6 that passed are the controls and the edited-wins cases, which pass when nothing is translated. - Leg 2: replace the per-view `valueOverridesPackagedBase(...)` with `false`. Result: **5 failed / 16 passed**. All 5 failures are override cases. - Cross-package type: inject `listViews: 5` into service-analytics' `ObjectLike` literal. `tsc` answered `src/plugin.ts(1056,61): error TS2322`, saying type `number` is not assignable to the string-keyed `Record` of `unknown`. That is the rebuilt `.d.ts`; the old one would have answered an excess-property error. Restored. ## Local verification (HEAD `402ab32f5`) The spec closure (①) is empty, because `packages/spec` has no workspace dependencies. Consumer closures were built with `turbo run build --filter='@objectstack/rest^...'` and with the `@objectstack/cli^...` / `@objectstack/plugin-approvals^...` packages that were not built yet. - `pnpm --filter @objectstack/spec build` → 0. `check:generated` → `✓ All 15 generated artifacts are up to date`. - `pnpm --filter @objectstack/spec typecheck` → 0 (`check:test-typecheck: OK`). `vitest run src/system` → 49 files / 1720 passed. - Consumers, one reading per package: - `@objectstack/rest`, the five `meta-*i18n*` tests: 5 files / 96 passed. - `@objectstack/metadata-protocol`, full suite: 195 passed / 3 skipped files, 2896 tests passed. - `@objectstack/cli`, `--project unit` `test/i18n-` + `test/platform-page-i18n-parity`: 22 files / 292 passed. The integration tier is declared to CI. - `@objectstack/service-analytics`, full suite: 150 files / 3458 passed. Its `typecheck` → 0. - `@objectstack/platform-objects`, full suite: 59 files / 948 passed. - `@objectstack/plugin-approvals`, `src/translations`: 3 files / 11 passed. - `dispatch-gates.mjs --commands` over this diff (84 families) plus `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity`: 83 of 84 ran and exited 0. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3) and exited 0 after the missing packages were built. `--ran` reconciliation: 84 derived, 83 run, 0 NOT-MEASURED, 1 UNRUN. - NOT MEASURED: `check:type-check-debt`. Reason: `--re-measure` runs tsc over the whole workspace and was killed twice at the 570 s foreground budget; it has no narrowing option. CI's `Lint & Repo Gates` owns it. The type-check this diff owes was measured: spec and service-analytics `typecheck` both 0. - **Lint, a measured narrowing.** `eslint --no-inline-config --format json` over the two changed TS files: 2 files, 0 errors, 0 warnings. Both files are in the population, by `eslint --print-config`. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on any untouched file. The full `pnpm lint` run belongs to CI. ## Acceptance notes - The extractor's `pushViewEntries` docblock (`packages/cli/src/utils/i18n-extract.ts`) names `translateView` as the server-side reader of `_views.VIEW.label` / `.description`. After this PR, `translateObject` is a second server-side reader, for the object-nested channel. The docblock lags in a file outside this card's surface. Noted, not filed. - `_views.VIEW.emptyState.*` is still read client-side only, on both channels. That is unchanged and is stated as design in the extractor's docblock. - This branch is based on `2f2fa11d7` and was not merged with `main` before opening. `origin/main` is 21 commits ahead. None of those commits touches `i18n-resolver.ts`, its tests, the REST meta read gate, the extractor or the platform catalogs. Their `ui/view.zod.ts` change is docblock-only, and their `rest-server.ts` change is outside the translation path. PR CI runs on the merge ref. ## objectui follow-up (not in this PR) objectui's `packages/app-shell/src/hooks/useServedViewItems.ts` documents the `/meta/object`-embedded views as untranslated by the server. Once this lands, `isServedView` should also answer true for a view id that is a key of the served `/meta/object` document's `listViews`, read from the pre-merge `useMetadata().getItemsByType('object')`. Then `viewTabLabel` (`ObjectView.tsx`) and the `AppHeader` crumb draw those labels as given instead of a second `viewLabel` pass, in which the bundle would win an edit back. The seat files that child after this lands. --- _Generated by [Claude Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f57888 commit c27404f

3 files changed

Lines changed: 466 additions & 21 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): an object's embedded `listViews` are served in the reader's language, from the `_views` keys `os i18n extract` already writes
6+
7+
Clause-②: no
8+
9+
- `GET /api/v1/meta/object` and `GET /api/v1/meta/object/:name` now translate each view in an object's `listViews`: its `label`, its `description`, and the copy of its `bulkActionDefs` (label, confirm prompt, confirm button, and each param's label, help and placeholder). They read the keys `os i18n extract` writes for those views, `objects.<object>._views.<view>.*`, where `<view>` is the view's key under `listViews`. For example, `sys_account`'s `mine` view is now served as `我的链接` to a `zh-CN` reader. It used to be served as the authored `My Links`.
10+
- A view with no translation for the requested locale keeps its authored text. An object with no `_views` entries is served unchanged.
11+
- A string that was changed after the package shipped still wins over the packaged translation, the same rule a served view document and a dashboard follow. The string is compared with the same view in the packaged object. If it differs, the changed string is served in every locale. A view the packaged object does not declare keeps all of its own strings.
12+
- `translateObject` (and `translateMetadataDocument('object', …)`) in `@objectstack/spec/system` does the translating, so any caller of those functions gets the same result.
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pin: `translateObject` localizes an object's EMBEDDED `listViews` from the
5+
* catalog keys `os i18n extract` already writes for them —
6+
* `objects.<object>._views.<listViews key>.{label, description, bulkActions.*}`
7+
* (`pushViewEntries` in `packages/cli/src/utils/i18n-extract.ts`, called with
8+
* the record key) — and lets an explicit override beat that catalog by the
9+
* same comparison `translateView` applies to a served view document
10+
* (ADR-0029 D9.2a).
11+
*
12+
* Before this, the extractor wrote those keys and every shipped platform bundle
13+
* carried them (`sys_account._views.mine.label` → `我的链接`), while the served
14+
* object document kept the authored `My Links`: extract and serve disagreed,
15+
* and only a client re-translating with its own bundle showed the tab in the
16+
* reader's language.
17+
*
18+
* The fixture is `sys_account` as `@objectstack/platform-objects` ships it
19+
* (`identity/sys-account.object.ts`), its three list views transcribed with
20+
* the copy the catalog addresses, and the bundle carries the shipped `en` /
21+
* `zh-CN` `_views` leaves verbatim. Both are PARSED by the authoring schemas,
22+
* so a view address or catalog key either schema does not carry cannot pass
23+
* here. `spec` cannot import `platform-objects` (the dependency runs the other
24+
* way), which is why the shape is transcribed rather than imported.
25+
*/
26+
27+
import { describe, it, expect } from 'vitest';
28+
import { translateObject, translateMetadataDocument } from './i18n-resolver';
29+
import { TranslationBundleSchema, type TranslationBundle } from './translation.zod';
30+
import { ObjectListViewSchema } from '../ui/view.zod';
31+
32+
const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
33+
34+
/** One embedded list view, parsed by the schema `ObjectSchema.listViews` uses. */
35+
const view = (raw: Record<string, unknown>): any => ObjectListViewSchema.parse(raw);
36+
37+
/** `sys_account` as the package ships it — the PACKAGED base. */
38+
const PACKAGED: any = {
39+
name: 'sys_account',
40+
label: 'Account',
41+
listViews: {
42+
mine: view({
43+
type: 'grid',
44+
name: 'mine',
45+
label: 'My Links',
46+
data: { provider: 'object', object: 'sys_account' },
47+
columns: ['provider_id', 'account_id', 'created_at', 'updated_at'],
48+
}),
49+
by_provider: view({
50+
type: 'grid',
51+
name: 'by_provider',
52+
label: 'By Provider',
53+
data: { provider: 'object', object: 'sys_account' },
54+
columns: ['provider_id', 'user_id', 'account_id', 'created_at'],
55+
}),
56+
all_links: view({
57+
type: 'grid',
58+
name: 'all_links',
59+
label: 'All',
60+
data: { provider: 'object', object: 'sys_account' },
61+
columns: ['provider_id', 'user_id', 'account_id', 'created_at', 'updated_at'],
62+
}),
63+
},
64+
};
65+
66+
/**
67+
* The shipped `_views` leaves (`en.objects.generated.ts` /
68+
* `zh-CN.objects.generated.ts`). `en` repeats the authored strings, which is
69+
* what extract writes for the source locale — and exactly as able to overwrite
70+
* an edit as a translation is.
71+
*/
72+
const BUNDLE: TranslationBundle = TranslationBundleSchema.parse({
73+
en: {
74+
objects: {
75+
sys_account: {
76+
label: 'Account',
77+
_views: { mine: { label: 'My Links' }, by_provider: { label: 'By Provider' }, all_links: { label: 'All' } },
78+
},
79+
},
80+
},
81+
'zh-CN': {
82+
objects: {
83+
sys_account: {
84+
label: '身份链接',
85+
_views: { mine: { label: '我的链接' }, by_provider: { label: '按提供方' }, all_links: { label: '全部' } },
86+
},
87+
},
88+
},
89+
});
90+
91+
const zh = (doc: any, packagedBase?: unknown): any =>
92+
translateObject(doc, BUNDLE, { locale: 'zh-CN', packagedBase });
93+
94+
describe('translateObject — embedded listViews read the `_views` keys the extractor writes', () => {
95+
it("serves sys_account's `mine` tab translated in zh-CN — and every sibling tab", () => {
96+
const out = zh(clone(PACKAGED), PACKAGED);
97+
expect(out.listViews.mine.label).toBe('我的链接');
98+
expect(out.listViews.by_provider.label).toBe('按提供方');
99+
expect(out.listViews.all_links.label).toBe('全部');
100+
// The object's own label was already translated; it still is.
101+
expect(out.label).toBe('身份链接');
102+
});
103+
104+
it('serves the same tabs with no packaged base known (the pre-override answer: the catalog applies)', () => {
105+
expect(zh(clone(PACKAGED)).listViews.mine.label).toBe('我的链接');
106+
});
107+
108+
it('keys on the listViews RECORD key — the extractor\'s key — never on the view\'s own `name`', () => {
109+
// `pushViewEntries` is handed the record key (`Object.entries(obj.listViews)`),
110+
// so that is the one address a translation exists under. A view whose
111+
// optional `name` says something else is still the tab under its key.
112+
const doc = clone(PACKAGED);
113+
doc.listViews.mine.name = 'my_accounts';
114+
expect(zh(doc, PACKAGED).listViews.mine.label).toBe('我的链接');
115+
});
116+
117+
it('the control — a view with no catalog entry keeps its authored label', () => {
118+
const doc = clone(PACKAGED);
119+
doc.listViews.recent = view({
120+
type: 'grid',
121+
label: 'Recently Linked',
122+
data: { provider: 'object', object: 'sys_account' },
123+
columns: ['provider_id'],
124+
});
125+
const out = zh(doc);
126+
expect(out.listViews.recent.label).toBe('Recently Linked');
127+
// …and the tabs that DO have entries are translated beside it.
128+
expect(out.listViews.mine.label).toBe('我的链接');
129+
});
130+
131+
it('the control — an object whose catalog carries no `_views` hands back the very same listViews', () => {
132+
const doc = { ...clone(PACKAGED), name: 'sys_untranslated' };
133+
const out = translateObject(doc, BUNDLE, { locale: 'zh-CN' }) as any;
134+
expect(out.listViews).toBe(doc.listViews);
135+
expect(out.listViews.mine.label).toBe('My Links');
136+
});
137+
138+
it('no bundle at all hands back the very same listViews', () => {
139+
const doc = clone(PACKAGED);
140+
expect((translateObject(doc, undefined, { locale: 'zh-CN' }) as any).listViews).toBe(doc.listViews);
141+
});
142+
143+
it('an object with no listViews gains none', () => {
144+
const doc = { name: 'sys_account', label: 'Account' };
145+
expect('listViews' in translateObject(doc, BUNDLE, { locale: 'zh-CN' })).toBe(false);
146+
});
147+
148+
it('walks the request locale the same way every other resolver does (BCP-47 base language)', () => {
149+
expect(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh' }).listViews.mine.label).toBe('我的链接');
150+
});
151+
152+
it('a request for the default locale answers with the authored label, never a fallback chain entry', () => {
153+
// The #15711 rule `localeChain` carries: the shared lookup is what makes
154+
// the embedded tab obey it too.
155+
const out = translateObject(clone(PACKAGED), BUNDLE, {
156+
locale: 'fr-FR',
157+
defaultLocale: 'fr-FR',
158+
fallbackChain: ['zh-CN'],
159+
}) as any;
160+
expect(out.listViews.mine.label).toBe('My Links');
161+
});
162+
163+
it('does not mutate either input document', () => {
164+
const doc = clone(PACKAGED);
165+
doc.listViews.mine.label = 'My Links (edited)';
166+
const before = clone(doc);
167+
const packagedBefore = clone(PACKAGED);
168+
zh(doc, PACKAGED);
169+
expect(doc).toEqual(before);
170+
expect(PACKAGED).toEqual(packagedBefore);
171+
});
172+
173+
it('reaches the generic dispatcher — translateMetadataDocument("object") translates the tabs', () => {
174+
// The REST boundary never calls `translateObject` directly.
175+
const out = translateMetadataDocument('object', clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED });
176+
expect(out.listViews.mine.label).toBe('我的链接');
177+
});
178+
});
179+
180+
describe('translateObject — embedded listViews: description and bulk-action copy', () => {
181+
const WITH_COPY: any = {
182+
name: 'sys_account',
183+
listViews: {
184+
mine: view({
185+
type: 'grid',
186+
label: 'My Links',
187+
description: 'Accounts linked to you',
188+
data: { provider: 'object', object: 'sys_account' },
189+
columns: ['provider_id'],
190+
bulkActionDefs: [
191+
{
192+
name: 'mark_reviewed',
193+
label: 'Mark reviewed',
194+
operation: 'update',
195+
patch: { reviewed: true },
196+
confirmText: 'Mark the selected accounts reviewed?',
197+
confirmLabel: 'Mark them',
198+
params: [{ name: 'note', type: 'text', label: 'Note', help: 'Kept in the audit log', placeholder: 'Optional' }],
199+
},
200+
],
201+
}),
202+
},
203+
};
204+
205+
const COPY_BUNDLE: TranslationBundle = TranslationBundleSchema.parse({
206+
'zh-CN': {
207+
objects: {
208+
sys_account: {
209+
_views: {
210+
mine: {
211+
label: '我的链接',
212+
description: '与你关联的账户',
213+
bulkActions: {
214+
mark_reviewed: {
215+
label: '标记已复核',
216+
confirmText: '确定将所选账户标记为已复核吗?',
217+
confirmLabel: '确认标记',
218+
params: { note: { label: '备注', help: '记录在审计日志中', placeholder: '选填' } },
219+
},
220+
},
221+
},
222+
},
223+
},
224+
},
225+
},
226+
});
227+
228+
const translate = (doc: any, packagedBase?: unknown): any =>
229+
translateObject(doc, COPY_BUNDLE, { locale: 'zh-CN', packagedBase });
230+
const def = (doc: any) => doc.listViews.mine.bulkActionDefs[0];
231+
232+
it('translates the description and the bulk-action defs authored ON the embedded view', () => {
233+
// An embedded view carries its defs on itself — the authored address the
234+
// extractor reads (`view.bulkActionDefs`) — not under the `config` a served
235+
// view document nests them in.
236+
const out = translate(clone(WITH_COPY), WITH_COPY);
237+
expect(out.listViews.mine.description).toBe('与你关联的账户');
238+
expect(def(out)).toMatchObject({ label: '标记已复核', confirmText: '确定将所选账户标记为已复核吗?', confirmLabel: '确认标记' });
239+
expect(def(out).params[0]).toMatchObject({ label: '备注', help: '记录在审计日志中', placeholder: '选填' });
240+
// Nothing else on the def moved.
241+
expect(def(out)).toMatchObject({ operation: 'update', patch: { reviewed: true } });
242+
});
243+
244+
it('an edited def string beats the catalog; the untouched ones on it stay translated', () => {
245+
const doc = clone(WITH_COPY);
246+
def(doc).label = 'Close out';
247+
def(doc).params[0].help = 'Shown to admins';
248+
const out = translate(doc, WITH_COPY);
249+
expect(def(out).label).toBe('Close out');
250+
expect(def(out).confirmText).toBe('确定将所选账户标记为已复核吗?');
251+
expect(def(out).params[0].help).toBe('Shown to admins');
252+
expect(def(out).params[0].label).toBe('备注');
253+
});
254+
255+
it('an edited description beats the catalog; the label beside it stays translated', () => {
256+
const doc = clone(WITH_COPY);
257+
doc.listViews.mine.description = 'Our accounts';
258+
const out = translate(doc, WITH_COPY);
259+
expect(out.listViews.mine.description).toBe('Our accounts');
260+
expect(out.listViews.mine.label).toBe('我的链接');
261+
});
262+
});
263+
264+
describe('translateObject — embedded listViews: the catalog loses to an explicit override (ADR-0029 D9.2a)', () => {
265+
const edited = (label: string) => {
266+
const doc = clone(PACKAGED);
267+
doc.listViews.mine.label = label;
268+
return doc;
269+
};
270+
271+
it("an org overlay's edited tab label is served — in zh-CN and in the source locale", () => {
272+
for (const locale of ['zh-CN', 'en']) {
273+
const out = translateObject(edited('My Links (edited)'), BUNDLE, { locale, packagedBase: PACKAGED }) as any;
274+
expect(out.listViews.mine.label, locale).toBe('My Links (edited)');
275+
}
276+
});
277+
278+
it('judges each tab SEPARATELY — the unedited siblings stay translated', () => {
279+
const out = zh(edited('My Links (edited)'), PACKAGED);
280+
expect(out.listViews.by_provider.label).toBe('按提供方');
281+
expect(out.listViews.all_links.label).toBe('全部');
282+
});
283+
284+
it('NO packaged base supplied → the catalog applies (unknown is not authored)', () => {
285+
expect(zh(edited('My Links (edited)')).listViews.mine.label).toBe('我的链接');
286+
expect(zh(edited('My Links (edited)'), null).listViews.mine.label).toBe('我的链接');
287+
});
288+
289+
it('a reset (the served tab equals the packaged one again) restores the translated shipped label', () => {
290+
expect(zh(edited('My Links (edited)'), PACKAGED).listViews.mine.label).toBe('My Links (edited)');
291+
expect(zh(clone(PACKAGED), PACKAGED).listViews.mine.label).toBe('我的链接');
292+
});
293+
294+
it('the ruled edge — an edit back to exactly the shipped string is a no-op, and a non-string label is never an override', () => {
295+
expect(zh(edited('My Links'), PACKAGED).listViews.mine.label).toBe('我的链接');
296+
const map = clone(PACKAGED);
297+
map.listViews.mine.label = { en: 'Mine', 'zh-CN': '我的' };
298+
expect(zh(map, PACKAGED).listViews.mine.label).toBe('我的链接');
299+
});
300+
301+
it('a tab the package does not ship was authored after the fact and keeps its label', () => {
302+
// A base that IS known but carries no such view: every string on it
303+
// diverged, the rule a dashboard widget or a bulk-action def the package
304+
// never shipped follows. The catalog entry under the same bare key
305+
// translates some OTHER view and must not reach this one.
306+
const base = { ...clone(PACKAGED), listViews: { by_provider: PACKAGED.listViews.by_provider } };
307+
const out = zh(clone(PACKAGED), base);
308+
expect(out.listViews.mine.label).toBe('My Links');
309+
expect(out.listViews.by_provider.label).toBe('按提供方');
310+
// A base with no listViews at all: the same answer for every tab.
311+
expect(zh(clone(PACKAGED), { name: 'sys_account' }).listViews.mine.label).toBe('My Links');
312+
});
313+
314+
it('the object-scalar rule and the tab rule are independent comparisons', () => {
315+
const doc = edited('My Links (edited)');
316+
doc.label = 'Linked Identity';
317+
const out = zh(doc, PACKAGED);
318+
expect(out.label).toBe('Linked Identity');
319+
expect(out.listViews.mine.label).toBe('My Links (edited)');
320+
expect(out.listViews.by_provider.label).toBe('按提供方');
321+
});
322+
});

0 commit comments

Comments
 (0)