Files
ComfyUI_frontend/tests-ui/platform/assets/composables/useAssetBrowser.test.ts
Christian Byrne 8120ed9dfa load assets browser before fetch completes and show loading state (#6189)
## Summary

Moves the fetch and post-fetch logic associated with the asset browser
into the component and shows a loading state while fetching.

To test, use this branch:
https://github.com/comfyanonymous/ComfyUI/pull/10045



https://github.com/user-attachments/assets/718974d5-efc7-46a0-bcd6-e82596d4c389

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6189-load-assets-browser-before-fetch-completes-and-show-loading-state-2946d73d365081879d1bd05d86e8c036)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2025-10-23 13:43:34 -07:00

337 lines
10 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
vi.mock('@/i18n', () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'assetBrowser.allModels': 'All Models',
'assetBrowser.assets': 'Assets',
'assetBrowser.unknown': 'unknown'
}
return translations[key] || key
},
d: (date: Date) => date.toLocaleDateString()
}))
describe('useAssetBrowser', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
// Test fixtures - minimal data focused on functionality being tested
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
id: 'test-id',
name: 'test-asset.safetensors',
asset_hash: 'blake3:abc123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
...overrides
})
describe('Category Filtering', () => {
it('exposes category-filtered assets for filter options', () => {
const checkpointAsset = createApiAsset({
id: 'checkpoint-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
})
const loraAsset = createApiAsset({
id: 'lora-1',
name: 'lora.pt',
tags: ['models', 'loras']
})
const { selectedCategory, categoryFilteredAssets } = useAssetBrowser(
ref([checkpointAsset, loraAsset])
)
// Initially should show all assets
expect(categoryFilteredAssets.value).toHaveLength(2)
// When category selected, should only show that category
selectedCategory.value = 'checkpoints'
expect(categoryFilteredAssets.value).toHaveLength(1)
expect(categoryFilteredAssets.value[0].id).toBe('checkpoint-1')
selectedCategory.value = 'loras'
expect(categoryFilteredAssets.value).toHaveLength(1)
expect(categoryFilteredAssets.value[0].id).toBe('lora-1')
})
})
describe('Asset Transformation', () => {
it('transforms API asset to include display properties', () => {
const apiAsset = createApiAsset({
user_metadata: { description: 'Test model' }
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0] // Get the transformed asset from filteredAssets
// Preserves API properties
expect(result.id).toBe(apiAsset.id)
expect(result.name).toBe(apiAsset.name)
// Adds display properties
expect(result.description).toBe('Test model')
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
})
it('creates fallback description from tags when metadata missing', () => {
const apiAsset = createApiAsset({
tags: ['models', 'loras'],
user_metadata: undefined
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.description).toBe('loras model')
})
})
describe('Tag-Based Filtering', () => {
it('filters assets by category tag', async () => {
const assets = [
createApiAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createApiAsset({ id: '2', tags: ['models', 'loras'] }),
createApiAsset({ id: '3', tags: ['models', 'checkpoints'] })
]
const { selectedCategory, filteredAssets } = useAssetBrowser(ref(assets))
selectedCategory.value = 'checkpoints'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(
filteredAssets.value.every((asset) =>
asset.tags.includes('checkpoints')
)
).toBe(true)
})
it('returns all assets when category is "all"', async () => {
const assets = [
createApiAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createApiAsset({ id: '2', tags: ['models', 'loras'] })
]
const { selectedCategory, filteredAssets } = useAssetBrowser(ref(assets))
selectedCategory.value = 'all'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
})
})
describe('Search Functionality', () => {
it('searches across asset name', async () => {
const assets = [
createApiAsset({ name: 'realistic_vision.safetensors' }),
createApiAsset({ name: 'anime_style.ckpt' }),
createApiAsset({ name: 'photorealistic_v2.safetensors' })
]
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
searchQuery.value = 'realistic'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(
filteredAssets.value.every((asset) =>
asset.name.toLowerCase().includes('realistic')
)
).toBe(true)
})
it('searches in user metadata description', async () => {
const assets = [
createApiAsset({
name: 'model1.safetensors',
user_metadata: { description: 'fantasy artwork model' }
}),
createApiAsset({
name: 'model2.safetensors',
user_metadata: { description: 'portrait photography' }
})
]
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
searchQuery.value = 'fantasy'
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].name).toBe('model1.safetensors')
})
it('handles empty search results', async () => {
const assets = [createApiAsset({ name: 'test.safetensors' })]
const { searchQuery, filteredAssets } = useAssetBrowser(ref(assets))
searchQuery.value = 'nonexistent'
await nextTick()
expect(filteredAssets.value).toHaveLength(0)
})
})
describe('Combined Search and Filtering', () => {
it('applies both search and category filter', async () => {
const assets = [
createApiAsset({
name: 'realistic_checkpoint.safetensors',
tags: ['models', 'checkpoints']
}),
createApiAsset({
name: 'realistic_lora.safetensors',
tags: ['models', 'loras']
}),
createApiAsset({
name: 'anime_checkpoint.safetensors',
tags: ['models', 'checkpoints']
})
]
const { searchQuery, selectedCategory, filteredAssets } = useAssetBrowser(
ref(assets)
)
searchQuery.value = 'realistic'
selectedCategory.value = 'checkpoints'
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].name).toBe(
'realistic_checkpoint.safetensors'
)
})
})
describe('Sorting', () => {
it('sorts assets by name', async () => {
const assets = [
createApiAsset({ name: 'zebra.safetensors' }),
createApiAsset({ name: 'alpha.safetensors' }),
createApiAsset({ name: 'beta.safetensors' })
]
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({ sortBy: 'name', fileFormats: [], baseModels: [] })
await nextTick()
const names = filteredAssets.value.map((asset) => asset.name)
expect(names).toEqual([
'alpha.safetensors',
'beta.safetensors',
'zebra.safetensors'
])
})
it('sorts assets by creation date', async () => {
const assets = [
createApiAsset({ created_at: '2024-03-01T00:00:00Z' }),
createApiAsset({ created_at: '2024-01-01T00:00:00Z' }),
createApiAsset({ created_at: '2024-02-01T00:00:00Z' })
]
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({ sortBy: 'recent', fileFormats: [], baseModels: [] })
await nextTick()
const dates = filteredAssets.value.map((asset) => asset.created_at)
expect(dates).toEqual([
'2024-03-01T00:00:00Z',
'2024-02-01T00:00:00Z',
'2024-01-01T00:00:00Z'
])
})
})
describe('Dynamic Category Extraction', () => {
it('extracts categories from asset tags', () => {
const assets = [
createApiAsset({ tags: ['models', 'checkpoints'] }),
createApiAsset({ tags: ['models', 'loras'] }),
createApiAsset({ tags: ['models', 'checkpoints'] }) // duplicate
]
const { availableCategories } = useAssetBrowser(ref(assets))
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--package]'
},
{ id: 'loras', label: 'Loras', icon: 'icon-[lucide--package]' }
])
})
it('handles assets with no category tag', () => {
const assets = [
createApiAsset({ tags: ['models'] }), // No second tag
createApiAsset({ tags: ['models', 'vae'] })
]
const { availableCategories } = useAssetBrowser(ref(assets))
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{ id: 'vae', label: 'Vae', icon: 'icon-[lucide--package]' }
])
})
it('ignores non-models root tags', () => {
const assets = [
createApiAsset({ tags: ['input', 'images'] }),
createApiAsset({ tags: ['models', 'checkpoints'] })
]
const { availableCategories } = useAssetBrowser(ref(assets))
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--package]'
}
])
})
it('computes content title from selected category', () => {
const assets = [createApiAsset({ tags: ['models', 'checkpoints'] })]
const { selectedCategory, contentTitle } = useAssetBrowser(ref(assets))
// Default
expect(contentTitle.value).toBe('All Models')
// Set specific category
selectedCategory.value = 'checkpoints'
expect(contentTitle.value).toBe('Checkpoints')
// Unknown category
selectedCategory.value = 'unknown'
expect(contentTitle.value).toBe('Assets')
})
})
})