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 d3ee425dd..0b1b88820 100644 --- a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue +++ b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue @@ -5,25 +5,32 @@ import { cn } from '@/utils/tailwindUtil' import TransitionCollapse from './TransitionCollapse.vue' -const props = defineProps<{ +const { + disabled, + label, + enableEmptyState, + tooltip, + class: className +} = defineProps<{ disabled?: boolean label?: string enableEmptyState?: boolean tooltip?: string + class?: string }>() const isCollapse = defineModel('collapse', { default: false }) -const isExpanded = computed(() => !isCollapse.value && !props.disabled) +const isExpanded = computed(() => !isCollapse.value && !disabled) const tooltipConfig = computed(() => { - if (!props.tooltip) return undefined - return { value: props.tooltip, showDelay: 1000 } + if (!tooltip) return undefined + return { value: tooltip, showDelay: 1000 } }) 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 @@ +