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
This commit is contained in:
213
frontend/tests/react-admin.test.mjs
Normal file
213
frontend/tests/react-admin.test.mjs
Normal file
@@ -0,0 +1,213 @@
|
||||
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/);
|
||||
});
|
||||
Reference in New Issue
Block a user