Files
portal/app/tests/Frontend/SalesProspectBoard.spec.ts
T
Дмитрий 80c9ca7e67 feat(витрина B1,B2,B4,B5): летопись эпизодов прогрева + значки в воронке; уборка мёртвых скоупов
Кусок B «витрина прогрева» (спека 2026-07-21 §6), решение владельца — полная летопись:
- B1: таблица sales_ad_audience_warming_episodes + модель + WarmingEpisodeRecorder
  (open/close/record идемпотентно). Схема v8.85, бэкфилл из firm_channels(warming)
  и боевых СМС, guard по источнику. rls-reviewer OK 8/8.
- B2: «Греть»/«Убрать» на площадке открывают/закрывают эпизоды канала.
- B4: warmingByProspect считает значки из летописи (live/count вместо массива каналов),
  тип WarmingBadgeState в sales.ts.
- B5: единый компонент WarmingBadges.vue (идёт/грели раньше/×N) в канбане;
  осиротевший WarmingChannelIcons удалён.
Уборка: убраны мёртвые скоупы forYandex/Vk/Mts + их импорт + тест (боевых вызовов нет).
cspell: +5 пре-существующих слов CHANGELOG в словарь (apk/cvtjpq/hgq/sar/sca).

Проверено: Sales 427/427, composer stan 0, pint/prettier чисто, весь Vue-набор зелёный.
B3 (СМС→эпизод) — отдельным коммитом (СМС-блок правит и параллельная сессия).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:54:09 +03:00

183 lines
8.2 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,
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,
notes: null,
...overrides,
};
}
describe('SalesProspectBoard', () => {
it('рисует 9 колонок (вкл. «Взят в работу») и раскладывает карточки по стадиям', () => {
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(9);
expect(w.text()).toContain('Новые');
expect(w.text()).toContain('Взят в работу');
expect(w.text()).toContain('Фирма 1');
});
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);
});
});