Skip to content

Commit cdbe1c0

Browse files
committed
test(spec): pin a form view's sections translated through objects.OBJECT._sections.NAME
Claude-Session: https://claude.ai/code/session_01HjR8RUwTPQ3TgbfKihmxp9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1555e2 commit cdbe1c0

1 file changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pin: `translateView` localizes a served FORM view's `sections` — each
5+
* section's `label` and `description` — through
6+
* `objects.<object>._sections.<name>`, on the shared locale chain and under
7+
* the same catalog-vs-explicit-override rule as every other string the view
8+
* translator overlays (ADR-0029 D9.2a).
9+
*
10+
* Before this, `TranslationDataSchema` declared the key, the i18n extractor
11+
* scaffolded it and objectui's form renderers resolved it client-side, but no
12+
* exported resolver read it: a server-side reader that has to translate on the
13+
* caller's behalf — the anonymous public form, whose visitor cannot load the
14+
* bundle — served every section heading as authored, beside translated field
15+
* labels.
16+
*
17+
* The served documents below are what `expandViewContainer` emits from an
18+
* authored container (the composer the registrars run), and both the
19+
* container and the bundle's object node are PARSED by the authoring schemas,
20+
* so a section address or a catalog key neither schema carries cannot pass.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { translateView, translateMetadataDocument } from './i18n-resolver';
25+
import { ObjectTranslationDataSchema, type TranslationBundle } from './translation.zod';
26+
import { ViewSchema, expandViewContainer } from '../ui/view.zod';
27+
28+
const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
29+
30+
/** The container as the package ships it. */
31+
const CONTAINER = {
32+
form: {
33+
type: 'simple' as const,
34+
data: { provider: 'object' as const, object: 'crm_lead' },
35+
sections: [
36+
{ name: 'basics', label: 'Basics', description: 'Who is asking', fields: ['name'] },
37+
// No bundle entry — the negative control for a missing key.
38+
{ name: 'notes', label: 'Notes', description: 'Anything else', fields: ['note'] },
39+
// No `name`: no `_sections` key, whatever the bundle carries — including a
40+
// key equal to the slug of its label, which must NOT be read.
41+
{ label: 'Contact Details', description: 'How to reach them', fields: ['email'] },
42+
// Named, but authors neither a heading nor a description.
43+
{ name: 'extra', fields: ['phone'] },
44+
],
45+
},
46+
formViews: {
47+
// Retargeted at another object: its sections key under the form's OWN
48+
// binding, the object the extractor and the linter bind them to.
49+
referral: {
50+
type: 'simple' as const,
51+
data: { provider: 'object' as const, object: 'crm_contact' },
52+
sections: [{ name: 'basics', label: 'Basics', fields: ['name'] }],
53+
},
54+
},
55+
};
56+
57+
const served = (name: string): any => {
58+
const item = expandViewContainer('crm_lead', CONTAINER).find((v) => v.name === name);
59+
if (!item) throw new Error(`fixture missing ${name}`);
60+
return item;
61+
};
62+
const PACKAGED = served('crm_lead.form');
63+
64+
const LEAD_ZH = {
65+
_sections: {
66+
basics: { label: '基本信息', description: '咨询人是谁' },
67+
contact_details: { label: 'MUST-NOT-APPLY', description: 'MUST-NOT-APPLY' },
68+
extra: { label: 'MUST-NOT-APPLY', description: 'MUST-NOT-APPLY' },
69+
},
70+
};
71+
const LEAD_EN = {
72+
_sections: { basics: { label: 'Basics', description: 'Who is asking' } },
73+
};
74+
const CONTACT_ZH = {
75+
_sections: { basics: { label: '联系人基本信息' } },
76+
};
77+
78+
const BUNDLE: TranslationBundle = {
79+
en: { objects: { crm_lead: LEAD_EN } } as any,
80+
'zh-CN': { objects: { crm_lead: LEAD_ZH, crm_contact: CONTACT_ZH } } as any,
81+
};
82+
83+
const section = (view: any, name: string): any => view.config.sections.find((s: any) => s.name === name);
84+
85+
describe('translateView — a form view\'s sections, keyed under `_sections`', () => {
86+
it('composes the identity this test is pinned to — legal fixtures, sections live under `config`', () => {
87+
expect(ViewSchema.safeParse(CONTAINER).success).toBe(true);
88+
for (const node of [LEAD_ZH, LEAD_EN, CONTACT_ZH]) {
89+
expect(ObjectTranslationDataSchema.safeParse(node).success).toBe(true);
90+
}
91+
expect(PACKAGED.viewKind).toBe('form');
92+
expect(PACKAGED.object).toBe('crm_lead');
93+
expect(PACKAGED.sections).toBeUndefined();
94+
expect(PACKAGED.config.sections.map((s: any) => s.name)).toEqual(['basics', 'notes', undefined, 'extra']);
95+
});
96+
97+
it('a zh-CN pack with a `_sections` key yields the translated label and description', () => {
98+
const out = translateMetadataDocument('view', clone(PACKAGED), BUNDLE, { locale: 'zh-CN' });
99+
expect(section(out, 'basics')).toMatchObject({ label: '基本信息', description: '咨询人是谁', fields: ['name'] });
100+
});
101+
102+
it('a missing key yields the authored text', () => {
103+
const out = translateMetadataDocument('view', clone(PACKAGED), BUNDLE, { locale: 'zh-CN' });
104+
expect(section(out, 'notes')).toEqual(PACKAGED.config.sections[1]);
105+
// A key that carries a label but no description leaves the description as authored.
106+
const label = translateMetadataDocument('view', clone(served('crm_lead.referral')), BUNDLE, { locale: 'zh-CN' });
107+
expect(section(label, 'basics').label).toBe('联系人基本信息');
108+
expect(section(label, 'basics').description).toBeUndefined();
109+
});
110+
111+
it('a section with no `name` is left as authored — it has no key, and its label is never slugged into one', () => {
112+
const doc = clone(PACKAGED);
113+
const out = translateView(doc, BUNDLE, { locale: 'zh-CN' }) as any;
114+
expect(out.config.sections[2]).toBe(doc.config.sections[2]);
115+
expect(out.config.sections[2]).toMatchObject({ label: 'Contact Details', description: 'How to reach them' });
116+
});
117+
118+
it('the catalog translates authored copy only — it never adds a heading or a description', () => {
119+
const out = translateView(clone(PACKAGED), BUNDLE, { locale: 'zh-CN' }) as any;
120+
expect(section(out, 'extra').label).toBeUndefined();
121+
expect(section(out, 'extra').description).toBeUndefined();
122+
});
123+
124+
it('an inline locale map heading is authored text and takes the catalog, as a view label does', () => {
125+
const doc = clone(PACKAGED);
126+
doc.config.sections[0].label = { en: 'Basics', 'zh-CN': '基础' };
127+
const out = translateView(doc, BUNDLE, { locale: 'zh-CN' }) as any;
128+
expect(section(out, 'basics').label).toBe('基本信息');
129+
});
130+
131+
it('keys a retargeted form\'s sections under the form\'s own `data.object`', () => {
132+
const referral = served('crm_lead.referral');
133+
expect(referral.object).toBe('crm_lead');
134+
const out = translateView(clone(referral), BUNDLE, { locale: 'zh-CN' }) as any;
135+
// `crm_lead._sections.basics` would read 基本信息; the form binds crm_contact.
136+
expect(section(out, 'basics').label).toBe('联系人基本信息');
137+
});
138+
139+
it('walks the shared locale chain — BCP-47 ladder, declared fallback chain, default locale', () => {
140+
expect(section(translateView(clone(PACKAGED), BUNDLE, { locale: 'zh' }), 'basics').label).toBe('基本信息');
141+
const viaChain = translateView(clone(PACKAGED), BUNDLE, { locale: 'fr-FR', fallbackChain: ['zh-CN'] });
142+
expect(section(viaChain, 'basics').label).toBe('基本信息');
143+
// A request for the default locale never walks the chain: the authored text answers.
144+
const asDefault = translateView(clone(PACKAGED), BUNDLE, {
145+
locale: 'fr-FR', defaultLocale: 'fr-FR', fallbackChain: ['zh-CN'],
146+
});
147+
expect(section(asDefault, 'basics').label).toBe('Basics');
148+
});
149+
150+
it('returns the SAME `config` reference when no section gained a translation, and never mutates its input', () => {
151+
const doc = clone(PACKAGED);
152+
expect((translateView(doc, { 'ja-JP': {} }, { locale: 'ja-JP' }) as any).config).toBe(doc.config);
153+
const before = clone(doc);
154+
translateView(doc, BUNDLE, { locale: 'zh-CN' });
155+
expect(doc).toEqual(before);
156+
});
157+
});
158+
159+
describe('translateView — a form section\'s copy loses to an explicit override (ADR-0029 D9.2a)', () => {
160+
it('an unedited form view gets the catalog for every section string', () => {
161+
const out = translateView(clone(PACKAGED), BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED });
162+
expect(section(out, 'basics')).toMatchObject({ label: '基本信息', description: '咨询人是谁' });
163+
});
164+
165+
it('an edited heading is served as edited — in zh-CN and in the source locale — and judged apart from its description', () => {
166+
const doc = clone(PACKAGED);
167+
doc.config.sections[0].label = 'Basics (edited)';
168+
for (const locale of ['zh-CN', 'en']) {
169+
const out = translateView(doc, BUNDLE, { locale, packagedBase: PACKAGED });
170+
expect(section(out, 'basics').label, locale).toBe('Basics (edited)');
171+
}
172+
expect(section(translateView(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED }), 'basics').description)
173+
.toBe('咨询人是谁');
174+
});
175+
176+
it('a section the package does not ship was authored after the fact and keeps its strings', () => {
177+
const doc = clone(PACKAGED);
178+
doc.config.sections[0].name = 'contact_details';
179+
doc.config.sections[0].label = 'Added by the org';
180+
const out = translateView(doc, BUNDLE, { locale: 'zh-CN', packagedBase: PACKAGED });
181+
expect(section(out, 'contact_details').label).toBe('Added by the org');
182+
});
183+
184+
it('NO packaged base supplied → the catalog applies (the control)', () => {
185+
const doc = clone(PACKAGED);
186+
doc.config.sections[0].label = 'Basics (edited)';
187+
expect(section(translateView(doc, BUNDLE, { locale: 'zh-CN' }), 'basics').label).toBe('基本信息');
188+
});
189+
});

0 commit comments

Comments
 (0)