1- import { describe , it , expect , beforeEach , afterEach , jest } from "bun:test" ;
2- import { RefreshController } from "./RefreshController" ;
1+ import { describe , it , expect , mock } from "bun:test" ;
32
4- describe ( "RefreshController" , ( ) => {
5- beforeEach ( ( ) => {
6- jest . useFakeTimers ( ) ;
7- } ) ;
3+ import { RefreshController , type LastRefreshInfo } from "./RefreshController" ;
84
9- afterEach ( ( ) => {
10- jest . useRealTimers ( ) ;
11- } ) ;
5+ const sleep = ( ms : number ) => new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
126
13- it ( "rate-limits multiple schedule() calls (doesn't reset timer)" , ( ) => {
14- const onRefresh = jest . fn < ( ) => void > ( ) ;
15- const controller = new RefreshController ( { onRefresh, debounceMs : 100 } ) ;
7+ // NOTE: Bun's Jest-compat layer does not currently expose timer controls like
8+ // jest.advanceTimersByTime(), so these tests use real timers.
9+
10+ describe ( "RefreshController" , ( ) => {
11+ it ( "rate-limits multiple schedule() calls (doesn't reset timer)" , async ( ) => {
12+ const onRefresh = mock < ( ) => void > ( ( ) => undefined ) ;
13+ const controller = new RefreshController ( { onRefresh, debounceMs : 20 } ) ;
1614
1715 controller . schedule ( ) ;
18- jest . advanceTimersByTime ( 50 ) ;
16+ await sleep ( 10 ) ;
1917 controller . schedule ( ) ; // Shouldn't reset timer
20- jest . advanceTimersByTime ( 50 ) ;
2118
22- // Should fire at 100ms from first call, not 150ms
19+ await sleep ( 40 ) ;
20+
21+ // Should fire ~20ms after first call, not ~30ms after second.
2322 expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
2423
2524 controller . dispose ( ) ;
2625 } ) ;
2726
28- it ( "coalesces calls during rate-limit window" , ( ) => {
29- const onRefresh = jest . fn < ( ) => void > ( ) ;
30- const controller = new RefreshController ( { onRefresh, debounceMs : 100 } ) ;
27+ it ( "coalesces calls during rate-limit window" , async ( ) => {
28+ const onRefresh = mock < ( ) => void > ( ( ) => undefined ) ;
29+ const controller = new RefreshController ( { onRefresh, debounceMs : 20 } ) ;
3130
3231 controller . schedule ( ) ;
3332 controller . schedule ( ) ;
3433 controller . schedule ( ) ;
3534
3635 expect ( onRefresh ) . not . toHaveBeenCalled ( ) ;
3736
38- jest . advanceTimersByTime ( 100 ) ;
37+ await sleep ( 60 ) ;
3938
4039 expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
4140
4241 controller . dispose ( ) ;
4342 } ) ;
4443
45- it ( "requestImmediate() bypasses rate-limit timer" , ( ) => {
46- const onRefresh = jest . fn < ( ) => void > ( ) ;
47- const controller = new RefreshController ( { onRefresh, debounceMs : 100 } ) ;
44+ it ( "requestImmediate() bypasses rate-limit timer" , async ( ) => {
45+ const onRefresh = mock < ( ) => void > ( ( ) => undefined ) ;
46+ const controller = new RefreshController ( { onRefresh, debounceMs : 50 } ) ;
4847
4948 controller . schedule ( ) ;
5049 expect ( onRefresh ) . not . toHaveBeenCalled ( ) ;
@@ -53,7 +52,7 @@ describe("RefreshController", () => {
5352 expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
5453
5554 // Original timer should be cleared
56- jest . advanceTimersByTime ( 100 ) ;
55+ await sleep ( 80 ) ;
5756 expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
5857
5958 controller . dispose ( ) ;
@@ -62,17 +61,14 @@ describe("RefreshController", () => {
6261 it ( "guards against concurrent sync refreshes (in-flight queuing)" , ( ) => {
6362 // Track if refresh is currently in-flight
6463 let inFlight = false ;
65- const onRefresh = jest . fn ( ( ) => {
66- // Simulate sync operation that takes time
64+ const onRefresh = mock ( ( ) => {
6765 expect ( inFlight ) . toBe ( false ) ; // Should never be called while already in-flight
6866 inFlight = true ;
69- // Immediately complete (sync)
7067 inFlight = false ;
7168 } ) ;
7269
73- const controller = new RefreshController ( { onRefresh, debounceMs : 100 } ) ;
70+ const controller = new RefreshController ( { onRefresh, debounceMs : 20 } ) ;
7471
75- // Multiple immediate requests should only call once (queued ones execute after)
7672 controller . requestImmediate ( ) ;
7773 expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
7874
@@ -81,169 +77,170 @@ describe("RefreshController", () => {
8177
8278 it ( "schedule() during in-flight queues refresh for after completion" , async ( ) => {
8379 let resolveRefresh : ( ) => void ;
84- const onRefresh = jest . fn (
80+ const onRefresh = mock (
8581 ( ) =>
8682 new Promise < void > ( ( resolve ) => {
8783 resolveRefresh = resolve ;
8884 } )
8985 ) ;
9086
91- const controller = new RefreshController ( { onRefresh, debounceMs : 100 } ) ;
87+ const controller = new RefreshController ( { onRefresh, debounceMs : 20 } ) ;
9288
93- // Start first refresh
9489 controller . requestImmediate ( ) ;
9590 expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
9691
97- // schedule() while in-flight should queue, not start timer
92+ // schedule() while in-flight should queue for after completion.
9893 controller . schedule ( ) ;
9994
100- // Complete the first refresh and let .finally() run
10195 resolveRefresh ! ( ) ;
10296 await Promise . resolve ( ) ;
103- await Promise . resolve ( ) ; // Extra tick for .finally()
10497
105- // Should trigger follow-up refresh, but never more frequently than the minimum interval.
106- // First tick runs the post-flight setTimeout(0), then we wait out the min interval.
107- jest . advanceTimersByTime ( 0 ) ;
108- jest . advanceTimersByTime ( 500 ) ;
98+ // Follow-up refresh is subject to MIN_REFRESH_INTERVAL_MS (500ms).
99+ await sleep ( 650 ) ;
100+
109101 expect ( onRefresh ) . toHaveBeenCalledTimes ( 2 ) ;
110102
111103 controller . dispose ( ) ;
112104 } ) ;
113105
114- it ( "isRefreshing reflects in-flight state" , ( ) => {
106+ it ( "isRefreshing reflects in-flight state" , async ( ) => {
115107 let resolveRefresh : ( ) => void ;
116108 const refreshPromise = new Promise < void > ( ( resolve ) => {
117109 resolveRefresh = resolve ;
118110 } ) ;
119111
120- const onRefresh = jest . fn ( ( ) => refreshPromise ) ;
121- const controller = new RefreshController ( { onRefresh, debounceMs : 100 } ) ;
112+ const onRefresh = mock ( ( ) => refreshPromise ) ;
113+ const controller = new RefreshController ( { onRefresh, debounceMs : 20 } ) ;
122114
123115 expect ( controller . isRefreshing ) . toBe ( false ) ;
124116
125117 controller . requestImmediate ( ) ;
126118 expect ( controller . isRefreshing ) . toBe ( true ) ;
127119
128- // Complete the promise
129120 resolveRefresh ! ( ) ;
121+ await Promise . resolve ( ) ;
122+
123+ expect ( controller . isRefreshing ) . toBe ( false ) ;
130124
131125 controller . dispose ( ) ;
132126 } ) ;
133127
134- it ( "dispose() cleans up debounce timer" , ( ) => {
135- const onRefresh = jest . fn < ( ) => void > ( ) ;
136- const controller = new RefreshController ( { onRefresh, debounceMs : 100 } ) ;
128+ it ( "dispose() cleans up debounce timer" , async ( ) => {
129+ const onRefresh = mock < ( ) => void > ( ( ) => undefined ) ;
130+ const controller = new RefreshController ( { onRefresh, debounceMs : 20 } ) ;
137131
138132 controller . schedule ( ) ;
139133 controller . dispose ( ) ;
140134
141- jest . advanceTimersByTime ( 100 ) ;
135+ await sleep ( 80 ) ;
142136
143137 expect ( onRefresh ) . not . toHaveBeenCalled ( ) ;
144138 } ) ;
145139
146- it ( "does not refresh after dispose" , ( ) => {
147- const onRefresh = jest . fn < ( ) => void > ( ) ;
148- const controller = new RefreshController ( { onRefresh, debounceMs : 100 } ) ;
140+ it ( "does not refresh after dispose" , async ( ) => {
141+ const onRefresh = mock < ( ) => void > ( ( ) => undefined ) ;
142+ const controller = new RefreshController ( { onRefresh, debounceMs : 20 } ) ;
149143
150144 controller . dispose ( ) ;
151145 controller . schedule ( ) ;
152146 controller . requestImmediate ( ) ;
153147
154- jest . advanceTimersByTime ( 100 ) ;
148+ await sleep ( 80 ) ;
155149
156150 expect ( onRefresh ) . not . toHaveBeenCalled ( ) ;
157151 } ) ;
158152
159- it ( "requestImmediate() bypasses isPaused check (for manual refresh)" , ( ) => {
160- const onRefresh = jest . fn < ( ) => void > ( ) ;
153+ it ( "requestImmediate() bypasses isPaused check (for manual refresh)" , async ( ) => {
154+ const onRefresh = mock < ( ) => void > ( ( ) => undefined ) ;
161155 const paused = true ;
162156 const controller = new RefreshController ( {
163157 onRefresh,
164- debounceMs : 100 ,
158+ debounceMs : 20 ,
165159 isPaused : ( ) => paused ,
166160 } ) ;
167161
168- // schedule() should be blocked by isPaused
169162 controller . schedule ( ) ;
170- jest . advanceTimersByTime ( 100 ) ;
163+ await sleep ( 80 ) ;
171164 expect ( onRefresh ) . not . toHaveBeenCalled ( ) ;
172165
173- // requestImmediate() should bypass isPaused (manual refresh)
174166 controller . requestImmediate ( ) ;
175167 expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
176168
177169 controller . dispose ( ) ;
178170 } ) ;
179171
180- it ( "schedule() respects isPaused and flushes on notifyUnpaused" , ( ) => {
181- const onRefresh = jest . fn < ( ) => void > ( ) ;
172+ it ( "schedule() respects isPaused and flushes on notifyUnpaused" , async ( ) => {
173+ const onRefresh = mock < ( ) => void > ( ( ) => undefined ) ;
182174 let paused = true ;
183175 const controller = new RefreshController ( {
184176 onRefresh,
185- debounceMs : 100 ,
177+ debounceMs : 20 ,
186178 isPaused : ( ) => paused ,
187179 } ) ;
188180
189- // schedule() should queue but not execute while paused
190181 controller . schedule ( ) ;
191- jest . advanceTimersByTime ( 100 ) ;
182+ await sleep ( 80 ) ;
192183 expect ( onRefresh ) . not . toHaveBeenCalled ( ) ;
193184
194- // Unpausing should flush the pending refresh
195185 paused = false ;
196186 controller . notifyUnpaused ( ) ;
187+
197188 expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
198189
199190 controller . dispose ( ) ;
200191 } ) ;
201192
202- it ( "lastRefreshInfo tracks trigger and timestamp" , ( ) => {
203- const onRefresh = jest . fn < ( ) => void > ( ) ;
204- const controller = new RefreshController ( { onRefresh, debounceMs : 100 } ) ;
193+ it ( "lastRefreshInfo tracks trigger and timestamp" , async ( ) => {
194+ const onRefresh = mock < ( ) => void > ( ( ) => undefined ) ;
195+ const controller = new RefreshController ( {
196+ onRefresh,
197+ debounceMs : 20 ,
198+ priorityDebounceMs : 20 ,
199+ } ) ;
205200
206201 expect ( controller . lastRefreshInfo ) . toBeNull ( ) ;
207202
208- // Manual refresh should record "manual" trigger
209203 const beforeManual = Date . now ( ) ;
210204 controller . requestImmediate ( ) ;
205+
206+ expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
211207 expect ( controller . lastRefreshInfo ) . not . toBeNull ( ) ;
212208 expect ( controller . lastRefreshInfo ! . trigger ) . toBe ( "manual" ) ;
213209 expect ( controller . lastRefreshInfo ! . timestamp ) . toBeGreaterThanOrEqual ( beforeManual ) ;
214210
215- // Scheduled refresh should record "scheduled" trigger
216211 controller . schedule ( ) ;
217- jest . advanceTimersByTime ( 500 ) ;
212+ await sleep ( 650 ) ;
213+ expect ( onRefresh ) . toHaveBeenCalledTimes ( 2 ) ;
218214 expect ( controller . lastRefreshInfo ! . trigger ) . toBe ( "scheduled" ) ;
219215
220- // Priority refresh should record "priority" trigger
221216 controller . schedulePriority ( ) ;
222- jest . advanceTimersByTime ( 500 ) ;
217+ await sleep ( 650 ) ;
218+ expect ( onRefresh ) . toHaveBeenCalledTimes ( 3 ) ;
223219 expect ( controller . lastRefreshInfo ! . trigger ) . toBe ( "priority" ) ;
224220
225221 controller . dispose ( ) ;
226222 } ) ;
227223
228- it ( "onRefreshComplete callback is called with refresh info" , ( ) => {
229- const onRefresh = jest . fn < ( ) => void > ( ) ;
230- const onRefreshComplete = jest . fn < ( info : { trigger : string ; timestamp : number } ) => void > ( ) ;
224+ it ( "onRefreshComplete callback is called with refresh info" , async ( ) => {
225+ const onRefresh = mock < ( ) => void > ( ( ) => undefined ) ;
226+ const onRefreshComplete = mock < ( info : LastRefreshInfo ) => void > ( ( ) => undefined ) ;
231227 const controller = new RefreshController ( {
232228 onRefresh,
233229 onRefreshComplete,
234- debounceMs : 100 ,
230+ debounceMs : 20 ,
235231 } ) ;
236232
237233 expect ( onRefreshComplete ) . not . toHaveBeenCalled ( ) ;
238234
239235 controller . requestImmediate ( ) ;
240236 expect ( onRefreshComplete ) . toHaveBeenCalledTimes ( 1 ) ;
241237 expect ( onRefreshComplete ) . toHaveBeenCalledWith (
238+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
242239 expect . objectContaining ( { trigger : "manual" , timestamp : expect . any ( Number ) } )
243240 ) ;
244241
245242 controller . schedule ( ) ;
246- jest . advanceTimersByTime ( 500 ) ;
243+ await sleep ( 650 ) ;
247244 expect ( onRefreshComplete ) . toHaveBeenCalledTimes ( 2 ) ;
248245 expect ( onRefreshComplete ) . toHaveBeenLastCalledWith (
249246 expect . objectContaining ( { trigger : "scheduled" } )
0 commit comments