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