11import { describe , it , expect , vi , beforeEach , afterEach } from 'vitest' ;
2- import { renderHook , act } from '@testing-library/react' ;
3- import { render , screen , waitFor } from '@testing-library/react' ;
2+ import { act , render , screen } from '@testing-library/react' ;
43import OfflineStatusBanner from './OfflineStatusBanner' ;
5- import * as offlineMessageQueue from '@/lib/offlineMessageQueue' ;
4+ import { useOnlineStatus } from '@/hooks/useOnlineStatus' ;
5+ import { subscribeToQueuedMessageCount } from '@/lib/offlineMessageQueue' ;
66
77// Mock dependencies
88vi . mock ( '@/hooks/useOnlineStatus' , ( ) => ( {
@@ -26,14 +26,42 @@ vi.mock('@/lib/offlineStatusSchema', () => ({
2626} ) ) ;
2727
2828vi . mock ( '@/lib/offlineMessageQueue' , ( ) => ( {
29- subscribeToQueuedMessageCount : vi . fn ( ) ,
29+ subscribeToQueuedMessageCount : vi . fn ( ( ) => ( ) => { } ) ,
3030 setQueuedMessageCount : vi . fn ( ) ,
3131 getQueuedMessageCount : vi . fn ( ( ) => 0 ) ,
3232} ) ) ;
3333
34+ const mockedUseOnlineStatus = vi . mocked ( useOnlineStatus ) ;
35+ const mockedSubscribe = vi . mocked ( subscribeToQueuedMessageCount ) ;
36+
37+ /** Elapse the 300ms initial loading gate so it cannot mask later renders. */
38+ function settleLoadingGate ( ) {
39+ act ( ( ) => {
40+ vi . advanceTimersByTime ( 300 ) ;
41+ } ) ;
42+ }
43+
44+ /** Point `useOnlineStatus` at a value the test can flip between renders. */
45+ function stubOnlineStatus ( getIsOnline : ( ) => boolean , wasOffline = false ) {
46+ const resetWasOffline = vi . fn ( ) ;
47+ mockedUseOnlineStatus . mockImplementation ( ( ) => ( {
48+ get isOnline ( ) {
49+ return getIsOnline ( ) ;
50+ } ,
51+ wasOffline,
52+ resetWasOffline,
53+ } ) ) ;
54+ return resetWasOffline ;
55+ }
56+
3457describe ( 'OfflineStatusBanner - Optimistic UI Updates' , ( ) => {
3558 beforeEach ( ( ) => {
3659 vi . clearAllMocks ( ) ;
60+ mockedSubscribe . mockImplementation ( ( ) => ( ) => { } ) ;
61+ // Fake timers are required for the 300ms loading skeleton and the 500ms
62+ // reconnect dismissal. Assertions below are made directly after an explicit
63+ // `advanceTimersByTime` rather than through `waitFor`, whose polling never
64+ // fires while the clock is frozen.
3765 vi . useFakeTimers ( ) ;
3866 } ) ;
3967
@@ -42,138 +70,104 @@ describe('OfflineStatusBanner - Optimistic UI Updates', () => {
4270 vi . useRealTimers ( ) ;
4371 } ) ;
4472
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- } ) ;
73+ it ( 'should show banner immediately when going offline' , ( ) => {
74+ stubOnlineStatus ( ( ) => false ) ;
5275
5376 render ( < OfflineStatusBanner /> ) ;
5477
55- await waitFor ( ( ) => {
56- expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
57- } ) ;
58-
78+ expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
5979 expect ( screen . getByText ( / Y o u a r e o f f l i n e / i) ) . toBeInTheDocument ( ) ;
6080 } ) ;
6181
62- it ( 'should show reconnecting state when coming back online' , async ( ) => {
63- const { useOnlineStatus } = await import ( '@/hooks/useOnlineStatus' ) ;
82+ it ( 'should show reconnecting state when coming back online' , ( ) => {
6483 let isOnline = false ;
65-
66- ( useOnlineStatus as any ) . mockImplementation ( ( ) => ( {
67- get isOnline ( ) { return isOnline ; } ,
68- wasOffline : true ,
69- resetWasOffline : vi . fn ( ) ,
70- } ) ) ;
84+ stubOnlineStatus ( ( ) => isOnline , true ) ;
7185
7286 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- } ) ;
87+ settleLoadingGate ( ) ;
88+ expect ( screen . getByText ( / Y o u a r e o f f l i n e / i) ) . toBeInTheDocument ( ) ;
7789
7890 // Simulate coming back online
7991 isOnline = true ;
8092 rerender ( < OfflineStatusBanner /> ) ;
8193
82- await waitFor ( ( ) => {
83- expect ( screen . getByText ( / R e c o n n e c t i n g / i) ) . toBeInTheDocument ( ) ;
84- } ) ;
94+ expect ( screen . getByText ( / R e c o n n e c t i n g / i) ) . toBeInTheDocument ( ) ;
8595 } ) ;
8696
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 ( ) ,
97+ it ( 'should display optimistic pending count' , ( ) => {
98+ stubOnlineStatus ( ( ) => false ) ;
99+ // The count is pushed by `offlineMessageQueue` subscribers, not pulled.
100+ mockedSubscribe . mockImplementation ( ( listener ) => {
101+ listener ( 3 ) ;
102+ return ( ) => { } ;
93103 } ) ;
94104
95- ( offlineMessageQueue . getQueuedMessageCount as any ) . mockReturnValue ( 3 ) ;
96-
97105 render ( < OfflineStatusBanner /> ) ;
98106
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 ( ) ;
107+ 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+ } ) ;
109+
110+ it ( 'should pluralise a single pending message' , ( ) => {
111+ stubOnlineStatus ( ( ) => false ) ;
112+ mockedSubscribe . mockImplementation ( ( listener ) => {
113+ listener ( 1 ) ;
114+ return ( ) => { } ;
101115 } ) ;
116+
117+ render ( < OfflineStatusBanner /> ) ;
118+
119+ 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 ( ) ;
102120 } ) ;
103121
104- it ( 'should hide banner after reconnection delay' , async ( ) => {
105- const { useOnlineStatus } = await import ( '@/hooks/useOnlineStatus' ) ;
122+ it ( 'should hide banner after reconnection delay' , ( ) => {
106123 let isOnline = false ;
107-
108- ( useOnlineStatus as any ) . mockImplementation ( ( ) => ( {
109- get isOnline ( ) { return isOnline ; } ,
110- wasOffline : true ,
111- resetWasOffline : vi . fn ( ) ,
112- } ) ) ;
124+ const resetWasOffline = stubOnlineStatus ( ( ) => isOnline , true ) ;
113125
114126 const { rerender } = render ( < OfflineStatusBanner /> ) ;
115-
116- await waitFor ( ( ) => {
117- expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
118- } ) ;
127+ settleLoadingGate ( ) ;
128+ expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
119129
120130 // Simulate coming back online
121131 isOnline = true ;
122132 rerender ( < OfflineStatusBanner /> ) ;
133+ expect ( screen . getByRole ( 'status' ) ) . toBeInTheDocument ( ) ;
123134
124135 act ( ( ) => {
125136 vi . advanceTimersByTime ( 500 ) ;
126137 } ) ;
127138
128- await waitFor ( ( ) => {
129- expect ( screen . queryByRole ( 'status' ) ) . not . toBeInTheDocument ( ) ;
130- } ) ;
139+ expect ( screen . queryByRole ( 'status' ) ) . not . toBeInTheDocument ( ) ;
140+ expect ( resetWasOffline ) . toHaveBeenCalled ( ) ;
131141 } ) ;
132142
133- it ( 'should update aria-label based on connection state' , async ( ) => {
134- const { useOnlineStatus } = await import ( '@/hooks/useOnlineStatus' ) ;
143+ it ( 'should update aria-label based on connection state' , ( ) => {
135144 let isOnline = false ;
136-
137- ( useOnlineStatus as any ) . mockImplementation ( ( ) => ( {
138- get isOnline ( ) { return isOnline ; } ,
139- wasOffline : true ,
140- resetWasOffline : vi . fn ( ) ,
141- } ) ) ;
145+ stubOnlineStatus ( ( ) => isOnline , true ) ;
142146
143147 const { rerender } = render ( < OfflineStatusBanner /> ) ;
144-
145- await waitFor ( ( ) => {
146- expect ( screen . getByLabelText ( 'Offline status' ) ) . toBeInTheDocument ( ) ;
147- } ) ;
148+ settleLoadingGate ( ) ;
149+ expect ( screen . getByLabelText ( 'Offline status' ) ) . toBeInTheDocument ( ) ;
148150
149151 isOnline = true ;
150152 rerender ( < OfflineStatusBanner /> ) ;
151153
152- await waitFor ( ( ) => {
153- expect ( screen . getByLabelText ( 'Reconnecting' ) ) . toBeInTheDocument ( ) ;
154- } ) ;
154+ expect ( screen . getByLabelText ( 'Reconnecting' ) ) . toBeInTheDocument ( ) ;
155155 } ) ;
156156
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- } ) ;
157+ it ( 'should show loading skeleton initially when online' , ( ) => {
158+ stubOnlineStatus ( ( ) => true ) ;
164159
165160 render ( < OfflineStatusBanner /> ) ;
166161
167162 // Should show loading skeleton initially
168- const skeleton = document . querySelector ( '[aria-hidden="true"]' ) ;
169- expect ( skeleton ) . toBeInTheDocument ( ) ;
163+ expect ( document . querySelector ( '[aria-hidden="true"]' ) ) . toBeInTheDocument ( ) ;
170164
171165 act ( ( ) => {
172166 vi . advanceTimersByTime ( 300 ) ;
173167 } ) ;
174168
175- await waitFor ( ( ) => {
176- expect ( document . querySelector ( '[aria-hidden="true"]' ) ) . not . toBeInTheDocument ( ) ;
177- } ) ;
169+ expect (
170+ document . querySelector ( '[aria-hidden="true"]' ) ,
171+ ) . not . toBeInTheDocument ( ) ;
178172 } ) ;
179173} ) ;
0 commit comments