@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.
@@ -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?.message ? `${key}:${options.message}` : key }) }));
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, vote counts and per-option voters from a server-shaped payload', () => {
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(/Library \(2\)/)).toBeTruthy();
62
- expect(screen.getByText(/Cafe \(1\)/)).toBeTruthy();
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 checkboxes = screen.getAllByRole('checkbox');
69
- expect(checkboxes[0].checked).toBe(true);
70
- expect(checkboxes[1].checked).toBe(false);
71
- expect(screen.getByText(/Library.*MessagingPoll\.YOU_VOTED/)).toBeTruthy();
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('checkbox')[0].checked).toBe(true));
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(/Library \(3\)/)).toBeTruthy());
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('checkbox')[0].checked).toBe(true);
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('checkbox').forEach((checkbox) => expect(checkbox.disabled).toBe(true));
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) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
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, delivered_count: 0 }),
22
+ getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
23
23
  };
24
24
  }
25
25
  function renderBubble(ui) {