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 FieldWorkspaceScreen from '../../resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue'; import { useAutopodborStore } from '../../resources/js/stores/autopodborStore'; const vuetify = createVuetify(); function makeNav() { return { go: vi.fn(), ctx: reactive({ competitorId: null }), screen: ref('field') }; } function mountWs(nav: any) { return mount(FieldWorkspaceScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } }); } function baseStore() { const store = useAutopodborStore(); vi.spyOn(store, 'loadField').mockResolvedValue(); vi.spyOn(store, 'loadProposalGroups').mockResolvedValue(); return store; } describe('FieldWorkspaceScreen — финальный проход «Почистить поле»', () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); }); it('открытие окна грузит группы-кандидаты и показывает имена + причину', async () => { const store = baseStore(); vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([ { reason: 'совпал телефон: 79135396546', competitors: [{ id: 1, name: 'AutoMoney' }, { id: 2, name: 'Автодом24' }] }, ]); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openDupes(); await w.vm.$nextTick(); expect(store.loadDuplicateGroups).toHaveBeenCalled(); expect(w.text()).toContain('AutoMoney'); expect(w.text()).toContain('Автодом24'); expect(w.text()).toContain('совпал телефон'); }); it('«Объединить» зовёт merge с id группы и убирает группу из окна', async () => { const store = baseStore(); vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([ { reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] }, ]); const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue(); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openDupes(); await w.vm.$nextTick(); await vm.mergeGroup(vm.dupes.groups[0]); expect(merge).toHaveBeenCalledWith([1, 2], 'field', 'A'); expect(vm.dupes.groups.length).toBe(0); }); it('имя объединённого по умолчанию — первого, передаётся в merge; можно сменить', async () => { const store = baseStore(); vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([ { reason: 'r', competitors: [{ id: 1, name: 'Сибиряк' }, { id: 2, name: 'Нанжуль' }] }, ]); const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue(); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openDupes(); await w.vm.$nextTick(); expect(vm.dupes.groups[0].chosenName).toBe('Сибиряк'); vm.dupes.groups[0].chosenName = 'Нанжуль'; await vm.mergeGroup(vm.dupes.groups[0]); expect(merge).toHaveBeenCalledWith([1, 2], 'field', 'Нанжуль'); }); it('группа из 3+: объединяем ПОДМНОЖЕСТВО (2 из 3)', async () => { const store = baseStore(); vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([ { reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, { id: 3, name: 'C' }] }, ]); const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue(); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openDupes(); await w.vm.$nextTick(); // по умолчанию выбраны все три expect(vm.dupes.groups[0].sel).toEqual([1, 2, 3]); // снимаем C (id 3) → сливаем только A+B vm.toggleDup(vm.dupes.groups[0], 3); await vm.mergeGroup(vm.dupes.groups[0]); expect(merge).toHaveBeenCalledWith([1, 2], 'field', 'A'); }); it('нельзя объединить меньше двух выбранных', async () => { const store = baseStore(); vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([ { reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] }, ]); const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue(); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openDupes(); await w.vm.$nextTick(); vm.toggleDup(vm.dupes.groups[0], 2); // остаётся только один выбранный await vm.mergeGroup(vm.dupes.groups[0]); expect(merge).not.toHaveBeenCalled(); }); it('«Это разные» убирает группу без склейки', async () => { const store = baseStore(); vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([ { reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] }, ]); const merge = vi.spyOn(store, 'mergeCompetitors').mockResolvedValue(); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openDupes(); await w.vm.$nextTick(); vm.dismissGroup(0); expect(merge).not.toHaveBeenCalled(); expect(vm.dupes.groups.length).toBe(0); }); it('на кнопке «Найти и объединить дубли» показывается число найденных дублей', async () => { const store = baseStore(); // ≥2 конкурента в поле → кнопка видна и счётчик считается на загрузке // eslint-disable-next-line @typescript-eslint/no-explicit-any store.field = [ { id: 1, name: 'A', box: 'field', relevance_pct: 100, counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] }, { id: 2, name: 'B', box: 'field', relevance_pct: 90, counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] }, ] as any; vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([ { reason: 'r', competitors: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] }, { reason: 'r2', competitors: [{ id: 3, name: 'C' }, { id: 4, name: 'D' }] }, ]); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); await w.vm.$nextTick(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; expect(vm.dupCount).toBe(2); const btn = w.findAll('button').find((b) => b.text().includes('Найти и объединить дубли')); expect(btn?.text()).toContain('2'); }); it('дублей нет → число на кнопке не показывается', async () => { const store = baseStore(); // eslint-disable-next-line @typescript-eslint/no-explicit-any store.field = [ { id: 1, name: 'A', box: 'field', relevance_pct: 100, counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] }, { id: 2, name: 'B', box: 'field', relevance_pct: 90, counters: { sources: 0, projects_created: 0, projects_in_work: 0 }, sources: [] }, ] as any; vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([]); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); await w.vm.$nextTick(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; expect(vm.dupCount).toBe(0); const btn = w.findAll('button').find((b) => b.text().includes('Найти и объединить дубли')); expect(btn?.text()).not.toContain('('); }); it('окно дублей — кросс-ящик: помечает предложение-дубль карточки поля', async () => { const store = baseStore(); const spy = vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([ { reason: 'совпал телефон', competitors: [ { id: 1, name: 'СМ-сити', box: 'field' }, { id: 2, name: 'Южный берег', box: 'proposal' }, ], }, ]); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openDupes(); await w.vm.$nextTick(); expect(spy).toHaveBeenCalledWith('field', 'cross'); // зовём кросс-ящик поле↔предложения expect(w.text()).toContain('Южный берег'); expect(w.text()).toContain('предложение'); // видно, что цепляем предложение }); it('имя по умолчанию — у ВЫЖИВАЮЩЕЙ карточки (не первой), и видно «останется»', async () => { const store = baseStore(); // survivor помечен бэкендом на ВТОРОЙ карточке (напр. полевая с проектами) — её имя должно // стать именем объединённого по умолчанию, чтобы клиент не потерял её вслепую (баг «САПС»). vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([ { reason: 'совпал телефон', competitors: [ { id: 1, name: 'Метрополис', box: 'proposal', survivor: false }, { id: 2, name: 'СМ-сити', box: 'field', survivor: true }, ], }, ]); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openDupes(); await w.vm.$nextTick(); expect(vm.dupes.groups[0].chosenName).toBe('СМ-сити'); // имя выжившей, а не первой «Метрополис» expect(w.text()).toContain('останется'); // клиенту видно, какая карточка останется }); it('история слияний: видно «что во что слили», кнопка «Вернуть» зовёт restore', async () => { const store = baseStore(); vi.spyOn(store, 'loadMergeEvents').mockResolvedValue([ { id: 7, survivor_id: 2, survivor_name: 'СМ-сити', reason: 'совпал телефон', restored: false, created_at: '2026-07-05T11:49:09+03:00', absorbed: [{ id: 1, name: 'Метрополис', box: 'proposal' }], }, ]); const restore = vi.spyOn(store, 'restoreMergeEvent').mockResolvedValue(); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openHistory(); await w.vm.$nextTick(); expect(store.loadMergeEvents).toHaveBeenCalled(); expect(w.text()).toContain('Метрополис'); // поглощённая expect(w.text()).toContain('СМ-сити'); // выжившая await vm.restoreEvent(vm.history.events[0]); expect(restore).toHaveBeenCalledWith(7); }); it('дублей нет → окно сообщает «дублей не найдено»', async () => { const store = baseStore(); vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([]); const w = mountWs(makeNav()); await new Promise((r) => setTimeout(r, 0)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const vm = w.vm as any; await vm.openDupes(); await w.vm.$nextTick(); expect(w.text().toLowerCase()).toContain('дубл'); }); });