@micha.bigler/ui-core-micha 2.13.0 → 2.15.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.
- package/WORK_ORDERS.md +4 -2
- package/dist/components/charts/BarChart.js +32 -0
- package/dist/components/charts/ChartFrame.js +50 -0
- package/dist/components/charts/LineChart.js +32 -0
- package/dist/components/charts/exportChart.js +67 -0
- package/dist/components/charts/formatters.js +23 -0
- package/dist/components/charts/palette.js +21 -0
- package/dist/i18n/chartsTranslations.js +38 -0
- package/dist/i18n/notificationsTranslations.js +2 -0
- package/dist/index.js +12 -4
- package/dist/notifications/NotificationsProvider.js +13 -0
- package/dist/notifications/PopupSurface.js +84 -0
- package/dist/onboarding/OnboardingWizard.js +3 -12
- package/dist/onboarding/WizardDialogShell.js +29 -0
- package/package.json +3 -1
- package/src/components/charts/BarChart.jsx +44 -0
- package/src/components/charts/ChartFrame.jsx +112 -0
- package/src/components/charts/LineChart.jsx +44 -0
- package/src/components/charts/exportChart.js +71 -0
- package/src/components/charts/formatters.js +39 -0
- package/src/components/charts/palette.js +24 -0
- package/src/i18n/chartsTranslations.ts +38 -0
- package/src/i18n/notificationsTranslations.ts +2 -0
- package/src/index.js +19 -4
- package/src/notifications/NotificationsProvider.jsx +21 -0
- package/src/notifications/PopupSurface.jsx +103 -0
- package/src/onboarding/OnboardingWizard.jsx +9 -23
- package/src/onboarding/WizardDialogShell.jsx +51 -0
- package/tests/BarChart.test.jsx +55 -0
- package/tests/ChartFrame.test.jsx +122 -0
- package/tests/LineChart.test.jsx +33 -0
- package/tests/NotificationsProvider.test.jsx +38 -0
- package/tests/OnboardingWizard.test.jsx +143 -0
- package/tests/PopupSurface.test.jsx +142 -0
- package/tests/chartsPalette.test.js +39 -0
- package/tests/notificationsExports.test.js +5 -0
- package/work-orders/CHART-1.md +285 -0
- package/work-orders/NOTIF-12.md +75 -23
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
|
+
|
|
7
|
+
const chartSpy = vi.hoisted(() => vi.fn((props) => <div data-testid="mui-bar-chart" data-props={JSON.stringify(props)} />));
|
|
8
|
+
vi.mock('@mui/x-charts/BarChart', () => ({ BarChart: chartSpy }));
|
|
9
|
+
|
|
10
|
+
import { BarChart } from '../src/components/charts/BarChart';
|
|
11
|
+
|
|
12
|
+
function renderChart(props) {
|
|
13
|
+
return render(
|
|
14
|
+
<ThemeProvider theme={createTheme({ palette: { primary: { main: 'rgb(1, 2, 3)' } } })}>
|
|
15
|
+
<BarChart xAxisLabel="Year" yAxisLabel="Value (kg)" {...props} />
|
|
16
|
+
</ThemeProvider>,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('BarChart preset', () => {
|
|
21
|
+
afterEach(cleanup);
|
|
22
|
+
|
|
23
|
+
it('passes both axis labels, tooltip, responsive sizing, and hides a single-series legend', () => {
|
|
24
|
+
renderChart({ series: [{ data: [1, 2] }] });
|
|
25
|
+
const props = chartSpy.mock.calls[0][0];
|
|
26
|
+
|
|
27
|
+
expect(props.xAxis[0].label).toBe('Year');
|
|
28
|
+
expect(props.yAxis[0].label).toBe('Value (kg)');
|
|
29
|
+
expect(props.slotProps).toEqual({ tooltip: { trigger: 'axis' } });
|
|
30
|
+
expect(props.hideLegend).toBe(true);
|
|
31
|
+
expect(screen.getByTestId('bar-chart-container').getAttribute('style') || '').not.toContain('px');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it.each([
|
|
35
|
+
['grouped', [{ data: [1] }, { data: [2] }], undefined],
|
|
36
|
+
['stacked', [{ data: [1], stack: 'total' }, { data: [2], stack: 'total' }], undefined],
|
|
37
|
+
['dual axis', [{ data: [1], yAxisKey: 'secondary' }, { data: [2] }], [{ id: 'primary' }, { id: 'secondary' }]],
|
|
38
|
+
])('renders the %s variant with a legend for multiple series', (_name, series, yAxis) => {
|
|
39
|
+
renderChart({ series, yAxis });
|
|
40
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
41
|
+
expect(props.hideLegend).toBe(false);
|
|
42
|
+
expect(props.series).toEqual(series);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('passes a caller palette through without substituting a hardcoded colour', () => {
|
|
46
|
+
renderChart({ series: [{ data: [1] }], palette: ['var(--application-chart-colour)'] });
|
|
47
|
+
expect(chartSpy.mock.calls.at(-1)[0].colors).toEqual(['var(--application-chart-colour)']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('requires labels for both axes', () => {
|
|
51
|
+
expect(() => render(
|
|
52
|
+
<ThemeProvider theme={createTheme()}><BarChart series={[]} xAxisLabel="Year" /></ThemeProvider>,
|
|
53
|
+
)).toThrow('BarChart requires xAxisLabel and yAxisLabel.');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import i18next from 'i18next';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
6
|
+
import { I18nextProvider } from 'react-i18next';
|
|
7
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
8
|
+
|
|
9
|
+
const chartExport = vi.hoisted(() => ({
|
|
10
|
+
exportChartSvg: vi.fn(() => new Blob()),
|
|
11
|
+
exportChartPng: vi.fn(() => Promise.resolve(new Blob())),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('../src/components/charts/exportChart', () => chartExport);
|
|
15
|
+
|
|
16
|
+
import { ChartFrame } from '../src/components/charts/ChartFrame';
|
|
17
|
+
|
|
18
|
+
const resources = {
|
|
19
|
+
en: {
|
|
20
|
+
translation: {
|
|
21
|
+
'ChartFrame.LOADING': 'Loading chart.',
|
|
22
|
+
'ChartFrame.EMPTY_DEFAULT': 'No data available.',
|
|
23
|
+
'ChartFrame.ERROR_DEFAULT': 'The chart could not be loaded.',
|
|
24
|
+
'ChartFrame.EXPORT_SVG_LABEL': 'Export SVG',
|
|
25
|
+
'ChartFrame.EXPORT_PNG_LABEL': 'Export PNG',
|
|
26
|
+
'ChartFrame.EXPORT_ERROR': 'The chart could not be exported.',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
fr: {
|
|
30
|
+
translation: {
|
|
31
|
+
'ChartFrame.LOADING': 'Chargement du graphique.',
|
|
32
|
+
'ChartFrame.EMPTY_DEFAULT': 'Aucune donnée disponible.',
|
|
33
|
+
'ChartFrame.ERROR_DEFAULT': 'Impossible de charger le graphique.',
|
|
34
|
+
'ChartFrame.EXPORT_SVG_LABEL': 'Exporter en SVG',
|
|
35
|
+
'ChartFrame.EXPORT_PNG_LABEL': 'Exporter en PNG',
|
|
36
|
+
'ChartFrame.EXPORT_ERROR': 'Impossible d’exporter le graphique.',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function renderFrame(props = {}, language = 'en') {
|
|
42
|
+
const i18n = i18next.createInstance();
|
|
43
|
+
i18n.init({ lng: language, resources, interpolation: { escapeValue: false } });
|
|
44
|
+
return render(
|
|
45
|
+
<ThemeProvider theme={createTheme()}>
|
|
46
|
+
<I18nextProvider i18n={i18n}>
|
|
47
|
+
<ChartFrame title="Chart title" subtitle="Chart subtitle" {...props}>
|
|
48
|
+
<svg data-testid="chart-body" />
|
|
49
|
+
</ChartFrame>
|
|
50
|
+
</I18nextProvider>
|
|
51
|
+
</ThemeProvider>,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('ChartFrame', () => {
|
|
56
|
+
afterEach(cleanup);
|
|
57
|
+
|
|
58
|
+
it('renders its title, subtitle, toolbar, and accessible chart region', () => {
|
|
59
|
+
renderFrame({ toolbar: <button type="button">Period selector</button> });
|
|
60
|
+
|
|
61
|
+
expect(screen.getByText('Chart title')).toBeTruthy();
|
|
62
|
+
expect(screen.getByText('Chart subtitle')).toBeTruthy();
|
|
63
|
+
expect(screen.getByRole('button', { name: 'Period selector' })).toBeTruthy();
|
|
64
|
+
expect(screen.getByRole('img', { name: 'Chart title' })).toBeTruthy();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('lets a custom ariaLabel override the title as the accessible name', () => {
|
|
68
|
+
renderFrame({ ariaLabel: 'Custom accessible description' });
|
|
69
|
+
|
|
70
|
+
expect(screen.getByRole('img', { name: 'Custom accessible description' })).toBeTruthy();
|
|
71
|
+
expect(screen.queryByRole('img', { name: 'Chart title' })).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('enforces loading, error, empty, then children state precedence', () => {
|
|
75
|
+
const loading = renderFrame({ loading: true, error: 'Failure', isEmpty: true });
|
|
76
|
+
expect(screen.getByLabelText('Loading chart.')).toBeTruthy();
|
|
77
|
+
expect(screen.queryByText('Failure')).toBeNull();
|
|
78
|
+
expect(screen.queryByTestId('chart-body')).toBeNull();
|
|
79
|
+
loading.unmount();
|
|
80
|
+
|
|
81
|
+
const failure = renderFrame({ error: 'Failure', isEmpty: true });
|
|
82
|
+
expect(screen.getByText('Failure')).toBeTruthy();
|
|
83
|
+
expect(screen.queryByTestId('chart-body')).toBeNull();
|
|
84
|
+
failure.unmount();
|
|
85
|
+
|
|
86
|
+
renderFrame();
|
|
87
|
+
expect(screen.getByTestId('chart-body')).toBeTruthy();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('renders an empty message before children when empty', () => {
|
|
91
|
+
renderFrame({ isEmpty: true, emptyMessage: 'No observations' });
|
|
92
|
+
expect(screen.getByText('No observations')).toBeTruthy();
|
|
93
|
+
expect(screen.queryByTestId('chart-body')).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('shows export controls only when enabled and invokes the SVG callback', () => {
|
|
97
|
+
const onExportSvg = vi.fn();
|
|
98
|
+
const disabled = renderFrame();
|
|
99
|
+
expect(screen.queryByRole('button', { name: 'Export SVG' })).toBeNull();
|
|
100
|
+
disabled.unmount();
|
|
101
|
+
|
|
102
|
+
renderFrame({ exportOptions: { png: false }, onExportSvg });
|
|
103
|
+
fireEvent.click(screen.getByRole('button', { name: 'Export SVG' }));
|
|
104
|
+
expect(chartExport.exportChartSvg).toHaveBeenCalledOnce();
|
|
105
|
+
expect(onExportSvg).toHaveBeenCalledOnce();
|
|
106
|
+
expect(screen.queryByRole('button', { name: 'Export PNG' })).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('invokes the PNG callback when the PNG export control is used', async () => {
|
|
110
|
+
const onExportPng = vi.fn();
|
|
111
|
+
renderFrame({ exportOptions: true, onExportPng });
|
|
112
|
+
|
|
113
|
+
fireEvent.click(screen.getByRole('button', { name: 'Export PNG' }));
|
|
114
|
+
await vi.waitFor(() => expect(chartExport.exportChartPng).toHaveBeenCalledOnce());
|
|
115
|
+
await vi.waitFor(() => expect(onExportPng).toHaveBeenCalledOnce());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('uses resolved i18n strings for its own empty state', () => {
|
|
119
|
+
renderFrame({ isEmpty: true }, 'fr');
|
|
120
|
+
expect(screen.getByText('Aucune donnée disponible.')).toBeTruthy();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, render } from '@testing-library/react';
|
|
5
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
|
+
|
|
7
|
+
const chartSpy = vi.hoisted(() => vi.fn(() => <div data-testid="mui-line-chart" />));
|
|
8
|
+
vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: chartSpy }));
|
|
9
|
+
|
|
10
|
+
import { LineChart } from '../src/components/charts/LineChart';
|
|
11
|
+
|
|
12
|
+
describe('LineChart preset', () => {
|
|
13
|
+
afterEach(cleanup);
|
|
14
|
+
|
|
15
|
+
it('renders multi-series time data with labelled axes, legend, and tooltip', () => {
|
|
16
|
+
render(
|
|
17
|
+
<ThemeProvider theme={createTheme()}>
|
|
18
|
+
<LineChart
|
|
19
|
+
xAxisLabel="Date"
|
|
20
|
+
yAxisLabel="Rate (%)"
|
|
21
|
+
xAxis={[{ scaleType: 'point', data: ['2026-01', '2026-02'] }]}
|
|
22
|
+
series={[{ data: [1, 2] }, { data: [2, 3] }]}
|
|
23
|
+
/>
|
|
24
|
+
</ThemeProvider>,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const props = chartSpy.mock.calls[0][0];
|
|
28
|
+
expect(props.xAxis[0].label).toBe('Date');
|
|
29
|
+
expect(props.yAxis[0].label).toBe('Rate (%)');
|
|
30
|
+
expect(props.hideLegend).toBe(false);
|
|
31
|
+
expect(props.slotProps).toEqual({ tooltip: { trigger: 'axis' } });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -195,4 +195,42 @@ describe('NotificationsProvider', () => {
|
|
|
195
195
|
expect(currentNotifications()[0]).toEqual(expect.objectContaining({ done: true }));
|
|
196
196
|
expect(feedApi.markNotifications).toHaveBeenCalledWith({ action: 'done', ids: [1] });
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
it('folds two WS messages for the same notification_id into one feed entry with a single unread increment (NOTIF-12 D2)', async () => {
|
|
200
|
+
renderProvider();
|
|
201
|
+
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('2'));
|
|
202
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
203
|
+
|
|
204
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
205
|
+
type: 'payment_due',
|
|
206
|
+
channel: 'chip',
|
|
207
|
+
notification_id: 404,
|
|
208
|
+
content: { title_key: 'chip.title' },
|
|
209
|
+
}) });
|
|
210
|
+
await waitFor(() => expect(screen.getByTestId('unread-count').textContent).toBe('3'));
|
|
211
|
+
|
|
212
|
+
MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
|
|
213
|
+
type: 'payment_due',
|
|
214
|
+
channel: 'popup',
|
|
215
|
+
notification_id: 404,
|
|
216
|
+
content: { title_key: 'popup.title' },
|
|
217
|
+
}) });
|
|
218
|
+
|
|
219
|
+
await waitFor(() => expect(currentNotifications().find((n) => n.notification_id === 404).content)
|
|
220
|
+
.toEqual({ title_key: 'popup.title' }));
|
|
221
|
+
expect(currentNotifications().filter((n) => n.notification_id === 404)).toHaveLength(1);
|
|
222
|
+
expect(screen.getByTestId('unread-count').textContent).toBe('3');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('NOTIF-13 R1 guard: does not open the socket until the REST seed resolves', async () => {
|
|
226
|
+
let resolveFeed;
|
|
227
|
+
feedApi.getNotificationFeed.mockReturnValue(new Promise((resolve) => { resolveFeed = resolve; }));
|
|
228
|
+
|
|
229
|
+
renderProvider();
|
|
230
|
+
|
|
231
|
+
expect(MockWebSocket.instances).toHaveLength(0);
|
|
232
|
+
|
|
233
|
+
resolveFeed({ results: [] });
|
|
234
|
+
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
|
|
235
|
+
});
|
|
198
236
|
});
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({
|
|
7
|
+
useTranslation: () => ({
|
|
8
|
+
t: (key, params) => (params && Object.keys(params).length ? `${key}:${JSON.stringify(params)}` : key),
|
|
9
|
+
}),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const onboardingModule = vi.hoisted(() => ({ useOnboarding: vi.fn() }));
|
|
13
|
+
vi.mock('../src/onboarding/OnboardingProvider', () => onboardingModule);
|
|
14
|
+
|
|
15
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
16
|
+
import { OnboardingWizard } from '../src/onboarding/OnboardingWizard';
|
|
17
|
+
|
|
18
|
+
function StepBody({ onComplete, onDismiss }) {
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<button onClick={onComplete}>step-complete</button>
|
|
22
|
+
<button onClick={onDismiss}>step-dismiss</button>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeStep(overrides = {}) {
|
|
28
|
+
return {
|
|
29
|
+
id: 'step-1',
|
|
30
|
+
blocking: false,
|
|
31
|
+
persistDismissed: false,
|
|
32
|
+
Component: StepBody,
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderWizard({ onboarding, refreshUser = vi.fn().mockResolvedValue() } = {}) {
|
|
38
|
+
onboardingModule.useOnboarding.mockReturnValue(onboarding);
|
|
39
|
+
return render(
|
|
40
|
+
<AuthContext.Provider value={{ refreshUser }}>
|
|
41
|
+
<OnboardingWizard />
|
|
42
|
+
</AuthContext.Provider>,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('OnboardingWizard regression guard (NOTIF-12 shell extraction)', () => {
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
cleanup();
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders nothing when there is no active step', () => {
|
|
53
|
+
renderWizard({ onboarding: { activeSteps: [], markStepSeen: vi.fn(), dismissStep: vi.fn(), ctx: {} } });
|
|
54
|
+
|
|
55
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('shows the single-step SETUP title with no progress bar for exactly one step', () => {
|
|
59
|
+
renderWizard({ onboarding: {
|
|
60
|
+
activeSteps: [makeStep()],
|
|
61
|
+
markStepSeen: vi.fn(),
|
|
62
|
+
dismissStep: vi.fn(),
|
|
63
|
+
ctx: {},
|
|
64
|
+
} });
|
|
65
|
+
|
|
66
|
+
expect(screen.getByText('Onboarding.SETUP')).toBeTruthy();
|
|
67
|
+
expect(screen.queryByRole('progressbar')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('shows the step counter and progress bar across multiple steps, advancing on completion', () => {
|
|
71
|
+
renderWizard({ onboarding: {
|
|
72
|
+
activeSteps: [makeStep({ id: 'a' }), makeStep({ id: 'b' })],
|
|
73
|
+
markStepSeen: vi.fn(),
|
|
74
|
+
dismissStep: vi.fn(),
|
|
75
|
+
ctx: {},
|
|
76
|
+
} });
|
|
77
|
+
|
|
78
|
+
expect(screen.getByText('Onboarding.STEP_COUNTER:{"current":1,"total":2}')).toBeTruthy();
|
|
79
|
+
expect(screen.getByRole('progressbar')).toBeTruthy();
|
|
80
|
+
|
|
81
|
+
fireEvent.click(screen.getByRole('button', { name: 'step-complete' }));
|
|
82
|
+
|
|
83
|
+
expect(screen.getByText('Onboarding.STEP_COUNTER:{"current":2,"total":2}')).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('marks the current step seen and session-dismisses it once completed, revealing the next step', () => {
|
|
87
|
+
const markStepSeen = vi.fn();
|
|
88
|
+
renderWizard({ onboarding: {
|
|
89
|
+
activeSteps: [makeStep({ id: 'a' }), makeStep({ id: 'b' })],
|
|
90
|
+
markStepSeen,
|
|
91
|
+
dismissStep: vi.fn(),
|
|
92
|
+
ctx: {},
|
|
93
|
+
} });
|
|
94
|
+
|
|
95
|
+
expect(markStepSeen).toHaveBeenCalledWith('a');
|
|
96
|
+
|
|
97
|
+
fireEvent.click(screen.getByRole('button', { name: 'step-complete' }));
|
|
98
|
+
|
|
99
|
+
expect(markStepSeen).toHaveBeenCalledWith('b');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('disables escape-key close for a blocking step', () => {
|
|
103
|
+
renderWizard({ onboarding: {
|
|
104
|
+
activeSteps: [makeStep({ blocking: true })],
|
|
105
|
+
markStepSeen: vi.fn(),
|
|
106
|
+
dismissStep: vi.fn(),
|
|
107
|
+
ctx: {},
|
|
108
|
+
} });
|
|
109
|
+
|
|
110
|
+
const dialog = screen.getByRole('dialog');
|
|
111
|
+
fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' });
|
|
112
|
+
|
|
113
|
+
expect(screen.getByRole('dialog')).toBeTruthy();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('calls dismissStep only when the dismissed step is persistDismissed', () => {
|
|
117
|
+
const dismissStep = vi.fn();
|
|
118
|
+
renderWizard({ onboarding: {
|
|
119
|
+
activeSteps: [makeStep({ persistDismissed: true })],
|
|
120
|
+
markStepSeen: vi.fn(),
|
|
121
|
+
dismissStep,
|
|
122
|
+
ctx: {},
|
|
123
|
+
} });
|
|
124
|
+
|
|
125
|
+
fireEvent.click(screen.getByRole('button', { name: 'step-dismiss' }));
|
|
126
|
+
|
|
127
|
+
expect(dismissStep).toHaveBeenCalledWith('step-1');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('does not call dismissStep for a non-persistDismissed step on dismiss', () => {
|
|
131
|
+
const dismissStep = vi.fn();
|
|
132
|
+
renderWizard({ onboarding: {
|
|
133
|
+
activeSteps: [makeStep({ persistDismissed: false })],
|
|
134
|
+
markStepSeen: vi.fn(),
|
|
135
|
+
dismissStep,
|
|
136
|
+
ctx: {},
|
|
137
|
+
} });
|
|
138
|
+
|
|
139
|
+
fireEvent.click(screen.getByRole('button', { name: 'step-dismiss' }));
|
|
140
|
+
|
|
141
|
+
expect(dismissStep).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({
|
|
7
|
+
useTranslation: () => ({
|
|
8
|
+
t: (key, params) => (params && Object.keys(params).length ? `${key}:${JSON.stringify(params)}` : key),
|
|
9
|
+
}),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const onboardingModule = vi.hoisted(() => ({ useOnboarding: vi.fn() }));
|
|
13
|
+
vi.mock('../src/onboarding/OnboardingProvider', () => onboardingModule);
|
|
14
|
+
|
|
15
|
+
const notificationsModule = vi.hoisted(() => ({ useNotifications: vi.fn() }));
|
|
16
|
+
vi.mock('../src/notifications/NotificationsProvider', () => notificationsModule);
|
|
17
|
+
|
|
18
|
+
const realtimeModule = vi.hoisted(() => ({ useRealtime: vi.fn(), DEFAULT_ENVELOPE: 'notification' }));
|
|
19
|
+
vi.mock('../src/notifications/realtime', () => realtimeModule);
|
|
20
|
+
|
|
21
|
+
import { PopupSurface } from '../src/notifications/PopupSurface';
|
|
22
|
+
|
|
23
|
+
describe('PopupSurface (NOTIF-12 scope B/C)', () => {
|
|
24
|
+
let handler;
|
|
25
|
+
let subscribeFn;
|
|
26
|
+
let markSeen;
|
|
27
|
+
let markDismissed;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
handler = null;
|
|
32
|
+
subscribeFn = (envelope, fn) => {
|
|
33
|
+
handler = fn;
|
|
34
|
+
return () => { handler = null; };
|
|
35
|
+
};
|
|
36
|
+
markSeen = vi.fn();
|
|
37
|
+
markDismissed = vi.fn();
|
|
38
|
+
notificationsModule.useNotifications.mockReturnValue({ markSeen, markDismissed, markDone: vi.fn() });
|
|
39
|
+
onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [] });
|
|
40
|
+
realtimeModule.useRealtime.mockImplementation(() => ({ subscribe: subscribeFn }));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function push(data) {
|
|
48
|
+
act(() => {
|
|
49
|
+
handler(data);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it('renders nothing when no popup-eligible notification is present', () => {
|
|
54
|
+
render(<PopupSurface />);
|
|
55
|
+
|
|
56
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('renders a popup-eligible notification once, marking seen exactly once on display, and shows the notification title (not the onboarding header)', () => {
|
|
60
|
+
render(<PopupSurface />);
|
|
61
|
+
|
|
62
|
+
push({ notification_id: 1, channel: 'popup', content: { title_key: 'T', body_key: 'B', params: {} } });
|
|
63
|
+
|
|
64
|
+
expect(screen.getByRole('dialog')).toBeTruthy();
|
|
65
|
+
expect(screen.getByText('T')).toBeTruthy();
|
|
66
|
+
expect(screen.queryByText('Onboarding.SETUP')).toBeNull();
|
|
67
|
+
expect(markSeen).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(markSeen).toHaveBeenCalledWith([1]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('marks using the wire recipient_id (feed/mark/ id-space), not the notification_id, when present', () => {
|
|
72
|
+
render(<PopupSurface />);
|
|
73
|
+
|
|
74
|
+
push({ notification_id: 10, recipient_id: 999, channel: 'popup', content: { title_key: 'T' } });
|
|
75
|
+
|
|
76
|
+
expect(markSeen).toHaveBeenCalledWith([999]);
|
|
77
|
+
|
|
78
|
+
fireEvent.click(screen.getByRole('button', { name: 'PopupSurface.CLOSE' }));
|
|
79
|
+
|
|
80
|
+
expect(markDismissed).toHaveBeenCalledWith([999]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('fires markDismissed, never markDone, when the popup is closed', () => {
|
|
84
|
+
const { markDone } = notificationsModule.useNotifications();
|
|
85
|
+
render(<PopupSurface />);
|
|
86
|
+
push({ notification_id: 1, channel: 'popup', content: { title_key: 'T' } });
|
|
87
|
+
|
|
88
|
+
fireEvent.click(screen.getByRole('button', { name: 'PopupSurface.CLOSE' }));
|
|
89
|
+
|
|
90
|
+
expect(markDismissed).toHaveBeenCalledWith([1]);
|
|
91
|
+
expect(markDone).not.toHaveBeenCalled();
|
|
92
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('ignores a chip-channel message and a channel-less legacy message — no popup storm', () => {
|
|
96
|
+
render(<PopupSurface />);
|
|
97
|
+
|
|
98
|
+
push({ notification_id: 2, channel: 'chip', content: { title_key: 'T' } });
|
|
99
|
+
push({ notification_id: 3, content: { title_key: 'T' } });
|
|
100
|
+
|
|
101
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
102
|
+
expect(markSeen).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('ignores a notification.status message regardless of its channel field', () => {
|
|
106
|
+
render(<PopupSurface />);
|
|
107
|
+
|
|
108
|
+
push({ type: 'notification.status', notification_id: 4, channel: 'popup', status: {} });
|
|
109
|
+
|
|
110
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('coexistence: a blocking onboarding step suppresses the popup; it appears once the step clears, never stacking two dialogs', () => {
|
|
114
|
+
onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [{ id: 'cookie_consent', blocking: true }] });
|
|
115
|
+
const { rerender } = render(<PopupSurface />);
|
|
116
|
+
|
|
117
|
+
push({ notification_id: 5, channel: 'popup', content: { title_key: 'T' } });
|
|
118
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
119
|
+
expect(markSeen).not.toHaveBeenCalled();
|
|
120
|
+
|
|
121
|
+
onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [] });
|
|
122
|
+
rerender(<PopupSurface />);
|
|
123
|
+
|
|
124
|
+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
|
125
|
+
expect(markSeen).toHaveBeenCalledWith([5]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('coexistence: a non-blocking onboarding step also suppresses the popup, never stacking two dialogs', () => {
|
|
129
|
+
onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [{ id: 'browser_push', blocking: false }] });
|
|
130
|
+
const { rerender } = render(<PopupSurface />);
|
|
131
|
+
|
|
132
|
+
push({ notification_id: 6, channel: 'popup', content: { title_key: 'T' } });
|
|
133
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
134
|
+
expect(markSeen).not.toHaveBeenCalled();
|
|
135
|
+
|
|
136
|
+
onboardingModule.useOnboarding.mockReturnValue({ activeSteps: [] });
|
|
137
|
+
rerender(<PopupSurface />);
|
|
138
|
+
|
|
139
|
+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
|
140
|
+
expect(markSeen).toHaveBeenCalledWith([6]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createChartFormatters,
|
|
4
|
+
formatCompact,
|
|
5
|
+
formatPercentage,
|
|
6
|
+
getNeutralChartPalette,
|
|
7
|
+
} from '../src/index';
|
|
8
|
+
|
|
9
|
+
describe('chart palette and formatters', () => {
|
|
10
|
+
it('resolves different neutral palettes from light and dark MUI themes without literal API colours', () => {
|
|
11
|
+
const tokenTheme = (mode) => ({
|
|
12
|
+
palette: {
|
|
13
|
+
mode,
|
|
14
|
+
primary: { main: `${mode}-primary-main`, light: `${mode}-primary-light`, dark: `${mode}-primary-dark` },
|
|
15
|
+
secondary: { main: `${mode}-secondary-main`, light: `${mode}-secondary-light` },
|
|
16
|
+
info: { main: `${mode}-info-main`, light: `${mode}-info-light` },
|
|
17
|
+
success: { main: `${mode}-success-main`, light: `${mode}-success-light` },
|
|
18
|
+
warning: { main: `${mode}-warning-main`, light: `${mode}-warning-light` },
|
|
19
|
+
action: { disabled: `${mode}-action-disabled` },
|
|
20
|
+
text: { secondary: `${mode}-text-secondary` },
|
|
21
|
+
divider: `${mode}-divider`,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const light = getNeutralChartPalette(tokenTheme('light'));
|
|
25
|
+
const dark = getNeutralChartPalette(tokenTheme('dark'));
|
|
26
|
+
|
|
27
|
+
expect(light.categorical).not.toEqual(dark.categorical);
|
|
28
|
+
expect(light.categorical).toHaveLength(5);
|
|
29
|
+
expect(light.categorical).toContain('light-primary-main');
|
|
30
|
+
expect(light.categorical.every((colour) => !colour.includes('#'))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('formats percentage and compact numbers according to the requested locale', () => {
|
|
34
|
+
expect(formatPercentage(0.25, 'en-US')).toBe('25%');
|
|
35
|
+
expect(formatPercentage(0.25, 'de-DE')).toContain('25');
|
|
36
|
+
expect(formatCompact(12500, 'en-US')).toMatch(/12.5K|13K/);
|
|
37
|
+
expect(createChartFormatters('fr-FR').ratio(1.5)).toContain('1');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
NotificationSettings,
|
|
5
|
+
PopupSurface,
|
|
5
6
|
getNotificationPreferences,
|
|
6
7
|
getVapidPublicKey,
|
|
7
8
|
listPushSubscriptions,
|
|
@@ -26,4 +27,8 @@ describe('notification barrel exports', () => {
|
|
|
26
27
|
notificationsTranslations,
|
|
27
28
|
].forEach((value) => expect(value).toBeDefined());
|
|
28
29
|
});
|
|
30
|
+
|
|
31
|
+
it('additively exports the NOTIF-12 popup surface', () => {
|
|
32
|
+
expect(PopupSurface).toBeDefined();
|
|
33
|
+
});
|
|
29
34
|
});
|