9a9164fee2
Секция «На актуализацию»: дельта-карточка показывает текущие сайты и карточки фирмы поля + новый сайт кликабельными ссылками, кнопки Добавить и Заменить ведут через подтверждение «Создать проект?» в существующую форму CreateScreen с подставленным новым источником. Имя фирмы берётся из поля, не перезатирается.
Бэкенд: endpoint POST /competitors/{id}/actualize action=add|replace new_site. add заводит сайт-источник рядом со старым, replace архивирует старые сайт-источники и заводит новый, при активном проекте по старому сайту возвращает 409 manage_via_project. /proposals обогащён полем matched с текущими сайтами и карточками совпавшей фирмы.
По TDD. Бэкенд автоподбора 254/254, фронт автоподбора 64/64, ESLint чисто, Pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
243 lines
12 KiB
TypeScript
243 lines
12 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 FieldProposalsScreen from '../../resources/js/views/autopodbor/screens/FieldProposalsScreen.vue';
|
|
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
|
|
import { autopodborErrorMessage } from '../../resources/js/api/autopodbor';
|
|
import type { ActualizeCard, CompetitorDto, ProposalGroups } from '../../resources/js/api/autopodbor';
|
|
|
|
const vuetify = createVuetify();
|
|
|
|
function makeNav() {
|
|
return {
|
|
go: vi.fn(),
|
|
ctx: reactive({ competitorId: null as number | null, selectedSourceIds: [] as number[] }),
|
|
screen: ref('field-proposals'),
|
|
};
|
|
}
|
|
|
|
/** Карточка группы «на актуализацию»: находка с новым сайтом + на кого в поле похожа. */
|
|
function actualizeCard(over: Partial<ActualizeCard> = {}): ActualizeCard {
|
|
return {
|
|
...comp({ id: 20, name: 'Яричъ (нашли заново)', site_url: 'yarich-new.ru', directory_urls: [] }),
|
|
matched_id: 5,
|
|
delta_keys: ['s:yarich-new.ru'],
|
|
matched: { id: 5, name: 'Яричъ', sites: ['yarich.ru'], directory_urls: ['https://2gis.ru/firm/111'] },
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function comp(over: Partial<CompetitorDto> = {}): CompetitorDto {
|
|
return {
|
|
id: 1,
|
|
name: 'Окна',
|
|
description: 'Окна ПВХ под ключ',
|
|
is_federal: false,
|
|
relevance_pct: 80,
|
|
origin: 'auto',
|
|
box: 'proposal',
|
|
site_url: 'okna.ru',
|
|
directory_urls: ['https://2gis.ru/firm/1', 'https://yandex.ru/maps/1'],
|
|
studied_at: null,
|
|
study_run_id: null,
|
|
search_run_id: 5,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
/** Разложить набор предложений по группам (актуализация/архив пусты, если не заданы). */
|
|
function groups(over: Partial<ProposalGroups> = {}): ProposalGroups {
|
|
return { new: [], actualize: [], archived: [], ...over };
|
|
}
|
|
|
|
function mountP(nav: ReturnType<typeof makeNav>) {
|
|
return mount(FieldProposalsScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
|
|
}
|
|
|
|
describe('FieldProposalsScreen', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia());
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('грузит предложения группами и показывает карточку-плитку с похожестью и Справочником', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => {
|
|
store.proposalGroups = groups({ new: [comp({ id: 1, name: 'Окна Комфорт', relevance_pct: 80 })] });
|
|
});
|
|
const w = mountP(makeNav());
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(store.loadProposalGroups).toHaveBeenCalled();
|
|
expect(w.find('.ld-card').exists()).toBe(true);
|
|
expect(w.text()).toContain('Окна Комфорт');
|
|
expect(w.text()).toContain('80');
|
|
expect(w.text()).toContain('Справочник');
|
|
expect(w.text()).toContain('2ГИС');
|
|
expect(w.text()).toContain('Яндекс.Карты');
|
|
});
|
|
|
|
it('«В поле →» по конкуренту зовёт moveCompetitorToBox(field)', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => {
|
|
store.proposalGroups = groups({ new: [comp({ id: 7 })] });
|
|
});
|
|
const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue();
|
|
const w = mountP(makeNav());
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
const btn = w.find('.ld-cfoot button');
|
|
await btn.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(moveSpy).toHaveBeenCalledWith(7, 'field');
|
|
});
|
|
|
|
it('пусто показывает заглушку', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => {
|
|
store.proposalGroups = groups();
|
|
});
|
|
const w = mountP(makeNav());
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(w.text()).toContain('Предложений пока нет');
|
|
});
|
|
|
|
it('«Собрать конкурентов» открывает окно сбора с ценой 300 ₽ (не уходит на старую форму)', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockResolvedValue();
|
|
store.prices = { search: '300', study: '50' };
|
|
const nav = makeNav();
|
|
const w = mountP(nav);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
const btn = w.findAll('button').find((b) => b.text().includes('Собрать конкурентов'));
|
|
await btn!.trigger('click');
|
|
expect(w.find('.ld-ovl').exists()).toBe(true);
|
|
expect(w.text()).toContain('Сбор конкурентов');
|
|
expect(w.text()).toContain('300 ₽');
|
|
expect(nav.go).not.toHaveBeenCalledWith('autoform');
|
|
});
|
|
|
|
it('массово переносит выбранных в поле при ≥2', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => {
|
|
store.proposalGroups = groups({ new: [comp({ id: 1 }), comp({ id: 2 })] });
|
|
});
|
|
const moveSpy = vi.spyOn(store, 'moveCompetitorToBox').mockResolvedValue();
|
|
const w = mountP(makeNav());
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
const boxes = w.findAll('.ld-pick');
|
|
await boxes[0].trigger('change');
|
|
await boxes[1].trigger('change');
|
|
expect(w.find('.ld-bulkbar').exists()).toBe(true);
|
|
const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Перенести'));
|
|
await btn!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(moveSpy).toHaveBeenCalledWith(1, 'field');
|
|
expect(moveSpy).toHaveBeenCalledWith(2, 'field');
|
|
});
|
|
|
|
it('«Изменить» открывает окно правки карточки конкурента', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => {
|
|
store.proposalGroups = groups({ new: [comp({ id: 1, name: 'Окна Комфорт' })] });
|
|
});
|
|
const w = mountP(makeNav());
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
const link = w.findAll('.ld-link').find((b) => b.text().includes('Изменить'));
|
|
await link!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(w.find('.ld-ovl').exists()).toBe(true);
|
|
expect(w.text()).toContain('карточку конкурента');
|
|
});
|
|
|
|
it('группа «На актуализацию»: показывает фирму поля, текущий и новый сайты ссылками + «проверьте»', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => {
|
|
store.proposalGroups = groups({ actualize: [actualizeCard()] });
|
|
});
|
|
const w = mountP(makeNav());
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(w.text()).toContain('На актуализацию');
|
|
expect(w.text()).toContain('Яричъ'); // имя из поля
|
|
expect(w.text()).toContain('Проверьте');
|
|
// текущий и новый сайты — кликабельными ссылками
|
|
const links = w.findAll('a').map((a) => a.text());
|
|
expect(links).toContain('yarich.ru'); // текущий
|
|
expect(links).toContain('yarich-new.ru'); // новый (дельта)
|
|
// две кнопки решения
|
|
const labels = w.findAll('button').map((b) => b.text());
|
|
expect(labels).toContain('Добавить');
|
|
expect(labels).toContain('Заменить');
|
|
});
|
|
|
|
it('«Добавить» → подтверждение «Создать проект?» → зовёт actualizeCompetitor(add) и ведёт в форму', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => {
|
|
store.proposalGroups = groups({ actualize: [actualizeCard({ matched_id: 5, site_url: 'yarich-new.ru' })] });
|
|
});
|
|
const actSpy = vi.spyOn(store, 'actualizeCompetitor').mockResolvedValue({ source_id: 77 });
|
|
const loadSpy = vi.spyOn(store, 'loadCompetitor').mockResolvedValue();
|
|
const nav = makeNav();
|
|
const w = mountP(nav);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
const addBtn = w.findAll('button').find((b) => b.text() === 'Добавить');
|
|
await addBtn!.trigger('click');
|
|
// модалка подтверждения
|
|
expect(w.text()).toContain('Создать проект');
|
|
const yes = w.findAll('button').find((b) => b.text() === 'Да, создать проект');
|
|
await yes!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(actSpy).toHaveBeenCalledWith(5, 'add', 'yarich-new.ru');
|
|
expect(loadSpy).toHaveBeenCalledWith(5);
|
|
expect(nav.ctx.selectedSourceIds).toEqual([77]);
|
|
expect(nav.ctx.competitorId).toBe(5);
|
|
expect(nav.go).toHaveBeenCalledWith('create');
|
|
});
|
|
|
|
it('«Заменить» при активном проекте (409 manage_via_project) — показывает подсказку, в форму не ведёт', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => {
|
|
store.proposalGroups = groups({ actualize: [actualizeCard({ matched_id: 5, site_url: 'yarich-new.ru' })] });
|
|
});
|
|
// бэкенд вернул 409 → маппер отдаёт человеческую подсказку (мокаем её возврат)
|
|
const err = Object.assign(new Error('409'), {
|
|
isAxiosError: true,
|
|
response: { data: { error: 'manage_via_project' } },
|
|
});
|
|
vi.spyOn(store, 'actualizeCompetitor').mockRejectedValue(err);
|
|
vi.mocked(autopodborErrorMessage).mockReturnValue('Смена источника — через «Сменить источник» в проекте.');
|
|
const nav = makeNav();
|
|
const w = mountP(nav);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
const repl = w.findAll('button').find((b) => b.text() === 'Заменить');
|
|
await repl!.trigger('click');
|
|
const yes = w.findAll('button').find((b) => b.text() === 'Да, создать проект');
|
|
await yes!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(nav.go).not.toHaveBeenCalledWith('create');
|
|
expect(w.text()).toContain('Сменить источник'); // текст ошибки manage_via_project
|
|
});
|
|
|
|
it('группа «Ранее удалённые» с кнопкой «Удалить всех» зовёт dismissArchivedGroup', async () => {
|
|
const store = useAutopodborStore();
|
|
vi.spyOn(store, 'loadProposalGroups').mockImplementation(async () => {
|
|
store.proposalGroups = groups({ archived: [comp({ id: 9, name: 'Бета заново' })] });
|
|
});
|
|
const dismissSpy = vi.spyOn(store, 'dismissArchivedGroup').mockResolvedValue();
|
|
const w = mountP(makeNav());
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(w.text()).toContain('Ранее удалённые');
|
|
expect(w.text()).toContain('ранее удалён');
|
|
const btn = w.findAll('button').find((b) => b.text().includes('Удалить всех'));
|
|
await btn!.trigger('click');
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(dismissSpy).toHaveBeenCalled();
|
|
});
|
|
});
|