Skip to content

Commit 59775a0

Browse files
fix(spec): a published fieldPatches label is served by every object read, not only /layers (#29624)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8Mwj3s3TJDLo1t9qXkT1euk)_ Fixes #29564 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`translateObject` 的字段 `label` 与选项 `label` 两个槽位改走既有的 `valueOverridesPackagedBase` 比较,与 `description` / `inlineHelpText` / `inlineTitle` 及对象三标量同一条规则。 - 为什么改:对象扩展发布的 `fieldPatches` 改了字段标签后,只有 `/layers` 能看到,无包名读、`?package=` 基础包读和列表读都被目录(catalog)里的出厂标签盖回去了。 - 风险与回滚:租户覆盖层对字段标签或选项标签的改名,从此也会压过目录翻译(与对象改名 #8284 同理);回滚即还原 `i18n-resolver.ts` 的两处判断。 - 先看:`packages/spec/src/system/i18n-resolver.ts` 的 `translateField`(两处新判断与选项基线的取法),再看 `packages/rest/src/meta-object-field-patch-i18n.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/meta-object-field-patch-i18n.test.ts`(先建 spec),预期 7/7 通过,补丁标签 `Headline` / `Queued` 在 zh-CN 与 en 两个语言下都原样返回。 - 需要你做的:无,席位落地。 ## What changes - `packages/spec/src/system/i18n-resolver.ts`, `translateObject` → `translateField`: - The field `label` (catalog, then the built-in system-field label) is applied only while `valueOverridesPackagedBase(packagedField, 'label', def.label)` is false, i.e. only while the served label still equals the same field's label in `packagedBase`. - Each option label is applied only while it still equals the label of the packaged field's option with the same `value` (found with the existing `packagedPart` finder, matched by `String(value)` as the dashboard filter options are). - A picklist-bound served field (`picklist` set) is judged against no field-level baseline: its options are the list's, resolved onto the served field on every read (`resolvePicklistFieldsOnto`) and never present on the packaged declaration, so the catalog (field-level, then `picklists.LIST`) applies to them exactly as before. The registry fold never applies an option patch to a picklist-bound field either. - The docblock paragraph that said a field label stays a flat `catalog ?? document` now states the rule above. - One comparison, no second application: nothing re-applies the patch after translation; the two slots ask the same predicate their siblings ask. ## Why ADR-0146 D10 admits a base field's `label` and option `label` through an object extension's `fieldPatches`, and the registry folds the patch onto every read. The served `/meta` exit (`packages/rest/src/meta-item-read-gate.ts`, `translateMetaDocument` / `translateMetaList`) localizes the folded body with `translateMetadataDocument('object', …)`, which wrote the catalog's field label and option labels unconditionally. The showcase catalog carries an `en` entry for every `showcase_task` field, so the shipped text replaced the patch on the bare, `?package=` and list reads (reproduced on `main` `50e35a28`, report 6588058932). The objectql pin reads registry bodies directly and never runs this localization, which is why it stayed green. ## Tests - `packages/spec/src/system/i18n-resolver.test.ts`: new block "translateObject — a field label and its option labels yield to a diverged value" (9 cases: the patched label and option through the type dispatch in `en` and `zh-CN`; per-field and per-option judgment; untouched document; no base; absent or empty served label; a field or option the base does not declare; a picklist-bound field keeps the list's labels; an injected system field's built-in label; no input mutation). The old case "leaves FIELD labels alone — the rule is scoped to the three scalars" pinned exactly the branch this changes, so it is replaced by "judges a FIELD label by the same rule". - `packages/rest/src/meta-object-field-patch-i18n.test.ts` (new, 7 cases): a REAL `SchemaRegistry` with an owner and a `fieldPatches` extender; the protocol double's `getPackagedObjectBase` reads the registry. It drives `translateMetaDocument` and `translateMetaList` and asserts the patched field label, patched description and patched option label in `zh-CN` and `en`, the untouched siblings still translated, and the no-extension control. ## Acceptance notes - Side effect, by the #8284 rule: a tenant overlay's rename of a field label or option label is now served over the catalog too, the way an object rename already is. - A field the packaged base does not declare (an extension's own field) counts as diverged on every slot, as its `description` / `inlineHelpText` slots already did. So a catalog entry for such a field's label is no longer served. In this repository no producer writes one: the i18n extractor walks `objects` only, and no generated bundle carries an extension field. Named here because a third-party bundle could. - Not changed, noted only: `formula-option-labels.ts` (objectql) and the analytics dimension labels call `translateObject` without a `packagedBase`, so they keep applying the catalog unconditionally (no base means the rule infers nothing). Not measured to diverge on a public read. ## Verification Head `6d0f0e01`, base `origin/main` `81cd9eb0`. <details><summary>Ablation (both slots), each leg rebuilt and proved in dist</summary> Each leg: `node scripts/ablation-replace.mjs` (anchor 1 → 0, blob changed), `pnpm --filter @objectstack/spec build`, `node scripts/ablation-dist-preflight.mjs @objectstack/spec MARKER` (marker present in 2 built files), then both pins; the tool restored the source (blob equals HEAD, `git diff HEAD` empty). - Label slot (the guard made always-true): spec pin 3 failed / 389 passed; REST pin 4 failed / 3 passed. - Option slot (the early return disabled): spec pin 2 failed / 390 passed; REST pin 4 failed / 3 passed. - Restore: spec rebuilt, preflight `--absent` for both markers (absent from all 588 built files, tree clean); spec pin 392/392, REST pin 7/7. </details> <details><summary>Package suites</summary> - `pnpm --filter @objectstack/spec test`: 801 files, 23707 passed, 1 todo. `pnpm --filter @objectstack/spec typecheck`: green (test layer OK). - `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2`: 384 files, 7402 passed, 352 skipped. `pnpm --filter @objectstack/rest typecheck`: green. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `6d0f0e01`: 79 families. Reconciled with `--ran`: 79 accounted, 78 run with exit 0, 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` exit 3 (prerequisite: the whole workspace's `dist/` is not built in this container; left to CI). `check-plugin-teardown-shape.mjs --self-test` first exited 3 on the shallow clone; after fetching its pinned fixture commit it exits 0 (48 cases). Local-only: `check:skill-frame-freshness` 0; `check:dev-prereqs` refuses (35 workspace packages unbuilt, only the rest closure was built). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran; `pnpm lint` was not run locally, it is CI's run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (none were stale: the derived spec checks are green) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbX91BzYkHsRob9kBV8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4008e62 commit 59775a0

4 files changed

Lines changed: 376 additions & 16 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+
fix(spec): a served object keeps a field label and option labels that diverged from the packaged field, instead of overwriting them with the catalog
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`translateObject` (and so `translateMetadataDocument('object', …)`, which the REST `/meta` reads call) wrote the catalog's field `label` and option labels over every served field. A published object extension's `fieldPatches` label (ADR-0146 D10) was therefore served only by `GET /meta/object/:name/layers`; the read naming no package, the read naming the base and the list read all served the shipped label.
11+
12+
The two slots now follow the rule the field's `description`, `inlineHelpText` and `inlineTitle` and the object's own `label`, `pluralLabel` and `description` already follow (ADR-0029 D9.2a): the catalog applies only while the served value still equals the same field's, or the same option's, in the packaged base. With no packaged base the catalog applies as before. A picklist-bound field's options are the list's and keep the list's translations. A field the packaged base does not declare keeps its own label, as its other slots already did.
13+
14+
Side effect: a tenant overlay's rename of a field label, or of an option label, is now served over the catalog as well, the same way an object rename has been.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0146 D10 · ADR-0029 D9.2a] A published object extension's
5+
* `fieldPatches` label survives the served `/meta` exit.
6+
*
7+
* The registry folds the patch onto every read of the base object
8+
* (`registry-extension-field-patches.test.ts` pins that), but that pin reads
9+
* registry bodies directly and never runs the localization this package
10+
* applies on the way out. Measured on the booted showcase before the fix: the
11+
* read naming no package, the read naming the base and the type's list all
12+
* served the shipped field `label` and option label, because the catalog's
13+
* entries (`objects.<object>.fields.<field>.label` / `.options.<value>`) were
14+
* written over the folded body unconditionally; only the untranslated
15+
* `/layers` showed the patch, and a patched `description` was served, since
16+
* that slot already compared against the packaged field.
17+
*
18+
* So this pin runs the exit itself — {@link translateMetaDocument} (the item
19+
* read) and {@link translateMetaList} (the list) — over a REAL
20+
* `SchemaRegistry` carrying an owner and a patching extender, with the
21+
* packaged base read the way the protocol reads it
22+
* (`getPackagedObjectBase`, the owner's declaration before the fold). The
23+
* rule itself is spec's (`i18n-resolver.test.ts`); what only this layer can
24+
* show is that the folded body and the pre-fold base both reach it.
25+
*/
26+
27+
import { describe, it, expect } from 'vitest';
28+
import { SchemaRegistry } from '@objectstack/objectql';
29+
import { translateMetaDocument, translateMetaList, type MetaListTranslationSources } from './meta-item-read-gate.js';
30+
31+
const OWNER_PKG = 'com.example.showcase';
32+
const EXT_PKG = 'com.example.showcase.custom';
33+
const OBJ = 'showcase_task';
34+
35+
function owner(): any {
36+
return {
37+
name: OBJ,
38+
label: 'Task',
39+
fields: {
40+
title: { name: 'title', type: 'text', label: 'Title', description: 'What the task is.' },
41+
status: {
42+
name: 'status',
43+
type: 'select',
44+
label: 'Status',
45+
options: [
46+
{ value: 'todo', label: 'To Do' },
47+
{ value: 'done', label: 'Done' },
48+
],
49+
},
50+
},
51+
};
52+
}
53+
54+
/** The extension's patch, the shape the dev report measured on the showcase. */
55+
const PATCH = {
56+
title: { label: 'Headline', description: 'Patched description' },
57+
status: { options: { todo: { label: 'Queued' } } },
58+
};
59+
60+
/** `en` repeats the packaged strings (what `i18n:extract` writes); `zh-CN` translates them. */
61+
const BUNDLE: Record<string, any> = {
62+
en: {
63+
objects: {
64+
[OBJ]: {
65+
label: 'Task',
66+
fields: {
67+
title: { label: 'Title' },
68+
status: { label: 'Status', options: { todo: 'To Do', done: 'Done' } },
69+
},
70+
},
71+
},
72+
},
73+
'zh-CN': {
74+
objects: {
75+
[OBJ]: {
76+
label: '任务',
77+
fields: {
78+
title: { label: '标题' },
79+
status: { label: '状态', options: { todo: '待办', done: '已完成' } },
80+
},
81+
},
82+
},
83+
},
84+
};
85+
86+
const i18nService = {
87+
getLocales: () => ['en', 'zh-CN'],
88+
getTranslations: (locale: string) => BUNDLE[locale],
89+
getDefaultLocale: () => 'en',
90+
};
91+
92+
function registry(withPatch: boolean): SchemaRegistry {
93+
const reg = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
94+
reg.logLevel = 'silent';
95+
reg.registerObject(owner(), OWNER_PKG, undefined, 'own');
96+
if (withPatch) reg.registerObject({ name: OBJ, fieldPatches: PATCH } as any, EXT_PKG, undefined, 'extend', 200);
97+
return reg;
98+
}
99+
100+
/** The transport's sources, with the protocol's packaged-base accessor over the registry. */
101+
function sources(reg: SchemaRegistry, locale: string): MetaListTranslationSources {
102+
const protocol = { getPackagedObjectBase: (name: string) => reg.getPackagedObjectBase(name) };
103+
return {
104+
resolveI18nService: async () => i18nService,
105+
resolveProtocol: async () => protocol,
106+
requestLocale: () => locale,
107+
};
108+
}
109+
110+
/** The served (folded) body, as the protocol's reads hand it to the exit. */
111+
const served = (reg: SchemaRegistry) => structuredClone(reg.getObject(OBJ)) as any;
112+
113+
const titleOf = (doc: any) => doc.fields.title;
114+
const todoOf = (doc: any) => doc.fields.status.options.find((o: any) => o.value === 'todo');
115+
const doneOf = (doc: any) => doc.fields.status.options.find((o: any) => o.value === 'done');
116+
117+
describe('a published fieldPatches label survives the served /meta exit', () => {
118+
it('the registry folds the patch, and the packaged base is the pre-fold owner', () => {
119+
// The premise of every case below, read off the same registry: if
120+
// either half moved, the cases would pass or fail for another reason.
121+
const reg = registry(true);
122+
expect(titleOf(served(reg)).label).toBe('Headline');
123+
expect(todoOf(served(reg)).label).toBe('Queued');
124+
const base: any = reg.getPackagedObjectBase(OBJ);
125+
expect(base.fields.title.label).toBe('Title');
126+
expect(base.fields.status.options[0].label).toBe('To Do');
127+
});
128+
129+
for (const locale of ['zh-CN', 'en']) {
130+
it(`the item read serves the patched field and option labels (${locale})`, async () => {
131+
const reg = registry(true);
132+
const out: any = await translateMetaDocument(sources(reg, locale), 'object', served(reg));
133+
expect(titleOf(out).label).toBe('Headline');
134+
expect(titleOf(out).description).toBe('Patched description');
135+
expect(todoOf(out).label).toBe('Queued');
136+
});
137+
138+
it(`the list read serves them too (${locale})`, async () => {
139+
const reg = registry(true);
140+
const out: any = await translateMetaList(sources(reg, locale), 'object', [served(reg)]);
141+
expect(titleOf(out[0]).label).toBe('Headline');
142+
expect(todoOf(out[0]).label).toBe('Queued');
143+
});
144+
}
145+
146+
it('the slots the patch leaves alone are still translated', async () => {
147+
const reg = registry(true);
148+
const out: any = await translateMetaDocument(sources(reg, 'zh-CN'), 'object', served(reg));
149+
expect(out.label).toBe('任务');
150+
expect(out.fields.status.label).toBe('状态');
151+
expect(doneOf(out).label).toBe('已完成');
152+
});
153+
154+
it('with no extension, the shipped labels are translated as before', async () => {
155+
const reg = registry(false);
156+
const out: any = await translateMetaDocument(sources(reg, 'zh-CN'), 'object', served(reg));
157+
expect(titleOf(out).label).toBe('标题');
158+
expect(todoOf(out).label).toBe('待办');
159+
});
160+
});

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

Lines changed: 167 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3878,11 +3878,13 @@ describe('translateObject — catalog vs explicit override', () => {
38783878
expect(zh({ name: 'showcase_account', label: '' }, PACKAGED).label).toBe('客户');
38793879
});
38803880

3881-
it('leaves FIELD labels alone — the rule is scoped to the three scalars', () => {
3882-
// Field labels have their own per-field catalog keys and no measured
3883-
// divergence; widening the rule to them would be a second change riding
3884-
// this one.
3885-
const withFields = {
3881+
it('judges a FIELD label by the same rule, against the same field in the packaged base', () => {
3882+
// A field `label` is authored on top of the packaged declaration too — an
3883+
// extension's `fieldPatches` label, a tenant's rename — so the scalar rule
3884+
// reaches it, judged per field. The full truth table is the field-label
3885+
// block below.
3886+
const packaged = { ...PACKAGED, fields: { industry: { name: 'industry', type: 'select', label: 'Industry' } } };
3887+
const served = {
38863888
...PACKAGED,
38873889
label: 'Customer',
38883890
fields: { industry: { name: 'industry', type: 'select', label: 'Vertical' } },
@@ -3894,9 +3896,9 @@ describe('translateObject — catalog vs explicit override', () => {
38943896
},
38953897
} as any,
38963898
};
3897-
const out = translateObject(withFields, bundle, { locale: 'zh-CN', packagedBase: PACKAGED });
3899+
const out = translateObject(served, bundle, { locale: 'zh-CN', packagedBase: packaged });
38983900
expect(out.label).toBe('Customer');
3899-
expect((out.fields as any).industry.label).toBe('行业');
3901+
expect((out.fields as any).industry.label).toBe('Vertical');
39003902
});
39013903

39023904
it('does not mutate either input document', () => {
@@ -3921,6 +3923,164 @@ describe('translateObject — catalog vs explicit override', () => {
39213923
});
39223924
});
39233925

3926+
// ────────────────────────────────────────────────────────────────────────────
3927+
// translateObject — a field label and its option labels yield to a diverged
3928+
// value (ADR-0029 D9.2a, ADR-0146 D10 `fieldPatches`)
3929+
// ────────────────────────────────────────────────────────────────────────────
3930+
3931+
/**
3932+
* A published object extension's `fieldPatches` label (ADR-0146 D10) is folded
3933+
* onto every read of the base object, but the served read localized the
3934+
* folded body with the catalog's field `label` and option labels written
3935+
* unconditionally, so the bare, base-named and list reads kept the shipped
3936+
* text and only the untranslated `/layers` showed the patch. The two slots now
3937+
* follow the comparison their sibling slots (`description`, `inlineHelpText`,
3938+
* `inlineTitle`) and the object scalars already follow.
3939+
*/
3940+
describe('translateObject — a field label and its option labels yield to a diverged value', () => {
3941+
/** The packaged declaration: the owner's fields, as the owner contributor holds them. */
3942+
const PACKAGED = {
3943+
name: 'showcase_task',
3944+
label: 'Task',
3945+
fields: {
3946+
title: { name: 'title', type: 'text', label: 'Title' },
3947+
status: {
3948+
name: 'status',
3949+
type: 'select',
3950+
label: 'Status',
3951+
options: [
3952+
{ value: 'todo', label: 'To Do' },
3953+
{ value: 'done', label: 'Done' },
3954+
],
3955+
},
3956+
created_at: { name: 'created_at', type: 'datetime', label: 'Created At' },
3957+
},
3958+
};
3959+
3960+
/** `en` repeats the packaged strings (what `i18n:extract` writes); `zh-CN` translates them. */
3961+
const BUNDLE: TranslationBundle = {
3962+
en: {
3963+
objects: {
3964+
showcase_task: {
3965+
label: 'Task',
3966+
fields: {
3967+
title: { label: 'Title' },
3968+
status: { label: 'Status', options: { todo: 'To Do', done: 'Done' } },
3969+
},
3970+
},
3971+
},
3972+
} as any,
3973+
'zh-CN': {
3974+
objects: {
3975+
showcase_task: {
3976+
label: '任务',
3977+
fields: {
3978+
title: { label: '标题' },
3979+
status: { label: '状态', options: { todo: '待办', done: '已完成' } },
3980+
},
3981+
},
3982+
},
3983+
} as any,
3984+
};
3985+
3986+
const clone = <T>(value: T): T => structuredClone(value);
3987+
const fieldsOf = (out: unknown) => (out as { fields: Record<string, any> }).fields;
3988+
/** The served body after an extension's `fieldPatches` relabelled `title` and the `todo` option. */
3989+
const patched = () => {
3990+
const served = clone(PACKAGED) as any;
3991+
served.fields.title.label = 'Headline';
3992+
served.fields.status.options[0].label = 'Queued';
3993+
return served;
3994+
};
3995+
3996+
it('keeps a patched field label and option label, in every locale, through the type dispatch', () => {
3997+
// The REST exit reaches `translateObject` through the dispatcher, so the
3998+
// base must arrive there for the comparison to be made at all.
3999+
for (const locale of ['en', 'zh-CN']) {
4000+
const fields = fieldsOf(
4001+
translateMetadataDocument('object', patched(), BUNDLE, { locale, packagedBase: PACKAGED }),
4002+
);
4003+
expect(fields.title.label).toBe('Headline');
4004+
expect(fields.status.options[0].label).toBe('Queued');
4005+
}
4006+
});
4007+
4008+
it('judges per field and per option: the undiverged siblings are still translated', () => {
4009+
const fields = fieldsOf(translateObject(patched(), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }));
4010+
expect(fields.status.label).toBe('状态');
4011+
expect(fields.status.options[1].label).toBe('已完成');
4012+
});
4013+
4014+
it('an untouched document still gets the catalog on both slots', () => {
4015+
const fields = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }));
4016+
expect(fields.title.label).toBe('标题');
4017+
expect(fields.status.options.map((o: any) => o.label)).toEqual(['待办', '已完成']);
4018+
});
4019+
4020+
it('with no packaged base, the catalog applies — "unknown" is not "authored"', () => {
4021+
for (const packagedBase of [undefined, null]) {
4022+
const fields = fieldsOf(translateObject(patched(), BUNDLE, { locale: 'zh-CN', packagedBase }));
4023+
expect(fields.title.label).toBe('标题');
4024+
expect(fields.status.options[0].label).toBe('待办');
4025+
}
4026+
});
4027+
4028+
it('an absent or empty served label is not an override — the catalog fills it', () => {
4029+
const served = clone(PACKAGED) as any;
4030+
delete served.fields.title.label;
4031+
served.fields.status.options[0].label = '';
4032+
const fields = fieldsOf(translateObject(served, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }));
4033+
expect(fields.title.label).toBe('标题');
4034+
expect(fields.status.options[0].label).toBe('待办');
4035+
});
4036+
4037+
it('a field or option the packaged base does not declare counts as diverged', () => {
4038+
const served = clone(PACKAGED) as any;
4039+
served.fields.extra = { name: 'extra', type: 'text', label: 'Extra' };
4040+
served.fields.status.options.push({ value: 'blocked', label: 'Blocked' });
4041+
const bundle = clone(BUNDLE) as any;
4042+
bundle['zh-CN'].objects.showcase_task.fields.extra = { label: '额外' };
4043+
bundle['zh-CN'].objects.showcase_task.fields.status.options.blocked = '受阻';
4044+
const fields = fieldsOf(translateObject(served, bundle, { locale: 'zh-CN', packagedBase: PACKAGED }));
4045+
expect(fields.extra.label).toBe('Extra');
4046+
expect(fields.status.options[2].label).toBe('Blocked');
4047+
});
4048+
4049+
it("a picklist-bound field's options have no field-level baseline — the list's labels still apply", () => {
4050+
// The served options are the LIST's, resolved onto the served field on
4051+
// every read; the packaged field names the list and carries none.
4052+
const packaged = {
4053+
...PACKAGED,
4054+
fields: { ...PACKAGED.fields, stage: { name: 'stage', type: 'select', label: 'Stage', picklist: 'task_stage' } },
4055+
};
4056+
const served = clone(packaged) as any;
4057+
served.fields.stage.options = [{ value: 'new', label: 'New' }];
4058+
const bundle: TranslationBundle = {
4059+
'zh-CN': { picklists: { task_stage: { options: { new: '新建' } } } } as any,
4060+
};
4061+
const fields = fieldsOf(translateObject(served, bundle, { locale: 'zh-CN', packagedBase: packaged }));
4062+
expect(fields.stage.options[0].label).toBe('新建');
4063+
});
4064+
4065+
it('an undiverged injected system field keeps its built-in label; a diverged one keeps its text', () => {
4066+
const untouched = fieldsOf(translateObject(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }));
4067+
expect(untouched.created_at.label).toBe('创建时间');
4068+
const served = clone(PACKAGED) as any;
4069+
served.fields.created_at.label = 'Logged At';
4070+
const renamed = fieldsOf(translateObject(served, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }));
4071+
expect(renamed.created_at.label).toBe('Logged At');
4072+
});
4073+
4074+
it('does not mutate either input document', () => {
4075+
const served = patched();
4076+
const before = clone(served);
4077+
const base = clone(PACKAGED);
4078+
translateObject(served, BUNDLE, { locale: 'zh-CN', packagedBase: base });
4079+
expect(served).toEqual(before);
4080+
expect(base).toEqual(PACKAGED);
4081+
});
4082+
});
4083+
39244084
// ────────────────────────────────────────────────────────────────────────────
39254085
// translateObject — a field's translated help is served on `description` (#21948)
39264086
// and on an authored `inlineHelpText` (#29153)

0 commit comments

Comments
 (0)