ab1c048ded
Фича «Конкурентное поле» на dev до уровня прототипа 2026-06-29-konkurentnoe-pole-proto.html.
Данные: box (proposal|field) на competitors+sources; phone_type city/mobile/tollfree рядом
с phone_kind (вариант C). 3 миграции, дефолты тарифов 300/50.
API (AutopodborController): GET /field (+счётчики), GET /proposals, PATCH/DELETE competitors
и sources с гвардами активного проекта, переключение box, POST /competitors/manual (+directory_urls),
competitor(id) обогащён box+project-статусом; projectStatus отдаёт limit/delivered/days/regions.
Смена источника проекта = PATCH /api/projects/{id} (реальный гвард слепка §14.10).
Фронт: FieldWorkspaceScreen/FieldCompetitorScreen/FieldProposalsScreen/FieldManualCompetitorScreen
+ field-shared.css (Forest) + AutopodborServicesPanel в Биллинге. Дословно по прототипу: подзаголовки,
баннер предложений, баннер правил времени 18:00 МСК, Справочник 2ГИС·Яндекс, статус проекта
5/день·заявки, окна сбора с ценами 300/50 + «что известно», полные формы. Пункт меню «Конкурентное поле».
Тесты: backend автоподбор 80/80, фронт автоподбор 49/49. Движок шага 2 = заглушка FakeCompetitorAgent.
OmegaDemoFieldSeeder — только для визуальной проверки (НЕ на прод).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
183 lines
8.7 KiB
TypeScript
183 lines
8.7 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { mount } from '@vue/test-utils';
|
|
import { createPinia, setActivePinia } from 'pinia';
|
|
import { createVuetify } from 'vuetify';
|
|
import { reactive, ref } from 'vue';
|
|
|
|
vi.mock('../../resources/js/api/autopodbor');
|
|
import FieldCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue';
|
|
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
|
|
|
|
const vuetify = createVuetify();
|
|
|
|
function makeNav(competitorId: number | null = 3) {
|
|
return {
|
|
go: vi.fn(),
|
|
ctx: reactive({ competitorId, editProjectId: null, selectedSourceIds: [] as number[] }),
|
|
screen: ref('fieldcompetitor'),
|
|
};
|
|
}
|
|
|
|
function src(over: Partial<any> = {}) {
|
|
return {
|
|
id: 10,
|
|
competitor_id: 3,
|
|
signal_type: 'site',
|
|
identifier: 'okna.ru',
|
|
phone_kind: null,
|
|
phone_type: null,
|
|
box: 'field',
|
|
provenance_url: null,
|
|
provenance_label: null,
|
|
created_project_id: null,
|
|
project: null,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function proj(over: Partial<any> = {}) {
|
|
return {
|
|
id: 100, name: 'P', signal_identifier: 'okna.ru', is_active: true, paused_at: null,
|
|
preflight_blocked_at: null, daily_limit_target: 5, delivered_in_month: 0, delivery_days_mask: 127, regions: [24],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function seed(store: any, sources: any[], comp: Partial<any> = {}) {
|
|
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => {
|
|
store.competitor = { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 90, ...comp } as any;
|
|
store.sources = sources as any;
|
|
});
|
|
}
|
|
|
|
function mountFc(nav: any) {
|
|
return mount(FieldCompetitorScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
|
|
}
|
|
|
|
describe('FieldCompetitorScreen', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia());
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('грузит конкурента и источники в работе', async () => {
|
|
const store = useAutopodborStore();
|
|
seed(store, [src({ id: 10, identifier: 'okna.ru' })]);
|
|
const w = mountFc(makeNav(3));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(store.loadCompetitor).toHaveBeenCalledWith(3);
|
|
expect(w.text()).toContain('Окна Комфорт');
|
|
expect(w.text()).toContain('okna.ru');
|
|
});
|
|
|
|
it('источник без проекта показывает «Создать проект» и открывает окно создания', async () => {
|
|
const store = useAutopodborStore();
|
|
seed(store, [src({ id: 10, project: null })]);
|
|
const nav = makeNav(3);
|
|
const w = mountFc(nav);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
const btn = w.findAll('button').find((b) => b.text() === 'Создать проект');
|
|
expect(btn).toBeTruthy();
|
|
await btn!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(w.text()).toContain('Создать проект из источника');
|
|
expect(w.text()).toContain('Дни недели приёма');
|
|
});
|
|
|
|
it('активный проект → «Приостановить» зовёт toggleProjectActive(false)', async () => {
|
|
const store = useAutopodborStore();
|
|
seed(store, [src({ id: 10, project: proj({ id: 100, is_active: true }) })]);
|
|
const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue();
|
|
const w = mountFc(makeNav(3));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
const btn = w.findAll('button').find((b) => b.text() === 'Приостановить');
|
|
await btn!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(toggleSpy).toHaveBeenCalledWith(100, false);
|
|
});
|
|
|
|
it('телефон показывает значок и тип номера', async () => {
|
|
const store = useAutopodborStore();
|
|
seed(store, [src({ id: 11, signal_type: 'call', identifier: '78432001122', phone_kind: 'substitute', phone_type: 'city' })]);
|
|
const w = mountFc(makeNav(3));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(w.text()).toContain('🎭');
|
|
expect(w.text()).toContain('городской');
|
|
});
|
|
|
|
it('вкладка «Предложения» показывает источники-предложения и переносит «В работу»', async () => {
|
|
const store = useAutopodborStore();
|
|
seed(store, [src({ id: 12, box: 'proposal', identifier: 'prop.ru' })]);
|
|
const moveSpy = vi.spyOn(store, 'moveSourceToBox').mockResolvedValue();
|
|
const w = mountFc(makeNav(3));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
const propTab = w.findAll('button').find((b) => b.text().includes('Предложения'));
|
|
await propTab!.trigger('click');
|
|
expect(w.text()).toContain('prop.ru');
|
|
const btn = w.findAll('button').find((b) => b.text().includes('В источники'));
|
|
await btn!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(moveSpy).toHaveBeenCalledWith(3, 12, 'field');
|
|
});
|
|
|
|
it('источник с проектом показывает живой источник проекта и меняет через change_source', async () => {
|
|
const store = useAutopodborStore();
|
|
seed(store, [
|
|
src({ id: 10, signal_type: 'site', identifier: 'old.ru', project: proj({ id: 100, signal_identifier: 'live.ru', is_active: true }) }),
|
|
]);
|
|
const changeSpy = vi.spyOn(store, 'changeProjectSource').mockResolvedValue({ source_change_message: 'Лиды дойдут.' });
|
|
const w = mountFc(makeNav(3));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
// карточка показывает источник проекта, а не old.ru
|
|
expect(w.text()).toContain('live.ru');
|
|
|
|
const editBtn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник'));
|
|
await editBtn!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(w.text()).toContain('Сменить источник?');
|
|
|
|
const input = w.find('.ld-modal input');
|
|
await input.setValue('new.ru');
|
|
// первый клик «Сохранить» — показывает подтверждение
|
|
let save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сохранить');
|
|
await save!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(w.text()).toContain('Подтвердите смену источника');
|
|
// второй клик «Сменить источник» — выполняет change_source
|
|
save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сменить источник');
|
|
await save!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(changeSpy).toHaveBeenCalledWith(100, 'new.ru');
|
|
});
|
|
|
|
it('массовое «Приостановить выбранные» паузит проекты выбранных источников', async () => {
|
|
const store = useAutopodborStore();
|
|
seed(store, [
|
|
src({ id: 10, project: proj({ id: 100, signal_identifier: 'a.ru', is_active: true }) }),
|
|
src({ id: 11, identifier: 'b.ru', project: proj({ id: 101, signal_identifier: 'b.ru', is_active: true }) }),
|
|
]);
|
|
const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue();
|
|
const w = mountFc(makeNav(3));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
const boxes = w.findAll('.ld-pick');
|
|
await boxes[0].trigger('change');
|
|
await boxes[1].trigger('change');
|
|
const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Приостановить'));
|
|
await btn!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(toggleSpy).toHaveBeenCalledWith(100, false);
|
|
expect(toggleSpy).toHaveBeenCalledWith(101, false);
|
|
});
|
|
|
|
it('окно «Изменить источник» открывается с залоченным типом', async () => {
|
|
const store = useAutopodborStore();
|
|
seed(store, [src({ id: 10, signal_type: 'site', identifier: 'okna.ru' })]);
|
|
const w = mountFc(makeNav(3));
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
const btn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник'));
|
|
await btn!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(w.text()).toContain('тип не меняется');
|
|
});
|
|
});
|