Skip to content

Commit 77f275a

Browse files
committed
test(cli): pin contributed navigation coverage under the target app
Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9772f9f commit 77f275a

1 file changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// objectstack#24993 — a navigation item a package CONTRIBUTES into another
4+
// package's app (`manifest.navigationContributions`, ADR-0029 D7) owes its
5+
// translation under the TARGET app, like an item the app declares itself.
6+
//
7+
// The runtime folds a contributed item into the target app's navigation and
8+
// `translateApp` resolves its label from `apps.<target>.navigation.<id>.label`;
9+
// `translation-target-unknown` accepts that key (#18203). The walker read
10+
// `apps[].navigation` only, so the key was never expected: deleting a
11+
// contributed item's zh-CN label left `os lint --strict` and `os i18n check`
12+
// green, measured on HotCRM's `nav_case`, while the same deletion on one of
13+
// the app's own items reported `i18n/missing-navigation`.
14+
//
15+
// The fixture is the card's shape: a service module contributing into the
16+
// app's group and adding a group of its own, composed module-first with
17+
// `manifest: 'preserve'`, so the contributions live only in the module's
18+
// `packages[]` body. Both doors hand `computeI18nCoverage` the
19+
// `authoringRuleUnionStack` of that composition, which is what these pins do.
20+
21+
import { describe, it, expect } from 'vitest';
22+
import { composeStacks, defineStack } from '@objectstack/spec';
23+
import { TRANSLATION_TARGET_UNKNOWN, validateTranslationReferences } from '@objectstack/lint';
24+
25+
import { collectExpectedEntries } from '../src/utils/i18n-extract.js';
26+
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
27+
import { authoringRuleUnionStack } from '../src/utils/stack-collections.js';
28+
29+
const I18N = { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en' };
30+
const engines = { protocol: '^18' };
31+
32+
const appManifest = {
33+
id: 'com.example.cnav.app', name: 'Contributed Nav App', namespace: 'cnv', version: '1.0.0', type: 'app', engines,
34+
};
35+
36+
/** Into the app's own group, and a group of the module's own with a child. */
37+
const CONTRIBUTIONS = [
38+
{
39+
app: 'cnv_desk',
40+
group: 'grp_service',
41+
items: [{ id: 'nav_case', type: 'object', objectName: 'cnv_case', label: 'Cases' }],
42+
},
43+
{
44+
app: 'cnv_desk',
45+
items: [{
46+
id: 'grp_knowledge',
47+
type: 'group',
48+
label: 'Knowledge',
49+
children: [{ id: 'nav_article', type: 'object', objectName: 'cnv_article', label: 'Articles' }],
50+
}],
51+
},
52+
];
53+
54+
const svcManifest = {
55+
id: 'com.example.cnav.svc', name: 'Contributed Nav Service', namespace: 'cnv', version: '1.0.0', type: 'module', engines,
56+
navigationContributions: CONTRIBUTIONS,
57+
};
58+
59+
const object = (name: string, label: string) => ({
60+
name, label, pluralLabel: label, sharingModel: 'private',
61+
fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } },
62+
});
63+
64+
const app = {
65+
name: 'cnv_desk',
66+
label: 'Desk',
67+
navigation: [{
68+
id: 'grp_service',
69+
type: 'group',
70+
label: 'Service',
71+
children: [{ id: 'nav_accounts', type: 'object', objectName: 'cnv_account', label: 'Accounts' }],
72+
}],
73+
};
74+
75+
/** The zh-CN navigation labels, every one present; a case deletes one. */
76+
const OWN_NAV_ZH = { grp_service: { label: '服务' }, nav_accounts: { label: '客户' } };
77+
const CONTRIBUTED_NAV_ZH = { nav_case: { label: '工单' }, grp_knowledge: { label: '知识' }, nav_article: { label: '文章' } };
78+
79+
const without = (labels: Record<string, unknown>, id?: string) =>
80+
Object.fromEntries(Object.entries(labels).filter(([key]) => key !== id));
81+
82+
/** The app package carries the app's own labels; the module, the labels of what it contributes. */
83+
function stacks(drop?: string) {
84+
const appStack = defineStack({
85+
manifest: appManifest,
86+
objects: [object('cnv_account', 'Account')],
87+
apps: [app],
88+
i18n: I18N,
89+
translations: [{ 'zh-CN': { apps: { cnv_desk: { label: '服务台', navigation: without(OWN_NAV_ZH, drop) } } } }],
90+
} as any);
91+
const svcStack = defineStack({
92+
manifest: svcManifest,
93+
objects: [object('cnv_case', 'Case'), object('cnv_article', 'Article')],
94+
i18n: I18N,
95+
translations: [{ 'zh-CN': { apps: { cnv_desk: { navigation: without(CONTRIBUTED_NAV_ZH, drop) } } } }],
96+
} as any);
97+
return { appStack, svcStack };
98+
}
99+
100+
/** Module first, as HotCRM composes it: the top-level manifest is the app's. */
101+
function composed(drop?: string): Record<string, unknown> {
102+
const { appStack, svcStack } = stacks(drop);
103+
return authoringRuleUnionStack(composeStacks([svcStack, appStack], { manifest: 'preserve' }) as any);
104+
}
105+
106+
const navEntries = (config: any) =>
107+
collectExpectedEntries(config).filter((e) => e.path[0] === 'apps' && e.path[2] === 'navigation');
108+
109+
const zhNavIssueKeys = (config: any) =>
110+
computeI18nCoverage(config, { strict: true }).issues
111+
.filter((i) => i.locale === 'zh-CN' && i.source === 'navigation')
112+
.map((i) => i.key);
113+
114+
describe('what the walker expects from a contributed navigation item (#24993)', () => {
115+
it('reads the contributions from the module\'s package body alone — the fixture is the card\'s shape', () => {
116+
const stack = composed() as any;
117+
expect(stack.manifest.navigationContributions).toBeUndefined();
118+
expect(stack.packages.map((p: any) => p.manifest.navigationContributions?.length ?? 0)).toEqual([2, 0]);
119+
});
120+
121+
it('expects each contributed item under the TARGET app, seeded with its own label, after the app\'s own items', () => {
122+
const entries = navEntries(composed());
123+
expect(entries.map((e) => e.path.join('.'))).toEqual([
124+
'apps.cnv_desk.navigation.grp_service.label',
125+
'apps.cnv_desk.navigation.nav_accounts.label',
126+
'apps.cnv_desk.navigation.nav_case.label',
127+
'apps.cnv_desk.navigation.grp_knowledge.label',
128+
'apps.cnv_desk.navigation.nav_article.label',
129+
]);
130+
expect(entries[2]).toMatchObject({ sourceValue: 'Cases', inline: 'Cases', source: 'navigation', appName: 'cnv_desk' });
131+
expect(entries[4]).toMatchObject({ sourceValue: 'Articles', inline: 'Articles', source: 'navigation', appName: 'cnv_desk' });
132+
});
133+
134+
it('reads a single stack\'s own manifest too, keyed under an app the stack only contributes into', () => {
135+
const single = defineStack({
136+
manifest: { ...svcManifest, navigationContributions: [{ app: 'crm_enterprise', items: CONTRIBUTIONS[0].items }] },
137+
objects: [object('cnv_case', 'Case')],
138+
} as any);
139+
expect(navEntries(single).map((e) => e.path.join('.'))).toEqual(['apps.crm_enterprise.navigation.nav_case.label']);
140+
});
141+
142+
it('expects each key once when the top-level manifest is the contributor\'s copy', () => {
143+
const { appStack, svcStack } = stacks();
144+
const appLast = authoringRuleUnionStack(composeStacks([appStack, svcStack], { manifest: 'preserve' }) as any) as any;
145+
// Both carriers hold the module's contributions here.
146+
expect(appLast.manifest.navigationContributions).toHaveLength(2);
147+
expect(navEntries(appLast).map((e) => e.path.join('.'))).toEqual([
148+
'apps.cnv_desk.navigation.grp_service.label',
149+
'apps.cnv_desk.navigation.nav_accounts.label',
150+
'apps.cnv_desk.navigation.nav_case.label',
151+
'apps.cnv_desk.navigation.grp_knowledge.label',
152+
'apps.cnv_desk.navigation.nav_article.label',
153+
]);
154+
});
155+
156+
it('keeps the app\'s own entry when a contribution reuses one of its ids', () => {
157+
const config = {
158+
apps: [app],
159+
manifest: { navigationContributions: [{ app: 'cnv_desk', items: [{ id: 'nav_accounts', type: 'url', url: '/x', label: 'Elsewhere' }] }] },
160+
};
161+
const entries = navEntries(config);
162+
expect(entries.map((e) => e.path.join('.'))).toEqual([
163+
'apps.cnv_desk.navigation.grp_service.label',
164+
'apps.cnv_desk.navigation.nav_accounts.label',
165+
]);
166+
expect(entries[1].sourceValue).toBe('Accounts');
167+
});
168+
});
169+
170+
describe('the coverage gate reports a missing contributed item as it reports an own one (#24993)', () => {
171+
it('reports no navigation gap while every label is translated', () => {
172+
expect(zhNavIssueKeys(composed())).toEqual([]);
173+
});
174+
175+
it('reports a deleted contributed item', () => {
176+
expect(zhNavIssueKeys(composed('nav_case'))).toEqual(['apps.cnv_desk.navigation.nav_case.label']);
177+
});
178+
179+
it('reports a deleted child of a contributed group', () => {
180+
expect(zhNavIssueKeys(composed('nav_article'))).toEqual(['apps.cnv_desk.navigation.nav_article.label']);
181+
});
182+
183+
it('control: reports a deleted item the app declares itself, as before', () => {
184+
expect(zhNavIssueKeys(composed('nav_accounts'))).toEqual(['apps.cnv_desk.navigation.nav_accounts.label']);
185+
});
186+
187+
it('reports the deleted key once when both carriers hold the contribution', () => {
188+
const { appStack, svcStack } = stacks('nav_case');
189+
const appLast = authoringRuleUnionStack(composeStacks([appStack, svcStack], { manifest: 'preserve' }) as any);
190+
expect(zhNavIssueKeys(appLast)).toEqual(['apps.cnv_desk.navigation.nav_case.label']);
191+
});
192+
});
193+
194+
describe('every contributed key the walker expects is one translation-target-unknown accepts (#24993)', () => {
195+
/** A zh-CN bundle carrying exactly the navigation keys the walker expects. */
196+
const bundleOf = (config: any) => {
197+
const navigation: Record<string, { label: string }> = {};
198+
const apps: Record<string, { navigation: typeof navigation }> = {};
199+
for (const entry of navEntries(config)) {
200+
const [, appName, , id] = entry.path;
201+
apps[appName] ??= { navigation: {} };
202+
apps[appName].navigation[id] = { label: 'x' };
203+
}
204+
return { 'zh-CN': { apps } };
205+
};
206+
const unknownTargets = (stack: any, bundle: unknown) =>
207+
validateTranslationReferences({ ...stack, translations: [bundle] })
208+
.filter((f) => f.rule === TRANSLATION_TARGET_UNKNOWN)
209+
.map((f) => f.path);
210+
211+
it('accepts every expected key on the composed stack', () => {
212+
const stack = composed();
213+
expect(unknownTargets(stack, bundleOf(stack))).toEqual([]);
214+
});
215+
216+
it('accepts the expected key under an app the stack only contributes into', () => {
217+
const single = defineStack({
218+
manifest: { ...svcManifest, navigationContributions: [{ app: 'crm_enterprise', items: CONTRIBUTIONS[0].items }] },
219+
objects: [object('cnv_case', 'Case')],
220+
} as any) as any;
221+
expect(unknownTargets(single, bundleOf(single))).toEqual([]);
222+
});
223+
224+
it('control: still refuses a navigation id nothing declares or contributes', () => {
225+
const stack = composed();
226+
const bundle = { 'zh-CN': { apps: { cnv_desk: { navigation: { nav_ghost: { label: 'x' } } } } } };
227+
expect(unknownTargets(stack, bundle)).toEqual(['translations[0]["zh-CN"].apps.cnv_desk.navigation.nav_ghost']);
228+
});
229+
});

0 commit comments

Comments
 (0)