Created
September 12, 2025 07:44
-
-
Save dedemenezes/36f504df8440b2da5c05896edf430078 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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