Files
portal/app/tests/Frontend/SalesProspectBoard.spec.ts
T
Дмитрий 9ee4e9a79f feat(воронка продаж): корзина, дробь у «Отказа», фильтр по датам — экраны
- 12-я колонка «Корзина», результат «В корзину» просит только причину;
- в шапке «Отказа» дробь «4/2» с подсказкой «из них 2 после ручного
  тестирования»; при нуле дробь не рисуется — «69/0» это шум;
- у «Выслано КП» поле даты подписано «если договорились» и необязательно;
- орган фильтра по датам на ОБОИХ экранах, два режима + период.

Приёмка глазами пройдена живьём по всем семи пунктам, включая «Воронку отдела»
(снимки в docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/).
Браузер поймал то, чего не видели тесты: при смене режима оставался прежний
период, и «что менялось за завтра» давало пустую доску — теперь период
возвращается к «Сегодня», на это заведён отдельный тест.

Прогон: сервер 1245/1245, фронт 1567/1567. Журнал схемы — v9.31.
2026-08-01 13:29:10 +03:00

227 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { describe, expect, it } from 'vitest';
import SalesProspectBoard from '../../resources/js/components/sales/SalesProspectBoard.vue';
import type { SalesProspect } from '../../resources/js/api/sales';
const vuetify = createVuetify();
function p(id: number, stage: string, overrides: Partial<SalesProspect> = {}): SalesProspect {
return {
id,
sales_user_id: 1,
stage,
prev_stage: null,
firm_name: `Фирма ${id}`,
city: 'Ростов',
phone: null,
site: null,
inn: null,
rating_label: 'горячая',
source: 'search',
legal_name: null,
contacts: [],
payload: {},
next_call_at: null,
reason: null,
registered_email: null,
test_channel: null,
test_target: null,
kp_channel: null,
kp_target: null,
notes: null,
...overrides,
};
}
describe('SalesProspectBoard', () => {
it('рисует 12 колонок и раскладывает карточки по стадиям', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'new'), p(2, 'negotiation'), p(3, 'new')] },
global: { plugins: [vuetify] },
});
expect(w.findAll('.prospect-column').length).toBe(12);
expect(w.text()).toContain('Новые');
expect(w.text()).toContain('Взят в работу');
expect(w.text()).toContain('Тестирование ручное');
expect(w.text()).toContain('Выслано КП');
expect(w.text()).toContain('Корзина');
expect(w.text()).toContain('Фирма 1');
});
/** Заголовок колонки по её названию — счётчик живёт рядом с ним. */
function head(w: ReturnType<typeof mount>, title: string): string {
const col = w.findAll('.prospect-column-head').find((h) => h.text().includes(title));
return col?.text() ?? '';
}
it('«Отказ»: через дробь сколько отказов пришло из ручного тестирования', () => {
const w = mount(SalesProspectBoard, {
props: {
prospects: [
p(1, 'rejected', { prev_stage: 'manual_testing' }),
p(2, 'rejected', { prev_stage: 'negotiation' }),
p(3, 'rejected', { prev_stage: null }),
],
},
global: { plugins: [vuetify] },
});
expect(head(w, 'Отказ')).toContain('3/1');
});
it('«Отказ» без таких карточек — просто число, без дроби', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'rejected', { prev_stage: 'negotiation' })] },
global: { plugins: [vuetify] },
});
expect(head(w, 'Отказ')).not.toContain('/');
});
it('дробь — только у «Отказа»: в других колонках приезд из ручного тестирования её не рисует', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'no_answer', { prev_stage: 'manual_testing' })] },
global: { plugins: [vuetify] },
});
expect(head(w, 'Не смогли дозвониться')).not.toContain('/');
});
it('бейдж происхождения: «своя» у менеджера, «от начальника» из поиска', () => {
const w = mount(SalesProspectBoard, {
props: {
prospects: [
p(1, 'new', { firm_name: 'МОЯ', source: 'manager' }),
p(2, 'new', { firm_name: 'ОТДАЛИ', source: 'search' }),
],
},
global: { plugins: [vuetify] },
});
const cards = w.findAll('.prospect-card');
const own = cards.find((c) => c.text().includes('МОЯ'));
const given = cards.find((c) => c.text().includes('ОТДАЛИ'));
expect(own?.text()).toContain('своя');
expect(given?.text()).toContain('от начальника');
});
it('клик по карточке эмитит open с прогнозом', async () => {
const w = mount(SalesProspectBoard, { props: { prospects: [p(5, 'new')] }, global: { plugins: [vuetify] } });
await w.find('.prospect-card').trigger('click');
expect(w.emitted('open')?.[0]?.[0]).toMatchObject({ id: 5 });
});
it('просроченный созвон подсвечивается классом overdue', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(9, 'negotiation', { next_call_at: '2000-01-01T00:00:00+03:00' })] },
global: { plugins: [vuetify] },
});
expect(w.find('.prospect-card.overdue').exists()).toBe(true);
});
it('колонка «Переговоры» сортируется по ближайшему созвону (ASC, просроченные сверху)', () => {
// id по убыванию (как отдаёт API), но даты созвона вперемешку.
const w = mount(SalesProspectBoard, {
props: {
prospects: [
p(30, 'negotiation', { firm_name: 'ПОЗЖЕ', next_call_at: '2026-08-01T10:00:00+03:00' }),
p(20, 'negotiation', { firm_name: 'ПРОСРОЧЕНО', next_call_at: '2000-01-01T00:00:00+03:00' }),
p(10, 'negotiation', { firm_name: 'СКОРО', next_call_at: '2026-07-16T10:00:00+03:00' }),
],
},
global: { plugins: [vuetify] },
});
const negColumn = w.findAll('.prospect-column')[2]; // 0=Новые, 1=Взят в работу, 2=Переговоры
const names = negColumn.findAll('.prospect-card-name').map((n) => n.text());
expect(names).toEqual(['ПРОСРОЧЕНО', 'СКОРО', 'ПОЗЖЕ']);
});
it('карточки «Переговоры» без даты созвона — в конце колонки', () => {
const w = mount(SalesProspectBoard, {
props: {
prospects: [
p(40, 'negotiation', { firm_name: 'БЕЗ_ДАТЫ', next_call_at: null }),
p(41, 'negotiation', { firm_name: 'С_ДАТОЙ', next_call_at: '2026-07-16T10:00:00+03:00' }),
],
},
global: { plugins: [vuetify] },
});
const negColumn = w.findAll('.prospect-column')[2];
const names = negColumn.findAll('.prospect-card-name').map((n) => n.text());
expect(names).toEqual(['С_ДАТОЙ', 'БЕЗ_ДАТЫ']);
});
});
describe('сайт ссылкой на плитке', () => {
it('домен без схемы открывается по https', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'new', { site: 'omdent.ru' })] },
global: { plugins: [vuetify] },
});
const a = w.find('.prospect-card-site a');
expect(a.exists()).toBe(true);
expect(a.attributes('href')).toBe('https://omdent.ru');
expect(a.attributes('target')).toBe('_blank');
expect(a.attributes('rel')).toContain('noopener');
expect(a.text()).toBe('omdent.ru');
});
it('готовую ссылку не портим', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'new', { site: 'http://vitadent-omsk.ru' })] },
global: { plugins: [vuetify] },
});
expect(w.find('.prospect-card-site a').attributes('href')).toBe('http://vitadent-omsk.ru');
});
it('без сайта строки нет', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'new', { site: null })] },
global: { plugins: [vuetify] },
});
expect(w.find('.prospect-card-site').exists()).toBe(false);
});
it('клик по ссылке НЕ открывает карточку', async () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'new', { site: 'omdent.ru' })] },
global: { plugins: [vuetify] },
});
await w.find('.prospect-card-site a').trigger('click');
expect(w.emitted('open')).toBeFalsy();
await w.find('.prospect-card-name').trigger('click');
expect(w.emitted('open')).toBeTruthy();
});
});
// Пометка «греется/прогрет» + значки каналов на карточке воронки. Блок warming
// приходит с сервера (кусок B, форма {active, channels:{code:{live,count}}}),
// рисует его единый WarmingBadges.
describe('пометка прогрева на карточке', () => {
it('карточка с warming показывает бейдж состояния и значки каналов', () => {
const w = mount(SalesProspectBoard, {
props: {
prospects: [
p(1, 'new', {
warming: {
active: true,
channels: { yandex: { live: true, count: 1 }, sms: { live: false, count: 2 } },
},
}),
],
},
global: { plugins: [vuetify] },
});
expect(w.find('[data-testid="warming-state-badge"]').exists()).toBe(true);
expect(w.find('[data-testid="warming-badge-sms"]').exists()).toBe(true);
expect(w.find('[data-testid="warming-badge-sms"]').text()).toContain('×2');
});
it('карточка без warming пометку не рисует', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'new')] },
global: { plugins: [vuetify] },
});
expect(w.find('[data-testid="warming-state-badge"]').exists()).toBe(false);
expect(w.find('[data-testid="prospect-warming"]').exists()).toBe(false);
});
});