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> @
121 lines
6.2 KiB
TypeScript
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();
|
|
});
|
|
});
|