diff --git a/.storybook/main.ts b/.storybook/main.ts index a799ec143..3869df31c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -3,7 +3,7 @@ import { FileSystemIconLoader } from 'unplugin-icons/loaders' import IconsResolver from 'unplugin-icons/resolver' import Icons from 'unplugin-icons/vite' import Components from 'unplugin-vue-components/vite' -import type { InlineConfig } from 'vite' +import type { InlineConfig, Plugin } from 'vite' const config: StorybookConfig = { stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], @@ -18,8 +18,14 @@ const config: StorybookConfig = { // Filter out any plugins that might generate import maps if (config.plugins) { - config.plugins = config.plugins.filter((plugin: any) => { - if (plugin && plugin.name && plugin.name.includes('import-map')) { + config.plugins = (config.plugins as Plugin[]).filter((plugin: Plugin) => { + if ( + plugin && + typeof plugin === 'object' && + 'name' in plugin && + typeof plugin.name === 'string' && + plugin.name.includes('import-map') + ) { return false } return true diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 747bbe802..ad8e61d99 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,7 +1,7 @@ import { definePreset } from '@primevue/themes' import Aura from '@primevue/themes/aura' import { setup } from '@storybook/vue3' -import type { Preview } from '@storybook/vue3-vite' +import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite' import { createPinia } from 'pinia' import 'primeicons/primeicons.css' import PrimeVue from 'primevue/config' @@ -10,10 +10,9 @@ import ToastService from 'primevue/toastservice' import Tooltip from 'primevue/tooltip' import '../src/assets/css/style.css' +import GlobalDialog from '../src/components/dialog/GlobalDialog.vue' import { i18n } from '../src/i18n' import '../src/lib/litegraph/public/css/litegraph.css' -import { useWidgetStore } from '../src/stores/widgetStore' -import { useColorPaletteStore } from '../src/stores/workspace/colorPaletteStore' const ComfyUIPreset = definePreset(Aura, { semantic: { @@ -25,12 +24,14 @@ const ComfyUIPreset = definePreset(Aura, { // Setup Vue app for Storybook setup((app) => { app.directive('tooltip', Tooltip) + + // Create Pinia instance const pinia = createPinia() + app.use(pinia) - // Initialize stores - useColorPaletteStore(pinia) - useWidgetStore(pinia) + // Register global components for dialogs + app.component('GlobalDialog', GlobalDialog) app.use(i18n) app.use(PrimeVue, { @@ -50,8 +51,8 @@ setup((app) => { app.use(ToastService) }) -// Dark theme decorator -export const withTheme = (Story: any, context: any) => { +// Theme and dialog decorator +export const withTheme = (Story: StoryFn, context: StoryContext) => { const theme = context.globals.theme || 'light' // Apply theme class to document root @@ -63,7 +64,19 @@ export const withTheme = (Story: any, context: any) => { document.body.classList.remove('dark-theme') } - return Story() + // Return story with GlobalDialog included + return { + components: { GlobalDialog }, + setup() { + return { storyResult: Story(context.args, context) } + }, + template: ` +
+ + +
+ ` + } } const preview: Preview = { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index e70301ce7..d880c1ba8 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1862,5 +1862,14 @@ "showGroups": "Show Frames/Groups", "renderBypassState": "Render Bypass State", "renderErrorState": "Render Error State" + }, + "assetBrowser": { + "assets": "Assets", + "browseAssets": "Browse Assets", + "noAssetsFound": "No assets found", + "tryAdjustingFilters": "Try adjusting your search or filters", + "loadingModels": "Loading {type}...", + "connectionError": "Please check your connection and try again", + "noModelsInFolder": "No {type} available in this folder" } } diff --git a/src/platform/assets/components/AssetBadgeGroup.vue b/src/platform/assets/components/AssetBadgeGroup.vue new file mode 100644 index 000000000..73194743d --- /dev/null +++ b/src/platform/assets/components/AssetBadgeGroup.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/platform/assets/components/AssetBrowserModal.stories.ts b/src/platform/assets/components/AssetBrowserModal.stories.ts new file mode 100644 index 000000000..25d4916c7 --- /dev/null +++ b/src/platform/assets/components/AssetBrowserModal.stories.ts @@ -0,0 +1,175 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import { createMockAssets, mockAssets } from '../fixtures/ui-mock-assets' +import AssetBrowserModal from './AssetBrowserModal.vue' + +// Story arguments interface +interface StoryArgs { + nodeType: string + inputName: string + currentValue: string + showLeftPanel?: boolean +} + +const meta: Meta = { + title: 'Platform/Assets/AssetBrowserModal', + component: AssetBrowserModal, + parameters: { + layout: 'fullscreen' + }, + argTypes: { + nodeType: { + control: 'select', + options: ['CheckpointLoaderSimple', 'VAELoader', 'ControlNetLoader'], + description: 'ComfyUI node type for context' + }, + inputName: { + control: 'select', + options: ['ckpt_name', 'vae_name', 'control_net_name'], + description: 'Widget input name' + }, + currentValue: { + control: 'text', + description: 'Current selected asset value' + }, + showLeftPanel: { + control: 'boolean', + description: 'Whether to show the left panel with categories' + } + } +} + +export default meta +type Story = StoryObj + +// Modal Layout Stories +export const Default: Story = { + args: { + nodeType: 'CheckpointLoaderSimple', + inputName: 'ckpt_name', + currentValue: '', + showLeftPanel: undefined + }, + render: (args) => ({ + components: { AssetBrowserModal }, + setup() { + const onAssetSelect = (asset: any) => { + console.log('Selected asset:', asset) + } + const onClose = () => { + console.log('Modal closed') + } + + return { + ...args, + onAssetSelect, + onClose, + assets: mockAssets + } + }, + template: ` +
+ +
+ ` + }) +} + +// Story demonstrating single asset type (auto-hides left panel) +export const SingleAssetType: Story = { + args: { + nodeType: 'CheckpointLoaderSimple', + inputName: 'ckpt_name', + currentValue: '', + showLeftPanel: undefined + }, + render: (args) => ({ + components: { AssetBrowserModal }, + setup() { + const onAssetSelect = (asset: any) => { + console.log('Selected asset:', asset) + } + const onClose = () => { + console.log('Modal closed') + } + + // Create assets with only one type (checkpoints) + const singleTypeAssets = createMockAssets(15).map((asset) => ({ + ...asset, + type: 'checkpoint' + })) + + return { ...args, onAssetSelect, onClose, assets: singleTypeAssets } + }, + template: ` +
+ +
+ ` + }), + parameters: { + docs: { + description: { + story: + 'Modal with assets of only one type (checkpoint) - left panel auto-hidden.' + } + } + } +} + +// Story with left panel explicitly hidden +export const NoLeftPanel: Story = { + args: { + nodeType: 'CheckpointLoaderSimple', + inputName: 'ckpt_name', + currentValue: '', + showLeftPanel: false + }, + render: (args) => ({ + components: { AssetBrowserModal }, + setup() { + const onAssetSelect = (asset: any) => { + console.log('Selected asset:', asset) + } + const onClose = () => { + console.log('Modal closed') + } + + return { ...args, onAssetSelect, onClose, assets: mockAssets } + }, + template: ` +
+ +
+ ` + }), + parameters: { + docs: { + description: { + story: + 'Modal with left panel explicitly disabled via showLeftPanel=false.' + } + } + } +} diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue new file mode 100644 index 000000000..188daaee3 --- /dev/null +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -0,0 +1,109 @@ + + + diff --git a/src/platform/assets/components/AssetCard.stories.ts b/src/platform/assets/components/AssetCard.stories.ts new file mode 100644 index 000000000..ffce30d55 --- /dev/null +++ b/src/platform/assets/components/AssetCard.stories.ts @@ -0,0 +1,156 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' +import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets' + +import AssetCard from './AssetCard.vue' + +// Use the first mock asset as base and transform it to display format +const baseAsset = mockAssets[0] +const createAssetData = ( + overrides: Partial = {} +): AssetDisplayItem => ({ + ...baseAsset, + description: + 'High-quality realistic images with perfect detail and natural lighting effects for professional photography', + formattedSize: '2.1 GB', + badges: [ + { label: 'checkpoints', type: 'type' }, + { label: '2.1 GB', type: 'size' } + ], + stats: { + formattedDate: '3/15/25', + downloadCount: '1.8k', + stars: '4.2k' + }, + ...overrides +}) + +const meta: Meta = { + title: 'Platform/Assets/AssetCard', + component: AssetCard, + parameters: { + layout: 'centered' + }, + decorators: [ + () => ({ + template: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +// Default story with all data provided +export const Default: Story = { + args: { + asset: createAssetData() + }, + parameters: { + docs: { + description: { + story: + 'Default AssetCard with complete data including badges and all stats.' + } + } + } +} + +// Story with all edge cases in a grid layout +export const EdgeCases: Story = { + render: () => ({ + components: { AssetCard }, + setup() { + const edgeCases = [ + // Default case for comparison + createAssetData({ + name: 'Complete Data', + description: 'Asset with all data present for comparison' + }), + // No badges + createAssetData({ + id: 'no-badges', + name: 'No Badges', + description: 'Testing graceful handling when badges are not provided', + badges: [] + }), + // No stars + createAssetData({ + id: 'no-stars', + name: 'No Stars', + description: 'Testing missing stars data gracefully', + stats: { + downloadCount: '1.8k', + formattedDate: '3/15/25' + } + }), + // No downloads + createAssetData({ + id: 'no-downloads', + name: 'No Downloads', + description: 'Testing missing downloads data gracefully', + stats: { + stars: '4.2k', + formattedDate: '3/15/25' + } + }), + // No date + createAssetData({ + id: 'no-date', + name: 'No Date', + description: 'Testing missing date data gracefully', + stats: { + stars: '4.2k', + downloadCount: '1.8k' + } + }), + // No stats at all + createAssetData({ + id: 'no-stats', + name: 'No Stats', + description: 'Testing when all stats are missing', + stats: {} + }), + // Long description + createAssetData({ + id: 'long-desc', + name: 'Long Description', + description: + 'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.' + }), + // Minimal data + createAssetData({ + id: 'minimal', + name: 'Minimal', + description: 'Basic model', + tags: ['models'], + badges: [], + stats: {} + }) + ] + + return { edgeCases } + }, + template: ` +
+ +
+ ` + }), + parameters: { + layout: 'fullscreen', + docs: { + description: { + story: + 'All AssetCard edge cases in a grid layout to test graceful handling of missing data, badges, stats, and long descriptions.' + } + } + } +} diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue new file mode 100644 index 000000000..3a2458954 --- /dev/null +++ b/src/platform/assets/components/AssetCard.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue new file mode 100644 index 000000000..66dbe9c45 --- /dev/null +++ b/src/platform/assets/components/AssetFilterBar.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/platform/assets/components/AssetGrid.vue b/src/platform/assets/components/AssetGrid.vue new file mode 100644 index 000000000..fe7dbb840 --- /dev/null +++ b/src/platform/assets/components/AssetGrid.vue @@ -0,0 +1,70 @@ + + + diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts new file mode 100644 index 000000000..5c0d8ead2 --- /dev/null +++ b/src/platform/assets/composables/useAssetBrowser.ts @@ -0,0 +1,203 @@ +import { computed, ref } from 'vue' + +import type { UUID } from '@/lib/litegraph/src/utils/uuid' +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' + +// Display properties for transformed assets +export interface AssetDisplayItem extends AssetItem { + description: string + formattedSize: string + badges: Array<{ + label: string + type: 'type' | 'base' | 'size' + }> + stats: { + formattedDate?: string + downloadCount?: string + stars?: string + } +} + +/** + * Asset Browser composable + * Manages search, filtering, asset transformation and selection logic + */ +export function useAssetBrowser(assets: AssetItem[] = []) { + // State + const searchQuery = ref('') + const selectedCategory = ref('all') + const sortBy = ref('name') + + // Transform API asset to display asset + function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem { + // Extract description from metadata or create from tags + const description = + asset.user_metadata?.description || + `${asset.tags.find((tag) => tag !== 'models') || 'Unknown'} model` + + // Format file size + const formattedSize = formatFileSize(asset.size) + + // Create badges from tags and metadata + const badges = [] + + // Type badge from non-root tag + const typeTag = asset.tags.find((tag) => tag !== 'models') + if (typeTag) { + badges.push({ label: typeTag, type: 'type' as const }) + } + + // Base model badge from metadata + if (asset.user_metadata?.base_model) { + badges.push({ + label: asset.user_metadata.base_model, + type: 'base' as const + }) + } + + // Size badge + badges.push({ label: formattedSize, type: 'size' as const }) + + // Create display stats from API data + const stats = { + formattedDate: new Date(asset.created_at).toLocaleDateString(), + downloadCount: undefined, // Not available in API + stars: undefined // Not available in API + } + + return { + ...asset, + description, + formattedSize, + badges, + stats + } + } + + // Helper to format file sizes + function formatFileSize(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB'] + let size = bytes + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex++ + } + + return `${size.toFixed(1)} ${units[unitIndex]}` + } + + // Extract available categories from assets + const availableCategories = computed(() => { + const categorySet = new Set() + + assets.forEach((asset) => { + // Second tag is the category (after 'models' root tag) + if (asset.tags.length > 1 && asset.tags[0] === 'models') { + categorySet.add(asset.tags[1]) + } + }) + + return [ + { id: 'all', label: 'All Models', icon: 'i-lucide:folder' }, + ...Array.from(categorySet) + .sort() + .map((category) => ({ + id: category, + label: category.charAt(0).toUpperCase() + category.slice(1), + icon: 'i-lucide:package' + })) + ] + }) + + // Compute content title from selected category + const contentTitle = computed(() => { + if (selectedCategory.value === 'all') { + return 'All Models' + } + + const category = availableCategories.value.find( + (cat) => cat.id === selectedCategory.value + ) + return category?.label || 'Assets' + }) + + // Computed filtered and transformed assets + const filteredAssets = computed(() => { + let filtered = [...assets] + + // Filter by category (tag-based) + if (selectedCategory.value !== 'all') { + filtered = filtered.filter((asset) => + asset.tags.includes(selectedCategory.value) + ) + } + + // Filter by search query + if (searchQuery.value) { + const query = searchQuery.value.toLowerCase() + filtered = filtered.filter( + (asset) => + asset.name.toLowerCase().includes(query) || + asset.user_metadata?.description?.toLowerCase().includes(query) || + asset.tags.some((tag) => tag.toLowerCase().includes(query)) + ) + } + + // Sort assets + filtered.sort((a, b) => { + switch (sortBy.value) { + case 'date': + return ( + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ) + case 'name': + default: + return a.name.localeCompare(b.name) + } + }) + + // Transform to display format + return filtered.map(transformAssetForDisplay) + }) + + // Actions + function setSearchQuery(query: string) { + searchQuery.value = query + } + + function setCategory(category: string) { + selectedCategory.value = category + } + + function setSortBy(sort: string) { + sortBy.value = sort + } + + function selectAsset(asset: AssetDisplayItem): UUID { + if (import.meta.env.DEV) { + console.log('Asset selected:', asset.id, asset.name) + } + return asset.id + } + + return { + // State + searchQuery, + selectedCategory, + sortBy, + + // Computed + availableCategories, + contentTitle, + filteredAssets, + + // Actions + setSearchQuery, + setCategory, + setSortBy, + selectAsset, + transformAssetForDisplay + } +} diff --git a/src/platform/assets/composables/useAssetBrowserDialog.stories.ts b/src/platform/assets/composables/useAssetBrowserDialog.stories.ts new file mode 100644 index 000000000..784edafb9 --- /dev/null +++ b/src/platform/assets/composables/useAssetBrowserDialog.stories.ts @@ -0,0 +1,200 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import AssetBrowserModal from '../components/AssetBrowserModal.vue' + +// Component that simulates the useAssetBrowserDialog functionality with working close +const DialogDemoComponent = { + components: { AssetBrowserModal }, + setup() { + const isDialogOpen = ref(false) + const currentNodeType = ref('CheckpointLoaderSimple') + const currentInputName = ref('ckpt_name') + const currentValue = ref('') + + const handleOpenDialog = ( + nodeType: string, + inputName: string, + value = '' + ) => { + currentNodeType.value = nodeType + currentInputName.value = inputName + currentValue.value = value + isDialogOpen.value = true + } + + const handleCloseDialog = () => { + isDialogOpen.value = false + } + + const handleAssetSelected = (assetPath: string) => { + console.log('Asset selected:', assetPath) + alert(`Selected asset: ${assetPath}`) + isDialogOpen.value = false // Auto-close like the real composable + } + + const handleOpenWithCurrentValue = () => { + handleOpenDialog( + 'CheckpointLoaderSimple', + 'ckpt_name', + 'realistic_vision_v5.safetensors' + ) + } + + return { + isDialogOpen, + currentNodeType, + currentInputName, + currentValue, + handleOpenDialog, + handleOpenWithCurrentValue, + handleCloseDialog, + handleAssetSelected + } + }, + template: ` +
+
+

Asset Browser Dialog Demo

+ +
+
+

Different Node Types

+
+ + + +
+
+ +
+

With Current Value

+ +

+ Opens with "realistic_vision_v5.safetensors" as current value +

+
+ +
+

Instructions:

+
    +
  • • Click any button to open the Asset Browser dialog
  • +
  • • Select an asset to see the callback in action
  • +
  • • Check the browser console for logged events
  • +
  • • Try toggling the left panel with different asset types
  • +
  • • Close button will work properly in this demo
  • +
+
+
+
+ + +
+
+ +
+
+
+ ` +} + +const meta: Meta = { + title: 'Platform/Assets/useAssetBrowserDialog', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Demonstrates the AssetBrowserModal functionality as used by the useAssetBrowserDialog composable.' + } + } + } +} + +export default meta +type Story = StoryObj + +export const Demo: Story = { + render: () => ({ + components: { DialogDemoComponent }, + template: ` +
+ + + +
+

Code Example

+

+ This is how you would use the composable in your component: +

+
+
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
+
+export default {
+  setup() {
+    const assetBrowserDialog = useAssetBrowserDialog()
+
+    const openBrowser = () => {
+      assetBrowserDialog.show({
+        nodeType: 'CheckpointLoaderSimple',
+        inputName: 'ckpt_name',
+        currentValue: '',
+        onAssetSelected: (assetPath) => {
+          console.log('Selected:', assetPath)
+          // Update your component state
+        }
+      })
+    }
+
+    return { openBrowser }
+  }
+}
+
+
+

+ 💡 Try it: Use the interactive buttons above to see this code in action! +

+
+
+
+ ` + }), + parameters: { + docs: { + description: { + story: + 'Complete demo showing both interactive functionality and code examples for using useAssetBrowserDialog to open the Asset Browser modal programmatically.' + } + } + } +} diff --git a/src/platform/assets/composables/useAssetBrowserDialog.ts b/src/platform/assets/composables/useAssetBrowserDialog.ts new file mode 100644 index 000000000..dfacd83eb --- /dev/null +++ b/src/platform/assets/composables/useAssetBrowserDialog.ts @@ -0,0 +1,68 @@ +import { useDialogStore } from '@/stores/dialogStore' + +import AssetBrowserModal from '../components/AssetBrowserModal.vue' + +interface AssetBrowserDialogProps { + /** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */ + nodeType: string + /** Widget input name (e.g., 'ckpt_name') */ + inputName: string + /** Current selected asset value */ + currentValue?: string + /** Callback for when an asset is selected */ + onAssetSelected?: (assetPath: string) => void +} + +export const useAssetBrowserDialog = () => { + const dialogStore = useDialogStore() + const dialogKey = 'global-asset-browser' + + function hide() { + dialogStore.closeDialog({ key: dialogKey }) + } + + function show(props: AssetBrowserDialogProps) { + const handleAssetSelected = (assetPath: string) => { + props.onAssetSelected?.(assetPath) + hide() // Auto-close on selection + } + + const handleClose = () => { + hide() + } + + // Default dialog configuration for AssetBrowserModal + const dialogComponentProps = { + headless: true, + modal: true, + closable: false, + pt: { + root: { + class: + 'rounded-2xl overflow-hidden h-[80vh] w-[80vw] max-h-[80vh] max-w-[80vw]' + }, + header: { + class: 'p-0! hidden' + }, + content: { + class: 'p-0! m-0! h-full w-full' + } + } + } + + dialogStore.showDialog({ + key: dialogKey, + component: AssetBrowserModal, + props: { + nodeType: props.nodeType, + inputName: props.inputName, + currentValue: props.currentValue, + onSelect: handleAssetSelected, + onClose: handleClose + }, + dialogComponentProps + }) + } + + return { show, hide } +} diff --git a/src/platform/assets/fixtures/ui-mock-assets.ts b/src/platform/assets/fixtures/ui-mock-assets.ts new file mode 100644 index 000000000..6c7284386 --- /dev/null +++ b/src/platform/assets/fixtures/ui-mock-assets.ts @@ -0,0 +1,128 @@ +import type { AssetItem } from '@/platform/assets/schemas/assetSchema' + +// 🎭 OBVIOUSLY FAKE MOCK DATA - DO NOT USE IN PRODUCTION! 🎭 +const fakeFunnyModelNames = [ + '🎯_totally_real_model_v420.69', + '🚀_definitely_not_fake_v999', + '🎪_super_legit_checkpoint_pro_max', + '🦄_unicorn_dreams_totally_real.model', + '🍕_pizza_generator_supreme', + '🎸_rock_star_fake_data_v1337', + '🌮_taco_tuesday_model_deluxe', + '🦖_dino_nugget_generator_v3', + '🎮_gamer_fuel_checkpoint_xl', + '🍄_mushroom_kingdom_diffusion', + '🏴‍☠️_pirate_treasure_model_arr', + '🦋_butterfly_effect_generator', + '🎺_jazz_hands_checkpoint_pro', + '🥨_pretzel_logic_model_v2', + '🌙_midnight_snack_generator', + '🎭_drama_llama_checkpoint', + '🧙‍♀️_wizard_hat_diffusion_xl', + '🎪_circus_peanut_model_v4', + '🦒_giraffe_neck_generator', + '🎲_random_stuff_checkpoint_max' +] + +const obviouslyFakeDescriptions = [ + '⚠️ FAKE DATA: Generates 100% authentic fake images with premium mock quality', + '🎭 MOCK ALERT: This totally real model creates absolutely genuine fake content', + '🚨 NOT REAL: Professional-grade fake imagery for your mock data needs', + '🎪 DEMO ONLY: Circus-quality fake generation with extra mock seasoning', + '🍕 FAKE FOOD: Generates delicious fake pizzas (not edible in reality)', + "🎸 MOCK ROCK: Creates fake rock stars who definitely don't exist", + '🌮 TACO FAKERY: Tuesday-themed fake tacos for your mock appetite', + '🦖 PREHISTORIC FAKE: Generates extinct fake dinosaurs for demo purposes', + '🎮 FAKE GAMING: Level up your mock data with obviously fake content', + '🍄 FUNGI FICTION: Magical fake mushrooms from the demo dimension', + '🏴‍☠️ FAKE TREASURE: Arr! This be mock data for ye demo needs, matey!', + '🦋 DEMO EFFECT: Small fake changes create big mock differences', + '🎺 JAZZ FAKERY: Smooth fake jazz for your mock listening pleasure', + '🥨 MOCK LOGIC: Twisted fake reasoning for your demo requirements', + '🌙 MIDNIGHT MOCK: Late-night fake snacks for your demo hunger', + '🎭 FAKE DRAMA: Over-the-top mock emotions for demo entertainment', + '🧙‍♀️ WIZARD MOCK: Magically fake spells cast with demo ingredients', + '🎪 CIRCUS FAKE: Big top mock entertainment under the demo tent', + '🦒 TALL FAKE: Reaches new heights of obviously fake content', + '🎲 RANDOM MOCK: Generates random fake stuff for your demo pleasure' +] + +// API-compliant tag structure: first tag must be root (models/input/output), second is category +const modelCategories = ['checkpoints', 'loras', 'embeddings', 'vae'] +const baseModels = ['sd15', 'sdxl', 'sd35'] +const fileExtensions = ['.safetensors', '.ckpt', '.pt'] +const mimeTypes = [ + 'application/octet-stream', + 'application/x-pytorch', + 'application/x-safetensors' +] + +function getRandomElement(array: T[]): T { + return array[Math.floor(Math.random() * array.length)] +} + +function getRandomNumber(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +function getRandomISODate(): string { + const start = new Date('2024-01-01').getTime() + const end = new Date('2024-12-31').getTime() + const randomTime = start + Math.random() * (end - start) + return new Date(randomTime).toISOString() +} + +function generateFakeAssetHash(): string { + const chars = '0123456789abcdef' + let hash = 'blake3:' + for (let i = 0; i < 64; i++) { + hash += chars[Math.floor(Math.random() * chars.length)] + } + return hash +} + +// 🎭 CREATES OBVIOUSLY FAKE ASSETS FOR DEMO/TEST PURPOSES ONLY! 🎭 +export function createMockAssets(count: number = 20): AssetItem[] { + return Array.from({ length: count }, (_, index) => { + const category = getRandomElement(modelCategories) + const baseModel = getRandomElement(baseModels) + const extension = getRandomElement(fileExtensions) + const mimeType = getRandomElement(mimeTypes) + const sizeInBytes = getRandomNumber( + 500 * 1024 * 1024, + 8 * 1024 * 1024 * 1024 + ) // 500MB to 8GB + const createdAt = getRandomISODate() + const updatedAt = createdAt + const lastAccessTime = getRandomISODate() + + const fakeFileName = `${fakeFunnyModelNames[index]}${extension}` + + return { + id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`, + name: fakeFileName, + asset_hash: generateFakeAssetHash(), + size: sizeInBytes, + mime_type: mimeType, + tags: [ + 'models', // Root tag (required first) + category, // Category tag (required second for models) + 'fake-data', // Obviously fake tag + ...(Math.random() > 0.5 ? ['demo-mode'] : ['test-only']), + ...(Math.random() > 0.7 ? ['obviously-mock'] : []) + ], + preview_url: `/api/assets/mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake/content`, + created_at: createdAt, + updated_at: updatedAt, + last_access_time: lastAccessTime, + user_metadata: { + description: obviouslyFakeDescriptions[index], + base_model: baseModel, + original_name: fakeFunnyModelNames[index], + warning: '🚨 THIS IS FAKE DEMO DATA - NOT A REAL MODEL! 🚨' + } + } + }) +} + +export const mockAssets = createMockAssets(20) diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index 277efcbb0..1927cd9c2 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -1,12 +1,19 @@ import { z } from 'zod' -// Zod schemas for asset API validation +// Zod schemas for asset API validation matching ComfyUI Assets REST API spec const zAsset = z.object({ id: z.string(), name: z.string(), - tags: z.array(z.string()), + asset_hash: z.string(), size: z.number(), - created_at: z.string().optional() + mime_type: z.string(), + tags: z.array(z.string()), + preview_url: z.string().optional(), + created_at: z.string(), + updated_at: z.string(), + last_access_time: z.string(), + user_metadata: z.record(z.any()).optional(), + preview_id: z.string().nullable().optional() }) const zAssetResponse = z.object({ @@ -20,19 +27,22 @@ const zModelFolder = z.object({ folders: z.array(z.string()) }) +// Zod schema for ModelFile to align with interface +const zModelFile = z.object({ + name: z.string(), + pathIndex: z.number() +}) + // Export schemas following repository patterns export const assetResponseSchema = zAssetResponse // Export types derived from Zod schemas +export type AssetItem = z.infer export type AssetResponse = z.infer export type ModelFolder = z.infer +export type ModelFile = z.infer -// Common interfaces for API responses -export interface ModelFile { - name: string - pathIndex: number -} - +// Legacy interface for backward compatibility (now aligned with Zod schema) export interface ModelFolderInfo { name: string folders: string[] diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 344209bf7..74b20a753 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -67,7 +67,7 @@ function createAssetService() { ) // Blacklist directories we don't want to show - const blacklistedDirectories = ['configs'] + const blacklistedDirectories = new Set(['configs']) // Extract directory names from assets that actually exist, exclude missing assets const discoveredFolders = new Set( @@ -75,7 +75,7 @@ function createAssetService() { ?.filter((asset) => !asset.tags.includes(MISSING_TAG)) ?.flatMap((asset) => asset.tags) ?.filter( - (tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag) + (tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag) ) ?? [] ) diff --git a/tests-ui/platform/assets/components/AssetBrowserModal.test.ts b/tests-ui/platform/assets/components/AssetBrowserModal.test.ts new file mode 100644 index 000000000..0d110b60d --- /dev/null +++ b/tests-ui/platform/assets/components/AssetBrowserModal.test.ts @@ -0,0 +1,322 @@ +import { VueWrapper, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, 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: ` + + ` + } +})) + +vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({ + default: { + name: 'BaseModalLayout', + props: ['contentTitle'], + emits: ['close'], + template: ` +
+
+ +
+
+ +
+
+ +
+
+ ` + } +})) + +vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({ + default: { + name: 'LeftSidePanel', + props: ['modelValue', 'navItems'], + emits: ['update:modelValue'], + template: ` +
+ +
+ ` + } +})) + +vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({ + default: { + name: 'AssetGrid', + props: ['assets'], + emits: ['asset-select'], + template: ` +
+
+ {{ asset.name }} +
+
+ No assets found +
+
+ ` + } +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +describe('AssetBrowserModal', () => { + let wrapper: VueWrapper + + 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 = {} + ) => { + const pinia = createPinia() + setActivePinia(pinia) + + wrapper = mount(AssetBrowserModal, { + props: { + assets: assets, + ...props + }, + global: { + plugins: [pinia], + stubs: { + 'i-lucide:folder': { + template: '
' + } + }, + mocks: { + $t: (key: string) => key + } + } + }) + return wrapper + } + + afterEach(() => { + if (wrapper) { + wrapper.unmount() + } + }) + + 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') + ] + 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') + ] + 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') + ] + 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') + ] + createWrapper(assets) + + // 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')] + 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')] + 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 when only one category exists', () => { + const singleCategoryAssets = [ + createTestAsset('single1', 'Asset 1', 'checkpoints'), + createTestAsset('single2', 'Asset 2', 'checkpoints') + ] + createWrapper(singleCategoryAssets) + + expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(false) + }) + + it('shows left panel when multiple categories exist', async () => { + const multiCategoryAssets = [ + createTestAsset('asset1', 'Checkpoint', 'checkpoints'), + createTestAsset('asset2', 'LoRA', 'loras') + ] + createWrapper(multiCategoryAssets) + + // Wait for Vue reactivity to compute shouldShowLeftPanel + await nextTick() + + expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(true) + }) + + it('respects explicit showLeftPanel prop override', () => { + const singleCategoryAssets = [ + createTestAsset('single1', 'Asset 1', 'checkpoints') + ] + + // Force show even with single category + 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') + ] + createWrapper(multiCategoryAssets, { showLeftPanel: false }) + expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(false) + }) + }) +}) diff --git a/tests-ui/platform/assets/components/AssetFilterBar.test.ts b/tests-ui/platform/assets/components/AssetFilterBar.test.ts new file mode 100644 index 000000000..db24070e1 --- /dev/null +++ b/tests-ui/platform/assets/components/AssetFilterBar.test.ts @@ -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: ` +
+ +
+ ` + } +})) + +vi.mock('@/components/input/SingleSelect.vue', () => ({ + default: { + name: 'SingleSelect', + props: { + modelValue: String, + label: String, + options: Array, + class: String + }, + emits: ['update:modelValue'], + template: ` +
+ +
+ ` + } +})) + +// 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 + ) + }) + }) +}) diff --git a/tests-ui/platform/assets/composables/useAssetBrowser.test.ts b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts new file mode 100644 index 000000000..cf2b410a1 --- /dev/null +++ b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts @@ -0,0 +1,315 @@ +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 => ({ + 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.0 GB') + expect(result.badges).toContainEqual({ + label: 'checkpoints', + type: 'type' + }) + expect(result.badges).toContainEqual({ label: '2.0 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.0 B' }, + { size: 1536, expected: '1.5 KB' }, + { size: 2097152, expected: '2.0 MB' }, + { size: 3221225472, expected: '3.0 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 { setCategory, filteredAssets } = useAssetBrowser(assets) + + setCategory('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 { setCategory, filteredAssets } = useAssetBrowser(assets) + + setCategory('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 { setSearchQuery, filteredAssets } = useAssetBrowser(assets) + + setSearchQuery('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 { setSearchQuery, filteredAssets } = useAssetBrowser(assets) + + setSearchQuery('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 { setSearchQuery, filteredAssets } = useAssetBrowser(assets) + + setSearchQuery('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 { setSearchQuery, setCategory, filteredAssets } = + useAssetBrowser(assets) + + setSearchQuery('realistic') + setCategory('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 { setSortBy, filteredAssets } = useAssetBrowser(assets) + + setSortBy('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 { setSortBy, filteredAssets } = useAssetBrowser(assets) + + setSortBy('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: 'i-lucide:folder' }, + { id: 'checkpoints', label: 'Checkpoints', icon: 'i-lucide:package' }, + { id: 'loras', label: 'Loras', icon: 'i-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: 'i-lucide:folder' }, + { id: 'vae', label: 'Vae', icon: 'i-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: 'i-lucide:folder' }, + { id: 'checkpoints', label: 'Checkpoints', icon: 'i-lucide:package' } + ]) + }) + + it('computes content title from selected category', () => { + const assets = [createApiAsset({ tags: ['models', 'checkpoints'] })] + const { setCategory, contentTitle } = useAssetBrowser(assets) + + // Default + expect(contentTitle.value).toBe('All Models') + + // Set specific category + setCategory('checkpoints') + expect(contentTitle.value).toBe('Checkpoints') + + // Unknown category + setCategory('unknown') + expect(contentTitle.value).toBe('Assets') + }) + }) +}) diff --git a/tests-ui/platform/assets/composables/useAssetBrowserDialog.test.ts b/tests-ui/platform/assets/composables/useAssetBrowserDialog.test.ts new file mode 100644 index 000000000..029bf3148 --- /dev/null +++ b/tests-ui/platform/assets/composables/useAssetBrowserDialog.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog' + +// Mock the dialog store +let mockShowDialog: ReturnType +let mockCloseDialog: ReturnType + +vi.mock('@/stores/dialogStore', () => ({ + useDialogStore: vi.fn(() => ({ + showDialog: mockShowDialog, + closeDialog: mockCloseDialog + })) +})) + +// Test factory functions +interface AssetBrowserProps { + nodeType: string + inputName: string + onAssetSelected?: ReturnType +} + +function createAssetBrowserProps( + overrides: Partial = {} +): AssetBrowserProps { + return { + nodeType: 'CheckpointLoaderSimple', + inputName: 'ckpt_name', + ...overrides + } +} + +describe('useAssetBrowserDialog', () => { + let assetBrowserDialog: ReturnType + + beforeEach(() => { + mockShowDialog = vi.fn() + mockCloseDialog = vi.fn() + assetBrowserDialog = useAssetBrowserDialog() + }) + + describe('Asset Selection Flow', () => { + it('auto-closes dialog when asset is selected', () => { + 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', () => { + 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' + }) + }) + }) +}) diff --git a/tests-ui/tests/services/assetService.test.ts b/tests-ui/tests/services/assetService.test.ts index f11c9d40d..d96ef765b 100644 --- a/tests-ui/tests/services/assetService.test.ts +++ b/tests-ui/tests/services/assetService.test.ts @@ -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 = {}) { + 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)