Files
portal/app/tests/Frontend/FieldWorkspaceDuplicates.spec.ts
T
Дмитрий 0af5c5696f chore+fix: разметка документации и «неизвестный тип» в интерфейсе убраны в ноль
РАЗМЕТКА ДОКУМЕНТАЦИИ. Проверка сторожа давала 237 замечаний в 21 файле — стало
0 во всех файлах под учётом git. Пустые строки вокруг списков и заголовков
поправлены автоматом в 12 файлах, два файла пришлось делать руками, потому что
автоправка там навредила:
- в описании починки инцидента номера шагов 1…7 — это ИМЕНА, на них ссылается
  сам текст «из шага 2»; автоправка перенумеровала их в 1,1,1,1,2 и сломала
  ссылки. Вывел ровно этот файл из-под одного правила, объяснение — в самом
  файле, остальные правила действуют.
- в протоколе архитектуры автоправка склеила «R-* / DR-*» в «R-*/ DR-*».
  Вместо этого обозначения взяты в обратные кавычки — смысл сохранён.
Семь файлов не тронуты: они вне учёта git — два в папке второй смены и старые
черновики в корне.

ИНТЕРФЕЙС. «Неизвестный тип» убран целиком: было 123 замечания, стало 0.
- Навигация автоподбора описана ОДИН раз — app/resources/js/views/autopodbor/nav.ts.
  Раньше девять экранов повторяли описание с «неизвестным типом», а ещё три
  описывали его каждый по-своему и не полностью.
- Чтение ошибки от сервера собрано в один помощник — app/resources/js/api/errors.ts,
  с проверками. Он намеренно читает ФОРМУ ответа, а не полагается на axios:
  тесты экранов подсовывают ошибку простым объектом той же формы, и экран обязан
  вести себя в тестах так же, как в бою.
- В тестах вместо «неизвестного типа» — именованный доступ exposed<T> и полные
  заготовки: app/tests/Frontend/support/. Каждый тест теперь объявляет ровно те
  поля, до которых дотягивается, и опечатка в имени снова становится ошибкой,
  а не молчаливым undefined. Заодно сняты 14 построчных отключений правила.
- Разведены задвоенные имена в шаблонах: в таблице сделок в одном шаблоне жили
  ДВЕ разные функции isSelected — одна берёт номер сделки, другая строку таблицы.
- Убрана мёртвая функция dirLabel и три неиспользуемые переменные.

ПРОВЕРКА ТИПОВ: было 7 ошибок, стало 5. Две унаследованные закрылись попутно —
более строгие заготовки вскрыли нехватку обязательного поля elements. Оставшиеся
пять были до меня и не в моих строках.

ПРИЁМКА. Разметка проверена вырезанием: подложил поломку обратно — сторож её
поймал, вернул — снова чисто. Полный набор тестов интерфейса: 233 файла,
1750 тестов, 3 пропущено, 0 падений. Рабочий код на PHP не тронут.

NB: LEFTHOOK_EXCLUDE=cspell — тем же приёмом и по той же причине, что и в записи
e0223de4, которой эти шесть файлов исследования заводились в репозиторий: сырой
внешний материал с чужой терминологией. Замерено: до моих правок сторож
орфографии находил в них ровно столько же слов, сколько после — я не добавил
ни одного. Разметка в этих файлах теперь проходит начисто, отключена ТОЛЬКО
орфография и только на эту запись; остальные семнадцать сторожей отработали.
Решение владельца, спрошено отдельно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:07:24 +03:00

284 lines
13 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';
vi.mock('../../resources/js/api/autopodbor');
import FieldWorkspaceScreen from '../../resources/js/views/autopodbor/screens/FieldWorkspaceScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
import type { DuplicateGroupDto, MergeEventDto } from '../../resources/js/api/autopodbor';
import { AUTOPODBOR_NAV, type AutopodborNav } from '../../resources/js/views/autopodbor/nav';
import { exposed } from './support/exposed';
import { fieldCompetitor, navStub } from './support/fixtures';
const vuetify = createVuetify();
/** Группа дублей на экране: к ответу сервера экран добавляет выбор и имя-победителя. */
interface DupGroup extends DuplicateGroupDto {
sel: number[];
chosenName: string;
}
/** Окна склейки дублей и истории склеек — то, что экран отдаёт наружу. */
interface WorkspaceVm {
dupCount: number;
openDupes: () => Promise<void>;
mergeGroup: (g: DupGroup) => Promise<void>;
toggleDup: (g: DupGroup, id: number) => void;
dismissGroup: (i: number) => void;
openHistory: () => Promise<void>;
restoreEvent: (ev: MergeEventDto) => Promise<void>;
dupes: { open: boolean; loading: boolean; groups: DupGroup[] };
history: { open: boolean; loading: boolean; events: MergeEventDto[] };
}
function makeNav(): AutopodborNav {
return navStub('field');
}
function mountWs(nav: AutopodborNav) {
return mount(FieldWorkspaceScreen, { global: { plugins: [vuetify], provide: { [AUTOPODBOR_NAV]: 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));
const vm = exposed<WorkspaceVm>(w);
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));
const vm = exposed<WorkspaceVm>(w);
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));
const vm = exposed<WorkspaceVm>(w);
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));
const vm = exposed<WorkspaceVm>(w);
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));
const vm = exposed<WorkspaceVm>(w);
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));
const vm = exposed<WorkspaceVm>(w);
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 конкурента в поле → кнопка видна и счётчик считается на загрузке
store.field = [
fieldCompetitor({ id: 1, name: 'A' }),
fieldCompetitor({ id: 2, name: 'B', relevance_pct: 90 }),
];
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();
const vm = exposed<WorkspaceVm>(w);
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();
store.field = [
fieldCompetitor({ id: 1, name: 'A' }),
fieldCompetitor({ id: 2, name: 'B', relevance_pct: 90 }),
];
vi.spyOn(store, 'loadDuplicateGroups').mockResolvedValue([]);
const w = mountWs(makeNav());
await new Promise((r) => setTimeout(r, 0));
await w.vm.$nextTick();
const vm = exposed<WorkspaceVm>(w);
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));
const vm = exposed<WorkspaceVm>(w);
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));
const vm = exposed<WorkspaceVm>(w);
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));
const vm = exposed<WorkspaceVm>(w);
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));
const vm = exposed<WorkspaceVm>(w);
await vm.openDupes();
await w.vm.$nextTick();
expect(w.text().toLowerCase()).toContain('дубл');
});
});