Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/stores/assetsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,78 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})

describe('assetsStore - loadedJobIds (all-job dedup)', () => {
let store: ReturnType<typeof useAssetsStore>

const createFailedJobItem = (id: string): JobListItem => ({
id,
status: 'failed',
create_time: 1000,
update_time: 1000,
last_state_update: 1000,
priority: 1
})

const createDisplayableJobItem = (id: string, index = 0): JobListItem => ({
id,
status: 'completed',
create_time: 1000 + index,
update_time: 1000 + index,
last_state_update: 1000 + index,
priority: 1000 + index,
preview_output: {
filename: `output_${id}.png`,
subfolder: '',
type: 'output',
nodeId: 'node_1',
mediaType: 'images'
}
})

beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAssetsStore()
vi.clearAllMocks()
})

it('does not false-negative dedup when the first page is all non-displayable jobs', async () => {
const firstBatch = Array.from({ length: 200 }, (_, i) =>
createFailedJobItem(`failed_${i}`)
)
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
await store.updateHistory()

expect(store.historyAssets).toHaveLength(0)

const secondBatch = [
createFailedJobItem('failed_0'),
createDisplayableJobItem('new_job', 0)
]
vi.mocked(api.getHistory).mockResolvedValueOnce(secondBatch)
await store.loadMoreHistory()

const ids = store.historyAssets.map((a) => a.id)
expect(ids).toContain('new_job')
expect(ids).not.toContain('failed_0')
})

it('loadedJobIds is cleared on reset (updateHistory)', async () => {
const firstBatch = [createFailedJobItem('job_a')]
vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch)
await store.updateHistory()

const secondBatch = [createDisplayableJobItem('job_a', 0)]
vi.mocked(api.getHistory)
.mockResolvedValueOnce(secondBatch)
.mockResolvedValueOnce([])

await store.updateHistory()

expect(store.historyAssets).toHaveLength(1)
expect(store.historyAssets[0].id).toBe('job_a')
})
})

describe('assetsStore - Model Assets Cache (Cloud)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
Expand Down Expand Up @@ -1634,4 +1706,61 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {

expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
})

describe('in-flight tracking: refresh vs loadMore', () => {
it('refresh during an in-flight loadMore runs its reset path and is not dropped', async () => {
const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
makeAsset(`a${i}`, `f${i}.png`)
)
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce(firstPage)
const store = useAssetsStore()
await store.updateFlatOutputs()

vi.mocked(assetService.getAssetsByTag).mockClear()

let resolveLoadMore!: (assets: AssetItem[]) => void
const loadMorePromise = new Promise<AssetItem[]>((res) => {
resolveLoadMore = res
})
let resolveRefresh!: (assets: AssetItem[]) => void
const refreshPromise = new Promise<AssetItem[]>((res) => {
resolveRefresh = res
})

vi.mocked(assetService.getAssetsByTag)
.mockReturnValueOnce(loadMorePromise)
.mockReturnValueOnce(refreshPromise)

const loadMoreResult = store.loadMoreFlatOutputs()
const refreshResult = store.updateFlatOutputs()

expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledTimes(2)

resolveRefresh([makeAsset('fresh-1', 'fresh.png')])
resolveLoadMore([makeAsset('extra-1', 'extra.png')])
await Promise.all([loadMoreResult, refreshResult])

expect(store.flatOutputAssets.map((a) => a.id)).toContain('fresh-1')
Comment thread
mattmillerai marked this conversation as resolved.
expect(store.flatOutputAssets.map((a) => a.id)).not.toContain('extra-1')
})

it('a second concurrent refresh coalesces into the first refresh promise', async () => {
let resolvePage!: (assets: AssetItem[]) => void
const pagePromise = new Promise<AssetItem[]>((res) => {
resolvePage = res
})
vi.mocked(assetService.getAssetsByTag).mockReturnValueOnce(pagePromise)

const store = useAssetsStore()
const r1 = store.updateFlatOutputs()
const r2 = store.updateFlatOutputs()

expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledTimes(1)

resolvePage([makeAsset('only-1', 'only.png')])
await Promise.all([r1, r2])

expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['only-1'])
})
})
})
44 changes: 27 additions & 17 deletions src/stores/assetsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,46 +151,38 @@ export const useAssetsStore = defineStore('assets', () => {
* @param loadMore - true for pagination (append), false for initial load (replace)
*/
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
// Reset state for initial load
if (!loadMore) {
historyOffset.value = 0
hasMoreHistory.value = true
allHistoryItems.value = []
loadedIds.clear()
}

// Fetch from server with offset
const history = await api.getHistory(BATCH_SIZE, {
offset: historyOffset.value
})

// Convert JobListItems to AssetItems
const newAssets = mapHistoryToAssets(history)

if (loadMore) {
// Filter out duplicates and insert in sorted order
for (const asset of newAssets) {
if (loadedIds.has(asset.id)) {
continue // Skip duplicates
continue
}
loadedIds.add(asset.id)

// Find insertion index to maintain sorted order (newest first)
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)

if (insertIndex === -1) {
// Asset is oldest, append to end
allHistoryItems.value.push(asset)
} else {
// Insert at the correct position
allHistoryItems.value.splice(insertIndex, 0, asset)
}
}
} else {
// Initial load: replace all
allHistoryItems.value = newAssets
newAssets.forEach((asset) => loadedIds.add(asset.id))
}
Expand Down Expand Up @@ -267,28 +259,36 @@ export const useAssetsStore = defineStore('assets', () => {
const flatOutputHasMore = ref(true)
const flatOutputIsLoadingMore = ref(false)
const flatOutputSeenIds = new Set<string>()
let flatOutputInFlight: Promise<AssetItem[]> | null = null
let flatOutputRefreshInFlight: Promise<AssetItem[]> | null = null
let flatOutputLoadMoreInFlight: Promise<AssetItem[]> | null = null
let flatOutputGeneration = 0

async function fetchFlatOutputs(loadMore: boolean): Promise<AssetItem[]> {
if (flatOutputInFlight) return flatOutputInFlight

if (loadMore) {
if (!flatOutputHasMore.value) return flatOutputAssets.value
if (flatOutputLoadMoreInFlight) return flatOutputLoadMoreInFlight
Comment thread
mattmillerai marked this conversation as resolved.
flatOutputIsLoadingMore.value = true
} else {
if (flatOutputRefreshInFlight) return flatOutputRefreshInFlight
flatOutputGeneration++
flatOutputLoading.value = true
flatOutputOffset.value = 0
flatOutputHasMore.value = true
flatOutputSeenIds.clear()
}
flatOutputError.value = null

flatOutputInFlight = (async () => {
const generation = flatOutputGeneration

const inFlight = (async () => {
try {
const page = await assetService.getAssetsByTag(OUTPUT_TAG, true, {
limit: FLAT_OUTPUT_PAGE_SIZE,
offset: flatOutputOffset.value
})
if (loadMore && generation !== flatOutputGeneration) {
return flatOutputAssets.value
}
const fresh = loadMore
? page.filter((asset) => !flatOutputSeenIds.has(asset.id))
: page
Expand All @@ -304,13 +304,23 @@ export const useAssetsStore = defineStore('assets', () => {
console.error('Failed to fetch output assets:', err)
return loadMore ? flatOutputAssets.value : []
} finally {
if (loadMore) flatOutputIsLoadingMore.value = false
else flatOutputLoading.value = false
flatOutputInFlight = null
if (loadMore) {
flatOutputIsLoadingMore.value = false
flatOutputLoadMoreInFlight = null
} else {
flatOutputLoading.value = false
flatOutputRefreshInFlight = null
}
}
})()

return flatOutputInFlight
if (loadMore) {
flatOutputLoadMoreInFlight = inFlight
} else {
flatOutputRefreshInFlight = inFlight
}

return inFlight
}

const updateFlatOutputs = () => fetchFlatOutputs(false)
Expand Down
Loading