0af5c5696f
РАЗМЕТКА ДОКУМЕНТАЦИИ. Проверка сторожа давала 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>
284 lines
13 KiB
TypeScript
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('дубл');
|
|
});
|
|
});
|