Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
128 changes: 128 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()
Comment thread
mattmillerai marked this conversation as resolved.
Outdated

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,60 @@ 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.
Outdated
})

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'])
})
})
})
43 changes: 26 additions & 17 deletions src/stores/assetsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const useAssetsStore = defineStore('assets', () => {
const allHistoryItems = ref<AssetItem[]>([])

const loadedIds = shallowReactive(new Set<string>())
const loadedJobIds = new Set<string>()

const fetchInputFiles = isCloud
? fetchInputFilesFromCloud
Expand Down Expand Up @@ -151,46 +152,43 @@ export const useAssetsStore = defineStore('assets', () => {
* @param loadMore - true for pagination (append), false for initial load (replace)
Comment thread
mattmillerai marked this conversation as resolved.
*/
const fetchHistoryAssets = async (loadMore = false): Promise<AssetItem[]> => {
// Reset state for initial load
if (!loadMore) {
historyOffset.value = 0
hasMoreHistory.value = true
allHistoryItems.value = []
loadedIds.clear()
Comment thread
mattmillerai marked this conversation as resolved.
loadedJobIds.clear()
}

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

// Convert JobListItems to AssetItems
for (const job of history) {
loadedJobIds.add(job.id)
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
}

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,23 +265,24 @@ 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
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
Comment thread
mattmillerai marked this conversation as resolved.
Outdated

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

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

flatOutputInFlight = (async () => {
const inFlight = (async () => {
try {
const page = await assetService.getAssetsByTag(OUTPUT_TAG, true, {
limit: FLAT_OUTPUT_PAGE_SIZE,
Expand All @@ -304,13 +303,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
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
} else {
flatOutputRefreshInFlight = inFlight
}

return inFlight
}

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