@micha.bigler/ui-core-micha 3.6.0 → 3.8.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/DESIGN.md +9 -1
  3. package/README.md +2 -0
  4. package/WORK_ORDERS.md +8 -0
  5. package/dist/auth/UserMenu.js +23 -1
  6. package/dist/components/EmptyState.js +17 -0
  7. package/dist/components/UserListComponent.js +2 -1
  8. package/dist/i18n/authTranslations.js +196 -0
  9. package/dist/i18n/uiCoreTranslations.js +19 -0
  10. package/dist/index.js +2 -1
  11. package/dist/messaging/Composer.js +4 -2
  12. package/dist/messaging/MessageBubble.js +2 -1
  13. package/dist/messaging/ReadTicks.js +5 -3
  14. package/dist/messaging/linkifyText.js +39 -0
  15. package/dist/pages/PasswordInvitePage.js +23 -8
  16. package/dist/pages/SignUpPage.js +4 -4
  17. package/dist/theme/createAppTheme.js +62 -9
  18. package/dist/theme/kitSxRegistry.js +5 -1
  19. package/dist/theme/tokens.js +1 -1
  20. package/package.json +1 -1
  21. package/src/auth/UserMenu.jsx +26 -1
  22. package/src/components/EmptyState.jsx +38 -0
  23. package/src/components/UserListComponent.jsx +2 -0
  24. package/src/i18n/authTranslations.ts +197 -0
  25. package/src/i18n/uiCoreTranslations.js +10 -0
  26. package/src/index.js +2 -1
  27. package/src/messaging/Composer.jsx +7 -3
  28. package/src/messaging/MessageBubble.jsx +2 -1
  29. package/src/messaging/ReadTicks.jsx +4 -3
  30. package/src/messaging/linkifyText.jsx +37 -0
  31. package/src/pages/PasswordInvitePage.jsx +36 -7
  32. package/src/pages/SignUpPage.jsx +79 -68
  33. package/src/theme/createAppTheme.js +68 -4
  34. package/src/theme/kitSxRegistry.js +9 -1
  35. package/src/theme/tokens.js +1 -1
  36. package/tests/EmptyState.test.jsx +62 -0
  37. package/tests/PasswordInvitePage.test.jsx +135 -0
  38. package/tests/SignUpPage.test.jsx +15 -2
  39. package/tests/UserListComponent.test.jsx +85 -0
  40. package/tests/UserMenu.test.jsx +40 -4
  41. package/tests/createAppThemePageContrast.test.js +108 -0
  42. package/tests/i18nGermanVariant.test.js +65 -0
  43. package/tests/messagingComposer.test.jsx +25 -0
  44. package/tests/messagingLinkify.test.jsx +51 -0
  45. package/tests/messagingReadTicksTooltip.test.jsx +36 -0
  46. package/tests/userScreenTranslations.test.jsx +165 -0
  47. package/work-orders/UCM-AUTH-9.md +207 -0
  48. package/work-orders/UCM-I18N-4.md +226 -0
  49. package/work-orders/UCM-MSG-20.md +192 -0
  50. package/work-orders/UCM-PRIM-2.md +114 -0
  51. package/work-orders/UCM-SHELL-7.md +134 -0
  52. package/work-orders/UCM-THEME-13.md +69 -0
  53. package/work-orders/UCM-THEME-14.md +142 -0
  54. package/work-orders/UCM-THEME-15.md +356 -0
@@ -0,0 +1,85 @@
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, waitFor } from '@testing-library/react';
5
+
6
+ const authApi = vi.hoisted(() => ({
7
+ fetchUsersList: vi.fn(),
8
+ updateUserRole: vi.fn(),
9
+ deleteUser: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/auth/authApi', () => authApi);
13
+ vi.mock('react-i18next', () => ({
14
+ useTranslation: () => ({
15
+ t: (key, fallback) => ({
16
+ 'UserList.TITLE': 'All Users',
17
+ 'Common.SEARCH': 'Search',
18
+ 'UserList.SEARCH_PLACEHOLDER': 'Search users...',
19
+ 'Auth.EMAIL_LABEL': 'Email',
20
+ 'Profile.NAME_LABEL': 'Name',
21
+ 'UserList.ROLE': 'Role',
22
+ 'UserList.ROLE_UPDATE_SUCCESS': 'Role updated.',
23
+ 'Auth.USER_ROLE_UPDATE_FAILED': 'Role update failed.',
24
+ 'UserList.NO_USERS': 'No users found.',
25
+ }[key] ?? (typeof fallback === 'string' ? fallback : key)),
26
+ }),
27
+ }));
28
+
29
+ import { UserListComponent } from '../src/components/UserListComponent';
30
+
31
+ const currentUser = { id: 99, is_superuser: true, role: 'admin' };
32
+ const user = { id: 1, email: 'person@example.com', role: 'student' };
33
+
34
+ function renderUserList() {
35
+ return render(
36
+ <UserListComponent
37
+ currentUser={currentUser}
38
+ showNewColumn={false}
39
+ showSuccessfulLoginColumn={false}
40
+ showDeleteAction={false}
41
+ />,
42
+ );
43
+ }
44
+
45
+ async function chooseRole(role) {
46
+ fireEvent.mouseDown(screen.getAllByRole('combobox')[0]);
47
+ await waitFor(() => expect(screen.getByRole('listbox')).toBeTruthy());
48
+ fireEvent.click(screen.getByRole('option', { name: role }));
49
+ }
50
+
51
+ describe('UserListComponent role updates', () => {
52
+ beforeEach(() => {
53
+ vi.clearAllMocks();
54
+ authApi.fetchUsersList.mockResolvedValue([user]);
55
+ authApi.updateUserRole.mockResolvedValue({});
56
+ });
57
+
58
+ afterEach(() => {
59
+ cleanup();
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ it('confirms a successful role update', async () => {
64
+ const alert = vi.spyOn(window, 'alert').mockImplementation(() => {});
65
+ renderUserList();
66
+
67
+ await waitFor(() => expect(screen.getAllByRole('combobox')[0]).toBeTruthy());
68
+ await chooseRole('teacher');
69
+
70
+ await waitFor(() => expect(authApi.updateUserRole).toHaveBeenCalledWith(1, 'teacher'));
71
+ expect(alert).toHaveBeenCalledWith('Role updated.');
72
+ });
73
+
74
+ it('keeps the failure feedback when a role update fails', async () => {
75
+ authApi.updateUserRole.mockRejectedValue({ code: 'Auth.USER_ROLE_UPDATE_FAILED' });
76
+ const alert = vi.spyOn(window, 'alert').mockImplementation(() => {});
77
+ renderUserList();
78
+
79
+ await waitFor(() => expect(screen.getAllByRole('combobox')[0]).toBeTruthy());
80
+ await chooseRole('teacher');
81
+
82
+ await waitFor(() => expect(alert).toHaveBeenCalledWith('Role update failed.'));
83
+ expect(authApi.fetchUsersList).toHaveBeenCalledTimes(1);
84
+ });
85
+ });
@@ -2,6 +2,7 @@
2
2
  import React from 'react';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import { createTheme, ThemeProvider } from '@mui/material/styles';
5
6
 
6
7
  const translation = vi.hoisted(() => ({
7
8
  t: vi.fn((key) => `translated:${key}`),
@@ -11,15 +12,26 @@ vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
11
12
 
12
13
  import { AuthContext } from '../src/auth/AuthContext';
13
14
  import { UserMenu } from '../src/auth/UserMenu';
15
+ import { calculateContrastRatio, createAppTheme } from '../src/theme';
14
16
 
15
- function renderUserMenu({ user, logout = vi.fn(), ...props } = {}) {
17
+ const defaultTheme = createAppTheme({ palette: { primary: { main: createTheme().palette.primary.main } } });
18
+
19
+ function renderUserMenu({ user, logout = vi.fn(), theme = defaultTheme, ...props } = {}) {
16
20
  return render(
17
- <AuthContext.Provider value={{ user, logout }}>
18
- <UserMenu {...props} />
19
- </AuthContext.Provider>,
21
+ <ThemeProvider theme={theme}>
22
+ <AuthContext.Provider value={{ user, logout }}>
23
+ <UserMenu {...props} />
24
+ </AuthContext.Provider>
25
+ </ThemeProvider>,
20
26
  );
21
27
  }
22
28
 
29
+ function getAvatarContrast() {
30
+ const avatar = screen.getByText(/^[A-Z]+$/).closest('.MuiAvatar-root');
31
+ const styles = window.getComputedStyle(avatar);
32
+ return calculateContrastRatio(styles.color, styles.backgroundColor);
33
+ }
34
+
23
35
  describe('UserMenu', () => {
24
36
  afterEach(() => {
25
37
  cleanup();
@@ -132,4 +144,28 @@ describe('UserMenu', () => {
132
144
  renderUserMenu({ user: { username: 'grace' } });
133
145
  expect(screen.getByText('G')).toBeTruthy();
134
146
  });
147
+
148
+ it("uses a contrasting avatar pair with jg-ferien's primary colour", () => {
149
+ const theme = createAppTheme({ palette: { primary: { main: '#367964' } } });
150
+ renderUserMenu({ theme, user: { username: 'ab' } });
151
+
152
+ expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
153
+ });
154
+
155
+ it('uses a contrasting avatar pair with the library baseline primary colour', () => {
156
+ renderUserMenu({ user: { username: 'ab' } });
157
+
158
+ expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
159
+ });
160
+
161
+ it('falls back to the dark text pair for a medium-luminance primary colour', () => {
162
+ // A synthetic mid-grey (luminance ~0.216, no real brand meaning) picked to
163
+ // fail white-on-background at 4.5:1 (~3.95:1) so this test actually
164
+ // exercises the black-fallback branch, not just the white branch every
165
+ // other test here happens to land on.
166
+ const theme = createAppTheme({ palette: { primary: { main: '#808080' } } });
167
+ renderUserMenu({ theme, user: { username: 'ab' } });
168
+
169
+ expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
170
+ });
135
171
  });
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { assertThemeComplete, createAppTheme } from '../src/theme';
4
+ import { BASELINE_PALETTE } from '../src/theme/tokens';
5
+
6
+ const primary = { main: '#0F62FE' };
7
+ const pageBackgrounds = [
8
+ '#FAFAFA',
9
+ '#D8E4E1',
10
+ '#eceae2',
11
+ '#E8EAF6',
12
+ '#E6EAF0',
13
+ '#E398C7',
14
+ ];
15
+
16
+ function pageContrastFindings(theme) {
17
+ return assertThemeComplete(theme).findings.filter(({ surface }) => (
18
+ /^contrast\.(success|warning|error|info)\.main-on-(white|page)$/.test(surface)
19
+ || surface.startsWith('contrast.controlBorder.')
20
+ ));
21
+ }
22
+
23
+ describe('createAppTheme page-sensitive baseline contrast', () => {
24
+ it.each(pageBackgrounds)('derives baseline colours for page %s', (background) => {
25
+ const theme = createAppTheme({ palette: { primary, background: { default: background } } });
26
+
27
+ expect(pageContrastFindings(theme)).toEqual([]);
28
+ });
29
+
30
+ it('keeps all baseline values byte-identical when they already pass', () => {
31
+ const theme = createAppTheme({ palette: { primary } });
32
+
33
+ for (const status of ['success', 'warning', 'error', 'info']) {
34
+ expect(theme.palette[status]).toMatchObject({
35
+ main: BASELINE_PALETTE[status].main,
36
+ light: BASELINE_PALETTE[status].light,
37
+ dark: BASELINE_PALETTE[status].dark,
38
+ contrastText: BASELINE_PALETTE[status].contrastText,
39
+ });
40
+ }
41
+ for (const state of ['main', 'hover', 'error']) {
42
+ expect(theme.palette.controlBorder[state]).toBe(BASELINE_PALETTE.controlBorder[state]);
43
+ }
44
+ });
45
+
46
+ it('leaves app-supplied failing status and control-border values untouched', () => {
47
+ const appConfig = {
48
+ palette: {
49
+ primary,
50
+ background: { default: '#E398C7' },
51
+ success: { main: '#A5D6A7' },
52
+ warning: { main: '#FFE082' },
53
+ controlBorder: { main: '#FFFFFF', hover: '#FAFAFA', error: '#FFCDD2' },
54
+ },
55
+ };
56
+ const theme = createAppTheme(appConfig);
57
+ const findings = assertThemeComplete(theme).findings.map(({ surface }) => surface);
58
+
59
+ expect(theme.palette.success.main).toBe(appConfig.palette.success.main);
60
+ expect(theme.palette.warning.main).toBe(appConfig.palette.warning.main);
61
+ expect(theme.palette.controlBorder.main).toBe(appConfig.palette.controlBorder.main);
62
+ expect(theme.palette.controlBorder.hover).toBe(appConfig.palette.controlBorder.hover);
63
+ expect(theme.palette.controlBorder.error).toBe(appConfig.palette.controlBorder.error);
64
+ for (const surface of [
65
+ 'contrast.success.main-on-white',
66
+ 'contrast.success.main-on-page',
67
+ 'contrast.warning.main-on-white',
68
+ 'contrast.warning.main-on-page',
69
+ 'contrast.controlBorder.main-on-white',
70
+ 'contrast.controlBorder.main-on-page',
71
+ 'contrast.controlBorder.hover-on-white',
72
+ 'contrast.controlBorder.hover-on-page',
73
+ 'contrast.controlBorder.error-on-white',
74
+ 'contrast.controlBorder.error-on-page',
75
+ ]) {
76
+ expect(findings).toContain(surface);
77
+ }
78
+
79
+ // The two untouched status keys (error/info) -- both fail on ygbs's pink
80
+ // per the Envelope's measured table -- must still derive normally, proving
81
+ // rule 3 is checked per status key, not per whole-status-object: this
82
+ // config only overrides success/warning, so error/info stay baseline-owned
83
+ // and should move off their baseline hex and stop failing.
84
+ expect(theme.palette.error.main).not.toBe(BASELINE_PALETTE.error.main);
85
+ expect(theme.palette.info.main).not.toBe(BASELINE_PALETTE.info.main);
86
+ expect(findings).not.toContain('contrast.error.main-on-page');
87
+ expect(findings).not.toContain('contrast.info.main-on-page');
88
+ });
89
+
90
+ it('terminates and leaves findings when the darkening cap cannot pass', () => {
91
+ const create = () => createAppTheme({
92
+ palette: { primary, background: { default: '#666666' } },
93
+ });
94
+
95
+ expect(create).not.toThrow();
96
+ const theme = create();
97
+ expect(theme.palette.success.main).toBeDefined();
98
+ expect(theme.palette.warning.main).toBeDefined();
99
+ expect(theme.palette.error.main).toBeDefined();
100
+ expect(theme.palette.info.main).toBeDefined();
101
+ expect(theme.palette.controlBorder.main).toBeDefined();
102
+ expect(theme.palette.controlBorder.hover).toBeDefined();
103
+ expect(theme.palette.controlBorder.error).toBeDefined();
104
+ expect(assertThemeComplete(theme).findings.map(({ surface }) => surface)).toContain(
105
+ 'contrast.success.main-on-page',
106
+ );
107
+ });
108
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { authTranslations } from '../src/i18n/authTranslations';
4
+ import {
5
+ createUiCoreTranslations,
6
+ uiCoreTranslations,
7
+ } from '../src/i18n/uiCoreTranslations';
8
+
9
+ const SIE_FORM = /\b(Sie|Ihr|Ihre|Ihrem|Ihren|Ihrer|Ihres)\b/;
10
+ const REQUIRED_LOCALES = ['de', 'en', 'fr', 'sw'];
11
+
12
+ describe('German translation variants', () => {
13
+ it('keeps formal German by default and switches representative auth copy to du', () => {
14
+ const key = 'Auth.PAGE_INVITE_SUBTITLE';
15
+ const formal = createUiCoreTranslations();
16
+ const informal = createUiCoreTranslations({ germanVariant: 'informal' });
17
+
18
+ expect(formal[key].de).toBe(uiCoreTranslations[key].de);
19
+ expect(formal[key]).not.toHaveProperty('de_informal');
20
+ expect(informal[key].de).toBe('Bitte wähle ein Passwort, um auf dein Konto zuzugreifen.');
21
+ expect(informal[key].de).not.toMatch(SIE_FORM);
22
+ expect(informal['Auth.EMAIL_LABEL'].de).toBe(formal['Auth.EMAIL_LABEL'].de);
23
+ });
24
+
25
+ it('switches EVERY Sie-form key to its own rewritten de_informal, not one hardcoded example', () => {
26
+ // Not just one representative key -- a factory hardcoded to a single
27
+ // (key, value) pair would pass the test above but fail here the moment a
28
+ // second Sie-form key is checked against ITS OWN de_informal value.
29
+ const formalKeys = Object.entries(authTranslations)
30
+ .filter(([, value]) => SIE_FORM.test(value.de))
31
+ .map(([key]) => key);
32
+ expect(formalKeys.length).toBeGreaterThan(1);
33
+
34
+ const informal = createUiCoreTranslations({ germanVariant: 'informal' });
35
+ const formal = createUiCoreTranslations();
36
+ for (const key of formalKeys) {
37
+ expect(informal[key].de, `${key} should switch to its own de_informal`).toBe(authTranslations[key].de_informal);
38
+ expect(informal[key].de).not.toMatch(SIE_FORM);
39
+ expect(formal[key].de, `${key} default must stay formal`).toBe(authTranslations[key].de);
40
+ }
41
+ });
42
+
43
+ it('requires every Sie-form auth string to have a rewritten informal variant', () => {
44
+ const formalKeys = Object.entries(authTranslations)
45
+ .filter(([, value]) => SIE_FORM.test(value.de))
46
+ .map(([key]) => key);
47
+
48
+ expect(formalKeys.length).toBeGreaterThan(0);
49
+ for (const key of formalKeys) {
50
+ const informal = authTranslations[key].de_informal;
51
+ expect(informal, `${key} needs de_informal`).toEqual(expect.any(String));
52
+ expect(informal.trim(), `${key}.de_informal must not be empty`).not.toBe('');
53
+ expect(informal, `${key}.de_informal must not retain Sie-form`).not.toMatch(SIE_FORM);
54
+ }
55
+ });
56
+
57
+ it('preserves the four-locale shape for the aggregate after adding screen keys', () => {
58
+ for (const [key, value] of Object.entries(uiCoreTranslations)) {
59
+ for (const locale of REQUIRED_LOCALES) {
60
+ expect(typeof value[locale], `${key}.${locale} must be a string`).toBe('string');
61
+ expect(value[locale].trim(), `${key}.${locale} must not be empty`).not.toBe('');
62
+ }
63
+ }
64
+ });
65
+ });
@@ -108,4 +108,29 @@ describe('Composer optimistic send and attachments', () => {
108
108
  render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[{ id: 1, filename: 'note.pdf', content_type: 'application/pdf' }]} /></MessagingProvider>);
109
109
  expect(screen.getByLabelText('MessagingAttachments.LABEL')).toBeTruthy();
110
110
  });
111
+
112
+ it('keeps the message field and send button usable beside all enabled actions', () => {
113
+ render(<MessagingProvider api={makeApi()} active={false}><Composer conversationId={7} conversation={{ kind: 'group' }} allowAnnouncement /></MessagingProvider>);
114
+ const messageField = screen.getByLabelText('MessagingComposer.MESSAGE');
115
+ const messageFieldRoot = messageField.closest('.MuiFormControl-root');
116
+ expect(messageFieldRoot).toBeTruthy();
117
+ expect(window.getComputedStyle(messageFieldRoot).minWidth).toBe('120px');
118
+ const sendButton = screen.getByRole('button', { name: 'MessagingComposer.SEND' });
119
+ expect(window.getComputedStyle(sendButton).flexShrink).toBe('0');
120
+ expect(screen.getByRole('button', { name: 'MessagingComposer.ADD_ATTACHMENT' }).disabled).toBe(false);
121
+ expect(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }).disabled).toBe(false);
122
+ expect(screen.getByRole('button', { name: 'MessagingPoll.CREATE' }).disabled).toBe(false);
123
+ expect(screen.getByRole('button', { name: 'MessagingAnnouncement.CREATE' }).disabled).toBe(false);
124
+
125
+ // Structural proof, not just a CSS-value check: the action icons and the
126
+ // message field/send button must sit in DIFFERENT Stack rows, not merely
127
+ // carry different sx values while still being flex siblings in one row
128
+ // (a same-row layout with only the sx tweaks would still pass every
129
+ // assertion above without actually fixing the 375px overflow).
130
+ const attachButton = screen.getByRole('button', { name: 'MessagingComposer.ADD_ATTACHMENT' });
131
+ const actionsRow = attachButton.closest('.MuiStack-root');
132
+ expect(actionsRow).toBeTruthy();
133
+ expect(actionsRow.contains(messageField)).toBe(false);
134
+ expect(actionsRow.contains(sendButton)).toBe(false);
135
+ });
111
136
  });
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, render, screen } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key, i18n: { language: 'en' } }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { MessageBubble } from '../src/messaging/MessageBubble';
10
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
11
+
12
+ function makeApi() {
13
+ return {
14
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
15
+ getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
16
+ votePoll: vi.fn(), closePoll: vi.fn(),
17
+ };
18
+ }
19
+ function renderMessage(body) {
20
+ return render(<MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ id: 1, body, sender: { display_name: 'Ava' } }} conversation={{ kind: 'group' }} /></MessagingProvider>);
21
+ }
22
+
23
+ afterEach(cleanup);
24
+
25
+ describe('message URL linkification', () => {
26
+ it('renders http(s) URLs as safe external links', () => {
27
+ renderMessage('Open https://example.com/path for details.');
28
+ const link = screen.getByRole('link', { name: 'https://example.com/path' });
29
+ expect(link.getAttribute('href')).toBe('https://example.com/path');
30
+ expect(link.getAttribute('target')).toBe('_blank');
31
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer');
32
+ });
33
+
34
+ it('does not turn javascript URLs into links', () => {
35
+ renderMessage('javascript:alert(1)');
36
+ expect(screen.queryByRole('link')).toBeNull();
37
+ expect(screen.getByText('javascript:alert(1)')).toBeTruthy();
38
+ });
39
+
40
+ it('leaves surrounding prose outside the link and trims sentence punctuation', () => {
41
+ const { container } = renderMessage('See https://example.com/path, then continue reading.');
42
+ const link = screen.getByRole('link');
43
+ expect(link.textContent).toBe('https://example.com/path');
44
+ // MUI's `Link` itself carries a `MuiTypography-root` class (it is a
45
+ // Typography variant), so `.closest('.MuiTypography-root')` would match
46
+ // the link, not its containing paragraph -- `.closest('p')` skips past it
47
+ // to the actual message-body Typography (MUI's default root tag for the
48
+ // unvaried `body1` variant used here).
49
+ expect(link.closest('p').textContent).toBe('See https://example.com/path, then continue reading.');
50
+ });
51
+ });
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.read != null ? `${key}:${options.read}/${options.total}` : key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
10
+ import { ReadTicks } from '../src/messaging/ReadTicks';
11
+
12
+ function makeApi(status) {
13
+ return {
14
+ getReadStatus: vi.fn().mockResolvedValue(status),
15
+ getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
16
+ votePoll: vi.fn(), closePoll: vi.fn(),
17
+ };
18
+ }
19
+
20
+ afterEach(cleanup);
21
+
22
+ describe('message read ratio marker', () => {
23
+ it('gives the visible ratio an explanatory tooltip', async () => {
24
+ render(<MessagingProvider api={makeApi({ read_count: 2, recipient_count: 5 })} active={false}><ReadTicks messageId={7} conversation={{ kind: 'group' }} /></MessagingProvider>);
25
+ expect(await screen.findByTitle('MessagingReadTicks.READ_RATIO:2/5')).toBeTruthy();
26
+ });
27
+
28
+ it('hides a ratio when there are no recipients', async () => {
29
+ let resolveStatus;
30
+ const api = { ...makeApi({}), getReadStatus: vi.fn().mockReturnValue(new Promise((resolve) => { resolveStatus = resolve; })) };
31
+ const { container } = render(<MessagingProvider api={api} active={false}><ReadTicks messageId={7} conversation={{ kind: 'group' }} /></MessagingProvider>);
32
+ await waitFor(() => expect(api.getReadStatus).toHaveBeenCalled());
33
+ await act(async () => resolveStatus({ read_count: 0, recipient_count: 0 }));
34
+ expect(container.textContent).toBe('');
35
+ });
36
+ });
@@ -0,0 +1,165 @@
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, waitFor } from '@testing-library/react';
5
+ import { I18nextProvider } from 'react-i18next';
6
+ import i18next from 'i18next';
7
+
8
+ const authApi = vi.hoisted(() => ({
9
+ deleteUser: vi.fn(),
10
+ fetchUsersList: vi.fn(),
11
+ sendAdminInvite: vi.fn(),
12
+ updateUserRole: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('../src/auth/authApi', () => authApi);
16
+
17
+ import { authTranslations } from '../src/i18n/authTranslations';
18
+ import { BulkInviteCsvTab } from '../src/components/BulkInviteCsvTab';
19
+ import { UserInviteComponent } from '../src/components/UserInviteComponent';
20
+ import { UserListComponent } from '../src/components/UserListComponent';
21
+
22
+ const germanResources = Object.fromEntries(
23
+ Object.entries(authTranslations).map(([key, value]) => [key, value.de]),
24
+ );
25
+ const i18n = i18next.createInstance();
26
+ i18n.init({
27
+ lng: 'de',
28
+ fallbackLng: 'de',
29
+ resources: { de: { translation: germanResources } },
30
+ keySeparator: false,
31
+ nsSeparator: false,
32
+ interpolation: { escapeValue: false },
33
+ });
34
+
35
+ const ENGLISH_DEFAULTS = [
36
+ 'All Users',
37
+ 'Search',
38
+ 'Search users...',
39
+ 'Rows per page:',
40
+ 'Successful Login',
41
+ 'Are you sure you want to delete this user?',
42
+ 'Invite a new user',
43
+ 'Bulk Invite via CSV',
44
+ 'Upload a CSV file containing email addresses. Header "email" is supported.',
45
+ 'Upload CSV',
46
+ 'Send Invites',
47
+ 'Invitation sent.',
48
+ ];
49
+
50
+ function withGermanI18n(children) {
51
+ return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
52
+ }
53
+
54
+ function expectNoEnglishDefaults() {
55
+ const text = document.body.textContent;
56
+ for (const english of ENGLISH_DEFAULTS) {
57
+ expect(text).not.toContain(english);
58
+ }
59
+ }
60
+
61
+ describe('German user and invite screen translations', () => {
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ authApi.fetchUsersList.mockResolvedValue([
65
+ {
66
+ id: 1,
67
+ email: 'person@example.com',
68
+ first_name: 'Ada',
69
+ role: 'student',
70
+ is_new: true,
71
+ successful_login: true,
72
+ },
73
+ ]);
74
+ authApi.sendAdminInvite.mockResolvedValue({});
75
+ });
76
+
77
+ afterEach(() => {
78
+ cleanup();
79
+ });
80
+
81
+ it('renders the empty-list and CSV-error strings in German too', async () => {
82
+ authApi.fetchUsersList.mockResolvedValue([]);
83
+ render(withGermanI18n(<UserListComponent currentUser={{ id: 99, is_superuser: true, role: 'admin' }} />));
84
+ await waitFor(() => expect(screen.getByText('Keine Benutzer gefunden.')).toBeTruthy());
85
+
86
+ cleanup();
87
+
88
+ render(withGermanI18n(<BulkInviteCsvTab inviteFn={vi.fn()} />));
89
+ const fileInput = document.querySelector('input[type="file"]');
90
+ fireEvent.change(fileInput, {
91
+ target: { files: [new File([''], 'empty.csv', { type: 'text/csv' })] },
92
+ });
93
+ await waitFor(() => expect(screen.getByText('Keine gültigen E-Mail-Adressen in der CSV-Datei gefunden.')).toBeTruthy());
94
+ expectNoEnglishDefaults();
95
+ });
96
+
97
+ it('renders UserListComponent in German, including pagination and action feedback', async () => {
98
+ const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
99
+ const alert = vi.spyOn(window, 'alert').mockImplementation(() => {});
100
+
101
+ render(withGermanI18n(
102
+ <UserListComponent
103
+ currentUser={{ id: 99, is_superuser: true, role: 'admin' }}
104
+ extraRowActions={[{
105
+ key: 'failing-action',
106
+ label: 'Aktion',
107
+ onClick: () => Promise.reject({}),
108
+ }]}
109
+ />,
110
+ ));
111
+
112
+ await waitFor(() => expect(screen.getByText('Alle Benutzer')).toBeTruthy());
113
+ expect(screen.getByLabelText('Suche')).toBeTruthy();
114
+ expect(screen.getByPlaceholderText('Benutzer suchen...')).toBeTruthy();
115
+ for (const german of ['Neu', 'Erfolgreiche Anmeldung', 'Rolle', 'Aktionen', 'Zeilen pro Seite:', 'Löschen']) {
116
+ expect(screen.getByText(german)).toBeTruthy();
117
+ }
118
+
119
+ fireEvent.click(screen.getByRole('button', { name: 'Löschen' }));
120
+ expect(confirm).toHaveBeenCalledWith('Möchten Sie diesen Benutzer wirklich löschen?');
121
+
122
+ fireEvent.click(screen.getByRole('button', { name: 'Aktion' }));
123
+ await waitFor(() => expect(alert).toHaveBeenCalledWith('Vorgang fehlgeschlagen.'));
124
+ expectNoEnglishDefaults();
125
+ });
126
+
127
+ it('renders UserInviteComponent in German and translates the success message', async () => {
128
+ render(withGermanI18n(<UserInviteComponent />));
129
+
130
+ expect(screen.getByText('Neuen Benutzer einladen')).toBeTruthy();
131
+ fireEvent.change(screen.getByLabelText('E-Mail-Adresse'), {
132
+ target: { value: 'new@example.com' },
133
+ });
134
+ fireEvent.click(screen.getByRole('button', { name: 'Einladen' }));
135
+
136
+ await waitFor(() => expect(screen.getByText('Einladung gesendet.')).toBeTruthy());
137
+ expectNoEnglishDefaults();
138
+ });
139
+
140
+ it('renders BulkInviteCsvTab in German with translated progress and table copy', async () => {
141
+ render(withGermanI18n(
142
+ <BulkInviteCsvTab inviteFn={vi.fn().mockResolvedValue({})} />,
143
+ ));
144
+
145
+ expect(screen.getByText('Masseneinladung per CSV')).toBeTruthy();
146
+ expect(screen.getByText('CSV-Datei mit E-Mail-Adressen hochladen. Header "email" wird unterstützt.')).toBeTruthy();
147
+ expect(screen.getByRole('button', { name: 'CSV hochladen' })).toBeTruthy();
148
+ expect(screen.getByRole('button', { name: 'Einladungen senden' })).toBeTruthy();
149
+
150
+ const fileInput = document.querySelector('input[type="file"]');
151
+ fireEvent.change(fileInput, {
152
+ target: { files: [new File(['email\nperson@example.com'], 'users.csv', { type: 'text/csv' })] },
153
+ });
154
+ await waitFor(() => expect(screen.getByText('1 E-Mail-Adressen geladen')).toBeTruthy());
155
+
156
+ fireEvent.click(screen.getByRole('button', { name: 'Einladungen senden' }));
157
+ await waitFor(() => expect(screen.getByText('1 / 1 Einladungen gesendet.')).toBeTruthy());
158
+ for (const german of ['Einladung gesendet.', 'Erfolgreich', 'Status', 'Details']) {
159
+ expect(screen.getByText(german)).toBeTruthy();
160
+ }
161
+ expect(document.body.textContent).toContain('1 / 1 verarbeitet');
162
+ expect(document.body.textContent).toContain('1 erfolgreich');
163
+ expectNoEnglishDefaults();
164
+ });
165
+ });