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

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateCalendarTooltipPosition } from '../admin/js/views/academic-calendar.js';
import { calculateCalendarTooltipPosition } from '../react/admin/academic-calendar-service.js';
test('подсказка располагается с отступом справа и снизу от курсора', () => {
assert.deepEqual(calculateCalendarTooltipPosition({

View File

@@ -7,7 +7,7 @@ import {
activityColorClass,
activityColorCode,
validateAcademicYearSemesters
} from '../admin/js/views/academic-calendar.js';
} from '../react/admin/academic-calendar-service.js';
test('окончание, название и границы семестров рассчитываются по году начала', () => {
assert.deepEqual(academicYearDefaults('2026'), {
@@ -53,11 +53,11 @@ test('цвет обозначения берётся из справочника
});
test('форма создания не содержит поля названия и включает оба семестра', async () => {
const view = await readFile(new URL('../admin/views/academic-calendar.html', import.meta.url), 'utf8');
const view = await readFile(new URL('../react/admin/tabs/AcademicCalendarTab.jsx', import.meta.url), 'utf8');
assert.doesNotMatch(view, /id="academic-year-title"/);
assert.match(view, /id="academic-year-start"[^>]+maxlength="4"[^>]+pattern="\[0-9\]\{4\}"/);
assert.match(view, /id="academic-year-end"[^>]+maxlength="4"[^>]+readonly/);
assert.match(view, /id="academic-year-start"[^>]+maxLength="4"[^>]+pattern="\[0-9\]\{4\}"/);
assert.match(view, /id="academic-year-end"[^>]+maxLength="4"[^>]+readOnly/);
assert.match(view, /id="academic-year-autumn-start"/);
assert.match(view, /id="academic-year-spring-start"/);
assert.match(view, /Сохранить учебный год и семестры/);

View File

@@ -5,7 +5,7 @@ import test from 'node:test';
import {
scheduleSlotActionLabel,
shouldMarkRuleFormDirty
} from '../admin/js/views/schedule.js';
} from '../react/admin/schedule-service.js';
const readFrontendFile = relativePath => readFile(
new URL(`../${relativePath}`, import.meta.url),
@@ -26,57 +26,66 @@ test('конструктор считает изменениями только
});
test('поисковые списки просмотра расписаний загружаются свёрнутыми', async () => {
const scheduleView = await readFrontendFile('admin/js/views/schedule-view.js');
const [scheduleViewTab, combobox] = await Promise.all([
readFrontendFile('react/admin/tabs/ScheduleViewTab.jsx'),
readFrontendFile('react/shared/ui/AsyncCombobox.jsx')
]);
assert.match(scheduleView, /combo\.preload\(''\)/);
assert.doesNotMatch(scheduleView, /combo\.search\(''\)/);
assert.match(scheduleViewTab, /<AsyncCombobox/);
assert.doesNotMatch(scheduleViewTab, /search\(''\)/);
assert.match(combobox, /const \[open, setOpen\] = useState\(false\)/);
assert.match(combobox, /onFocus=\{handleFocus\}/);
});
test('в общих заголовках админ-панели и настроек нет бургер-меню', async () => {
const files = await Promise.all([
readFrontendFile('admin/index.html'),
readFrontendFile('admin/js/main.js'),
readFrontendFile('react/admin/AdminApp.jsx'),
readFrontendFile('admin/settings/index.html'),
readFrontendFile('admin/settings/js/main.js')
readFrontendFile('react/settings/SettingsApp.jsx')
]);
files.forEach(content => assert.doesNotMatch(content, /menu-toggle/));
});
test('заголовок вкладки соответствует конструктору правил', async () => {
const mainScript = await readFrontendFile('admin/js/main.js');
assert.match(mainScript, /schedule:\s*\{\s*title:\s*'Конструктор правил'/);
const adminService = await readFrontendFile('react/admin/admin-service.js');
assert.match(adminService, /schedule:\s*\{\s*title:\s*'Конструктор правил'/);
});
test('основная кнопка показа находится в заголовке блока параметров', async () => {
const scheduleView = await readFrontendFile('admin/views/schedule-view.html');
const controlHeaderIndex = scheduleView.indexOf('schedule-view-control-header');
const controlGridIndex = scheduleView.indexOf('schedule-view-control-grid');
const loadButtonIndex = scheduleView.indexOf('id="schedule-view-load"');
const scheduleViewTab = await readFrontendFile('react/admin/tabs/ScheduleViewTab.jsx');
const controlHeaderIndex = scheduleViewTab.indexOf('schedule-view-control-header');
const controlGridIndex = scheduleViewTab.indexOf('schedule-view-control-grid');
const loadButtonIndex = scheduleViewTab.indexOf('id="schedule-view-load"');
assert.ok(controlHeaderIndex >= 0);
assert.ok(loadButtonIndex > controlHeaderIndex);
assert.ok(loadButtonIndex < controlGridIndex);
assert.equal(scheduleView.match(/id="schedule-view-load"/g)?.length, 1);
assert.equal(scheduleViewTab.match(/id="schedule-view-load"/g)?.length, 1);
});
test('пустое состояние расписания содержит собственную кнопку обновления', async () => {
const scheduleScript = await readFrontendFile('admin/js/views/schedule-view.js');
const [scheduleViewService, scheduleViewTab] = await Promise.all([
readFrontendFile('react/admin/schedule-view-service.js'),
readFrontendFile('react/admin/tabs/ScheduleViewTab.jsx')
]);
assert.match(scheduleScript, /SCHEDULE_REFRESH_PROMPT\s*=\s*'Нажмите «Показать», чтобы обновить расписание'/);
assert.match(scheduleScript, /data-schedule-view-reload/);
assert.match(scheduleScript, /closest\('\[data-schedule-view-reload\]'\)[\s\S]*?loadData\(\)/);
assert.match(scheduleScript, /setTableState\(message,\s*message === SCHEDULE_REFRESH_PROMPT\)/);
assert.match(scheduleViewService, /SCHEDULE_REFRESH_PROMPT\s*=\s*'Нажмите «Показать», чтобы обновить расписание'/);
assert.match(scheduleViewTab, /data-schedule-view-reload/);
assert.match(scheduleViewTab, /data-schedule-view-reload=""[\s\S]*?void loadData\(\)/);
assert.match(scheduleViewTab, /actionable: message === SCHEDULE_REFRESH_PROMPT/);
assert.match(scheduleViewTab, /renderStateRow\(tableState\.message,\s*tableState\.actionable\)/);
});
test('действия слотов имеют размер соседнего поля', async () => {
const [components, scheduleScript] = await Promise.all([
const [components, scheduleTab] = await Promise.all([
readFrontendFile('admin/css/components.css'),
readFrontendFile('admin/js/views/schedule.js')
readFrontendFile('react/admin/tabs/ScheduleTab.jsx')
]);
assert.match(components, /\.schedule-slot-remove\s*\{[^}]*min-width:\s*125px[^}]*min-height:\s*44px/s);
assert.match(scheduleScript, /class="btn btn-md \$\{actionClass\} schedule-slot-remove"/);
assert.match(scheduleTab, /className=\{`btn btn-md \$\{actionClass\} schedule-slot-remove`\}/);
});
test('все варианты системных кнопок имеют видимую обводку', async () => {
@@ -89,59 +98,57 @@ test('все варианты системных кнопок имеют вид
});
test('подсказка календарной сетки получает позицию напрямую от курсора', async () => {
const calendarScript = await readFrontendFile('admin/js/views/academic-calendar.js');
const calendarTab = await readFrontendFile('react/admin/tabs/AcademicCalendarTab.jsx');
assert.match(calendarScript, /translate3d\(\$\{left\}px, \$\{top\}px, 0\) scale\(1\)/);
assert.match(calendarScript, /\{ duration: 1, fill: 'forwards' \}/);
assert.doesNotMatch(calendarScript, /\.style\.(?:left|top)\s*=/);
assert.match(calendarTab, /translate3d\(\$\{left\}px, \$\{top\}px, 0\) scale\(1\)/);
assert.match(calendarTab, /\{ duration: 1, fill: 'forwards' \}/);
assert.doesNotMatch(calendarTab, /\.style\.(?:left|top)\s*=/);
});
test('светлая тема задаёт контрастные пункты и заголовки левого меню', async () => {
const [layout, index] = await Promise.all([
const [layout, workspace, adminService] = await Promise.all([
readFrontendFile('admin/css/layout.css'),
readFrontendFile('admin/index.html')
readFrontendFile('react/admin/AdminApp.jsx'),
readFrontendFile('react/admin/admin-service.js')
]);
assert.match(layout, /\[data-theme="light"\] \.sidebar \.nav-item\s*\{[^}]*color:\s*#334155/s);
assert.match(layout, /\[data-theme="light"\] \.sidebar-section-title\s*\{[^}]*color:\s*#64748b !important/s);
assert.match(index, />Расписание<\/div>/);
assert.match(workspace, /sidebar-section-title/);
assert.match(adminService, /title: 'Расписание'/);
});
test('таблица календарных графиков не содержит отдельный столбец названия', async () => {
const [calendarView, calendarScript] = await Promise.all([
readFrontendFile('admin/views/academic-calendar.html'),
readFrontendFile('admin/js/views/academic-calendar.js')
]);
const bodyIndex = calendarView.indexOf('id="academic-calendars-tbody"');
const tableStart = calendarView.lastIndexOf('<table>', bodyIndex);
const tableEnd = calendarView.indexOf('</table>', bodyIndex);
const calendarTable = calendarView.slice(tableStart, tableEnd);
const calendarTab = await readFrontendFile('react/admin/tabs/AcademicCalendarTab.jsx');
const bodyIndex = calendarTab.indexOf('id="academic-calendars-tbody"');
const tableStart = calendarTab.lastIndexOf('<table>', bodyIndex);
const tableEnd = calendarTab.indexOf('</table>', bodyIndex);
const calendarTable = calendarTab.slice(tableStart, tableEnd);
assert.doesNotMatch(calendarTable, /<th>Название<\/th>/);
assert.match(calendarTable, /colspan="6"/);
assert.doesNotMatch(calendarScript, /title:\s*buildCalendarTitleFromForm/);
assert.match(calendarTable, /colSpan=\{6\}/);
assert.doesNotMatch(calendarTab, /title:\s*buildCalendarTitleFromForm/);
});
test('конструктор правил показывает готовую компоновку без скачков колонок', async () => {
const [scheduleView, scheduleScript, components] = await Promise.all([
readFrontendFile('admin/views/schedule.html'),
readFrontendFile('admin/js/views/schedule.js'),
const [scheduleTab, components] = await Promise.all([
readFrontendFile('react/admin/tabs/ScheduleTab.jsx'),
readFrontendFile('admin/css/components.css')
]);
assert.match(scheduleView, /id="schedule-initial-state"/);
assert.match(scheduleView, /id="schedule-initial-content"[^>]*class="schedule-initial-content is-loading"|class="schedule-initial-content is-loading"[^>]*id="schedule-initial-content"/);
assert.match(scheduleView, /<table id="schedule-table">\s*<colgroup>/s);
assert.match(scheduleScript, /finally\s*\{[\s\S]*initialContent\?\.classList\.remove\('is-loading'\)/);
assert.match(scheduleTab, /id="schedule-initial-state"/);
assert.match(scheduleTab, /schedule-initial-content\$\{initialPhase === 'loading' \? ' is-loading' : ''\}/);
assert.match(scheduleTab, /<table id="schedule-table">\s*<colgroup>/s);
assert.match(scheduleTab, /finally\s*\{[\s\S]*?setInitialPhase\('ready'\)/);
assert.match(components, /#schedule-table\s*\{[^}]*table-layout:\s*fixed/s);
assert.match(components, /#schedule-table tbody tr\s*\{[^}]*animation:\s*none/s);
});
test('форма учебного года расположена после рабочих блоков календарного графика', async () => {
const calendarView = await readFrontendFile('admin/views/academic-calendar.html');
const calendarsRegistryIndex = calendarView.indexOf('id="academic-calendars-tbody"');
const academicYearBuilderIndex = calendarView.indexOf('academic-year-builder-card');
const managePanelEndIndex = calendarView.indexOf('</section>', academicYearBuilderIndex);
const calendarTab = await readFrontendFile('react/admin/tabs/AcademicCalendarTab.jsx');
const calendarsRegistryIndex = calendarTab.indexOf('id="academic-calendars-tbody"');
const academicYearBuilderIndex = calendarTab.indexOf('academic-year-builder-card');
const managePanelEndIndex = calendarTab.indexOf('</section>', academicYearBuilderIndex);
assert.ok(calendarsRegistryIndex >= 0);
assert.ok(academicYearBuilderIndex > calendarsRegistryIndex);
@@ -149,13 +156,14 @@ test('форма учебного года расположена после р
});
test('поисковые и диалоговые списки имеют размытие и светлую тему', async () => {
const [components, absences, absenceView] = await Promise.all([
const [components, absences, absenceTab] = await Promise.all([
readFrontendFile('admin/css/components.css'),
readFrontendFile('admin/css/teacher-absences.css'),
readFrontendFile('admin/views/teacher-absences.html')
readFrontendFile('react/admin/tabs/TeacherAbsencesTab.jsx')
]);
assert.match(absenceView, /id="teacher-absence-teacher"[^>]*data-native-select="true"/);
assert.match(absenceTab, /AsyncCombobox/);
assert.match(absenceTab, /placeholder="Найдите преподавателя по ФИО"/);
assert.match(components, /\.async-combobox-menu\s*\{[^}]*backdrop-filter:\s*blur\(20px\)/s);
assert.match(components, /\[data-theme="light"\] \.async-combobox-menu\s*\{[^}]*background:\s*rgba\(255, 255, 255, 0\.96\)/s);
assert.match(absences, /\.teacher-dialogue-toolbar \.custom-select-menu\s*\{[^}]*backdrop-filter:\s*blur\(20px\)/s);

View File

@@ -50,9 +50,11 @@ test('неполный, шестизначный год и несуществу
});
test('все поля дат календарного графика подключены к числовой маске', async () => {
const template = await readFile(new URL('../admin/views/academic-calendar.html', import.meta.url), 'utf8');
assert.equal(template.match(/\bdata-date-input\b/g)?.length, 6);
assert.equal(template.match(/\bmaxlength="10"/g)?.length, 6);
const template = await readFile(new URL('../react/admin/tabs/AcademicCalendarTab.jsx', import.meta.url), 'utf8');
// Маска объявлена один раз в компоненте поля даты и подключена ко всем шести полям
assert.match(template, /data-date-input/);
assert.match(template, /maxLength="10"/);
assert.equal(template.match(/<MaskedDateInput\b/g)?.length, 6);
assert.doesNotMatch(template, /type="date"/);
});

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
LOGIN_ERROR_MESSAGES,
requestLogin,
validateLogin
} from '../react/login/login-service.js';
test('React-форма входа валидирует обязательные поля и длину пароля', () => {
assert.deepEqual(validateLogin(), {
password: 'Введите пароль',
username: 'Введите имя пользователя'
});
assert.deepEqual(validateLogin({ username: 'admin', password: '123' }), {
password: 'Минимум 4 символа'
});
assert.deepEqual(validateLogin({ username: ' admin ', password: '1234' }), {});
});
test('React-форма сохраняет прежний контракт POST /api/auth/login', async () => {
const calls = [];
const result = await requestLogin(
{ username: ' admin ', password: 'секрет' },
async (url, options) => {
calls.push({ options, url });
return new Response(JSON.stringify({
redirect: '/admin/',
role: 'ADMIN',
token: 'access-token'
}), {
headers: { 'Content-Type': 'application/json' },
status: 200
});
}
);
assert.equal(result.ok, true);
assert.equal(calls[0].url, '/api/auth/login');
assert.equal(calls[0].options.method, 'POST');
assert.equal(calls[0].options.credentials, 'same-origin');
assert.deepEqual(JSON.parse(calls[0].options.body), {
password: 'секрет',
username: 'admin'
});
});
test('React-форма использует безопасное русское сообщение при не-JSON ошибке', async () => {
const result = await requestLogin(
{ username: 'admin', password: '1234' },
async () => new Response('Ошибка шлюза', { status: 502 })
);
assert.equal(result.ok, false);
assert.equal(result.message, LOGIN_ERROR_MESSAGES.credentials);
});

View File

@@ -0,0 +1,257 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyModeLabel,
buildSlotPayload,
createScope,
createTenant,
dayShort,
defaultScopeId,
defaultSettingsTabForRole,
deleteTenant,
fetchDatabaseStatus,
fetchEducationForms,
fetchScopes,
fetchTenants,
fetchTimeSlots,
normalizeDomain,
resolveActiveScopeId,
saveTimeSlot,
scopeBadgeClass,
scopeBadgeLabel,
scopeOptionLabel,
scopeSummaryText,
settingsTabsForRole,
slotDurationMinutes,
slotsForScope,
testTenantConnection,
validateSlotPayload,
validateTenantFields
} from '../react/settings/settings-service.js';
import {
renderConfirmDialog,
renderDatabaseTab,
renderEduFormsTab,
renderGeneralTab,
renderTimeSlotsTab
} from '../dist/tests/react-settings-renderer.mjs';
const SCOPES = [
{ applyMode: 'DEFAULT', id: 1, name: 'Базовая сетка' },
{ applyMode: 'WEEKDAY', dayOfWeek: 6, id: 2, name: 'Субботняя сетка' },
{ applyMode: 'MANUAL', id: 3, name: 'Праздничная', systemScope: false }
];
const SLOTS = [
{ endTime: '10:10:00', id: 11, orderNumber: 2, scopeId: 1, startTime: '08:30:00' },
{ endTime: '10:10:00', id: 10, orderNumber: 1, scopeId: 1, startTime: '08:30:00' },
{ endTime: '13:30:00', id: 12, orderNumber: 1, scopeId: 2, startTime: '12:00:00' }
];
test('вкладки настроек фильтруются по единой матрице ролей', () => {
assert.deepEqual(
settingsTabsForRole('ADMIN').map(tab => tab.id),
['general', 'time-slots', 'edu-forms', 'database']
);
assert.deepEqual(
settingsTabsForRole('EDUCATION_OFFICE').map(tab => tab.id),
['time-slots', 'edu-forms']
);
assert.deepEqual(settingsTabsForRole('DEPARTMENT'), []);
assert.deepEqual(settingsTabsForRole('UNKNOWN_ROLE'), []);
assert.equal(defaultSettingsTabForRole('ADMIN'), 'general');
assert.equal(defaultSettingsTabForRole('EDUCATION_OFFICE'), 'time-slots');
assert.equal(defaultSettingsTabForRole('DEPARTMENT'), null);
});
test('метки сеток времени переводятся на русский', () => {
assert.equal(applyModeLabel('DEFAULT'), 'применяется по умолчанию');
assert.equal(applyModeLabel('WEEKDAY'), 'применяется автоматически');
assert.equal(applyModeLabel('MANUAL'), 'применяется вручную');
assert.equal(dayShort(6), 'Сб');
assert.equal(dayShort(1), 'Пн');
assert.equal(dayShort(99), '-');
assert.equal(scopeOptionLabel(SCOPES[0]), 'Базовая сетка - применяется по умолчанию');
assert.equal(scopeOptionLabel(SCOPES[1]), 'Субботняя сетка - применяется автоматически, Сб');
assert.equal(scopeBadgeLabel(SCOPES[0]), 'Базовая');
assert.equal(scopeBadgeLabel(SCOPES[1]), 'Сб');
assert.equal(scopeBadgeLabel(SCOPES[2]), 'Ручная');
assert.equal(scopeBadgeClass(SCOPES[0]), 'scope-badge scope-badge-base');
assert.equal(scopeBadgeClass(SCOPES[2]), 'scope-badge');
assert.equal(scopeSummaryText(SCOPES[0], SLOTS), 'применяется по умолчанию. Слотов: 2.');
assert.equal(scopeSummaryText(SCOPES[1], SLOTS), 'применяется автоматически, Сб. Слотов: 1.');
});
test('активная сетка fallback-ится на базовую, слоты сортируются по номеру пары', () => {
assert.equal(defaultScopeId(SCOPES), 1);
assert.equal(resolveActiveScopeId(SCOPES, 3), 3);
assert.equal(resolveActiveScopeId(SCOPES, 999), 1);
assert.equal(resolveActiveScopeId([], 1), null);
const scoped = slotsForScope(SLOTS, 1);
assert.deepEqual(scoped.map(slot => slot.id), [10, 11]);
assert.deepEqual(slotsForScope(SLOTS, 3), []);
});
test('валидация слота проверяет сетку, номер пары и время', () => {
assert.equal(validateSlotPayload({}), 'Выберите сетку времени');
assert.equal(
validateSlotPayload({ orderNumber: 0, scopeId: 1 }),
'Укажите номер пары'
);
assert.equal(
validateSlotPayload({ orderNumber: 1, scopeId: 1, startTime: '', endTime: '' }),
'Укажите время начала и окончания'
);
assert.equal(
validateSlotPayload({ endTime: '08:30', orderNumber: 1, scopeId: 1, startTime: '10:10' }),
'Время начала должно быть раньше времени окончания'
);
assert.equal(
validateSlotPayload({ endTime: '10:10', orderNumber: 1, scopeId: 1, startTime: '08:30' }),
''
);
});
test('длительность слота считается в минутах и отвергает обратное время', () => {
assert.equal(slotDurationMinutes('08:30', '10:10'), 100);
assert.equal(slotDurationMinutes('12:00', '12:00'), null);
assert.equal(slotDurationMinutes('10:10', '08:30'), null);
assert.equal(slotDurationMinutes('', '10:10'), null);
});
test('payload слота различает создание и редактирование', () => {
assert.deepEqual(
buildSlotPayload({ endTime: '10:10', orderNumber: '2', scopeId: 1, startTime: '08:30' }),
{ endTime: '10:10', id: null, orderNumber: 2, scopeId: 1, startTime: '08:30' }
);
assert.deepEqual(
buildSlotPayload({ endTime: '10:10', id: '10', orderNumber: '1', scopeId: 1, startTime: '08:30' }),
{ endTime: '10:10', id: 10, orderNumber: 1, scopeId: 1, startTime: '08:30' }
);
});
test('валидация тенанта проверяет обязательные поля, домен нормализуется', () => {
assert.equal(validateTenantFields({ domain: 'swsu', name: 'ЮЗГУ', url: 'jdbc:postgresql://x' }), '');
assert.equal(validateTenantFields({ domain: ' ', name: 'ЮЗГУ', url: 'jdbc:postgresql://x' }), 'Заполните все обязательные поля');
assert.equal(validateTenantFields({}), 'Заполните все обязательные поля');
assert.equal(normalizeDomain(' SwSu '), 'swsu');
});
test('сервис настроек сохраняет контракты API', async () => {
const calls = [];
const client = {
get: async (endpoint, options) => {
calls.push({ endpoint, options });
return [];
},
post: async (endpoint, json, options) => {
calls.push({ endpoint, json, options });
return { success: true };
},
put: async (endpoint, json, options) => {
calls.push({ endpoint, json, options });
return {};
},
delete: async (endpoint, options) => {
calls.push({ endpoint, options });
return {};
}
};
await fetchScopes(client);
await createScope('Праздничная', client);
await fetchTimeSlots(client);
await saveTimeSlot({ endTime: '10:10', orderNumber: 1, scopeId: 1, startTime: '08:30' }, client);
await saveTimeSlot({ endTime: '10:10', id: 10, orderNumber: 1, scopeId: 1, startTime: '08:30' }, client);
await fetchEducationForms(client);
await fetchDatabaseStatus(client);
await fetchTenants(client);
await testTenantConnection({ password: 'p', url: 'jdbc:x', username: 'u' }, client);
await createTenant({ domain: 'swsu', name: 'ЮЗГУ', password: 'p', url: 'jdbc:x', username: 'u' }, client);
await deleteTenant('swsu', client);
assert.equal(calls[0].endpoint, '/api/admin/time-slots/scopes');
assert.equal(calls[1].endpoint, '/api/admin/time-slots/scopes');
assert.deepEqual(calls[1].json, { name: 'Праздничная' });
assert.equal(calls[2].endpoint, '/api/admin/time-slots');
assert.equal(calls[3].endpoint, '/api/admin/time-slots');
assert.equal(calls[4].endpoint, '/api/admin/time-slots/10');
assert.equal(calls[4].json.id, 10);
assert.equal(calls[5].endpoint, '/api/education-forms');
assert.equal(calls[6].endpoint, '/api/database/status');
assert.equal(calls[7].endpoint, '/api/database/tenants');
assert.equal(calls[8].endpoint, '/api/database/test');
assert.deepEqual(calls[8].json, { password: 'p', url: 'jdbc:x', username: 'u' });
assert.equal(calls[9].endpoint, '/api/database/tenants');
assert.equal(calls[10].endpoint, '/api/database/tenants/swsu');
assert.ok(calls.every(call => call.options && call.options.errorMessage));
});
test('вкладка общих настроек показывает заглушку разработки', () => {
const markup = renderGeneralTab();
assert.match(markup, /Общие настройки/);
assert.match(markup, /Этот раздел находится в разработке/);
assert.match(markup, /settings-placeholder/);
});
test('вкладка временных слотов рендерит сетку, форму и таблицу', () => {
const markup = renderTimeSlotsTab();
assert.match(markup, /Временные слоты/);
assert.match(markup, /Область действия/);
assert.match(markup, /Новая ручная сетка/);
assert.match(markup, /Новый слот/);
assert.match(markup, /Рассчитывается по времени начала и окончания/);
assert.match(markup, /Загрузка\.\.\./);
assert.match(markup, /Как применяется/);
});
test('вкладка форм обучения рендерит форму создания и таблицу', () => {
const markup = renderEduFormsTab();
assert.match(markup, /Новая форма обучения/);
assert.match(markup, /Все формы обучения/);
assert.match(markup, /Загрузка\.\.\./);
assert.match(markup, /required/);
});
test('вкладка базы данных рендерит статус, тенантов и форму без inline-стилей', async () => {
const { readFile } = await import('node:fs/promises');
const source = await readFile(
new URL('../react/settings/DatabaseTab.jsx', import.meta.url),
'utf8'
);
const markup = renderDatabaseTab();
assert.match(markup, /Текущее подключение/);
assert.match(markup, /Подключённые университеты/);
assert.match(markup, /Добавить подключение/);
assert.match(markup, /Загрузка\.\.\./);
assert.match(source, /db-status-grid/);
assert.match(markup, /autoComplete="new-password"/);
assert.doesNotMatch(markup, /style="/);
assert.doesNotMatch(source, /style="/);
});
test('диалог подтверждения переиспользует общие классы и семантику', () => {
const markup = renderConfirmDialog({
confirmLabel: 'Удалить',
danger: true,
message: 'Удалить форму обучения?',
title: 'Удаление формы обучения',
onClose: () => {},
onConfirm: () => {}
});
assert.match(markup, /project-dialog-backdrop/);
assert.match(markup, /role="dialog"/);
assert.match(markup, /aria-modal="true"/);
assert.match(markup, /aria-labelledby="project-dialog-title"/);
assert.match(markup, /btn-danger/);
assert.match(markup, /Удалить форму обучения\?/);
});

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestJson, ApiRequestError } from '../react/shared/api-client.js';
import { loadAuthorizedSession } from '../react/shared/auth-client.js';
import { resolveSessionState, SESSION_PHASES } from '../react/shared/session-policy.js';
import {
renderAsyncState,
renderErrorBoundaryContent,
renderFormAlert
} from '../dist/tests/react-shared-renderer.mjs';
test('общая политика сессии различает разрешённую и запрещённую роль', () => {
const ready = resolveSessionState({ role: 'STUDENT', userId: '7' }, ['STUDENT']);
const denied = resolveSessionState({ role: 'TEACHER', userId: '8' }, ['STUDENT']);
assert.equal(ready.phase, SESSION_PHASES.ready);
assert.equal(ready.session.userId, '7');
assert.equal(denied.phase, SESSION_PHASES.unauthorized);
assert.equal(denied.message, 'У вас нет доступа к этому разделу');
});
test('auth-адаптер возвращает безопасное состояние при ошибке восстановления', async () => {
const state = await loadAuthorizedSession(['STUDENT'], {
restoreSessionImpl: async () => {
throw new Error('Техническая причина не должна попасть в UI');
}
});
assert.equal(state.phase, SESSION_PHASES.error);
assert.equal(state.message, 'Не удалось восстановить сессию');
assert.doesNotMatch(state.message, /Техническая причина/);
});
test('общий API-клиент сериализует JSON и сохраняет same-origin auth-транспорт', async () => {
const calls = [];
const result = await requestJson('/api/example', {
json: { value: 'данные' },
method: 'POST'
}, {
fetchImpl: async (endpoint, options) => {
calls.push({ endpoint, options });
return new Response(JSON.stringify({ saved: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200
});
}
});
assert.deepEqual(result, { saved: true });
assert.equal(calls[0].endpoint, '/api/example');
assert.equal(calls[0].options.method, 'POST');
assert.equal(new Headers(calls[0].options.headers).get('Accept'), 'application/json');
assert.equal(new Headers(calls[0].options.headers).get('Content-Type'), 'application/json');
assert.deepEqual(JSON.parse(calls[0].options.body), { value: 'данные' });
});
test('общий API-клиент не выводит технический ответ сервера как пользовательскую ошибку', async () => {
await assert.rejects(
requestJson('/api/example', { errorMessage: 'Не удалось загрузить пример' }, {
fetchImpl: async () => new Response(JSON.stringify({ message: 'Internal failure' }), {
headers: { 'Content-Type': 'application/json' },
status: 500
})
}),
error => {
assert.ok(error instanceof ApiRequestError);
assert.equal(error.message, 'Не удалось загрузить пример');
assert.equal(error.status, 500);
assert.equal(error.data.message, 'Internal failure');
return true;
}
);
});
test('общие React-состояния имеют русские тексты и доступную семантику', () => {
const loading = renderAsyncState({ kind: 'loading' });
const failure = renderAsyncState({
kind: 'error',
message: 'Не удалось получить расписание',
onRetry: () => {}
});
const alert = renderFormAlert({
alert: { message: 'Проверьте поля', type: 'error' },
id: 'form-alert'
});
assert.match(loading, /role="status"/);
assert.match(loading, /Загружаем данные/);
assert.match(failure, /role="alert"/);
assert.match(failure, /Не удалось получить расписание/);
assert.match(failure, /Повторить/);
assert.match(alert, /id="form-alert"/);
assert.match(alert, /Проверьте поля/);
assert.match(renderErrorBoundaryContent(), /Рабочее содержимое/);
});

View File

@@ -0,0 +1,191 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
addDays,
buildLessonChips,
buildWeekStatusMessage,
fetchGroupOptions,
fetchSchedule,
formatWeekLabel,
groupLessonsByDate,
groupOptionDescription,
parseIsoDate,
resolveSelectedGroup,
startOfWeek,
toIsoDate,
weekDates,
weekEndIso
} from '../react/student/student-service.js';
import {
renderAsyncCombobox,
renderScheduleOverview,
renderWeekGrid,
weekStartOf
} from '../dist/tests/react-student-renderer.mjs';
test('неделя студента начинается с понедельника, включая воскресенье', () => {
assert.equal(toIsoDate(startOfWeek(parseIsoDate('2026-09-30'))), '2026-09-28');
assert.equal(toIsoDate(startOfWeek(parseIsoDate('2026-10-04'))), '2026-09-28');
assert.equal(toIsoDate(startOfWeek(parseIsoDate('2026-10-10'))), '2026-10-05');
});
test('недельная сетка содержит семь дней и подпись границ недели', () => {
const weekStart = startOfWeek(parseIsoDate('2026-09-28'));
const days = weekDates(weekStart);
assert.equal(days.length, 7);
assert.equal(toIsoDate(days[0]), '2026-09-28');
assert.equal(toIsoDate(days[6]), '2026-10-04');
assert.equal(weekEndIso(weekStart), '2026-10-04');
assert.equal(formatWeekLabel(weekStart), '28.09.2026 - 04.10.2026');
});
test('занятия группируются по датам без потери пустых дней', () => {
const byDate = groupLessonsByDate([
{ date: '2026-09-28', subjectName: 'Лекции' },
{ date: '2026-09-28', subjectName: 'Практика' },
{ date: '2026-09-30', subjectName: 'Лаборатория' }
]);
assert.equal(byDate.get('2026-09-28').length, 2);
assert.equal(byDate.get('2026-09-30').length, 1);
assert.equal(byDate.get('2026-10-01'), undefined);
});
test('чипы занятия содержат тип, подгруппы, формат, преподавателя, аудиторию и группы', () => {
const chips = buildLessonChips({
classroomName: '301',
groupNames: ['ИС-41', 'ИС-42'],
lessonFormat: 'Очно',
lessonTypeName: 'Лекция',
subgroupNames: ['1 подгруппа'],
teacherName: 'Иванов И. И.'
});
assert.deepEqual(chips, ['Лекция', '1 подгруппа', 'Очно', 'Иванов И. И.', '301', 'ИС-41, ИС-42']);
});
test('подгруппа-строка используется, когда массив подгрупп пуст', () => {
const chips = buildLessonChips({
lessonTypeName: 'Практика',
subgroupNames: [],
subgroupName: '2 подгруппа',
teacherName: 'Петрова П. П.'
});
assert.deepEqual(chips, ['Практика', '2 подгруппа', 'Петрова П. П.']);
});
test('статус недели различает найденные занятия и пустую неделю', () => {
assert.equal(buildWeekStatusMessage([{ date: '2026-09-28' }]), 'Найдено занятий: 1');
assert.equal(buildWeekStatusMessage([]), 'На этой неделе занятий нет');
});
test('выбор группы сохраняет сохранённый ID и падает обратно на первую группу', () => {
const groups = [{ id: 1, name: 'ИС-41' }, { id: 7, name: 'ИС-42' }];
assert.equal(resolveSelectedGroup(groups, '7').name, 'ИС-42');
assert.equal(resolveSelectedGroup(groups, '999').name, 'ИС-41');
assert.equal(resolveSelectedGroup([], '7'), null);
});
test('описание группы собирает код, профиль и курс через точку', () => {
assert.equal(
groupOptionDescription({ course: 3, specialtyCode: '09.03.03', specialtyProfileName: 'Информационные системы' }),
'09.03.03 · Информационные системы · 3 курс'
);
assert.equal(groupOptionDescription({ specialtyCode: '09.03.03' }), '09.03.03');
});
test('сервис студента сохраняет прежние контракты API', async () => {
const calls = [];
const client = {
get: async (endpoint, options) => {
calls.push({ endpoint, options });
if (endpoint.startsWith('/api/groups/options')) return [{ id: 5, name: 'ИС-41' }];
return [{ date: '2026-09-28', subjectName: 'Лекция' }];
}
};
const groups = await fetchGroupOptions({ query: 'ИС', selectedId: '5' }, client);
const lessons = await fetchSchedule({ groupId: 5, startDate: '2026-09-28', endDate: '2026-10-04' }, client);
assert.deepEqual(groups, [{ id: 5, name: 'ИС-41' }]);
assert.deepEqual(lessons, [{ date: '2026-09-28', subjectName: 'Лекция' }]);
assert.match(calls[0].endpoint, /^\/api\/groups\/options\?query=%D0%98%D0%A1&limit=20&selectedId=5$/);
assert.equal(calls[0].options.errorMessage, 'Не удалось загрузить группы');
assert.equal(calls[1].endpoint, '/api/schedule?groupId=5&startDate=2026-09-28&endDate=2026-10-04');
assert.equal(calls[1].options.errorMessage, 'Не удалось загрузить расписание');
});
test('сетка недели рендерит дни, занятия и пустые дни с русскими текстами', () => {
const markup = renderWeekGrid({
lessons: [
{
classroomName: '301',
date: '2026-09-28',
endTime: '10:10',
lessonFormat: 'Очно',
lessonTypeName: 'Лекция',
startTime: '08:30',
subjectName: 'Базы данных',
teacherName: 'Иванов И. И.'
}
],
weekStart: weekStartOf('2026-09-30')
});
assert.match(markup, /Понедельник/);
assert.match(markup, /Базы данных/);
assert.match(markup, /08:30 - 10:10/);
assert.match(markup, /class="chip"/);
assert.match(markup, /Занятий нет/);
assert.match(markup, /aria-live="polite"/);
});
test('обзор ближайших занятий выделяет сегодня и следующую пару', () => {
const now = new Date('2026-09-28T09:00:00');
const markup = renderScheduleOverview({
lessons: [
{ date: '2026-09-28', startTime: '08:30', endTime: '10:10', subjectName: 'Базы данных', teacherName: 'Иванов И. И.', classroomName: '301' },
{ date: '2026-09-30', startTime: '12:00', endTime: '13:30', subjectName: 'Тестирование', teacherName: 'Петрова П. П.', classroomName: '205' }
],
now,
onGoToday: () => {}
});
assert.match(markup, /Быстрый обзор/);
assert.match(markup, /1 занятие/);
assert.match(markup, /Следующая пара/);
assert.match(markup, /Идёт сейчас/);
assert.match(markup, /Открыть текущую неделю/);
});
test('обзор без ближайших занятий показывает пустые состояния', () => {
const markup = renderScheduleOverview({ lessons: [], now: new Date('2026-09-28T09:00:00') });
assert.match(markup, /Свободный день/);
assert.match(markup, /Пока ничего не запланировано/);
assert.match(markup, /В ближайшие 14 дней занятий нет\./);
});
test('поисковый список групп рендерится с доступной семантикой combobox', () => {
const markup = renderAsyncCombobox({
allowClear: false,
ariaLabel: 'Группа',
placeholder: 'Найдите группу по названию или специальности',
selectedLabel: 'ИС-41',
selectedValue: '5',
load: () => Promise.resolve([]),
onSelect: () => {}
});
assert.match(markup, /role="combobox"/);
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /aria-autocomplete="list"/);
assert.match(markup, /aria-label="Группа"/);
assert.match(markup, /value="ИС-41"/);
assert.doesNotMatch(markup, /async-combobox-clear/);
assert.doesNotMatch(markup, /Очистить выбор/);
});

View File

@@ -0,0 +1,440 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
absenceStatusClass,
absenceStatusLabel,
activeDatePreferences,
activeWeekPreferences,
buildChangeRequestPayload,
buildDesktopWeekModel,
buildTeacherLessonChips,
candidateOptions,
canRequestChange,
changeRequestTarget,
changeTypeLabel,
collectSlots,
createAbsence,
createPreference,
deletePreference,
fetchAbsences,
fetchChangeRequestCandidates,
fetchChangeRequests,
fetchPreferenceMeta,
fetchPreferences,
fetchTeacherSchedule,
findCompactPreference,
findWeekPreference,
formatInstant,
lessonsByDate,
pendingCount,
preferenceStatusLabel,
preferenceTypeClass,
preferenceTypeLabel,
preferenceTypeSymbol,
resolveCurrentSemester,
semesterLabel,
validateAbsence
} from '../react/teacher/teacher-service.js';
import {
renderAbsencePanel,
renderPreferencesTab,
renderRequestDialog,
renderRequestsTab,
renderTeacherWeekGrid,
weekStartOf
} from '../dist/tests/react-teacher-renderer.mjs';
const LESSONS = [
{
classroomName: '301',
date: '2026-09-28',
endTime: '10:10',
groupNames: ['ИС-41', 'ИС-42'],
lessonTypeName: 'Лекция',
scheduleRuleSlotId: 11,
startTime: '08:30',
subjectName: 'Базы данных',
subgroupNames: ['1 подгруппа'],
timeSlotOrder: 1
},
{
classroomName: '205',
date: '2026-09-29',
endTime: '13:30',
groupNames: ['ИС-41'],
lessonTypeName: 'Практика',
scheduleRuleSlotId: 12,
startTime: '12:00',
subjectName: 'Тестирование',
subgroupName: '2 подгруппа',
timeSlotOrder: 2
}
];
test('слоты недели собираются без дубликатов и сортируются по порядку', () => {
const slots = collectSlots([
{ timeSlotOrder: 2, startTime: '12:00', endTime: '13:30' },
{ timeSlotOrder: 1, startTime: '08:30', endTime: '10:10' },
{ timeSlotOrder: 1, startTime: '08:30', endTime: '10:10' }
]);
assert.deepEqual(slots, [
{ order: 1, label: '08:30 - 10:10' },
{ order: 2, label: '12:00 - 13:30' }
]);
assert.deepEqual(collectSlots([]), [{ order: 1, label: 'Расписание' }]);
});
test('модель недели преподавателя строит дни и матрицу занятий', () => {
const weekStart = weekStartOf('2026-09-30');
const model = buildDesktopWeekModel(weekStart, LESSONS);
assert.equal(model.days.length, 7);
assert.equal(model.days[0].isoDate, '2026-09-28');
assert.equal(model.days[6].isoDate, '2026-10-04');
assert.equal(model.slots.length, 2);
assert.equal(model.lessonsByKey.get('2026-09-28:1').subjectName, 'Базы данных');
assert.equal(model.lessonsByKey.get('2026-09-29:2').subjectName, 'Тестирование');
assert.equal(model.lessonsByKey.get('2026-09-30:1'), undefined);
});
test('занятия преподавателя группируются по датам', () => {
const byDate = lessonsByDate(LESSONS);
assert.equal(byDate.get('2026-09-28').length, 1);
assert.equal(byDate.get('2026-09-29').length, 1);
assert.equal(byDate.get('2026-10-01'), undefined);
});
test('чипы занятия преподавателя начинаются с времени и не содержат имени преподавателя', () => {
const chips = buildTeacherLessonChips(LESSONS[0]);
assert.deepEqual(chips, ['08:30 - 10:10', 'Лекция', '1 подгруппа', '301', 'ИС-41, ИС-42']);
assert.ok(!chips.includes('Иванов И. И.'));
});
test('заявку можно создать только для занятия из правила без ручного изменения', () => {
assert.equal(canRequestChange(LESSONS[0]), true);
assert.equal(canRequestChange({ ...LESSONS[0], scheduleOverrideId: 7 }), false);
assert.equal(canRequestChange({ ...LESSONS[0], scheduleRuleSlotId: null }), false);
});
test('статусы отсутствия переводятся на русский с классами-модификаторами', () => {
assert.equal(absenceStatusLabel('PENDING'), 'На подтверждении');
assert.equal(absenceStatusLabel('APPROVED'), 'Согласовано');
assert.equal(absenceStatusLabel('RESOLVED'), 'Замены выполнены');
assert.equal(absenceStatusLabel(''), '—');
assert.equal(absenceStatusClass('PENDING'), 'is-pending');
assert.equal(absenceStatusClass('APPROVED'), 'is-approved');
});
test('типы пожеланий переводятся на русский с символами и классами', () => {
assert.equal(preferenceTypeLabel('HARD_UNAVAILABLE'), 'Строго недоступен');
assert.equal(preferenceTypeLabel('SOFT_PREFERRED'), 'Предпочтительно');
assert.equal(preferenceTypeLabel('CONSECUTIVE'), 'Пары подряд');
assert.equal(preferenceTypeClass('HARD_UNAVAILABLE'), 'is-hard');
assert.equal(preferenceTypeClass('CONSECUTIVE'), '');
assert.equal(preferenceTypeSymbol('HARD_UNAVAILABLE'), '×');
assert.equal(preferenceTypeSymbol('SOFT_PREFERRED'), '✓');
assert.equal(preferenceTypeSymbol('SOFT_UNWANTED'), '!');
assert.equal(preferenceStatusLabel('PENDING'), 'На рассмотрении');
assert.equal(preferenceStatusLabel('APPROVED'), 'Согласовано');
});
test('цель заявки форматируется по типу изменения', () => {
assert.equal(changeTypeLabel('MOVE'), 'Перенос');
assert.equal(changeTypeLabel('CHANGE_CLASSROOM'), 'Смена аудитории');
assert.equal(changeTypeLabel('CANCEL'), 'Отмена');
const move = {
requestType: 'MOVE',
lessonDate: '2026-09-28',
targetLessonDate: '2026-10-01',
requestedStartTime: '12:00',
requestedEndTime: '13:30'
};
assert.equal(changeRequestTarget(move), '01.10.2026, 12:00–13:30');
assert.equal(
changeRequestTarget({ requestType: 'CHANGE_CLASSROOM', requestedClassroomName: '305' }),
'305'
);
assert.equal(changeRequestTarget({ requestType: 'CANCEL' }), 'Отмена занятия');
});
test('метка семестра содержит год, тип и границы', () => {
const label = semesterLabel({
academicYearTitle: '2026/2027',
endDate: '2027-01-31',
semesterType: 'AUTUMN',
startDate: '2026-09-01'
});
assert.equal(label, '2026/2027 · осенний · 01.09.2026–31.01.2027');
assert.equal(
resolveCurrentSemester(
[{ id: 1, startDate: '2026-09-01', endDate: '2026-12-31' }],
'2026-09-28'
).id,
1
);
assert.equal(resolveCurrentSemester([], '2026-09-28'), null);
});
test('активные пожелания фильтруются по статусу и типу', () => {
const preferences = [
{ id: 1, status: 'PENDING', dayOfWeek: 1, timeSlotId: 2, preferenceType: 'SOFT_PREFERRED' },
{ id: 2, status: 'APPROVED', dayOfWeek: 3, timeSlotId: 2, preferenceType: 'HARD_UNAVAILABLE' },
{ id: 3, status: 'REJECTED', dayOfWeek: 5, timeSlotId: 2, preferenceType: 'SOFT_UNWANTED' },
{ id: 4, status: 'CANCELLED', dayOfWeek: 2, timeSlotId: 2, preferenceType: 'SOFT_PREFERRED' },
{ id: 5, status: 'PENDING', preferenceDate: '2026-11-04', preferenceType: 'HARD_UNAVAILABLE' },
{ id: 6, status: 'APPROVED', preferenceType: 'CONSECUTIVE' }
];
assert.deepEqual(activeWeekPreferences(preferences).map(item => item.id), [1, 2]);
assert.deepEqual(activeDatePreferences(preferences).map(item => item.id), [5]);
assert.equal(findCompactPreference(preferences, 'CONSECUTIVE').id, 6);
assert.equal(findCompactPreference(preferences, 'NO_GAPS'), null);
assert.equal(findWeekPreference(preferences, 1, 2).id, 1);
assert.equal(findWeekPreference(preferences, 5, 2), null);
assert.equal(pendingCount(preferences), 2);
});
test('валидация отсутствия проверяет заполненность и порядок дат', () => {
assert.equal(validateAbsence({}), 'Заполните период и причину отсутствия');
assert.equal(
validateAbsence({ startDate: '2026-10-05', endDate: '2026-10-01', reason: ' ' }),
'Заполните период и причину отсутствия'
);
assert.equal(
validateAbsence({ startDate: '2026-10-05', endDate: '2026-10-01', reason: 'Командировка' }),
'Дата окончания не может быть раньше даты начала'
);
assert.equal(validateAbsence({ startDate: '2026-10-01', endDate: '2026-10-05', reason: 'Командировка' }), '');
});
test('payload заявки на изменение зависит от типа', () => {
const lesson = { date: '2026-09-28', scheduleRuleSlotId: 11 };
assert.deepEqual(
buildChangeRequestPayload({
classroomId: '',
lesson,
reason: ' Болезнь ',
requestType: 'MOVE',
targetDate: '2026-10-01',
timeSlotId: '3'
}),
{
baseRuleSlotId: 11,
lessonDate: '2026-09-28',
requestType: 'MOVE',
requestedClassroomId: null,
requestedTimeSlotId: 3,
targetLessonDate: '2026-10-01',
reason: 'Болезнь'
}
);
assert.deepEqual(
buildChangeRequestPayload({
classroomId: '9',
lesson,
reason: 'Ремонт',
requestType: 'CHANGE_CLASSROOM'
}),
{
baseRuleSlotId: 11,
lessonDate: '2026-09-28',
requestType: 'CHANGE_CLASSROOM',
requestedClassroomId: 9,
requestedTimeSlotId: null,
targetLessonDate: null,
reason: 'Ремонт'
}
);
assert.deepEqual(
buildChangeRequestPayload({ lesson, reason: 'Отмена', requestType: 'CANCEL' }),
{
baseRuleSlotId: 11,
lessonDate: '2026-09-28',
requestType: 'CANCEL',
requestedClassroomId: null,
requestedTimeSlotId: null,
targetLessonDate: null,
reason: 'Отмена'
}
);
});
test('варианты кандидатов отдают только доступные с запасным текстом', () => {
const items = [
{ id: 1, label: '08:30 - 10:10', available: true },
{ id: 2, label: '10:20 - 12:00', available: false }
];
assert.deepEqual(candidateOptions(items, 'Нет слотов'), [items[0]]);
assert.deepEqual(
candidateOptions([{ id: 2, available: false }], 'Нет свободных аудиторий'),
[{ id: '', label: 'Нет свободных аудиторий' }]
);
});
test('сервис преподавателя сохраняет контракты API', async () => {
const calls = [];
const client = {
get: async (endpoint, options) => {
calls.push({ endpoint, options });
return [];
},
post: async (endpoint, body, options) => {
calls.push({ endpoint, body, options });
return {};
},
delete: async (endpoint, options) => {
calls.push({ endpoint, options });
return {};
}
};
await fetchTeacherSchedule({ teacherId: 5, startDate: '2026-09-28', endDate: '2026-10-04' }, client);
await fetchPreferenceMeta(client);
await fetchPreferences(3, client);
await createPreference({ semesterId: 3, preferenceType: 'NO_GAPS' }, client);
await deletePreference(7, client);
await fetchChangeRequests(client);
await fetchChangeRequestCandidates({ baseRuleSlotId: 11, lessonDate: '2026-09-28', targetDate: '2026-10-01' }, client);
await fetchAbsences(client);
await createAbsence({ startDate: '2026-10-01', endDate: '2026-10-02', reason: 'Конференция' }, client);
assert.equal(calls[0].endpoint, '/api/schedule?teacherId=5&startDate=2026-09-28&endDate=2026-10-04');
assert.equal(calls[1].endpoint, '/api/teacher-preferences/meta');
assert.equal(calls[2].endpoint, '/api/teacher-preferences?semesterId=3');
assert.equal(calls[3].endpoint, '/api/teacher-preferences');
assert.deepEqual(calls[3].body, { semesterId: 3, preferenceType: 'NO_GAPS' });
assert.equal(calls[4].endpoint, '/api/teacher-preferences/7');
assert.equal(calls[5].endpoint, '/api/teacher-change-requests');
assert.equal(
calls[6].endpoint,
'/api/teacher-change-requests/candidates?baseRuleSlotId=11&lessonDate=2026-09-28&targetDate=2026-10-01'
);
assert.equal(calls[7].endpoint, '/api/teacher-absences');
assert.equal(calls[8].endpoint, '/api/teacher-absences');
assert.deepEqual(calls[8].body, { startDate: '2026-10-01', endDate: '2026-10-02', reason: 'Конференция' });
assert.ok(calls.every(call => call.options && call.options.errorMessage));
});
test('сетка недели преподавателя рендерит матрицу с заявкой на изменение', () => {
const markup = renderTeacherWeekGrid({
lessons: LESSONS,
onRequestLesson: () => {},
weekStart: weekStartOf('2026-09-30')
});
assert.match(markup, /Понедельник/);
assert.match(markup, /Базы данных/);
assert.match(markup, /08:30 - 10:10/);
assert.match(markup, /Запросить изменение/);
assert.match(markup, /Нет занятия/);
assert.match(markup, /aria-live="polite"/);
});
test('сетка недели без занятий показывает пустую неделю', () => {
const markup = renderTeacherWeekGrid({
emptyMessage: 'Ошибка загрузки',
lessons: [],
onRequestLesson: () => {},
weekStart: weekStartOf('2026-09-30')
});
assert.match(markup, /Неделя/);
assert.match(markup, /Ошибка загрузки/);
assert.doesNotMatch(markup, /Запросить изменение/);
});
test('панель отсутствия рендерит скрытую форму и состояние загрузки', () => {
const markup = renderAbsencePanel();
assert.match(markup, /Сообщить об отсутствии/);
assert.match(markup, /Новая заявка/);
assert.match(markup, /hidden/);
assert.match(markup, /Загрузка заявок\.\.\./);
assert.match(markup, /aria-expanded="false"/);
});
test('вкладка пожеланий без настроенной сетки показывает пустое состояние', () => {
const markup = renderPreferencesTab();
assert.match(markup, /Недельный шаблон/);
assert.match(markup, /Режим отметки/);
assert.match(markup, /Базовая сетка времени не настроена/);
assert.match(markup, /preference-grid/);
});
test('журнал заявок рендерит записи с целью изменения и историей', () => {
const markup = renderRequestsTab({
items: [{
groupNames: ['ИС-41'],
history: [{ comment: 'Принято в работу', createdAt: '2026-09-28T10:00:00', status: 'PENDING' }],
id: 1,
lessonDate: '2026-09-28',
reason: 'Болезнь',
requestType: 'MOVE',
requestedStartTime: '12:00',
requestedEndTime: '13:30',
sourceClassroomName: '301',
sourceEndTime: '10:10',
sourceStartTime: '08:30',
status: 'PENDING',
subjectName: 'Базы данных',
targetLessonDate: '2026-10-01'
}],
onCancel: () => {},
onRefresh: () => {},
stateKind: 'ready'
});
assert.match(markup, /Базы данных/);
assert.match(markup, /Перенос/);
assert.match(markup, /01\.10\.2026, 12:00–13:30/);
assert.match(markup, /На рассмотрении/);
assert.match(markup, /Отозвать/);
assert.match(markup, /История решения · 1/);
});
test('журнал заявок показывает пустое и ошибочное состояния', () => {
const empty = renderRequestsTab({ items: [], onCancel: () => {}, onRefresh: () => {}, stateKind: 'ready' });
const error = renderRequestsTab({ items: [], onCancel: () => {}, onRefresh: () => {}, stateKind: 'error' });
assert.match(empty, /Заявок на изменение пока нет/);
assert.match(error, /Не удалось загрузить заявки/);
});
test('диалог заявки рендерит типы изменения и поля обоснования', () => {
const markup = renderRequestDialog({
lesson: {
classroomName: '301',
date: '2026-09-28',
endTime: '10:10',
scheduleRuleSlotId: 11,
startTime: '08:30',
subjectName: 'Базы данных'
},
onClose: () => {},
onSubmitted: async () => true
});
assert.match(markup, /Заявка на изменение/);
assert.match(markup, /Перенести дату или время/);
assert.match(markup, /Сменить аудиторию/);
assert.match(markup, /Отменить занятие/);
assert.match(markup, /Обоснование/);
assert.match(markup, /Отправить в учебный отдел/);
assert.match(markup, /role="dialog"/);
});
test('мгновенный timestamp форматируется в русской локали', () => {
const formatted = formatInstant('2026-09-28T10:00:00');
assert.match(formatted, /28\.09\.2026/);
assert.match(formatted, /10:00/);
assert.equal(formatInstant(null), '—');
});

View File

@@ -8,7 +8,7 @@ import {
overrideActionForChanges,
pickMatchingTimeSlot,
weekDates
} from '../admin/js/views/schedule-override-panel.js';
} from '../react/admin/schedule-override-service.js';
test('редактировать разовые изменения могут только администратор и учебный отдел', () => {
assert.equal(canEditScheduleOverrides('ADMIN'), true);

View File

@@ -6,7 +6,7 @@ import {
filterQualityProblems,
formatMetricValue,
qualityBand
} from '../admin/js/views/schedule-quality.js';
} from '../react/admin/schedule-quality-service.js';
test('оценка качества получает понятный диапазон', () => {
assert.equal(qualityBand(94).code, 'excellent');
@@ -34,23 +34,26 @@ test('фильтр проблем одновременно учитывает в
test('метрики и вкладка анализа подключены к SPA ответственных ролей', async () => {
assert.equal(formatMetricValue({ value: 93.4, unit: '%' }), '93,4%');
const [capabilities, main, index, view, script] = await Promise.all([
const [capabilities, service, reactTabs, workspace, tab, qualityService] = await Promise.all([
readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/index.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule-quality.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/views/schedule-quality.js', import.meta.url), 'utf8')
readFile(new URL('../react/admin/admin-service.js', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/react-tabs.js', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/AdminApp.jsx', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/tabs/ScheduleQualityTab.jsx', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/schedule-quality-service.js', import.meta.url), 'utf8')
]);
assert.match(capabilities, /ADMIN:[\s\S]*schedule-quality/);
assert.match(capabilities, /EDUCATION_OFFICE:[\s\S]*schedule-quality/);
assert.doesNotMatch(capabilities, /DEPARTMENT:[\s\S]*schedule-quality/);
assert.match(main, /'schedule-quality':[\s\S]*schedule-quality\.js/);
assert.match(index, /data-tab="schedule-quality"/);
assert.match(view, /Правила, снижающие оценку/);
assert.match(script, /Изменить правило/);
assert.match(script, /magistr\.schedule\.openRuleId/);
assert.doesNotMatch(script, /quality\/recommendations/);
assert.match(service, /'schedule-quality':\s*\{\s*title:\s*'Анализ качества расписания'\s*\}/);
assert.match(reactTabs, /'schedule-quality':\s*\{\s*Component/);
assert.match(workspace, /'schedule-quality'/);
assert.match(tab, /Правила, снижающие оценку/);
assert.match(tab, /Изменить правило/);
assert.match(tab, /SCHEDULE_OPEN_RULE_STORAGE_KEY/);
assert.doesNotMatch(tab, /quality\/recommendations/);
assert.doesNotMatch(qualityService, /quality\/recommendations/);
});

View File

@@ -2,9 +2,9 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { historyActionLabel } from '../admin/js/views/schedule-versions.js';
import { versionStatusLabel } from '../admin/js/views/schedule-quality.js';
import { chooseScheduleVersion } from '../admin/js/views/schedule.js';
import { historyActionLabel } from '../react/admin/schedule-versions-service.js';
import { versionStatusLabel } from '../react/admin/schedule-quality-service.js';
import { chooseScheduleVersion } from '../react/admin/schedule-service.js';
test('журнал версий переводит системные действия на русский язык', () => {
assert.equal(historyActionLabel('CREATED'), 'Создан черновик');
@@ -33,34 +33,36 @@ test('конструктор по умолчанию открывает опуб
});
test('контур версий подключён к SPA, а профильные вкладки имеют локальный выбор периода', async () => {
const [main, roles, constructor, quality, index, view, qualityView, versionsView, scheduleView] = await Promise.all([
readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'),
const [reactTabs, roles, constructorTab, quality, service, scheduleService, versionsService, qualityTab, versionsTab, scheduleViewTab] = await Promise.all([
readFile(new URL('../react/admin/react-tabs.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/views/schedule.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/views/schedule-quality.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/index.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule-quality.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule-versions.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/views/schedule-view.html', import.meta.url), 'utf8')
readFile(new URL('../react/admin/tabs/ScheduleTab.jsx', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/schedule-quality-service.js', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/admin-service.js', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/schedule-service.js', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/schedule-versions-service.js', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/tabs/ScheduleQualityTab.jsx', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/tabs/ScheduleVersionsTab.jsx', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/tabs/ScheduleViewTab.jsx', import.meta.url), 'utf8')
]);
assert.match(main, /'schedule-versions'/);
assert.match(reactTabs, /'schedule-versions':\s*\{\s*Component/);
assert.match(roles, /schedule-versions/);
assert.match(index, /Версии расписания/);
assert.match(constructor, /scheduleVersionId:\s*Number\(scheduleVersionSelect\.value\)/);
assert.match(constructor, /basedOnVersionId:\s*Number\(base\.id\)/);
assert.match(constructor, /magistr\.schedule\.openVersionId/);
assert.match(constructor, /magistr\.schedule\.openRuleId/);
assert.match(constructor, /function localIsoDate\(date\)/);
assert.match(constructor, /data-slot-id/);
assert.match(view, /id="schedule-version-create-draft"/);
assert.match(view, /id="schedule-version-year"/);
assert.match(view, /id="schedule-version-semester"/);
assert.match(qualityView, /id="quality-year"/);
assert.match(qualityView, /id="quality-version"/);
assert.match(versionsView, /id="version-year"/);
assert.match(scheduleView, /id="schedule-view-year"/);
assert.doesNotMatch(index, /id="workspace-context"/);
assert.match(service, /Версии расписания/);
assert.match(scheduleService, /scheduleVersionId:\s*Number\(versionId\)/);
assert.match(constructorTab, /basedOnVersionId:\s*Number\(base\.id\)/);
assert.match(versionsService, /magistr\.schedule\.openVersionId/);
assert.match(versionsService, /magistr\.schedule\.openRuleId/);
assert.match(constructorTab, /SCHEDULE_OPEN_VERSION_STORAGE_KEY/);
assert.match(constructorTab, /SCHEDULE_OPEN_RULE_STORAGE_KEY/);
assert.match(constructorTab, /formatLocalDate\(new Date\(\)\)/);
assert.match(constructorTab, /data-slot-id/);
assert.match(constructorTab, /id="schedule-version-create-draft"/);
assert.match(constructorTab, /id="schedule-version-year"/);
assert.match(constructorTab, /id="schedule-version-semester"/);
assert.match(qualityTab, /id="quality-year"/);
assert.match(qualityTab, /id="quality-version"/);
assert.match(versionsTab, /id="version-year"/);
assert.match(scheduleViewTab, /id="schedule-view-year"/);
assert.match(quality, /versionId=.*encodeURIComponent\(versionId\)/);
});

View File

@@ -7,7 +7,7 @@ import {
formatLessonWeeksText,
scheduleRangeForSemester,
semesterOptionLabel
} from '../admin/js/views/schedule-view.js';
} from '../react/admin/schedule-view-service.js';
import {
defaultSchedulePeriod,
preferredScheduleSemester

View File

@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { calculateVisualContextMenuPosition } from '../admin/js/views/schedule.js';
import { calculateVisualContextMenuPosition } from '../react/admin/schedule-service.js';
test('меню пары открывается справа от кнопки с тремя точками', () => {
assert.deepEqual(calculateVisualContextMenuPosition({

View File

@@ -6,7 +6,7 @@ import test from 'node:test';
import { fileURLToPath } from 'node:url';
const frontendRoot = fileURLToPath(new URL('..', import.meta.url));
const sourceExtensions = new Set(['.html', '.js', '.mjs', '.css']);
const sourceExtensions = new Set(['.html', '.js', '.jsx', '.mjs', '.css']);
async function sourceFiles(directory = frontendRoot) {
const result = [];
@@ -77,8 +77,9 @@ test('Docker-образ включает локальный telemetry bundle и
const dockerfile = await readFile(join(frontendRoot, 'Dockerfile'), 'utf8');
const telemetry = await readFile(join(frontendRoot, 'telemetry.js'), 'utf8');
assert.match(dockerfile, /npm ci/);
assert.match(dockerfile, /npm run build:vendor/);
assert.match(dockerfile, /scripts\/build-vendor\.mjs/);
assert.match(dockerfile, /npm run build/);
assert.match(dockerfile, /scripts\/build-react\.mjs scripts\/build-vendor\.mjs/);
assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/react\//);
assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/vendor\//);
assert.match(dockerfile, /magistr-security\.conf/);
for (const asset of ['ui-foundation.css', 'schedule-overview.css', 'schedule-overview.js']) {
@@ -93,10 +94,13 @@ test('Docker-образ включает локальный telemetry bundle и
test('сообщения об ошибках не вставляют текст исключения через innerHTML', async () => {
const files = [
'admin/js/main.js',
'admin/settings/js/main.js',
'admin/settings/js/views/database.js',
'admin/js/views/teacher-requests.js'
'react/admin/AdminApp.jsx',
'react/admin/tabs/TeacherRequestsTab.jsx',
'react/admin/tabs/DepartmentWorkspaceTab.jsx',
'react/admin/tabs/AuditoriumWorkloadTab.jsx',
'react/admin/tabs/AcademicCalendarTab.jsx',
'react/settings/SettingsApp.jsx',
'react/settings/DatabaseTab.jsx'
];
for (const relativePath of files) {
@@ -112,22 +116,22 @@ test('сообщения об ошибках не вставляют текст
});
test('пароли скрыты и помечены для менеджера паролей', async () => {
const users = await readFile(join(frontendRoot, 'admin/views/users.html'), 'utf8');
const database = await readFile(join(frontendRoot, 'admin/settings/views/database.html'), 'utf8');
const teacherRequests = await readFile(join(frontendRoot, 'admin/js/views/teacher-requests.js'), 'utf8');
const users = await readFile(join(frontendRoot, 'react/admin/tabs/UsersTab.jsx'), 'utf8');
const databaseTab = await readFile(join(frontendRoot, 'react/settings/DatabaseTab.jsx'), 'utf8');
const teacherRequests = await readFile(join(frontendRoot, 'react/admin/tabs/TeacherRequestsTab.jsx'), 'utf8');
assert.match(users, /id="new-password"[^>]*type="password"|type="password"[^>]*id="new-password"/);
assert.match(users, /id="new-password"[^>]*autocomplete="new-password"/);
assert.match(database, /id="tenant-password"[^>]*type="password"|type="password"[^>]*id="tenant-password"/);
assert.match(database, /id="tenant-password"[^>]*autocomplete="new-password"/);
assert.match(teacherRequests, /type="password"[^>]*class="teacher-request-password"[^>]*autocomplete="new-password"/);
assert.match(users, /id="new-password"[^>]*autoComplete="new-password"/);
assert.match(databaseTab, /id="tenant-password"[\s\S]*?type="password"|type="password"[\s\S]*?id="tenant-password"/);
assert.match(databaseTab, /id="tenant-password"[\s\S]*?autoComplete="new-password"|autoComplete="new-password"[\s\S]*?id="tenant-password"/);
assert.match(teacherRequests, /type="password"[^>]*className="teacher-request-password"[^>]*autoComplete="new-password"/);
});
test('tenant UI и production-логи соблюдают русский языковой регламент', async () => {
const database = await readFile(join(frontendRoot, 'admin/settings/js/views/database.js'), 'utf8');
assert.doesNotMatch(database, /\bOnline\b|\bOffline\b/);
assert.match(database, /Доступно/);
assert.match(database, /Недоступно/);
const databaseTab = await readFile(join(frontendRoot, 'react/settings/DatabaseTab.jsx'), 'utf8');
assert.doesNotMatch(databaseTab, /\bOnline\b|\bOffline\b/);
assert.match(databaseTab, /Доступно/);
assert.match(databaseTab, /Недоступно/);
const backendRoot = join(frontendRoot, '..', 'backend', 'src', 'main', 'java', 'com', 'magistr', 'app', 'config', 'tenant');
const logSources = await Promise.all([
@@ -144,7 +148,7 @@ test('tenant UI и production-логи соблюдают русский язы
);
const frontendSources = (await sourceCorpus())
.filter(({ path }) => extname(path) === '.js')
.filter(({ path }) => ['.js', '.jsx'].includes(extname(path)))
.map(({ text }) => text)
.join('\n');
assert.doesNotMatch(frontendSources, /console\.(?:log|info|warn|error)\([^\n]*(?:Failed|Error)/);
@@ -166,10 +170,10 @@ test('единая матрица ролей согласует admin и setting
assert.equal(educationOffice.defaultSettingsTab, 'time-slots');
assert.equal(capabilitiesForRole('DEPARTMENT').settingsTabs.length, 0);
const adminMain = await readFile(join(frontendRoot, 'admin/js/main.js'), 'utf8');
const settingsMain = await readFile(join(frontendRoot, 'admin/settings/js/main.js'), 'utf8');
assert.match(adminMain, /from '\.\/role-capabilities\.js'/);
assert.match(settingsMain, /from '\.\.\/\.\.\/js\/role-capabilities\.js'/);
assert.doesNotMatch(adminMain, /const ROLE_NAVIGATION/);
assert.doesNotMatch(settingsMain, /const ROLE_TABS/);
const adminService = await readFile(join(frontendRoot, 'react/admin/admin-service.js'), 'utf8');
const settingsApp = await readFile(join(frontendRoot, 'react/settings/SettingsApp.jsx'), 'utf8');
assert.match(adminService, /from '\.\.\/\.\.\/admin\/js\/role-capabilities\.js'/);
assert.match(settingsApp, /from '\.\.\/\.\.\/admin\/js\/role-capabilities\.js'/);
assert.doesNotMatch(adminService, /const ROLE_NAVIGATION/);
assert.doesNotMatch(settingsApp, /const ROLE_TABS/);
});

View File

@@ -6,7 +6,7 @@ import {
decisionOptions,
parseDecisionOption,
teacherAbsenceTeacherOptionsUrl
} from '../admin/js/views/teacher-absences.js';
} from '../react/admin/teacher-absences-service.js';
test('значение мастера преобразуется в payload точечного изменения', () => {
assert.deepEqual(parseDecisionOption('REPLACE_TEACHER|17'), {
@@ -71,15 +71,15 @@ test('список преподавателей раскрывается пов
});
test('вкладка отсутствий доступна ответственным ролям и подключена к SPA', async () => {
const [capabilities, main, index] = await Promise.all([
const [capabilities, reactTabs, workspace] = await Promise.all([
readFile(new URL('../admin/js/role-capabilities.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/main.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/index.html', import.meta.url), 'utf8')
readFile(new URL('../react/admin/react-tabs.js', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/AdminApp.jsx', import.meta.url), 'utf8')
]);
assert.match(capabilities, /ADMIN:[\s\S]*teacher-absences/);
assert.match(capabilities, /EDUCATION_OFFICE:[\s\S]*teacher-absences/);
assert.match(capabilities, /DEPARTMENT:[\s\S]*teacher-absences/);
assert.match(main, /'teacher-absences':[\s\S]*teacher-absences\.js/);
assert.match(index, /data-tab="teacher-absences"/);
assert.match(reactTabs, /'teacher-absences':\s*\{\s*Component/);
assert.match(workspace, /'teacher-absences'/);
});

View File

@@ -3,42 +3,47 @@ import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('кабинет преподавателя содержит календарь пожеланий и журнал заявок', async () => {
const [index, app] = await Promise.all([
const [index, app, service, preferences, dialog] = await Promise.all([
readFile(new URL('../teacher/index.html', import.meta.url), 'utf8'),
readFile(new URL('../teacher/app.js', import.meta.url), 'utf8')
readFile(new URL('../react/teacher/TeacherApp.jsx', import.meta.url), 'utf8'),
readFile(new URL('../react/teacher/teacher-service.js', import.meta.url), 'utf8'),
readFile(new URL('../react/teacher/PreferencesTab.jsx', import.meta.url), 'utf8'),
readFile(new URL('../react/teacher/RequestDialog.jsx', import.meta.url), 'utf8')
]);
assert.match(index, /data-workspace-tab="preferences"/);
assert.match(index, /data-workspace-tab="requests"/);
assert.match(index, /id="preference-grid"/);
assert.match(index, /id="change-request-form"/);
assert.match(app, /\/api\/teacher-preferences\/meta/);
assert.match(app, /\/api\/teacher-change-requests\/candidates/);
assert.match(app, /MOVE:[\s\S]*CHANGE_CLASSROOM:[\s\S]*CANCEL:/);
assert.match(index, /\/react\/teacher\.js/);
assert.match(app, /preferences: 'Мои пожелания'/);
assert.match(app, /requests: 'Заявки на изменение'/);
assert.match(preferences, /preference-grid/);
assert.match(dialog, /<form/);
assert.match(service, /\/api\/teacher-preferences\/meta/);
assert.match(service, /\/api\/teacher-change-requests\/candidates/);
assert.match(dialog, /value="MOVE"[\s\S]*value="CHANGE_CLASSROOM"[\s\S]*value="CANCEL"/);
});
test('ответственные роли видят согласование, а применять изменение может учебный отдел', async () => {
const [view, script] = await Promise.all([
readFile(new URL('../admin/views/teacher-absences.html', import.meta.url), 'utf8'),
readFile(new URL('../admin/js/views/teacher-absences.js', import.meta.url), 'utf8')
const [tab, service] = await Promise.all([
readFile(new URL('../react/admin/tabs/TeacherAbsencesTab.jsx', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/teacher-absences-service.js', import.meta.url), 'utf8')
]);
assert.match(view, /id="teacher-preference-review-list"/);
assert.match(view, /id="teacher-change-review-list"/);
assert.match(script, /\/api\/teacher-preferences/);
assert.match(script, /\/api\/teacher-change-requests/);
assert.match(script, /role === 'ADMIN' \|\| role === 'EDUCATION_OFFICE'/);
assert.match(script, /Применение доступно учебному отделу/);
assert.match(tab, /id="teacher-preference-review-list"/);
assert.match(tab, /id="teacher-change-review-list"/);
assert.match(service, /\/api\/teacher-preferences/);
assert.match(service, /\/api\/teacher-change-requests/);
assert.match(tab, /role === 'ADMIN' \|\| role === 'EDUCATION_OFFICE'/);
assert.match(tab, /Применение доступно учебному отделу/);
});
test('конструктор правил показывает только согласованные пожелания преподавателя', async () => {
const [script, styles] = await Promise.all([
readFile(new URL('../admin/js/views/schedule.js', import.meta.url), 'utf8'),
const [tab, service, styles] = await Promise.all([
readFile(new URL('../react/admin/tabs/ScheduleTab.jsx', import.meta.url), 'utf8'),
readFile(new URL('../react/admin/schedule-service.js', import.meta.url), 'utf8'),
readFile(new URL('../admin/css/components.css', import.meta.url), 'utf8')
]);
assert.match(script, /\/api\/teacher-preferences\?status=APPROVED/);
assert.match(script, /data-preference-hint/);
assert.match(script, /Строгая недоступность/);
assert.match(service, /\/api\/teacher-preferences\?status=APPROVED/);
assert.match(tab, /data-preference-hint/);
assert.match(service, /Строгая недоступность/);
assert.match(styles, /\.schedule-preference-hint/);
});

View File

@@ -58,37 +58,59 @@ test('локальные стили используют общие шрифто
}
});
test('корневые контейнеры админ-оболочек растягиваются на всю ширину окна', async () => {
// body в admin/css/main.css и admin/settings/css/main.css — flex-контейнер:
// без растяжения корневой div сжимается до max-content контента, и узкие
// страницы (карточки, формы) не занимают всю ширину монитора, а дашборд
// «растягивается» только после загрузки данных. flex-basis 0 обязателен:
// intrinsic-замер max-content раздувает вложенные flex-фильтры
// (панель загруженности аудиторий) до ~1 млн px горизонтального скролла.
const cases = [
['admin/css/layout.css', '#admin-root'],
['admin/settings/css/layout.css', '#settings-root']
];
for (const [file, selector] of cases) {
const css = await readFrontendFile(file);
const escaped = selector.replace('#', '\\#');
assert.match(
css,
new RegExp(`${escaped}\\s*\\{[^}]*flex:\\s*1 1 0%\\s*;[^}]*min-width:\\s*0`, 's'),
`${file}: ${selector} должен растягиваться ровно на ширину окна (flex: 1 1 0%; min-width: 0)`
);
}
});
test('архивные действия и кнопки закрытия используют системные размеры', async () => {
const actionSources = await Promise.all([
readFrontendFile('admin/js/views/classrooms.js'),
readFrontendFile('admin/js/views/groups.js'),
readFrontendFile('admin/js/views/users.js')
readFrontendFile('react/admin/tabs/ClassroomsTab.jsx'),
readFrontendFile('react/admin/tabs/GroupsTab.jsx'),
readFrontendFile('react/admin/tabs/UsersTab.jsx')
]);
actionSources.forEach(source => {
assert.match(source, /class="btn btn-sm btn-secondary btn-restore-/);
assert.match(source, /class(?:Name)?="btn btn-sm btn-secondary btn-restore-/);
});
const modalViews = await Promise.all([
'admin/views/academic-calendar.html',
'admin/views/classrooms.html',
'admin/views/groups.html',
'admin/views/schedule.html',
'admin/views/schedule-versions.html',
'admin/views/subjects.html',
'admin/views/university-structure.html',
'admin/settings/views/time-slots.html'
'react/admin/tabs/AcademicCalendarTab.jsx',
'react/admin/tabs/ClassroomsTab.jsx',
'react/admin/tabs/GroupsTab.jsx',
'react/admin/tabs/ScheduleTab.jsx',
'react/admin/tabs/ScheduleVersionsTab.jsx',
'react/admin/tabs/SubjectsTab.jsx',
'react/admin/tabs/UniversityStructureTab.jsx'
].map(readFrontendFile));
const closeButtons = modalViews.flatMap(html => Array.from(
html.matchAll(/<button\b[^>]*class="[^"]*(?:modal-close|version-modal-close|settings-modal-close)[^"]*"[^>]*>/g),
html.matchAll(/<button\b[^>]*class(?:Name)?="[^"]*(?:modal-close|version-modal-close|settings-modal-close)[^"]*"[^>]*>/g),
match => match[0]
));
assert.ok(closeButtons.length > 0);
closeButtons.forEach(button => {
assert.match(button, /type="button"/);
assert.match(button, /class="[^"]*\bbtn\b[^"]*\bbtn-icon-md\b/);
assert.match(button, /class(?:Name)?="[^"]*\bbtn\b[^"]*\bbtn-icon-md\b/);
assert.match(button, /aria-label="Закрыть окно"/);
});
});