Files
portal/app/tests/Frontend/DealsView.spec.ts
T
Дмитрий 3bedf10449 feat(deals): drawer виден при selected≤1, bulk-полоса только при ≥2
UX-request 18.05.2026:
- selected.length === 1 → drawer авто-открывается на этой сделке,
  bulk-полоса скрыта (одну сделку проще менять через drawer)
- selected.length >= 2 → drawer закрыт, bulk-полоса видна
- selected.length === 0 → как сейчас (drawer по row-click)

Vitest 12/12 на DealsView.spec (2 новых теста + 10 существующих, none broken).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:14:03 +03:00

182 lines
9.1 KiB
TypeScript

import { describe, it, expect, vi, afterEach } 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 * as dealsApi from '../../resources/js/api/deals';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
function apiDeal(id: number, over: Partial<dealsApi.ApiDeal> = {}): dealsApi.ApiDeal {
return {
id, tenant_id: 42, project_id: 1, project_name: 'Окна', phone: `+7 916 000-00-0${id}`,
contact_name: null, status: 'new', manager_id: null, manager_name: null,
manager_initials: null, received_at: '2026-05-15T09:00:00+00:00',
comment: null, city: null, project_signal_type: 'call', next_reminder_at: null,
...over,
};
}
async function mountDeals(deals: dealsApi.ApiDeal[] = [apiDeal(1), apiDeal(2)], total = 2) {
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
const dealsSpy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals, total, limit: 20, offset: 0 });
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([
{ id: 1, name: 'Окна', tag: null, type: 'supplier' },
]);
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push('/deals');
await router.isReady();
const wrapper = mount(DealsView, {
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
});
await flushPromises();
// Reset call history so subsequent vi.spyOn calls in tests start from count=0.
dealsSpy.mockClear();
return wrapper;
}
describe('DealsView.vue — реестр лидов', () => {
it('заголовок «Сделки»', async () => {
expect((await mountDeals()).find('h1').text()).toBe('Сделки');
});
it('панель экспорта: поля дат + кнопки Excel/CSV', async () => {
const w = await mountDeals();
expect(w.find('[data-testid="export-from"]').exists()).toBe(true);
expect(w.find('[data-testid="export-to"]').exists()).toBe(true);
expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true);
expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true);
});
it('селектор «Показывать по» с вариантами 10/20/50', async () => {
const w = await mountDeals();
const toggle = w.find('[data-testid="perpage-toggle"]');
expect(toggle.exists()).toBe(true);
['10', '20', '50'].forEach((n) => expect(toggle.text()).toContain(n));
});
it('НЕТ кнопки «Новая сделка» и режима «Корзина»', async () => {
const w = await mountDeals();
// «Новая сделка» присутствует как статус-пилюля в таблице (slug `new` —
// редизайн воронки 2026-05-17), поэтому проверяем отсутствие именно
// КНОПКИ создания сделки: ручное создание убрано в реестре лидов.
const buttons = w.findAll('button');
expect(buttons.some((b) => b.text().includes('Новая сделка'))).toBe(false);
expect(w.text()).not.toContain('Корзина');
});
it('загружает сделки в dealsState через API', async () => {
const w = await mountDeals([apiDeal(1), apiDeal(2), apiDeal(3)], 3);
const vm = w.vm as unknown as { dealsState: MockDeal[]; total: number };
expect(vm.dealsState.length).toBe(3);
expect(vm.total).toBe(3);
});
it('openPanel выбирает сделку, повторный клик закрывает', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
dealsState: MockDeal[]; panelOpen: boolean; selectedDeal: MockDeal | null;
openPanel: (d: MockDeal) => void;
};
vm.openPanel(vm.dealsState[0]);
expect(vm.panelOpen).toBe(true);
expect(vm.selectedDeal?.id).toBe(1);
vm.openPanel(vm.dealsState[0]);
expect(vm.panelOpen).toBe(false);
});
it('при selected=1 drawer авто-открывается, bulk-полоса скрыта (18.05.2026 ux)', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
selected: number[]; panelOpen: boolean; selectedDeal: MockDeal | null;
};
vm.selected = [1];
await flushPromises();
expect(vm.panelOpen).toBe(true);
expect(vm.selectedDeal?.id).toBe(1);
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(false);
});
it('при selected≥2 drawer закрывается, bulk-полоса видна (18.05.2026 ux)', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
selected: number[]; panelOpen: boolean; dealsState: MockDeal[];
openPanel: (d: MockDeal) => void;
};
vm.openPanel(vm.dealsState[0]);
expect(vm.panelOpen).toBe(true);
vm.selected = [1, 2];
await flushPromises();
expect(vm.panelOpen).toBe(false);
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
});
it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
selected: number[]; dealsState: MockDeal[]; applyBulkStatus: (s: string) => Promise<void>;
};
vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 2, requested: 2, status: 'viewed' });
vm.selected = [1, 2];
await flushPromises();
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
await vm.applyBulkStatus('viewed');
expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('viewed');
});
it('exportByRange xlsx вызывает exportDealsByRange', async () => {
const w = await mountDeals();
const spy = vi.spyOn(dealsApi, 'exportDealsByRange').mockResolvedValue(new Blob());
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:m'), configurable: true });
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true });
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const vm = w.vm as unknown as { exportByRange: (f: string) => Promise<void>; exportToastOpen: boolean };
await vm.exportByRange('xlsx');
expect(spy).toHaveBeenCalledOnce();
expect(vm.exportToastOpen).toBe(true);
});
it('смена фильтра вызывает loadDeals ровно один раз (без двойного fetch)', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as { filterStatus: string | null; page: number };
// Установим spy до смены page, чтобы перехватить все вызовы
const spy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals: [], total: 0, limit: 20, offset: 0 });
// Переходим на страницу 3 — это вызовет watch(page) → loadDeals один раз
vm.page = 3;
await flushPromises();
spy.mockClear(); // сбрасываем счётчик: интересует только смена фильтра
// Смена фильтра при page=3: A10 fix должен лишь сбросить page→1 (без прямого loadDeals),
// затем watch(page) делает ровно один fetch
vm.filterStatus = 'viewed';
await flushPromises();
expect(spy).toHaveBeenCalledTimes(1);
});
it('loadDeals reject → dealsState пустой + fetchError', async () => {
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([]);
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
await router.push('/deals');
await router.isReady();
const w = mount(DealsView, {
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
});
await flushPromises();
const vm = w.vm as unknown as { dealsState: MockDeal[]; fetchError: boolean };
expect(vm.dealsState.length).toBe(0);
expect(vm.fetchError).toBe(true);
});
});
afterEach(() => vi.restoreAllMocks());