test: migrate 13 component tests from VTU to VTL (Phase 1)

Migrate component tests from @vue/test-utils to @testing-library/vue
with proper screen queries, userEvent interactions, and jest-dom
assertions. Add data-testid attributes to 6 components for lint-clean
icon/element queries. Delete unused test-utils.ts.

Files migrated: ConfirmationDialogContent, BadgePill,
QueueNotificationBanner, AudioThumbnail, QueueInlineProgress,
UserCredit, LogoOverlay, DefaultThumbnail, QueueProgressOverlay,
TopbarBadge, UserAvatar, FormRadioGroup, ImageLightbox

Amp-Thread-ID: https://ampcode.com/threads/T-019d1dc6-4dcb-71e1-9ffa-27573d698127
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-03-24 11:56:51 -07:00
parent 98d56bdada
commit 6adbd4a81e
20 changed files with 461 additions and 577 deletions

View File

@@ -1,89 +1,103 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import BadgePill from './BadgePill.vue'
describe('BadgePill', () => {
it('renders text content', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Test Badge' }
})
expect(wrapper.text()).toBe('Test Badge')
expect(screen.getByText('Test Badge')).toBeInTheDocument()
})
it('renders icon when provided', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { icon: 'icon-[comfy--credits]', text: 'Credits' }
})
expect(wrapper.find('i.icon-\\[comfy--credits\\]').exists()).toBe(true)
expect(screen.getByTestId('badge-icon')).toHaveClass(
'icon-[comfy--credits]'
)
})
it('applies iconClass to icon', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: {
icon: 'icon-[comfy--credits]',
iconClass: 'text-amber-400'
}
})
const icon = wrapper.find('i')
expect(icon.classes()).toContain('text-amber-400')
expect(screen.getByTestId('badge-icon')).toHaveClass('text-amber-400')
})
it('uses default border color when no borderStyle', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Default' }
})
expect(wrapper.attributes('style')).toContain(
'border-color: var(--border-color)'
expect(screen.getByTestId('badge-pill')).toHaveAttribute(
'style',
expect.stringContaining('border-color: var(--border-color)')
)
})
it('applies solid border color when borderStyle is a color', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Colored', borderStyle: '#f59e0b' }
})
expect(wrapper.attributes('style')).toContain('border-color: #f59e0b')
expect(screen.getByTestId('badge-pill')).toHaveAttribute(
'style',
expect.stringContaining('border-color: #f59e0b')
)
})
it('applies gradient border when borderStyle contains linear-gradient', () => {
const gradient = 'linear-gradient(90deg, #3186FF, #FABC12)'
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Gradient', borderStyle: gradient }
})
const element = wrapper.element as HTMLElement
const element = screen.getByTestId('badge-pill') as HTMLElement
expect(element.style.borderColor).toBe('transparent')
expect(element.style.backgroundOrigin).toBe('border-box')
expect(element.style.backgroundClip).toBe('padding-box, border-box')
})
it('applies filled style with background and text color', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
})
const style = wrapper.attributes('style')
expect(style).toContain('border-color: #f59e0b')
expect(style).toContain('background-color: #f59e0b33')
expect(style).toContain('color: #f59e0b')
const pill = screen.getByTestId('badge-pill')
expect(pill).toHaveAttribute(
'style',
expect.stringContaining('border-color: #f59e0b')
)
expect(pill).toHaveAttribute(
'style',
expect.stringContaining('background-color: #f59e0b33')
)
expect(pill).toHaveAttribute(
'style',
expect.stringContaining('color: #f59e0b')
)
})
it('has foreground text when not filled', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Not Filled', borderStyle: '#f59e0b' }
})
expect(wrapper.classes()).toContain('text-foreground')
expect(screen.getByTestId('badge-pill')).toHaveClass('text-foreground')
})
it('does not have foreground text class when filled', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
props: { text: 'Filled', borderStyle: '#f59e0b', filled: true }
})
expect(wrapper.classes()).not.toContain('text-foreground')
expect(screen.getByTestId('badge-pill')).not.toHaveClass('text-foreground')
})
it('renders slot content', () => {
const wrapper = mount(BadgePill, {
render(BadgePill, {
slots: { default: 'Slot Content' }
})
expect(wrapper.text()).toBe('Slot Content')
expect(screen.getByText('Slot Content')).toBeInTheDocument()
})
})

View File

@@ -1,5 +1,6 @@
<template>
<span
data-testid="badge-pill"
:class="
cn(
'flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-xxs',
@@ -8,7 +9,11 @@
"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />
<i
v-if="icon"
data-testid="badge-icon"
:class="cn(icon, 'size-2.5', iconClass)"
/>
<slot>{{ text }}</slot>
</span>
</template>

View File

@@ -1,241 +1,210 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import RadioButton from 'primevue/radiobutton'
import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import { describe, expect, it } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import type { SettingOption } from '@/platform/settings/types'
import FormRadioGroup from './FormRadioGroup.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
describe('FormRadioGroup', () => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
components: { RadioButton }
},
props,
...options
function renderComponent(props: FormRadioGroupProps) {
return render(FormRadioGroup, {
global: { plugins: [PrimeVue] },
props
})
}
describe('normalizedOptions computed property', () => {
it('handles string array options', () => {
const wrapper = mountComponent({
renderComponent({
modelValue: 'option1',
options: ['option1', 'option2', 'option3'],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
const radios = screen.getAllByRole('radio')
expect(radios).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('option1')
expect(radioButtons[1].props('value')).toBe('option2')
expect(radioButtons[2].props('value')).toBe('option3')
expect(radios[0]).toHaveAttribute('value', 'option1')
expect(radios[1]).toHaveAttribute('value', 'option2')
expect(radios[2]).toHaveAttribute('value', 'option3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('option1')
expect(labels[1].text()).toBe('option2')
expect(labels[2].text()).toBe('option3')
expect(screen.getByText('option1')).toBeInTheDocument()
expect(screen.getByText('option2')).toBeInTheDocument()
expect(screen.getByText('option3')).toBeInTheDocument()
})
it('handles SettingOption array', () => {
const options: SettingOption[] = [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
]
const wrapper = mountComponent({
renderComponent({
modelValue: 'md',
options,
options: [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
] satisfies SettingOption[],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
const radios = screen.getAllByRole('radio')
expect(radios).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('sm')
expect(radioButtons[1].props('value')).toBe('md')
expect(radioButtons[2].props('value')).toBe('lg')
expect(radios[0]).toHaveAttribute('value', 'sm')
expect(radios[1]).toHaveAttribute('value', 'md')
expect(radios[2]).toHaveAttribute('value', 'lg')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Small')
expect(labels[1].text()).toBe('Medium')
expect(labels[2].text()).toBe('Large')
expect(screen.getByText('Small')).toBeInTheDocument()
expect(screen.getByText('Medium')).toBeInTheDocument()
expect(screen.getByText('Large')).toBeInTheDocument()
})
it('handles SettingOption with undefined value (uses text as value)', () => {
const options: SettingOption[] = [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
]
const wrapper = mountComponent({
renderComponent({
modelValue: 'Option A',
options,
options: [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
] satisfies SettingOption[],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('value')).toBe('Option A')
expect(radioButtons[1].props('value')).toBe('Option B')
const radios = screen.getAllByRole('radio')
expect(radios[0]).toHaveAttribute('value', 'Option A')
expect(radios[1]).toHaveAttribute('value', 'Option B')
})
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
]
const wrapper = mountComponent({
renderComponent({
modelValue: 2,
options,
options: [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
],
optionLabel: 'name',
optionValue: 'id',
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
const radios = screen.getAllByRole('radio')
expect(radios).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
expect(radios[0]).toHaveAttribute('value', '1')
expect(radios[1]).toHaveAttribute('value', '2')
expect(radios[2]).toHaveAttribute('value', '3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
expect(labels[1].text()).toBe('Second Option')
expect(labels[2].text()).toBe('Third Option')
expect(screen.getByText('First Option')).toBeInTheDocument()
expect(screen.getByText('Second Option')).toBeInTheDocument()
expect(screen.getByText('Third Option')).toBeInTheDocument()
})
it('handles mixed array with strings and SettingOptions', () => {
const options: (string | SettingOption)[] = [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
]
const wrapper = mountComponent({
renderComponent({
modelValue: 'complex',
options,
options: [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
] as (string | SettingOption)[],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
const radios = screen.getAllByRole('radio')
expect(radios).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('Simple String')
expect(radioButtons[1].props('value')).toBe('complex')
expect(radioButtons[2].props('value')).toBe('Another String')
expect(radios[0]).toHaveAttribute('value', 'Simple String')
expect(radios[1]).toHaveAttribute('value', 'complex')
expect(radios[2]).toHaveAttribute('value', 'Another String')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Simple String')
expect(labels[1].text()).toBe('Complex Option')
expect(labels[2].text()).toBe('Another String')
expect(screen.getByText('Simple String')).toBeInTheDocument()
expect(screen.getByText('Complex Option')).toBeInTheDocument()
expect(screen.getByText('Another String')).toBeInTheDocument()
})
it('handles empty options array', () => {
const wrapper = mountComponent({
renderComponent({
modelValue: null,
options: [],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
expect(screen.queryAllByRole('radio')).toHaveLength(0)
})
it('handles undefined options gracefully', () => {
const wrapper = mountComponent({
renderComponent({
modelValue: null,
options: undefined,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
expect(screen.queryAllByRole('radio')).toHaveLength(0)
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const wrapper = mountComponent({
renderComponent({
modelValue: 'opt1',
options,
options: [{ label: 'Option 1', val: 'opt1' }],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
expect(screen.getAllByRole('radio')).toHaveLength(1)
expect(screen.getByText('Unknown')).toBeInTheDocument()
})
})
describe('component functionality', () => {
it('sets correct input-id and name attributes', () => {
const options = ['A', 'B']
const wrapper = mountComponent({
it('sets correct id and name attributes on inputs', () => {
renderComponent({
modelValue: 'A',
options,
options: ['A', 'B'],
id: 'my-radio-group'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
const radios = screen.getAllByRole('radio')
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
expect(radioButtons[0].props('name')).toBe('my-radio-group')
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
expect(radioButtons[1].props('name')).toBe('my-radio-group')
expect(radios[0]).toHaveAttribute('id', 'my-radio-group-A')
expect(radios[0]).toHaveAttribute('name', 'my-radio-group')
expect(radios[1]).toHaveAttribute('id', 'my-radio-group-B')
expect(radios[1]).toHaveAttribute('name', 'my-radio-group')
})
it('associates labels with radio buttons correctly', () => {
const options = ['Yes', 'No']
const wrapper = mountComponent({
renderComponent({
modelValue: 'Yes',
options,
options: ['Yes', 'No'],
id: 'confirm-radio'
})
const labels = wrapper.findAll('label')
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
expect(screen.getByText('Yes')).toHaveAttribute(
'for',
'confirm-radio-Yes'
)
expect(screen.getByText('No')).toHaveAttribute('for', 'confirm-radio-No')
})
it('sets aria-describedby attribute correctly', () => {
const options: SettingOption[] = [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
renderComponent({
modelValue: 'opt1',
options,
options: [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
] satisfies SettingOption[],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].attributes('aria-describedby')).toBe(
const radios = screen.getAllByRole('radio')
// PrimeVue RadioButton places aria-describedby on its root <div>, not the <input>
// eslint-disable-next-line testing-library/no-node-access
expect(radios[0].closest('[aria-describedby]')).toHaveAttribute(
'aria-describedby',
'Option 1-label'
)
expect(radioButtons[1].attributes('aria-describedby')).toBe(
// eslint-disable-next-line testing-library/no-node-access
expect(radios[1].closest('[aria-describedby]')).toHaveAttribute(
'aria-describedby',
'Option 2-label'
)
})

View File

@@ -1,6 +1,6 @@
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ImageLightbox from './ImageLightbox.vue'
@@ -13,49 +13,39 @@ const i18n = createI18n({
fallbackWarn: false
})
function findCloseButton() {
const el = document.body.querySelector('[aria-label="g.close"]')
return el ? new DOMWrapper(el) : null
}
describe(ImageLightbox, () => {
let wrapper: VueWrapper
afterEach(() => {
wrapper.unmount()
})
function mountComponent(props: { src: string; alt?: string }, open = true) {
wrapper = mount(ImageLightbox, {
function renderComponent(props: { src: string; alt?: string }, open = true) {
const user = userEvent.setup()
const onUpdate = vi.fn()
const result = render(ImageLightbox, {
global: { plugins: [i18n] },
props: { ...props, modelValue: open },
attachTo: document.body
props: {
...props,
modelValue: open,
'onUpdate:modelValue': onUpdate
}
})
return wrapper
return { ...result, user, onUpdate }
}
it('renders the image with correct src and alt when open', async () => {
mountComponent({ src: '/test.png', alt: 'Test image' })
await flushPromises()
const img = document.body.querySelector('img')
expect(img).toBeTruthy()
expect(img?.getAttribute('src')).toBe('/test.png')
expect(img?.getAttribute('alt')).toBe('Test image')
renderComponent({ src: '/test.png', alt: 'Test image' })
const img = await screen.findByRole('img')
expect(img).toHaveAttribute('src', '/test.png')
expect(img).toHaveAttribute('alt', 'Test image')
})
it('does not render dialog content when closed', async () => {
mountComponent({ src: '/test.png' }, false)
await flushPromises()
expect(document.body.querySelector('img')).toBeNull()
it('does not render dialog content when closed', () => {
renderComponent({ src: '/test.png' }, false)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('emits update:modelValue false when close button is clicked', async () => {
mountComponent({ src: '/test.png' })
await flushPromises()
const closeButton = findCloseButton()
expect(closeButton).toBeTruthy()
await closeButton!.trigger('click')
await flushPromises()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
const { user, onUpdate } = renderComponent({ src: '/test.png' })
const closeButton = await screen.findByLabelText('g.close')
await user.click(closeButton)
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith(false)
})
})
})

View File

@@ -1,10 +1,9 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { mount } from '@vue/test-utils'
import Avatar from 'primevue/avatar'
import { fireEvent, render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import UserAvatar from './UserAvatar.vue'
@@ -24,85 +23,73 @@ const i18n = createI18n({
})
describe('UserAvatar', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
return mount(UserAvatar, {
function renderComponent(props: ComponentProps<typeof UserAvatar> = {}) {
return render(UserAvatar, {
global: {
plugins: [PrimeVue, i18n],
components: { Avatar }
plugins: [PrimeVue, i18n]
},
props
})
}
it('renders correctly with photo Url', async () => {
const wrapper = mountComponent({
it('renders correctly with photo Url', () => {
renderComponent({
photoUrl: 'https://example.com/avatar.jpg'
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBe('https://example.com/avatar.jpg')
expect(avatar.props('icon')).toBeNull()
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://example.com/avatar.jpg'
)
expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument()
})
it('renders with default icon when no photo Url is provided', () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: undefined
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBeNull()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
})
it('renders with default icon when provided photo Url is null', () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: null
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBeNull()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
})
it('falls back to icon when image fails to load', async () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: 'https://example.com/broken-image.jpg'
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.props('icon')).toBeNull()
const img = screen.getByRole('img')
expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument()
// Simulate image load error
avatar.vm.$emit('error')
await fireEvent.error(img)
await nextTick()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
})
it('uses provided ariaLabel', () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: 'https://example.com/avatar.jpg',
ariaLabel: 'Custom Label'
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.attributes('aria-label')).toBe('Custom Label')
expect(screen.getByLabelText('Custom Label')).toBeInTheDocument()
})
it('falls back to i18n translation when no ariaLabel is provided', () => {
const wrapper = mountComponent({
renderComponent({
photoUrl: 'https://example.com/avatar.jpg'
})
const avatar = wrapper.findComponent(Avatar)
expect(avatar.attributes('aria-label')).toBe('User Avatar')
expect(screen.getByLabelText('User Avatar')).toBeInTheDocument()
})
})

View File

@@ -3,7 +3,12 @@
class="aspect-square bg-interface-panel-selected-surface"
:image="photoUrl ?? undefined"
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
:pt:icon:class="{ 'size-4': !hasAvatar }"
:pt="{
icon: {
class: { 'size-4': !hasAvatar },
'data-testid': 'avatar-icon'
}
}"
shape="circle"
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
@error="handleImageError"

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -50,19 +50,19 @@ describe('UserCredit', () => {
mockIsFetchingBalance.value = false
})
const mountComponent = (props = {}) => {
const renderComponent = (props = {}) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(UserCredit, {
return render(UserCredit, {
props,
global: {
plugins: [i18n],
stubs: {
Skeleton: true,
Skeleton: { template: '<div data-testid="skeleton" />' },
Tag: true
}
}
@@ -77,8 +77,8 @@ describe('UserCredit', () => {
currency: 'usd'
}
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Credits')
renderComponent()
expect(screen.getByText(/Credits/)).toBeInTheDocument()
})
it('uses effective_balance_micros when zero', () => {
@@ -88,8 +88,8 @@ describe('UserCredit', () => {
currency: 'usd'
}
const wrapper = mountComponent()
expect(wrapper.text()).toContain('0')
renderComponent()
expect(screen.getByText(/\b0\b/)).toBeInTheDocument()
})
it('uses effective_balance_micros when negative', () => {
@@ -99,8 +99,8 @@ describe('UserCredit', () => {
currency: 'usd'
}
const wrapper = mountComponent()
expect(wrapper.text()).toContain('-')
renderComponent()
expect(screen.getByText((text) => text.includes('-'))).toBeInTheDocument()
})
it('falls back to amount_micros when effective_balance_micros is missing', () => {
@@ -109,8 +109,8 @@ describe('UserCredit', () => {
currency: 'usd'
} as typeof mockBalance.value
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Credits')
renderComponent()
expect(screen.getByText(/Credits/)).toBeInTheDocument()
})
it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
@@ -118,8 +118,8 @@ describe('UserCredit', () => {
currency: 'usd'
} as typeof mockBalance.value
const wrapper = mountComponent()
expect(wrapper.text()).toContain('0')
renderComponent()
expect(screen.getByText(/\b0\b/)).toBeInTheDocument()
})
})
@@ -127,8 +127,8 @@ describe('UserCredit', () => {
it('shows skeleton when loading', () => {
mockIsFetchingBalance.value = true
const wrapper = mountComponent()
expect(wrapper.findComponent({ name: 'Skeleton' }).exists()).toBe(true)
renderComponent()
expect(screen.getAllByTestId('skeleton').length).toBeGreaterThan(0)
})
})
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -22,8 +22,8 @@ describe('ConfirmationDialogContent', () => {
setActivePinia(createPinia())
})
function mountComponent(props: Partial<Props> = {}) {
return mount(ConfirmationDialogContent, {
function renderComponent(props: Partial<Props> = {}) {
return render(ConfirmationDialogContent, {
global: {
plugins: [PrimeVue, i18n]
},
@@ -39,7 +39,7 @@ describe('ConfirmationDialogContent', () => {
it('renders long messages without breaking layout', () => {
const longFilename =
'workflow_checkpoint_' + 'a'.repeat(200) + '.safetensors'
const wrapper = mountComponent({ message: longFilename })
expect(wrapper.text()).toContain(longFilename)
renderComponent({ message: longFilename })
expect(screen.getByText(longFilename)).toBeInTheDocument()
})
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
@@ -17,8 +17,9 @@ vi.mock('@/composables/queue/useQueueProgress', () => ({
})
}))
const createWrapper = (props: { hidden?: boolean } = {}) =>
mount(QueueInlineProgress, { props })
function renderComponent(props: { hidden?: boolean } = {}) {
return render(QueueInlineProgress, { props })
}
describe('QueueInlineProgress', () => {
beforeEach(() => {
@@ -29,47 +30,53 @@ describe('QueueInlineProgress', () => {
it('renders when total progress is non-zero', () => {
mockProgress.totalPercent.value = 12
const wrapper = createWrapper()
renderComponent()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument()
})
it('renders when current node progress is non-zero', () => {
mockProgress.currentNodePercent.value = 33
const wrapper = createWrapper()
renderComponent()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument()
})
it('does not render when hidden', () => {
mockProgress.totalPercent.value = 45
const wrapper = createWrapper({ hidden: true })
renderComponent({ hidden: true })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
expect(
screen.queryByTestId('queue-inline-progress')
).not.toBeInTheDocument()
})
it('shows when progress becomes non-zero', async () => {
const wrapper = createWrapper()
renderComponent()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
expect(
screen.queryByTestId('queue-inline-progress')
).not.toBeInTheDocument()
mockProgress.totalPercent.value = 10
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument()
})
it('hides when progress returns to zero', async () => {
mockProgress.totalPercent.value = 10
const wrapper = createWrapper()
renderComponent()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
expect(screen.getByTestId('queue-inline-progress')).toBeInTheDocument()
mockProgress.totalPercent.value = 0
mockProgress.currentNodePercent.value = 0
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
expect(
screen.queryByTestId('queue-inline-progress')
).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,7 @@
<template>
<div
v-if="shouldShow"
data-testid="queue-inline-progress"
aria-hidden="true"
:class="
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -29,71 +29,77 @@ const i18n = createI18n({
}
})
const mountComponent = (notification: QueueNotificationBannerItem) =>
mount(QueueNotificationBanner, {
function renderComponent(notification: QueueNotificationBannerItem) {
return render(QueueNotificationBanner, {
props: { notification },
global: {
plugins: [i18n]
}
})
}
describe(QueueNotificationBanner, () => {
it('renders singular queued message without count prefix', () => {
const wrapper = mountComponent({
renderComponent({
type: 'queued',
count: 1
})
expect(wrapper.text()).toContain('Job added to queue')
expect(wrapper.text()).not.toContain('1 job')
expect(screen.getByText('Job added to queue')).toBeInTheDocument()
expect(screen.queryByText(/1 job/)).not.toBeInTheDocument()
})
it('renders queued message with pluralization', () => {
const wrapper = mountComponent({
renderComponent({
type: 'queued',
count: 2
})
expect(wrapper.text()).toContain('2 jobs added to queue')
expect(wrapper.html()).toContain('icon-[lucide--check]')
expect(screen.getByText('2 jobs added to queue')).toBeInTheDocument()
expect(screen.getByTestId('notification-icon')).toHaveClass(
'icon-[lucide--check]'
)
})
it('renders queued pending message with spinner icon', () => {
const wrapper = mountComponent({
renderComponent({
type: 'queuedPending',
count: 1
})
expect(wrapper.text()).toContain('Job queueing')
expect(wrapper.html()).toContain('icon-[lucide--loader-circle]')
expect(wrapper.html()).toContain('animate-spin')
expect(screen.getByText('Job queueing')).toBeInTheDocument()
const icon = screen.getByTestId('notification-icon')
expect(icon).toHaveClass('icon-[lucide--loader-circle]')
expect(icon).toHaveClass('animate-spin')
})
it('renders failed message and alert icon', () => {
const wrapper = mountComponent({
renderComponent({
type: 'failed',
count: 1
})
expect(wrapper.text()).toContain('Job failed')
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
expect(screen.getByText('Job failed')).toBeInTheDocument()
expect(screen.getByTestId('notification-icon')).toHaveClass(
'icon-[lucide--circle-alert]'
)
})
it('renders completed message with thumbnail preview when provided', () => {
const wrapper = mountComponent({
renderComponent({
type: 'completed',
count: 3,
thumbnailUrls: ['https://example.com/preview.png']
})
expect(wrapper.text()).toContain('3 jobs completed')
const image = wrapper.get('img')
expect(image.attributes('src')).toBe('https://example.com/preview.png')
expect(image.attributes('alt')).toBe('Preview')
expect(screen.getByText('3 jobs completed')).toBeInTheDocument()
const image = screen.getByRole('img')
expect(image).toHaveAttribute('src', 'https://example.com/preview.png')
expect(image).toHaveAttribute('alt', 'Preview')
})
it('renders two completion thumbnail previews', () => {
const wrapper = mountComponent({
renderComponent({
type: 'completed',
count: 4,
thumbnailUrls: [
@@ -102,18 +108,20 @@ describe(QueueNotificationBanner, () => {
]
})
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe(
const images = screen.getAllByRole('img')
expect(images).toHaveLength(2)
expect(images[0]).toHaveAttribute(
'src',
'https://example.com/preview-1.png'
)
expect(images[1].attributes('src')).toBe(
expect(images[1]).toHaveAttribute(
'src',
'https://example.com/preview-2.png'
)
})
it('caps completion thumbnail previews at two', () => {
const wrapper = mountComponent({
renderComponent({
type: 'completed',
count: 4,
thumbnailUrls: [
@@ -124,12 +132,14 @@ describe(QueueNotificationBanner, () => {
]
})
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe(
const images = screen.getAllByRole('img')
expect(images).toHaveLength(2)
expect(images[0]).toHaveAttribute(
'src',
'https://example.com/preview-1.png'
)
expect(images[1].attributes('src')).toBe(
expect(images[1]).toHaveAttribute(
'src',
'https://example.com/preview-2.png'
)
})

View File

@@ -41,6 +41,7 @@
v-else
:class="cn(iconClass, 'size-4', iconColorClass)"
aria-hidden="true"
data-testid="notification-icon"
/>
</div>
<div class="flex h-full items-center">

View File

@@ -1,5 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
@@ -38,10 +39,10 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
})
}
const mountComponent = (
function renderComponent(
runningTasks: TaskItemImpl[],
pendingTasks: TaskItemImpl[]
) => {
) {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
@@ -51,7 +52,9 @@ const mountComponent = (
queueStore.runningTasks = runningTasks
queueStore.pendingTasks = pendingTasks
const wrapper = mount(QueueProgressOverlay, {
const user = userEvent.setup()
render(QueueProgressOverlay, {
props: {
expanded: true
},
@@ -68,7 +71,7 @@ const mountComponent = (
}
})
return { wrapper, sidebarTabStore }
return { sidebarTabStore, user }
}
describe('QueueProgressOverlay', () => {
@@ -77,7 +80,7 @@ describe('QueueProgressOverlay', () => {
})
it('shows expanded header with running and queued labels', () => {
const { wrapper } = mountComponent(
renderComponent(
[
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
@@ -85,39 +88,32 @@ describe('QueueProgressOverlay', () => {
[createTask('pending-1', 'pending')]
)
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
expect(screen.getByTestId('expanded-title')).toHaveTextContent(
'2 running, 1 queued'
)
})
it('shows only running label when queued count is zero', () => {
const { wrapper } = mountComponent(
[createTask('running-1', 'in_progress')],
[]
)
renderComponent([createTask('running-1', 'in_progress')], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'1 running'
)
expect(screen.getByTestId('expanded-title')).toHaveTextContent('1 running')
})
it('shows job queue title when there are no active jobs', () => {
const { wrapper } = mountComponent([], [])
renderComponent([], [])
expect(wrapper.get('[data-testid="expanded-title"]').text()).toBe(
'Job Queue'
)
expect(screen.getByTestId('expanded-title')).toHaveTextContent('Job Queue')
})
it('toggles the assets sidebar tab when show-assets is clicked', async () => {
const { wrapper, sidebarTabStore } = mountComponent([], [])
const { sidebarTabStore, user } = renderComponent([], [])
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
await user.click(screen.getByTestId('show-assets-button'))
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await wrapper.get('[data-testid="show-assets-button"]').trigger('click')
await user.click(screen.getByTestId('show-assets-button'))
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
})

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
@@ -11,8 +11,8 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}))
describe('AudioThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(AudioThumbnail, {
function renderThumbnail(props = {}) {
return render(AudioThumbnail, {
props: {
src: '/test-audio.mp3',
...props
@@ -21,15 +21,9 @@ describe('AudioThumbnail', () => {
}
it('renders an audio element with correct src', () => {
const wrapper = mountThumbnail()
const audio = wrapper.find('audio')
expect(audio.exists()).toBe(true)
expect(audio.attributes('src')).toBe('/test-audio.mp3')
})
it('uses BaseThumbnail as container', () => {
const wrapper = mountThumbnail()
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.exists()).toBe(true)
renderThumbnail()
const audio = screen.getByTestId('audio-player')
expect(audio).toBeInTheDocument()
expect(audio).toHaveAttribute('src', '/test-audio.mp3')
})
})

View File

@@ -7,7 +7,13 @@
backgroundRepeat: 'round'
}"
>
<audio controls class="relative w-full" :src="src" @click.stop />
<audio
controls
class="relative w-full"
:src="src"
data-testid="audio-player"
@click.stop
/>
</div>
</BaseThumbnail>
</template>

View File

@@ -1,4 +1,4 @@
import { mount } from '@vue/test-utils'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
@@ -21,8 +21,8 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
}))
describe('DefaultThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(DefaultThumbnail, {
function renderThumbnail(props = {}) {
return render(DefaultThumbnail, {
props: {
src: '/test-image.jpg',
alt: 'Test Image',
@@ -33,95 +33,69 @@ describe('DefaultThumbnail', () => {
}
it('renders image with correct src and alt', () => {
const wrapper = mountThumbnail()
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('src')).toBe('/test-image.jpg')
expect(lazyImage.props('alt')).toBe('Test Image')
renderThumbnail()
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', '/test-image.jpg')
expect(img).toHaveAttribute('alt', 'Test Image')
})
it('applies scale transform when hovered', () => {
const wrapper = mountThumbnail({
renderThumbnail({
isHovered: true,
hoverZoom: 10
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
expect(screen.getByRole('img')).toHaveStyle({
transform: 'scale(1.1)'
})
})
it('does not apply scale transform when not hovered', () => {
const wrapper = mountThumbnail({
renderThumbnail({
isHovered: false
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toBeUndefined()
expect(screen.getByRole('img')).not.toHaveStyle({
transform: 'scale(1.1)'
})
})
it('applies video styling for video type', () => {
const wrapper = mountThumbnail({
renderThumbnail({
isVideo: true
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('w-full')
expect(classString).toContain('h-full')
expect(classString).toContain('object-cover')
expect(screen.getByRole('img')).toHaveClass(
'w-full',
'h-full',
'object-cover'
)
})
it('applies image styling for non-video type', () => {
const wrapper = mountThumbnail({
renderThumbnail({
isVideo: false
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('max-w-full')
expect(classString).toContain('object-contain')
expect(screen.getByRole('img')).toHaveClass('max-w-full', 'object-contain')
})
it('applies correct styling for webp images', () => {
const wrapper = mountThumbnail({
renderThumbnail({
src: '/test-video.webp',
isVideo: true
})
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('object-cover')
expect(screen.getByRole('img')).toHaveClass('object-cover')
})
it('image is not draggable', () => {
const wrapper = mountThumbnail()
const img = wrapper.find('img')
expect(img.attributes('draggable')).toBe('false')
renderThumbnail()
expect(screen.getByRole('img')).toHaveAttribute('draggable', 'false')
})
it('applies transition classes', () => {
const wrapper = mountThumbnail()
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('transform-gpu')
expect(classString).toContain('transition-transform')
expect(classString).toContain('duration-300')
expect(classString).toContain('ease-out')
})
it('passes correct props to BaseThumbnail', () => {
const wrapper = mountThumbnail({
hoverZoom: 20,
isHovered: true
})
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.props('hoverZoom')).toBe(20)
expect(baseThumbnail.props('isHovered')).toBe(true)
renderThumbnail()
expect(screen.getByRole('img')).toHaveClass(
'transform-gpu',
'transition-transform',
'duration-300',
'ease-out'
)
})
})

View File

@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { fireEvent, render, screen } from '@testing-library/vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { nextTick, ref } from 'vue'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
@@ -21,11 +21,11 @@ describe('LogoOverlay', () => {
return `/logos/${provider}.png`
}
function mountOverlay(
function renderOverlay(
logos: LogoInfo[],
props: Partial<LogoOverlayProps> = {}
) {
return mount(LogoOverlay, {
return render(LogoOverlay, {
props: {
logos,
getLogoUrl: mockGetLogoUrl,
@@ -35,123 +35,113 @@ describe('LogoOverlay', () => {
}
it('renders nothing when logos array is empty', () => {
const wrapper = mountOverlay([])
expect(wrapper.findAll('img')).toHaveLength(0)
renderOverlay([])
expect(screen.queryAllByRole('img')).toHaveLength(0)
})
it('renders a single logo with correct src and alt', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('/logos/Google.png')
expect(img.attributes('alt')).toBe('Google')
renderOverlay([{ provider: 'Google' }])
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', '/logos/Google.png')
expect(img).toHaveAttribute('alt', 'Google')
})
it('renders multiple separate logo entries', () => {
const wrapper = mountOverlay([
renderOverlay([
{ provider: 'Google' },
{ provider: 'OpenAI' },
{ provider: 'Stability' }
])
expect(wrapper.findAll('img')).toHaveLength(3)
expect(screen.getAllByRole('img')).toHaveLength(3)
})
it('displays provider name as label for single provider', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const span = wrapper.find('span')
expect(span.text()).toBe('Google')
renderOverlay([{ provider: 'Google' }])
expect(screen.getByText('Google')).toBeInTheDocument()
})
it('images are not draggable', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.attributes('draggable')).toBe('false')
renderOverlay([{ provider: 'Google' }])
expect(screen.getByRole('img')).toHaveAttribute('draggable', 'false')
})
it('filters out logos with empty URLs', () => {
function getLogoUrl(provider: string) {
return provider === 'Google' ? '/logos/Google.png' : ''
}
const wrapper = mount(LogoOverlay, {
render(LogoOverlay, {
props: {
logos: [{ provider: 'Google' }, { provider: 'Unknown' }],
getLogoUrl
}
})
expect(wrapper.findAll('img')).toHaveLength(1)
expect(screen.getAllByRole('img')).toHaveLength(1)
})
it('renders one logo per unique provider', () => {
const wrapper = mountOverlay([
{ provider: 'Google' },
{ provider: 'OpenAI' }
])
expect(wrapper.findAll('img')).toHaveLength(2)
renderOverlay([{ provider: 'Google' }, { provider: 'OpenAI' }])
expect(screen.getAllByRole('img')).toHaveLength(2)
})
describe('stacked logos', () => {
it('renders multiple providers as stacked overlapping logos', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = wrapper.findAll('img')
renderOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = screen.getAllByRole('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('alt')).toBe('WaveSpeed')
expect(images[1].attributes('alt')).toBe('Hunyuan')
expect(images[0]).toHaveAttribute('alt', 'WaveSpeed')
expect(images[1]).toHaveAttribute('alt', 'Hunyuan')
})
it('joins provider names with locale-aware conjunction for default label', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const span = wrapper.find('span')
expect(span.text()).toBe('WaveSpeed and Hunyuan')
renderOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
expect(screen.getByText('WaveSpeed and Hunyuan')).toBeInTheDocument()
})
it('uses custom label when provided', () => {
const wrapper = mountOverlay([
renderOverlay([
{ provider: ['WaveSpeed', 'Hunyuan'], label: 'Custom Label' }
])
const span = wrapper.find('span')
expect(span.text()).toBe('Custom Label')
expect(screen.getByText('Custom Label')).toBeInTheDocument()
})
it('applies negative gap for overlap effect', () => {
const wrapper = mountOverlay([
{ provider: ['WaveSpeed', 'Hunyuan'], gap: -8 }
])
const images = wrapper.findAll('img')
expect(images[1].attributes('style')).toContain('margin-left: -8px')
renderOverlay([{ provider: ['WaveSpeed', 'Hunyuan'], gap: -8 }])
const images = screen.getAllByRole('img')
expect(images[1]).toHaveStyle({ marginLeft: '-8px' })
})
it('applies default gap when not specified', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = wrapper.findAll('img')
expect(images[1].attributes('style')).toContain('margin-left: -6px')
renderOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = screen.getAllByRole('img')
expect(images[1]).toHaveStyle({ marginLeft: '-6px' })
})
it('filters out invalid providers from stacked logos', () => {
function getLogoUrl(provider: string) {
return provider === 'WaveSpeed' ? '/logos/WaveSpeed.png' : ''
}
const wrapper = mount(LogoOverlay, {
render(LogoOverlay, {
props: {
logos: [{ provider: ['WaveSpeed', 'Unknown'] }],
getLogoUrl
}
})
expect(wrapper.findAll('img')).toHaveLength(1)
expect(wrapper.find('span').text()).toBe('WaveSpeed')
expect(screen.getAllByRole('img')).toHaveLength(1)
expect(screen.getByText('WaveSpeed')).toBeInTheDocument()
})
})
describe('error handling', () => {
it('keeps showing remaining providers when one image fails in stacked logos', async () => {
const wrapper = mountOverlay([{ provider: ['Google', 'OpenAI'] }])
const images = wrapper.findAll('[data-testid="logo-img"]')
renderOverlay([{ provider: ['Google', 'OpenAI'] }])
const images = screen.getAllByTestId('logo-img')
expect(images).toHaveLength(2)
await images[0].trigger('error')
await nextTick()
await fireEvent.error(images[0])
const remainingImages = wrapper.findAll('[data-testid="logo-img"]')
const remainingImages = screen.getAllByTestId('logo-img')
expect(remainingImages).toHaveLength(2)
expect(remainingImages[1].attributes('alt')).toBe('OpenAI')
expect(remainingImages[1]).toHaveAttribute('alt', 'OpenAI')
})
})
})

View File

@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import Popover from 'primevue/popover'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
@@ -24,26 +24,27 @@ describe('TopbarBadge', () => {
variant: 'info'
}
const mountTopbarBadge = (
function renderTopbarBadge(
badge: Partial<TopbarBadgeType> = {},
displayMode: 'full' | 'compact' | 'icon-only' = 'full'
) => {
return mount(TopbarBadge, {
) {
const user = userEvent.setup()
const result = render(TopbarBadge, {
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip },
components: { Popover }
directives: { tooltip: Tooltip }
},
props: {
badge: { ...exampleBadge, ...badge },
displayMode
}
})
return { ...result, user }
}
describe('full display mode', () => {
it('renders all badge elements (icon, label, text)', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Comfy Cloud',
label: 'BETA',
@@ -51,30 +52,28 @@ describe('TopbarBadge', () => {
},
'full'
)
expect(wrapper.find('.pi-cloud').exists()).toBe(true)
expect(wrapper.text()).toContain('BETA')
expect(wrapper.text()).toContain('Comfy Cloud')
expect(screen.getByTestId('badge-icon')).toHaveClass('pi-cloud')
expect(screen.getByText('BETA')).toBeInTheDocument()
expect(screen.getByText('Comfy Cloud')).toBeInTheDocument()
})
it('renders without icon when not provided', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Test',
label: 'NEW'
},
'full'
)
expect(wrapper.find('i').exists()).toBe(false)
expect(wrapper.text()).toContain('NEW')
expect(wrapper.text()).toContain('Test')
expect(screen.queryByTestId('badge-icon')).not.toBeInTheDocument()
expect(screen.getByText('NEW')).toBeInTheDocument()
expect(screen.getByText('Test')).toBeInTheDocument()
})
})
describe('compact display mode', () => {
it('renders icon and label but not text', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Hidden Text',
label: 'BETA',
@@ -82,32 +81,28 @@ describe('TopbarBadge', () => {
},
'compact'
)
expect(wrapper.find('.pi-cloud').exists()).toBe(true)
expect(wrapper.text()).toContain('BETA')
expect(wrapper.text()).not.toContain('Hidden Text')
expect(screen.getByTestId('badge-icon')).toHaveClass('pi-cloud')
expect(screen.getByText('BETA')).toBeInTheDocument()
expect(screen.queryByText('Hidden Text')).not.toBeInTheDocument()
})
it('opens popover on click', async () => {
const wrapper = mountTopbarBadge(
it('reveals full text when clicked', async () => {
const { user } = renderTopbarBadge(
{
text: 'Full Text',
label: 'ALERT'
},
'compact'
)
const clickableArea = wrapper.find('[class*="flex h-full"]')
await clickableArea.trigger('click')
const popover = wrapper.findComponent(Popover)
expect(popover.exists()).toBe(true)
expect(screen.queryByText('Full Text')).not.toBeInTheDocument()
await user.click(screen.getByText('ALERT'))
expect(screen.getByText('Full Text')).toBeInTheDocument()
})
})
describe('icon-only display mode', () => {
it('renders only icon', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Hidden Text',
label: 'BETA',
@@ -115,29 +110,27 @@ describe('TopbarBadge', () => {
},
'icon-only'
)
expect(wrapper.find('.pi-cloud').exists()).toBe(true)
expect(wrapper.text()).not.toContain('BETA')
expect(wrapper.text()).not.toContain('Hidden Text')
expect(screen.getByTestId('badge-icon')).toHaveClass('pi-cloud')
expect(screen.queryByText('BETA')).not.toBeInTheDocument()
expect(screen.queryByText('Hidden Text')).not.toBeInTheDocument()
})
it('renders label when no icon provided', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Hidden Text',
label: 'NEW'
},
'icon-only'
)
expect(wrapper.text()).toContain('NEW')
expect(wrapper.text()).not.toContain('Hidden Text')
expect(screen.getByText('NEW')).toBeInTheDocument()
expect(screen.queryByText('Hidden Text')).not.toBeInTheDocument()
})
})
describe('badge variants', () => {
it('applies error variant styles', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Error Message',
label: 'ERROR',
@@ -145,13 +138,12 @@ describe('TopbarBadge', () => {
},
'full'
)
expect(wrapper.find('.bg-danger-100').exists()).toBe(true)
expect(wrapper.find('.text-danger-100').exists()).toBe(true)
expect(screen.getByText('ERROR')).toHaveClass('bg-danger-100')
expect(screen.getByText('Error Message')).toHaveClass('text-danger-100')
})
it('applies warning variant styles', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Warning Message',
label: 'WARN',
@@ -159,65 +151,53 @@ describe('TopbarBadge', () => {
},
'full'
)
expect(wrapper.find('.bg-gold-600').exists()).toBe(true)
expect(wrapper.find('.text-warning-background').exists()).toBe(true)
expect(screen.getByText('WARN')).toHaveClass('bg-gold-600')
expect(screen.getByText('Warning Message')).toHaveClass(
'text-warning-background'
)
})
it('uses default error icon for error variant', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Error',
variant: 'error'
},
'full'
)
expect(wrapper.find('.pi-exclamation-circle').exists()).toBe(true)
expect(screen.getByTestId('badge-icon')).toHaveClass(
'pi-exclamation-circle'
)
})
it('uses default warning icon for warning variant', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Warning',
variant: 'warning'
},
'full'
)
expect(wrapper.find('.icon-\\[lucide--triangle-alert\\]').exists()).toBe(
true
expect(screen.getByTestId('badge-icon')).toHaveClass(
'icon-[lucide--triangle-alert]'
)
})
})
describe('popover', () => {
it('includes popover component in compact and icon-only modes', () => {
const compactWrapper = mountTopbarBadge({}, 'compact')
const iconOnlyWrapper = mountTopbarBadge({}, 'icon-only')
const fullWrapper = mountTopbarBadge({}, 'full')
expect(compactWrapper.findComponent(Popover).exists()).toBe(true)
expect(iconOnlyWrapper.findComponent(Popover).exists()).toBe(true)
expect(fullWrapper.findComponent(Popover).exists()).toBe(false)
})
})
describe('edge cases', () => {
it('handles badge with only text', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Simple Badge'
},
'full'
)
expect(wrapper.text()).toContain('Simple Badge')
expect(wrapper.find('i').exists()).toBe(false)
expect(screen.getByText('Simple Badge')).toBeInTheDocument()
expect(screen.queryByTestId('badge-icon')).not.toBeInTheDocument()
})
it('handles custom icon override', () => {
const wrapper = mountTopbarBadge(
renderTopbarBadge(
{
text: 'Custom',
variant: 'error',
@@ -225,9 +205,10 @@ describe('TopbarBadge', () => {
},
'full'
)
expect(wrapper.find('.pi-custom-icon').exists()).toBe(true)
expect(wrapper.find('.pi-exclamation-circle').exists()).toBe(false)
expect(screen.getByTestId('badge-icon')).toHaveClass('pi-custom-icon')
expect(screen.getByTestId('badge-icon')).not.toHaveClass(
'pi-exclamation-circle'
)
})
})
})

View File

@@ -9,6 +9,7 @@
>
<i
v-if="iconClass"
data-testid="badge-icon"
:class="['shrink-0 text-base', iconClass, iconColorClass]"
/>
<div
@@ -62,6 +63,7 @@
>
<i
v-if="iconClass"
data-testid="badge-icon"
:class="['shrink-0 text-base', iconClass, iconColorClass]"
/>
<div
@@ -108,6 +110,7 @@
>
<i
v-if="iconClass"
data-testid="badge-icon"
:class="['shrink-0 text-base', iconClass, iconColorClass]"
/>
<div

View File

@@ -1,49 +0,0 @@
import type { RenderResult } from '@testing-library/vue'
import { render } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { ComponentMountingOptions } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
function createDefaultPlugins() {
return [
PrimeVue,
createTestingPinia({ stubActions: false }),
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
}
function renderWithDefaults<C>(
component: C,
options?: ComponentMountingOptions<C> & { setupUser?: boolean }
): RenderResult & { user: ReturnType<typeof userEvent.setup> | undefined } {
const { setupUser = true, global: globalOptions, ...rest } = options ?? {}
const user = setupUser ? userEvent.setup() : undefined
const result = render(
component as Parameters<typeof render>[0],
{
global: {
plugins: [...createDefaultPlugins(), ...(globalOptions?.plugins ?? [])],
stubs: globalOptions?.stubs,
directives: globalOptions?.directives
},
...rest
} as Parameters<typeof render>[1]
)
return {
...result,
user
}
}
export { renderWithDefaults as render }
export { screen } from '@testing-library/vue'