Files
magistr/frontend/tests/react-admin.test.mjs
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00

214 lines
9.8 KiB
JavaScript
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 assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
ADMIN_ROUTES,
allowedAdminTabs,
badgeVisibility,
defaultAdminTabForRole,
fetchPendingRequestsCount,
logoTitleForRole,
navBadgeLabel,
navSectionsForRole,
requestCountLabel,
tabFromHash
} from '../react/admin/admin-service.js';
import { renderAdminWorkspace } from '../dist/tests/react-admin-renderer.mjs';
const readFrontendFile = relativePath => readFile(
new URL(`../${relativePath}`, import.meta.url),
'utf8'
);
test('навигация админки фильтруется по единой матрице ролей', () => {
const adminTabs = [...allowedAdminTabs('ADMIN')];
assert.ok(adminTabs.includes('dashboard'));
assert.ok(adminTabs.includes('users'));
assert.ok(adminTabs.includes('schedule'));
assert.equal(adminTabs.length, 14);
assert.ok(!adminTabs.includes('department-workspace'));
const officeSections = navSectionsForRole('EDUCATION_OFFICE');
const officeTabs = officeSections.flatMap(section => section.items.map(item => item.id));
assert.deepEqual(
officeTabs,
['dashboard', 'schedule', 'schedule-versions', 'schedule-quality', 'academic-calendar',
'auditorium-workload', 'schedule-view', 'teacher-absences', 'classrooms']
);
assert.ok(!officeTabs.includes('teacher-requests'));
assert.ok(!officeTabs.includes('department-workspace'));
assert.ok(!officeTabs.includes('users'));
const departmentTabs = navSectionsForRole('DEPARTMENT')
.flatMap(section => section.items.map(item => item.id));
assert.deepEqual(departmentTabs, ['department-workspace', 'schedule-view', 'teacher-absences']);
const viewerTabs = navSectionsForRole('SCHEDULE_VIEWER')
.flatMap(section => section.items.map(item => item.id));
assert.deepEqual(viewerTabs, ['schedule-view']);
assert.deepEqual(navSectionsForRole('UNKNOWN_ROLE'), []);
});
test('секции навигации сохраняют заголовки и русские подписи пунктов', () => {
const sections = navSectionsForRole('ADMIN');
assert.deepEqual(sections.map(section => section.title), ['', 'Расписание', 'Ресурсы', 'Система']);
const labels = sections.flatMap(section => section.items.map(item => item.label));
assert.ok(labels.includes('Конструктор правил'));
assert.ok(labels.includes('Версии расписания'));
assert.ok(labels.includes('Календарный график'));
assert.ok(labels.includes('Группы обучения'));
});
test('заголовок логотипа и вкладка по умолчанию зависят от роли', () => {
assert.equal(logoTitleForRole('ADMIN'), 'Magistr');
assert.equal(logoTitleForRole('EDUCATION_OFFICE'), 'Учебный отдел');
assert.equal(logoTitleForRole('DEPARTMENT'), 'Кафедра');
assert.equal(logoTitleForRole('SCHEDULE_VIEWER'), 'Расписания');
assert.equal(logoTitleForRole('UNKNOWN_ROLE'), 'Magistr');
assert.equal(defaultAdminTabForRole('ADMIN'), 'dashboard');
assert.equal(defaultAdminTabForRole('DEPARTMENT'), 'department-workspace');
assert.equal(defaultAdminTabForRole('SCHEDULE_VIEWER'), 'schedule-view');
});
test('hash-навигация уважает разрешения роли', () => {
assert.equal(tabFromHash('#users', 'ADMIN'), 'users');
assert.equal(tabFromHash('#users', 'DEPARTMENT'), 'department-workspace');
assert.equal(tabFromHash('#schedule-view', 'SCHEDULE_VIEWER'), 'schedule-view');
assert.equal(tabFromHash('#unknown', 'ADMIN'), 'dashboard');
assert.equal(tabFromHash('', 'ADMIN'), 'dashboard');
assert.equal(tabFromHash('#teacher-requests', 'EDUCATION_OFFICE'), 'dashboard');
});
test('счётчик заявок склоняется и обрезается по правилам русского языка', () => {
assert.equal(requestCountLabel(1), '1 заявка');
assert.equal(requestCountLabel(2), '2 заявки');
assert.equal(requestCountLabel(5), '5 заявок');
assert.equal(requestCountLabel(11), '11 заявок');
assert.equal(requestCountLabel(21), '21 заявка');
assert.equal(requestCountLabel(23), '23 заявки');
assert.equal(requestCountLabel(100), '100 заявок');
assert.equal(navBadgeLabel(0), '0');
assert.equal(navBadgeLabel(7), '7');
assert.equal(navBadgeLabel(100), '99+');
assert.deepEqual(badgeVisibility(0), { hidden: true, ariaLabel: 'Заявок в очереди нет' });
assert.deepEqual(badgeVisibility(3), { hidden: false, ariaLabel: 'Заявок в очереди: 3' });
});
test('оболочка запрашивает счётчик ожидающих заявок одним page-запросом', async () => {
const calls = [];
const client = {
get: async (endpoint, options) => {
calls.push({ endpoint, options });
return { totalItems: 42 };
}
};
assert.equal(await fetchPendingRequestsCount(client), 42);
assert.equal(calls.length, 1);
assert.equal(calls[0].endpoint, '/api/teacher-requests/page?status=PENDING&page=0&size=1');
assert.ok(calls[0].options.errorMessage);
const emptyClient = { get: async () => ({}) };
assert.equal(await fetchPendingRequestsCount(emptyClient), 0);
});
test('маршруты вкладок полностью перенесены на React', async () => {
const reactTabsSource = await readFrontendFile('react/admin/react-tabs.js');
const reactTabs = Array.from(reactTabsSource.matchAll(/^\s{4}'?([\w-]+)'?: \{ Component/gm), match => match[1]);
assert.deepEqual(reactTabs, [
'dashboard',
'classrooms',
'subjects',
'users',
'groups',
'university-structure',
'teacher-requests',
'department-workspace',
'auditorium-workload',
'academic-calendar',
'schedule-view',
'schedule',
'schedule-versions',
'schedule-quality',
'teacher-absences'
]);
assert.equal(reactTabs.length, 15);
for (const [tab, route] of Object.entries(ADMIN_ROUTES)) {
assert.ok(route.title, tab);
assert.ok(reactTabs.includes(tab), `${tab} перенесён на React`);
assert.equal(route.viewFile, undefined, `${tab} не должен ссылаться на HTML-фрагмент`);
}
assert.equal(ADMIN_ROUTES.schedule.title, 'Конструктор правил');
});
test('каждая lazy-вкладка разрешается в именованный экспорт своего модуля', async () => {
const source = await readFrontendFile('react/admin/react-tabs.js');
// React.lazy требует default-экспорт: модули вкладок используют
// именованные экспорты, поэтому реестр обязан явно отображать их
// через .then(m => ({ default: m.XTab })). Голый import() внутри
// lazy(...) роняет всю админ-панель в браузере (React error #306).
assert.doesNotMatch(source, /lazy\(\(\) => import\([^)]+\)\)/);
const entries = Array.from(source.matchAll(
/'?\w+'?:\s*\{\s*Component:\s*lazy\(\(\)\s*=>\s*import\('([^']+)'\)\.then\(m\s*=>\s*\(\{\s*default:\s*m\.(\w+)\s*\}\)\)\)/g
));
assert.equal(entries.length, 15);
for (const [, modulePath, exportName] of entries) {
const tabSource = await readFrontendFile(`react/admin/${modulePath.replace('./', '')}`);
assert.match(
tabSource,
new RegExp(`export (?:function|const|class) ${exportName}\\b`),
`${modulePath} должен экспортировать компонент ${exportName} для React.lazy`
);
}
});
test('оболочка рендерит sidebar, topbar и контейнер вкладки', () => {
const markup = renderAdminWorkspace({ role: 'ADMIN' });
assert.match(markup, /class="sidebar"/);
assert.match(markup, /sidebar-nav/);
assert.match(markup, /data-tab="dashboard"/);
assert.match(markup, /data-tab="teacher-requests"/);
assert.match(markup, /data-tab="users"/);
assert.match(markup, /Дашборд/);
assert.match(markup, /Конструктор правил/);
assert.match(markup, /id="page-title"/);
assert.match(markup, /id="app-content"/);
// Дашборд перенесён на React: статический рендер показывает Suspense-fallback
assert.match(markup, /class="tab-loading"/);
assert.match(markup, /Настройки/);
assert.match(markup, /Выйти/);
assert.doesNotMatch(markup, /style="/);
});
test('оболочка скрывает недоступные вкладки и посторонние разделы', () => {
const markup = renderAdminWorkspace({ role: 'SCHEDULE_VIEWER' });
assert.match(markup, /data-tab="schedule-view"/);
assert.doesNotMatch(markup, /data-tab="dashboard"/);
assert.doesNotMatch(markup, /data-tab="users"/);
assert.doesNotMatch(markup, /Система/);
assert.doesNotMatch(markup, /Ресурсы/);
assert.match(markup, /Расписания/);
});
test('оболочка админки не содержит бургер-меню и контекста кафедры', async () => {
const index = await readFrontendFile('admin/index.html');
const workspace = await readFrontendFile('react/admin/AdminApp.jsx');
assert.doesNotMatch(index, /menu-toggle/);
assert.doesNotMatch(workspace, /menu-toggle/);
assert.doesNotMatch(index, /id="workspace-context"/);
assert.match(index, /id="admin-root"/);
assert.match(index, /\/react\/admin\.js/);
});