feat(notifications+a11y): центр уведомлений /notifications, skip-link, axe smoke

Шаг 2.5 плана production-готовности:
- страница уведомлений (фильтр, догрузка, прочтение) + ссылки из dropdown/мобильного меню
- общие хелперы notification-utils (дедупликация dropdown)
- skip-link + id=content на main
- axe smoke-тесты (0 critical/serious), исправлены 2 нарушения в новой странице
- 3 новых теста (107/107), i18n отложен по решению пользователя
This commit is contained in:
2026-09-08 09:32:36 +03:00
parent 6e7270d2ee
commit 42e5ace655
12 changed files with 1249 additions and 127 deletions

172
tests/a11y.test.tsx Normal file
View File

@@ -0,0 +1,172 @@
// @vitest-environment jsdom
/**
* Базовый axe-проход по ключевым компонентам: форма входа, онбординг-чеклист,
* страница уведомлений. Проверяем отсутствие нарушений уровня critical/serious.
* Это smoke-задел, а не полный аудит всего приложения.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import axe from 'axe-core';
// jsdom не имеет ResizeObserver, а Radix-контролы (Checkbox и др.) его требуют
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
// --- Моки хуков: тестируем разметку без сети и auth ---
// Текущий «пользователь» для useAuth — переключается между тестами
const mockAuthState = vi.hoisted(() => ({
user: null as null | Record<string, unknown>,
}));
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: mockAuthState.user,
login: vi.fn(),
isLoading: false,
isExpiredOffline: false,
}),
}));
vi.mock('@/hooks/use-toast', () => ({
useToast: () => ({ toast: vi.fn() }),
toast: vi.fn(),
}));
// Layout в странице уведомлений заменяем на passthrough —
// сайдбар с SSE/офлайн-контекстами в jsdom не нужен
vi.mock('@/components/Layout', () => ({
default: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
const FIXTURE_NOTIFICATIONS = [
{
id: 1,
taskId: 10,
type: 'task.assigned',
title: 'Вас назначили ответственным',
message: 'Задача «Проверить отчёт» назначена вам',
isRead: false,
createdAt: '2026-03-01T10:00:00.000Z',
task: { id: 10, title: 'Проверить отчёт', formId: 5 },
},
{
id: 2,
taskId: 11,
type: 'task_mention',
title: 'Вас упомянули',
isRead: true,
createdAt: '2026-02-28T09:00:00.000Z',
task: { id: 11, title: 'Согласовать договор', formId: 5 },
taskMessage: {
id: 100,
message: 'Посмотрите, пожалуйста',
author: { firstName: 'Иван', lastName: 'Петров' },
},
},
];
vi.mock('@/hooks/useNotifications', () => ({
CHAT_EVENT_TYPES: ['task.mentioned', 'task.comment.created', 'task.comment.replied', 'task_mention', 'task_reply'],
useNotifications: () => ({
allNotifications: FIXTURE_NOTIFICATIONS,
chatNotifications: [],
systemNotifications: FIXTURE_NOTIFICATIONS,
unreadChatCount: 0,
unreadSystemCount: 1,
isLoading: false,
sseConnected: true,
}),
}));
// Импорты компонентов — после vi.mock (hoisting учтён)
import Login from '../client/src/pages/Login';
import { OnboardingChecklist } from '../client/src/components/OnboardingChecklist';
import NotificationsPage from '../client/src/pages/NotificationsPage';
afterEach(cleanup);
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Infinity,
// Заглушка queryFn: тесты работают на предзагруженном кэше, сеть не нужна
queryFn: () => Promise.resolve({}),
},
},
});
}
async function runAxe(container: HTMLElement) {
const results = await axe.run(container, {
// color-contrast в jsdom не вычисляется (нет реальных стилей/canvas) — исключаем,
// чтобы не ловить ложные срабатывания; контраст проверяется скинами дизайн-системы
rules: { 'color-contrast': { enabled: false } },
});
return results.violations.filter(
v => v.impact === 'critical' || v.impact === 'serious'
);
}
describe('a11y smoke (axe)', () => {
it('Login: форма входа без критичных нарушений', async () => {
const { container } = render(<Login />);
// Защита от вакуумного прохода: форма действительно отрендерилась
expect(container.querySelector('[data-testid="input-email"]')).toBeTruthy();
const violations = await runAxe(container);
expect(
violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`)
).toEqual([]);
});
it('OnboardingChecklist: чеклист без критичных нарушений', async () => {
mockAuthState.user = {
id: 1,
firstName: 'Тест',
lastName: 'Тестов',
organization: { id: 1 },
};
const queryClient = makeQueryClient();
// Данные предзагружены — компонент рендерит шаги без сетевых запросов
queryClient.setQueryData(['/api/forms'], { success: true, forms: [{}] });
queryClient.setQueryData(['/api/users/list'], { success: true, users: [{}, {}] });
queryClient.setQueryData(['/api/tasks', 'onboarding-first'], { success: true, tasks: [] });
const { container } = render(
<QueryClientProvider client={queryClient}>
<OnboardingChecklist />
</QueryClientProvider>
);
// Чеклист действительно виден (не скрыт по loading/dismissed)
expect(container.textContent).toContain('Начало работы');
const violations = await runAxe(container);
expect(
violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`)
).toEqual([]);
});
it('NotificationsPage: центр уведомлений без критичных нарушений', async () => {
const queryClient = makeQueryClient();
const { container } = render(
<QueryClientProvider client={queryClient}>
<NotificationsPage />
</QueryClientProvider>
);
// Список действительно отрендерился
expect(container.querySelector('[data-testid="notification-1"]')).toBeTruthy();
const violations = await runAxe(container);
expect(
violations.map(v => `${v.id}: ${v.nodes.map(n => n.target).join(', ')}`)
).toEqual([]);
});
});