@@ -1802,7 +1802,21 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
18021802 } )
18031803
18041804 describe ( 'in-flight tracking: refresh vs loadMore' , ( ) => {
1805- it ( 'refresh during an in-flight loadMore runs its reset path and is not dropped' , async ( ) => {
1805+ const deferredPage = ( ) => {
1806+ let resolve ! : ( page : AssetResponse ) => void
1807+ let reject ! : ( err : unknown ) => void
1808+ const promise = new Promise < AssetResponse > ( ( res , rej ) => {
1809+ resolve = res
1810+ reject = rej
1811+ } )
1812+ return { promise, resolve, reject }
1813+ }
1814+
1815+ /**
1816+ * Loads a full first page so `hasMore` is true and a loadMore is legal,
1817+ * then clears the mock so each test's assertions see only its own calls.
1818+ */
1819+ const setupStoreWithFirstPage = async ( ) => {
18061820 const firstPage = Array . from ( { length : FLAT_OUTPUT_PAGE_SIZE } , ( _ , i ) =>
18071821 makeAsset ( `a${ i } ` , `f${ i } .png` )
18081822 )
@@ -1811,21 +1825,18 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
18111825 )
18121826 const store = useAssetsStore ( )
18131827 await store . updateFlatOutputs ( )
1814-
18151828 vi . mocked ( assetService . getAssetsPageByTag ) . mockClear ( )
1829+ return { store, firstPageIds : firstPage . map ( ( a ) => a . id ) }
1830+ }
18161831
1817- let resolveLoadMore ! : ( page : AssetResponse ) => void
1818- const loadMorePromise = new Promise < AssetResponse > ( ( res ) => {
1819- resolveLoadMore = res
1820- } )
1821- let resolveRefresh ! : ( page : AssetResponse ) => void
1822- const refreshPromise = new Promise < AssetResponse > ( ( res ) => {
1823- resolveRefresh = res
1824- } )
1832+ it ( 'refresh during an in-flight loadMore runs its reset path and is not dropped' , async ( ) => {
1833+ const { store } = await setupStoreWithFirstPage ( )
1834+ const loadMorePage = deferredPage ( )
1835+ const refreshPage = deferredPage ( )
18251836
18261837 vi . mocked ( assetService . getAssetsPageByTag )
1827- . mockReturnValueOnce ( loadMorePromise )
1828- . mockReturnValueOnce ( refreshPromise )
1838+ . mockReturnValueOnce ( loadMorePage . promise )
1839+ . mockReturnValueOnce ( refreshPage . promise )
18291840
18301841 const loadMoreResult = store . loadMoreFlatOutputs ( )
18311842 const refreshResult = store . updateFlatOutputs ( )
@@ -1834,46 +1845,29 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
18341845 2
18351846 )
18361847
1837- resolveRefresh ( makePage ( [ makeAsset ( 'fresh-1' , 'fresh.png' ) ] ) )
1838- resolveLoadMore ( makePage ( [ makeAsset ( 'extra-1' , 'extra.png' ) ] ) )
1848+ refreshPage . resolve ( makePage ( [ makeAsset ( 'fresh-1' , 'fresh.png' ) ] ) )
1849+ loadMorePage . resolve ( makePage ( [ makeAsset ( 'extra-1' , 'extra.png' ) ] ) )
18391850 await Promise . all ( [ loadMoreResult , refreshResult ] )
18401851
1841- expect ( store . flatOutputAssets . map ( ( a ) => a . id ) ) . toContain ( 'fresh-1' )
1842- expect ( store . flatOutputAssets . map ( ( a ) => a . id ) ) . not . toContain ( 'extra-1' )
1852+ expect ( store . flatOutputAssets . map ( ( a ) => a . id ) ) . toEqual ( [ 'fresh-1' ] )
18431853 } )
18441854
18451855 it ( 'a stale loadMore that resolves before the refresh does not corrupt seenIds' , async ( ) => {
1846- const firstPage = Array . from ( { length : FLAT_OUTPUT_PAGE_SIZE } , ( _ , i ) =>
1847- makeAsset ( `a${ i } ` , `f${ i } .png` )
1848- )
1849- vi . mocked ( assetService . getAssetsPageByTag ) . mockResolvedValueOnce (
1850- makePage ( firstPage , { hasMore : true } )
1851- )
1852- const store = useAssetsStore ( )
1853- await store . updateFlatOutputs ( )
1854-
1855- vi . mocked ( assetService . getAssetsPageByTag ) . mockClear ( )
1856-
1857- let resolveLoadMore ! : ( page : AssetResponse ) => void
1858- const loadMorePromise = new Promise < AssetResponse > ( ( res ) => {
1859- resolveLoadMore = res
1860- } )
1861- let resolveRefresh ! : ( page : AssetResponse ) => void
1862- const refreshPromise = new Promise < AssetResponse > ( ( res ) => {
1863- resolveRefresh = res
1864- } )
1856+ const { store } = await setupStoreWithFirstPage ( )
1857+ const loadMorePage = deferredPage ( )
1858+ const refreshPage = deferredPage ( )
18651859
18661860 vi . mocked ( assetService . getAssetsPageByTag )
1867- . mockReturnValueOnce ( loadMorePromise )
1868- . mockReturnValueOnce ( refreshPromise )
1861+ . mockReturnValueOnce ( loadMorePage . promise )
1862+ . mockReturnValueOnce ( refreshPage . promise )
18691863
18701864 const loadMoreResult = store . loadMoreFlatOutputs ( )
18711865 const refreshResult = store . updateFlatOutputs ( )
18721866
18731867 // The stale loadMore settles first, before the refresh has replaced the
18741868 // list. Its page must be discarded rather than folded into seenIds.
1875- resolveLoadMore ( makePage ( [ makeAsset ( 'extra-1' , 'extra.png' ) ] ) )
1876- resolveRefresh (
1869+ loadMorePage . resolve ( makePage ( [ makeAsset ( 'extra-1' , 'extra.png' ) ] ) )
1870+ refreshPage . resolve (
18771871 makePage ( [ makeAsset ( 'fresh-1' , 'fresh.png' ) ] , { hasMore : true } )
18781872 )
18791873 await Promise . all ( [ loadMoreResult , refreshResult ] )
@@ -1893,13 +1887,75 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
18931887 ] )
18941888 } )
18951889
1896- it ( 'a second concurrent refresh coalesces into the first refresh promise' , async ( ) => {
1897- let resolvePage ! : ( page : AssetResponse ) => void
1898- const pagePromise = new Promise < AssetResponse > ( ( res ) => {
1899- resolvePage = res
1900- } )
1890+ it ( 'discards a stale loadMore even when the refresh reports no further pages' , async ( ) => {
1891+ const { store } = await setupStoreWithFirstPage ( )
1892+ const loadMorePage = deferredPage ( )
1893+ const refreshPage = deferredPage ( )
1894+
1895+ vi . mocked ( assetService . getAssetsPageByTag )
1896+ . mockReturnValueOnce ( loadMorePage . promise )
1897+ . mockReturnValueOnce ( refreshPage . promise )
1898+
1899+ const loadMoreResult = store . loadMoreFlatOutputs ( )
1900+ const refreshResult = store . updateFlatOutputs ( )
1901+
1902+ loadMorePage . resolve (
1903+ makePage ( [ makeAsset ( 'extra-1' , 'extra.png' ) ] , { hasMore : true } )
1904+ )
1905+ refreshPage . resolve (
1906+ makePage ( [ makeAsset ( 'fresh-1' , 'fresh.png' ) ] , { hasMore : false } )
1907+ )
1908+ await Promise . all ( [ loadMoreResult , refreshResult ] )
1909+
1910+ expect ( store . flatOutputAssets . map ( ( a ) => a . id ) ) . toEqual ( [ 'fresh-1' ] )
1911+ expect ( store . flatOutputHasMore ) . toBe ( false )
1912+ } )
1913+
1914+ it ( 'keeps the list and the loaded-id check in step when a refresh fails mid-loadMore' , async ( ) => {
1915+ const consoleSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } )
1916+ try {
1917+ const { store, firstPageIds } = await setupStoreWithFirstPage ( )
1918+ const loadMorePage = deferredPage ( )
1919+ const refreshPage = deferredPage ( )
1920+
1921+ vi . mocked ( assetService . getAssetsPageByTag )
1922+ . mockReturnValueOnce ( loadMorePage . promise )
1923+ . mockReturnValueOnce ( refreshPage . promise )
1924+
1925+ const loadMoreResult = store . loadMoreFlatOutputs ( )
1926+ const refreshResult = store . updateFlatOutputs ( )
1927+
1928+ const err = new Error ( 'network down' )
1929+ refreshPage . reject ( err )
1930+ loadMorePage . resolve ( makePage ( [ makeAsset ( 'extra-1' , 'extra.png' ) ] ) )
1931+ await Promise . all ( [ loadMoreResult , refreshResult ] )
1932+
1933+ expect ( store . flatOutputError ) . toBe ( err )
1934+ expect ( store . flatOutputLoading ) . toBe ( false )
1935+ expect ( store . flatOutputIsLoadingMore ) . toBe ( false )
1936+ // The failed refresh dropped nothing; the stale loadMore was discarded.
1937+ expect ( store . flatOutputAssets . map ( ( a ) => a . id ) ) . toEqual ( firstPageIds )
1938+
1939+ // A retry restarts from the head. The re-served first page is already
1940+ // in seenIds, so it must not be appended a second time.
1941+ vi . mocked ( assetService . getAssetsPageByTag ) . mockResolvedValueOnce (
1942+ makePage (
1943+ firstPageIds . map ( ( id , i ) => makeAsset ( id , `f${ i } .png` ) ) ,
1944+ { hasMore : true }
1945+ )
1946+ )
1947+ await store . loadMoreFlatOutputs ( )
1948+
1949+ expect ( store . flatOutputAssets . map ( ( a ) => a . id ) ) . toEqual ( firstPageIds )
1950+ } finally {
1951+ consoleSpy . mockRestore ( )
1952+ }
1953+ } )
1954+
1955+ it ( 'concurrent refreshes deduplicate the network call and produce consistent state' , async ( ) => {
1956+ const page = deferredPage ( )
19011957 vi . mocked ( assetService . getAssetsPageByTag ) . mockReturnValueOnce (
1902- pagePromise
1958+ page . promise
19031959 )
19041960
19051961 const store = useAssetsStore ( )
@@ -1910,7 +1966,7 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
19101966 1
19111967 )
19121968
1913- resolvePage ( makePage ( [ makeAsset ( 'only-1' , 'only.png' ) ] ) )
1969+ page . resolve ( makePage ( [ makeAsset ( 'only-1' , 'only.png' ) ] ) )
19141970 await Promise . all ( [ r1 , r2 ] )
19151971
19161972 expect ( store . flatOutputAssets . map ( ( a ) => a . id ) ) . toEqual ( [ 'only-1' ] )
0 commit comments