4387333118
Фича «Конкурентное поле» на 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>
158 lines
7.4 KiB
TypeScript
158 lines
7.4 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { setActivePinia, createPinia } from 'pinia';
|
|
|
|
vi.mock('../../resources/js/api/autopodbor');
|
|
import * as api from '../../resources/js/api/autopodbor';
|
|
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
|
|
|
|
describe('autopodborStore', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia());
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('loadState заполняет enabled/prices/runs', async () => {
|
|
(api.fetchState as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
enabled: true,
|
|
prices: { search: '500', study: '300' },
|
|
runs: [{ id: 1, kind: 'search', status: 'done' }],
|
|
});
|
|
const s = useAutopodborStore();
|
|
await s.loadState();
|
|
expect(s.enabled).toBe(true);
|
|
expect(s.prices.search).toBe('500');
|
|
expect(s.runs.length).toBe(1);
|
|
});
|
|
|
|
it('search кладёт currentRun', async () => {
|
|
(api.startSearch as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 9, kind: 'search', status: 'queued' });
|
|
const s = useAutopodborStore();
|
|
await s.search({ region_code: 16, examples: ['okna.ru'], about_self: [], include_federal: true });
|
|
expect(api.startSearch).toHaveBeenCalled();
|
|
expect(s.currentRun?.id).toBe(9);
|
|
});
|
|
|
|
it('loadCompetitor кладёт competitor и sources', async () => {
|
|
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
competitor: { id: 3, name: 'Окна' },
|
|
sources: [{ id: 1, signal_type: 'site' }],
|
|
});
|
|
const s = useAutopodborStore();
|
|
await s.loadCompetitor(3);
|
|
expect(s.competitor?.id).toBe(3);
|
|
expect(s.sources.length).toBe(1);
|
|
});
|
|
|
|
it('pollRun опрашивает до терминального статуса', async () => {
|
|
vi.useFakeTimers();
|
|
(api.fetchRun as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ id: 5, kind: 'search', status: 'running' })
|
|
.mockResolvedValueOnce({ id: 5, kind: 'search', status: 'done' });
|
|
const s = useAutopodborStore();
|
|
const p = s.pollRun(5);
|
|
// прокрутить таймеры и микрозадачи
|
|
await vi.runAllTimersAsync();
|
|
const final = await p;
|
|
expect(final.status).toBe('done');
|
|
expect(s.currentRun?.status).toBe('done');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('makeProjects возвращает созданные проекты', async () => {
|
|
(api.createProjects as ReturnType<typeof vi.fn>).mockResolvedValue([{ id: 1, name: 'Окна Комфорт' }]);
|
|
const s = useAutopodborStore();
|
|
const res = await s.makeProjects({
|
|
source_ids: [1],
|
|
regions: [16],
|
|
daily_limit_target: 20,
|
|
delivery_days_mask: 127,
|
|
launch: false,
|
|
});
|
|
expect(res).toHaveLength(1);
|
|
});
|
|
|
|
// ——— «Конкурентное поле»: рабочее место (два ящика) ———
|
|
|
|
it('loadField кладёт конкурентов поля', async () => {
|
|
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{ id: 7, name: 'Окна', box: 'field', counters: { sources: 2, projects_created: 1, projects_in_work: 1 }, sources: [] },
|
|
]);
|
|
const s = useAutopodborStore();
|
|
await s.loadField();
|
|
expect(s.field).toHaveLength(1);
|
|
expect(s.field[0].counters.projects_in_work).toBe(1);
|
|
});
|
|
|
|
it('moveCompetitorToBox в proposal убирает конкурента из поля', async () => {
|
|
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{ id: 7, name: 'Окна', box: 'field', counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
|
|
]);
|
|
(api.setCompetitorBox as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 7, box: 'proposal' });
|
|
const s = useAutopodborStore();
|
|
await s.loadField();
|
|
await s.moveCompetitorToBox(7, 'proposal');
|
|
expect(api.setCompetitorBox).toHaveBeenCalledWith(7, 'proposal');
|
|
expect(s.field.find((c) => c.id === 7)).toBeUndefined();
|
|
});
|
|
|
|
it('removeCompetitor убирает конкурента из поля', async () => {
|
|
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{ id: 7, name: 'Окна', box: 'field', counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
|
|
]);
|
|
(api.deleteCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
const s = useAutopodborStore();
|
|
await s.loadField();
|
|
await s.removeCompetitor(7);
|
|
expect(api.deleteCompetitor).toHaveBeenCalledWith(7);
|
|
expect(s.field).toHaveLength(0);
|
|
});
|
|
|
|
it('editCompetitor обновляет поля конкурента на месте', async () => {
|
|
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{ id: 7, name: 'Старое', box: 'field', counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] },
|
|
]);
|
|
(api.updateCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 7, name: 'Новое', relevance_pct: 88 });
|
|
const s = useAutopodborStore();
|
|
await s.loadField();
|
|
await s.editCompetitor(7, { name: 'Новое', relevance_pct: 88 });
|
|
expect(s.field[0].name).toBe('Новое');
|
|
expect(s.field[0].relevance_pct).toBe(88);
|
|
});
|
|
|
|
it('addFieldCompetitor добавляет нового конкурента в поле', async () => {
|
|
(api.createManualCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
id: 99, name: 'Ромашка', box: 'field', origin: 'manual',
|
|
});
|
|
const s = useAutopodborStore();
|
|
const c = await s.addFieldCompetitor({ name: 'Ромашка' });
|
|
expect(c.id).toBe(99);
|
|
expect(s.field.find((x) => x.id === 99)?.name).toBe('Ромашка');
|
|
});
|
|
|
|
it('changeProjectSource зовёт ручку проектов и возвращает сообщение', async () => {
|
|
(api.changeProjectSource as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
source_change_message: 'Лиды по старому источнику придут до 30.06, дальше — по новому.',
|
|
});
|
|
const s = useAutopodborStore();
|
|
const res = await s.changeProjectSource(100, 'new.ru');
|
|
expect(api.changeProjectSource).toHaveBeenCalledWith(100, 'new.ru');
|
|
expect(res.source_change_message).toContain('по новому');
|
|
});
|
|
|
|
it('removeSource убирает источник из карточки конкурента в поле', async () => {
|
|
(api.fetchField as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
{
|
|
id: 7, name: 'Окна', box: 'field',
|
|
counters: { sources: 1, projects_created: 0, projects_in_work: 0 },
|
|
sources: [{ id: 50, competitor_id: 7, signal_type: 'site', identifier: 'a.ru', box: 'field', project: null }],
|
|
},
|
|
]);
|
|
(api.deleteSource as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
|
const s = useAutopodborStore();
|
|
await s.loadField();
|
|
await s.removeSource(7, 50);
|
|
expect(api.deleteSource).toHaveBeenCalledWith(50);
|
|
expect(s.field[0].sources).toHaveLength(0);
|
|
});
|
|
});
|