From f48f19008fb3497da2fefc48ed0dece61c09e6eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:29:02 +0000 Subject: [PATCH] fix(examples): name form/page sections and translate headings into zh-CN (#8231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For each section flagged by translation-section-name-missing, add a stable snake_case name plus a real (non-echoed) zh-CN bundle entry — 9/9 in app-crm, 12/15 in app-showcase. The remaining 3 in app-showcase (TaskViews' edit/quick, ContactViews' create) are pinned nameless as regression fixtures by packages/lint's own test suite, which is out of this PR's declared file surface; #8231 stays open for that follow-up. Extends the seed.test.ts / adds an i18n-sections.test.ts harness so a name alone (with no real translation) fails the sweep. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk --- .changeset/crm-showcase-section-name-i18n.md | 45 +++++++ .../src/translations/crm.translation.ts | 28 ++++ examples/app-crm/src/views/activity.view.ts | 3 + examples/app-crm/src/views/lead.view.ts | 5 + .../app-crm/src/views/opportunity.view.ts | 1 + examples/app-crm/test/i18n-sections.test.ts | 93 +++++++++++++ .../src/system/translations/index.ts | 39 ++++++ .../src/ui/pages/project-detail.page.ts | 6 +- .../src/ui/pages/settings.page.ts | 4 +- .../src/ui/pages/task-detail.page.ts | 11 +- .../src/ui/views/business-unit.view.ts | 2 +- .../app-showcase/src/ui/views/inquiry.view.ts | 1 + .../app-showcase/src/ui/views/project.view.ts | 4 +- examples/app-showcase/test/seed.test.ts | 125 ++++++++++++++++++ 14 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 .changeset/crm-showcase-section-name-i18n.md create mode 100644 examples/app-crm/test/i18n-sections.test.ts diff --git a/.changeset/crm-showcase-section-name-i18n.md b/.changeset/crm-showcase-section-name-i18n.md new file mode 100644 index 0000000000..d13d9fb659 --- /dev/null +++ b/.changeset/crm-showcase-section-name-i18n.md @@ -0,0 +1,45 @@ +--- +"@objectstack/example-crm": patch +"@objectstack/example-showcase": patch +--- + +fix(examples): name the form/page sections that had a label but no `name`, and translate the headings into zh-CN (#8231) + +`translation-section-name-missing` fired on every build of both example apps: a +form or `record:details` section that declares a `label` but no `name` has no +key a bundle can carry (`objects.._sections..label`), so its +heading renders in the source locale in EVERY locale — permanently, and +invisibly, because every neighbouring field label on the same object +translates fine. `app-crm` ships en + zh-CN; `app-showcase` ships the same. + +21 of the 24 flagged sections now declare a stable snake_case `name` and +resolve a real (non-echoed) zh-CN label: + +- **app-crm** (9/9): `crm_activity` (`activity_details`, `related_records`, + `notes`), `crm_lead` (`contact_us`, `lead_information`, `qualification`, + `conversion`, `notes`), `crm_opportunity` (`opportunity`). +- **app-showcase** (12/15): `showcase_project` form (`project`, + `budget_schedule`) and its detail page (`overview`, `financials`, + `timeline`); `showcase_task`'s detail page (`overview`, `schedule`, + `details` — reusing the same names and zh-CN copy its `tabbed` form view + already declares, so no new bundle entries were needed there); + `showcase_inquiry` (`tell_us_about_yourself`); `showcase_business_unit` + (`unit`); `showcase_preference`'s settings page (`appearance`, + `notifications`). + +**Not named here — a `packages/**` conflict, out of this PR's scope.** Three +`app-showcase` sections (`showcase_task`'s `formViews.edit`/`Task` and +`formViews.quick`/`Quick Edit`, `showcase_contact`'s `formViews.create`/`Who is +this?`) are pinned NAMELESS as regression fixtures by +`packages/lint/src/validate-translatable-sections.test.ts` and +`validate-translation-references.test.ts`, which import `TaskViews` / +`ContactViews` directly from this app and assert on their current unnamed +shape. Naming them requires a coordinated `packages/lint` test update; #8231 +remains open for that follow-up. + +Adding a `name` alone would have silenced the warning with zero translation +delivered, so both apps also gain a generalized i18n-coverage sweep test +(`examples/app-crm/test/i18n-sections.test.ts`, +`examples/app-showcase/test/seed.test.ts`) asserting every section this PR +touches BOTH has a `name` AND resolves a real, non-ASCII zh-CN +`_sections..label` — not just that the section has a name. diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index 0a0e1dfde8..6eb26fb05e 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -136,6 +136,14 @@ export const CrmTranslationBundle = defineTranslationBundle({ all: { label: '全部商机' }, pipeline: { label: '商机看板' }, }, + // Form section heading of `ui/views/opportunity.view.ts` `default`. + // Declares a stable `name`, which is the only thing that makes the + // heading translatable — otherwise it renders the English `label` in + // every locale (#8231). Bare object word, matching this bundle's own + // `label` above. + _sections: { + opportunity: { label: '商机' }, + }, }, crm_lead: { label: '线索', @@ -153,6 +161,18 @@ export const CrmTranslationBundle = defineTranslationBundle({ all: { label: '全部线索' }, pipeline: { label: '线索看板' }, }, + // Form section headings of `ui/views/lead.view.ts` (public web-to-lead + // `contact_us`, plus the four `default` groups). Each section declares + // a stable `name`, the only thing that makes the heading translatable + // (#8231). `conversion` reuses this bundle's own `messages` vocabulary + // for "convert" (`crm.lead.convert.success` already reads 转化). + _sections: { + contact_us: { label: '联系我们' }, + lead_information: { label: '线索信息' }, + qualification: { label: '资格审查' }, + conversion: { label: '转化' }, + notes: { label: '备注' }, + }, }, crm_activity: { label: '活动', @@ -170,6 +190,14 @@ export const CrmTranslationBundle = defineTranslationBundle({ all: { label: '全部活动' }, calendar: { label: '活动日历' }, }, + // Form section headings of `ui/views/activity.view.ts` `default`. Each + // declares a stable `name`, the only thing that makes the heading + // translatable (#8231). + _sections: { + activity_details: { label: '活动详情' }, + related_records: { label: '相关记录' }, + notes: { label: '备注' }, + }, }, }, apps: { diff --git a/examples/app-crm/src/views/activity.view.ts b/examples/app-crm/src/views/activity.view.ts index b8172bc01f..9b70551fb8 100644 --- a/examples/app-crm/src/views/activity.view.ts +++ b/examples/app-crm/src/views/activity.view.ts @@ -53,6 +53,7 @@ export const ActivityViews = defineView({ type: 'simple', sections: [ { + name: 'activity_details', label: 'Activity Details', columns: 2, fields: [ @@ -64,6 +65,7 @@ export const ActivityViews = defineView({ ], }, { + name: 'related_records', label: 'Related Records', columns: 2, fields: [ @@ -73,6 +75,7 @@ export const ActivityViews = defineView({ ], }, { + name: 'notes', label: 'Notes', columns: 1, fields: [ diff --git a/examples/app-crm/src/views/lead.view.ts b/examples/app-crm/src/views/lead.view.ts index c566eb937a..abe5670a7d 100644 --- a/examples/app-crm/src/views/lead.view.ts +++ b/examples/app-crm/src/views/lead.view.ts @@ -60,6 +60,7 @@ export const LeadViews = defineView({ data: { provider: 'object', object: 'crm_lead' }, sections: [ { + name: 'contact_us', label: 'Contact us', columns: 1, fields: [ @@ -81,6 +82,7 @@ export const LeadViews = defineView({ type: 'simple', sections: [ { + name: 'lead_information', label: 'Lead Information', columns: 2, fields: [ @@ -93,6 +95,7 @@ export const LeadViews = defineView({ ], }, { + name: 'qualification', label: 'Qualification', columns: 2, fields: [ @@ -103,6 +106,7 @@ export const LeadViews = defineView({ ], }, { + name: 'conversion', label: 'Conversion', columns: 2, fields: [ @@ -111,6 +115,7 @@ export const LeadViews = defineView({ ], }, { + name: 'notes', label: 'Notes', columns: 1, fields: [{ field: 'notes' }], diff --git a/examples/app-crm/src/views/opportunity.view.ts b/examples/app-crm/src/views/opportunity.view.ts index 472119a5a0..edc3d9562c 100644 --- a/examples/app-crm/src/views/opportunity.view.ts +++ b/examples/app-crm/src/views/opportunity.view.ts @@ -52,6 +52,7 @@ export const OpportunityViews = defineView({ data: { provider: 'object', object: 'crm_opportunity' }, sections: [ { + name: 'opportunity', label: 'Opportunity', columns: 2, fields: [ diff --git a/examples/app-crm/test/i18n-sections.test.ts b/examples/app-crm/test/i18n-sections.test.ts new file mode 100644 index 0000000000..299f4cefd6 --- /dev/null +++ b/examples/app-crm/test/i18n-sections.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import stack from '../objectstack.config.js'; + +/** + * #8231 — a form section can silence `translation-section-name-missing` + * completely just by getting a `name`, with ZERO translation delivered: the + * bundle simply never gains a matching `_sections.` entry, and the + * heading keeps rendering in the source locale in every locale. Naming the + * section is necessary but not sufficient. + * + * This is the `examples/app-showcase/test/seed.test.ts` "keys the contact + * form sections to exactly what the container declares" harness, generalized + * to every CRM view container instead of one hand-picked object: for each + * container, every section any of its `formViews` declares must (a) carry a + * `name` and (b) resolve to a REAL zh-CN `_sections..label` — non-empty + * and not an echoed English string. + * + * Read on the COMPOSED stack (`objectstack.config.ts`'s `views`/`translations` + * arrays), not the imported view/bundle modules directly — what the platform's + * i18n resolver and lint gates see is the composed stack, which is exactly the + * reachability this issue is about. + */ +describe('app-crm form section i18n coverage (#8231)', () => { + const zhBundle = (stack.translations ?? [])[0] as + | { 'zh-CN'?: { objects?: Record }> } } + | undefined; + + interface FormSection { + name?: unknown; + label?: unknown; + } + interface FormView { + data?: { object?: unknown }; + sections?: FormSection[]; + } + interface ViewContainer { + list?: { data?: { object?: unknown } }; + formViews?: Record; + } + + const containers = (stack.views ?? []) as ViewContainer[]; + + for (const container of containers) { + const containerObject = container.list?.data?.object; + if (typeof containerObject !== 'string') continue; + const formViews = container.formViews ?? {}; + + for (const [viewKey, formView] of Object.entries(formViews)) { + const sections = Array.isArray(formView.sections) ? formView.sections : []; + if (sections.length === 0) continue; + const objectName = typeof formView.data?.object === 'string' ? formView.data.object : containerObject; + + sections.forEach((section, i) => { + const label = typeof section.label === 'string' ? section.label : '(untitled)'; + it(`${objectName} · formViews.${viewKey} · section "${label}" declares a name and resolves in zh-CN`, () => { + expect( + typeof section.name === 'string' && section.name.length > 0, + `${objectName}.formViews.${viewKey}.sections[${i}] ("${label}") has no \`name\` — ` + + 'it can never be translated (translation-section-name-missing).', + ).toBe(true); + const name = section.name as string; + + const zhLabel = zhBundle?.['zh-CN']?.objects?.[objectName]?._sections?.[name]?.label; + expect( + zhLabel, + `objects.${objectName}._sections.${name}.label is missing from the zh-CN bundle — ` + + 'the section has a name but no real translation.', + ).toBeTruthy(); + // An echoed English label (or anything ASCII-only) is not a real + // zh-CN translation — it satisfies the key set while faking coverage. + expect(zhLabel, `zh-CN _sections.${name}.label reads as untranslated ASCII`).not.toMatch( + /^[\x20-\x7e]+$/, + ); + }); + }); + } + } + + it('exercised at least one container with named sections (guards against a vacuous sweep)', () => { + const total = containers.reduce( + (n, c) => + n + + Object.values(c.formViews ?? {}).reduce( + (m, fv) => m + (Array.isArray(fv.sections) ? fv.sections.length : 0), + 0, + ), + 0, + ); + expect(total).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 755ef79c9a..bc0d377b8e 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -229,6 +229,24 @@ export const ShowcaseTranslationBundle = { by_status: { label: '按状态' }, budget_chart: { label: '按客户预算' }, }, + // Section headings from two surfaces: `ui/views/project.view.ts`'s + // `edit` form (`project` / `budget_schedule`) and + // `ui/pages/project-detail.page.ts`'s `record:details` tab + // (`overview` / `financials` / `timeline`). Each declares a stable + // `name`, the only thing that makes the heading translatable + // (#8231). Wording reuses vocabulary this bundle already established + // elsewhere: `预算` (fields.budget above), `排期` (showcase_task + // `_sections.schedule` below), `概览` (showcase_task + // `_sections.overview`), `财务信息` (showcase_semantic_zoo + // `_sections.money`), `时间线` (showcase_task `_views.timeline`, + // "活动时间线"). + _sections: { + project: { label: '项目' }, + budget_schedule: { label: '预算与排期' }, + overview: { label: '概览' }, + financials: { label: '财务信息' }, + timeline: { label: '时间线' }, + }, }, showcase_task: { label: '任务', @@ -438,6 +456,14 @@ export const ShowcaseTranslationBundle = { notifications_enabled: { label: '启用通知' }, compact_density: { label: '紧凑密度' }, }, + // `ui/pages/settings.page.ts` `record:details` sections. Each + // declares a stable `name`, the only thing that makes the heading + // translatable (#8231). `通知` reuses `fields.notifications_enabled` + // ("启用通知") above. + _sections: { + appearance: { label: '外观' }, + notifications: { label: '通知' }, + }, }, showcase_product: { label: '产品', pluralLabel: '产品', @@ -465,12 +491,25 @@ export const ShowcaseTranslationBundle = { default: { label: '客户询问' }, triage: { label: '询问分流' }, }, + // The public web-to-lead form's single section + // (`ui/views/inquiry.view.ts` `formViews.contact`). Declares a stable + // `name`, the only thing that makes the heading translatable + // (#8231). + _sections: { + tell_us_about_yourself: { label: '介绍一下您自己' }, + }, }, showcase_business_unit: { _views: { default: { label: '全部单元' }, org_chart: { label: '组织架构图' }, }, + // `ui/views/business-unit.view.ts` `edit` form's single section. + // Declares a stable `name`, the only thing that makes the heading + // translatable (#8231). Bare object word, matching `全部单元` above. + _sections: { + unit: { label: '单元' }, + }, }, // `_sections` only, on the same footing as the two `_views`-only blocks // above: this ADR-0085 fixture has no zh-CN block of its own and its diff --git a/examples/app-showcase/src/ui/pages/project-detail.page.ts b/examples/app-showcase/src/ui/pages/project-detail.page.ts index 5d1d026923..f2c6bf9e3e 100644 --- a/examples/app-showcase/src/ui/pages/project-detail.page.ts +++ b/examples/app-showcase/src/ui/pages/project-detail.page.ts @@ -56,9 +56,9 @@ export const ProjectDetailPage = definePage({ type: 'record:details', properties: { sections: [ - { label: 'Overview', columns: 2, fields: ['name', 'account', 'owner', 'status'] }, - { label: 'Financials', columns: 2, fields: ['budget', 'spent'] }, - { label: 'Timeline', columns: 2, fields: ['start_date', 'end_date'] }, + { name: 'overview', label: 'Overview', columns: 2, fields: ['name', 'account', 'owner', 'status'] }, + { name: 'financials', label: 'Financials', columns: 2, fields: ['budget', 'spent'] }, + { name: 'timeline', label: 'Timeline', columns: 2, fields: ['start_date', 'end_date'] }, ], }, }, diff --git a/examples/app-showcase/src/ui/pages/settings.page.ts b/examples/app-showcase/src/ui/pages/settings.page.ts index 2353bd5c8a..b880b40d09 100644 --- a/examples/app-showcase/src/ui/pages/settings.page.ts +++ b/examples/app-showcase/src/ui/pages/settings.page.ts @@ -25,8 +25,8 @@ export const SettingsPage = definePage({ type: 'record:details', properties: { sections: [ - { label: 'Appearance', columns: 2, fields: ['theme', 'compact_density', 'default_landing', 'items_per_page'] }, - { label: 'Notifications', columns: 2, fields: ['notifications_enabled', 'email_digest'] }, + { name: 'appearance', label: 'Appearance', columns: 2, fields: ['theme', 'compact_density', 'default_landing', 'items_per_page'] }, + { name: 'notifications', label: 'Notifications', columns: 2, fields: ['notifications_enabled', 'email_digest'] }, ], }, }, diff --git a/examples/app-showcase/src/ui/pages/task-detail.page.ts b/examples/app-showcase/src/ui/pages/task-detail.page.ts index 81e3e0ae74..2eb8b1dfcb 100644 --- a/examples/app-showcase/src/ui/pages/task-detail.page.ts +++ b/examples/app-showcase/src/ui/pages/task-detail.page.ts @@ -71,9 +71,14 @@ export const TaskDetailPage = definePage({ type: 'record:details', properties: { sections: [ - { label: 'Overview', columns: 2, fields: ['title', 'project', 'assignee', 'status', 'priority'] }, - { label: 'Schedule', columns: 2, fields: ['start_date', 'end_date', 'due_date', 'estimate_hours'] }, - { label: 'Details', columns: 1, fields: ['labels', 'location', 'notes'] }, + // Reuses `objects.showcase_task._sections.{overview,schedule,details}` — + // the same three names `ui/views/task.view.ts`'s `tabbed` form + // already declares and `system/translations/index.ts` already + // translates, so this page's headings resolve in zh-CN with no + // new bundle entries (#8231). + { name: 'overview', label: 'Overview', columns: 2, fields: ['title', 'project', 'assignee', 'status', 'priority'] }, + { name: 'schedule', label: 'Schedule', columns: 2, fields: ['start_date', 'end_date', 'due_date', 'estimate_hours'] }, + { name: 'details', label: 'Details', columns: 1, fields: ['labels', 'location', 'notes'] }, ], }, }, diff --git a/examples/app-showcase/src/ui/views/business-unit.view.ts b/examples/app-showcase/src/ui/views/business-unit.view.ts index 7762cbbe3d..e8ded4d853 100644 --- a/examples/app-showcase/src/ui/views/business-unit.view.ts +++ b/examples/app-showcase/src/ui/views/business-unit.view.ts @@ -44,7 +44,7 @@ export const BusinessUnitViews = defineView({ type: 'simple', data, sections: [ - { label: 'Unit', columns: 2, fields: ['name', 'parent', 'kind', 'manager', 'headcount'] }, + { name: 'unit', label: 'Unit', columns: 2, fields: ['name', 'parent', 'kind', 'manager', 'headcount'] }, ], }, }, diff --git a/examples/app-showcase/src/ui/views/inquiry.view.ts b/examples/app-showcase/src/ui/views/inquiry.view.ts index 91b2c10980..23569e227e 100644 --- a/examples/app-showcase/src/ui/views/inquiry.view.ts +++ b/examples/app-showcase/src/ui/views/inquiry.view.ts @@ -56,6 +56,7 @@ export const InquiryViews = defineView({ data, sections: [ { + name: 'tell_us_about_yourself', label: 'Tell us about yourself', columns: 1, fields: [ diff --git a/examples/app-showcase/src/ui/views/project.view.ts b/examples/app-showcase/src/ui/views/project.view.ts index 0b91826268..eda3065c11 100644 --- a/examples/app-showcase/src/ui/views/project.view.ts +++ b/examples/app-showcase/src/ui/views/project.view.ts @@ -178,8 +178,8 @@ export const ProjectViews = defineView({ type: 'simple', data, sections: [ - { label: 'Project', columns: 2, fields: ['name', 'account', 'status', 'health', 'owner'] }, - { label: 'Budget & Schedule', columns: 2, fields: ['budget', 'spent', 'start_date', 'end_date'] }, + { name: 'project', label: 'Project', columns: 2, fields: ['name', 'account', 'status', 'health', 'owner'] }, + { name: 'budget_schedule', label: 'Budget & Schedule', columns: 2, fields: ['budget', 'spent', 'start_date', 'end_date'] }, ], // No subforms here: the Tasks subtable is derived from the data model — // showcase_task.project declares `inlineEdit: true`, so every standard diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index c122b63cdf..8a8b1df90d 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -188,6 +188,131 @@ describe('showcase stack', () => { }); }); +/** + * #8231 — generalizes the "keys the contact form sections..." test above to + * EVERY view container's `form`/`formViews`, and to every `record:details` + * section on every page. A section can silence + * `translation-section-name-missing` completely just by getting a `name`, + * with ZERO translation delivered: the bundle simply never gains a matching + * `_sections.` entry, and the heading keeps rendering in the source + * locale in every locale. This asserts BOTH halves for every section this + * sweep can see: it has a `name`, AND the zh-CN bundle carries a REAL + * (non-empty, non-English) label for it — an echoed English string would + * satisfy the key set while faking coverage. + * + * Read on the COMPOSED stack, the same reachability principle the test above + * documents: what the platform's i18n resolver and lint gates see is + * `stack.views` / `stack.pages` / `stack.translations`, not the imported + * modules. + */ +describe('showcase form + page section i18n coverage (#8231)', () => { + const zh = (stack.translations?.[0] as any)?.['zh-CN']?.objects as + | Record }> + | undefined; + + interface Found { + object: string; + where: string; + label: string; + name: unknown; + } + + /** + * KNOWN EXCLUSIONS — deliberately left unnamed by #8231's own scope. + * + * Naming any of these three would flip a currently-green, currently-PINNED + * finding in `packages/lint/src/validate-translatable-sections.test.ts` + * ("reports both nameless headings the shipped task container declares", + * "reports the sparse create override and nothing from the named default + * form") and `validate-translation-references.test.ts` ("still reports a + * section name nothing declares") — all three import `TaskViews` / + * `ContactViews` directly from this app and pin their CURRENT nameless + * state as the regression fixture. Fixing them requires a coordinated + * `packages/lint` test update, which is `packages/**` — out of #8231's + * declared file surface. Tracked as a follow-up rather than silently + * widening scope; see this test's introducing PR for the issue link. + */ + const KNOWN_UNNAMED = new Set([ + 'showcase_task::formViews.edit::Task', + 'showcase_task::formViews.quick::Quick Edit', + 'showcase_contact::formViews.create::Who is this?', + ]); + + function collectFormSections(): Found[] { + const out: Found[] = []; + for (const container of (stack.views ?? []) as any[]) { + const containerObject: string | undefined = + container.list?.data?.object ?? container.form?.data?.object; + const forms: Record = { ...(container.formViews ?? {}) }; + if (container.form) forms.form = container.form; + for (const [viewKey, fv] of Object.entries(forms)) { + const sections = Array.isArray(fv?.sections) ? fv.sections : []; + const objectName: string | undefined = fv?.data?.object ?? containerObject; + if (!objectName) continue; + const where = viewKey === 'form' ? 'form' : `formViews.${viewKey}`; + sections.forEach((s: any) => { + out.push({ object: objectName, where, label: String(s?.label ?? '(untitled)'), name: s?.name }); + }); + } + } + return out; + } + + function collectPageDetailSections(): Found[] { + const out: Found[] = []; + const visit = (node: unknown, pageName: string, pageObject: string) => { + if (Array.isArray(node)) { + for (const item of node) visit(item, pageName, pageObject); + return; + } + if (node && typeof node === 'object') { + const rec = node as Record; + if (rec.type === 'record:details' && rec.properties && typeof rec.properties === 'object') { + const sections = (rec.properties as any).sections; + if (Array.isArray(sections)) { + sections.forEach((s: any) => { + out.push({ + object: pageObject, + where: `page "${pageName}" · record:details`, + label: String(s?.label ?? '(untitled)'), + name: s?.name, + }); + }); + } + } + for (const v of Object.values(rec)) visit(v, pageName, pageObject); + } + }; + for (const page of (stack.pages ?? []) as any[]) { + if (typeof page.object !== 'string') continue; + visit(page.regions, page.name, page.object); + visit(page.slots, page.name, page.object); + } + return out; + } + + const found = [...collectFormSections(), ...collectPageDetailSections()].filter( + (f) => !KNOWN_UNNAMED.has(`${f.object}::${f.where}::${f.label}`), + ); + + it('found more than a token number of sections (guards against a vacuous sweep)', () => { + expect(found.length).toBeGreaterThanOrEqual(12); + }); + + for (const f of found) { + it(`${f.object} · ${f.where} · section "${f.label}" declares a name and resolves in zh-CN`, () => { + expect( + typeof f.name === 'string' && f.name.length > 0, + `${f.object} · ${f.where} · "${f.label}" has no \`name\` — untranslatable by construction.`, + ).toBe(true); + const name = f.name as string; + const label = zh?.[f.object]?._sections?.[name]?.label; + expect(label, `objects.${f.object}._sections.${name}.label is missing from the zh-CN bundle`).toBeTruthy(); + expect(label, `zh-CN _sections.${name}.label reads as untranslated ASCII`).not.toMatch(/^[\x20-\x7e]+$/); + }); + } +}); + /** * Static shadow of the seed contract after #3433: a seed write is a curated * end-state fact, so the platform EXEMPTS it from the object's `state_machine`