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:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View 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/);
});