Skip to content

Commit bb86ff9

Browse files
fix(spec): one label order on an overridden item, for all eight document translators (#30236)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> Fixes #30193 _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBCTY6Vx1RjbGoWGwHcjPnfP)_ Clause-②: yes (widening) Metadata-protocol: none Skills-docs: follow-up owed — skills/objectstack-platform/SKILL.md (the ADR-0146 "Customizing an installed package" paragraph, :128, says nothing of an override's label order) and skills/objectstack-i18n/SKILL.md (no line on an overridden item); owed once the serving half passes `pairBundle`, so a reader can see the order; the skills lane is paused Prior rulings read: packagedbase,translatepage,override label → 0 hits; none; thread: none; repo: objectstack-ai/objectstack ## 维护者速读 - 改了什么:`.custom` 配对包里覆盖基础包的条目(视图、仪表盘、报表、对象、页面、数据集、应用、文档手册八类),每个标签按同一顺序取文字:配对包自带的译文 → 覆盖项自己写的文字 → 基础包的译文;覆盖项没改动的标签照旧用基础包译文。 - 为什么改:页面、数据集、应用、手册四类此前不比对基础包,覆盖项改过的标签会被基础包译文盖回;另外四类虽保住了覆盖文字,却读不到配对包自带的译文,因为解析器拿到的是一份合并后的词典,分不清译文出自哪个包。 - 风险与回滚:只新增一个可选项 `pairBundle`;不传时输出与现在完全相同(已用既有 1024 个测试强制空配对验证),服务端也还没有传它,所以合并后用户可见行为不变;回滚即还原本 PR。 - 先看:① `packages/spec/src/system/i18n-resolver.ts` 的 `labelCatalogs` / `catalogFor`(规则的唯一出处);② 同文件 `translatePage` 的页头标题判定;③ `i18n-resolver.test.ts` 末尾的八类型表驱动测试。 - 两分钟验证:`cd packages/spec && pnpm exec vitest run --project local src/system/i18n-resolver.test.ts -t "label precedence on an overridden item"`,预期 75 个测试全部通过。 - 需要你做的:无,席位落地。 ## What changes - **One new option, `TranslateDocumentOptions.pairBundle`**: the translations the served item's paired `.custom` package ships itself, locale-keyed as `bundle` is. - **One rule, one helper.** `labelCatalogs(bundle, opts)` builds two catalogs per document: `changed` (the pair's own translations, nothing else) and `unchanged` (locale by locale, the pair's translation laid over the deployment catalog's). `catalogFor(catalogs, changed)` picks one from the comparison every translator already makes, `valueOverridesPackagedBase` against `packagedBase`. Every former "skip the catalog when the value diverged" gate is now "read the catalog this string earns", through these two functions, in all eight translators and every shared nested helper (bulk-action defs, form sections and title, the thank-you panel, global filters, report blocks, object list views, dataset members, page tab items, interface tabs). - **`translatePage`, `translateDataset`, `translateApp` and `translateBook` read `packagedBase`** (their options type widens from `ResolveOptions` to `TranslateDocumentOptions`), each string judged against the base's counterpart: page components by `id` and THE page's header, dataset members by `name`, navigation nodes by `id`, book groups by `key`. A page header title the override left as the base wrote it translates as the base's label. - No `pairBundle`, or one that answers none of the locale chain's codes: the catalogs are exactly the ones without it (same references). No `packagedBase`: nothing counts as changed. So no caller's output moves today: the server passes `packagedBase` only for object, dashboard, view and report (`PACKAGED_BASE_ACCESSORS`, `form-routes.ts`), and passes no `pairBundle` at all. ## Precedence, per type (before and after) "Changed" = the served string differs from the base's (`valueOverridesPackagedBase`). The catalog the server passes today is one merged dictionary per locale (see the design below), so "the merged catalog" means every package's code bundle and every authored translation row, deep-merged, with no way to tell the pair's from the base's. | Type | Strings judged | Before: a string the override changed | Before: one it left alone | After: changed | After: left alone | |:--|:--|:--|:--|:--|:--| | view | label, description, bulk-action defs (label, confirm text and button, params), form sections, form title, thank-you panel | its own text (the pair's translation is never read) | the merged catalog | the pair's translation along the locale chain, else its own text | per locale: the pair's translation, then the base's | | dashboard | label, description, widget title and description, global filter label and options | its own text (pair never read) | the merged catalog | pair, else its own text | pair, then base, per locale | | report | label, description, joined block label and description | its own text (pair never read) | the merged catalog | pair, else its own text | pair, then base, per locale | | object | label, pluralLabel, description, field label, help, option labels, list views | its own text (pair never read) | the merged catalog | pair, else its own text | pair, then base, per locale | | page | label, description, header title and subtitle, component copy, `page:tabs` items, interface tabs, form thank-you panel | the base's translation (`packagedBase` not read) | the merged catalog | pair, else its own text | pair, then base, per locale | | dataset | label, description, dimension and measure labels | the base's translation (`packagedBase` not read) | the merged catalog | pair, else its own text | pair, then base, per locale | | app | label, description, navigation node labels (any depth) | the base's translation (`packagedBase` not read) | the merged catalog | pair, else its own text | pair, then base, per locale | | book | label, description, group labels | the base's translation (`packagedBase` not read) | the merged catalog | pair, else its own text | pair, then base, per locale | ## Pair-provenance design Measured at dfafae8: the resolver cannot tell a pair translation from the base's. - `packages/rest/src/meta-item-read-gate.ts:2887` `translationBundleOf` builds `{ locale: i18n.getTranslations(locale) }`. - `packages/services/service-i18n/src/file-i18n-adapter.ts:189` `getTranslations` returns `deepMerge(static, authored)`: - static = every package's code bundle, deep-merged in load order (`loadTranslations`, :199); - authored = every active `translation` sys_metadata row across ALL packages, deep-merged by item name, `_packageId` stripped (`packages/core/src/fallbacks/authored-translation-sync.ts` `readAuthoredTranslationLayer`). - One merged catalog per locale; provenance is lost twice. So the resolver needs a new input. The option: `TranslateDocumentOptions.pairBundle?: TranslationBundle` (the same locale-keyed shape as `bundle`): the translations the served item's paired `.custom` package itself ships, and nothing else. | The string | Read | No hit | |:--|:--|:--| | changed by the override (`valueOverridesPackagedBase` true) | `pairBundle` only, along `localeChain` | the override's own value (the base catalog is never read) | | not changed, or no `packagedBase` | per chain locale: `pairBundle`, then the deployment catalog | the authored value | - Interleaved per locale (pair zh-CN, catalog zh-CN, pair en, catalog en) for an unchanged string, so a pair that ships only `en` never displaces the base's `zh-CN` on an untouched label. - A pair that answers none of the chain's locales short-circuits to the catalogs without it, so an empty pair keeps even the no-bundle reference identity. ## Accepted deviations - **An object's `validations[].message` stays on the plain merged bundle, and inline object `actions` (`translateAction`) stay as today** (seat ruling on the design, item 2). Reason: the objectql rule evaluator (`packages/objectql/src/validation/rule-validator.ts:3785`) answers the same message from `i18n.t()` with no pair input, and the pinned cross-path invariant is refusal text = served text. `action` is not one of the eight types and has no `packagedBase`. - **What the validations deviation means for a user** (contract review 6648581045, should-fix 2): on an overridden object, a `validations[].message` the override reworded never takes the pair's translation while the object's labels do, and a message it left alone reads the merged catalog, where the pair's row can lose to the base's by merge order. Lifting it needs a pair input on the objectql evaluator and the resolver together: #30245. ## Server-half contract for #30062 For a served item that is a declared `.custom` override, on item, list and container reads alike, for all eight types: - `packagedBase` = the BASE package's item (pre-override: the document the override replaces), never the override itself; - `pairBundle` = `{ locale: data }` built from that `.custom` package's own translations only: its code-shipped translation bundle and its authored `translation` rows, both of which are the pair's own; - keep passing `packagedBase` for object, dashboard, view and report exactly as today, on every served item (`PACKAGED_BASE_ACCESSORS`, `form-routes.ts` :385 :421): the object reads it for extension-folded field labels too (ADR-0029 D9.2a, #27559), so it is not override-only. Add the same accessor for page, dataset, app and book; - omit `pairBundle` for an item that is not a declared override. _Seat correction, body r1 (contract review 6648581045, should-fix 1): the first version said "omit both for an item that is not an override", which would have regressed the four types the server serves with `packagedBase` today._ ## Carried - **Studio finding, filed as #30244 (ui, p2, blocked by #30062).** Studio's own rails pair served `/meta` text (`ui/packages/app-shell/src/views/studio-design/servedNavText.ts`), so they share the server's answer. The Console header does not: `ui/packages/i18n/src/useObjectLabel.ts:489` `reportLabel` (and `:482` `pageLabel`; the dashboard and app labels have the same shape) resolves the catalog key FIRST and the served label only as the fallback, so it re-applies the base's translation over the server's answer. Callers: `ui/packages/app-shell/src/layout/AppHeader.tsx:750` (the report crumb), `ui/packages/app-shell/src/views/appReportIndex.ts:324`, `ui/packages/app-shell/src/hooks/useRecentItemLabel.ts:122`. The non-spec fix: for these served documents, take the served label as already translated. Nothing in `ui/**` changes here. ## Tests - New: one top-level describe in `packages/spec/src/system/i18n-resolver.test.ts`, "label precedence on an overridden item", table-driven over all eight types through the dispatcher (`translateMetadataDocument`). Per type, a top-level label and one or two nested ones (bulk-action label, widget title, block label, field and option label, component copy and tab item, measure and dimension, nested nav node, group), each row of the table above: the pair wins; with no pair translation (no pair, and a pair shipping nothing for the item) the override's own label beats the base's translation in `zh-CN` and in the source locale; an unchanged label keeps the base's translation beside a changed one; an untouched label takes the pair's translation; the per-locale case (pair ships only `en`, an untouched label keeps the base's `zh-CN`); no `packagedBase` = nothing changed; an empty pair is the same answer as none, byte for byte; no input mutated. Plus the page header restatement case. 75 tests. - `pnpm exec vitest run --project local --maxWorkers=2` over the 25 spec test files importing the resolver, under `os-verify-lock`: `Test Files 25 passed (25)`, `Tests 1099 passed (1099)`, `VERDICT command-exit 0`. - The byte-identical claim, run on every existing fixture: with an in-chain empty pair forced onto every call (`pairBundle ?? { each chain code: {} }`), the 25 files gave 1023/1024; the one red was a reference-identity assert with no bundle (equal value, new object), which the short-circuit above closes. - Ablations (each restored, verified by `git diff --stat`): the pair never read → 41 of the 75 red (5 rows × 8 types, plus the page header pair case); the interleave dropped → 8 red, exactly the per-locale case once per type; changed strings reading the merged catalog → 16 red. - `pnpm --filter @objectstack/spec typecheck` (build, scripts and test layers) exit 0. ## Verification - `pnpm --filter @objectstack/spec build` under the lock: exit 0. `pnpm check:generated`: all 14 generated artifacts up to date (api-surface included), exit 0. - Dispatch gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 78 commands at 8476878; all 78 ran and exited 0. Four first exited 3 (prerequisite not met, nothing measured) and were re-run green after `git fetch --depth=1` of the teardown self-test's pinned fixture commit and a `pnpm exec turbo run build`: `check-plugin-teardown-shape --self-test`, `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`. `--ran` reconciliation: 78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN. - `GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event` with this body: exit 0; the level axis reads `Clause-②: yes (widening)` and `Metadata-protocol: none`, a non-protocol change at `patch`. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzhytXa3Yi3uzE5pvuv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 159930a commit bb86ff9

3 files changed

Lines changed: 893 additions & 169 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
feat(i18n): one label order on an overridden item, for all eight document translators — the pair's translation, then the override's own label, then the base's translation
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
- **New option `TranslateDocumentOptions.pairBundle`.** The translations the served item's paired `.custom` package ships itself (ADR-0146 D2/D3), locale-keyed as `bundle` is. The serving layer's `bundle` is one merged catalog per locale with the package ids dropped, so only this option can tell a translation the pair ships from the base's.
11+
- **The order, per translatable string.** A string the override changed (`valueOverridesPackagedBase` against `packagedBase`, the comparison every translator already makes) reads the pair's translations only, along the locale chain, and otherwise keeps its own text: the base's translation is never read for it. A string the override left alone reads, locale by locale, the pair's translation and then the deployment catalog, so a pair that ships only `en` never displaces the base's `zh-CN` on an untouched label. Nested labels follow the same rule: widget titles, filter and option labels, view bulk-action defs and form sections, report blocks, field labels, help and options, page component copy, tab items and the header, dataset members, app navigation nodes and book groups.
12+
- **`translatePage`, `translateDataset`, `translateApp` and `translateBook` now read `packagedBase`** (their third parameter widens from `ResolveOptions` to `TranslateDocumentOptions`), each string judged against the base's counterpart: the page's components by id and its header, the dataset's members by `name`, the app's navigation nodes by `id`, the book's groups by `key`. A page header title the override left as the base wrote it translates as the base's label.
13+
- **Nothing moves for an existing caller.** With no `pairBundle` (or one that answers none of the chain's locales) every translator gives exactly the answer it gave before, and with no `packagedBase` nothing counts as changed. Passing both for a `.custom` override is the serving layer's half (#30062).
14+
- An object's `validations[].message` and its inline `actions` keep reading the merged catalog: the rule evaluator answers the same message from `i18n.t()` with no pair input, and the refusal text must equal the served text.

‎packages/spec/src/system/i18n-resolver.test.ts‎

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6081,3 +6081,329 @@ describe('resolveMetadataFormSchemaTitles — bundle labels overlaid as JSON Sch
60816081
expect(out.properties.fields.items.properties.label.title).toBe('行标签');
60826082
});
60836083
});
6084+
6085+
// ────────────────────────────────────────────────────────────────────────────
6086+
// #30193 — label precedence on an overridden item (ADR-0146 D2)
6087+
// ────────────────────────────────────────────────────────────────────────────
6088+
6089+
import { type TranslateDocumentOptions } from './i18n-resolver';
6090+
import { ReportSchema } from '../ui/report.zod';
6091+
import { BookSchema } from './book.zod';
6092+
6093+
describe("label precedence on an overridden item — the pair's translation, then the override's label, then the base's translation (ADR-0146 D2)", () => {
6094+
/** One translatable string of a served document, and the bundle address that translates it. */
6095+
interface Slot {
6096+
readonly what: string;
6097+
readonly read: (doc: any) => unknown;
6098+
readonly write: (doc: any, text: string) => void;
6099+
/** The translation data addressing this string, holding `text`. */
6100+
readonly entry: (text: string) => Record<string, unknown>;
6101+
/** The base's `zh-CN` translation, as the deployment catalog carries it. */
6102+
readonly baseZh: string;
6103+
}
6104+
6105+
/** A type's packaged base and its strings: the top-level label first, then nested ones. */
6106+
interface PrecedenceCase {
6107+
readonly base: () => any;
6108+
readonly slots: readonly Slot[];
6109+
}
6110+
6111+
const isRecord = (value: unknown): value is Record<string, unknown> =>
6112+
typeof value === 'object' && value !== null && !Array.isArray(value);
6113+
const merge = (...parts: Record<string, unknown>[]): Record<string, unknown> => {
6114+
const out: Record<string, unknown> = {};
6115+
for (const part of parts) {
6116+
for (const [key, value] of Object.entries(part)) {
6117+
const prior = out[key];
6118+
out[key] = isRecord(prior) && isRecord(value) ? merge(prior, value) : value;
6119+
}
6120+
}
6121+
return out;
6122+
};
6123+
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
6124+
/** A slot builder: `path` addresses the string inside the translation data. */
6125+
const slot = (
6126+
what: string,
6127+
read: (doc: any) => unknown,
6128+
write: (doc: any, text: string) => void,
6129+
path: readonly string[],
6130+
baseZh: string,
6131+
): Slot => ({
6132+
what,
6133+
read,
6134+
write,
6135+
baseZh,
6136+
entry: (text) => path.reduceRight<unknown>((inner, key) => ({ [key]: inner }), text) as Record<string, unknown>,
6137+
});
6138+
6139+
const viewContainer = {
6140+
listViews: {
6141+
in_progress: {
6142+
label: 'In Progress',
6143+
type: 'grid' as const,
6144+
data: { provider: 'object' as const, object: 'showcase_task' },
6145+
columns: [{ field: 'title' }],
6146+
bulkActionDefs: [{ name: 'mark_done', label: 'Mark done', operation: 'update' as const, patch: { status: 'done' } }],
6147+
},
6148+
},
6149+
};
6150+
const VIEW = ['objects', 'showcase_task', '_views', 'in_progress'];
6151+
6152+
const CASES: Record<string, PrecedenceCase> = {
6153+
view: {
6154+
base: () => expandViewContainer('showcase_task', viewContainer).find((v) => v.name === 'showcase_task.in_progress'),
6155+
slots: [
6156+
slot('label', (d) => d.label, (d, t) => { d.label = t; }, [...VIEW, 'label'], '进行中'),
6157+
slot('bulk action label', (d) => d.config.bulkActionDefs[0].label, (d, t) => { d.config.bulkActionDefs[0].label = t; },
6158+
[...VIEW, 'bulkActions', 'mark_done', 'label'], '标记完成'),
6159+
],
6160+
},
6161+
dashboard: {
6162+
base: () => ({ name: 'system_overview', label: 'System Overview', widgets: [{ id: 'widget_total_users', title: 'Total Users' }] }),
6163+
slots: [
6164+
slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['dashboards', 'system_overview', 'label'], '系统概览'),
6165+
slot('widget title', (d) => d.widgets[0].title, (d, t) => { d.widgets[0].title = t; },
6166+
['dashboards', 'system_overview', 'widgets', 'widget_total_users', 'title'], '用户总数'),
6167+
],
6168+
},
6169+
report: {
6170+
base: () => ReportSchema.parse({
6171+
name: 'task_overview',
6172+
label: 'Task Overview',
6173+
type: 'joined',
6174+
blocks: [{ name: 'open_block', label: 'Open Tasks', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['n'] }],
6175+
}),
6176+
slots: [
6177+
slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['reports', 'task_overview', 'label'], '任务概览'),
6178+
slot('block label', (d) => d.blocks[0].label, (d, t) => { d.blocks[0].label = t; },
6179+
['reports', 'task_overview', 'blocks', 'open_block', 'label'], '未完成任务'),
6180+
],
6181+
},
6182+
object: {
6183+
base: () => ({
6184+
name: 'showcase_task',
6185+
label: 'Task',
6186+
fields: {
6187+
title: { name: 'title', type: 'text', label: 'Title' },
6188+
status: { name: 'status', type: 'select', label: 'Status', options: [{ value: 'todo', label: 'To Do' }] },
6189+
},
6190+
}),
6191+
slots: [
6192+
slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['objects', 'showcase_task', 'label'], '任务'),
6193+
slot('field label', (d) => d.fields.title.label, (d, t) => { d.fields.title.label = t; },
6194+
['objects', 'showcase_task', 'fields', 'title', 'label'], '标题'),
6195+
slot('option label', (d) => d.fields.status.options[0].label, (d, t) => { d.fields.status.options[0].label = t; },
6196+
['objects', 'showcase_task', 'fields', 'status', 'options', 'todo'], '待办'),
6197+
],
6198+
},
6199+
page: {
6200+
base: () => ({
6201+
name: 'ops_home',
6202+
label: 'Ops Home',
6203+
regions: [{
6204+
name: 'main',
6205+
components: [
6206+
{ type: 'page:card', id: 'open_work', properties: { title: 'Open Work' } },
6207+
{ type: 'page:tabs', id: 'ops_tabs', properties: { items: [{ label: 'Mine', value: 'mine', children: [] }] } },
6208+
],
6209+
}],
6210+
}),
6211+
slots: [
6212+
slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['pages', 'ops_home', 'label'], '运营首页'),
6213+
slot('component copy', (d) => d.regions[0].components[0].properties.title,
6214+
(d, t) => { d.regions[0].components[0].properties.title = t; },
6215+
['pages', 'ops_home', 'components', 'open_work', 'title'], '进行中的工作'),
6216+
slot('tab item label', (d) => d.regions[0].components[1].properties.items[0].label,
6217+
(d, t) => { d.regions[0].components[1].properties.items[0].label = t; },
6218+
['pages', 'ops_home', 'components', 'ops_tabs', 'items', 'mine', 'label'], '我的'),
6219+
],
6220+
},
6221+
dataset: {
6222+
base: () => DatasetSchema.parse({
6223+
name: 'duty_pulse',
6224+
label: 'Duty pulse',
6225+
object: 'duly_duty',
6226+
dimensions: [{ name: 'owner', field: 'owner_id', label: 'Owner' }],
6227+
measures: [{ name: 'untouched_14d', aggregate: 'count', label: 'Untouched > 14 days' }],
6228+
}),
6229+
slots: [
6230+
slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['datasets', 'duty_pulse', 'label'], '任务脉搏'),
6231+
slot('measure label', (d) => d.measures[0].label, (d, t) => { d.measures[0].label = t; },
6232+
['datasets', 'duty_pulse', 'measures', 'untouched_14d', 'label'], '停滞超过 14 天'),
6233+
slot('dimension label', (d) => d.dimensions[0].label, (d, t) => { d.dimensions[0].label = t; },
6234+
['datasets', 'duty_pulse', 'dimensions', 'owner', 'label'], '负责人'),
6235+
],
6236+
},
6237+
app: {
6238+
base: () => ({
6239+
name: 'setup',
6240+
label: 'Setup',
6241+
navigation: [{
6242+
id: 'group_overview',
6243+
type: 'group',
6244+
label: 'Overview',
6245+
children: [{ id: 'nav_users', type: 'object', label: 'Users' }],
6246+
}],
6247+
}),
6248+
slots: [
6249+
slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['apps', 'setup', 'label'], '系统设置'),
6250+
slot('nested nav label', (d) => d.navigation[0].children[0].label, (d, t) => { d.navigation[0].children[0].label = t; },
6251+
['apps', 'setup', 'navigation', 'nav_users', 'label'], '用户'),
6252+
],
6253+
},
6254+
book: {
6255+
base: () => BookSchema.parse({
6256+
name: 'crm_manual',
6257+
label: 'User Manual',
6258+
groups: [{ key: 'start', label: 'Getting started', include: 'crm_start_*' }],
6259+
}),
6260+
slots: [
6261+
slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['books', 'crm_manual', 'label'], '使用手册'),
6262+
slot('group label', (d) => d.groups[0].label, (d, t) => { d.groups[0].label = t; },
6263+
['books', 'crm_manual', 'groups', 'start', 'label'], '快速上手'),
6264+
],
6265+
},
6266+
};
6267+
6268+
it('covers every document translator the ruling names — all eight types', () => {
6269+
expect(Object.keys(CASES).sort()).toEqual(['app', 'book', 'dashboard', 'dataset', 'object', 'page', 'report', 'view']);
6270+
for (const type of Object.keys(CASES)) expect(TRANSLATABLE_METADATA_TYPES.has(type), type).toBe(true);
6271+
});
6272+
6273+
for (const [type, c] of Object.entries(CASES)) {
6274+
describe(type, () => {
6275+
const base = c.base();
6276+
const ownText = (s: Slot) => String(s.read(base));
6277+
const overrideText = (s: Slot) => `${ownText(s)} (ours)`;
6278+
const pairText = (s: Slot) => `${s.baseZh}(配对)`;
6279+
// The deployment catalog: the base's translations. `en` repeats the
6280+
// shipped strings, as an extracted source locale does, so the source
6281+
// locale is as able to overwrite an override as `zh-CN` is.
6282+
const bundle: TranslationBundle = {
6283+
en: merge(...c.slots.map((s) => s.entry(ownText(s)))) as any,
6284+
'zh-CN': merge(...c.slots.map((s) => s.entry(s.baseZh))) as any,
6285+
};
6286+
/** The override: the base with `which` strings re-authored. */
6287+
const overridden = (which: readonly Slot[] = c.slots) => {
6288+
const doc = clone(base);
6289+
for (const s of which) s.write(doc, overrideText(s));
6290+
return doc;
6291+
};
6292+
/** What the pair ships: a translation of each `which` string, in `locale`. */
6293+
const pairOf = (locale: string, which: readonly Slot[] = c.slots): TranslationBundle =>
6294+
({ [locale]: merge(...which.map((s) => s.entry(pairText(s)))) }) as TranslationBundle;
6295+
const run = (doc: any, opts: TranslateDocumentOptions) =>
6296+
translateMetadataDocument(type, doc, bundle, { packagedBase: base, ...opts });
6297+
6298+
it('composes the fixture this block is pinned to — the catalog translates every string of the base', () => {
6299+
const out = run(clone(base), { locale: 'zh-CN' });
6300+
for (const s of c.slots) expect(s.read(out), s.what).toBe(s.baseZh);
6301+
});
6302+
6303+
it("the pair wins — its translation beats the override's own label, top-level and nested", () => {
6304+
const out = run(overridden(), { locale: 'zh-CN', pairBundle: pairOf('zh-CN') });
6305+
for (const s of c.slots) expect(s.read(out), s.what).toBe(pairText(s));
6306+
});
6307+
6308+
it("with no pair translation the override's own label beats the base's translation — in zh-CN and in the source locale", () => {
6309+
for (const locale of ['zh-CN', 'en']) {
6310+
// No pair at all (today's call), and a pair that ships nothing for this item.
6311+
for (const pairBundle of [undefined, { [locale]: {} } as TranslationBundle]) {
6312+
const out = run(overridden(), { locale, pairBundle });
6313+
for (const s of c.slots) expect(s.read(out), `${s.what} · ${locale} · pair ${pairBundle ? 'empty' : 'absent'}`).toBe(overrideText(s));
6314+
}
6315+
}
6316+
});
6317+
6318+
it("a label the override did not change keeps the base's translation, beside one it changed", () => {
6319+
for (const changed of c.slots) {
6320+
const out = run(overridden([changed]), { locale: 'zh-CN', pairBundle: pairOf('zh-CN', [changed]) });
6321+
for (const s of c.slots) {
6322+
expect(s.read(out), `${changed.what} changed · ${s.what}`).toBe(s === changed ? pairText(s) : s.baseZh);
6323+
}
6324+
}
6325+
});
6326+
6327+
it("an untouched label takes the pair's translation where the pair ships one", () => {
6328+
const out = run(clone(base), { locale: 'zh-CN', pairBundle: pairOf('zh-CN') });
6329+
for (const s of c.slots) expect(s.read(out), s.what).toBe(pairText(s));
6330+
});
6331+
6332+
it("per locale: a pair that ships only `en` leaves an untouched label on the base's zh-CN", () => {
6333+
const [changed, ...untouched] = c.slots;
6334+
const out = run(overridden([changed]), { locale: 'zh-CN', fallbackChain: ['en'], pairBundle: pairOf('en') });
6335+
for (const s of untouched) expect(s.read(out), s.what).toBe(s.baseZh);
6336+
// The changed one reads the pair alone, along the chain.
6337+
expect(changed.read(out), changed.what).toBe(pairText(changed));
6338+
});
6339+
6340+
it('no packaged base supplied — nothing counts as changed, so every string reads the pair, then the catalog', () => {
6341+
const [first, ...rest] = c.slots;
6342+
const out = run(overridden(), { locale: 'zh-CN', packagedBase: undefined, pairBundle: pairOf('zh-CN', [first]) });
6343+
expect(first.read(out), first.what).toBe(pairText(first));
6344+
for (const s of rest) expect(s.read(out), s.what).toBe(s.baseZh);
6345+
});
6346+
6347+
it('an empty pairBundle is the same answer as none — byte for byte, with and without a base, in every reading', () => {
6348+
const readings: TranslateDocumentOptions[] = [
6349+
{ locale: 'zh-CN' }, { locale: 'en' }, { locale: 'zh-CN', fallbackChain: ['en'] }, { locale: 'fr-FR' },
6350+
];
6351+
for (const doc of [clone(base), overridden(), overridden(c.slots.slice(0, 1))]) {
6352+
for (const reading of readings) {
6353+
for (const packagedBase of [base, undefined]) {
6354+
const opts = { ...reading, packagedBase };
6355+
const without = translateMetadataDocument(type, doc, bundle, opts);
6356+
for (const pairBundle of [{}, { 'de-DE': { apps: {} } }] as TranslationBundle[]) {
6357+
expect(JSON.stringify(translateMetadataDocument(type, doc, bundle, { ...opts, pairBundle }))).toBe(JSON.stringify(without));
6358+
}
6359+
}
6360+
}
6361+
// No catalog at all: the same answer, and the input reference back
6362+
// wherever the translator hands it back without a pair.
6363+
const bare = translateMetadataDocument(type, doc, undefined, { locale: 'zh-CN', packagedBase: base });
6364+
const withEmpty = translateMetadataDocument(type, doc, undefined, { locale: 'zh-CN', packagedBase: base, pairBundle: {} });
6365+
expect(withEmpty).toStrictEqual(bare);
6366+
if (bare === doc) expect(withEmpty).toBe(doc);
6367+
}
6368+
});
6369+
6370+
it('reads neither input: the served document, the base and the pair come back as they went in', () => {
6371+
const doc = overridden();
6372+
const pair = pairOf('zh-CN');
6373+
const before = JSON.stringify([doc, base, pair, bundle]);
6374+
run(doc, { locale: 'zh-CN', pairBundle: pair });
6375+
expect(JSON.stringify([doc, base, pair, bundle])).toBe(before);
6376+
});
6377+
});
6378+
}
6379+
6380+
describe('page — the header title restates the label it was written against', () => {
6381+
const base = {
6382+
name: 'ops_home',
6383+
label: 'Ops Home',
6384+
regions: [{ name: 'header', components: [{ type: 'page:header', properties: { title: 'Ops Home' } }] }],
6385+
};
6386+
const bundle: TranslationBundle = { 'zh-CN': { pages: { ops_home: { label: '运营首页' } } } as any };
6387+
const title = (doc: any) => doc.regions[0].components[0].properties.title;
6388+
const relabelled = (headerToo: boolean) => {
6389+
const doc = clone(base);
6390+
doc.label = 'Our Ops';
6391+
if (headerToo) doc.regions[0].components[0].properties.title = 'Our Ops';
6392+
return doc;
6393+
};
6394+
6395+
it("a header title the override left as the base wrote it translates as the base's label, though the label changed", () => {
6396+
const out: any = translatePage(relabelled(false), bundle, { locale: 'zh-CN', packagedBase: base });
6397+
expect(out.label).toBe('Our Ops');
6398+
expect(title(out)).toBe('运营首页');
6399+
});
6400+
6401+
it("a header title the override re-authored restates the override's label — its own text, or the pair's translation of it", () => {
6402+
expect(title(translatePage(relabelled(true), bundle, { locale: 'zh-CN', packagedBase: base }))).toBe('Our Ops');
6403+
const pairBundle: TranslationBundle = { 'zh-CN': { pages: { ops_home: { label: '我们的运营' } } } as any };
6404+
const out: any = translatePage(relabelled(true), bundle, { locale: 'zh-CN', packagedBase: base, pairBundle });
6405+
expect(out.label).toBe('我们的运营');
6406+
expect(title(out)).toBe('我们的运营');
6407+
});
6408+
});
6409+
});

0 commit comments

Comments
 (0)