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