3e9ce2091e
Владелец застрял на шаге «Цена» (экскурсия про сбор конкурентов): карточка встала ниже видимой части окна и вдобавок была накрыта виджетом Jivo — кнопку «Далее» нажать было нечем, экскурсия превращалась в тупик. 1. Позиция карточки прижимается к экрану (clampTop): она всегда целиком видна, какой бы низкой ни была цель шага. 2. z-index экскурсии поднят выше Jivo (виджет рисует себя поверх всего). Оба бага сначала воспроизведены тестами. Vitest 1227/1227. Живьём: все 4 шага экскурсии collect-competitors — карточка в экране, «Далее» кликабельна. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
185 lines
8.0 KiB
TypeScript
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();
|
|
});
|
|
});
|