Files
portal/app/tests/Frontend/GuidedTour.spec.ts
T
Дмитрий 3e9ce2091e fix(tours): карточка экскурсии не уезжает за экран и не тонет под виджетом чата
Владелец застрял на шаге «Цена» (экскурсия про сбор конкурентов): карточка встала
ниже видимой части окна и вдобавок была накрыта виджетом Jivo — кнопку «Далее»
нажать было нечем, экскурсия превращалась в тупик.

1. Позиция карточки прижимается к экрану (clampTop): она всегда целиком видна,
   какой бы низкой ни была цель шага.
2. z-index экскурсии поднят выше Jivo (виджет рисует себя поверх всего).

Оба бага сначала воспроизведены тестами. Vitest 1227/1227. Живьём: все 4 шага
экскурсии collect-competitors — карточка в экране, «Далее» кликабельна.

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

185 lines
8.0 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import GuidedTour from '../../resources/js/components/layout/GuidedTour.vue';
import type { TourStep } from '../../resources/js/tours/catalog';
const steps: TourStep[] = [
{ route: '/projects', target: '[data-tour="a"]', title: 'Шаг 1', text: 'т1' },
{ route: '/projects', target: '[data-tour="b"]', title: 'Шаг 2', text: 'т2' },
];
const push = vi.fn();
const currentPath = { value: '/projects' };
vi.mock('vue-router', () => ({
useRouter: () => ({ push: (...args: unknown[]) => push(...args) }),
useRoute: () => ({
get path() {
return currentPath.value;
},
}),
}));
function mountTour(customSteps: TourStep[] = steps, path = '/projects') {
push.mockClear();
currentPath.value = path;
return mount(GuidedTour, {
props: { steps: customSteps, active: true },
global: { stubs: { 'v-btn': { template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
});
}
describe('GuidedTour', () => {
it('показывает первый шаг и счётчик', () => {
const w = mountTour();
expect(w.text()).toContain('Шаг 1');
expect(w.text()).toContain('1 из 2');
});
it('Далее ведёт по шагам, на последнем — Готово и finish', async () => {
const w = mountTour();
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.text()).toContain('Шаг 2');
await w.find('[data-testid="tour-next"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('шаг на другом экране — раннер сам туда переходит', async () => {
// Живой баг 12.07.2026: экскурсия «застревала» на первом экране — шаг про
// «Проекты» показывался поверх «Биллинга». Раннер обязан вести за собой роутер.
const crossScreen: TourStep[] = [
{ route: '/billing', target: '[data-tour="a"]', title: 'Шаг 1', text: 'т1' },
{ route: '/projects', target: '[data-tour="b"]', title: 'Шаг 2', text: 'т2' },
];
const w = mountTour(crossScreen, '/billing');
await w.find('[data-testid="tour-next"]').trigger('click');
expect(push).toHaveBeenCalledWith('/projects');
});
it('шаг на том же экране — лишнего перехода нет', async () => {
const w = mountTour();
await w.find('[data-testid="tour-next"]').trigger('click');
expect(push).not.toHaveBeenCalled();
});
it('шаг с open — раннер сам открывает окно и показывает, что ВНУТРИ', async () => {
// Претензия владельца 12.07.2026: «надо не только кнопку показывать, а и что за ней,
// а то видео тупое». Шаг с `open` — раннер сам жмёт кнопку и подсвечивает начинку.
vi.useFakeTimers();
const opener = document.createElement('button');
opener.setAttribute('data-tour', 'topup');
opener.addEventListener('click', () => {
const inner = document.createElement('div');
inner.setAttribute('data-tour', 'topup-amount');
document.body.appendChild(inner);
});
document.body.appendChild(opener);
const withOpen: TourStep[] = [
{ route: '/billing', target: '[data-tour="topup"]', title: 'Шаг 1', text: 'т1' },
{
route: '/billing',
open: '[data-tour="topup"]',
target: '[data-tour="topup-amount"]',
title: 'Шаг 2',
text: 'т2',
},
];
const w = mountTour(withOpen, '/billing');
await w.find('[data-testid="tour-next"]').trigger('click');
await vi.advanceTimersByTimeAsync(1000);
expect(document.querySelector('[data-tour="topup-amount"]')).not.toBeNull();
expect((w.vm as any).targetRect).not.toBeNull();
document.body.innerHTML = '';
vi.useRealTimers();
});
it('окно уже открыто — раннер не жмёт кнопку повторно (не закроет его)', async () => {
vi.useFakeTimers();
let clicks = 0;
const opener = document.createElement('button');
opener.setAttribute('data-tour', 'topup');
opener.addEventListener('click', () => {
clicks += 1;
});
document.body.appendChild(opener);
const inner = document.createElement('div');
inner.setAttribute('data-tour', 'topup-amount');
document.body.appendChild(inner);
const withOpen: TourStep[] = [
{ route: '/billing', target: '[data-tour="topup"]', title: 'Шаг 1', text: 'т1' },
{
route: '/billing',
open: '[data-tour="topup"]',
target: '[data-tour="topup-amount"]',
title: 'Шаг 2',
text: 'т2',
},
];
const w = mountTour(withOpen, '/billing');
await w.find('[data-testid="tour-next"]').trigger('click');
await vi.advanceTimersByTimeAsync(1000);
expect(clicks).toBe(0);
document.body.innerHTML = '';
vi.useRealTimers();
});
it('цель у нижнего края — карточка не уезжает за экран (кнопка «Далее» доступна)', () => {
// Живой баг 12.07.2026: владелец застрял на шаге «Цена» — карточка встала ниже
// видимой части окна, кнопку «Далее» было не нажать. Карточка обязана оставаться
// в пределах экрана.
const w = mountTour();
const vm = w.vm as unknown as {
targetRect: { top: number; left: number; width: number; height: number } | null;
};
// цель у самого низа экрана (768 — высота окна в jsdom)
vm.targetRect = { top: 740, left: 400, width: 300, height: 40 };
const style = (w.vm as unknown as { tooltipStyle: Record<string, string> }).tooltipStyle;
const top = Number.parseInt(style.top ?? '0', 10);
expect(top).toBeGreaterThanOrEqual(0);
expect(top + 220).toBeLessThanOrEqual(window.innerHeight);
});
it('карточка экскурсии выше виджета чата (иначе её нечем закрыть)', () => {
// Jivo рисует свой виджет поверх всего — экскурсия должна быть выше него.
const w = mountTour();
const root = w.find('[data-testid="guided-tour"]');
expect(root.exists()).toBe(true);
expect((w.vm as unknown as { Z_INDEX: number }).Z_INDEX).toBeGreaterThan(2147483000);
});
it('Пропустить завершает тур сразу', async () => {
const w = mountTour();
await w.find('[data-testid="tour-skip"]').trigger('click');
expect(w.emitted('finish')).toBeTruthy();
});
it('цель не найдена → карточка по центру (targetRect null), без падения', () => {
const w = mountTour();
expect(w.find('[data-testid="guided-tour"]').exists()).toBe(true);
});
it('ретрай измерения: цель появляется позже — подсветка находит её', async () => {
vi.useFakeTimers();
const w = mountTour();
const el = document.createElement('div');
el.setAttribute('data-tour', 'a');
document.body.appendChild(el);
await vi.advanceTimersByTimeAsync(1000);
expect((w.vm as any).targetRect).not.toBeNull();
el.remove();
vi.useRealTimers();
});
});