Skip to content

Instantly share code, notes, and snippets.

@dedemenezes
Created September 12, 2025 07:44
Show Gist options
  • Select an option

  • Save dedemenezes/36f504df8440b2da5c05896edf430078 to your computer and use it in GitHub Desktop.

Select an option

Save dedemenezes/36f504df8440b2da5c05896edf430078 to your computer and use it in GitHub Desktop.
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/features/filters/SearchFilter.vue
raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/features/filters/SearchBar.vue
raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/common/cards/SessionCard.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/layout/InfiniteScrollLayout.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/lib/utils.js
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/lib/filterUtils.js
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/features/filters/composables/useMobileTrigger.js
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/AccordionGroup.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/ui/SelectComponent.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/ui/ComboboxComponent.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/features/filters/MobileTrigger.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/common/tags/TagFilter.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/features/filters/SearchBar.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/features/filters/SearchFilter.vue
raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/features/filters/ResponsiveFilterMenu.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/components/features/filters/ProgramsFilterForm.vue
https://raw.githubusercontent.com/dedemenezes/riff-inertia/refs/heads/main/app/frontend/pages/ProgramPage.vue
// ==============================================================================
// COMPREHENSIVE FILTER SYSTEM TEST SUITE
// ==============================================================================
//
// This test suite covers:
// 1. Unit tests for individual components
// 2. Integration tests for component communication
// 3. End-to-end filter workflows
// 4. Edge cases and error handling
// 5. Performance considerations (debouncing)
// 6. Mobile/desktop responsive behavior
//
// Test files should be organized as:
// - tests/unit/components/filters/
// - tests/integration/filters/
// - tests/utils/
// - tests/e2e/filters/
// ==============================================================================
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount, shallowMount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { router } from '@inertiajs/vue3'
// ==============================================================================
// MOCK UTILITIES AND SETUP
// ==============================================================================
// Mock Inertia router
vi.mock('@inertiajs/vue3', () => ({
router: {
get: vi.fn(),
visit: vi.fn()
}
}))
// Mock icons
vi.mock('@/components/common/icons', () => ({
IconSearch: { template: '<div data-testid="search-icon"></div>' },
IconClose: { template: '<div data-testid="close-icon"></div>' },
IconFilter: { template: '<div data-testid="filter-icon"></div>' },
IconCarretUp: { template: '<div data-testid="carret-icon"></div>' },
IconPin: { template: '<div data-testid="pin-icon"></div>' }
}))
// Mock shadcn/ui components
vi.mock('@/components/ui/select', () => ({
Select: { template: '<div data-testid="select"><slot /></div>' },
SelectContent: { template: '<div data-testid="select-content"><slot /></div>' },
SelectGroup: { template: '<div data-testid="select-group"><slot /></div>' },
SelectItem: { template: '<div data-testid="select-item" @click="$emit(\'select\')"><slot /></div>' },
SelectTrigger: { template: '<div data-testid="select-trigger"><slot /></div>' },
SelectValue: { template: '<div data-testid="select-value"><slot /></div>' }
}))
// Test data fixtures
const mockFilterOptions = {
mostras: [
{ filter_display: 'Mostra Principal', filter_value: 1, filter_label: 'Mostra' },
{ filter_display: 'Mostra Alternativa', filter_value: 2, filter_label: 'Mostra' }
],
cinemas: [
{ filter_display: 'Cinema Odeon', filter_value: 1, filter_label: 'Cinema' },
{ filter_display: 'Cine Santa Teresa', filter_value: 2, filter_label: 'Cinema' }
],
paises: [
{ filter_display: 'Brasil', filter_value: 1, filter_label: 'País' },
{ filter_display: 'França', filter_value: 2, filter_label: 'País' }
],
genres: [
{ filter_display: 'Drama', filter_value: 1, filter_label: 'Gênero' },
{ filter_display: 'Comédia', filter_value: 2, filter_label: 'Gênero' }
],
sessoes: [
{ filter_display: '14:00', filter_value: '14:00', filter_label: 'Sessão' },
{ filter_display: '19:00', filter_value: '19:00', filter_label: 'Sessão' }
],
directors: [
{ filter_display: 'Pedro Almodóvar', filter_value: 1, filter_label: 'Direção' },
{ filter_display: 'Denis Villeneuve', filter_value: 2, filter_label: 'Direção' }
],
actors: [
{ filter_display: 'Wagner Moura', filter_value: 1, filter_label: 'Elenco' },
{ filter_display: 'Fernanda Montenegro', filter_value: 2, filter_label: 'Elenco' }
]
}
const mockSessionData = [
{
id: 1,
titulo: 'Test Movie 1',
genero: 'Drama',
paises: 'Brasil',
duracao: 120,
cinema: 'Cinema Odeon',
mostra: 'Mostra Principal',
sessao: ['14:00', '19:00'],
imagem: 'test-poster-1.jpg'
},
{
id: 2,
titulo: 'Test Movie 2',
genero: 'Comédia',
paises: 'França',
duracao: 95,
cinema: 'Cine Santa Teresa',
mostra: 'Mostra Alternativa',
sessao: ['16:00'],
imagem: 'test-poster-2.jpg'
}
]
const mockPagyData = {
page: 1,
last: 3,
count: 50,
items: 20
}
// ==============================================================================
// UTILITY FUNCTION TESTS
// ==============================================================================
describe('Filter Utility Functions', () => {
// Import actual utilities
const { cleanObject, toHHMM } = await import('@/lib/utils')
const { extractFilterValues } = await import('@/lib/filterUtils')
describe('cleanObject', () => {
it('should remove null, undefined, and empty string values', () => {
const input = {
valid: 'value',
nullValue: null,
undefinedValue: undefined,
emptyString: '',
zero: 0,
false: false
}
const result = cleanObject(input)
expect(result).toEqual({
valid: 'value',
zero: 0,
false: false
})
})
it('should return empty object for all invalid values', () => {
const input = {
nullValue: null,
undefinedValue: undefined,
emptyString: ''
}
const result = cleanObject(input)
expect(result).toEqual({})
})
})
describe('toHHMM', () => {
it('should format time strings correctly', () => {
expect(toHHMM('14:30')).toBe('14:30')
expect(toHHMM('9:5')).toBe('9:5')
expect(toHHMM('14')).toBe('14:00')
expect(toHHMM('')).toBe('00:00')
})
})
describe('extractFilterValues', () => {
it('should extract filter_value from object filters', () => {
const input = {
mostra: { filter_value: 1, filter_display: 'Mostra Principal' },
cinema: { filter_value: 2, filter_display: 'Cinema Odeon' },
query: 'search term'
}
const result = extractFilterValues(input)
expect(result).toEqual({
mostra: 1,
cinema: 2,
query: 'search term'
})
})
it('should handle null values correctly', () => {
const input = {
mostra: null,
cinema: { filter_value: 2, filter_display: 'Cinema Odeon' },
query: null
}
const result = extractFilterValues(input)
expect(result).toEqual({
cinema: 2
})
})
it('should handle edge cases', () => {
const input = {
invalidObject: { not_filter_value: 'invalid' },
emptyObject: {},
stringQuery: 'direct string'
}
const result = extractFilterValues(input)
expect(result).toEqual({})
})
})
})
// ==============================================================================
// COMPOSABLE TESTS
// ==============================================================================
describe('useMobileTrigger Composable', () => {
let composable
beforeEach(async () => {
// Reset DOM
document.body.style.overflow = ''
const { useMobileTrigger } = await import('@/components/features/filters/composables/useMobileTrigger')
composable = useMobileTrigger()
})
it('should initialize with closed state', () => {
expect(composable.isFilterMenuOpen.value).toBe(false)
})
it('should open menu and lock body scroll', () => {
composable.openMenu()
expect(composable.isFilterMenuOpen.value).toBe(true)
expect(document.body.style.overflow).toBe('hidden')
})
it('should close menu and restore body scroll', () => {
composable.openMenu()
composable.closeMenu()
expect(composable.isFilterMenuOpen.value).toBe(false)
expect(document.body.style.overflow).toBe('')
})
})
// ==============================================================================
// COMPONENT UNIT TESTS
// ==============================================================================
describe('SearchBar Component', () => {
let SearchBar
beforeEach(async () => {
SearchBar = (await import('@/components/features/filters/SearchBar.vue')).default
})
it('should render search input with correct placeholder', () => {
const wrapper = mount(SearchBar, {
props: { modelValue: '' }
})
const input = wrapper.find('input[type="text"]')
expect(input.exists()).toBe(true)
expect(input.attributes('placeholder')).toBe('Pesquisar')
})
it('should emit update:modelValue on input with debouncing', async () => {
vi.useFakeTimers()
const wrapper = mount(SearchBar, {
props: { modelValue: '' }
})
const input = wrapper.find('input')
await input.setValue('test query')
// Should not emit immediately
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
// Fast forward debounce timer
vi.advanceTimersByTime(300)
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['test query'])
vi.useRealTimers()
})
it('should emit search on Enter key press', async () => {
const wrapper = mount(SearchBar, {
props: { modelValue: 'test query' }
})
const input = wrapper.find('input')
await input.trigger('keyup.enter')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')[0]).toEqual(['test query'])
})
it('should show clear button when input has value', async () => {
const wrapper = mount(SearchBar, {
props: { modelValue: 'test' }
})
const clearButton = wrapper.find('[data-testid="close-icon"]')
expect(clearButton.exists()).toBe(true)
})
it('should clear input and emit clear event', async () => {
const wrapper = mount(SearchBar, {
props: { modelValue: 'test' }
})
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual([null])
expect(wrapper.emitted('clear')).toBeTruthy()
})
})
describe('AccordionGroup Component', () => {
let AccordionGroup
beforeEach(async () => {
AccordionGroup = (await import('@/components/AccordionGroup.vue')).default
})
it('should render with correct title', () => {
const wrapper = mount(AccordionGroup, {
props: { text: 'Test Accordion' },
slots: { content: '<div>Test Content</div>' }
})
expect(wrapper.text()).toContain('Test Accordion')
})
it('should be closed by default', () => {
const wrapper = mount(AccordionGroup, {
props: { text: 'Test Accordion' }
})
const details = wrapper.find('details')
expect(details.attributes('open')).toBeFalsy()
})
it('should be open when isOpen prop is true', () => {
const wrapper = mount(AccordionGroup, {
props: { text: 'Test Accordion', isOpen: true }
})
const details = wrapper.find('details')
expect(details.attributes('open')).toBe('')
})
})
describe('MobileTrigger Component', () => {
let MobileTrigger
beforeEach(async () => {
MobileTrigger = (await import('@/components/features/filters/MobileTrigger.vue')).default
})
it('should render filter button', () => {
const wrapper = mount(MobileTrigger)
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.text()).toContain('Filtros')
expect(wrapper.find('[data-testid="filter-icon"]').exists()).toBe(true)
})
it('should emit open-menu on click', async () => {
const wrapper = mount(MobileTrigger)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('open-menu')).toBeTruthy()
})
})
describe('TagFilter Component', () => {
let TagFilter
beforeEach(async () => {
TagFilter = (await import('@/components/common/tags/TagFilter.vue')).default
})
const mockFilter = {
filter_display: 'Brasil',
filter_value: 1,
filter_label: 'País'
}
it('should render filter text and close button', () => {
const wrapper = mount(TagFilter, {
props: {
text: 'Brasil',
filter: mockFilter
}
})
expect(wrapper.text()).toContain('Brasil')
expect(wrapper.find('[data-testid="close-icon"]').exists()).toBe(true)
})
it('should emit remove-filter when close button is clicked', async () => {
const wrapper = mount(TagFilter, {
props: {
text: 'Brasil',
filter: mockFilter
}
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('remove-filter')).toBeTruthy()
expect(wrapper.emitted('remove-filter')[0]).toEqual([mockFilter])
})
it('should have proper accessibility attributes', () => {
const wrapper = mount(TagFilter, {
props: {
text: 'Brasil',
filter: mockFilter
}
})
const span = wrapper.find('span')
const button = wrapper.find('button')
expect(span.attributes('aria-label')).toBe('Filter: Brasil')
expect(button.attributes('aria-label')).toBe('Remove Brasil filter')
})
})
// ==============================================================================
// COMPLEX COMPONENT TESTS
// ==============================================================================
describe('SearchFilter Component', () => {
let SearchFilter
beforeEach(async () => {
SearchFilter = (await import('@/components/features/filters/SearchFilter.vue')).default
})
const mockModelValue = {
query: null,
mostra: null,
cinema: null,
pais: null,
genre: null
}
it('should render slot content correctly', () => {
const wrapper = mount(SearchFilter, {
props: { modelValue: mockModelValue },
slots: {
filters: '<div data-testid="filter-content">Filter Content</div>'
}
})
expect(wrapper.find('[data-testid="filter-content"]').exists()).toBe(true)
})
it('should provide correct slot props', () => {
let slotProps = null
const wrapper = mount(SearchFilter, {
props: { modelValue: mockModelValue },
slots: {
filters: (props) => {
slotProps = props
return '<div>Slot content</div>'
}
}
})
expect(slotProps).toHaveProperty('modelValue')
expect(slotProps).toHaveProperty('updateField')
expect(typeof slotProps.updateField).toBe('function')
})
it('should detect active filters correctly', async () => {
const activeFilters = {
query: { filter_value: 'test', filter_display: 'test' },
mostra: null,
cinema: null,
pais: null
}
let hasActiveFilters = null
const wrapper = mount(SearchFilter, {
props: { modelValue: activeFilters },
slots: {
actions: (props) => {
hasActiveFilters = props.hasActiveFilters
return '<div>Actions</div>'
}
}
})
expect(hasActiveFilters).toBe(true)
})
it('should emit filtersApplied with cleaned data', async () => {
const mockFilters = {
query: { filter_value: 'test', filter_display: 'test' },
mostra: { filter_value: 1, filter_display: 'Mostra Principal' },
cinema: null,
startTime: '14:30',
endTime: null
}
let slotProps = null
const wrapper = mount(SearchFilter, {
props: { modelValue: mockFilters },
slots: {
actions: (props) => {
slotProps = props
return '<button @click="props.applyFilters">Apply</button>'
}
}
})
// Trigger apply filters
slotProps.applyFilters()
expect(wrapper.emitted('filtersApplied')).toBeTruthy()
const emittedData = wrapper.emitted('filtersApplied')[0][0]
// Should clean out null values and format time
expect(emittedData).toEqual({
query: { filter_value: 'test', filter_display: 'test' },
mostra: { filter_value: 1, filter_display: 'Mostra Principal' },
startTime: '14:30'
})
})
it('should clear all filters', async () => {
const activeFilters = {
query: { filter_value: 'test' },
mostra: { filter_value: 1 }
}
let slotProps = null
const wrapper = mount(SearchFilter, {
props: { modelValue: activeFilters },
slots: {
actions: (props) => {
slotProps = props
return '<button @click="props.clearAllFilters">Clear</button>'
}
}
})
slotProps.clearAllFilters()
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('filtersCleared')).toBeTruthy()
const clearedData = wrapper.emitted('update:modelValue')[0][0]
expect(Object.values(clearedData).every(value => value === null)).toBe(true)
})
})
describe('ProgramsFilterForm Component', () => {
let ProgramsFilterForm
beforeEach(async () => {
// Mock async component
vi.doMock('@/components/ui/ComboboxComponent.vue', () => ({
default: { template: '<div data-testid="combobox"><slot /></div>' }
}))
ProgramsFilterForm = (await import('@/components/features/filters/ProgramsFilterForm.vue')).default
})
const defaultProps = {
modelValue: {
query: null,
sessao: null,
mostra: null,
cinema: null,
genre: null,
pais: null,
'direção': null,
elenco: null
},
updateField: vi.fn(),
...mockFilterOptions
}
it('should render all filter sections', () => {
const wrapper = mount(ProgramsFilterForm, { props: defaultProps })
// Check for SearchBar
expect(wrapper.findComponent({ name: 'SearchBar' })).toBeTruthy()
// Check for accordion sections
const accordions = wrapper.findAllComponents({ name: 'AccordionGroup' })
expect(accordions.length).toBeGreaterThan(0)
})
it('should call updateField when search query changes', async () => {
const updateField = vi.fn()
const wrapper = mount(ProgramsFilterForm, {
props: { ...defaultProps, updateField }
})
const searchBar = wrapper.findComponent({ name: 'SearchBar' })
await searchBar.vm.$emit('update:modelValue', 'test query')
expect(updateField).toHaveBeenCalledWith('query', {
filter_display: 'test query',
filter_value: 'test query'
})
})
it('should open accordions for active filters', () => {
const activeFilters = {
...defaultProps.modelValue,
mostra: { filter_value: 1, filter_display: 'Mostra Principal' }
}
const wrapper = mount(ProgramsFilterForm, {
props: { ...defaultProps, modelValue: activeFilters }
})
const accordions = wrapper.findAllComponents({ name: 'AccordionGroup' })
const mostraAccordion = accordions.find(accordion =>
accordion.props('text') === 'Mostra'
)
expect(mostraAccordion.props('isOpen')).toBe(true)
})
})
// ==============================================================================
// INTEGRATION TESTS
// ==============================================================================
describe('Filter System Integration', () => {
let ProgramPage
let mockRouterGet
beforeEach(async () => {
mockRouterGet = vi.fn()
router.get = mockRouterGet
ProgramPage = (await import('@/pages/ProgramPage.vue')).default
})
const defaultPageProps = {
tabBaseUrl: '/programacao',
items: [],
elements: mockSessionData,
pagy: mockPagyData,
menuTabs: [
{ name: 'Programação', url: '/programacao', active: true }
],
current_filters: {},
has_active_filters: false,
crumbs: [
{ name: 'Home', url: '/' },
{ name: 'Programação', url: '/programacao' }
],
...mockFilterOptions
}
it('should initialize filters correctly', () => {
const wrapper = mount(ProgramPage, {
props: defaultPageProps,
global: {
stubs: {
'ResponsiveFilterMenu': true,
'InfiniteScrollLayout': true,
'SessionCard': true,
'MenuContext': true,
'MenuTabs': true,
'Breadcrumb': true,
'MobileTrigger': true,
'TagFilter': true
}
}
})
// Check if filters are initialized
expect(wrapper.vm.filters).toBeDefined()
expect(wrapper.vm.localFilters).toBeDefined()
})
it('should handle filter application', async () => {
const wrapper = mount(ProgramPage, {
props: defaultPageProps,
global: {
stubs: {
'ResponsiveFilterMenu': {
template: '<div @filtersApplied="$emit(\'filtersApplied\', $event)"></div>',
emits: ['filtersApplied']
},
'InfiniteScrollLayout': true,
'SessionCard': true,
'MenuContext': true,
'MenuTabs': true,
'Breadcrumb': true,
'MobileTrigger': true,
'TagFilter': true
}
}
})
const filterMenu = wrapper.findComponent({ name: 'ResponsiveFilterMenu' })
// Simulate filter application
await filterMenu.vm.$emit('filtersApplied', {
mostra: 1,
cinema: 2
})
expect(mockRouterGet).toHaveBeenCalledWith(
'/programacao',
{ mostra: 1, cinema: 2 },
expect.objectContaining({
preserveScroll: true,
only: ['elements', 'pagy', 'current_filters', 'has_active_filters', 'menuTabs']
})
)
})
it('should handle tag filter removal', async () => {
const propsWithFilters = {
...defaultPageProps,
current_filters: {
pais: { filter_display: 'Brasil', filter_value: 1, filter_label: 'País' }
},
has_active_filters: true
}
const wrapper = mount(ProgramPage, {
props: propsWithFilters,
global: {
stubs: {
'ResponsiveFilterMenu': true,
'InfiniteScrollLayout': true,
'SessionCard': true,
'MenuContext': true,
'MenuTabs': true,
'Breadcrumb': true,
'MobileTrigger': true,
'TagFilter': {
template: '<div @remove-filter="$emit(\'remove-filter\', $event)"></div>',
emits: ['remove-filter']
}
}
}
})
const tagFilter = wrapper.findComponent({ name: 'TagFilter' })
await tagFilter.vm.$emit('remove-filter', {
filter_display: 'Brasil',
filter_value: 1,
filter_label: 'País'
})
expect(mockRouterGet).toHaveBeenCalled()
})
it('should handle mobile filter menu toggle', async () => {
const wrapper = mount(ProgramPage, {
props: defaultPageProps,
global: {
stubs: {
'ResponsiveFilterMenu': true,
'InfiniteScrollLayout': true,
'SessionCard': true,
'MenuContext': true,
'MenuTabs': true,
'Breadcrumb': true,
'MobileTrigger': {
template: '<div @open-menu="$emit(\'open-menu\')"></div>',
emits: ['open-menu']
},
'TagFilter': true
}
}
})
const mobileTrigger = wrapper.findComponent({ name: 'MobileTrigger' })
await mobileTrigger.vm.$emit('open-menu')
expect(wrapper.vm.isFilterMenuOpen).toBe(true)
})
})
// ==============================================================================
// RESPONSIVE FILTER MENU INTEGRATION TESTS
// ==============================================================================
describe('ResponsiveFilterMenu Integration', () => {
let ResponsiveFilterMenu
beforeEach(async () => {
ResponsiveFilterMenu = (await import('@/components/features/filters/ResponsiveFilterMenu.vue')).default
})
const defaultMenuProps = {
isOpen: false,
initialFilters: {},
modelValue: {
query: null,
mostra: null,
cinema: null,
pais: null
}
}
it('should sync internal filters with modelValue changes', async () => {
const wrapper = mount(ResponsiveFilterMenu, {
props: defaultMenuProps,
slots: {
filters: '<div>Filter content</div>'
},
global: {
stubs: {
'SearchFilter': true,
'TwContainer': { template: '<div><slot /></div>' },
'Teleport': { template: '<div><slot /></div>' }
}
}
})
// Update modelValue
await wrapper.setProps({
modelValue: {
query: null,
mostra: { filter_value: 1, filter_display: 'Test Mostra' },
cinema: null,
pais: null
}
})
expect(wrapper.vm.internalFilters.mostra).toEqual({
filter_value: 1,
filter_display: 'Test Mostra'
})
})
it('should provide correct updateField function to slot', () => {
let slotProps = null
const wrapper = mount(ResponsiveFilterMenu, {
props: defaultMenuProps,
slots: {
filters: (props) => {
slotProps = props
return '<div>Filter content</div>'
}
},
global: {
stubs: {
'SearchFilter': {
template: '<div><slot name="filters" v-bind="$attrs" /></div>'
},
'TwContainer': { template: '<div><slot /></div>' },
'Teleport': { template: '<div><slot /></div>' }
}
}
})
expect(slotProps).toHaveProperty('updateField')
expect(typeof slotProps.updateField).toBe('function')
})
it('should emit update:modelValue when updateField is called', async () => {
let updateField = null
const wrapper = mount(ResponsiveFilterMenu, {
props: defaultMenuProps,
slots: {
filters: (props) => {
updateField = props.updateField
return '<div>Filter content</div>'
}
},
global: {
stubs: {
'SearchFilter': {
template: '<div><slot name="filters" v-bind="$attrs" /></div>'
},
'TwContainer': { template: '<div><slot /></div>' },
'Teleport': { template: '<div><slot /></div>' }
}
}
})
// Call updateField
updateField('mostra', { filter_value: 1, filter_display: 'Test' })
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0][0]).toEqual({
query: null,
mostra: { filter_value: 1, filter_display: 'Test' },
cinema: null,
pais: null
})
})
})
// ==============================================================================
// END-TO-END FILTER WORKFLOW TESTS
// ==============================================================================
describe('Complete Filter Workflows', () => {
let ProgramPage
let mockRouterGet
beforeEach(async () => {
mockRouterGet = vi.fn()
router.get = mockRouterGet
ProgramPage = (await import('@/pages/ProgramPage.vue')).default
})
const createFullPageWrapper = (props = {}) => {
return mount(ProgramPage, {
props: {
tabBaseUrl: '/programacao',
items: [],
elements: mockSessionData,
pagy: mockPagyData,
menuTabs: [{ name: 'Programação', url: '/programacao', active: true }],
current_filters: {},
has_active_filters: false,
crumbs: [{ name: 'Home', url: '/' }],
...mockFilterOptions,
...props
},
global: {
stubs: {
'InfiniteScrollLayout': {
template: '<div><slot name="content" :allElements="elements" /></div>',
props: ['elements'],
setup(props) {
return { elements: props.elements }
}
},
'SessionCard': { template: '<div class="session-card">{{ session.titulo }}</div>' },
'MenuContext': true,
'MenuTabs': true,
'Breadcrumb': true,
'MobileTrigger': true,
'TagFilter': true,
'ResponsiveFilterMenu': true,
'SearchFilter': true,
'ProgramsFilterForm': true
}
}
})
}
it('should complete single filter application workflow', async () => {
const wrapper = createFullPageWrapper()
// 1. Open mobile menu
await wrapper.vm.openMenu()
expect(wrapper.vm.isFilterMenuOpen).toBe(true)
// 2. Update filter
await wrapper.vm.updateField('mostra', {
filter_value: 1,
filter_display: 'Mostra Principal'
})
expect(wrapper.vm.filters.mostra).toEqual({
filter_value: 1,
filter_display: 'Mostra Principal'
})
// 3. Apply filters
await wrapper.vm.filterSearch(wrapper.vm.filters)
expect(mockRouterGet).toHaveBeenCalledWith(
'/programacao',
{ mostra: 1 },
expect.objectContaining({
preserveScroll: true,
only: ['elements', 'pagy', 'current_filters', 'has_active_filters', 'menuTabs']
})
)
})
it('should complete multiple filter application workflow', async () => {
const wrapper = createFullPageWrapper()
// Apply multiple filters
await wrapper.vm.updateField('mostra', {
filter_value: 1,
filter_display: 'Mostra Principal'
})
await wrapper.vm.updateField('cinema', {
filter_value: 2,
filter_display: 'Cinema Odeon'
})
await wrapper.vm.updateField('query', {
filter_value: 'drama',
filter_display: 'drama'
})
await wrapper.vm.filterSearch(wrapper.vm.filters)
expect(mockRouterGet).toHaveBeenCalledWith(
'/programacao',
{ mostra: 1, cinema: 2, query: 'drama' },
expect.any(Object)
)
})
it('should complete filter removal workflow', async () => {
const wrapper = createFullPageWrapper({
current_filters: {
mostra: { filter_value: 1, filter_display: 'Mostra Principal', filter_label: 'Mostra' },
cinema: { filter_value: 2, filter_display: 'Cinema Odeon', filter_label: 'Cinema' }
},
has_active_filters: true
})
// Remove single filter
await wrapper.vm.removeQuery({
filter_value: 1,
filter_display: 'Mostra Principal',
filter_label: 'Mostra'
})
// Should maintain other filters
expect(mockRouterGet).toHaveBeenCalled()
})
it('should complete clear all filters workflow', async () => {
const wrapper = createFullPageWrapper({
current_filters: {
mostra: { filter_value: 1, filter_display: 'Mostra Principal' },
cinema: { filter_value: 2, filter_display: 'Cinema Odeon' }
},
has_active_filters: true
})
await wrapper.vm.clearSearchQuery()
expect(mockRouterGet).toHaveBeenCalledWith(
'/programacao',
{},
expect.any(Object)
)
})
})
// ==============================================================================
// INFINITE SCROLL INTEGRATION TESTS
// ==============================================================================
describe('InfiniteScrollLayout with Filters', () => {
let InfiniteScrollLayout
let mockRouterVisit
beforeEach(async () => {
mockRouterVisit = vi.fn()
router.visit = mockRouterVisit
InfiniteScrollLayout = (await import('@/components/layout/InfiniteScrollLayout.vue')).default
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation((callback) => ({
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn()
}))
})
const defaultScrollProps = {
elements: mockSessionData,
pagy: { page: 1, last: 3, count: 50 },
filters: { mostra: 1, cinema: 2 }
}
it('should preserve filters when loading more content', async () => {
// Mock current URL
Object.defineProperty(window, 'location', {
value: {
href: 'http://localhost/programacao?mostra=1&cinema=2&page=1'
},
writable: true
})
const wrapper = mount(InfiniteScrollLayout, {
props: defaultScrollProps
})
// Trigger load more
await wrapper.vm.loadMore()
expect(mockRouterVisit).toHaveBeenCalledWith(
expect.stringContaining('page=2'),
expect.objectContaining({
method: 'get',
preserveState: true,
preserveScroll: true,
only: ['elements', 'pagy']
})
)
})
it('should reset scroll when new filters are applied', async () => {
const wrapper = mount(InfiniteScrollLayout, {
props: defaultScrollProps
})
// Simulate new filter results (page 1)
await wrapper.setProps({
elements: [mockSessionData[0]],
pagy: { page: 1, last: 2, count: 10 }
})
expect(wrapper.vm.allElements).toEqual([mockSessionData[0]])
expect(wrapper.vm.currentPage).toBe(1)
})
})
// ==============================================================================
// EDGE CASES AND ERROR HANDLING
// ==============================================================================
describe('Filter System Edge Cases', () => {
describe('Error Handling', () => {
it('should handle router errors gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
router.get = vi.fn().mockRejectedValue(new Error('Network error'))
const ProgramPage = (await import('@/pages/ProgramPage.vue')).default
const wrapper = mount(ProgramPage, {
props: {
tabBaseUrl: '/programacao',
items: [],
elements: [],
pagy: mockPagyData,
menuTabs: [],
current_filters: {},
has_active_filters: false,
crumbs: [],
...mockFilterOptions
},
global: {
stubs: { '*': true }
}
})
await wrapper.vm.filterSearch({ mostra: 1 })
// Should not throw and should log error
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
describe('Empty States', () => {
it('should handle empty filter options', () => {
const ProgramsFilterForm = require('@/components/features/filters/ProgramsFilterForm.vue').default
const wrapper = mount(ProgramsFilterForm, {
props: {
modelValue: {},
updateField: vi.fn(),
mostras: [],
cinemas: [],
paises: [],
genres: [],
sessoes: [],
directors: [],
actors: []
},
global: {
stubs: { '*': true }
}
})
// Should render without errors
expect(wrapper.exists()).toBe(true)
})
it('should handle null filter values correctly', () => {
const { extractFilterValues } = require('@/lib/filterUtils')
const result = extractFilterValues({
mostra: null,
cinema: undefined,
pais: '',
genre: { filter_value: null }
})
expect(result).toEqual({})
})
})
describe('Performance Considerations', () => {
it('should debounce search input correctly', async () => {
vi.useFakeTimers()
const SearchBar = (await import('@/components/features/filters/SearchBar.vue')).default
const wrapper = mount(SearchBar, {
props: { modelValue: '' }
})
const input = wrapper.find('input')
// Rapid typing
await input.setValue('a')
await input.setValue('ab')
await input.setValue('abc')
// Should not emit yet
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
// Fast forward past debounce
vi.advanceTimersByTime(300)
// Should emit only once with final value
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue').length).toBe(1)
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['abc'])
vi.useRealTimers()
})
it('should handle rapid filter changes', async () => {
const ResponsiveFilterMenu = (await import('@/components/features/filters/ResponsiveFilterMenu.vue')).default
const wrapper = mount(ResponsiveFilterMenu, {
props: {
isOpen: false,
initialFilters: {},
modelValue: {}
},
global: {
stubs: { '*': true }
}
})
let updateField
const filterSlot = wrapper.findComponent({ name: 'SearchFilter' })
// Simulate rapid updates
for (let i = 0; i < 10; i++) {
await wrapper.vm.updateField('query', { filter_value: `test${i}` })
}
// Should handle all updates without errors
expect(wrapper.vm.internalFilters.query.filter_value).toBe('test9')
})
})
describe('Mobile/Desktop Behavior', () => {
it('should handle mobile menu state correctly', () => {
const { useMobileTrigger } = require('@/components/features/filters/composables/useMobileTrigger')
const composable = useMobileTrigger()
// Initial state
expect(composable.isFilterMenuOpen.value).toBe(false)
expect(document.body.style.overflow).toBe('')
// Open menu
composable.openMenu()
expect(composable.isFilterMenuOpen.value).toBe(true)
expect(document.body.style.overflow).toBe('hidden')
// Close menu
composable.closeMenu()
expect(composable.isFilterMenuOpen.value).toBe(false)
expect(document.body.style.overflow).toBe('')
})
it('should clean up mobile menu state on unmount', async () => {
const { useMobileTrigger } = require('@/components/features/filters/composables/useMobileTrigger')
const composable = useMobileTrigger()
composable.openMenu()
expect(document.body.style.overflow).toBe('hidden')
// Simulate component unmount
composable.closeMenu()
expect(document.body.style.overflow).toBe('')
})
})
})
// ==============================================================================
// ACCESSIBILITY TESTS
// ==============================================================================
describe('Filter System Accessibility', () => {
it('should have proper ARIA labels on filter tags', () => {
const TagFilter = require('@/components/common/tags/TagFilter.vue').default
const mockFilter = {
filter_display: 'Brasil',
filter_value: 1,
filter_label: 'País'
}
const wrapper = mount(TagFilter, {
props: { text: 'Brasil', filter: mockFilter }
})
expect(wrapper.find('span').attributes('aria-label')).toBe('Filter: Brasil')
expect(wrapper.find('button').attributes('aria-label')).toBe('Remove Brasil filter')
})
it('should have proper form labels', () => {
const SearchBar = require('@/components/features/filters/SearchBar.vue').default
const wrapper = mount(SearchBar, {
props: { modelValue: '' }
})
const input = wrapper.find('input')
expect(input.attributes('type')).toBe('text')
expect(input.attributes('placeholder')).toBe('Pesquisar')
})
it('should support keyboard navigation', async () => {
const SearchBar = require('@/components/features/filters/SearchBar.vue').default
const wrapper = mount(SearchBar, {
props: { modelValue: 'test' }
})
const input = wrapper.find('input')
await input.trigger('keyup.enter')
expect(wrapper.emitted('search')).toBeTruthy()
})
})
// ==============================================================================
// PERFORMANCE AND MEMORY LEAK TESTS
// ==============================================================================
describe('Filter System Performance', () => {
it('should clean up event listeners', () => {
const InfiniteScrollLayout = require('@/components/layout/InfiniteScrollLayout.vue').default
const wrapper = mount(InfiniteScrollLayout, {
props: {
elements: mockSessionData,
pagy: mockPagyData
}
})
const observerSpy = vi.spyOn(wrapper.vm.observer || {}, 'disconnect')
wrapper.unmount()
// Note: In real implementation, ensure observer.disconnect() is called
// This test ensures the pattern is followed
expect(wrapper.vm.observer).toBeDefined()
})
it('should handle large filter option lists efficiently', () => {
const largeOptionsList = Array.from({ length: 1000 }, (_, i) => ({
filter_display: `Option ${i}`,
filter_value: i,
filter_label: 'Test'
}))
const ProgramsFilterForm = require('@/components/features/filters/ProgramsFilterForm.vue').default
const startTime = performance.now()
const wrapper = mount(ProgramsFilterForm, {
props: {
modelValue: {},
updateField: vi.fn(),
mostras: largeOptionsList,
cinemas: [],
paises: [],
genres: [],
sessoes: [],
directors: [],
actors: []
},
global: {
stubs: { '*': true }
}
})
const endTime = performance.now()
// Should render within reasonable time (adjust threshold as needed)
expect(endTime - startTime).toBeLessThan(100)
expect(wrapper.exists()).toBe(true)
})
})
// ==============================================================================
// MOCK INERTIA RESPONSES
// ==============================================================================
describe('Filter System with Mock Server Responses', () => {
it('should handle successful filter response', async () => {
const mockResponse = {
elements: mockSessionData.slice(0, 1),
pagy: { page: 1, last: 1, count: 1 },
current_filters: { mostra: { filter_value: 1, filter_display: 'Mostra Principal' } },
has_active_filters: true,
menuTabs: []
}
router.get = vi.fn().mockImplementation((url, params, options) => {
options.onSuccess({ props: mockResponse })
})
const ProgramPage = (await import('@/pages/ProgramPage.vue')).default
const wrapper = mount(ProgramPage, {
props: {
tabBaseUrl: '/programacao',
items: [],
elements: mockSessionData,
pagy: mockPagyData,
menuTabs: [],
current_filters: {},
has_active_filters: false,
crumbs: [],
...mockFilterOptions
},
global: {
stubs: { '*': true }
}
})
await wrapper.vm.filterSearch({ mostra: 1 })
expect(router.get).toHaveBeenCalled()
})
it('should handle filter response errors', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
router.get = vi.fn().mockImplementation((url, params, options) => {
options.onError && options.onError(new Error('Server error'))
})
const ProgramPage = (await import('@/pages/ProgramPage.vue')).default
const wrapper = mount(ProgramPage, {
props: {
tabBaseUrl: '/programacao',
items: [],
elements: mockSessionData,
pagy: mockPagyData,
menuTabs: [],
current_filters: {},
has_active_filters: false,
crumbs: [],
...mockFilterOptions
},
global: {
stubs: { '*': true }
}
})
await wrapper.vm.filterSearch({ mostra: 1 })
// Should handle error gracefully
expect(router.get).toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
// ==============================================================================
// TEST CLEANUP AND UTILITIES
// ==============================================================================
afterEach(() => {
// Clean up mocks
vi.clearAllMocks()
// Reset DOM state
document.body.style.overflow = ''
// Clear any timers
vi.clearAllTimers()
})
// ==============================================================================
// EXPORT TEST CONFIGURATION AND HELPERS
// ==============================================================================
export const testHelpers = {
mockFilterOptions,
mockSessionData,
mockPagyData,
createMockFilter: (display, value, label) => ({
filter_display: display,
filter_value: value,
filter_label: label
})
}
export const testConfig = {
testEnvironment: 'jsdom',
setupFiles: ['./tests/setup.js'],
globals: {
'vue-jest': {
transform: {
'^.+\\.vue: 'vue-jest'
}
}
}
}
// ==============================================================================
// TEST CONFIGURATION FILES
// ==============================================================================
// ================================
// vitest.config.js
// ================================
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.js'
]
},
// Increase timeout for integration tests
testTimeout: 10000
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./app/frontend', import.meta.url)),
'@components': fileURLToPath(new URL('./app/frontend/components', import.meta.url)),
'@pages': fileURLToPath(new URL('./app/frontend/pages', import.meta.url)),
'@assets': fileURLToPath(new URL('./app/frontend/assets', import.meta.url))
}
}
})
// ================================
// tests/setup.js
// ================================
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
// Mock global objects
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation((callback) => ({
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn()
}))
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn()
}))
// Global test configuration
config.global.stubs = {
// Stub Inertia components
'Link': { template: '<a><slot /></a>' },
'Head': { template: '<div></div>' },
// Stub transition components
'transition': { template: '<div><slot /></div>' },
'Teleport': { template: '<div><slot /></div>' },
// Common stubs
'router-link': { template: '<a><slot /></a>' },
'router-view': { template: '<div><slot /></div>' }
}
// Mock performance API
if (!global.performance) {
global.performance = {
now: vi.fn(() => Date.now()),
mark: vi.fn(),
measure: vi.fn()
}
}
// ================================
// tests/utils/testHelpers.js
// ================================
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
/**
* Wait for all pending promises and Vue updates
*/
export const flushPromises = async () => {
await new Promise(resolve => setTimeout(resolve, 0))
await nextTick()
}
/**
* Create a wrapper with common stubs and global configuration
*/
export const createWrapper = (component, options = {}) => {
const defaultStubs = {
'TwContainer': { template: '<div class="container"><slot /></div>' },
'MenuContext': { template: '<div class="menu-context"><slot /></div>' },
'MenuTabs': { template: '<div class="menu-tabs"><slot /></div>' },
'Breadcrumb': { template: '<div class="breadcrumb"><slot /></div>' },
}
return mount(component, {
global: {
stubs: {
...defaultStubs,
...options.stubs
},
...options.global
},
...options
})
}
/**
* Simulate user typing with debounce
*/
export const simulateTyping = async (wrapper, selector, text, delay = 50) => {
const input = wrapper.find(selector)
for (let i = 0; i < text.length; i++) {
const partialText = text.substring(0, i + 1)
await input.setValue(partialText)
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
}
await flushPromises()
}
/**
* Mock Inertia router responses
*/
export const mockInertiaResponse = (responseData) => {
return vi.fn().mockImplementation((url, params, options) => {
if (options.onSuccess) {
setTimeout(() => {
options.onSuccess({ props: responseData })
}, 0)
}
return Promise.resolve()
})
}
/**
* Create mock filter data
*/
export const createMockFilterData = (overrides = {}) => ({
mostras: [
{ filter_display: 'Mostra Principal', filter_value: 1, filter_label: 'Mostra' },
{ filter_display: 'Panorama', filter_value: 2, filter_label: 'Mostra' }
],
cinemas: [
{ filter_display: 'Cinema Odeon', filter_value: 1, filter_label: 'Cinema' },
{ filter_display: 'Cine Santa Teresa', filter_value: 2, filter_label: 'Cinema' }
],
paises: [
{ filter_display: 'Brasil', filter_value: 1, filter_label: 'País' },
{ filter_display: 'França', filter_value: 2, filter_label: 'País' }
],
genres: [
{ filter_display: 'Drama', filter_value: 1, filter_label: 'Gênero' },
{ filter_display: 'Comédia', filter_value: 2, filter_label: 'Gênero' }
],
sessoes: [
{ filter_display: '14:00', filter_value: '14:00', filter_label: 'Sessão' },
{ filter_display: '19:00', filter_value: '19:00', filter_label: 'Sessão' }
],
directors
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment