Files
portal/app/tests/Frontend/TourCatalog.spec.ts
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

121 lines
6.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { TOURS, findTour, type TourScenario } from '../../resources/js/tours/catalog';
const HELP_DIR = join(__dirname, '../../resources/help');
/** Читает frontmatter-поле `tour:` из каждой статьи клиентской инструкции. */
function toursDeclaredInHelp(): { file: string; tour: string }[] {
return readdirSync(HELP_DIR)
.filter((f) => f.endsWith('.md'))
.map((file) => {
const m = /^---[\s\S]*?\ntour:\s*(\S+)\s*\n[\s\S]*?---/.exec(readFileSync(join(HELP_DIR, file), 'utf8'));
return { file, tour: m?.[1] ?? '' };
})
.filter((x) => x.tour !== '');
}
/** Собирает разметку всех .vue-файлов портала — чтобы проверить, что якоря экскурсий существуют. */
function readAllVueSources(dir: string): string {
let out = '';
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
// Только разметка: сам каталог экскурсий читать нельзя — он бы «подтвердил» собственные селекторы.
if (entry.isDirectory()) out += readAllVueSources(p);
else if (entry.name.endsWith('.vue') && !entry.name.endsWith('.story.vue')) out += readFileSync(p, 'utf8');
}
return out;
}
describe('каталог экскурсий', () => {
it('имена уникальны', () => {
const names = TOURS.map((t: TourScenario) => t.name);
expect(names).toEqual([...new Set(names)]);
});
it('у каждой статьи инструкции есть своя экскурсия', () => {
const names = new Set(TOURS.map((t) => t.name));
for (const { file, tour } of toursDeclaredInHelp()) {
expect(names.has(tour), `статья ${file} ссылается на экскурсию «${tour}», которой нет в каталоге`).toBe(
true,
);
}
});
it('экскурсия не обрублена и не «всё подряд»: от 3 до 7 шагов', () => {
// Владелец, 12.07.2026: «видео обрубленные» И «показываем полное видео, а я спрашиваю
// про одно — надо резать». Отсюда обе границы: не куце и не свалка.
for (const tour of TOURS) {
expect(tour.steps.length, `экскурсия ${tour.name} слишком короткая`).toBeGreaterThanOrEqual(3);
expect(tour.steps.length, `экскурсия ${tour.name} раздута — режь по темам`).toBeLessThanOrEqual(7);
}
});
it('каждый шаг имеет route, target, title и содержательный text', () => {
for (const tour of TOURS) {
for (const s of tour.steps) {
expect(s.route.startsWith('/')).toBe(true);
expect(s.target).toBeTruthy();
expect(s.title).toBeTruthy();
expect(s.text.length, `слишком куцый текст шага в ${tour.name}`).toBeGreaterThan(30);
}
}
});
it('экскурсия не может состоять из одних пунктов меню — она заходит внутрь экрана', () => {
for (const tour of TOURS) {
const inside = tour.steps.filter((s) => !s.target.includes('"nav-'));
expect(inside.length, `экскурсия ${tour.name} только водит по меню, ничего не показывает`).toBeGreaterThan(
0,
);
}
});
it('экскурсии про действия сами открывают окно и показывают, что за кнопкой', () => {
// «Надо не только кнопку показывать, а и что за ней, а то видео тупое» (владелец).
const mustOpenSomething = [
'create-project',
'top-up-balance',
'invoice',
'tariffs',
'dupes',
'collect-competitors',
'study-sources',
'project-from-source',
'security',
'import',
'deal-status',
];
for (const name of mustOpenSomething) {
const tour = findTour(name);
expect(tour, `нет экскурсии ${name}`).not.toBeNull();
const opens = tour!.steps.filter((s) => s.open !== undefined);
expect(opens.length, `экскурсия ${name} только показывает кнопку — не открывает то, что за ней`).toBeGreaterThan(0);
}
});
it('каждая цель экскурсии реально существует в разметке портала', () => {
// Иначе экскурсия «показывает» пустоту: подсветка не находит цель и висит по центру.
const markup = readAllVueSources(join(__dirname, '../../resources/js'));
const missing: string[] = [];
for (const tour of TOURS) {
for (const s of tour.steps) {
for (const sel of [s.target, s.open]) {
const anchor = sel === undefined ? null : /\[data-tour="([a-z0-9-]+)"\]/.exec(sel)?.[1];
if (anchor && !markup.includes(`"${anchor}"`) && !markup.includes(`'${anchor}'`)) {
missing.push(`${tour.name}: ${anchor}`);
}
}
}
}
expect(missing, `нет якоря data-tour в разметке: ${missing.join(', ')}`).toEqual([]);
});
it('findTour находит по имени и отдаёт null на мусор', () => {
expect(findTour('create-project')?.name).toBe('create-project');
expect(findTour('no-such-tour')).toBeNull();
expect(findTour('')).toBeNull();
});
});