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:
172
tests/a11y.test.tsx
Normal file
172
tests/a11y.test.tsx
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user