Files
portal/app/tests/Frontend/AppLayout.spec.ts
T
Дмитрий 17e3c04f24 fix(layout): topbar title из route.meta.title для страниц вне sidebar-nav
AppLayout брал заголовок топбара только из sidebar navItems → /reminders и
/import (которых нет в боковом меню) показывали fallback «Страница». Добавлен
fallback на route.meta.title перед «Страница». TDD: AppLayout.spec.ts (RED→GREEN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:23:58 +03:00

200 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
// Мокаем api/notifications до import'а AppLayout (использует store, который импортит api).
vi.mock('../../resources/js/api/notifications', () => ({
listNotifications: vi.fn().mockResolvedValue({ items: [], unread_count: 0, total: 0 }),
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
deleteNotification: vi.fn(),
}));
vi.mock('../../resources/js/api/reminders', () => ({
listReminders: vi.fn().mockResolvedValue({
items: [],
counts: { active: 0, today: 0, upcoming: 0, overdue: 0 },
}),
createReminder: vi.fn(),
updateReminder: vi.fn(),
completeReminder: vi.fn(),
deleteReminder: vi.fn(),
}));
import * as notificationsApi from '../../resources/js/api/notifications';
import AppLayout from '../../resources/js/layouts/AppLayout.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
import { useNotificationsStore } from '../../resources/js/stores/notifications';
import { useDealsCountStore } from '../../resources/js/stores/dealsCount';
import type { AuthUser } from '../../resources/js/api/auth';
const mockUser: AuthUser = {
id: 1,
email: 'ivan.petrov@example.ru',
first_name: 'Иван',
last_name: 'Петров',
tenant_id: 1,
totp_enabled: false,
last_login_at: null,
};
// AppLayout содержит sidebar (6 nav-items в 3 группах) + topbar (crumb/search/user) + RouterView.
const mountAppLayout = async (path = '/dashboard', user: AuthUser | null = mockUser) => {
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = user;
// B2: init deals count so badge renders (replaces hardcoded 247 in AppSidebar).
useDealsCountStore().count = 247;
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/dashboard', component: { template: '<div>dashboard</div>' } },
{ path: '/deals', component: { template: '<div>deals</div>' } },
{ path: '/kanban', component: { template: '<div>kanban</div>' } },
{ path: '/projects', component: { template: '<div>projects</div>' } },
{ path: '/billing', component: { template: '<div>billing</div>' } },
{ path: '/reports', component: { template: '<div>reports</div>' } },
{ path: '/settings', component: { template: '<div>settings</div>' } },
// Не в sidebar nav, но имеют meta.title — topbar должен брать title оттуда.
{
path: '/reminders',
component: { template: '<div>reminders</div>' },
meta: { title: 'Напоминания' },
},
{ path: '/import', component: { template: '<div>import</div>' }, meta: { title: 'Импорт данных' } },
],
});
await router.push(path);
await router.isReady();
return mount(AppLayout, {
global: { plugins: [createVuetify(), router] },
});
};
describe('AppLayout.vue', () => {
it('монтируется без ошибок', async () => {
const wrapper = await mountAppLayout();
expect(wrapper.exists()).toBe(true);
});
it('содержит брендовый блок «Лидерра.» в sidebar', async () => {
const wrapper = await mountAppLayout();
expect(wrapper.text()).toContain('Лидерра');
});
it('содержит 3 nav-группы: Работа, Финансы, Команда', async () => {
const wrapper = await mountAppLayout();
const text = wrapper.text();
expect(text).toContain('Работа');
expect(text).toContain('Финансы');
expect(text).toContain('Команда');
});
it('содержит 6 nav-пунктов (Импорт данных + Отчёты убраны по требованию заказчика)', async () => {
const wrapper = await mountAppLayout();
const text = wrapper.text();
['Проекты', 'Сделки', 'Канбан', 'Дашборд', 'Биллинг', 'Настройки'].forEach((label) =>
expect(text).toContain(label),
);
expect(text).not.toContain('Менеджеры');
expect(text).not.toContain('Напоминания');
expect(text).not.toContain('Импорт данных');
expect(text).not.toContain('Отчёты');
});
it('показывает счётчики только у пунктов с count', async () => {
const wrapper = await mountAppLayout();
const text = wrapper.text();
expect(text).toContain('247'); // Сделки (mock)
});
it('breadcrumb показывает текущую страницу', async () => {
const wrapper = await mountAppLayout('/dashboard');
expect(wrapper.text()).toContain('Дашборд');
});
it('topbar title для страницы вне sidebar nav берётся из route.meta.title (Напоминания)', async () => {
const wrapper = await mountAppLayout('/reminders');
// Напоминания нет в sidebar nav (см. тест выше) — title должен прийти из meta, не «Страница».
expect(wrapper.text()).toContain('Напоминания');
expect(wrapper.text()).not.toContain('Страница');
});
it('topbar title для /import берётся из route.meta.title (Импорт данных)', async () => {
const wrapper = await mountAppLayout('/import');
expect(wrapper.text()).toContain('Импорт данных');
});
it('user-chip показывает initials и shortName из store user', async () => {
const wrapper = await mountAppLayout();
const text = wrapper.text();
expect(text).toContain('ИП'); // initials Иван Петров
expect(text).toContain('Иван П.'); // shortName
});
it('при null user (гость) показывает «?» и «Гость»', async () => {
const wrapper = await mountAppLayout('/dashboard', null);
const text = wrapper.text();
expect(text).toContain('Гость');
expect(text).toContain('?');
});
it('при отсутствии first_name fallback на email', async () => {
const wrapper = await mountAppLayout('/dashboard', {
...mockUser,
first_name: null,
last_name: null,
});
expect(wrapper.text()).toContain('ivan.petrov@example.ru');
});
it('bell-icon кнопка существует', async () => {
const wrapper = await mountAppLayout();
const bellBtn = wrapper.find('[data-testid="notifications-btn"]');
expect(bellBtn.exists()).toBe(true);
});
it('pip скрыт когда unreadCount=0 (default state)', async () => {
const wrapper = await mountAppLayout();
await wrapper.vm.$nextTick();
const pip = wrapper.find('[data-testid="notifications-pip"]');
expect(pip.exists()).toBe(false);
});
it('pip показывает unreadCount когда > 0', async () => {
const wrapper = await mountAppLayout();
const store = useNotificationsStore();
store.unreadCount = 5;
await wrapper.vm.$nextTick();
const pip = wrapper.find('[data-testid="notifications-pip"]');
expect(pip.exists()).toBe(true);
expect(pip.text()).toBe('5');
});
it('pip показывает «99+» когда unreadCount > 99', async () => {
const wrapper = await mountAppLayout();
const store = useNotificationsStore();
store.unreadCount = 142;
await wrapper.vm.$nextTick();
const pip = wrapper.find('[data-testid="notifications-pip"]');
expect(pip.text()).toBe('99+');
});
it('listNotifications вызывается на mount при наличии user', async () => {
await mountAppLayout('/dashboard', mockUser);
expect(notificationsApi.listNotifications).toHaveBeenCalled();
});
it('listNotifications НЕ вызывается на mount без user', async () => {
vi.mocked(notificationsApi.listNotifications).mockClear();
await mountAppLayout('/dashboard', null);
expect(notificationsApi.listNotifications).not.toHaveBeenCalled();
});
});