/** * i18n emission — SELF-SUFFICIENT merged catalogue (ctx.existingI18n) + * collision-free key scheme (placeholders / options / actionParams). * * Regression net for the AtlasHub PROJECTS/BUDGETS incident (2026-08-04): * 7-entity module shipped with ONE entity in budgets.json (144/440 keys) and * PRD-authored labels reverted to the generic floor ("Gérer les project * budgets") — the per-view pipeline's later calls clobbered earlier calls' * PRD text because the floor of EVERY view ships on EVERY call. */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import type { ScaffoldComponentInput, PageSpecMin } from '../types.js' import { extractTCalls, resolveI18nKey, LOCALES } from '../../../../../../lib/i18n-keys.js' function fixture(overrides: Partial = {}): ScaffoldComponentInput { return { module: 'crm', appCode: 'TestV2', entity: 'Contact', section: 'directory', views: ['list', 'detail', 'form'], fields: [ { name: 'firstName', type: 'string', required: true }, { name: 'lastName', type: 'string', required: true }, { name: 'email', type: 'string', required: false }, ], projectPath: '/web', ...overrides, } } const localeJson = (files: ReturnType, loc: string) => JSON.parse(files.find((f) => new RegExp(`/i18n/locales/${loc}/crm\\.json$`).test(f.path))!.content) describe('scaffold-component / i18n emission — self-sufficient merge (ctx.existingI18n)', () => { it('carries sibling entity roots verbatim when scaffolding a second entity of the module', () => { const company = { list: { title: 'Sociétés', subtitle: 'Suivi des sociétés' }, custom: { note: 'gardé' } } const files = generate(fixture(), { existingI18n: { fr: { company } } }) const fr = localeJson(files, 'fr') // Both roots present — the historical failure shipped ONLY the last entity. expect(Object.keys(fr).sort()).toEqual(['company', 'contact']) // The sibling subtree is byte-identical (never flattened, never floored). expect(fr.company).toEqual(company) }) it('a form-view call preserves an existing PRD list.subtitle over its own floor (cross-view clobber)', () => { const files = generate( fixture({ views: ['form'] }), { existingI18n: { fr: { contact: { list: { subtitle: 'Suivi des contacts clients' } } } } }, ) const fr = localeJson(files, 'fr') // The AtlasHub symptom: the form call's view-agnostic floor used to revert // this to "Gérer les contacts" through the writer's incoming-wins merge. expect(fr.contact.list.subtitle).toBe('Suivi des contacts clients') // The floor still fills this call's own gaps. expect(fr.contact.form.createTitle).toBeDefined() }) it('the current call PRD i18nKeys win over both the floor and the existing catalogue', () => { const files = generate( fixture({ pageSpec: { i18nKeys: { fr: { 'list.subtitle': 'Annuaire à jour' } } } as unknown as PageSpecMin }), { existingI18n: { fr: { contact: { list: { subtitle: 'Ancien texte' } } } } }, ) expect(localeJson(files, 'fr').contact.list.subtitle).toBe('Annuaire à jour') }) it('a legacy stub spec (id-only fields, no pageSpec) never downgrades an existing rich entity subtree', () => { // Mirrors the audit-dev-frontend / ui-polish apply re-scaffold stubs: no // pageSpec, minimal fields. Under existing-wins-over-floor the thin floor // only ADDS absent structural keys — it can no longer revert PRD text. const files = generate( fixture({ fields: [{ name: 'id', type: 'string', required: true }] }), { existingI18n: { fr: { contact: { list: { subtitle: 'Suivi des contacts clients' }, custom: { confirmArchive: 'Archiver ce contact ?' }, } } } }, ) const fr = localeJson(files, 'fr') expect(fr.contact.list.subtitle).toBe('Suivi des contacts clients') expect(fr.contact.custom.confirmArchive).toBe('Archiver ce contact ?') // The stub's own floor still lands where nothing existed. expect(fr.contact.list.columns.id).toBeDefined() }) it('without existingI18n context the emission is unchanged (first-run regression)', () => { const bare = localeJson(generate(fixture()), 'fr') const withEmptyCtx = localeJson(generate(fixture(), {}), 'fr') expect(withEmptyCtx).toEqual(bare) expect(Object.keys(bare)).toEqual(['contact']) }) }) describe('scaffold-component / i18n key scheme — labels are leaves, channels are siblings', () => { it('plain input placeholders read the form.placeholders sibling channel (dead key removed)', () => { const files = generate(fixture({ views: ['form'] })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!.content // `form.fields..placeholder` could NEVER ship (nested under the label // leaf, dropped by flatToNested) — the input placeholder was forever empty. expect(form).toMatch(/placeholder=\{t\('contact\.form\.placeholders\.firstName', \{ defaultValue: '' \}\)\}/) expect(form).not.toMatch(/form\.fields\.firstName\.placeholder/) }) const payloadActionSpec = () => ({ screenCode: 'SCR-CONTACT-LIST', view: 'list', filePath: 'src/pages/testv2/crm/directory/ContactsListPage.tsx', actions: [ { code: 'transferer', kind: 'api', scope: 'row', endpoint: 'transferer', httpMethod: 'POST', labelKey: 'list.actions.transferer', permission: 'crm.directory.update', ucReference: 'UC-CRM-001', payloadType: 'TransfererPayload', payloadParameters: [ { name: 'targetId', type: 'lookup', entity: 'Contact', module: 'crm', required: true }, { name: 'mode', type: 'select', options: [{ value: 'MUTATION' }, { value: 'internalTransfer' }] }, ] }, ], i18nKeys: { fr: { 'list.actions.transferer': 'Transférer' }, en: { 'list.actions.transferer': 'Transfer' }, it: { 'list.actions.transferer': 'Trasferire' }, de: { 'list.actions.transferer': 'Übertragen' }, }, }) as unknown as PageSpecMin it('action param labels read {root}.actionParams..

with a per-locale humanised floor', () => { const files = generate(fixture({ views: ['list'], pageSpec: payloadActionSpec() })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!.content // Sibling branch in the TSX — never nested under the button-label leaf. expect(list).toMatch(/label: t\('contact\.list\.actionParams\.transferer\.targetId', \{ defaultValue: 'targetId' \}\)/) expect(list).toMatch(/t\('contact\.list\.actionParamOptions\.transferer\.mode\.MUTATION', \{ defaultValue: 'MUTATION' \}\)/) const fr = localeJson(files, 'fr') // Button label (PRD) and param labels (floor) COEXIST — the collision that // used to force one branch to lose. expect(fr.contact.list.actions.transferer).toBe('Transférer') // Lookup param floors as the TARGET name ("Target", never "Target Id"), // per-locale through FIELD_NAME_FLOOR when the name is ubiquitous. expect(fr.contact.list.actionParams.transferer.targetId).toBe('Target') expect(fr.contact.list.actionParams.transferer.mode).toBe('Mode') }) it('select-param option floor never humanizes an ALL-CAPS code; identifier-like codes humanise', () => { const files = generate(fixture({ views: ['list'], pageSpec: payloadActionSpec() })) const fr = localeJson(files, 'fr') expect(fr.contact.list.actionParamOptions.transferer.mode.MUTATION).toBe('MUTATION') expect(fr.contact.list.actionParamOptions.transferer.mode.internalTransfer).toBe('Internal Transfer') }) it('legacy actions..params.

(.options.) PRD keys remap to actionParams / actionParamOptions', () => { const ps = payloadActionSpec() as unknown as { i18nKeys: Record> } ps.i18nKeys.fr = { 'list.actions.transferer': 'Transférer', 'list.actions.transferer.params.targetId': 'Contact cible', 'list.actions.transferer.params.mode.options.MUTATION': 'Mutation', } const files = generate(fixture({ views: ['list'], pageSpec: ps as unknown as PageSpecMin })) const fr = localeJson(files, 'fr') // All three coexist — authored in the abandoned nested scheme, shipped in // the sibling-branch scheme. expect(fr.contact.list.actions.transferer).toBe('Transférer') expect(fr.contact.list.actionParams.transferer.targetId).toBe('Contact cible') expect(fr.contact.list.actionParamOptions.transferer.mode.MUTATION).toBe('Mutation') }) it('an explicit legacy p.labelKey override lands on the same remapped path in TSX and catalogue', () => { const ps = payloadActionSpec() as unknown as { actions: Array<{ payloadParameters: Array> }> i18nKeys: Record> } ps.actions[0]!.payloadParameters[0]!.labelKey = 'list.actions.transferer.params.targetId' ps.i18nKeys.fr['list.actions.transferer.params.targetId'] = 'Contact cible' const files = generate(fixture({ views: ['list'], pageSpec: ps as unknown as PageSpecMin })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!.content expect(list).toMatch(/label: t\('contact\.list\.actionParams\.transferer\.targetId'/) expect(localeJson(files, 'fr').contact.list.actionParams.transferer.targetId).toBe('Contact cible') }) it('INVARIANT: every bare t() key of every generated page resolves to a STRING in all 4 locales', () => { // Rich fixture: 3 views, enum (segmented), FK lookup, sections, payload // action with lookup + select params. Every t() WITHOUT defaultValue must // resolve to a string leaf — 'missing' is a raw key on screen, 'object' is // the label/children collision this whole scheme exists to kill. const files = generate(fixture({ fields: [ { name: 'firstName', type: 'string', required: true, section: 'identity' }, { name: 'status', type: 'enum', required: true, options: [{ value: 'open', label: 'Open' }, { value: 'closed', label: 'Closed' }] }, { name: 'organisationId', type: 'guid', required: true, fkTo: { entity: 'Organisation', module: 'crm', apiEndpoint: '/api/crm/organisations/lookup' } }, { name: 'notes', type: 'string', required: false, control: 'textarea' }, ], pageSpec: payloadActionSpec(), })) const pages = files.filter((f) => f.path.endsWith('.tsx')) expect(pages.length).toBeGreaterThan(0) const offenders: string[] = [] for (const page of pages) { for (const call of extractTCalls(page.content, 'crm')) { if (call.hasDefaultValue) continue if (call.namespace !== 'crm') continue // SDK namespaces (common:…) are out of scope for (const locale of LOCALES) { const tree = localeJson(files, locale) const res = resolveI18nKey(tree, call.key) if (res !== 'string') offenders.push(`${page.path}:${call.line} ${call.key} → ${res} (${locale})`) } } } expect(offenders).toEqual([]) }) }) describe('scaffold-component / i18n emission — coded-entity business label (libellé « Référence »)', () => { const codedFixture = (codedEntity: unknown) => fixture({ fields: [ { name: 'code', type: 'string', required: false, readonly: true }, { name: 'label', type: 'string', required: true }, ], codedEntity: codedEntity as never, }) it('the label facet beats the generic floor in all 4 catalogues (fr=fr, en/it/de=en??fr)', () => { const files = generate(codedFixture({ label: { fr: 'Référence', en: 'Reference' } }), {}) expect(localeJson(files, 'fr').contact.list.columns.code).toBe('Référence') expect(localeJson(files, 'en').contact.list.columns.code).toBe('Reference') expect(localeJson(files, 'it').contact.list.columns.code).toBe('Reference') expect(localeJson(files, 'de').contact.list.columns.code).toBe('Reference') }) it('a label authored in ONE language still beats the floor everywhere (fr-only → de reads fr)', () => { const files = generate(codedFixture({ label: { fr: 'Référence' } }), {}) expect(localeJson(files, 'de').contact.list.columns.code).toBe('Référence') }) it('the PRD i18nKeys stay sovereign over the facet (floor < existing < PRD)', () => { const files = generate( { ...codedFixture({ label: { fr: 'Référence' } }), pageSpec: { screenCode: 'SCR-X', module: 'crm', appCode: 'testv2', section: 'directory', entity: 'Contact', view: 'list', filePath: 'pagespecs/Contact.list.md', permission: 'crm.directory.read', specHash: 'x', i18nKeys: { fr: { 'list.columns.code': 'N° dossier' }, en: {}, it: {}, de: {} }, } as never, }, {}, ) expect(localeJson(files, 'fr').contact.list.columns.code).toBe('N° dossier') }) it('without the facet the generic floor is untouched (regression pin — boolean flag)', () => { const withBool = generate(codedFixture(true), {}) const without = generate(fixture({ fields: [ { name: 'code', type: 'string', required: false, readonly: true }, { name: 'label', type: 'string', required: true }, ] }), {}) expect(localeJson(withBool, 'fr').contact.list.columns.code) .toBe(localeJson(without, 'fr').contact.list.columns.code) }) it('the facet labels ONLY the code field — other fields keep their floor', () => { const files = generate(codedFixture({ label: { fr: 'Référence' } }), {}) expect(localeJson(files, 'fr').contact.list.columns.label).not.toBe('Référence') }) })