@@ -60,8 +60,14 @@ describe("ChartRefreshEngine", () => {
6060
6161 beforeEach ( ( ) => {
6262 vi . useFakeTimers ( )
63+ // clearAllMocks keeps implementations; reset the module mock explicitly so
64+ // one test's snapshot fixture can't hydrate another test's cells.
65+ vi . mocked ( loadCellSnapshot ) . mockResolvedValue ( undefined )
6366 deps = makeDeps ( )
64- engine = new ChartRefreshEngine ( BUFFER_ID , ( ) => deps as ChartRefreshDeps )
67+ // Jitter off: tests assert exact fetch timing.
68+ engine = new ChartRefreshEngine ( BUFFER_ID , ( ) => deps as ChartRefreshDeps , {
69+ initialFetchJitterMs : 0 ,
70+ } )
6571 engine . attach ( )
6672 } )
6773
@@ -273,6 +279,137 @@ describe("ChartRefreshEngine", () => {
273279 expect ( deps . mirrorCellResult ) . toHaveBeenCalledTimes ( 1 )
274280 } )
275281
282+ it ( "does not poll a cell that is hidden before it enters draw mode" , async ( ) => {
283+ // Given the observer reported the cell offscreen before it became a chart
284+ engine . setVisible ( "c1" , false )
285+
286+ // When the engine syncs the polling draw cell
287+ engine . sync ( [ drawCell ( "c1" , "select 1" , "1s" ) ] )
288+ await flushAsync ( )
289+
290+ // Then no fetch happens no matter how much time passes
291+ await vi . advanceTimersByTimeAsync ( 10_000 )
292+ expect ( deps . executeSingle ) . not . toHaveBeenCalled ( )
293+ } )
294+
295+ it ( "catches up immediately on reveal when the cell never fetched" , async ( ) => {
296+ // Given a hidden, never-fetched polling cell
297+ engine . setVisible ( "c1" , false )
298+ engine . sync ( [ drawCell ( "c1" , "select 1" , "1s" ) ] )
299+ await flushAsync ( )
300+ expect ( deps . executeSingle ) . not . toHaveBeenCalled ( )
301+
302+ // When the cell scrolls into view
303+ engine . setVisible ( "c1" , true )
304+ await flushAsync ( )
305+
306+ // Then it fetches immediately and keeps polling
307+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 1 )
308+ await vi . advanceTimersByTimeAsync ( 1000 )
309+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 2 )
310+ } )
311+
312+ it ( "pauses polling when the cell scrolls out of view" , async ( ) => {
313+ // Given a visible polling cell that has fetched once
314+ engine . sync ( [ drawCell ( "c1" , "select 1" , "1s" ) ] )
315+ await flushAsync ( )
316+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 1 )
317+
318+ // When it scrolls out of view
319+ engine . setVisible ( "c1" , false )
320+
321+ // Then polling stops while its data stays intact
322+ await vi . advanceTimersByTimeAsync ( 10_000 )
323+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 1 )
324+ expect ( engine . getState ( "c1" ) ?. results ) . toHaveLength ( 1 )
325+ } )
326+
327+ it ( "skips the reveal catch-up when the data is still fresh" , async ( ) => {
328+ // Given a cell hidden right after a fetch
329+ engine . sync ( [ drawCell ( "c1" , "select 1" , "1s" ) ] )
330+ await flushAsync ( )
331+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 1 )
332+ engine . setVisible ( "c1" , false )
333+
334+ // When it is revealed again well within its refresh interval
335+ await vi . advanceTimersByTimeAsync ( 100 )
336+ engine . setVisible ( "c1" , true )
337+ await flushAsync ( )
338+
339+ // Then there is no immediate refetch — the next one waits a full interval
340+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 1 )
341+ await vi . advanceTimersByTimeAsync ( 1000 )
342+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 2 )
343+ } )
344+
345+ it ( "defers an auto-refresh-off cell's initial fetch to its first reveal" , async ( ) => {
346+ // Given a hidden cell with auto-refresh off
347+ engine . setVisible ( "c1" , false )
348+ engine . sync ( [ drawCell ( "c1" , "select 1" , false ) ] )
349+ await flushAsync ( )
350+ expect ( deps . executeSingle ) . not . toHaveBeenCalled ( )
351+
352+ // When it is revealed for the first time
353+ engine . setVisible ( "c1" , true )
354+ await flushAsync ( )
355+
356+ // Then it fetches exactly once
357+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 1 )
358+
359+ // And hiding and revealing it again does not refetch settled data
360+ engine . setVisible ( "c1" , false )
361+ engine . setVisible ( "c1" , true )
362+ await flushAsync ( )
363+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 1 )
364+ } )
365+
366+ it ( "bounds concurrent fetches across cells" , async ( ) => {
367+ // Given an engine capped at one in-flight fetch and a slow first query
368+ engine . destroy ( )
369+ engine = new ChartRefreshEngine ( BUFFER_ID , ( ) => deps as ChartRefreshDeps , {
370+ initialFetchJitterMs : 0 ,
371+ maxConcurrentFetches : 1 ,
372+ } )
373+ engine . attach ( )
374+ let releaseFirst ! : ( value : QueryExecResult ) => void
375+ deps . executeSingle
376+ . mockImplementationOnce (
377+ ( ) => new Promise < QueryExecResult > ( ( res ) => ( releaseFirst = res ) ) ,
378+ )
379+ . mockImplementation ( ( sql : string ) => Promise . resolve ( dqlResult ( sql ) ) )
380+
381+ // When two cells want to fetch at the same time
382+ engine . sync ( [
383+ drawCell ( "c1" , "select 1" , false ) ,
384+ drawCell ( "c2" , "select 2" , false ) ,
385+ ] )
386+ await flushAsync ( )
387+
388+ // Then only the first is in flight; the second waits its turn
389+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 1 )
390+ releaseFirst ( dqlResult ( "select 1" ) )
391+ await flushAsync ( )
392+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 2 )
393+ } )
394+
395+ it ( "spreads a loop's first fetch by the configured jitter" , async ( ) => {
396+ // Given an engine with 300ms of initial jitter
397+ engine . destroy ( )
398+ engine = new ChartRefreshEngine ( BUFFER_ID , ( ) => deps as ChartRefreshDeps , {
399+ initialFetchJitterMs : 300 ,
400+ } )
401+ engine . attach ( )
402+
403+ // When a polling cell syncs
404+ engine . sync ( [ drawCell ( "c1" , "select 1" , "1s" ) ] )
405+ await flushAsync ( )
406+
407+ // Then the first fetch waits for the jitter window instead of firing at t=0
408+ expect ( deps . executeSingle ) . not . toHaveBeenCalled ( )
409+ await vi . advanceTimersByTimeAsync ( 300 )
410+ expect ( deps . executeSingle ) . toHaveBeenCalledTimes ( 1 )
411+ } )
412+
276413 it ( "publishes loading state for the cell toolbar" , async ( ) => {
277414 // Given a listener on the chart loading event
278415 const events : Array < { loading : boolean ; refreshing : boolean } > = [ ]
0 commit comments