mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-26 17:30:07 +00:00
Asset Browser Modal Component (#5607)
* [ci] ignore playwright mcp directory * [feat] add AssetBrowserModal And all related sub components * [feat] reactive filter functions * [ci] clean up storybook config * [feat] add sematic AssetCard * [fix] i love lucide * [fix] AssetCard layout issues * [fix] add AssetBadge type * [fix] simplify useAssetBrowser * [fix] modal layout * [fix] simplify useAssetBrowserDialog * [fix] add tailwind back to storybook * [fix] better reponsive layout * [fix] missed i18n string * [fix] missing i18n translations * [fix] remove erroneous prevent on keyboard.space * [feat] add asset metadata validation utilities * [fix] remove erroneous test code * [fix] remove forced min and max width on AssetCard * [fix] import statement nits
This commit is contained in:
304
tests-ui/platform/assets/components/AssetBrowserModal.test.ts
Normal file
304
tests-ui/platform/assets/components/AssetBrowserModal.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
// Mock external dependencies with minimal functionality needed for business logic tests
|
||||
vi.mock('@/components/input/SearchBox.vue', () => ({
|
||||
default: {
|
||||
name: 'SearchBox',
|
||||
props: ['modelValue', 'size', 'placeholder', 'class'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
data-testid="search-box"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
|
||||
default: {
|
||||
name: 'BaseModalLayout',
|
||||
props: ['contentTitle'],
|
||||
emits: ['close'],
|
||||
template: `
|
||||
<div data-testid="base-modal-layout">
|
||||
<div v-if="$slots.leftPanel" data-testid="left-panel">
|
||||
<slot name="leftPanel" />
|
||||
</div>
|
||||
<div data-testid="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div data-testid="content">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
|
||||
default: {
|
||||
name: 'LeftSidePanel',
|
||||
props: ['modelValue', 'navItems'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="left-side-panel">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
@click="$emit('update:modelValue', item.id)"
|
||||
:data-testid="'nav-item-' + item.id"
|
||||
:class="{ active: modelValue === item.id }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
|
||||
default: {
|
||||
name: 'AssetGrid',
|
||||
props: ['assets'],
|
||||
emits: ['asset-select'],
|
||||
template: `
|
||||
<div data-testid="asset-grid">
|
||||
<div
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
@click="$emit('asset-select', asset)"
|
||||
:data-testid="'asset-' + asset.id"
|
||||
class="asset-card"
|
||||
>
|
||||
{{ asset.name }}
|
||||
</div>
|
||||
<div v-if="assets.length === 0" data-testid="empty-state">
|
||||
No assets found
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
describe('AssetBrowserModal', () => {
|
||||
const createTestAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
category: string
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
asset_hash: `blake3:${id.padEnd(64, '0')}`,
|
||||
size: 1024000,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', category, 'test'],
|
||||
preview_url: `/api/assets/${id}/content`,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
last_access_time: '2024-01-01T00:00:00Z',
|
||||
user_metadata: {
|
||||
description: `Test ${name}`,
|
||||
base_model: 'sd15'
|
||||
}
|
||||
})
|
||||
|
||||
const createWrapper = (
|
||||
assets: AssetItem[] = [],
|
||||
props: Record<string, unknown> = {}
|
||||
) => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
return mount(AssetBrowserModal, {
|
||||
props: {
|
||||
assets: assets,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
'i-lucide:folder': {
|
||||
template: '<div data-testid="folder-icon"></div>'
|
||||
}
|
||||
},
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('filters assets when search query changes', async () => {
|
||||
const assets = [
|
||||
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
|
||||
createTestAsset('asset2', 'Checkpoint Model B', 'checkpoints'),
|
||||
createTestAsset('asset3', 'LoRA Model C', 'loras')
|
||||
]
|
||||
const wrapper = createWrapper(assets)
|
||||
|
||||
const searchBox = wrapper.find('[data-testid="search-box"]')
|
||||
|
||||
// Search for "Checkpoint"
|
||||
await searchBox.setValue('Checkpoint')
|
||||
await nextTick()
|
||||
|
||||
// Should filter to only checkpoint assets
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
|
||||
|
||||
expect(filteredAssets.length).toBe(2)
|
||||
expect(
|
||||
filteredAssets.every((asset: AssetDisplayItem) =>
|
||||
asset.name.includes('Checkpoint')
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('search is case insensitive', async () => {
|
||||
const assets = [
|
||||
createTestAsset('asset1', 'LoRA Model C', 'loras'),
|
||||
createTestAsset('asset2', 'Checkpoint Model', 'checkpoints')
|
||||
]
|
||||
const wrapper = createWrapper(assets)
|
||||
|
||||
const searchBox = wrapper.find('[data-testid="search-box"]')
|
||||
|
||||
// Search with different case
|
||||
await searchBox.setValue('lora')
|
||||
await nextTick()
|
||||
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
|
||||
|
||||
expect(filteredAssets.length).toBe(1)
|
||||
expect(filteredAssets[0].name).toContain('LoRA')
|
||||
})
|
||||
|
||||
it('shows empty state when search has no results', async () => {
|
||||
const assets = [
|
||||
createTestAsset('asset1', 'Checkpoint Model', 'checkpoints')
|
||||
]
|
||||
const wrapper = createWrapper(assets)
|
||||
|
||||
const searchBox = wrapper.find('[data-testid="search-box"]')
|
||||
|
||||
// Search for something that doesn't exist
|
||||
await searchBox.setValue('nonexistent')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Category Navigation', () => {
|
||||
it('filters assets by selected category', async () => {
|
||||
const assets = [
|
||||
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
|
||||
createTestAsset('asset2', 'LoRA Model C', 'loras'),
|
||||
createTestAsset('asset3', 'VAE Model D', 'vae')
|
||||
]
|
||||
const wrapper = createWrapper(assets, { showLeftPanel: true })
|
||||
|
||||
// Wait for Vue reactivity and component mounting
|
||||
await nextTick()
|
||||
|
||||
// Check if left panel exists first (since we have multiple categories)
|
||||
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
||||
expect(leftPanel.exists()).toBe(true)
|
||||
|
||||
// Check if the nav item exists before clicking
|
||||
const lorasNavItem = wrapper.find('[data-testid="nav-item-loras"]')
|
||||
expect(lorasNavItem.exists()).toBe(true)
|
||||
|
||||
// Click the loras category
|
||||
await lorasNavItem.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// Should filter to only LoRA assets
|
||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
|
||||
|
||||
expect(filteredAssets.length).toBe(1)
|
||||
expect(filteredAssets[0].name).toContain('LoRA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Asset Selection', () => {
|
||||
it('emits asset-select event when asset is selected', async () => {
|
||||
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||
const wrapper = createWrapper(assets)
|
||||
|
||||
// Click on first asset
|
||||
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('asset-select')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted).toHaveLength(1)
|
||||
|
||||
const emittedAsset = emitted![0][0] as AssetDisplayItem
|
||||
expect(emittedAsset.id).toBe('asset1')
|
||||
})
|
||||
|
||||
it('executes onSelect callback when provided', async () => {
|
||||
const onSelectSpy = vi.fn()
|
||||
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||
const wrapper = createWrapper(assets, { onSelect: onSelectSpy })
|
||||
|
||||
// Click on first asset
|
||||
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
|
||||
|
||||
expect(onSelectSpy).toHaveBeenCalledWith('Test Model')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Left Panel Conditional Logic', () => {
|
||||
it('hides left panel by default when showLeftPanel prop is undefined', () => {
|
||||
const singleCategoryAssets = [
|
||||
createTestAsset('single1', 'Asset 1', 'checkpoints'),
|
||||
createTestAsset('single2', 'Asset 2', 'checkpoints')
|
||||
]
|
||||
const wrapper = createWrapper(singleCategoryAssets)
|
||||
|
||||
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows left panel when showLeftPanel prop is explicitly true', () => {
|
||||
const singleCategoryAssets = [
|
||||
createTestAsset('single1', 'Asset 1', 'checkpoints')
|
||||
]
|
||||
|
||||
// Force show even with single category
|
||||
const wrapper = createWrapper(singleCategoryAssets, {
|
||||
showLeftPanel: true
|
||||
})
|
||||
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(true)
|
||||
|
||||
// Force hide even with multiple categories
|
||||
wrapper.unmount()
|
||||
const multiCategoryAssets = [
|
||||
createTestAsset('asset1', 'Checkpoint', 'checkpoints'),
|
||||
createTestAsset('asset2', 'LoRA', 'loras')
|
||||
]
|
||||
const wrapper2 = createWrapper(multiCategoryAssets, {
|
||||
showLeftPanel: false
|
||||
})
|
||||
expect(wrapper2.find('[data-testid="left-panel"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
138
tests-ui/platform/assets/components/AssetFilterBar.test.ts
Normal file
138
tests-ui/platform/assets/components/AssetFilterBar.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||
|
||||
// Mock components with minimal functionality for business logic testing
|
||||
vi.mock('@/components/input/MultiSelect.vue', () => ({
|
||||
default: {
|
||||
name: 'MultiSelect',
|
||||
props: {
|
||||
modelValue: Array,
|
||||
label: String,
|
||||
options: Array,
|
||||
class: String
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="multi-select">
|
||||
<select multiple @change="$emit('update:modelValue', Array.from($event.target.selectedOptions).map(o => ({ name: o.text, value: o.value })))">
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/input/SingleSelect.vue', () => ({
|
||||
default: {
|
||||
name: 'SingleSelect',
|
||||
props: {
|
||||
modelValue: String,
|
||||
label: String,
|
||||
options: Array,
|
||||
class: String
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="single-select">
|
||||
<select @change="$emit('update:modelValue', $event.target.value)">
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
// Test factory functions
|
||||
|
||||
describe('AssetFilterBar', () => {
|
||||
describe('Filter State Management', () => {
|
||||
it('maintains correct initial state', () => {
|
||||
const wrapper = mount(AssetFilterBar)
|
||||
|
||||
// Test initial state through component props
|
||||
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
|
||||
const singleSelect = wrapper.findComponent({ name: 'SingleSelect' })
|
||||
|
||||
expect(multiSelects[0].props('modelValue')).toEqual([])
|
||||
expect(multiSelects[1].props('modelValue')).toEqual([])
|
||||
expect(singleSelect.props('modelValue')).toBe('name-asc')
|
||||
})
|
||||
|
||||
it('handles multiple simultaneous filter changes correctly', async () => {
|
||||
const wrapper = mount(AssetFilterBar)
|
||||
|
||||
// Update file formats
|
||||
const fileFormatSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[0]
|
||||
await fileFormatSelect.vm.$emit('update:modelValue', [
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.safetensors', value: 'safetensors' }
|
||||
])
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Update base models
|
||||
const baseModelSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[1]
|
||||
await baseModelSelect.vm.$emit('update:modelValue', [
|
||||
{ name: 'SD XL', value: 'sdxl' }
|
||||
])
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Update sort
|
||||
const sortSelect = wrapper.findComponent({ name: 'SingleSelect' })
|
||||
await sortSelect.vm.$emit('update:modelValue', 'popular')
|
||||
|
||||
await nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('filterChange')
|
||||
expect(emitted).toHaveLength(3)
|
||||
|
||||
// Check final state
|
||||
const finalState: FilterState = emitted![2][0] as FilterState
|
||||
expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors'])
|
||||
expect(finalState.baseModels).toEqual(['sdxl'])
|
||||
expect(finalState.sortBy).toBe('popular')
|
||||
})
|
||||
|
||||
it('ensures FilterState interface compliance', async () => {
|
||||
const wrapper = mount(AssetFilterBar)
|
||||
|
||||
const fileFormatSelect = wrapper.findAllComponents({
|
||||
name: 'MultiSelect'
|
||||
})[0]
|
||||
await fileFormatSelect.vm.$emit('update:modelValue', [
|
||||
{ name: '.ckpt', value: 'ckpt' }
|
||||
])
|
||||
|
||||
await nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('filterChange')
|
||||
const filterState = emitted![0][0] as FilterState
|
||||
|
||||
// Type and structure assertions
|
||||
expect(Array.isArray(filterState.fileFormats)).toBe(true)
|
||||
expect(Array.isArray(filterState.baseModels)).toBe(true)
|
||||
expect(typeof filterState.sortBy).toBe('string')
|
||||
|
||||
// Value type assertions
|
||||
expect(filterState.fileFormats.every((f) => typeof f === 'string')).toBe(
|
||||
true
|
||||
)
|
||||
expect(filterState.baseModels.every((m) => typeof m === 'string')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
323
tests-ui/platform/assets/composables/useAssetBrowser.test.ts
Normal file
323
tests-ui/platform/assets/composables/useAssetBrowser.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
describe('useAssetBrowser', () => {
|
||||
// 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('Asset Transformation', () => {
|
||||
it('transforms API asset to include display properties', () => {
|
||||
const apiAsset = createApiAsset({
|
||||
size: 2147483648, // 2GB
|
||||
user_metadata: { description: 'Test model' }
|
||||
})
|
||||
|
||||
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
|
||||
const result = transformAssetForDisplay(apiAsset)
|
||||
|
||||
// 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.formattedSize).toBe('2 GB')
|
||||
expect(result.badges).toContainEqual({
|
||||
label: 'checkpoints',
|
||||
type: 'type'
|
||||
})
|
||||
expect(result.badges).toContainEqual({ label: '2 GB', type: 'size' })
|
||||
})
|
||||
|
||||
it('creates fallback description from tags when metadata missing', () => {
|
||||
const apiAsset = createApiAsset({
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: undefined
|
||||
})
|
||||
|
||||
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
|
||||
const result = transformAssetForDisplay(apiAsset)
|
||||
|
||||
expect(result.description).toBe('loras model')
|
||||
})
|
||||
|
||||
it('formats various file sizes correctly', () => {
|
||||
const { transformAssetForDisplay } = useAssetBrowser([])
|
||||
|
||||
const testCases = [
|
||||
{ size: 512, expected: '512 B' },
|
||||
{ size: 1536, expected: '1.5 KB' },
|
||||
{ size: 2097152, expected: '2 MB' },
|
||||
{ size: 3221225472, expected: '3 GB' }
|
||||
]
|
||||
|
||||
testCases.forEach(({ size, expected }) => {
|
||||
const asset = createApiAsset({ size })
|
||||
const result = transformAssetForDisplay(asset)
|
||||
expect(result.formattedSize).toBe(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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(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(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(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(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(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(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 { sortBy, filteredAssets } = useAssetBrowser(assets)
|
||||
|
||||
sortBy.value = 'name'
|
||||
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 { sortBy, filteredAssets } = useAssetBrowser(assets)
|
||||
|
||||
sortBy.value = 'date'
|
||||
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('Asset Selection', () => {
|
||||
it('returns selected asset UUID for efficient handling', () => {
|
||||
const asset = createApiAsset({
|
||||
id: 'test-uuid-123',
|
||||
name: 'selected_model.safetensors'
|
||||
})
|
||||
const { selectAsset, transformAssetForDisplay } = useAssetBrowser([asset])
|
||||
|
||||
const displayAsset = transformAssetForDisplay(asset)
|
||||
const result = selectAsset(displayAsset)
|
||||
|
||||
expect(result).toBe('test-uuid-123')
|
||||
})
|
||||
})
|
||||
|
||||
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(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(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(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(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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
// Mock the dialog store
|
||||
vi.mock('@/stores/dialogStore')
|
||||
|
||||
// Test factory functions
|
||||
interface AssetBrowserProps {
|
||||
nodeType: string
|
||||
inputName: string
|
||||
onAssetSelected?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createAssetBrowserProps(
|
||||
overrides: Partial<AssetBrowserProps> = {}
|
||||
): AssetBrowserProps {
|
||||
return {
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAssetBrowserDialog', () => {
|
||||
describe('Asset Selection Flow', () => {
|
||||
it('auto-closes dialog when asset is selected', () => {
|
||||
// Create fresh mocks for this test
|
||||
const mockShowDialog = vi.fn()
|
||||
const mockCloseDialog = vi.fn()
|
||||
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
showDialog: mockShowDialog,
|
||||
closeDialog: mockCloseDialog
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
const onAssetSelected = vi.fn()
|
||||
const props = createAssetBrowserProps({ onAssetSelected })
|
||||
|
||||
assetBrowserDialog.show(props)
|
||||
|
||||
// Get the onSelect handler that was passed to the dialog
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
const onSelectHandler = dialogCall.props.onSelect
|
||||
|
||||
// Simulate asset selection
|
||||
onSelectHandler('selected-asset-path')
|
||||
|
||||
// Should call the original callback and close dialog
|
||||
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'global-asset-browser'
|
||||
})
|
||||
})
|
||||
|
||||
it('closes dialog when close handler is called', () => {
|
||||
// Create fresh mocks for this test
|
||||
const mockShowDialog = vi.fn()
|
||||
const mockCloseDialog = vi.fn()
|
||||
|
||||
vi.mocked(useDialogStore).mockReturnValue({
|
||||
showDialog: mockShowDialog,
|
||||
closeDialog: mockCloseDialog
|
||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
||||
typeof useDialogStore
|
||||
>)
|
||||
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
const props = createAssetBrowserProps()
|
||||
|
||||
assetBrowserDialog.show(props)
|
||||
|
||||
// Get the onClose handler that was passed to the dialog
|
||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||
const onCloseHandler = dialogCall.props.onClose
|
||||
|
||||
// Simulate dialog close
|
||||
onCloseHandler()
|
||||
|
||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||
key: 'global-asset-browser'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
// Test factory functions
|
||||
function createTestAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-uuid',
|
||||
name: 'test-model.safetensors',
|
||||
asset_hash: 'blake3:test123',
|
||||
size: 123456,
|
||||
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',
|
||||
user_metadata: {
|
||||
base_model: 'sd15'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAssetFilterOptions', () => {
|
||||
describe('File Format Extraction', () => {
|
||||
it('extracts file formats from asset names', () => {
|
||||
const assets = [
|
||||
createTestAsset({ name: 'model1.safetensors' }),
|
||||
createTestAsset({ name: 'model2.ckpt' }),
|
||||
createTestAsset({ name: 'model3.pt' })
|
||||
]
|
||||
|
||||
const { availableFileFormats } = useAssetFilterOptions(assets)
|
||||
|
||||
expect(availableFileFormats.value).toEqual([
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.pt', value: 'pt' },
|
||||
{ name: '.safetensors', value: 'safetensors' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles duplicate file formats', () => {
|
||||
const assets = [
|
||||
createTestAsset({ name: 'model1.safetensors' }),
|
||||
createTestAsset({ name: 'model2.safetensors' }),
|
||||
createTestAsset({ name: 'model3.ckpt' })
|
||||
]
|
||||
|
||||
const { availableFileFormats } = useAssetFilterOptions(assets)
|
||||
|
||||
expect(availableFileFormats.value).toEqual([
|
||||
{ name: '.ckpt', value: 'ckpt' },
|
||||
{ name: '.safetensors', value: 'safetensors' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles assets with no file extension', () => {
|
||||
const assets = [
|
||||
createTestAsset({ name: 'model_no_extension' }),
|
||||
createTestAsset({ name: 'model.safetensors' })
|
||||
]
|
||||
|
||||
const { availableFileFormats } = useAssetFilterOptions(assets)
|
||||
|
||||
expect(availableFileFormats.value).toEqual([
|
||||
{ name: '.safetensors', value: 'safetensors' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles empty asset list', () => {
|
||||
const { availableFileFormats } = useAssetFilterOptions([])
|
||||
|
||||
expect(availableFileFormats.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Base Model Extraction', () => {
|
||||
it('extracts base models from user metadata', () => {
|
||||
const assets = [
|
||||
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sdxl' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sd35' } })
|
||||
]
|
||||
|
||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||
|
||||
expect(availableBaseModels.value).toEqual([
|
||||
{ name: 'sd15', value: 'sd15' },
|
||||
{ name: 'sd35', value: 'sd35' },
|
||||
{ name: 'sdxl', value: 'sdxl' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles duplicate base models', () => {
|
||||
const assets = [
|
||||
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
|
||||
]
|
||||
|
||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||
|
||||
expect(availableBaseModels.value).toEqual([
|
||||
{ name: 'sd15', value: 'sd15' },
|
||||
{ name: 'sdxl', value: 'sdxl' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles assets with missing user_metadata', () => {
|
||||
const assets = [
|
||||
createTestAsset({ user_metadata: undefined }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sd15' } })
|
||||
]
|
||||
|
||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||
|
||||
expect(availableBaseModels.value).toEqual([
|
||||
{ name: 'sd15', value: 'sd15' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles assets with missing base_model field', () => {
|
||||
const assets = [
|
||||
createTestAsset({ user_metadata: { description: 'A test model' } }),
|
||||
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
|
||||
]
|
||||
|
||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
||||
|
||||
expect(availableBaseModels.value).toEqual([
|
||||
{ name: 'sdxl', value: 'sdxl' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles empty asset list', () => {
|
||||
const { availableBaseModels } = useAssetFilterOptions([])
|
||||
|
||||
expect(availableBaseModels.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Reactivity', () => {
|
||||
it('returns computed properties that can be reactive', () => {
|
||||
const assets = [createTestAsset({ name: 'model.safetensors' })]
|
||||
|
||||
const { availableFileFormats, availableBaseModels } =
|
||||
useAssetFilterOptions(assets)
|
||||
|
||||
// These should be computed refs
|
||||
expect(availableFileFormats.value).toBeDefined()
|
||||
expect(availableBaseModels.value).toBeDefined()
|
||||
expect(typeof availableFileFormats.value).toBe('object')
|
||||
expect(typeof availableBaseModels.value).toBe('object')
|
||||
expect(Array.isArray(availableFileFormats.value)).toBe(true)
|
||||
expect(Array.isArray(availableBaseModels.value)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import {
|
||||
getAssetBaseModel,
|
||||
getAssetDescription
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
describe('assetMetadataUtils', () => {
|
||||
const mockAsset: AssetItem = {
|
||||
id: 'test-id',
|
||||
name: 'test-model',
|
||||
asset_hash: 'hash123',
|
||||
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'
|
||||
}
|
||||
|
||||
describe('getAssetDescription', () => {
|
||||
it('should return string description when present', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
user_metadata: { description: 'A test model' }
|
||||
}
|
||||
expect(getAssetDescription(asset)).toBe('A test model')
|
||||
})
|
||||
|
||||
it('should return null when description is not a string', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
user_metadata: { description: 123 }
|
||||
}
|
||||
expect(getAssetDescription(asset)).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when no metadata', () => {
|
||||
expect(getAssetDescription(mockAsset)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAssetBaseModel', () => {
|
||||
it('should return string base_model when present', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
user_metadata: { base_model: 'SDXL' }
|
||||
}
|
||||
expect(getAssetBaseModel(asset)).toBe('SDXL')
|
||||
})
|
||||
|
||||
it('should return null when base_model is not a string', () => {
|
||||
const asset = {
|
||||
...mockAsset,
|
||||
user_metadata: { base_model: 123 }
|
||||
}
|
||||
expect(getAssetBaseModel(asset)).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when no metadata', () => {
|
||||
expect(getAssetBaseModel(mockAsset)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -17,26 +18,39 @@ vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// Helper to create API-compliant test assets
|
||||
function createTestAsset(overrides: Partial<AssetItem> = {}) {
|
||||
return {
|
||||
id: 'test-uuid',
|
||||
name: 'test-model.safetensors',
|
||||
asset_hash: 'blake3:test123',
|
||||
size: 123456,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Test data constants
|
||||
const MOCK_ASSETS = {
|
||||
checkpoints: {
|
||||
checkpoints: createTestAsset({
|
||||
id: 'uuid-1',
|
||||
name: 'model1.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
size: 123456
|
||||
},
|
||||
loras: {
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
loras: createTestAsset({
|
||||
id: 'uuid-2',
|
||||
name: 'model2.safetensors',
|
||||
tags: ['models', 'loras'],
|
||||
size: 654321
|
||||
},
|
||||
vae: {
|
||||
tags: ['models', 'loras']
|
||||
}),
|
||||
vae: createTestAsset({
|
||||
id: 'uuid-3',
|
||||
name: 'vae1.safetensors',
|
||||
tags: ['models', 'vae'],
|
||||
size: 789012
|
||||
}
|
||||
tags: ['models', 'vae']
|
||||
})
|
||||
} as const
|
||||
|
||||
// Helper functions
|
||||
@@ -66,24 +80,21 @@ describe('assetService', () => {
|
||||
describe('getAssetModelFolders', () => {
|
||||
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
|
||||
const assets = [
|
||||
{
|
||||
createTestAsset({
|
||||
id: 'uuid-1',
|
||||
name: 'checkpoint1.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
size: 123456
|
||||
},
|
||||
{
|
||||
tags: ['models', 'checkpoints']
|
||||
}),
|
||||
createTestAsset({
|
||||
id: 'uuid-2',
|
||||
name: 'config.yaml',
|
||||
tags: ['models', 'configs'], // Blacklisted
|
||||
size: 654321
|
||||
},
|
||||
{
|
||||
tags: ['models', 'configs'] // Blacklisted
|
||||
}),
|
||||
createTestAsset({
|
||||
id: 'uuid-3',
|
||||
name: 'vae1.safetensors',
|
||||
tags: ['models', 'vae'],
|
||||
size: 789012
|
||||
}
|
||||
tags: ['models', 'vae']
|
||||
})
|
||||
]
|
||||
mockApiResponse(assets)
|
||||
|
||||
@@ -123,12 +134,11 @@ describe('assetService', () => {
|
||||
const assets = [
|
||||
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
|
||||
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
|
||||
{
|
||||
createTestAsset({
|
||||
id: 'uuid-4',
|
||||
name: 'missing-model.safetensors',
|
||||
tags: ['models', 'checkpoints', 'missing'], // Has missing tag
|
||||
size: 654321
|
||||
}
|
||||
tags: ['models', 'checkpoints', 'missing'] // Has missing tag
|
||||
})
|
||||
]
|
||||
mockApiResponse(assets)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user