@micha.bigler/ui-core-micha 2.14.0 → 2.16.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 (71) hide show
  1. package/README.md +7 -0
  2. package/WORK_ORDERS.md +8 -2
  3. package/dev/entries.jsx +147 -0
  4. package/dev/fixtures.js +26 -0
  5. package/dev/main.jsx +56 -0
  6. package/dev/mockTransport.jsx +128 -0
  7. package/dist/components/charts/BarChart.js +32 -0
  8. package/dist/components/charts/ChartFrame.js +50 -0
  9. package/dist/components/charts/LineChart.js +32 -0
  10. package/dist/components/charts/exportChart.js +67 -0
  11. package/dist/components/charts/formatters.js +23 -0
  12. package/dist/components/charts/palette.js +21 -0
  13. package/dist/i18n/chartsTranslations.js +38 -0
  14. package/dist/i18n/messagingTranslations.js +81 -0
  15. package/dist/index.js +25 -4
  16. package/dist/messaging/AttachmentList.js +43 -0
  17. package/dist/messaging/Composer.js +120 -0
  18. package/dist/messaging/ConversationLaunchers.js +39 -0
  19. package/dist/messaging/ConversationList.js +75 -0
  20. package/dist/messaging/MessageBubble.js +17 -0
  21. package/dist/messaging/MessagingProvider.js +317 -0
  22. package/dist/messaging/MessagingScopeConfig.js +28 -0
  23. package/dist/messaging/PollCard.js +53 -0
  24. package/dist/messaging/ReactionBar.js +44 -0
  25. package/dist/messaging/ReadTicks.js +25 -0
  26. package/dist/messaging/Thread.js +73 -0
  27. package/dist/messaging/api.js +51 -0
  28. package/dist/notifications/NotificationsProvider.js +2 -2
  29. package/dist/notifications/realtime.js +21 -1
  30. package/docs/messaging-deviations.md +36 -0
  31. package/index.html +12 -0
  32. package/package.json +6 -1
  33. package/src/components/charts/BarChart.jsx +44 -0
  34. package/src/components/charts/ChartFrame.jsx +112 -0
  35. package/src/components/charts/LineChart.jsx +44 -0
  36. package/src/components/charts/exportChart.js +71 -0
  37. package/src/components/charts/formatters.js +39 -0
  38. package/src/components/charts/palette.js +24 -0
  39. package/src/i18n/chartsTranslations.ts +38 -0
  40. package/src/i18n/messagingTranslations.ts +81 -0
  41. package/src/index.js +33 -4
  42. package/src/messaging/AttachmentList.jsx +38 -0
  43. package/src/messaging/Composer.jsx +110 -0
  44. package/src/messaging/ConversationLaunchers.jsx +51 -0
  45. package/src/messaging/ConversationList.jsx +78 -0
  46. package/src/messaging/MessageBubble.jsx +37 -0
  47. package/src/messaging/MessagingProvider.jsx +323 -0
  48. package/src/messaging/MessagingScopeConfig.jsx +24 -0
  49. package/src/messaging/PollCard.jsx +36 -0
  50. package/src/messaging/ReactionBar.jsx +46 -0
  51. package/src/messaging/ReadTicks.jsx +23 -0
  52. package/src/messaging/Thread.jsx +71 -0
  53. package/src/messaging/api.js +47 -0
  54. package/src/notifications/NotificationsProvider.jsx +2 -2
  55. package/src/notifications/realtime.jsx +21 -1
  56. package/tests/BarChart.test.jsx +55 -0
  57. package/tests/ChartFrame.test.jsx +122 -0
  58. package/tests/ConversationList.test.jsx +66 -0
  59. package/tests/LineChart.test.jsx +33 -0
  60. package/tests/MessagingProvider.test.jsx +50 -0
  61. package/tests/Thread.test.jsx +100 -0
  62. package/tests/chartsPalette.test.js +39 -0
  63. package/tests/messagingApi.test.js +32 -0
  64. package/tests/messagingComposer.test.jsx +111 -0
  65. package/tests/messagingExports.test.js +20 -0
  66. package/tests/messagingInteractions.test.jsx +112 -0
  67. package/tests/realtime.test.jsx +27 -0
  68. package/vite.config.mjs +6 -0
  69. package/work-orders/CHART-1.md +285 -0
  70. package/work-orders/DX-1.md +166 -0
  71. package/work-orders/MSG-3.md +375 -0
@@ -33,6 +33,7 @@ export { RealtimeContext };
33
33
  export function useRealtimeCore({ active, wsUrl }) {
34
34
  const socketRef = useRef(null);
35
35
  const subscribersRef = useRef(new Map());
36
+ const reconnectSubscribersRef = useRef(new Set());
36
37
 
37
38
  const subscribe = useCallback((envelope, handler) => {
38
39
  const subscribers = subscribersRef.current;
@@ -50,12 +51,21 @@ export function useRealtimeCore({ active, wsUrl }) {
50
51
  };
51
52
  }, []);
52
53
 
54
+ // Additive lifecycle signal for domains whose REST projections must be
55
+ // refreshed after the one shared transport reconnects. It deliberately does
56
+ // not alter the socket/backoff ownership or the envelope subscriber API.
57
+ const onReconnect = useCallback((handler) => {
58
+ reconnectSubscribersRef.current.add(handler);
59
+ return () => reconnectSubscribersRef.current.delete(handler);
60
+ }, []);
61
+
53
62
  useEffect(() => {
54
63
  if (!active) return undefined;
55
64
 
56
65
  let isActive = true;
57
66
  let reconnectTimer = null;
58
67
  let backoffMs = INITIAL_BACKOFF_MS;
68
+ let hasConnected = false;
59
69
 
60
70
  const handleMessage = (event) => {
61
71
  let data;
@@ -85,6 +95,16 @@ export function useRealtimeCore({ active, wsUrl }) {
85
95
 
86
96
  socket.onopen = () => {
87
97
  backoffMs = INITIAL_BACKOFF_MS;
98
+ if (hasConnected) {
99
+ reconnectSubscribersRef.current.forEach((handler) => {
100
+ try {
101
+ handler();
102
+ } catch (error) {
103
+ console.error('Realtime reconnect subscriber error', error);
104
+ }
105
+ });
106
+ }
107
+ hasConnected = true;
88
108
  };
89
109
  socket.onmessage = handleMessage;
90
110
  socket.onerror = () => {
@@ -114,5 +134,5 @@ export function useRealtimeCore({ active, wsUrl }) {
114
134
  };
115
135
  }, [active, wsUrl]);
116
136
 
117
- return { subscribe };
137
+ return { subscribe, onReconnect };
118
138
  }
@@ -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,66 @@
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
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { ConversationList } from '../src/messaging/ConversationList';
10
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
11
+
12
+ function renderList(api, props = {}) {
13
+ return render(<MessagingProvider api={api}><ConversationList onOpen={props.onOpen} {...props} /></MessagingProvider>);
14
+ }
15
+
16
+ describe('ConversationList', () => {
17
+ let api;
18
+ beforeEach(() => {
19
+ api = {
20
+ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, title: 'First', unread_count: 2, last_message: { body: 'Hello' } }], next_cursor: 'opaque-cursor' }),
21
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 2, by_conversation: { 1: 2 } }),
22
+ listMessages: vi.fn().mockResolvedValue({ results: [] }),
23
+ archiveConversation: vi.fn().mockResolvedValue({ id: 1, archived: true }),
24
+ patchConversationPreferences: vi.fn().mockResolvedValue({ id: 1, muted: true }),
25
+ createGroupConversation: vi.fn().mockResolvedValue({ id: 4, title: 'Volunteers', kind: 'group' }),
26
+ createBroadcastConversation: vi.fn().mockResolvedValue({ id: 5, title: 'Announcements', kind: 'broadcast' }),
27
+ };
28
+ });
29
+ afterEach(cleanup);
30
+
31
+ it('loads the next opaque cursor page without offset assumptions', async () => {
32
+ api.listConversations.mockResolvedValueOnce({ results: [{ id: 1, title: 'First' }], next_cursor: 'opaque-cursor' }).mockResolvedValueOnce({ results: [{ id: 2, title: 'Second' }], next_cursor: null });
33
+ renderList(api);
34
+ await screen.findByText('First');
35
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingList.LOAD_MORE' }));
36
+ await screen.findByText('Second');
37
+ // Append, not replace: the first page's item must still be present.
38
+ expect(screen.getByText('First')).toBeTruthy();
39
+ expect(api.listConversations).toHaveBeenLastCalledWith({ cursor: 'opaque-cursor' });
40
+ });
41
+
42
+ it('opens host-provided group and broadcast launchers into normalized list state', async () => {
43
+ const onOpen = vi.fn();
44
+ renderList(api, { onOpen, groupLaunchers: [{ id: 'volunteers', label: 'Volunteers', payload: { title: 'Volunteers' } }], broadcastLauncher: { label: 'Announcements', payload: {} } });
45
+ await screen.findByText('First');
46
+ fireEvent.click(screen.getByRole('button', { name: 'Volunteers' }));
47
+ await waitFor(() => expect(api.createGroupConversation).toHaveBeenCalledWith({ title: 'Volunteers' }));
48
+ expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: 4 }));
49
+ fireEvent.click(screen.getByRole('button', { name: 'Announcements' }));
50
+ await waitFor(() => expect(api.createBroadcastConversation).toHaveBeenCalled());
51
+ // Once opened it becomes a normal (unread/preview-bearing) list row; the
52
+ // static launcher button must not keep showing it a second time.
53
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Announcements' })).toBeNull());
54
+ });
55
+
56
+ it('wires mute and archive through participant APIs', async () => {
57
+ renderList(api);
58
+ await screen.findByText('First');
59
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingList.ACTIONS' }));
60
+ fireEvent.click(screen.getByText('MessagingList.MUTE'));
61
+ await waitFor(() => expect(api.patchConversationPreferences).toHaveBeenCalledWith(1, { muted: true }));
62
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingList.ACTIONS' }));
63
+ fireEvent.click(screen.getByText('MessagingList.ARCHIVE'));
64
+ await waitFor(() => expect(api.archiveConversation).toHaveBeenCalledWith(1, true));
65
+ });
66
+ });
@@ -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
+ });
@@ -0,0 +1,50 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { act, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ import { RealtimeContext } from '../src/notifications/realtime';
7
+ import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
8
+
9
+ function Harness() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
10
+ function makeTransport() {
11
+ const handlers = new Set(); const reconnectHandlers = new Set();
12
+ return { subscribe: vi.fn((_envelope, handler) => { handlers.add(handler); return () => handlers.delete(handler); }), onReconnect: vi.fn((handler) => { reconnectHandlers.add(handler); return () => reconnectHandlers.delete(handler); }), frame: (frame) => handlers.forEach((handler) => handler(frame)), reconnect: () => reconnectHandlers.forEach((handler) => handler()) };
13
+ }
14
+ function renderProvider({ transport = makeTransport(), api, activeConversationId = 1, active } = {}) {
15
+ const props = { api, activeConversationId, ...(active === undefined ? {} : { active }) };
16
+ render(<RealtimeContext.Provider value={transport}><MessagingProvider {...props}><Harness /></MessagingProvider></RealtimeContext.Provider>);
17
+ return transport;
18
+ }
19
+ const cache = () => JSON.parse(screen.getByTestId('cache').textContent);
20
+
21
+ describe('MessagingProvider', () => {
22
+ it('deduplicates repeated event IDs and only changes the affected message slice', async () => {
23
+ const api = { listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1 }, { id: 2 }] }), listMessages: vi.fn().mockResolvedValue({ results: [{ id: 10, conversation_id: 2, body: 'unchanged' }] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }) };
24
+ const transport = renderProvider({ api }); await waitFor(() => expect(api.listConversations).toHaveBeenCalled());
25
+ const frame = { envelope: 'messaging', type: 'message', event_id: 'once', conversation_id: 1, message: { id: 11, body: 'new' } };
26
+ act(() => { transport.frame(frame); transport.frame(frame); });
27
+ expect(Object.keys(cache().messages)).toEqual(['10', '11']);
28
+ expect(cache().messages['10'].body).toBe('unchanged');
29
+ });
30
+
31
+ it('refetches list, active thread and unread state when Layer 1 reconnects', async () => {
32
+ const api = { listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }) };
33
+ const transport = renderProvider({ api }); await waitFor(() => expect(api.listMessages).toHaveBeenCalledTimes(1));
34
+ await act(async () => transport.reconnect());
35
+ await waitFor(() => { expect(api.listConversations).toHaveBeenCalledTimes(2); expect(api.listMessages).toHaveBeenLastCalledWith(1); expect(api.getUnreadCount).toHaveBeenCalledTimes(2); });
36
+ });
37
+
38
+ it('does not call the authenticated-only REST surface while active=false, and starts fetching once it flips true', async () => {
39
+ const api = { listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }) };
40
+ const transport = makeTransport();
41
+ const { rerender } = render(<RealtimeContext.Provider value={transport}><MessagingProvider api={api} activeConversationId={1} active={false}><Harness /></MessagingProvider></RealtimeContext.Provider>);
42
+ await new Promise((resolve) => setTimeout(resolve, 0));
43
+ expect(api.listConversations).not.toHaveBeenCalled();
44
+ expect(api.listMessages).not.toHaveBeenCalled();
45
+ expect(api.getUnreadCount).not.toHaveBeenCalled();
46
+
47
+ rerender(<RealtimeContext.Provider value={transport}><MessagingProvider api={api} activeConversationId={1} active><Harness /></MessagingProvider></RealtimeContext.Provider>);
48
+ await waitFor(() => expect(api.listConversations).toHaveBeenCalledTimes(1));
49
+ });
50
+ });
@@ -0,0 +1,100 @@
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
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : key }) }));
7
+ vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
8
+
9
+ import { AuthContext } from '../src/auth/AuthContext';
10
+ import { MessageBubble } from '../src/messaging/MessageBubble';
11
+ import { MessagingProvider } from '../src/messaging/MessagingProvider';
12
+ import { ReadTicks } from '../src/messaging/ReadTicks';
13
+ import { Thread } from '../src/messaging/Thread';
14
+
15
+ function makeApi() {
16
+ return {
17
+ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, kind: 'group' }] }),
18
+ getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
19
+ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 2, conversation_id: 1, body: 'Visible message', created_at: '2026-07-31T10:00:00Z' }], next_cursor: 'signed-opaque-cursor' }),
20
+ listThread: vi.fn().mockResolvedValue({ results: [] }),
21
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
22
+ };
23
+ }
24
+ function renderThread(api, { userId = 9 } = {}) {
25
+ return render(
26
+ <AuthContext.Provider value={{ user: { id: userId } }}>
27
+ <MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider>
28
+ </AuthContext.Provider>,
29
+ );
30
+ }
31
+
32
+ describe('messaging Thread surfaces', () => {
33
+ let api;
34
+ beforeEach(() => { api = makeApi(); });
35
+ afterEach(cleanup);
36
+
37
+ it('uses the opaque message cursor and shows a rejected older page as an error', async () => {
38
+ api.listMessages.mockResolvedValueOnce({ results: [{ id: 2, conversation_id: 1, body: 'Visible message' }], next_cursor: 'signed-opaque-cursor' }).mockRejectedValueOnce(new Error('bad cursor'));
39
+ renderThread(api);
40
+ await screen.findByText('Visible message');
41
+ fireEvent.click(screen.getByRole('button', { name: 'MessagingThread.LOAD_OLDER' }));
42
+ await screen.findByRole('alert');
43
+ expect(api.listMessages).toHaveBeenLastCalledWith(1, { cursor: 'signed-opaque-cursor' });
44
+ expect(screen.getByText('Visible message')).toBeTruthy();
45
+ });
46
+
47
+ it('renders deleted content as a redacted placeholder', () => {
48
+ render(<MessageBubble message={{ id: 4, body: 'Stale secret', deleted_at: '2026-07-31T10:00:00Z' }} />);
49
+ expect(screen.getByText('MessagingThread.DELETED')).toBeTruthy();
50
+ expect(screen.queryByText('Stale secret')).toBeNull();
51
+ });
52
+
53
+ it('renders aggregate read state and never exposes supplied recipient details for a direct conversation', async () => {
54
+ api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Private recipient' }] });
55
+ render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'direct' }} /></MessagingProvider>);
56
+ await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
57
+ expect(screen.queryByText('Private recipient')).toBeNull();
58
+ });
59
+
60
+ it('renders supplied recipient detail for a non-direct conversation (the permitted case)', async () => {
61
+ api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Visible recipient' }] });
62
+ render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
63
+ const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
64
+ fireEvent.mouseOver(tick);
65
+ await screen.findByText('Visible recipient');
66
+ });
67
+
68
+ it('only mounts ReadTicks (and its REST call) for the current user\'s own messages, not every message in the timeline', async () => {
69
+ api.listMessages.mockResolvedValue({
70
+ results: [
71
+ { id: 2, conversation_id: 1, body: 'Mine', created_at: '2026-07-31T10:00:00Z', sender: { id: 9 } },
72
+ { id: 3, conversation_id: 1, body: 'Theirs', created_at: '2026-07-31T10:01:00Z', sender: { id: 42 } },
73
+ ],
74
+ next_cursor: null,
75
+ });
76
+ renderThread(api, { userId: 9 });
77
+ await screen.findByText('Mine');
78
+ await screen.findByText('Theirs');
79
+ await waitFor(() => expect(api.getReadStatus).toHaveBeenCalledTimes(1));
80
+ expect(api.getReadStatus).toHaveBeenCalledWith(2);
81
+ });
82
+
83
+ it('does not mount ReadTicks (or fire a REST call) for a still-pending optimistic own message', async () => {
84
+ api.listMessages.mockResolvedValue({
85
+ results: [{ id: 'local-r9', conversation_id: 1, body: 'Sending...', created_at: '2026-07-31T10:00:00Z', sender: { id: 9 }, status: 'pending', client_request_id: 'r9' }],
86
+ next_cursor: null,
87
+ });
88
+ renderThread(api, { userId: 9 });
89
+ await screen.findByText('Sending...');
90
+ expect(api.getReadStatus).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('mounts Thread and ReadTicks independently', async () => {
94
+ renderThread(api);
95
+ await screen.findByLabelText('MessagingThread.TIMELINE');
96
+ cleanup();
97
+ render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
98
+ await waitFor(() => expect(screen.getByLabelText('MessagingReadTicks.DELIVERED:2')).toBeTruthy());
99
+ });
100
+ });
@@ -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
+ });
@@ -0,0 +1,32 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() }));
4
+ vi.mock('../src/auth/apiClient', () => ({ default: client }));
5
+
6
+ import * as api from '../src/messaging/api';
7
+
8
+ const response = { data: { ok: true } };
9
+ beforeEach(() => { vi.clearAllMocks(); Object.values(client).forEach((method) => method.mockResolvedValue(response)); });
10
+
11
+ describe('messaging API contract', () => {
12
+ it('uses the published conversations and message routes', async () => {
13
+ await api.listConversations({ scope_kind: 'container', cursor: 'opaque' });
14
+ await api.createDirectConversation({ target_user_id: 8 });
15
+ await api.createGroupConversation({ title: 'Group' }); await api.createBroadcastConversation({}); await api.createManagedConversation({}); await api.createObjectThreadConversation({});
16
+ await api.getConversationConfig(1); await api.patchConversationConfig(1, {}); await api.archiveConversation(1, true); await api.patchConversationPreferences(1, {});
17
+ await api.listMessages(1, { cursor: 'opaque' }); await api.createMessage(1, { body: 'x' }, { idempotencyKey: 'key' }); await api.uploadAttachments(1, new FormData()); await api.createPoll(1, {});
18
+ expect(client.get).toHaveBeenCalledWith('/api/messaging/conversations/', { params: { scope_kind: 'container', cursor: 'opaque' } });
19
+ expect(client.post).toHaveBeenCalledWith('/api/messaging/conversations/direct/', { target_user_id: 8 });
20
+ expect(client.post).toHaveBeenCalledWith('/api/messaging/conversations/1/messages/', { body: 'x' }, { headers: { 'Idempotency-Key': 'key' } });
21
+ expect(client.get).toHaveBeenCalledWith('/api/messaging/conversations/1/messages/', { params: { cursor: 'opaque' } });
22
+ });
23
+
24
+ it('uses the published detail, receipt, poll and attachment routes', async () => {
25
+ await api.getMessage(2); await api.patchMessage(2, {}); await api.deleteMessage(2); await api.addReaction(2, '👍'); await api.removeReaction(2, '👍');
26
+ await api.listThread(2, { cursor: 'opaque' }); await api.markThreadRead(2); await api.markConversationRead(3); await api.getReadStatus(2); await api.votePoll(4, [5]); await api.closePoll(4); await api.getAttachment(6); await api.getAttachmentThumbnail(6); await api.getUnreadCount();
27
+ expect(client.delete).toHaveBeenCalledWith('/api/messaging/messages/2/reactions/%F0%9F%91%8D/');
28
+ expect(client.get).toHaveBeenCalledWith('/api/messaging/messages/2/thread/', { params: { cursor: 'opaque' } });
29
+ expect(client.post).toHaveBeenCalledWith('/api/messaging/conversations/3/read/', {});
30
+ expect(client.get).toHaveBeenCalledWith('/api/messaging/unread-count/');
31
+ });
32
+ });