mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
test: migrate 13 component tests from VTU to VTL (Phase 1) (#10471)
## Summary Migrate 13 component test files from @vue/test-utils to @testing-library/vue as Phase 1 of incremental VTL adoption. ## Changes - **What**: Rewrite 13 test files (88 tests) to use `render`/`screen` queries, `userEvent` interactions, and `jest-dom` assertions. Add `data-testid` attributes to 6 components for lint-clean icon/element queries. Delete unused `src/utils/test-utils.ts`. - **Dependencies**: `@testing-library/vue`, `@testing-library/user-event`, `@testing-library/jest-dom` (installed in Phase 0) ## Review Focus - `data-testid` additions to component templates are minimal and non-behavioral - PrimeVue passthrough (`pt`) usage in UserAvatar.vue for icon testid - 2 targeted `eslint-disable` in FormRadioGroup.test.ts where PrimeVue places `aria-describedby` on wrapper div, not input ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10471-test-migrate-13-component-tests-from-VTU-to-VTL-Phase-1-32d6d73d36508159a33ffa285afb4c38) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -208,7 +208,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
3. Keep your module mocks contained
|
||||
Do not use global mutable state within the test file
|
||||
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
|
||||
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
4. For Component testing, prefer [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) with `@testing-library/user-event` for user-centric, behavioral tests. [Vue Test Utils](https://test-utils.vuejs.org/) is also accepted, especially for tests that need direct access to the component wrapper (e.g., `findComponent`, `emitted()`). Follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
5. Aim for behavioral coverage of critical and new features
|
||||
|
||||
### Playwright / Browser / E2E Tests
|
||||
|
||||
@@ -29,7 +29,9 @@ The ComfyUI Frontend project uses **colocated tests** - test files are placed al
|
||||
Our tests use the following frameworks and libraries:
|
||||
|
||||
- [Vitest](https://vitest.dev/) - Test runner and assertion library
|
||||
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities
|
||||
- [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) - Preferred for user-centric component testing
|
||||
- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/) - Realistic user interaction simulation
|
||||
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities (also accepted)
|
||||
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
useQueueSettingsStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
import { render, screen } from '@/utils/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
|
||||
@@ -89,8 +90,9 @@ const stubs = {
|
||||
|
||||
function renderQueueButton() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const user = userEvent.setup()
|
||||
|
||||
return render(ComfyQueueButton, {
|
||||
const result = render(ComfyQueueButton, {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
directives: {
|
||||
@@ -99,6 +101,8 @@ function renderQueueButton() {
|
||||
stubs
|
||||
}
|
||||
})
|
||||
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('ComfyQueueButton', () => {
|
||||
@@ -148,7 +152,7 @@ describe('ComfyQueueButton', () => {
|
||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||
await nextTick()
|
||||
|
||||
await user!.click(screen.getByTestId('queue-button'))
|
||||
await user.click(screen.getByTestId('queue-button'))
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
@@ -167,7 +171,7 @@ describe('ComfyQueueButton', () => {
|
||||
queueSettingsStore.mode = 'instant-idle'
|
||||
await nextTick()
|
||||
|
||||
await user!.click(screen.getByTestId('queue-button'))
|
||||
await user.click(screen.getByTestId('queue-button'))
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-running')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(await screen.findByText('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,55 @@ 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'
|
||||
text: 'Simple Badge',
|
||||
label: undefined
|
||||
},
|
||||
'full'
|
||||
)
|
||||
|
||||
expect(wrapper.text()).toContain('Simple Badge')
|
||||
expect(wrapper.find('i').exists()).toBe(false)
|
||||
expect(screen.getByText('Simple Badge')).toBeInTheDocument()
|
||||
expect(screen.queryByText('BETA')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('badge-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles custom icon override', () => {
|
||||
const wrapper = mountTopbarBadge(
|
||||
renderTopbarBadge(
|
||||
{
|
||||
text: 'Custom',
|
||||
variant: 'error',
|
||||
@@ -225,9 +207,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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user