@micha.bigler/ui-core-micha 2.21.3 → 2.23.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 +3 -0
- package/dist/i18n/messagingTranslations.js +3 -3
- package/dist/messaging/ConversationList.js +8 -1
- package/dist/messaging/MessagingProvider.js +63 -5
- package/dist/messaging/PollCard.js +83 -23
- package/dist/messaging/ReadTicks.js +52 -10
- package/dist/messaging/Thread.js +29 -1
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +3 -3
- package/src/messaging/ConversationList.jsx +8 -1
- package/src/messaging/MessagingProvider.jsx +60 -5
- package/src/messaging/PollCard.jsx +103 -19
- package/src/messaging/ReadTicks.jsx +55 -8
- package/src/messaging/Thread.jsx +24 -1
- package/tests/ConversationList.test.jsx +52 -0
- package/tests/Thread.test.jsx +9 -8
- package/tests/messagingChunk6.test.jsx +16 -9
- package/tests/messagingCompactBubble.test.jsx +3 -3
- package/tests/messagingInteractions.test.jsx +10 -3
- package/tests/messagingMsg6.test.jsx +24 -9
- package/tests/messagingMsg6f.test.jsx +155 -0
- package/tests/messagingMsg6g.test.jsx +166 -0
- package/tests/messagingPollRendering.test.jsx +24 -14
- package/tests/messagingThreadReceipts.test.jsx +10 -2
- package/tests/messagingThreadingQuoting.test.jsx +1 -1
- package/work-orders/MSG-6f.md +214 -0
- package/work-orders/MSG-6g.md +244 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
|
+
|
|
7
|
+
// Thread.jsx (scope C) subscribes to the messaging envelope independently of
|
|
8
|
+
// MessagingProvider's own subscription -- a Set of handlers per envelope, not
|
|
9
|
+
// one, so a dispatched frame reaches every current subscriber.
|
|
10
|
+
let frameHandlers;
|
|
11
|
+
function dispatchFrame(frame) { frameHandlers.forEach((handler) => handler(frame)); }
|
|
12
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key, i18n: { language: 'en' } }) }));
|
|
13
|
+
vi.mock('../src/notifications/realtime', () => ({
|
|
14
|
+
useRealtime: () => ({
|
|
15
|
+
subscribe: (_envelope, handler) => { frameHandlers.add(handler); return () => frameHandlers.delete(handler); },
|
|
16
|
+
onReconnect: () => () => {},
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
21
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
22
|
+
import { ReadTicks } from '../src/messaging/ReadTicks';
|
|
23
|
+
import { Thread } from '../src/messaging/Thread';
|
|
24
|
+
|
|
25
|
+
const theme = createTheme();
|
|
26
|
+
function withTheme(children) { return <ThemeProvider theme={theme}>{children}</ThemeProvider>; }
|
|
27
|
+
|
|
28
|
+
function makeApi(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }),
|
|
31
|
+
listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
32
|
+
listThread: vi.fn().mockResolvedValue({ results: [] }),
|
|
33
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
34
|
+
markConversationRead: vi.fn().mockResolvedValue({}),
|
|
35
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => { frameHandlers = new Set(); });
|
|
41
|
+
afterEach(cleanup);
|
|
42
|
+
|
|
43
|
+
describe('MSG-6f scope B: read ratio vs two-state tick', () => {
|
|
44
|
+
it('4a: a direct conversation renders a two-state tick, and the two states resolve to visibly different colours', async () => {
|
|
45
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }) });
|
|
46
|
+
const { rerender } = render(withTheme(
|
|
47
|
+
<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct' }} /></MessagingProvider>
|
|
48
|
+
));
|
|
49
|
+
const sentIcon = await screen.findByLabelText('MessagingReadTicks.SENT');
|
|
50
|
+
const sentColor = getComputedStyle(sentIcon).color;
|
|
51
|
+
|
|
52
|
+
api.getReadStatus.mockResolvedValue({ all_read: true });
|
|
53
|
+
cleanup();
|
|
54
|
+
render(withTheme(
|
|
55
|
+
<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct' }} /></MessagingProvider>
|
|
56
|
+
));
|
|
57
|
+
const readIcon = await screen.findByLabelText('MessagingReadTicks.ALL_READ');
|
|
58
|
+
const readColor = getComputedStyle(readIcon).color;
|
|
59
|
+
|
|
60
|
+
expect(sentColor).toBeTruthy();
|
|
61
|
+
expect(readColor).toBeTruthy();
|
|
62
|
+
expect(sentColor).not.toBe(readColor);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('4b: a group WITH counts renders dcm\'s own read_count/recipient_count verbatim, never recomputed from recipientDetail (must fail if recomputed -- recipientDetail is not thresholded against this message\'s created_at, only dcm\'s own counts are)', async () => {
|
|
66
|
+
// Deliberately inconsistent with read_count/recipient_count: every
|
|
67
|
+
// participant has SOME last_read_at (e.g. from reading the conversation
|
|
68
|
+
// long before this message existed), which is exactly the shape that
|
|
69
|
+
// would make a naive "count non-null last_read_at" recomputation report
|
|
70
|
+
// 40/40 -- the bug caught in review. dcm's own read_count (18) is the
|
|
71
|
+
// only correct source; recipientDetail must not be trusted for the ratio.
|
|
72
|
+
const allEverRead = Array.from({ length: 40 }, (_, i) => ({ user_id: `u-${i}`, last_read_at: '2020-01-01T00:00:00Z' }));
|
|
73
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, read_count: 18, recipient_count: 40, recipient_detail: allEverRead }) });
|
|
74
|
+
render(<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
|
|
75
|
+
const ratio = await screen.findByLabelText(/READ_RATIO/);
|
|
76
|
+
expect(ratio.textContent).toBe('18/40');
|
|
77
|
+
expect(document.body.querySelector('[data-testid="DoneOutlinedIcon"]')).toBeNull();
|
|
78
|
+
expect(document.body.querySelector('[data-testid="DoneAllOutlinedIcon"]')).toBeNull();
|
|
79
|
+
expect(document.body.textContent).not.toMatch(/Zugestellt/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('4c: a group WITHOUT counts (an ordinary participant) renders no indicator at all', async () => {
|
|
83
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }) });
|
|
84
|
+
const { container } = render(<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'group' }} /></MessagingProvider>);
|
|
85
|
+
await waitFor(() => expect(api.getReadStatus).toHaveBeenCalled());
|
|
86
|
+
expect(container.textContent).toBe('');
|
|
87
|
+
expect(container.querySelector('svg')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('5: all_read still renders the double check with its own unchanged label', async () => {
|
|
91
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: true }) });
|
|
92
|
+
render(<MessagingProvider api={api} active={false}><ReadTicks messageId="m1" conversation={{ id: 'c1', kind: 'direct' }} /></MessagingProvider>);
|
|
93
|
+
const icon = await screen.findByLabelText('MessagingReadTicks.ALL_READ');
|
|
94
|
+
expect(icon.getAttribute('data-testid')).toBe('DoneAllOutlinedIcon');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('MSG-6f scope C: re-mark read on a live message frame for the open conversation', () => {
|
|
99
|
+
function renderThread(api, { userId = 1 } = {}) {
|
|
100
|
+
return render(
|
|
101
|
+
<AuthContext.Provider value={{ user: { id: userId } }}>
|
|
102
|
+
<MessagingProvider api={api}><Thread conversationId="c1" /></MessagingProvider>
|
|
103
|
+
</AuthContext.Provider>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('marks read again when a message frame arrives for the currently-open conversation', async () => {
|
|
108
|
+
const api = makeApi();
|
|
109
|
+
renderThread(api);
|
|
110
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
|
|
111
|
+
api.markConversationRead.mockClear();
|
|
112
|
+
|
|
113
|
+
act(() => dispatchFrame({ type: 'message', conversation_id: 'c1', message: { id: 'm2', conversation_id: 'c1', sender: { id: 2 } } }));
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalled());
|
|
116
|
+
expect(api.markConversationRead.mock.calls[0][0]).toBe('c1');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does not re-mark for a message frame belonging to a different conversation', async () => {
|
|
120
|
+
const api = makeApi();
|
|
121
|
+
renderThread(api);
|
|
122
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
|
|
123
|
+
api.markConversationRead.mockClear();
|
|
124
|
+
|
|
125
|
+
act(() => dispatchFrame({ type: 'message', conversation_id: 'other-conversation', message: { id: 'm2' } }));
|
|
126
|
+
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
128
|
+
expect(api.markConversationRead).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does not re-mark while the document is hidden', async () => {
|
|
132
|
+
const api = makeApi();
|
|
133
|
+
renderThread(api);
|
|
134
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
|
|
135
|
+
api.markConversationRead.mockClear();
|
|
136
|
+
|
|
137
|
+
const visibilitySpy = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
|
|
138
|
+
act(() => dispatchFrame({ type: 'message', conversation_id: 'c1', message: { id: 'm2' } }));
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
140
|
+
expect(api.markConversationRead).not.toHaveBeenCalled();
|
|
141
|
+
visibilitySpy.mockRestore();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not re-mark for a non-message frame type (e.g. reaction) for the same conversation', async () => {
|
|
145
|
+
const api = makeApi();
|
|
146
|
+
renderThread(api);
|
|
147
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledTimes(1));
|
|
148
|
+
api.markConversationRead.mockClear();
|
|
149
|
+
|
|
150
|
+
act(() => dispatchFrame({ type: 'reaction', conversation_id: 'c1', message_id: 'm2' }));
|
|
151
|
+
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
153
|
+
expect(api.markConversationRead).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key }) }));
|
|
7
|
+
|
|
8
|
+
const realtimeSubscribers = new Map();
|
|
9
|
+
vi.mock('../src/notifications/realtime', () => ({
|
|
10
|
+
useRealtime: () => ({
|
|
11
|
+
subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
|
|
12
|
+
onReconnect: () => () => {},
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
|
|
16
|
+
|
|
17
|
+
import { PollCard } from '../src/messaging/PollCard';
|
|
18
|
+
import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
19
|
+
|
|
20
|
+
function baseApi(overrides = {}) {
|
|
21
|
+
return {
|
|
22
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
23
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
24
|
+
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function serverPoll(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
id: 'poll-1', question: 'Q', allow_multiple: false, closed_at: null, created_by_id: 'user-9',
|
|
31
|
+
options: [
|
|
32
|
+
{ id: 'opt-a', text: 'A', order: 0, vote_count: 0, voters: [] },
|
|
33
|
+
{ id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] },
|
|
34
|
+
],
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function ConnectedPollCard({ messageId }) {
|
|
39
|
+
const { cache } = useMessaging();
|
|
40
|
+
return <PollCard message={cache.messages[messageId]} />;
|
|
41
|
+
}
|
|
42
|
+
function CachePollState({ messageId }) {
|
|
43
|
+
const { cache } = useMessaging();
|
|
44
|
+
return <output data-testid="cache-poll">{JSON.stringify(cache.messages[messageId]?.poll)}</output>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
48
|
+
|
|
49
|
+
describe('MSG-6g: tap-to-vote poll UI with result bars', () => {
|
|
50
|
+
it('1. single-choice: tapping an option votes without further interaction; tapping a second option moves the vote', async () => {
|
|
51
|
+
const api = baseApi();
|
|
52
|
+
api.votePoll.mockResolvedValueOnce({ poll: { ...serverPoll(), voted_option_ids: ['opt-a'], options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 1, voters: ['me'] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] }] } });
|
|
53
|
+
api.votePoll.mockResolvedValueOnce({ poll: { ...serverPoll(), voted_option_ids: ['opt-b'], options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 0, voters: [] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 1, voters: ['me'] }] } });
|
|
54
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
55
|
+
const radios = screen.getAllByRole('radio');
|
|
56
|
+
|
|
57
|
+
fireEvent.click(radios[0]);
|
|
58
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-a']));
|
|
59
|
+
expect(radios[0].checked).toBe(true);
|
|
60
|
+
|
|
61
|
+
fireEvent.click(radios[1]);
|
|
62
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-b']));
|
|
63
|
+
await waitFor(() => expect(radios[1].checked).toBe(true));
|
|
64
|
+
expect(radios[0].checked).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('2. multi-select: tapping toggles one option on and off, and each tap sends the complete option-id set', async () => {
|
|
68
|
+
const api = baseApi();
|
|
69
|
+
api.votePoll.mockResolvedValue({ poll: serverPoll({ allow_multiple: true }) });
|
|
70
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll({ allow_multiple: true }) }} /></MessagingProvider>);
|
|
71
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
72
|
+
|
|
73
|
+
fireEvent.click(checkboxes[0]);
|
|
74
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-a']));
|
|
75
|
+
fireEvent.click(checkboxes[1]);
|
|
76
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-a', 'opt-b']));
|
|
77
|
+
fireEvent.click(checkboxes[0]);
|
|
78
|
+
await waitFor(() => expect(api.votePoll).toHaveBeenCalledWith('poll-1', ['opt-b']));
|
|
79
|
+
expect(api.votePoll).toHaveBeenCalledTimes(3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('3. a rejected vote reverts the optimistic state and surfaces an error -- the reverted value, not merely an error message', async () => {
|
|
83
|
+
const api = baseApi();
|
|
84
|
+
api.votePoll.mockRejectedValue({ response: { data: { detail: 'Voting disabled' } } });
|
|
85
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
86
|
+
const radios = screen.getAllByRole('radio');
|
|
87
|
+
|
|
88
|
+
fireEvent.click(radios[0]);
|
|
89
|
+
// Optimistic: briefly checked before the rejection resolves.
|
|
90
|
+
await waitFor(() => expect(radios[0].checked).toBe(true));
|
|
91
|
+
await screen.findByRole('alert');
|
|
92
|
+
await waitFor(() => expect(radios[0].checked).toBe(false));
|
|
93
|
+
expect(radios[1].checked).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('4. bars render proportionally, including the zero-vote case, with no NaN and no division error', () => {
|
|
97
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
98
|
+
// vote_count 0/0 for both options -> 0%, never NaN%.
|
|
99
|
+
const results = screen.getAllByText(/MessagingPoll\.OPTION_RESULT/);
|
|
100
|
+
expect(results).toHaveLength(2);
|
|
101
|
+
results.forEach((node) => {
|
|
102
|
+
expect(node.textContent).toMatch(/"count":0/);
|
|
103
|
+
expect(node.textContent).toMatch(/"percent":0/);
|
|
104
|
+
expect(node.textContent).not.toMatch(/NaN/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('5. the viewer\'s own selection survives a poll_updated frame that does not carry voted_option_ids (merge-trap guard)', async () => {
|
|
109
|
+
const api = baseApi();
|
|
110
|
+
render(<MessagingProvider api={api} active={false}><ConnectedPollCard messageId="msg-1" /></MessagingProvider>);
|
|
111
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', kind: 'poll', poll: serverPoll({ voted_option_ids: ['opt-a'], options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 1, voters: ['me'] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] }] }) } });
|
|
112
|
+
await waitFor(() => expect(screen.getAllByRole('radio')[0].checked).toBe(true));
|
|
113
|
+
|
|
114
|
+
// A poll_updated frame never carries voted_option_ids (dcm's serialize_poll
|
|
115
|
+
// is viewer-independent) -- must not wipe the REST-known selection.
|
|
116
|
+
dispatchMessagingFrame({
|
|
117
|
+
envelope: 'messaging', type: 'poll_updated', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', poll_id: 'poll-1',
|
|
118
|
+
poll: serverPoll({ options: [{ id: 'opt-a', text: 'A', order: 0, vote_count: 2, voters: ['me', 'other'] }, { id: 'opt-b', text: 'B', order: 1, vote_count: 0, voters: [] }] }),
|
|
119
|
+
});
|
|
120
|
+
await waitFor(() => expect(screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":2/)).toBeTruthy());
|
|
121
|
+
expect(screen.getAllByRole('radio')[0].checked).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('6. a closed poll is non-interactive: tapping an option does nothing and issues no request', () => {
|
|
125
|
+
const api = baseApi();
|
|
126
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll({ closed_at: '2026-08-01T00:00:00Z' }) }} /></MessagingProvider>);
|
|
127
|
+
const radios = screen.getAllByRole('radio');
|
|
128
|
+
fireEvent.click(radios[0]);
|
|
129
|
+
expect(api.votePoll).not.toHaveBeenCalled();
|
|
130
|
+
expect(radios[0].checked).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('a rapid switch (A then B, before the first vote confirms) projects counts from the freshest cache state, not a stale prop (regression)', async () => {
|
|
134
|
+
// MessagingProvider.castPollVote must build its optimistic count delta from
|
|
135
|
+
// `previousPoll` (freshly read from the shared cache at call time), never
|
|
136
|
+
// from the caller-supplied `poll` argument. PollCard is deliberately given
|
|
137
|
+
// a STATIC message prop here (never re-rendered with a fresh one between
|
|
138
|
+
// taps, exactly like PollCard's own documented "dumb, prop-driven"
|
|
139
|
+
// contract, and exactly how a host that doesn't re-connect it to live
|
|
140
|
+
// cache updates would behave) -- a separate, genuinely cache-connected
|
|
141
|
+
// reader (CachePollState) is what must show the correct number. Starting
|
|
142
|
+
// counts are non-zero so a wrong baseline isn't masked by the
|
|
143
|
+
// Math.max(0, ...) clamp.
|
|
144
|
+
const api = baseApi();
|
|
145
|
+
api.votePoll.mockReturnValue(new Promise(() => {})); // never resolves -- isolate the optimistic phase
|
|
146
|
+
const seedPoll = serverPoll({
|
|
147
|
+
voted_option_ids: [],
|
|
148
|
+
options: [
|
|
149
|
+
{ id: 'opt-a', text: 'A', order: 0, vote_count: 5, voters: ['u1', 'u2', 'u3', 'u4', 'u5'] },
|
|
150
|
+
{ id: 'opt-b', text: 'B', order: 1, vote_count: 2, voters: ['u6', 'u7'] },
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
render(<MessagingProvider api={api} active={false}><PollCard message={{ id: 'msg-1', poll: seedPoll }} /><CachePollState messageId="msg-1" /></MessagingProvider>);
|
|
154
|
+
const radios = screen.getAllByRole('radio');
|
|
155
|
+
|
|
156
|
+
fireEvent.click(radios[0]); // vote A: 5 -> 6
|
|
157
|
+
await waitFor(() => expect(JSON.parse(screen.getByTestId('cache-poll').textContent).options.find((option) => option.id === 'opt-a').vote_count).toBe(6));
|
|
158
|
+
|
|
159
|
+
fireEvent.click(radios[1]); // switch to B: A must go 6 -> 5 in the shared cache (a stale-prop baseline of 5 would wrongly compute 4)
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
const poll = JSON.parse(screen.getByTestId('cache-poll').textContent);
|
|
162
|
+
expect(poll.options.find((option) => option.id === 'opt-a').vote_count).toBe(5);
|
|
163
|
+
expect(poll.options.find((option) => option.id === 'opt-b').vote_count).toBe(3);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -3,7 +3,7 @@ import React from 'react';
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
5
|
|
|
6
|
-
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options && Object.keys(options).length ? `${key}:${JSON.stringify(options)}` : key }) }));
|
|
7
7
|
|
|
8
8
|
const realtimeSubscribers = new Map();
|
|
9
9
|
vi.mock('../src/notifications/realtime', () => ({
|
|
@@ -55,20 +55,29 @@ function ConnectedPollCard({ messageId }) {
|
|
|
55
55
|
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
56
56
|
|
|
57
57
|
describe('poll rendering against the real server contract', () => {
|
|
58
|
-
it('renders question, options
|
|
58
|
+
it('renders question, options and proportional vote counts from a server-shaped payload', () => {
|
|
59
|
+
// totalVotes = 2 + 1 = 3 -> Library 67%, Cafe 33%.
|
|
59
60
|
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
60
61
|
expect(screen.getByText('Where should we meet?')).toBeTruthy();
|
|
61
|
-
expect(screen.getByText(
|
|
62
|
-
expect(screen.getByText(/
|
|
62
|
+
expect(screen.getByText('Library')).toBeTruthy();
|
|
63
|
+
expect(screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":2.*"percent":67/)).toBeTruthy();
|
|
64
|
+
expect(screen.getByText('Cafe')).toBeTruthy();
|
|
65
|
+
expect(screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":1.*"percent":33/)).toBeTruthy();
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
it('derives selection from voted_option_ids (REST-only) and marks the viewer\'s own vote', () => {
|
|
69
|
+
// Single-choice (allow_multiple: false) renders radios, not checkboxes --
|
|
70
|
+
// native radio semantics match the WO's "tapping a different option moves
|
|
71
|
+
// the vote, tapping the same one is a no-op" design directly.
|
|
66
72
|
const poll = serverPoll({ voted_option_ids: ['opt-a'] });
|
|
67
73
|
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
|
|
68
|
-
const
|
|
69
|
-
expect(
|
|
70
|
-
expect(
|
|
71
|
-
|
|
74
|
+
const radios = screen.getAllByRole('radio');
|
|
75
|
+
expect(radios[0].checked).toBe(true);
|
|
76
|
+
expect(radios[1].checked).toBe(false);
|
|
77
|
+
// Count/percent and the viewer's own vote both live in the accessible
|
|
78
|
+
// name, not conveyed by the bar's width alone (WO accessibility risk).
|
|
79
|
+
expect(radios[0].getAttribute('aria-label')).toMatch(/Library.*MessagingPoll\.OPTION_RESULT.*MessagingPoll\.YOU_VOTED/);
|
|
80
|
+
expect(radios[1].getAttribute('aria-label')).not.toMatch(/YOU_VOTED/);
|
|
72
81
|
});
|
|
73
82
|
|
|
74
83
|
it('does not clear or invert the viewer\'s own vote when a poll_updated frame carries no voted_option_ids', async () => {
|
|
@@ -80,7 +89,7 @@ describe('poll rendering against the real server contract', () => {
|
|
|
80
89
|
// own castPollVote reconciliation would leave it — not a static prop the
|
|
81
90
|
// component can never observe a later frame update against.
|
|
82
91
|
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', kind: 'poll', poll: votedPoll } });
|
|
83
|
-
await waitFor(() => expect(screen.getAllByRole('
|
|
92
|
+
await waitFor(() => expect(screen.getAllByRole('radio')[0].checked).toBe(true));
|
|
84
93
|
// The real poll_updated frame shape: payload.poll is a bare serialize_poll
|
|
85
94
|
// result (verified against services.py's _poll_updated_payload) — never
|
|
86
95
|
// carries voted_option_ids. A vote_count bump from another viewer must
|
|
@@ -89,10 +98,10 @@ describe('poll rendering against the real server contract', () => {
|
|
|
89
98
|
envelope: 'messaging', type: 'poll_updated', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', poll_id: 'poll-1',
|
|
90
99
|
poll: { ...serverPoll(), options: [{ id: 'opt-a', text: 'Library', order: 0, vote_count: 3, voters: ['user-1', 'user-2', 'user-4'] }, { id: 'opt-b', text: 'Cafe', order: 1, vote_count: 1, voters: ['user-3'] }] },
|
|
91
100
|
});
|
|
92
|
-
await waitFor(() => expect(screen.getByText(/
|
|
101
|
+
await waitFor(() => expect(screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":3/)).toBeTruthy());
|
|
93
102
|
// The frame carries no voted_option_ids at all — the viewer's REST-known
|
|
94
103
|
// vote must be carried forward, not wiped, by this update.
|
|
95
|
-
expect(screen.getAllByRole('
|
|
104
|
+
expect(screen.getAllByRole('radio')[0].checked).toBe(true);
|
|
96
105
|
});
|
|
97
106
|
|
|
98
107
|
it('grants close only to the poll creator (created_by_id) or a host override, never a guess from an object shape', () => {
|
|
@@ -105,11 +114,12 @@ describe('poll rendering against the real server contract', () => {
|
|
|
105
114
|
expect(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeTruthy();
|
|
106
115
|
});
|
|
107
116
|
|
|
108
|
-
it('disables voting once the poll is closed', () => {
|
|
117
|
+
it('disables voting once the poll is closed and shows a closed indicator, not merely a missing button', () => {
|
|
118
|
+
// With no separate submit button (MSG-6g), "closed" can no longer be
|
|
119
|
+
// inferred from its absence -- must be an explicit, visible indicator.
|
|
109
120
|
const poll = serverPoll({ closed_at: '2026-07-31T10:00:00Z' });
|
|
110
121
|
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
|
|
111
|
-
screen.getAllByRole('
|
|
112
|
-
expect(screen.getByRole('button', { name: 'MessagingPoll.VOTE' }).disabled).toBe(true);
|
|
122
|
+
screen.getAllByRole('radio').forEach((radio) => expect(radio.disabled).toBe(true));
|
|
113
123
|
expect(screen.getByText('MessagingPoll.CLOSED')).toBeTruthy();
|
|
114
124
|
});
|
|
115
125
|
});
|
|
@@ -7,14 +7,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
|
|
7
7
|
|
|
8
8
|
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count !== undefined ? `${key}:${options.count}` : key, i18n: { language: 'en' } }) }));
|
|
9
9
|
|
|
10
|
+
// Thread.jsx (MSG-6f scope C) now subscribes to the messaging envelope
|
|
11
|
+
// independently of MessagingProvider's own subscription -- a Set of handlers
|
|
12
|
+
// per envelope, not one handler per envelope, so a dispatched frame still
|
|
13
|
+
// reaches every current subscriber, matching the real registry's multi-handler support.
|
|
10
14
|
const realtimeSubscribers = new Map();
|
|
11
15
|
vi.mock('../src/notifications/realtime', () => ({
|
|
12
16
|
useRealtime: () => ({
|
|
13
|
-
subscribe: (envelope, handler) => {
|
|
17
|
+
subscribe: (envelope, handler) => {
|
|
18
|
+
if (!realtimeSubscribers.has(envelope)) realtimeSubscribers.set(envelope, new Set());
|
|
19
|
+
realtimeSubscribers.get(envelope).add(handler);
|
|
20
|
+
return () => realtimeSubscribers.get(envelope)?.delete(handler);
|
|
21
|
+
},
|
|
14
22
|
onReconnect: () => () => {},
|
|
15
23
|
}),
|
|
16
24
|
}));
|
|
17
|
-
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
|
|
25
|
+
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.forEach((handler) => handler(frame)); }
|
|
18
26
|
|
|
19
27
|
import { ConversationList } from '../src/messaging/ConversationList';
|
|
20
28
|
import { Thread } from '../src/messaging/Thread';
|
|
@@ -19,7 +19,7 @@ function makeApi(messages = []) {
|
|
|
19
19
|
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
20
20
|
markConversationRead: vi.fn().mockResolvedValue({}),
|
|
21
21
|
markThreadRead: vi.fn().mockResolvedValue({}),
|
|
22
|
-
getReadStatus: vi.fn().mockResolvedValue({ all_read: false
|
|
22
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
25
|
function renderBubble(ui) {
|