import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createPinia, setActivePinia } from 'pinia'; import { createVuetify } from 'vuetify'; import axios from 'axios'; vi.mock('axios'); import ProjectsView from '../../resources/js/views/ProjectsView.vue'; // VDialog в JSDOM не рендерит в teleport-цели; стабом отключаем диалоги, // чтобы не падал mount при попытке портала. const factory = () => mount(ProjectsView, { global: { plugins: [createVuetify()], stubs: { VDialog: { template: '
', props: ['modelValue'], }, NewProjectDialog: true, EditProjectDialog: true, }, }, }); beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); describe('ProjectsView', () => { it('shows empty state when no projects', async () => { (axios.get as unknown as ReturnType).mockResolvedValue({ data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } }, }); const wrapper = factory(); await flushPromises(); expect(wrapper.text()).toMatch(/нет проектов|empty/i); }); it('renders card for each project', async () => { (axios.get as unknown as ReturnType).mockResolvedValue({ data: { data: [ { id: 1, name: 'AlphaSite', signal_type: 'site', signal_identifier: 'a.ru', daily_limit_target: 10, delivered_today: 0, is_active: true, sync_status: 'ok', }, ], meta: { total: 1, current_page: 1, per_page: 20 }, }, }); const wrapper = factory(); await flushPromises(); expect(wrapper.text()).toContain('AlphaSite'); }); it.skip('filter by signal_type refetches', async () => { // TODO: VSelect dropdown в jsdom не открывает items-list через teleport, // findComponent({name:'VSelect'}).vm.$emit некорректно тригерит реактивную // цепочку @update:model-value. Покрытие — через Histoire + e2e после Plan 5. }); it('shows BulkActionsBar when at least 2 selected', async () => { (axios.get as unknown as ReturnType).mockResolvedValue({ data: { data: [ { id: 1, name: 'A', signal_type: 'site', signal_identifier: 'a.ru', daily_limit_target: 10, delivered_today: 0, is_active: true, sync_status: 'ok', }, { id: 2, name: 'B', signal_type: 'site', signal_identifier: 'b.ru', daily_limit_target: 10, delivered_today: 0, is_active: true, sync_status: 'ok', }, ], meta: { total: 2, current_page: 1, per_page: 20 }, }, }); const wrapper = factory(); await flushPromises(); const cards = wrapper.findAllComponents({ name: 'ProjectCard' }); expect(cards.length).toBe(2); cards[0].vm.$emit('toggle-select', 1); cards[1].vm.$emit('toggle-select', 2); await wrapper.vm.$nextTick(); expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true); }); }); // Integration tests for the Task 8 contract: drawer × bulk-bar mutual exclusion. // Contract: // - 0 selected → no drawer open, no bulk-bar, no .has-drawer class. // - 1 selected → drawer open, bulk-bar hidden, .has-drawer class on view. // - ≥2 selected → drawer NOT open, bulk-bar visible, no .has-drawer class. // - Drawer @close → store.clearSelection() → both hidden. // - singleSelectedProject = null when selected id not in items. describe('ProjectsView × ProjectDetailsDrawer integration', () => { const projectA = { id: 1, name: 'A', signal_type: 'site' as const, signal_identifier: 'a.ru', daily_limit_target: 10, delivered_today: 0, is_active: true, sync_status: 'ok' as const, }; const projectB = { id: 2, name: 'B', signal_type: 'site' as const, signal_identifier: 'b.ru', daily_limit_target: 10, delivered_today: 0, is_active: true, sync_status: 'ok' as const, }; function mockProjects(items: Array>) { (axios.get as unknown as ReturnType).mockResolvedValue({ data: { data: items, meta: { total: items.length, current_page: 1, per_page: 20 }, }, }); } it('0 selected → no drawer open, no bulk-bar, no .has-drawer class', async () => { mockProjects([projectA, projectB]); const wrapper = factory(); await flushPromises(); // Drawer aside element always renders, but should not have .open class. const drawer = wrapper.find('aside.project-details-drawer'); expect(drawer.exists()).toBe(true); expect(drawer.classes()).not.toContain('open'); // BulkActionsBar uses v-if; should not exist. expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false); // .has-drawer class should not be on the view root. expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer'); }); it('1 selected → drawer open, bulk-bar hidden, .has-drawer class present', async () => { mockProjects([projectA, projectB]); const wrapper = factory(); await flushPromises(); const cards = wrapper.findAllComponents({ name: 'ProjectCard' }); expect(cards.length).toBe(2); cards[0].vm.$emit('toggle-select', 1); await wrapper.vm.$nextTick(); // Drawer should be open. const drawer = wrapper.find('aside.project-details-drawer'); expect(drawer.exists()).toBe(true); expect(drawer.classes()).toContain('open'); // BulkActionsBar should NOT exist (size < 2). expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false); // .has-drawer class should be present. expect(wrapper.find('.projects-view').classes()).toContain('has-drawer'); }); it('2 selected → drawer NOT open, bulk-bar visible, no .has-drawer class', async () => { mockProjects([projectA, projectB]); const wrapper = factory(); await flushPromises(); const cards = wrapper.findAllComponents({ name: 'ProjectCard' }); expect(cards.length).toBe(2); cards[0].vm.$emit('toggle-select', 1); cards[1].vm.$emit('toggle-select', 2); await wrapper.vm.$nextTick(); // Drawer should NOT be open (singleSelectedProject = null when size != 1). const drawer = wrapper.find('aside.project-details-drawer'); expect(drawer.exists()).toBe(true); expect(drawer.classes()).not.toContain('open'); // BulkActionsBar should exist (size >= 2). expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(true); // .has-drawer class should NOT be present. expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer'); }); it('drawer @close → store.clearSelection → both bulk-bar and drawer hidden', async () => { mockProjects([projectA, projectB]); const wrapper = factory(); await flushPromises(); const cards = wrapper.findAllComponents({ name: 'ProjectCard' }); cards[0].vm.$emit('toggle-select', 1); await wrapper.vm.$nextTick(); // Sanity: drawer is open with 1 selected. const drawerBefore = wrapper.find('aside.project-details-drawer'); expect(drawerBefore.classes()).toContain('open'); // Emit close from drawer; onDrawerClose handler calls store.clearSelection(). const drawerComp = wrapper.findComponent({ name: 'ProjectDetailsDrawer' }); expect(drawerComp.exists()).toBe(true); drawerComp.vm.$emit('close'); await wrapper.vm.$nextTick(); // Both should be hidden now. const drawerAfter = wrapper.find('aside.project-details-drawer'); expect(drawerAfter.classes()).not.toContain('open'); expect(wrapper.findComponent({ name: 'BulkActionsBar' }).exists()).toBe(false); expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer'); }); it('singleSelectedProject = null when selected id is not in items', async () => { // Items contain only id=1, but we'll select id=999 (not present). mockProjects([projectA]); const wrapper = factory(); await flushPromises(); // Reach into store to add a phantom id directly (simulates stale selection // after items refetch dropped the project). const cards = wrapper.findAllComponents({ name: 'ProjectCard' }); expect(cards.length).toBe(1); cards[0].vm.$emit('toggle-select', 999); await wrapper.vm.$nextTick(); // Selection size is 1, but lookup fails → singleSelectedProject = null // → drawer NOT open. const drawer = wrapper.find('aside.project-details-drawer'); expect(drawer.exists()).toBe(true); expect(drawer.classes()).not.toContain('open'); // .has-drawer also depends on singleSelectedProject !== null. expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer'); }); }); describe('ProjectsView 18:00 cutoff banner', () => { beforeEach(() => { localStorage.clear(); (axios.get as unknown as ReturnType).mockResolvedValue({ data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } }, }); }); it('shows the cutoff banner with the 18:00 deadline by default', async () => { const wrapper = factory(); await flushPromises(); const banner = wrapper.find('[data-testid="cutoff-banner"]'); expect(banner.exists()).toBe(true); expect(banner.text()).toContain('18:00'); }); it('hides the banner after the close button and remembers it in localStorage', async () => { const wrapper = factory(); await flushPromises(); await wrapper.find('[data-testid="cutoff-banner-close"]').trigger('click'); await wrapper.vm.$nextTick(); expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false); expect(localStorage.getItem('projects.cutoffBannerDismissed')).toBe('1'); }); it('stays hidden on next mount when previously dismissed', async () => { localStorage.setItem('projects.cutoffBannerDismissed', '1'); const wrapper = factory(); await flushPromises(); expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false); }); });