Skip to content

Commit 757aadb

Browse files
committed
test(spec,lint,cli): repro — a page:tabs item label has no translation key
A pack key pages.PAGE.components.TABS.items.VALUE.label is refused by the schema, never applied by translatePage, not judged by objectstack validate and not offered by the extractor. Tests first; the fix follows. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
1 parent f32d795 commit 757aadb

4 files changed

Lines changed: 391 additions & 6 deletions

File tree

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)