@micha.bigler/ui-core-micha 2.12.0 → 2.14.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,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
+ });
@@ -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
  });
@@ -0,0 +1,192 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ const feedApi = vi.hoisted(() => ({
7
+ getNotificationFeed: vi.fn(),
8
+ getUnreadCount: vi.fn(),
9
+ markNotifications: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/notifications/feedApi', () => feedApi);
13
+
14
+ import { AuthContext } from '../src/auth/AuthContext';
15
+ import { NotificationsProvider, useNotifications } from '../src/notifications/NotificationsProvider';
16
+ import { useRealtime } from '../src/notifications/realtime';
17
+
18
+ class MockWebSocket {
19
+ static instances = [];
20
+
21
+ constructor(url) {
22
+ this.url = url;
23
+ this.close = vi.fn(() => this.onclose?.());
24
+ MockWebSocket.instances.push(this);
25
+ }
26
+ }
27
+
28
+ function StreamConsumer({ envelope, testId }) {
29
+ const { subscribe } = useRealtime();
30
+ const [received, setReceived] = React.useState([]);
31
+
32
+ React.useEffect(
33
+ () => subscribe(envelope, (data) => setReceived((prev) => [...prev, data])),
34
+ [subscribe, envelope],
35
+ );
36
+
37
+ return <output data-testid={testId}>{JSON.stringify(received)}</output>;
38
+ }
39
+
40
+ function NotificationsConsumer() {
41
+ const notifications = useNotifications();
42
+ return (
43
+ <>
44
+ <output data-testid="notifications">{JSON.stringify(notifications.notifications)}</output>
45
+ <output data-testid="unread-count">{notifications.unreadCount}</output>
46
+ <output data-testid="context-keys">{JSON.stringify(Object.keys(notifications).sort())}</output>
47
+ </>
48
+ );
49
+ }
50
+
51
+ function renderWithStreams({ streams = [], user = { id: 4 } } = {}) {
52
+ return render(
53
+ <AuthContext.Provider value={{ user }}>
54
+ <NotificationsProvider>
55
+ <NotificationsConsumer />
56
+ {streams.map(({ envelope, testId }) => (
57
+ <StreamConsumer key={testId} envelope={envelope} testId={testId} />
58
+ ))}
59
+ </NotificationsProvider>
60
+ </AuthContext.Provider>,
61
+ );
62
+ }
63
+
64
+ function received(testId) {
65
+ return JSON.parse(screen.getByTestId(testId).textContent);
66
+ }
67
+
68
+ describe('realtime primitive (NOTIF-13)', () => {
69
+ afterEach(() => {
70
+ cleanup();
71
+ });
72
+
73
+ beforeEach(() => {
74
+ vi.clearAllMocks();
75
+ MockWebSocket.instances = [];
76
+ vi.stubGlobal('WebSocket', MockWebSocket);
77
+ feedApi.getNotificationFeed.mockResolvedValue({ results: [] });
78
+ feedApi.getUnreadCount.mockResolvedValue({ count: 0 });
79
+ feedApi.markNotifications.mockResolvedValue({ updated: 1 });
80
+ });
81
+
82
+ it('ignores an unknown/unsubscribed envelope type — feed and unreadCount untouched', async () => {
83
+ renderWithStreams();
84
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
85
+
86
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({
87
+ envelope: 'message',
88
+ type: 'chat.new',
89
+ body: 'hello',
90
+ }) });
91
+
92
+ expect(screen.getByTestId('unread-count').textContent).toBe('0');
93
+ expect(currentNotificationsAreEmpty()).toBe(true);
94
+ });
95
+
96
+ function currentNotificationsAreEmpty() {
97
+ return JSON.parse(screen.getByTestId('notifications').textContent).length === 0;
98
+ }
99
+
100
+ it('routes two subscribers on distinct envelope types from one socket, each seeing only their own', async () => {
101
+ renderWithStreams({ streams: [
102
+ { envelope: 'message', testId: 'message-stream' },
103
+ { envelope: 'other', testId: 'other-stream' },
104
+ ] });
105
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
106
+
107
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'message', body: 'a' }) });
108
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'other', body: 'b' }) });
109
+
110
+ await waitFor(() => expect(received('message-stream')).toHaveLength(1));
111
+ expect(received('message-stream')[0]).toEqual(expect.objectContaining({ body: 'a' }));
112
+ expect(received('other-stream')).toHaveLength(1);
113
+ expect(received('other-stream')[0]).toEqual(expect.objectContaining({ body: 'b' }));
114
+ expect(MockWebSocket.instances).toHaveLength(1);
115
+ });
116
+
117
+ it('unsubscribe stops delivery for that subscriber without affecting others', async () => {
118
+ function Harness() {
119
+ const { subscribe } = useRealtime();
120
+ const [messageReceived, setMessageReceived] = React.useState([]);
121
+ const [otherReceived, setOtherReceived] = React.useState([]);
122
+ const [messageSubscribed, setMessageSubscribed] = React.useState(true);
123
+
124
+ React.useEffect(() => {
125
+ if (!messageSubscribed) return undefined;
126
+ return subscribe('message', (data) => setMessageReceived((prev) => [...prev, data]));
127
+ }, [subscribe, messageSubscribed]);
128
+
129
+ React.useEffect(
130
+ () => subscribe('other', (data) => setOtherReceived((prev) => [...prev, data])),
131
+ [subscribe],
132
+ );
133
+
134
+ return (
135
+ <>
136
+ <output data-testid="message-received">{JSON.stringify(messageReceived)}</output>
137
+ <output data-testid="other-received">{JSON.stringify(otherReceived)}</output>
138
+ <button onClick={() => setMessageSubscribed(false)}>unsubscribe-message</button>
139
+ </>
140
+ );
141
+ }
142
+
143
+ const { unmount } = render(
144
+ <AuthContext.Provider value={{ user: { id: 4 } }}>
145
+ <NotificationsProvider>
146
+ <Harness />
147
+ </NotificationsProvider>
148
+ </AuthContext.Provider>,
149
+ );
150
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
151
+
152
+ fireEvent.click(screen.getByRole('button', { name: 'unsubscribe-message' }));
153
+
154
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'message', body: 'after-unsub' }) });
155
+ MockWebSocket.instances[0].onmessage({ data: JSON.stringify({ envelope: 'other', body: 'still-alive' }) });
156
+
157
+ await waitFor(() => expect(received('other-received')).toHaveLength(1));
158
+ expect(received('message-received')).toHaveLength(0);
159
+ expect(received('other-received')[0]).toEqual(expect.objectContaining({ body: 'still-alive' }));
160
+
161
+ unmount();
162
+ });
163
+
164
+ it('keeps a subscriber registered across a close/backoff/reopen cycle with no re-registration', async () => {
165
+ vi.useFakeTimers();
166
+ try {
167
+ renderWithStreams({ streams: [{ envelope: 'message', testId: 'message-stream' }] });
168
+ await vi.waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
169
+
170
+ const firstSocket = MockWebSocket.instances[0];
171
+ firstSocket.onclose();
172
+ await vi.advanceTimersByTimeAsync(1_000);
173
+
174
+ expect(MockWebSocket.instances).toHaveLength(2);
175
+ MockWebSocket.instances[1].onmessage({ data: JSON.stringify({ envelope: 'message', body: 'after-reopen' }) });
176
+
177
+ await vi.waitFor(() => expect(received('message-stream')).toHaveLength(1));
178
+ expect(received('message-stream')[0]).toEqual(expect.objectContaining({ body: 'after-reopen' }));
179
+ } finally {
180
+ vi.useRealTimers();
181
+ }
182
+ });
183
+
184
+ it("NotificationsProvider's public context value is unchanged", async () => {
185
+ renderWithStreams();
186
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
187
+
188
+ expect(JSON.parse(screen.getByTestId('context-keys').textContent)).toEqual(
189
+ ['markDismissed', 'markDone', 'markSeen', 'notifications', 'refresh', 'unreadCount'].sort(),
190
+ );
191
+ });
192
+ });