@@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22import { act } from '@testing-library/react' ;
33import { render , screen , waitFor } from '@testing-library/react' ;
44import OfflineStatusBanner from './OfflineStatusBanner' ;
5- import * as offlineMessageQueue from '@/lib/offlineMessageQueue' ;
5+ import { useOnlineStatus } from '@/hooks/useOnlineStatus' ;
6+ import { subscribeToQueuedMessageCount } from '@/lib/offlineMessageQueue' ;
67
78// Mock dependencies
89vi . mock ( '@/hooks/useOnlineStatus' , ( ) => ( {
@@ -26,14 +27,42 @@ vi.mock('@/lib/offlineStatusSchema', () => ({
2627} ) ) ;
2728
2829vi . mock ( '@/lib/offlineMessageQueue' , ( ) => ( {
29- subscribeToQueuedMessageCount : vi . fn ( ) ,
30+ subscribeToQueuedMessageCount : vi . fn ( ( ) => ( ) => { } ) ,
3031 setQueuedMessageCount : vi . fn ( ) ,
3132 getQueuedMessageCount : vi . fn ( ( ) => 0 ) ,
3233} ) ) ;
3334
35+ const mockedUseOnlineStatus = vi . mocked ( useOnlineStatus ) ;
36+ const mockedSubscribe = vi . mocked ( subscribeToQueuedMessageCount ) ;
37+
38+ /** Elapse the 300ms initial loading gate so it cannot mask later renders. */
39+ function settleLoadingGate ( ) {
40+ act ( ( ) => {
41+ vi . advanceTimersByTime ( 300 ) ;
42+ } ) ;
43+ }
44+
45+ /** Point `useOnlineStatus` at a value the test can flip between renders. */
46+ function stubOnlineStatus ( getIsOnline : ( ) => boolean , wasOffline = false ) {
47+ const resetWasOffline = vi . fn ( ) ;
48+ mockedUseOnlineStatus . mockImplementation ( ( ) => ( {
49+ get isOnline ( ) {
50+ return getIsOnline ( ) ;
51+ } ,
52+ wasOffline,
53+ resetWasOffline,
54+ } ) ) ;
55+ return resetWasOffline ;
56+ }
57+
3458describe ( 'OfflineStatusBanner - Optimistic UI Updates' , ( ) => {
3559 beforeEach ( ( ) => {
3660 vi . clearAllMocks ( ) ;
61+ mockedSubscribe . mockImplementation ( ( ) => ( ) => { } ) ;
62+ // Fake timers are required for the 300ms loading skeleton and the 500ms
63+ // reconnect dismissal. Assertions below are made directly after an explicit
64+ // `advanceTimersByTime` rather than through `waitFor`, whose polling never
65+ // fires while the clock is frozen.
3766 vi . useFakeTimers ( ) ;
3867 } ) ;
3968
@@ -42,138 +71,104 @@ describe('OfflineStatusBanner - Optimistic UI Updates', () => {
4271 vi . useRealTimers ( ) ;
4372 } ) ;
4473
45- it ( 'should show banner immediately when going offline' , async ( ) => {
46- const { useOnlineStatus } = await import ( '@/hooks/useOnlineStatus' ) ;
47- ( useOnlineStatus as any ) . mockReturnValue ( {
48- isOnline : false ,
49- wasOffline : false ,
50- resetWasOffline : vi . fn ( ) ,
51- } ) ;
74+ it ( 'should show banner immediately when going offline' , ( ) => {
75+ stubOnlineStatus ( ( ) => false ) ;
5276
5377 render ( < OfflineStatusBanner /> ) ;
5478
55- await waitFor ( ( ) => {
56- expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
57- } ) ;
58-
79+ expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
5980 expect ( screen . getByText ( / Y o u a r e o f f l i n e / i) ) . toBeInTheDocument ( ) ;
6081 } ) ;
6182
62- it ( 'should show reconnecting state when coming back online' , async ( ) => {
63- const { useOnlineStatus } = await import ( '@/hooks/useOnlineStatus' ) ;
83+ it ( 'should show reconnecting state when coming back online' , ( ) => {
6484 let isOnline = false ;
65-
66- ( useOnlineStatus as any ) . mockImplementation ( ( ) => ( {
67- get isOnline ( ) { return isOnline ; } ,
68- wasOffline : true ,
69- resetWasOffline : vi . fn ( ) ,
70- } ) ) ;
85+ stubOnlineStatus ( ( ) => isOnline , true ) ;
7186
7287 const { rerender } = render ( < OfflineStatusBanner /> ) ;
73-
74- await waitFor ( ( ) => {
75- expect ( screen . getByText ( / Y o u a r e o f f l i n e / i) ) . toBeInTheDocument ( ) ;
76- } ) ;
88+ settleLoadingGate ( ) ;
89+ expect ( screen . getByText ( / Y o u a r e o f f l i n e / i) ) . toBeInTheDocument ( ) ;
7790
7891 // Simulate coming back online
7992 isOnline = true ;
8093 rerender ( < OfflineStatusBanner /> ) ;
8194
82- await waitFor ( ( ) => {
83- expect ( screen . getByText ( / R e c o n n e c t i n g / i) ) . toBeInTheDocument ( ) ;
84- } ) ;
95+ expect ( screen . getByText ( / R e c o n n e c t i n g / i) ) . toBeInTheDocument ( ) ;
8596 } ) ;
8697
87- it ( 'should display optimistic pending count' , async ( ) => {
88- const { useOnlineStatus } = await import ( '@/hooks/useOnlineStatus' ) ;
89- ( useOnlineStatus as any ) . mockReturnValue ( {
90- isOnline : false ,
91- wasOffline : false ,
92- resetWasOffline : vi . fn ( ) ,
98+ it ( 'should display optimistic pending count' , ( ) => {
99+ stubOnlineStatus ( ( ) => false ) ;
100+ // The count is pushed by `offlineMessageQueue` subscribers, not pulled.
101+ mockedSubscribe . mockImplementation ( ( listener ) => {
102+ listener ( 3 ) ;
103+ return ( ) => { } ;
93104 } ) ;
94105
95- ( offlineMessageQueue . getQueuedMessageCount as any ) . mockReturnValue ( 3 ) ;
96-
97106 render ( < OfflineStatusBanner /> ) ;
98107
99- await waitFor ( ( ) => {
100- expect ( screen . getByText ( / 3 m e s s a g e s w a i t i n g t o s e n d / i) ) . toBeInTheDocument ( ) ;
108+ expect ( screen . getByText ( / 3 m e s s a g e s w a i t i n g t o s e n d / i) ) . toBeInTheDocument ( ) ;
109+ } ) ;
110+
111+ it ( 'should pluralise a single pending message' , ( ) => {
112+ stubOnlineStatus ( ( ) => false ) ;
113+ mockedSubscribe . mockImplementation ( ( listener ) => {
114+ listener ( 1 ) ;
115+ return ( ) => { } ;
101116 } ) ;
117+
118+ render ( < OfflineStatusBanner /> ) ;
119+
120+ expect ( screen . getByText ( / 1 m e s s a g e w a i t i n g t o s e n d / i) ) . toBeInTheDocument ( ) ;
102121 } ) ;
103122
104- it ( 'should hide banner after reconnection delay' , async ( ) => {
105- const { useOnlineStatus } = await import ( '@/hooks/useOnlineStatus' ) ;
123+ it ( 'should hide banner after reconnection delay' , ( ) => {
106124 let isOnline = false ;
107-
108- ( useOnlineStatus as any ) . mockImplementation ( ( ) => ( {
109- get isOnline ( ) { return isOnline ; } ,
110- wasOffline : true ,
111- resetWasOffline : vi . fn ( ) ,
112- } ) ) ;
125+ const resetWasOffline = stubOnlineStatus ( ( ) => isOnline , true ) ;
113126
114127 const { rerender } = render ( < OfflineStatusBanner /> ) ;
115-
116- await waitFor ( ( ) => {
117- expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
118- } ) ;
128+ settleLoadingGate ( ) ;
129+ expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
119130
120131 // Simulate coming back online
121132 isOnline = true ;
122133 rerender ( < OfflineStatusBanner /> ) ;
134+ expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
123135
124136 act ( ( ) => {
125137 vi . advanceTimersByTime ( 500 ) ;
126138 } ) ;
127139
128- await waitFor ( ( ) => {
129- expect ( screen . queryByRole ( 'status' ) ) . not . toBeInTheDocument ( ) ;
130- } ) ;
140+ expect ( screen . queryByRole ( 'status' ) ) . not . toBeInTheDocument ( ) ;
141+ expect ( resetWasOffline ) . toHaveBeenCalled ( ) ;
131142 } ) ;
132143
133- it ( 'should update aria-label based on connection state' , async ( ) => {
134- const { useOnlineStatus } = await import ( '@/hooks/useOnlineStatus' ) ;
144+ it ( 'should update aria-label based on connection state' , ( ) => {
135145 let isOnline = false ;
136-
137- ( useOnlineStatus as any ) . mockImplementation ( ( ) => ( {
138- get isOnline ( ) { return isOnline ; } ,
139- wasOffline : true ,
140- resetWasOffline : vi . fn ( ) ,
141- } ) ) ;
146+ stubOnlineStatus ( ( ) => isOnline , true ) ;
142147
143148 const { rerender } = render ( < OfflineStatusBanner /> ) ;
144-
145- await waitFor ( ( ) => {
146- expect ( screen . getByLabelText ( 'Offline status' ) ) . toBeInTheDocument ( ) ;
147- } ) ;
149+ settleLoadingGate ( ) ;
150+ expect ( screen . getByLabelText ( 'Offline status' ) ) . toBeInTheDocument ( ) ;
148151
149152 isOnline = true ;
150153 rerender ( < OfflineStatusBanner /> ) ;
151154
152- await waitFor ( ( ) => {
153- expect ( screen . getByLabelText ( 'Reconnecting' ) ) . toBeInTheDocument ( ) ;
154- } ) ;
155+ expect ( screen . getByLabelText ( 'Reconnecting' ) ) . toBeInTheDocument ( ) ;
155156 } ) ;
156157
157- it ( 'should show loading skeleton initially when online' , async ( ) => {
158- const { useOnlineStatus } = await import ( '@/hooks/useOnlineStatus' ) ;
159- ( useOnlineStatus as any ) . mockReturnValue ( {
160- isOnline : true ,
161- wasOffline : false ,
162- resetWasOffline : vi . fn ( ) ,
163- } ) ;
158+ it ( 'should show loading skeleton initially when online' , ( ) => {
159+ stubOnlineStatus ( ( ) => true ) ;
164160
165161 render ( < OfflineStatusBanner /> ) ;
166162
167163 // Should show loading skeleton initially
168- const skeleton = document . querySelector ( '[aria-hidden="true"]' ) ;
169- expect ( skeleton ) . toBeInTheDocument ( ) ;
164+ expect ( document . querySelector ( '[aria-hidden="true"]' ) ) . toBeInTheDocument ( ) ;
170165
171166 act ( ( ) => {
172167 vi . advanceTimersByTime ( 300 ) ;
173168 } ) ;
174169
175- await waitFor ( ( ) => {
176- expect ( document . querySelector ( '[aria-hidden="true"]' ) ) . not . toBeInTheDocument ( ) ;
177- } ) ;
170+ expect (
171+ document . querySelector ( '[aria-hidden="true"]' ) ,
172+ ) . not . toBeInTheDocument ( ) ;
178173 } ) ;
179174} ) ;
0 commit comments