Files
portal/app/tests/Frontend/AutopodborFieldCompetitorScreen.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

319 lines
18 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 * as api from '../../resources/js/api/autopodbor';
import type { CompetitorDto, FieldSourceDto, SourceProjectDto } from '../../resources/js/api/autopodbor';
import FieldCompetitorScreen from '../../resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
import { AUTOPODBOR_NAV, type AutopodborNav } from '../../resources/js/views/autopodbor/nav';
import { exposed } from './support/exposed';
import { competitor, fieldSource, navStub, run, sourceProject } from './support/fixtures';
const vuetify = createVuetify();
/** Внутренности экрана конкурента, до которых дотягивается этот тест. */
interface CompetitorVm {
switchTab: (tab: string) => void;
openSettings: (p: SourceProjectDto) => void;
toggleFromSettings: (next: boolean) => Promise<void>;
inWork: SourceProjectDto[];
psettings: { open: boolean; isActive: boolean; projectId: number | null };
}
function makeNav(competitorId: number | null = 3): AutopodborNav {
return navStub('fieldcompetitor', { competitorId });
}
function src(over: Partial<FieldSourceDto> = {}): FieldSourceDto {
return fieldSource({ id: 10, competitor_id: 3, ...over });
}
function proj(over: Partial<SourceProjectDto> = {}): SourceProjectDto {
return sourceProject({ id: 100, name: 'P', signal_identifier: 'okna.ru', is_active: true, regions: [24], ...over });
}
function seed(store: ReturnType<typeof useAutopodborStore>, sources: FieldSourceDto[], comp: Partial<CompetitorDto> = {}) {
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => {
store.competitor = competitor({ id: 3, name: 'Окна Комфорт', relevance_pct: 90, ...comp });
store.sources = sources;
});
}
function mountFc(nav: AutopodborNav) {
return mount(FieldCompetitorScreen, { global: { plugins: [vuetify], provide: { [AUTOPODBOR_NAV]: nav } } });
}
describe('FieldCompetitorScreen', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('грузит конкурента и источники в работе', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, identifier: 'okna.ru' })]);
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(store.loadCompetitor).toHaveBeenCalledWith(3);
expect(w.text()).toContain('Окна Комфорт');
expect(w.text()).toContain('okna.ru');
});
it('источник без проекта показывает «Создать проект» и открывает окно создания', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, project: null })]);
const nav = makeNav(3);
const w = mountFc(nav);
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('button').find((b) => b.text() === 'Создать проект');
expect(btn).toBeTruthy();
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Создать проект из источника');
expect(w.text()).toContain('Дни недели приёма');
});
it('активный проект → «Приостановить» зовёт toggleProjectActive(false)', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, project: proj({ id: 100, is_active: true }) })]);
const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue();
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('button').find((b) => b.text() === 'Приостановить');
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(toggleSpy).toHaveBeenCalledWith(100, false);
});
it('телефон показывает значок и тип номера', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 11, signal_type: 'call', identifier: '78432001122', phone_kind: 'substitute', phone_type: 'city' })]);
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('🎭');
expect(w.text()).toContain('городской');
});
it('вкладка «Предложения» показывает источники-предложения и переносит «В работу»', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 12, box: 'proposal', identifier: 'prop.ru' })]);
const moveSpy = vi.spyOn(store, 'moveSourceToBox').mockResolvedValue();
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const propTab = w.findAll('button').find((b) => b.text().includes('Предложения'));
await propTab!.trigger('click');
expect(w.text()).toContain('prop.ru');
const btn = w.findAll('button').find((b) => b.text().includes('В источники'));
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(moveSpy).toHaveBeenCalledWith(3, 12, 'field');
});
it('источник с проектом показывает живой источник проекта и меняет через change_source', async () => {
const store = useAutopodborStore();
seed(store, [
src({ id: 10, signal_type: 'site', identifier: 'old.ru', project: proj({ id: 100, signal_identifier: 'live.ru', is_active: true }) }),
]);
const changeSpy = vi.spyOn(store, 'changeProjectSource').mockResolvedValue({ source_change_message: 'Лиды дойдут.' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
// карточка показывает источник проекта, а не old.ru
expect(w.text()).toContain('live.ru');
const editBtn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник'));
await editBtn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Сменить источник?');
const input = w.find('.ld-modal input');
await input.setValue('new.ru');
// первый клик «Сохранить» — показывает подтверждение
let save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сохранить');
await save!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Подтвердите смену источника');
// второй клик «Сменить источник» — выполняет change_source
save = w.findAll('.ld-modal button').find((b) => b.text() === 'Сменить источник');
await save!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(changeSpy).toHaveBeenCalledWith(100, 'new.ru');
});
it('массовое «Приостановить выбранные» паузит проекты выбранных источников через bulkProjectsById', async () => {
const store = useAutopodborStore();
seed(store, [
src({ id: 10, project: proj({ id: 100, signal_identifier: 'a.ru', is_active: true }) }),
src({ id: 11, identifier: 'b.ru', project: proj({ id: 101, signal_identifier: 'b.ru', is_active: true }) }),
]);
const bulkSpy = vi.spyOn(store, 'bulkProjectsById').mockResolvedValue({ updated: 2, skipped: [], warnings: [] });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const boxes = w.findAll('.ld-pick');
await boxes[0].trigger('change');
await boxes[1].trigger('change');
const btn = w.findAll('.ld-bulkbar button').find((b) => b.text().includes('Приостановить'));
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(bulkSpy).toHaveBeenCalledWith('pause', expect.arrayContaining([100, 101]));
});
it('у изучённого конкурента первичной кнопки «Собрать источники для меня» нет — вместо неё повтор', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10 })], { studied_at: '2026-06-30T00:00:00+00:00' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeFalsy();
expect(w.text()).toContain('Собрать источники ещё раз');
});
it('неизучённый конкурент показывает кнопку «Собрать источники для меня»', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10 })], { studied_at: null });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeTruthy();
});
it('сортирует источники: больше подтверждений — выше', async () => {
const store = useAutopodborStore();
seed(store, [
src({ id: 10, signal_type: 'call', identifier: 'мало-номер', phone_kind: 'real', phone_type: 'city', confirmations: 1, where_found: [{ label: 'в коде сайта', url: null }] }),
src({ id: 11, signal_type: 'call', identifier: 'много-номер', phone_kind: 'real', phone_type: 'city', confirmations: 3, where_found: [{ label: 'в коде сайта', url: 'https://k.ru' }, { label: '2ГИС', url: 'https://2gis.ru/x' }, { label: 'Контакты', url: null }] }),
]);
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const cards = w.findAll('.ld-srccard');
expect(cards[0].text()).toContain('много-номер');
expect(cards[1].text()).toContain('мало-номер');
});
it('«где нашли» показывает кликабельные ссылки', async () => {
const store = useAutopodborStore();
seed(store, [
src({ id: 10, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', phone_type: 'city', confirmations: 2, where_found: [{ label: 'в коде сайта', url: 'https://k.ru' }, { label: '2ГИС · ул. Весны 7а', url: 'https://2gis.ru/firm/1' }] }),
]);
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const links = w.findAll('a.ld-fchip');
expect(links.length).toBe(2);
expect(links[0].attributes('href')).toBe('https://k.ru');
expect(links[1].text()).toContain('Весны');
});
it('не делает кликабельной ссылку с javascript:-схемой (XSS-защита)', async () => {
const store = useAutopodborStore();
seed(store, [
src({ id: 10, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', phone_type: 'city', where_found: [{ label: 'плохая', url: 'javascript:alert(1)' }, { label: '2ГИС', url: 'https://2gis.ru/x' }] }),
]);
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const links = w.findAll('a.ld-fchip');
expect(links.length).toBe(1); // только https кликабельна
expect(links[0].attributes('href')).toBe('https://2gis.ru/x');
expect(links.some((a) => (a.attributes('href') ?? '').toLowerCase().startsWith('javascript'))).toBe(false);
expect(w.text()).toContain('плохая'); // не потеряли — показана простым текстом
});
it('окно «Изменить источник» открывается с залоченным типом', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, signal_type: 'site', identifier: 'okna.ru' })]);
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
const btn = w.findAll('.ld-link').find((b) => b.text().includes('Изменить источник'));
await btn!.trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('тип не меняется');
});
it('у уже изученного конкурента кнопка — «Собрать источники ещё раз» (повтор разрешён)', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, box: 'field' })], { studied_at: '2026-07-05T00:00:00Z' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Собрать источники ещё раз');
expect(w.text()).not.toContain('Источники собраны');
});
it('блок «ранее удалённые — вернуть?» показывает архивные источники из итога прогона', async () => {
const store = useAutopodborStore();
seed(store, [
src({ id: 10, box: 'field', identifier: 'okna.ru' }),
src({ id: 99, box: 'archived', identifier: 'staroe.ru' }),
], { studied_at: '2026-07-05T00:00:00Z' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
// Итог последнего прогона: источник 99 — ранее удалённый, найден снова.
store.currentRun = run({ id: 5, result: { new_sources: 0, refound_deleted: 1, refound_deleted_ids: [99] } });
exposed<CompetitorVm>(w).switchTab('sugg');
await w.vm.$nextTick();
expect(w.text().toLowerCase()).toContain('ранее удал');
expect(w.text()).toContain('staroe.ru');
});
it('заход на конкурента, по которому ИДЁТ сбор — опрос подхватывается (индикатор оживает после возврата)', async () => {
const store = useAutopodborStore();
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
competitor: { id: 3, name: 'ДСК', studied_at: null },
sources: [],
activeRun: { id: 9, kind: 'study', competitor_id: 3, status: 'running', queue_position: null, progress: { stage: 1, total: 1, label: 'Изучаю: ДСК' } },
});
const poll = vi.spyOn(store, 'pollRun').mockResolvedValue(run({ id: 9, status: 'done' }));
mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
expect(poll).toHaveBeenCalledWith(9);
});
it('идёт сбор по конкуренту — на экране бегущий индикатор с этапом (виден без окна)', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10 })], { studied_at: '2026-07-05T00:00:00Z' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
store.currentRun = run({ id: 5, kind: 'study', competitor_id: 3, status: 'running', progress: { stage: 1, total: 2, label: 'Изучаю: Сибиряк' } });
await w.vm.$nextTick();
expect(w.find('.ld-collecting__spin').exists()).toBe(true);
expect(w.text()).toContain('Изучаю: Сибиряк');
});
// Кнопка пуск/стоп в модалке «Настройки проекта» (2026-07-08).
it('кнопка пуск/стоп в модалке настроек переключает проект, не закрывая окно', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, project: proj({ id: 100, is_active: true }) })]);
const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue();
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
// открыть модалку настроек для проекта 100
exposed<CompetitorVm>(w).openSettings(exposed<CompetitorVm>(w).inWork[0]);
await w.vm.$nextTick();
expect(exposed<CompetitorVm>(w).psettings.isActive).toBe(true);
expect(w.text()).toContain('Остановить');
// нажать «Остановить»
await exposed<CompetitorVm>(w).toggleFromSettings(false);
await new Promise((r) => setTimeout(r, 0));
expect(toggleSpy).toHaveBeenCalledWith(100, false);
expect(exposed<CompetitorVm>(w).psettings.isActive).toBe(false);
expect(exposed<CompetitorVm>(w).psettings.open).toBe(true); // НЕ закрылась
});
it('открывает модалку настроек при заходе по ссылке (ctx.openProjectSettingsId)', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, project: proj({ id: 100, is_active: true }) })]);
const nav = makeNav(3);
nav.ctx.openProjectSettingsId = 100;
const w = mountFc(nav);
await new Promise((r) => setTimeout(r, 0));
await w.vm.$nextTick();
expect(exposed<CompetitorVm>(w).psettings.open).toBe(true);
expect(exposed<CompetitorVm>(w).psettings.projectId).toBe(100);
expect(nav.ctx.openProjectSettingsId).toBeNull();
});
});