import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mount } from '@vue/test-utils'; import { setActivePinia, createPinia } from 'pinia'; vi.mock('../../resources/js/api/autopodbor', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchCompetitor: vi.fn() }; }); import * as api from '../../resources/js/api/autopodbor'; import type { WhereFound } from '../../resources/js/api/autopodbor'; import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; import FieldCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue'; const makeNav = (competitorId = 10) => ({ go: vi.fn(), ctx: { competitorId, editProjectId: null, selectedSourceIds: [] }, screen: { value: 'field-competitor' }, }); // Источник-предложение (box 'proposal', без проекта) с произвольным списком «где нашли». const makeProposal = (id: number, whereFound: WhereFound[] = []) => ({ id, competitor_id: 10, signal_type: 'call' as const, identifier: `7800100811${id}`, phone_kind: 'real' as const, phone_type: 'tollfree' as const, box: 'proposal' as const, provenance_url: null, provenance_label: null, created_project_id: null, project: null, where_found: whereFound, }); // Источник в работе (box 'field', без активного проекта) — окно удаления должно остаться прежним. const makeWorkSource = (id: number, whereFound: WhereFound[] = []) => ({ id, competitor_id: 10, signal_type: 'site' as const, identifier: `work-${id}.ru`, phone_kind: null, phone_type: null, box: 'field' as const, provenance_url: null, provenance_label: null, created_project_id: null, project: null, where_found: whereFound, }); async function mountWith(sources: unknown[]) { (api.fetchCompetitor as ReturnType).mockResolvedValue({ competitor: { id: 10, name: 'Окна', is_federal: false, relevance_pct: 90, origin: 'auto', box: 'field', site_url: 'okna.ru', directory_urls: [], studied_at: '2026-01-01', study_run_id: 1, search_run_id: 1, description: '' }, sources, }); const store = useAutopodborStore(); await store.loadCompetitor(10); const wrapper = mount(FieldCompetitorScreen, { global: { provide: { autopodborNav: makeNav() }, stubs: { Transition: false } }, }); await new Promise((r) => setTimeout(r, 50)); return { wrapper, store }; } // Место = url карточки/сайта. const CARD = 'https://2gis.ru/firm/card-A'; const SITE = 'https://okna-a.ru'; const OTHER = 'https://2gis.ru/firm/card-B'; beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); describe('FieldCompetitorScreen — удаление предложения показывает пул связанных строк', () => { it('открывает окно-пул со строками из того же места (совпал url)', async () => { const { wrapper } = await mountWith([ makeProposal(1, [{ label: 'карточка 2ГИС', url: CARD }]), makeProposal(2, [{ label: 'карточка 2ГИС', url: CARD }]), makeProposal(3, [{ label: 'карточка 2ГИС', url: OTHER }]), ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.switchTab('sugg'); const clicked = vm.props.find((p: { id: number }) => p.id === 1); vm.openDelete(clicked); await wrapper.vm.$nextTick(); expect(vm.delPool.open).toBe(true); const ids = vm.delPool.items.map((i: { src: { id: number } }) => i.src.id).sort(); expect(ids).toEqual([1, 2]); // 3 — другое место, не в пуле }); it('по умолчанию отмечена только нажатая строка', async () => { const { wrapper } = await mountWith([ makeProposal(1, [{ label: 'карточка 2ГИС', url: CARD }]), makeProposal(2, [{ label: 'карточка 2ГИС', url: CARD }]), ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.switchTab('sugg'); vm.openDelete(vm.props.find((p: { id: number }) => p.id === 1)); await wrapper.vm.$nextTick(); const checked = vm.delPool.items.filter((i: { checked: boolean }) => i.checked).map((i: { src: { id: number } }) => i.src.id); expect(checked).toEqual([1]); }); it('строка без общего места показывает только себя', async () => { const { wrapper } = await mountWith([ makeProposal(1, [{ label: 'карточка 2ГИС', url: CARD }]), makeProposal(2, [{ label: 'карточка 2ГИС', url: OTHER }]), ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.switchTab('sugg'); vm.openDelete(vm.props.find((p: { id: number }) => p.id === 2)); await wrapper.vm.$nextTick(); expect(vm.delPool.items).toHaveLength(1); expect(wrapper.find('.ld-del-pool__none').text()).toContain('Других строк, найденных в этом же месте, нет'); }); it('generic-метки без url не связывают строки', async () => { const { wrapper } = await mountWith([ makeProposal(1, [{ label: 'привязана клиентом', url: null }]), makeProposal(2, [{ label: 'привязана клиентом', url: null }]), ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.switchTab('sugg'); vm.openDelete(vm.props.find((p: { id: number }) => p.id === 1)); await wrapper.vm.$nextTick(); expect(vm.delPool.items).toHaveLength(1); }); it('строки одного места объединяются даже при разных подписях, если совпал url', async () => { const { wrapper } = await mountWith([ makeProposal(1, [{ label: 'карточка 2ГИС', url: CARD }]), makeProposal(2, [{ label: 'сайт', url: SITE }, { label: 'найдено по телефону', url: CARD }]), ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.switchTab('sugg'); vm.openDelete(vm.props.find((p: { id: number }) => p.id === 1)); await wrapper.vm.$nextTick(); const ids = vm.delPool.items.map((i: { src: { id: number } }) => i.src.id).sort(); expect(ids).toEqual([1, 2]); }); it('подтверждение удаляет только отмеченные строки', async () => { const { wrapper, store } = await mountWith([ makeProposal(1, [{ label: 'карточка 2ГИС', url: CARD }]), makeProposal(2, [{ label: 'карточка 2ГИС', url: CARD }]), ]); const spy = vi.spyOn(store, 'removeSource').mockResolvedValue(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.switchTab('sugg'); vm.openDelete(vm.props.find((p: { id: number }) => p.id === 1)); await wrapper.vm.$nextTick(); // клиент дополнительно отмечает вторую связанную строку vm.delPool.items.find((i: { src: { id: number } }) => i.src.id === 2).checked = true; await vm.confirmDeletePool(); expect(spy).toHaveBeenCalledWith(10, 1); expect(spy).toHaveBeenCalledWith(10, 2); expect(spy).toHaveBeenCalledTimes(2); expect(vm.delPool.open).toBe(false); }); it('подтверждение с одной галочкой удаляет только её', async () => { const { wrapper, store } = await mountWith([ makeProposal(1, [{ label: 'карточка 2ГИС', url: CARD }]), makeProposal(2, [{ label: 'карточка 2ГИС', url: CARD }]), ]); const spy = vi.spyOn(store, 'removeSource').mockResolvedValue(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.switchTab('sugg'); vm.openDelete(vm.props.find((p: { id: number }) => p.id === 1)); await wrapper.vm.$nextTick(); await vm.confirmDeletePool(); expect(spy).toHaveBeenCalledWith(10, 1); expect(spy).toHaveBeenCalledTimes(1); }); it('удаление источника «в работе» НЕ открывает пул (старое окно)', async () => { const { wrapper } = await mountWith([makeWorkSource(1, [{ label: 'сайт', url: SITE }])]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.openDelete(vm.inWork[0]); await wrapper.vm.$nextTick(); expect(vm.delPool.open).toBe(false); expect(vm.del.open).toBe(true); expect(wrapper.text()).toContain('будет удалён из работы'); }); }); describe('FieldCompetitorScreen — окно подтверждения при массовом удалении предложений', () => { const L1: WhereFound[] = [{ label: 'карточка 2ГИС', url: CARD }]; const L2: WhereFound[] = [{ label: 'сайт конкурента', url: SITE }]; it('кнопка массового удаления открывает окно и НЕ удаляет сразу', async () => { const { wrapper, store } = await mountWith([makeProposal(1, L1), makeProposal(2, L2)]); const spy = vi.spyOn(store, 'removeSource').mockResolvedValue(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.switchTab('sugg'); vm.selected = [1, 2]; await wrapper.vm.$nextTick(); const delBtn = wrapper.findAll('.ld-bulkbar button').find((b) => b.text().includes('Удалить'))!; await delBtn.trigger('click'); await wrapper.vm.$nextTick(); expect(vm.delBulk.open).toBe(true); expect(spy).not.toHaveBeenCalled(); }); it('подтверждение в окне удаляет каждое предложение через removeSource', async () => { const { wrapper, store } = await mountWith([makeProposal(1, L1), makeProposal(2, L2)]); const spy = vi.spyOn(store, 'removeSource').mockResolvedValue(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = wrapper.vm as any; vm.switchTab('sugg'); vm.selected = [1, 2]; await wrapper.vm.$nextTick(); vm.askBulkDeleteProps(); await wrapper.vm.$nextTick(); await vm.bulkDeleteProps(); expect(spy).toHaveBeenCalledWith(10, 1); expect(spy).toHaveBeenCalledWith(10, 2); expect(spy).toHaveBeenCalledTimes(2); expect(vm.delBulk.open).toBe(false); }); });