import { describe, it, expect, vi } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import { createRouter, createMemoryHistory } from 'vue-router'; import { createPinia, setActivePinia } from 'pinia'; import DealsView from '../../resources/js/views/DealsView.vue'; import { MOCK_DEALS } from '../../resources/js/composables/mockDeals'; // Smoke-тесты DealsView с mock-данными. const mountDeals = async () => { setActivePinia(createPinia()); const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }], }); await router.push('/deals'); await router.isReady(); // DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует // injected layout от v-app — оборачиваем компонент в v-app для теста. // DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует // layout-injection от v-app. В Vitest vite-plugin-vuetify auto-import не // работает, layout-context недоступен. Stub'им сам Drawer (тестируется // отдельно в DealDetailDrawer.spec.ts). return mount(DealsView, { global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, NewDealDialog: true }, }, }); }; describe('DealsView.vue', () => { it('монтируется и содержит заголовок «Сделки»', async () => { const wrapper = await mountDeals(); expect(wrapper.find('h1').text()).toBe('Сделки'); }); it('содержит page-stats с числами всего/в работе/ждут оплату', async () => { const wrapper = await mountDeals(); const text = wrapper.text(); expect(text).toContain('новых лида с утра'); expect(text).toContain('всего'); expect(text).toContain('в работе'); expect(text).toContain('ждут оплату'); }); it('содержит ровно 5 chiprow-tabs', async () => { const wrapper = await mountDeals(); const text = wrapper.text(); ['Все', 'Активные', 'Ждут оплату', 'Закрытые', 'Невалидные'].forEach((label) => expect(text).toContain(label)); }); it('по умолчанию активен таб «Активные», показывает только active-сделки', async () => { const wrapper = await mountDeals(); await flushPromises(); const activeStatuses = ['new', 'viewed', 'worked', 'negotiations', 'hot']; const expectedCount = MOCK_DEALS.filter((d) => activeStatuses.includes(d.statusSlug)).length; const rows = wrapper.findAll('tbody tr'); expect(rows).toHaveLength(expectedCount); }); it('содержит кнопки Экспорт и Новая сделка', async () => { const wrapper = await mountDeals(); const text = wrapper.text(); expect(text).toContain('Экспорт'); expect(text).toContain('Новая сделка'); }); it('таблица содержит колонки Лид/Статус/Проект/Менеджер/Стоимость/Время', async () => { const wrapper = await mountDeals(); const headers = wrapper.findAll('thead th').map((h) => h.text()); ['Лид', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Время'].forEach((label) => { expect(headers.some((h) => h.includes(label))).toBe(true); }); }); it('форматирует стоимость как «N ₽» с разделителем тысяч', async () => { const wrapper = await mountDeals(); const text = wrapper.text(); // Intl.NumberFormat('ru-RU') использует non-breaking space (U+00A0) или // narrow nbsp (U+202F) как разделитель тысяч, не ASCII-пробел. Явные // \u-escape'ы — иначе ESLint ругается no-irregular-whitespace. expect(text).toMatch(/2\s+400\s*₽/); }); it('форматирует «время с момента» как «N мин назад» для свежих сделок', async () => { const wrapper = await mountDeals(); const text = wrapper.text(); expect(text).toContain('7 мин назад'); }); it('bulk-bar скрыт когда selected пустой; виден когда selected не пустой', async () => { const wrapper = await mountDeals(); await flushPromises(); // По умолчанию ничего не выбрано expect(wrapper.find('[data-testid="bulk-bar"]').exists()).toBe(false); // Симулируем выбор через v-model: selected const vm = wrapper.vm as unknown as { selected: number[] }; vm.selected = [1, 2]; await flushPromises(); const bar = wrapper.find('[data-testid="bulk-bar"]'); expect(bar.exists()).toBe(true); expect(bar.text()).toContain('Выбрано'); expect(bar.text()).toContain('2'); }); it('bulk-status: применение нового статуса меняет statusSlug у выбранных сделок и закрывает меню', async () => { const wrapper = await mountDeals(); const vm = wrapper.vm as unknown as { selected: number[]; dealsState: Array<{ id: number; statusSlug: string }>; applyBulkStatus: (slug: string) => void; }; vm.selected = [1, 2]; await flushPromises(); // До применения — id=1 'new', id=2 'worked' (из MOCK_DEALS) const before1 = vm.dealsState.find((d) => d.id === 1)!.statusSlug; expect(before1).toBe('new'); vm.applyBulkStatus('paid'); await flushPromises(); expect(vm.dealsState.find((d) => d.id === 1)!.statusSlug).toBe('paid'); expect(vm.dealsState.find((d) => d.id === 2)!.statusSlug).toBe('paid'); }); it('bulk-delete: confirm удаляет выбранные сделки и сбрасывает selected', async () => { const wrapper = await mountDeals(); const vm = wrapper.vm as unknown as { selected: number[]; dealsState: Array<{ id: number }>; applyBulkDelete: () => void; }; const before = vm.dealsState.length; vm.selected = [1, 3]; await flushPromises(); vm.applyBulkDelete(); await flushPromises(); expect(vm.dealsState.length).toBe(before - 2); expect(vm.dealsState.find((d) => d.id === 1)).toBeUndefined(); expect(vm.dealsState.find((d) => d.id === 3)).toBeUndefined(); expect(vm.selected).toEqual([]); }); it('bulk-export: показывает toast с количеством выбранных сделок + триггерит CSV-download', async () => { const wrapper = await mountDeals(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkExport: () => void; exportToastOpen: boolean; exportToastText: string; }; // Шпион на createObjectURL — в jsdom он бывает не определён, заменим. const createUrlSpy = vi.fn(() => 'blob:mock'); const revokeUrlSpy = vi.fn(); Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true }); Object.defineProperty(URL, 'revokeObjectURL', { value: revokeUrlSpy, configurable: true }); // Подменяем click() на якоре чтобы не словить navigation в jsdom. const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); vm.selected = [1, 2, 3, 4]; await flushPromises(); vm.applyBulkExport(); expect(createUrlSpy).toHaveBeenCalledTimes(1); expect(clickSpy).toHaveBeenCalledTimes(1); expect(vm.exportToastOpen).toBe(true); expect(vm.exportToastText).toContain('4'); expect(vm.exportToastText).toContain('CSV'); clickSpy.mockRestore(); }); it('bulk-export: пустой selected → toast «Нет выбранных» без CSV', async () => { const wrapper = await mountDeals(); const vm = wrapper.vm as unknown as { selected: number[]; applyBulkExport: () => void; exportToastOpen: boolean; exportToastText: string; }; const createUrlSpy = vi.fn(() => 'blob:mock'); Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true }); vm.selected = []; vm.applyBulkExport(); expect(createUrlSpy).not.toHaveBeenCalled(); expect(vm.exportToastText).toContain('Нет выбранных'); }); it('кнопка «Новая сделка» открывает NewDealDialog (newDealOpen=true)', async () => { const wrapper = await mountDeals(); await flushPromises(); const vm = wrapper.vm as unknown as { newDealOpen: boolean }; expect(vm.newDealOpen).toBe(false); await wrapper.find('[data-testid="new-deal-btn"]').trigger('click'); await flushPromises(); expect(vm.newDealOpen).toBe(true); }); it('onDealCreated добавляет новую сделку в начало dealsState', async () => { const wrapper = await mountDeals(); const vm = wrapper.vm as unknown as { dealsState: Array<{ id: number; name: string; statusSlug: string }>; onDealCreated: (deal: Record) => void; }; const before = vm.dealsState.length; // Передаём полную форму deal — table-cell ожидает manager.name/phone/cost. vm.onDealCreated({ id: 999, name: 'Новый клиент', phone: '+7 (999) 000-00-00', statusSlug: 'new', project: 'Окна Москва', manager: { initials: 'Н', name: 'Новый М.' }, cost: 1000, receivedMinutesAgo: 0, }); await flushPromises(); expect(vm.dealsState.length).toBe(before + 1); expect(vm.dealsState[0].id).toBe(999); expect(vm.dealsState[0].name).toBe('Новый клиент'); }); it('smart-filter projects: оставляет только сделки выбранного проекта', async () => { const wrapper = await mountDeals(); const vm = wrapper.vm as unknown as { activeTab: string; filterProjects: string[] }; vm.activeTab = 'all'; vm.filterProjects = ['Окна Москва']; await flushPromises(); const rows = wrapper.findAll('tbody tr'); // Минимум одна строка, и все содержат «Окна Москва» expect(rows.length).toBeGreaterThan(0); rows.forEach((row) => expect(row.text()).toContain('Окна Москва')); }); it('smart-filter managers: оставляет только сделки выбранного менеджера', async () => { const wrapper = await mountDeals(); const vm = wrapper.vm as unknown as { activeTab: string; filterManagers: string[] }; vm.activeTab = 'all'; vm.filterManagers = ['Иван П.']; await flushPromises(); const rows = wrapper.findAll('tbody tr'); expect(rows.length).toBeGreaterThan(0); rows.forEach((row) => expect(row.text()).toContain('Иван П.')); }); it('clearFilters сбрасывает projects+managers фильтры, кнопка появляется по условию', async () => { const wrapper = await mountDeals(); const vm = wrapper.vm as unknown as { filterProjects: string[]; filterManagers: string[]; clearFilters: () => void; }; expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(false); vm.filterProjects = ['Окна Москва']; await flushPromises(); expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(true); vm.clearFilters(); await flushPromises(); expect(vm.filterProjects).toEqual([]); expect(vm.filterManagers).toEqual([]); }); it('bulk-clear: иконка ✕ сбрасывает selected', async () => { const wrapper = await mountDeals(); const vm = wrapper.vm as unknown as { selected: number[] }; vm.selected = [1, 2]; await flushPromises(); const clearBtn = wrapper.find('[data-testid="bulk-clear-btn"]'); expect(clearBtn.exists()).toBe(true); await clearBtn.trigger('click'); await flushPromises(); expect(vm.selected).toEqual([]); }); });