diff --git a/docs/testing/vitest-patterns.md b/docs/testing/vitest-patterns.md index 2eb7c8e09..9c7fef7c2 100644 --- a/docs/testing/vitest-patterns.md +++ b/docs/testing/vitest-patterns.md @@ -30,6 +30,10 @@ describe('MyStore', () => { **Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior. +## i18n in Component Tests + +Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example. + ## Mock Patterns ### Reset all mocks at once diff --git a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue index 2a4448b04..ba0159ad2 100644 --- a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue +++ b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue @@ -1,54 +1,73 @@ diff --git a/src/locales/en/main.json b/src/locales/en/main.json index ea5284eb2..5a4dc9c19 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -183,6 +183,7 @@ "source": "Source", "filter": "Filter", "apply": "Apply", + "use": "Use", "enabled": "Enabled", "installed": "Installed", "restart": "Restart", @@ -2388,6 +2389,29 @@ "assetCard": "{name} - {type} asset", "loadingAsset": "Loading asset" }, + "modelInfo": { + "title": "Model Info", + "selectModelPrompt": "Select a model to see its information", + "basicInfo": "Basic Info", + "displayName": "Display Name", + "fileName": "File Name", + "source": "Source", + "viewOnSource": "View on {source}", + "modelTagging": "Model Tagging", + "modelType": "Model Type", + "selectModelType": "Select model type...", + "compatibleBaseModels": "Compatible Base Models", + "addBaseModel": "Add base model...", + "baseModelUnknown": "Base model unknown", + "additionalTags": "Additional Tags", + "addTag": "Add tag...", + "noAdditionalTags": "No additional tags", + "modelDescription": "Model Description", + "triggerPhrases": "Trigger Phrases", + "description": "Description", + "descriptionNotSet": "No description set", + "descriptionPlaceholder": "Add a description for this model..." + }, "media": { "threeDModelPlaceholder": "3D Model", "audioPlaceholder": "Audio" diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index c30dd02b6..2b8529333 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -1,8 +1,10 @@ + + diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue index 11e404d41..ccaf546c1 100644 --- a/src/platform/assets/components/AssetCard.vue +++ b/src/platform/assets/components/AssetCard.vue @@ -9,30 +9,28 @@ cn( 'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full', interactive && - 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4' + 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4', + focused && 'bg-secondary-background outline-solid' ) " + @click.stop="interactive && $emit('focus', asset)" + @focus="interactive && $emit('focus', asset)" @keydown.enter.self="interactive && $emit('select', asset)" >
- + + @@ -52,14 +57,19 @@ import VirtualGrid from '@/components/common/VirtualGrid.vue' import AssetCard from '@/platform/assets/components/AssetCard.vue' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' -const { assets } = defineProps<{ +const { assets, focusedAssetId, emptyTitle, emptyMessage } = defineProps<{ assets: AssetDisplayItem[] loading?: boolean + focusedAssetId?: string | null + emptyTitle?: string + emptyMessage?: string }>() defineEmits<{ + assetFocus: [asset: AssetDisplayItem] assetSelect: [asset: AssetDisplayItem] assetDeleted: [asset: AssetDisplayItem] + assetShowInfo: [asset: AssetDisplayItem] }>() const assetsWithKey = computed(() => @@ -73,7 +83,7 @@ const isLg = breakpoints.greaterOrEqual('lg') const isMd = breakpoints.greaterOrEqual('md') const maxColumns = computed(() => { if (is2Xl.value) return 5 - if (isXl.value) return 4 + if (isXl.value) return 3 if (isLg.value) return 3 if (isMd.value) return 2 return 1 diff --git a/src/platform/assets/components/modelInfo/ModelInfoField.vue b/src/platform/assets/components/modelInfo/ModelInfoField.vue new file mode 100644 index 000000000..34ff0a326 --- /dev/null +++ b/src/platform/assets/components/modelInfo/ModelInfoField.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts new file mode 100644 index 000000000..89300617f --- /dev/null +++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts @@ -0,0 +1,165 @@ +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' + +import ModelInfoPanel from './ModelInfoPanel.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} }, + missingWarn: false, + fallbackWarn: false +}) + +describe('ModelInfoPanel', () => { + const createMockAsset = ( + overrides: Partial = {} + ): AssetDisplayItem => ({ + id: 'test-id', + name: 'test-model.safetensors', + 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', + description: 'A test model description', + badges: [], + stats: {}, + ...overrides + }) + + const mountPanel = (asset: AssetDisplayItem) => { + return mount(ModelInfoPanel, { + props: { asset }, + global: { + plugins: [createTestingPinia({ stubActions: false }), i18n] + } + }) + } + + describe('Basic Info Section', () => { + it('renders basic info section', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo') + }) + + it('displays asset filename', () => { + const asset = createMockAsset({ name: 'my-model.safetensors' }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('my-model.safetensors') + }) + + it('displays name from user_metadata when present', () => { + const asset = createMockAsset({ + user_metadata: { name: 'My Custom Model' } + }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('My Custom Model') + }) + + it('falls back to asset name when user_metadata.name not present', () => { + const asset = createMockAsset({ name: 'fallback-model.safetensors' }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('fallback-model.safetensors') + }) + + it('renders source link when source_arn is present', () => { + const asset = createMockAsset({ + user_metadata: { source_arn: 'civitai:model:123:version:456' } + }) + const wrapper = mountPanel(asset) + const link = wrapper.find( + 'a[href="https://civitai.com/models/123?modelVersionId=456"]' + ) + expect(link.exists()).toBe(true) + expect(link.attributes('target')).toBe('_blank') + }) + + it('displays Civitai icon for Civitai source', () => { + const asset = createMockAsset({ + user_metadata: { source_arn: 'civitai:model:123:version:456' } + }) + const wrapper = mountPanel(asset) + expect( + wrapper.find('img[src="/assets/images/civitai.svg"]').exists() + ).toBe(true) + }) + + it('does not render source field when source_arn is absent', () => { + const asset = createMockAsset() + const wrapper = mountPanel(asset) + const links = wrapper.findAll('a') + expect(links).toHaveLength(0) + }) + }) + + describe('Model Tagging Section', () => { + it('renders model tagging section', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging') + }) + + it('renders model type field', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelType') + }) + + it('renders base models field', () => { + const asset = createMockAsset({ + user_metadata: { base_model: ['SDXL'] } + }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain( + 'assetBrowser.modelInfo.compatibleBaseModels' + ) + }) + + it('renders additional tags field', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.additionalTags') + }) + }) + + describe('Model Description Section', () => { + it('renders trigger phrases when present', () => { + const asset = createMockAsset({ + user_metadata: { trained_words: ['trigger1', 'trigger2'] } + }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('trigger1') + expect(wrapper.text()).toContain('trigger2') + }) + + it('renders description section', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain( + 'assetBrowser.modelInfo.modelDescription' + ) + }) + + it('does not render trigger phrases field when empty', () => { + const asset = createMockAsset() + const wrapper = mountPanel(asset) + expect(wrapper.text()).not.toContain( + 'assetBrowser.modelInfo.triggerPhrases' + ) + }) + }) + + describe('Accordion Structure', () => { + it('renders all three section labels', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo') + expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging') + expect(wrapper.text()).toContain( + 'assetBrowser.modelInfo.modelDescription' + ) + }) + }) +}) diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue new file mode 100644 index 000000000..c69cb6429 --- /dev/null +++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue @@ -0,0 +1,296 @@ +