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>
319 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|