|
| 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