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 { 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 { 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 { return { new: [], actualize: [], archived: [], ...over }; } function mountP(nav: ReturnType) { 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(); }); });