Skip to content

Commit 30d21f6

Browse files
feat(spec,lint,cli): a page:tabs item label gains the key pages.PAGE.components.TABS.items.VALUE.label (#25005)
Closes #24968 Clause-②: yes (widening) ## What changes A `page:tabs` item's `label` had no translation key. `TranslationBundle` `pages.PAGE.components` is keyed by component id. A tab item is not a component and carries no id. Spec `translatePage` descended `items[].children` but never read `items[].label`. HotCRM measured this on `sales_home_page`: three English tabs under a translated header, in zh-CN, ja-JP and es-ES. - **Spec** (`packages/spec/src/system/translation.zod.ts`): `pages.PAGE.components.TABS_ID.items.VALUE.label` is a declared key. The component entry gains `items`, a record keyed by the tab item's `value`. Each entry is closed (`strictObject`, surface "this page tab item translation"). `title`, `name` and `text` are renames onto `label`. (The `tabs` alias onto `items` was dropped in `f18a2f55dd`.) - **Resolver** (`packages/spec/src/system/i18n-resolver.ts`): `translatePage` overlays each item's `label` from the key. It resolves across the locale chain item by item, under the same `addressed` collision arbitration as the component copy. Only `page:tabs` components are read. Two new exports are shared by the resolver, the extractor and the lint check, so the three cannot drift: `PAGE_TABS_COMPONENT_TYPE` and `pageTabItemKey(item)`. - **Walk** (`walkAddressedPageComponents`): the walk used to rebuild a panel list from the authored node, so a visitor's edit to a panel's own key (its `label`) was overwritten. A panel's own keys now come from the visitor's return when the visitor keeps the list's authored shape (same length, an object at every panel position). Panel `children` are still descended from the authored document, so the walk still owns the descent. A visitor that changes the shape gets the authored list back. The docblocks state this, and a new walk test pins it. - **`objectstack validate`** (`packages/lint/src/validate-translation-references.ts`): the check builds a per-page universe with spec's `walkAddressedPageComponents`, the same walk `translatePage` runs. A `components.ID.items.VALUE` key resolves over a declared `page:tabs` item `value`. Otherwise it is refused as `translation-target-unknown`, in the existing one-line verdict style: - an unknown value (an index token such as `tab-2` included): "...which `page:tabs` "home_tabs" on page "sales_home_page" gives no item as its `value`, so the tab keeps its source-locale label.", followed by "Did you mean"; - a `page:accordion`: "...whose panels have no stable key, so nothing resolves them.", with a fix line pointing at the inline locale map; - another component type: "...declares as a `record:related_list`, not a `page:tabs`..."; - an undeclared component id: "...a component id page "P" does not declare...", followed by "Did you mean" over the page's tabs components. - **`os i18n extract` / coverage** (`packages/cli/src/utils/i18n-extract.ts`): `emitPageComponentCopy` writes a slot for every valued item of an addressed `page:tabs`. A reader exists (`translatePage`), so the slot is demanded, which differs from #24943. An inline locale map on the label is recorded as authored, through the same `pushEntry` path as the component keys. ## Why this spelling (shape (a) over (b)) - **Keyed by `value`, under the tabs component's id.** `PageTabsProps.items[]` already declares the identifier the key needs. `value` is the stable `?tab=` token that the renderer reads and restores, so it is unique within the strip. The #14749 ruling (Q3 = C3, quoted in the extractor's header) recorded the direction "identity first: `component.id` / `section.name` / `tabs item.value`". Adding an `id` to tab items (shape (b)) would give one tab two identifiers. It would also make `components.ID` address something that is not a component, and it would need a new authorable key on a published schema. Shape (a) adds no authorable page key and reuses the grammar the face already has, the same way `dashboards.D.widgets.ID` and `flows.F.screens.NODE` key a child by the identifier its consumer already holds. - **An item without a `value` has no key.** Its renderer token is `tab-INDEX`. The #14749 ruling forbids array-index keys, because reordering two tabs would swap their translations with every check still green. The lint verdict names the remedy: give the item a `value`. - **Inline locale maps keep working.** `translatePage` writes only where the bundle has an entry. An item the bundle does not key keeps its map, and a bundle entry wins where both exist. This is the rule the component copy already follows. - **Accordion is out on purpose.** `page:accordion` items have no author-settable identity (the renderer derives `panel-INDEX`, and `value` is refused on them with guidance), so their labels stay an inline-map surface. ## Tests - **Repro first**: commit `757aadb573` adds the tests before the fix. At that commit, on a rebuilt spec dist, the spec suite had 9 failing cases (4 schema, 4 resolver, 1 walk), the lint suite had 7 (5 refusal cases plus the two one-line arm coverage checks), and the new CLI file did not load, because the closure was not built yet. - **Flipped pin**: the `BOUNDARY` case in `i18n-resolver.test.ts` ("a tab panel label has no bundle key") said "If a key is added, this pin is the one to flip". It is flipped: its inline-map half is kept as a CONTROL, and the new `translatePage — page:tabs item labels` block pins the key. - **Controls**: panel children keep their own `components.ID` keys; the tabs component's own `label` key composes with its item keys; the same `items` key on a `page:accordion` translates nothing; an item without a `value` is untouched even if `tab-2` is keyed; the locale-chain fallback works key by key; the component keys of the page validate clean beside an item key; a page the stack does not declare is not judged. - **Package runs** (merged head `c8a248af73`, local base `origin/main` `7adfaec2a1`): - `pnpm --filter @objectstack/spec test`: 703 files, 21377 passed, 1 todo. - `pnpm --filter @objectstack/lint test`: 152 files, 7033 passed. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 316 files, 4629 passed, 29 skipped. Two published-subpath pins first refused because `packages/cli` itself was not built. That is a prerequisite refusal, not a measurement. After building the CLI they passed, with the new file: 3 files, 37 passed. The `integration` tier is declared to CI, since the diff touches no spawn entry. - `typecheck` for spec, lint and cli: exit 0 (each runs `tsc --noEmit` and `check:test-typecheck`). - **Generated artifacts**: `pnpm --filter @objectstack/spec check:generated --fix` regenerated `api-surface/system.json` and `export-origins/system.json` (the two new exports) and the strictness-ledger counts (`system.md`). The reference doc `translation.mdx` is unchanged, because its `components` cell is truncated with an ellipsis before `items`. - **Reverse verification** (after the fix was committed). Each mutation went through `scripts/ablation-replace.mjs`: the anchor hit once, and the restore was proven by `blob == HEAD` with an empty `git diff HEAD`. Each suite reads `src`. - A1, the `translatePageTabItems` call removed: spec resolver suite `4 failed | 363 passed`. - A2, the walk forced back to the authored panel keys: `5 failed | 362 passed` (the 4 above plus the walk pin). - A3, lint accepting every value: `4 failed | 190 passed` (the two value refusals and the two one-line arm-coverage checks). - A4, the extractor's item slot removed: the CLI file `4 failed | 4 passed`. - **Derived gates** (`dispatch-gates --commands`, reconciled with `--ran` with exit codes recorded): 94 derived, 93 run, all exit 0. `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` first refused with exit 3 (the CLI was not built). After the build they were re-run and exited 0: `check:i18n` reports 10 packages in sync, `check:i18n-coverage` reports 14 configs with none new, and `check:i18n-walk-parity` reports 11 declared groups, 9 walked and 2 exempted. NOT MEASURED: `check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET (exit 3), because packages this diff does not touch have no `dist` without a whole-repo `pnpm build`. Declared to CI. `check:nul-bytes`: exit 0. - **eslint, narrowed to the 8 touched source and test files** (`--no-inline-config --format json`, at `c8a248af73`): 8 files, 0 errors, 0 warnings. The config declares no type-aware linting (`eslint.config.mjs`: no `parserOptions.project`, no typed rules), so this diff cannot change the verdict on any untouched file. ## Review tier `packages/spec` is a published schema, so this PR owes a `CONTRACT_REVIEW_TIER` review of record before it enters the queue (pm-dispatch `references/contract-review.md`). It touches no governed surface (`.claude/**`, `docs/adr/**`, `AGENTS.md`, workflows, `skills/**`), so no Tier S or Tier H landing rule applies. ## Liveness `packages/spec/liveness/translation.json`'s `pages` row is `live` and not drilled, so its blanket verdict covers the new member. The reader is `translatePage`, which the REST metadata read applies through `translateMetadataDocument`. objectui's `page:tabs` renders a string label as is. ## Acceptance notes - `objectstack validate` still judges no other `pages.PAGE` member. An unknown page name, an unknown component id carrying only copy keys, and an unknown copy target are not checked by this rule today. This PR judges only the `items` member it adds, and skips pages the stack does not declare. - `page:accordion` panel labels have no bundle key (no stable identity). This is deliberate and recorded in the schema docblock. - `#24947` (this seat's open PR) also touches `i18n-extract.ts`, in a different function (bulk-confirm template slots). No textual overlap is expected. - The branch carries a `wip:` commit (`818b89e351`) between the repro and the fix commit. This PR is squash-landed, so it does not reach `main`. I did not rewrite the history. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dfe60e3 commit 30d21f6

13 files changed

Lines changed: 729 additions & 24 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/cli': minor
5+
---
6+
7+
feat(spec): a `page:tabs` item label gains a translation key, `pages.<page>.components.<tabsId>.items.<value>.label`
8+
9+
Clause-②: yes (widening)
10+
11+
- **Before.** `pages.<page>.components` is keyed by component id, and a `page:tabs` item is not a component and carries no id, so a tab label had no key. `items` was refused as an unknown key, and `translatePage` never read `items[].label`. A localized page kept its tab strip in the authoring language.
12+
- **Now.** `pages.<page>.components.<tabsId>.items.<value>.label` is a declared key. It is addressed by the tabs component's own `id` and the item's `value` (the stable `?tab=` token), and `translatePage` overlays it onto that item's `label`, across the locale chain. The entry is closed: `title`, `name` and `text` are renames onto `label`. An item without a `value` has no key, because its renderer token is index-derived and an array index is never a bundle key. Author a `value` on the item, or keep localizing that label with an inline locale map.
13+
- **`walkAddressedPageComponents`.** A visitor's edit to a panel's own keys (its `label`) now stands when the visitor keeps the panel list's authored shape. Panel children are still descended from the authored document.
14+
- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over a declared `page:tabs` item `value`. It refuses it as `translation-target-unknown` over an unknown value (with "Did you mean"), over a component of another type (a `page:accordion` is pointed at the inline locale map, since its panels have no stable key), and under a component id the page does not declare.
15+
- **`os i18n extract` / coverage.** The extractor writes a slot for every valued tab item, and the coverage check demands it. An inline locale map on the item label is counted as authored, as before.
16+
- **Unchanged.** Every existing `pages` key, and an inline locale map on a tab label that the bundle does not key.

‎content/docs/ui/translations.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export default defineStack({
8080
| Dataset dimension and measure labels | `datasets.<name>.dimensions.<dimension>.label` / `datasets.<name>.measures.<measure>.label` |
8181
| Page labels and `page:header` copy | `pages.<name>.label` / `description` / `title` / `subtitle` — on a `kind: 'slotted'` page the header under `slots.header` is the page's header |
8282
| Page component copy, by component id | `pages.<name>.components.<id>.title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.<slot>`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` |
83+
| Page tab labels (`page:tabs` items) | `pages.<name>.components.<tabsId>.items.<value>.label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none |
8384
| Screen-flow wizards (flow label, screen headings and body text, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.description` / `.fields.<field>.label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below |
8485
| Global actions, messages | `globalActions`, `messages` |
8586
| Settings UI shell copy (the source badge on a settings row) | `settingsCommon.sourceLabels.<layer>` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `system/` | 376 |
22+
| `system/` | 377 |

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
* pages.<page>.label / .description
104104
* pages.<page>.title / .subtitle (from the page's `page:header` component)
105105
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
106+
* pages.<page>.components.<tabsId>.items.<value>.label (a `page:tabs` item label)
106107
* flows.<flow>.label
107108
* flows.<flow>.screens.<node_id>.title (#7646 / #11287)
108109
* flows.<flow>.screens.<node_id>.description (#22507)
@@ -137,7 +138,9 @@ import {
137138
FLOW_SCREEN_COPY_KEYS,
138139
FLOW_SCREEN_FIELD_COPY_KEYS,
139140
FLOW_TERMINAL_MESSAGE_KEYS,
141+
PAGE_TABS_COMPONENT_TYPE,
140142
globalFilterKey,
143+
pageTabItemKey,
141144
walkAddressedPageComponents,
142145
} from '@objectstack/spec/system';
143146
import { FLOW_REGION_SLOTS_BY_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation';
@@ -1169,6 +1172,22 @@ function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): v
11691172
// records nothing (#14749).
11701173
pushEntry(out, ['pages', name, 'components', id as string, key], value, 'page');
11711174
}
1175+
// A `page:tabs` component's tab labels, keyed by each item's `value`
1176+
// under the tabs component's id — the key `translatePage` reads. An
1177+
// item without a `value` gets no slot: its renderer token is
1178+
// index-derived, and an array index is never a bundle key (#14749).
1179+
if (component.type === PAGE_TABS_COMPONENT_TYPE && Array.isArray(props.items)) {
1180+
for (const item of props.items as unknown[]) {
1181+
const value = pageTabItemKey(item);
1182+
if (value === undefined) continue;
1183+
pushEntry(
1184+
out,
1185+
['pages', name, 'components', id as string, 'items', value, 'label'],
1186+
(item as Record<string, unknown>).label,
1187+
'page',
1188+
);
1189+
}
1190+
}
11721191
}
11731192
return component;
11741193
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// A `page:tabs` item's `label` had no translation key. `pages.<page>.components`
4+
// is keyed by component id, a tab item is not a component and carries no id,
5+
// and `translatePage` never read `items[].label`, so a localized page kept its
6+
// tab strip in the authoring language (measured on HotCRM's
7+
// `sales_home_page`: three English tabs under a translated header).
8+
//
9+
// The key is `pages.<page>.components.<tabsId>.items.<value>.label`: the tabs
10+
// component's own id, then the item's `value`, the stable `?tab=` token the
11+
// renderer already reads. Unlike a key with no reader, `translatePage` reads
12+
// this one, so the extractor offers the slot and coverage demands it. An item
13+
// without a `value` gets no key: its renderer token is index-derived, and an
14+
// array index is never a bundle key (the identity-first direction of the
15+
// #14749 ruling).
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract';
19+
import { computeI18nCoverage } from '../src/utils/i18n-coverage';
20+
import { TranslationDataSchema, translatePage } from '@objectstack/spec/system';
21+
22+
const homePage = () => ({
23+
name: 'sales_home_page',
24+
label: 'Sales Home',
25+
regions: [{
26+
name: 'main',
27+
components: [{
28+
type: 'page:tabs',
29+
id: 'home_tabs',
30+
properties: {
31+
items: [
32+
{ label: 'My Leads', value: 'my_leads', children: [{ type: 'record:related_list', id: 'home_my_leads', properties: { title: 'Leads' } }] },
33+
{ label: { en: 'My Open Deals', 'zh-CN': '我的商机' }, value: 'my_deals', children: [] },
34+
// No `value`: no key, whatever its label says.
35+
{ label: 'My Open Tasks', children: [] },
36+
] as any[],
37+
},
38+
}],
39+
}],
40+
});
41+
42+
/** Every `…components.<id>.items.*` path the walker emits, as dot-paths. */
43+
const itemEntries = (config: any) =>
44+
collectExpectedEntries(config).filter((e) => e.path[0] === 'pages' && e.path[4] === 'items');
45+
46+
describe('the extractor scaffolds a key for every valued tab item', () => {
47+
it('emits `pages.<page>.components.<tabsId>.items.<value>.label`, and nothing for an item without a `value`', () => {
48+
expect(itemEntries({ pages: [homePage()] }).map((e) => e.path.join('.'))).toEqual([
49+
'pages.sales_home_page.components.home_tabs.items.my_leads.label',
50+
'pages.sales_home_page.components.home_tabs.items.my_deals.label',
51+
]);
52+
});
53+
54+
it('seeds a plain label, and records an inline locale map as authored with no seed', () => {
55+
const [leads, deals] = itemEntries({ pages: [homePage()] });
56+
expect(leads.sourceValue).toBe('My Leads');
57+
expect(deals.sourceValue).toBeUndefined();
58+
expect(deals.inlineLocales).toEqual({ en: 'My Open Deals', 'zh-CN': '我的商机' });
59+
});
60+
61+
it('CONTROL: the panel children keep their component keys', () => {
62+
const paths = collectExpectedEntries({ pages: [homePage()] }).map((e) => e.path.join('.'));
63+
expect(paths).toContain('pages.sales_home_page.components.home_my_leads.title');
64+
});
65+
66+
it('emits nothing for `items` on a component that is not `page:tabs`', () => {
67+
const page = homePage();
68+
(page.regions[0].components[0] as any).type = 'page:accordion';
69+
expect(itemEntries({ pages: [page] })).toEqual([]);
70+
});
71+
72+
it('emits nothing for a tabs component with no id', () => {
73+
const page = homePage();
74+
delete (page.regions[0].components[0] as any).id;
75+
expect(itemEntries({ pages: [page] })).toEqual([]);
76+
});
77+
});
78+
79+
describe('the emitted key is the key the schema declares and the resolver reads', () => {
80+
it('`TranslationDataSchema` accepts a bundle written at the extracted path, and `translatePage` applies it', () => {
81+
const out = extractTranslations({ pages: [homePage()] }, { locales: ['zh-CN'] });
82+
const zh = out.bundles['zh-CN'] as any;
83+
expect(zh?.pages?.sales_home_page?.components?.home_tabs?.items?.my_leads).toBeDefined();
84+
85+
const data = { pages: { sales_home_page: { components: { home_tabs: { items: { my_leads: { label: '我的线索' } } } } } } };
86+
expect(TranslationDataSchema.safeParse(data).success).toBe(true);
87+
const translated: any = translatePage(homePage(), { 'zh-CN': data } as any, { locale: 'zh-CN' });
88+
expect(translated.regions[0].components[0].properties.items[0].label).toBe('我的线索');
89+
});
90+
});
91+
92+
describe('coverage', () => {
93+
const config = (items: Record<string, unknown>) => ({
94+
pages: [homePage()],
95+
i18n: { supportedLocales: ['en', 'zh-CN'] },
96+
translations: [{ 'zh-CN': { pages: { sales_home_page: { components: { home_tabs: { items } } } } } }],
97+
});
98+
99+
const itemIssues = (report: { issues: Array<{ key: string }> }) =>
100+
report.issues.filter((i) => i.key.includes('.items.')).map((i) => i.key);
101+
102+
it('reports an untranslated tab label', () => {
103+
expect(itemIssues(computeI18nCoverage(config({})))).toEqual([
104+
'pages.sales_home_page.components.home_tabs.items.my_leads.label',
105+
]);
106+
});
107+
108+
it('stays quiet once the pack keys it — and about the inline-map item that covers its locale', () => {
109+
expect(itemIssues(computeI18nCoverage(config({ my_leads: { label: '我的线索' } })))).toEqual([]);
110+
});
111+
});

‎packages/lint/src/validate-translation-references.test.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2347,6 +2347,113 @@ describe('validateTranslationReferences — flow approval steps (`approvals.<nod
23472347
});
23482348
});
23492349

2350+
describe('validateTranslationReferences — `page:tabs` item labels (`components.<tabsId>.items.<value>.label`)', () => {
2351+
/**
2352+
* HotCRM's measured shape: a home page whose `page:tabs` holds the three
2353+
* tabs, beside an accordion and a related list, so each verdict below is
2354+
* judged against the same metadata as the clean run and differs only in
2355+
* the bundle.
2356+
*/
2357+
const homeStack = {
2358+
pages: [
2359+
{
2360+
name: 'sales_home_page',
2361+
label: 'Sales Home',
2362+
regions: [{
2363+
name: 'main',
2364+
components: [
2365+
{
2366+
type: 'page:tabs',
2367+
id: 'home_tabs',
2368+
properties: {
2369+
items: [
2370+
{ label: 'My Leads', value: 'my_leads', children: [{ type: 'record:related_list', id: 'home_my_leads', properties: { title: 'Leads' } }] },
2371+
{ label: 'My Open Deals', value: 'my_deals', children: [] },
2372+
{ label: 'My Open Tasks', children: [] },
2373+
],
2374+
},
2375+
},
2376+
{ type: 'page:accordion', id: 'faq', properties: { items: [{ label: 'Pricing', children: [] }] } },
2377+
],
2378+
}],
2379+
},
2380+
],
2381+
};
2382+
const bundle = (pages: unknown) => ({ ...homeStack, translations: [{ 'zh-CN': { pages } }] });
2383+
const tabs = (items: unknown, extra: Record<string, unknown> = {}) =>
2384+
bundle({ sales_home_page: { components: { home_tabs: { items }, ...extra } } });
2385+
2386+
it('reports nothing for the label of a valued tab item', () => {
2387+
expect(validateTranslationReferences(tabs({ my_leads: { label: '我的线索' }, my_deals: { label: '我的商机' } }))).toEqual([]);
2388+
});
2389+
2390+
it('controls: the component keys of the same page still resolve beside it, judged by nothing new', () => {
2391+
expect(
2392+
validateTranslationReferences(
2393+
tabs({ my_leads: { label: '我的线索' } }, { home_my_leads: { title: '线索' }, faq: { label: '常见问题' } }),
2394+
),
2395+
).toEqual([]);
2396+
});
2397+
2398+
it('refuses an item key over an UNKNOWN value, with the nearest tab value offered', () => {
2399+
const findings = validateTranslationReferences(tabs({ my_lead: { label: 'x' } }));
2400+
expect(findings).toHaveLength(1);
2401+
expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN);
2402+
expect(findings[0].severity).toBe('error');
2403+
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.home_tabs.items.my_lead');
2404+
expect(findings[0].message).toBe(
2405+
'Translations are keyed to tab "my_lead", which `page:tabs` "home_tabs" on page "sales_home_page" gives ' +
2406+
'no item as its `value`, so the tab keeps its source-locale label. Did you mean "my_leads"?',
2407+
);
2408+
expect(findings[0].hint).toBe(
2409+
"Tab-item translations are keyed by the item's `value`; give the item a `value` if it has none.",
2410+
);
2411+
});
2412+
2413+
it('refuses an item key over an index token, which no key ever reaches', () => {
2414+
const findings = validateTranslationReferences(tabs({ 'tab-2': { label: 'x' } }));
2415+
expect(findings).toHaveLength(1);
2416+
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.home_tabs.items.tab-2');
2417+
});
2418+
2419+
it('refuses `items` on a `page:accordion`, pointing at the inline locale map', () => {
2420+
const findings = validateTranslationReferences(bundle({ sales_home_page: { components: { faq: { items: { pricing: { label: 'x' } } } } } }));
2421+
expect(findings).toHaveLength(1);
2422+
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.faq.items');
2423+
expect(findings[0].message).toBe(
2424+
'Translations carry tab items for "faq", which page "sales_home_page" declares as a `page:accordion`, ' +
2425+
'whose panels have no stable key, so nothing resolves them.',
2426+
);
2427+
expect(findings[0].hint).toBe('Localize an accordion panel label in place, as an inline locale map on `items[].label`.');
2428+
});
2429+
2430+
it('refuses `items` on a component of another type, by name', () => {
2431+
const findings = validateTranslationReferences(
2432+
bundle({ sales_home_page: { components: { home_my_leads: { items: { leads: { label: 'x' } } } } } }),
2433+
);
2434+
expect(findings).toHaveLength(1);
2435+
expect(findings[0].message).toBe(
2436+
'Translations carry tab items for "home_my_leads", which page "sales_home_page" declares as a ' +
2437+
'`record:related_list`, not a `page:tabs`, so nothing resolves them.',
2438+
);
2439+
expect(findings[0].hint).toBe('Tab-item translations go under the `id` of a `page:tabs` component.');
2440+
});
2441+
2442+
it('refuses `items` under a component id the page does not declare', () => {
2443+
const findings = validateTranslationReferences(bundle({ sales_home_page: { components: { home_tab: { items: { my_leads: { label: 'x' } } } } } }));
2444+
expect(findings).toHaveLength(1);
2445+
expect(findings[0].path).toBe('translations[0]["zh-CN"].pages.sales_home_page.components.home_tab.items');
2446+
expect(findings[0].message).toBe(
2447+
'Translations carry tab items for "home_tab", a component id page "sales_home_page" does not declare, ' +
2448+
'so nothing resolves them. Did you mean "home_tabs"?',
2449+
);
2450+
});
2451+
2452+
it('control: a page this stack does not declare is not judged by this check', () => {
2453+
expect(validateTranslationReferences(bundle({ other_page: { components: { t: { items: { a: { label: 'x' } } } } } }))).toEqual([]);
2454+
});
2455+
});
2456+
23502457
describe('validateTranslationReferences — flow toasts and screen-field options (#22507)', () => {
23512458
/**
23522459
* hotclm's measured shape, trimmed: a screen flow that authors a completion
@@ -4035,6 +4142,10 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation
40354142
'`resultDialog.fields` does not declare',
40364143
'declares no `fields` (it renders the whole response as JSON)',
40374144
'Translations are keyed to picklist "',
4145+
'gives no item as its `value`',
4146+
'whose panels have no stable key',
4147+
'not a `page:tabs`, so nothing resolves them',
4148+
'a component id page "',
40384149
];
40394150
const OPTION_ARMS = [
40404151
'which declares no `options` at all (field type',
@@ -4186,6 +4297,9 @@ describe('[#22161] one-line fixes — translation-target-unknown', () => {
41864297
'Move the `resultDialog` translations under the action',
41874298
"Match the key to a declared field's literal `path`",
41884299
"Match the key to a picklist's `name`",
4300+
"Tab-item translations are keyed by the item's `value`",
4301+
'Localize an accordion panel label in place',
4302+
'Tab-item translations go under the `id` of a `page:tabs` component',
41894303
];
41904304

41914305
it('every `fix:` line the cases above fired is one line of at most 200 characters', () => {

0 commit comments

Comments
 (0)