2026-05-09 05:49:34 +03:00
|
|
|
|
import { describe, it, expect, vi } from 'vitest';
|
2026-05-08 17:45:25 +03:00
|
|
|
|
import { mount, flushPromises } from '@vue/test-utils';
|
|
|
|
|
|
import { createVuetify } from 'vuetify';
|
|
|
|
|
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
2026-05-09 06:43:21 +03:00
|
|
|
|
import { createPinia, setActivePinia } from 'pinia';
|
2026-05-08 17:45:25 +03:00
|
|
|
|
import DealsView from '../../resources/js/views/DealsView.vue';
|
|
|
|
|
|
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
|
|
|
|
|
|
|
|
|
|
|
|
// Smoke-тесты DealsView с mock-данными.
|
|
|
|
|
|
|
|
|
|
|
|
const mountDeals = async () => {
|
2026-05-09 06:43:21 +03:00
|
|
|
|
setActivePinia(createPinia());
|
2026-05-08 17:45:25 +03:00
|
|
|
|
const router = createRouter({
|
|
|
|
|
|
history: createMemoryHistory(),
|
|
|
|
|
|
routes: [{ path: '/deals', component: DealsView }],
|
|
|
|
|
|
});
|
|
|
|
|
|
await router.push('/deals');
|
|
|
|
|
|
await router.isReady();
|
2026-05-08 18:29:11 +03:00
|
|
|
|
// 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).
|
2026-05-08 17:45:25 +03:00
|
|
|
|
return mount(DealsView, {
|
2026-05-08 18:29:11 +03:00
|
|
|
|
global: {
|
|
|
|
|
|
plugins: [createVuetify(), router],
|
2026-05-09 05:33:21 +03:00
|
|
|
|
stubs: { DealDetailDrawer: true, NewDealDialog: true },
|
2026-05-08 18:29:11 +03:00
|
|
|
|
},
|
2026-05-08 17:45:25 +03:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-05-08 17:56:59 +03:00
|
|
|
|
['Все', 'Активные', 'Ждут оплату', 'Закрытые', 'Невалидные'].forEach((label) => expect(text).toContain(label));
|
2026-05-08 17:45:25 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 мин назад');
|
|
|
|
|
|
});
|
2026-05-09 05:33:21 +03:00
|
|
|
|
|
|
|
|
|
|
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([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-09 05:49:34 +03:00
|
|
|
|
it('bulk-export: показывает toast с количеством выбранных сделок + триггерит CSV-download', async () => {
|
2026-05-09 05:33:21 +03:00
|
|
|
|
const wrapper = await mountDeals();
|
|
|
|
|
|
const vm = wrapper.vm as unknown as {
|
|
|
|
|
|
selected: number[];
|
|
|
|
|
|
applyBulkExport: () => void;
|
|
|
|
|
|
exportToastOpen: boolean;
|
|
|
|
|
|
exportToastText: string;
|
|
|
|
|
|
};
|
2026-05-09 05:49:34 +03:00
|
|
|
|
// Шпион на 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(() => {});
|
|
|
|
|
|
|
2026-05-09 05:33:21 +03:00
|
|
|
|
vm.selected = [1, 2, 3, 4];
|
|
|
|
|
|
await flushPromises();
|
|
|
|
|
|
vm.applyBulkExport();
|
2026-05-09 05:49:34 +03:00
|
|
|
|
|
|
|
|
|
|
expect(createUrlSpy).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(clickSpy).toHaveBeenCalledTimes(1);
|
2026-05-09 05:33:21 +03:00
|
|
|
|
expect(vm.exportToastOpen).toBe(true);
|
|
|
|
|
|
expect(vm.exportToastText).toContain('4');
|
2026-05-09 05:49:34 +03:00
|
|
|
|
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('Нет выбранных');
|
2026-05-09 05:33:21 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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<string, unknown>) => 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([]);
|
|
|
|
|
|
});
|
2026-05-08 17:45:25 +03:00
|
|
|
|
});
|