@micha.bigler/ui-core-micha 2.35.0 → 2.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
5
+ import { ThemeProvider, createTheme } from '@mui/material/styles';
6
+ import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
7
+
8
+ vi.mock('react-i18next', () => ({
9
+ useTranslation: () => ({
10
+ t: (key, fallback) => ({
11
+ 'Account.TITLE': 'Account & Administration',
12
+ 'Account.PAGE_TITLE': 'Account',
13
+ 'Account.TAB_PROFILE': 'Profile',
14
+ 'Account.TAB_SECURITY': 'Security',
15
+ 'Account.TAB_USERS': 'Users',
16
+ 'Account.TAB_INVITE': 'Invite',
17
+ 'Account.TAB_SUPPORT': 'Support',
18
+ 'Account.GROUP_MY_ACCOUNT': 'My account',
19
+ 'Account.GROUP_MANAGEMENT': 'Management',
20
+ 'Account.GROUP_HELP': 'Help',
21
+ 'Account.GROUP_MORE': 'More',
22
+ 'SectionNav.TITLE': 'Switch section',
23
+ 'SectionNav.TRIGGER_EYEBROW': 'Section',
24
+ 'SectionNav.LAST_OPENED': 'Last opened',
25
+ }[key] ?? fallback ?? key),
26
+ }),
27
+ }));
28
+
29
+ vi.mock('../src/auth/authApi', () => ({
30
+ fetchAuthPolicy: vi.fn(() => new Promise(() => {})),
31
+ updateUserProfile: vi.fn(),
32
+ }));
33
+
34
+ vi.mock('../src/components/ProfileComponent', () => ({
35
+ ProfileComponent: () => <div>Profile content</div>,
36
+ }));
37
+ vi.mock('../src/components/SecurityComponent', () => ({
38
+ SecurityComponent: () => <div>Security content</div>,
39
+ }));
40
+ vi.mock('../src/components/UserListComponent', () => ({
41
+ UserListComponent: () => <div>Users content</div>,
42
+ }));
43
+ vi.mock('../src/components/UserInviteComponent', () => ({ UserInviteComponent: () => null }));
44
+ vi.mock('../src/components/AccessCodeManager', () => ({ AccessCodeManager: () => null }));
45
+ vi.mock('../src/components/AllowedEmailDomainsManager', () => ({ AllowedEmailDomainsManager: () => null }));
46
+ vi.mock('../src/components/RegistrationMethodsManager', () => ({ RegistrationMethodsManager: () => null }));
47
+ vi.mock('../src/components/AuthFactorRequirementCard', () => ({ AuthFactorRequirementCard: () => null }));
48
+ vi.mock('../src/components/AccessCodeSingleUseToggle', () => ({ AccessCodeSingleUseToggle: () => null }));
49
+ vi.mock('../src/components/QrSignupManager', () => ({ QrSignupManager: () => null }));
50
+ vi.mock('../src/components/QrSignupValidityManager', () => ({ QrSignupValidityManager: () => null }));
51
+ vi.mock('../src/components/SupportRecoveryRequestsTab', () => ({
52
+ SupportRecoveryRequestsTab: () => <div>Support content</div>,
53
+ }));
54
+ vi.mock('../src/components/BulkInviteCsvTab', () => ({ BulkInviteCsvTab: () => null }));
55
+
56
+ import { AuthContext } from '../src/auth/AuthContext';
57
+ import { AccountPage } from '../src/pages/AccountPage';
58
+
59
+ const theme = createTheme();
60
+ const superuser = {
61
+ email: 'admin@example.com',
62
+ is_superuser: true,
63
+ ui_permissions: {},
64
+ available_roles: [],
65
+ };
66
+ const notificationsTab = {
67
+ value: 'notifications',
68
+ label: 'Notifications',
69
+ render: () => <div>Notifications content</div>,
70
+ };
71
+
72
+ function setViewportWidth(width) {
73
+ window.matchMedia = vi.fn().mockImplementation((query) => {
74
+ const maxWidth = query.match(/max-width:(\d+(?:\.\d+)?)px/)?.[1];
75
+ const minWidth = query.match(/min-width:(\d+(?:\.\d+)?)px/)?.[1];
76
+ const matches = maxWidth
77
+ ? width <= Number(maxWidth)
78
+ : minWidth
79
+ ? width >= Number(minWidth)
80
+ : false;
81
+ return {
82
+ matches,
83
+ media: query,
84
+ onchange: null,
85
+ addEventListener: vi.fn(),
86
+ removeEventListener: vi.fn(),
87
+ addListener: vi.fn(),
88
+ removeListener: vi.fn(),
89
+ dispatchEvent: vi.fn(),
90
+ };
91
+ });
92
+ }
93
+
94
+ function LocationProbe() {
95
+ const location = useLocation();
96
+ return <output data-testid="location-search">{location.search}</output>;
97
+ }
98
+
99
+ function renderAccount({ user = superuser, route = '/account', extraTabs = [notificationsTab] } = {}) {
100
+ return render(
101
+ <ThemeProvider theme={theme}>
102
+ <AuthContext.Provider value={{ user, login: vi.fn(), loading: false }}>
103
+ <MemoryRouter initialEntries={[route]}>
104
+ <Routes>
105
+ <Route
106
+ path="/account"
107
+ element={(
108
+ <>
109
+ <AccountPage extraTabs={extraTabs} />
110
+ <LocationProbe />
111
+ </>
112
+ )}
113
+ />
114
+ </Routes>
115
+ </MemoryRouter>
116
+ </AuthContext.Provider>
117
+ </ThemeProvider>,
118
+ );
119
+ }
120
+
121
+ describe('AccountPage section navigation', () => {
122
+ beforeEach(() => setViewportWidth(375));
123
+
124
+ afterEach(() => {
125
+ cleanup();
126
+ vi.restoreAllMocks();
127
+ });
128
+
129
+ it('makes all six cockpit-shaped sections reachable at 375px without a horizontal strip', () => {
130
+ const { container } = renderAccount();
131
+ const trigger = screen.getByRole('button', { name: /Section Profile/i });
132
+
133
+ expect(container.querySelector('.MuiTabs-root')).toBeNull();
134
+ expect(getComputedStyle(trigger).width).toBe('100%');
135
+ fireEvent.click(trigger);
136
+
137
+ const sectionButtons = ['Profile', 'Security', 'Users', 'Invite', 'Support', 'Notifications']
138
+ .map((label) => screen.getByRole('button', { name: label }));
139
+ expect(sectionButtons).toHaveLength(6);
140
+ sectionButtons.forEach((item) => {
141
+ expect(getComputedStyle(item).position).not.toBe('absolute');
142
+ });
143
+ expect(document.documentElement.scrollWidth - document.documentElement.clientWidth).toBe(0);
144
+ });
145
+
146
+ it('keeps the ?tab= contract and updates it when a drawer item is selected', () => {
147
+ renderAccount({ route: '/account?tab=notifications' });
148
+
149
+ expect(screen.getByText('Notifications content')).toBeTruthy();
150
+ const trigger = screen.getByRole('button', { name: /Section Notifications/i });
151
+ fireEvent.click(trigger);
152
+ fireEvent.click(screen.getByRole('button', { name: 'Security' }));
153
+
154
+ expect(screen.getByText('Security content')).toBeTruthy();
155
+ expect(screen.getByTestId('location-search').textContent).toBe('?tab=security');
156
+ });
157
+
158
+ it('keeps permission-gated sections absent and falls an unpermitted query back to profile', () => {
159
+ renderAccount({
160
+ user: {
161
+ email: 'person@example.com',
162
+ is_superuser: false,
163
+ ui_permissions: {},
164
+ available_roles: [],
165
+ },
166
+ route: '/account?tab=users',
167
+ extraTabs: [],
168
+ });
169
+
170
+ expect(screen.getByText('Profile content')).toBeTruthy();
171
+ const trigger = screen.getByRole('button', { name: /Section Profile/i });
172
+ fireEvent.click(trigger);
173
+ expect(screen.getByRole('button', { name: 'Profile' })).toBeTruthy();
174
+ expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
175
+ expect(screen.queryByRole('button', { name: 'Users' })).toBeNull();
176
+ expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
177
+ expect(screen.queryByRole('button', { name: 'Support' })).toBeNull();
178
+ expect(screen.queryByText('Management')).toBeNull();
179
+ expect(screen.queryByText('Help')).toBeNull();
180
+ expect(screen.queryByText('More')).toBeNull();
181
+ });
182
+
183
+ it('uses the desktop grouped grid at 1280px with no tab strip left behind', () => {
184
+ setViewportWidth(1280);
185
+ const { container } = renderAccount();
186
+ const grid = container.querySelector('[data-section-nav-mode="desktop"]');
187
+
188
+ expect(grid).toBeTruthy();
189
+ expect(getComputedStyle(grid).gridTemplateColumns).toBe('280px minmax(0, 1fr)');
190
+ expect(container.querySelector('.MuiTabs-root')).toBeNull();
191
+ expect(container.querySelector('nav')).toBeTruthy();
192
+ });
193
+ });
@@ -0,0 +1,274 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import { ThemeProvider, createTheme } from '@mui/material/styles';
6
+
7
+ const translation = vi.hoisted(() => ({
8
+ t: vi.fn((key) => ({
9
+ 'SectionNav.TITLE': 'Switch section',
10
+ 'SectionNav.TRIGGER_EYEBROW': 'Section',
11
+ 'SectionNav.LAST_OPENED': 'Last opened',
12
+ }[key] ?? key)),
13
+ }));
14
+
15
+ vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
16
+
17
+ import {
18
+ SECTION_NAV_DRAWER_PAPER_SX,
19
+ SectionNav,
20
+ } from '../src/layout/SectionNav';
21
+ import { sectionNavTranslations } from '../src/i18n/sectionNavTranslations';
22
+ import { createAppTheme } from '../src/theme/createAppTheme';
23
+
24
+ const groups = [
25
+ {
26
+ key: 'account',
27
+ label: 'My account',
28
+ items: [
29
+ { key: 'profile', label: 'Profile' },
30
+ { key: 'security', label: 'Security' },
31
+ ],
32
+ },
33
+ {
34
+ key: 'management',
35
+ label: 'Management',
36
+ items: [
37
+ { key: 'users', label: 'Users' },
38
+ { key: 'invite', label: 'Invite' },
39
+ ],
40
+ },
41
+ {
42
+ key: 'help',
43
+ label: 'Help',
44
+ items: [{ key: 'support', label: 'Support' }],
45
+ },
46
+ {
47
+ key: 'more',
48
+ label: 'More',
49
+ items: [{ key: 'notifications', label: 'Notifications' }],
50
+ },
51
+ ];
52
+
53
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
54
+
55
+ function renderNav(props = {}, currentTheme = theme) {
56
+ return render(
57
+ <ThemeProvider theme={currentTheme}>
58
+ <SectionNav
59
+ mode="desktop"
60
+ groups={groups}
61
+ activeKey="profile"
62
+ onSelect={vi.fn()}
63
+ {...props}
64
+ >
65
+ <div data-testid="section-content">Section content</div>
66
+ </SectionNav>
67
+ </ThemeProvider>,
68
+ );
69
+ }
70
+
71
+ describe('SectionNav', () => {
72
+ afterEach(() => {
73
+ cleanup();
74
+ vi.clearAllMocks();
75
+ });
76
+
77
+ it('renders finished group and item labels in order, one section per item', () => {
78
+ renderNav();
79
+
80
+ expect(screen.getAllByRole('button').map((item) => item.textContent)).toEqual([
81
+ 'Profile',
82
+ 'Security',
83
+ 'Users',
84
+ 'Invite',
85
+ 'Support',
86
+ 'Notifications',
87
+ ]);
88
+ expect(screen.getAllByText(/My account|Management|Help|More/).map((item) => item.textContent))
89
+ .toEqual(['My account', 'Management', 'Help', 'More']);
90
+ });
91
+
92
+ it('selects exactly the matching item, tolerates no match, and selects an optional overview for null', () => {
93
+ const { rerender } = renderNav({ activeKey: 'users', overviewItem: { label: 'Overview' } });
94
+ const selected = () => screen.getAllByRole('button')
95
+ .filter((item) => item.classList.contains('Mui-selected'));
96
+
97
+ expect(selected()).toHaveLength(1);
98
+ expect(selected()[0].textContent).toBe('Users');
99
+
100
+ rerender(
101
+ <ThemeProvider theme={theme}>
102
+ <SectionNav
103
+ mode="desktop"
104
+ groups={groups}
105
+ activeKey="missing"
106
+ overviewItem={{ label: 'Overview' }}
107
+ onSelect={vi.fn()}
108
+ />
109
+ </ThemeProvider>,
110
+ );
111
+ expect(selected()).toHaveLength(0);
112
+
113
+ rerender(
114
+ <ThemeProvider theme={theme}>
115
+ <SectionNav
116
+ mode="desktop"
117
+ groups={groups}
118
+ activeKey={null}
119
+ overviewItem={{ label: 'Overview' }}
120
+ onSelect={vi.fn()}
121
+ />
122
+ </ThemeProvider>,
123
+ );
124
+ expect(selected()).toHaveLength(1);
125
+ expect(selected()[0].textContent).toBe('Overview');
126
+ });
127
+
128
+ it('omits overview and remembered UI completely when those props are absent', () => {
129
+ const { container } = renderNav();
130
+
131
+ expect(screen.queryByText('Overview')).toBeNull();
132
+ expect(screen.queryByText('Last opened')).toBeNull();
133
+ expect(container.querySelectorAll('.MuiPaper-root')).toHaveLength(groups.length);
134
+ });
135
+
136
+ it('shows the remembered secondary line only on its unselected item', () => {
137
+ renderNav({ activeKey: 'profile', rememberedKey: 'security' });
138
+ expect(screen.getByText('Last opened')).toBeTruthy();
139
+
140
+ cleanup();
141
+ renderNav({ activeKey: 'security', rememberedKey: 'security' });
142
+ expect(screen.queryByText('Last opened')).toBeNull();
143
+ });
144
+
145
+ it('calls onSelect with the item key and closes the uncontrolled drawer', async () => {
146
+ const onSelect = vi.fn();
147
+ renderNav({ mode: 'mobile', onSelect });
148
+
149
+ const trigger = screen.getByRole('button', { name: /Section Profile/i });
150
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
151
+ fireEvent.click(trigger);
152
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
153
+
154
+ fireEvent.click(screen.getByRole('button', { name: 'Security' }));
155
+ expect(onSelect).toHaveBeenCalledOnce();
156
+ expect(onSelect).toHaveBeenCalledWith('security');
157
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
158
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Security' })).toBeNull());
159
+ });
160
+
161
+ it('defers to the caller in controlled mode: the trigger calls onOpen, not internal state', () => {
162
+ const onOpen = vi.fn();
163
+ const onClose = vi.fn();
164
+ const { rerender } = renderNav({ mode: 'mobile', open: false, onOpen, onClose });
165
+
166
+ const trigger = screen.getByRole('button', { name: /Section Profile/i });
167
+ fireEvent.click(trigger);
168
+
169
+ expect(onOpen).toHaveBeenCalledOnce();
170
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
171
+ expect(screen.queryByRole('button', { name: 'Security' })).toBeNull();
172
+
173
+ rerender(
174
+ <ThemeProvider theme={theme}>
175
+ <SectionNav
176
+ mode="mobile"
177
+ groups={groups}
178
+ activeKey="profile"
179
+ onSelect={vi.fn()}
180
+ open
181
+ onOpen={onOpen}
182
+ onClose={onClose}
183
+ >
184
+ <div data-testid="section-content">Section content</div>
185
+ </SectionNav>
186
+ </ThemeProvider>,
187
+ );
188
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
189
+ expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
190
+ });
191
+
192
+ it('renders the desktop sidebar and children in a two-column grid, while mobile renders a trigger and no sidebar', () => {
193
+ const { container } = renderNav({ sidebarWidth: 312 });
194
+ const desktopRoot = container.querySelector('[data-section-nav-mode="desktop"]');
195
+
196
+ expect(desktopRoot).toBeTruthy();
197
+ expect(getComputedStyle(desktopRoot).display).toBe('grid');
198
+ expect(getComputedStyle(desktopRoot).gridTemplateColumns).toBe('312px minmax(0, 1fr)');
199
+ expect(container.querySelector('nav')).toBeTruthy();
200
+ expect(screen.getByTestId('section-content')).toBeTruthy();
201
+
202
+ cleanup();
203
+ const mobile = renderNav({ mode: 'mobile' });
204
+ expect(screen.getByRole('button', { name: /Section Profile/i })).toBeTruthy();
205
+ expect(mobile.container.querySelector('nav')).toBeNull();
206
+ expect(mobile.container.querySelector('[data-section-nav-mode="desktop"]')).toBeNull();
207
+ expect(screen.getByTestId('section-content')).toBeTruthy();
208
+ });
209
+
210
+ it('resolves the default drawer z-index above the SHELL-3 bottom bar', () => {
211
+ renderNav({ mode: 'mobile' });
212
+ fireEvent.click(screen.getByRole('button', { name: /Section Profile/i }));
213
+ const drawer = document.querySelector('.MuiDrawer-root');
214
+ const drawerZIndex = Number(getComputedStyle(drawer).zIndex);
215
+
216
+ expect(drawerZIndex).toBe(theme.zIndex.drawer + 3);
217
+ expect(drawerZIndex).toBeGreaterThan(theme.zIndex.drawer + 2);
218
+ });
219
+
220
+ it('follows surface and divider tokens under both createAppTheme and plain MUI themes', () => {
221
+ const themes = [
222
+ createAppTheme({
223
+ palette: {
224
+ primary: { main: '#123456' },
225
+ background: { paper: '#F1F2F3' },
226
+ divider: '#445566',
227
+ },
228
+ }),
229
+ createTheme({
230
+ palette: {
231
+ primary: { main: '#654321' },
232
+ background: { paper: '#E7E8E9' },
233
+ divider: '#665544',
234
+ },
235
+ }),
236
+ ];
237
+ const expected = [
238
+ { surface: 'rgb(241, 242, 243)', divider: 'rgb(68, 85, 102)' },
239
+ { surface: 'rgb(231, 232, 233)', divider: 'rgb(102, 85, 68)' },
240
+ ];
241
+
242
+ themes.forEach((currentTheme, index) => {
243
+ const { container } = renderNav({}, currentTheme);
244
+ const groupPaper = container.querySelector('.MuiPaper-root');
245
+ const groupHeader = groupPaper.querySelector('[id]');
246
+
247
+ expect(getComputedStyle(groupPaper).backgroundColor).toBe(expected[index].surface);
248
+ expect(getComputedStyle(groupHeader).borderBottomColor).toBe(expected[index].divider);
249
+ cleanup();
250
+ });
251
+ });
252
+
253
+ it('keeps the promoted drawer dimensions and safe-area declaration', () => {
254
+ expect(SECTION_NAV_DRAWER_PAPER_SX).toMatchObject({
255
+ borderTopLeftRadius: 18,
256
+ borderTopRightRadius: 18,
257
+ maxHeight: '78dvh',
258
+ p: 2,
259
+ pb: 'max(16px, env(safe-area-inset-bottom))',
260
+ });
261
+ });
262
+
263
+ it('ships every component-owned chrome string in all supported locales', () => {
264
+ expect(Object.keys(sectionNavTranslations)).toEqual([
265
+ 'SectionNav.TITLE',
266
+ 'SectionNav.TRIGGER_EYEBROW',
267
+ 'SectionNav.LAST_OPENED',
268
+ ]);
269
+ Object.values(sectionNavTranslations).forEach((translations) => {
270
+ expect(Object.keys(translations)).toEqual(['de', 'fr', 'en', 'sw']);
271
+ expect(Object.values(translations).every(Boolean)).toBe(true);
272
+ });
273
+ });
274
+ });
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ vi.mock('@mui/icons-material', () => ({
7
+ Archive: () => null,
8
+ Campaign: () => null,
9
+ Groups: () => null,
10
+ MoreVert: () => null,
11
+ NotificationsOff: () => null,
12
+ Person: () => null,
13
+ }));
14
+
15
+ import {
16
+ authTranslations as publicAuthTranslations,
17
+ chartsTranslations as publicChartsTranslations,
18
+ messagingTranslations as publicMessagingTranslations,
19
+ notificationsTranslations as publicNotificationsTranslations,
20
+ onboardingTranslations as publicOnboardingTranslations,
21
+ sectionNavTranslations as publicSectionNavTranslations,
22
+ uiCoreTranslations,
23
+ userMenuTranslations as publicUserMenuTranslations,
24
+ } from '../src/index';
25
+
26
+ const I18N_DIRECTORY = path.resolve('src/i18n');
27
+ // Scoped to the three locales every bundle already carries. `messagingTranslations`'s 116 keys
28
+ // have no `sw` value yet (pre-existing gap, out of I18N-1's scope — see the Notiz on the
29
+ // I18N-1 register row for the tracked follow-up to backfill it and restore `sw` here).
30
+ const REQUIRED_LOCALES = ['de', 'en', 'fr'];
31
+ const bundleImporters = import.meta.glob('../src/i18n/*.ts');
32
+ const PUBLIC_BUNDLES = {
33
+ authTranslations: publicAuthTranslations,
34
+ chartsTranslations: publicChartsTranslations,
35
+ messagingTranslations: publicMessagingTranslations,
36
+ notificationsTranslations: publicNotificationsTranslations,
37
+ onboardingTranslations: publicOnboardingTranslations,
38
+ sectionNavTranslations: publicSectionNavTranslations,
39
+ userMenuTranslations: publicUserMenuTranslations,
40
+ };
41
+
42
+ function bundleFileNames() {
43
+ return fs.readdirSync(I18N_DIRECTORY, { withFileTypes: true })
44
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
45
+ .map((entry) => entry.name)
46
+ .sort();
47
+ }
48
+
49
+ async function loadBundles() {
50
+ return Promise.all(bundleFileNames().map(async (fileName) => {
51
+ const modulePath = `../src/i18n/${fileName}`;
52
+ const importBundle = bundleImporters[modulePath];
53
+ if (!importBundle) {
54
+ throw new Error(`${fileName} was enumerated from src/i18n but has no module importer`);
55
+ }
56
+
57
+ const bundleModule = await importBundle();
58
+ const translationExports = Object.entries(bundleModule)
59
+ .filter(([exportName]) => exportName.endsWith('Translations'));
60
+
61
+ if (Object.keys(bundleModule).length !== 1 || translationExports.length !== 1) {
62
+ throw new Error(`${fileName} must export exactly one *Translations bundle`);
63
+ }
64
+
65
+ const [[exportName, translations]] = translationExports;
66
+ return { exportName, fileName, translations };
67
+ }));
68
+ }
69
+
70
+ describe('uiCoreTranslations', () => {
71
+ it('contains every directory-enumerated bundle key with its original value', async () => {
72
+ const bundles = await loadBundles();
73
+ const sourceKeys = [];
74
+
75
+ for (const { fileName, translations } of bundles) {
76
+ for (const [key, value] of Object.entries(translations)) {
77
+ sourceKeys.push(key);
78
+ expect(
79
+ Object.hasOwn(uiCoreTranslations, key),
80
+ `${key} from ${fileName} is missing from the public aggregate`,
81
+ ).toBe(true);
82
+ expect(uiCoreTranslations[key]).toEqual(value);
83
+ }
84
+ }
85
+
86
+ expect(Object.keys(uiCoreTranslations).sort()).toEqual(sourceKeys.sort());
87
+ });
88
+
89
+ it('does not allow duplicate keys across source bundles', async () => {
90
+ const owners = new Map();
91
+ const collisions = [];
92
+
93
+ for (const { fileName, translations } of await loadBundles()) {
94
+ for (const key of Object.keys(translations)) {
95
+ if (owners.has(key)) {
96
+ collisions.push(`${key}: ${owners.get(key)} and ${fileName}`);
97
+ } else {
98
+ owners.set(key, fileName);
99
+ }
100
+ }
101
+ }
102
+
103
+ expect(collisions, `Translation key collisions:\n${collisions.join('\n')}`).toEqual([]);
104
+ });
105
+
106
+ it('preserves at least the required locale shape for every aggregate value', () => {
107
+ for (const [key, value] of Object.entries(uiCoreTranslations)) {
108
+ for (const locale of REQUIRED_LOCALES) {
109
+ expect(typeof value[locale], `${key}.${locale} must be a string`).toBe('string');
110
+ expect(value[locale].trim(), `${key}.${locale} must not be empty`).not.toBe('');
111
+ }
112
+ }
113
+ });
114
+
115
+ it('keeps every per-feature bundle available unchanged from the package root', async () => {
116
+ for (const { exportName, translations } of await loadBundles()) {
117
+ expect(PUBLIC_BUNDLES[exportName], `${exportName} must remain a public export`)
118
+ .toBe(translations);
119
+ }
120
+ });
121
+ });