Files
magistr/frontend/tests/react-admin-tabs.test.mjs
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

2845 lines
128 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
classroomStatusLabel,
createClassroom,
createEquipment,
deleteEquipment,
equipmentIdSelection,
equipmentNamesText,
equipmentSelectLabel,
fetchClassrooms,
fetchEquipments,
parseCapacity,
restoreClassroom,
toggleClassroomAvailability,
updateClassroom
} from '../react/admin/classrooms-service.js';
import {
assignTeacher,
createSubject,
departmentLabel,
departmentOptionLabel,
fetchSubjectsPage,
groupTeachersBySubject,
initialSubjectsState,
normalizeSubjectsState,
removeTeacherAssignment,
subjectsEmptyMessage,
subjectsQueryParams,
subjectsUrlState,
teacherAssignmentsEndpoint,
teachersSearchEndpoint
} from '../react/admin/subjects-service.js';
import {
archiveUser,
createUser,
fetchUsersPage,
initialUsersState,
normalizeUsersState,
restoreUser,
roleBadgeClass,
roleLabel,
usersEmptyMessage,
usersQueryParams,
usersUrlState
} from '../react/admin/users-service.js';
import {
assignmentSubjectsGroups,
calendarsOptionsEndpoint,
createGroup,
efFilterLabel,
fetchGroupsPage,
groupPayloadFromFields,
groupsQueryParams,
groupsUrlState,
initialGroupsState,
naturalSubgroupOrder,
sortSubgroups,
specialityLabel,
statusBadgeClass,
statusLabel,
subjectLabel,
suggestedSubgroupCapacities,
syncSubgroups,
validateSubgroupCapacities
} from '../react/admin/groups-service.js';
import {
clampPage,
createDepartment,
createSpecialty,
departmentCode,
departmentFields,
departmentName,
fetchAllProfiles,
filterListItems,
pageSlice,
profileSpecialtyLabel,
specialtyCode,
specialtyName,
specialtyOptionLabel,
updateProfile
} from '../react/admin/university-structure-service.js';
import {
activeClassrooms,
classroomOccupancyLabel,
conflictDescriptionText,
currentSlotLabel,
detectScheduleConflicts,
fetchEffectiveTimeSlots,
findCurrentSlot,
freeClassroomsForSlot,
lessonsForSlot,
sortTimeSlots,
totalStudents
} from '../react/admin/dashboard-service.js';
import {
REQUEST_STATUS_OPTIONS,
approvePayloadFromFields,
approveRequest,
fetchTeacherRequestsPage,
initialTeacherRequestsState,
pendingCountDetail,
rejectRequest,
requestDepartmentLabel,
reviewedStatusLabel,
teacherRequestsEmptyMessage,
teacherRequestsQueryParams,
teacherRequestsUrlState
} from '../react/admin/teacher-requests-service.js';
import {
assignTeacher as assignTeacherToDepartment,
assignTeacherError,
availableTeachers,
departmentSubjectsEndpoint,
departmentTeacherRequestsEndpoint,
departmentTeachersEndpoint,
fetchDepartmentSubjects,
fetchDepartmentTeacherRequests,
fetchDepartmentTeachers,
fetchTeacherWorkload,
formatDateTime,
importSubjects,
importSubjectsPayload,
requestStatusClass,
requestStatusLabel,
subjectCountLabel,
submitTeacherRequest,
teacherMetrics,
teacherOptionLabel,
teacherRequestPayload,
teacherWorkloadEndpoint,
workloadLabel
} from '../react/admin/department-workspace-service.js';
import {
DISPLAY_ALL,
TARGET_CLASSROOMS,
TARGET_DEPARTMENTS,
TARGET_TEACHERS,
buildingOptions,
buildDateGridByDay,
buildSlotRows,
countAvailableRoomCells,
countOccupiedCells,
countOccupiedRoomCells,
datesBetween,
effectiveTimeSlotsEndpoint,
entityAxisParts,
entityIdsForLesson,
entityOptionLabel,
equipmentOptions,
fetchAcademicYears,
fetchEffectiveTimeSlots as fetchWorkloadTimeSlots,
fetchGroupSchedule,
filteredClassrooms,
formatDateShort,
groupLessonsByEntitySlot,
groupScheduleEndpoint,
isoDay,
loadEntityLessons,
loadOverviewLessons,
loadSlotsForDates,
overviewSummaryText,
parseMinCapacity,
parityForDate,
roomSlotState,
roomSlotStatesEqual,
roomSummaryParts,
roomWeekCaption,
teacherDepartmentId,
twoWeekRange,
uniqueLessons,
uniqueWeekNumbers,
weekNumberForDate
} from '../react/admin/auditorium-workload-service.js';
import {
academicCalendarsOptionsEndpoint,
buildDefaultGrid,
calendarGridEndpoint,
calendarOptionLabel,
calendarSemesterCount,
calendarSubjectsEndpoint,
calendarsPageEndpoint,
commonActivityValue,
dayDialogSummaryParts,
dayDialogTexts,
dayTooltipParts,
deleteCalendarRequest,
deleteTimeSlotDateAssignmentRequest,
effectiveActivityTypeId,
fetchCalendarGrid,
fetchCalendarOptions,
fetchCalendarSubjects,
fetchCalendarsPage,
fetchSpecialtyProfiles,
gridTotalsEntries,
readDisplayDateIso,
saveAcademicYearRequest,
saveCalendarGridRequest,
saveCalendarSubjectsRequest,
semesterBlocksForCourse,
semesterOptionLabel,
sortCalendarSubjectRows,
specialityLabel as calendarSpecialityLabel,
specialtyProfilesEndpoint,
startYearInputResult,
subjectRowLabel
} from '../react/admin/academic-calendar-service.js';
import {
ROLE_DEPARTMENT,
TARGET_CLASSROOMS as SCHEDULE_TARGET_CLASSROOMS,
TARGET_DEPARTMENTS as SCHEDULE_TARGET_DEPARTMENTS,
TARGET_GROUPS,
TARGET_TEACHERS as SCHEDULE_TARGET_TEACHERS,
buildCombinedWeekBlocks,
buildScheduleSections,
buildSectionContext,
buildSemesterLessonMatrix,
classroomsOptionsEndpoint,
collectSlots,
entityValueForTarget,
fetchClassroomOptions,
fetchGroupOptions,
fetchScheduleLessons,
fetchScheduleOverrides,
fetchScheduleViewDictionaries,
fetchTeacherOptions,
getLessonWeeksText,
groupsOptionsEndpoint,
groupLessonsByDateSlot,
hasScheduleSearchTarget,
initialScheduleViewState,
lessonCountLabel,
mergeDictionaryItems,
parityForDate as scheduleParityForDate,
scheduleSearchEndpoint,
scheduleViewUrlState,
sectionCountLabel,
semesterOptionLabel as scheduleSemesterOptionLabel,
slotStatesEqual,
slotStatesForSemester,
teachersOptionsEndpoint,
twoWeekRange as scheduleTwoWeekRange
} from '../react/admin/schedule-view-service.js';
import {
buildRulePayloadFromForm,
buildRulePayloadFromRule,
formSlotsToPayloads,
validateTypeLimits
} from '../react/admin/schedule-service.js';
import {
createScheduleDraftVersion,
draftBasisText,
fetchScheduleSemesters,
fetchScheduleVersionDiff,
fetchScheduleVersionHistory,
fetchScheduleVersionValidation,
fetchScheduleVersions,
formatVersionDateTime,
historyActionLabel,
publishValidationSummary,
ruleChangeTypeLabel,
runScheduleVersionAction,
scheduleVersionActionEndpoint,
scheduleVersionDiffEndpoint,
scheduleVersionHistoryEndpoint,
scheduleVersionValidateEndpoint,
scheduleVersionsEndpoint,
splitScheduleVersions,
versionSemesterLabel
} from '../react/admin/schedule-versions-service.js';
import {
fetchScheduleQualityAnalysis,
filterQualityProblems,
formatMetricValue,
formatQualityDate,
lessonWord,
problemTotalLabel,
qualityBand,
qualitySemesterLabel,
scheduleQualityEndpoint,
severityLabel,
versionStatusLabel
} from '../react/admin/schedule-quality-service.js';
import {
absenceSemesterLabel,
absenceStats,
changeReviewTarget,
changeTypeLabel,
createTeacherAbsence,
decisionCountLabel,
decisionOptions,
decisionStatusLabel,
deleteTeacherAbsence,
fetchAbsenceTeacherOptions,
fetchTeacherAbsenceWizard,
fetchTeacherAbsences,
fetchTeacherChangeRequests,
fetchTeacherPreferenceMeta,
fetchTeacherPreferences,
formatAbsenceDate,
dayOfMonth,
genericStatusLabel,
monthShort,
parseDecisionOption,
preferenceDescription,
resolutionLabel,
resolveTeacherAbsence,
reviewTeacherAbsence,
reviewTeacherChangeRequest,
reviewTeacherPreference,
statusClass,
statusLabel as absenceStatusLabel,
teacherAbsenceTeacherOptionsUrl,
teacherAbsencesEndpoint,
teacherChangeRequestsEndpoint,
teacherPreferencesEndpoint,
trimTime
} from '../react/admin/teacher-absences-service.js';
import {
renderAcademicCalendarTab,
renderAuditoriumWorkloadTab,
renderClassroomsTab,
renderDashboardTab,
renderDepartmentWorkspaceTab,
renderGroupsTab,
renderScheduleQualityTab,
renderScheduleTab,
renderScheduleVersionsTab,
renderScheduleViewTab,
renderSubjectsTab,
renderTeacherAbsencesTab,
renderTeacherRequestsTab,
renderUniversityStructureTab,
renderUsersTab
} from '../dist/tests/react-admin-renderer.mjs';
const readFrontendFile = relativePath => readFile(
new URL(`../${relativePath}`, import.meta.url),
'utf8'
);
function recordingClient(handler) {
const calls = [];
return {
calls,
get: async (endpoint, options) => {
calls.push({ method: 'GET', endpoint, options });
return handler('GET', endpoint);
},
post: async (endpoint, json, options) => {
calls.push({ method: 'POST', endpoint, json, options });
return handler('POST', endpoint, json);
},
put: async (endpoint, json, options) => {
calls.push({ method: 'PUT', endpoint, json, options });
return handler('PUT', endpoint, json);
},
delete: async (endpoint, options) => {
calls.push({ method: 'DELETE', endpoint, options });
return handler('DELETE', endpoint);
}
};
}
test('статусы и подписи аудиторий совпадают с legacy-поведением', () => {
assert.equal(classroomStatusLabel({ status: 'ARCHIVED', isAvailable: false }), 'Архив');
assert.equal(classroomStatusLabel({ status: 'ACTIVE', isAvailable: true }), 'Доступна');
assert.equal(classroomStatusLabel({ status: 'ACTIVE', isAvailable: false }), 'Не доступна');
assert.equal(equipmentSelectLabel([]), 'Выберите оборудование...');
assert.equal(equipmentSelectLabel(['Проектор']), 'Проектор');
assert.equal(equipmentSelectLabel(['Проектор', 'Доска']), 'Выбрано: 2');
assert.equal(equipmentNamesText({ equipments: [{ name: 'Проектор' }, { name: 'Доска' }] }), 'Проектор, Доска');
assert.equal(equipmentNamesText({ equipments: [] }), null);
assert.deepEqual(
equipmentIdSelection([{ id: 1, name: 'Проектор' }, { id: 2, name: 'Доска' }], [2]),
[
{ id: 1, name: 'Проектор', checked: false },
{ id: 2, name: 'Доска', checked: true }
]
);
assert.equal(parseCapacity('30'), 30);
assert.ok(Number.isNaN(parseCapacity('abc')));
});
test('контракты API аудиторий сохранены', async () => {
const client = recordingClient(() => ({ id: 7, name: '101' }));
await fetchClassrooms(client);
assert.equal(client.calls[0].endpoint, '/api/classrooms?includeArchived=true');
await createClassroom({ name: '101', capacity: 30, equipmentIds: [1, 2] }, client);
assert.deepEqual(client.calls[1], {
method: 'POST',
endpoint: '/api/classrooms',
json: { name: '101', capacity: 30, equipmentIds: [1, 2], isAvailable: true },
options: { errorMessage: 'Ошибка создания' }
});
await toggleClassroomAvailability(7, false, client);
assert.deepEqual(client.calls[2], {
method: 'PUT',
endpoint: '/api/classrooms/7',
json: { isAvailable: false },
options: { errorMessage: 'Ошибка обновления' }
});
await updateClassroom(7, { name: '102', capacity: 40, equipmentIds: [], isAvailable: true }, client);
assert.equal(client.calls[3].endpoint, '/api/classrooms/7');
await restoreClassroom(7, client);
assert.deepEqual(client.calls[4], {
method: 'POST',
endpoint: '/api/classrooms/7/restore',
json: {},
options: { errorMessage: 'Не удалось восстановить аудиторию' }
});
await createEquipment('Проектор', client);
assert.deepEqual(client.calls[5].json, { name: 'Проектор' });
await deleteEquipment(3, client);
assert.equal(client.calls[6].endpoint, '/api/equipments/3');
const failingClient = recordingClient(() => {
throw new Error('Отказ сети');
});
assert.deepEqual(await fetchEquipments(failingClient), []);
});
test('состояние реестра дисциплин нормализуется и сохраняется в URL', () => {
assert.deepEqual(initialSubjectsState({}), {
query: '',
departmentId: '',
page: 0,
size: 25,
sort: 'name,asc'
});
assert.deepEqual(
normalizeSubjectsState({
subjectQuery: 'мат',
subjectDepartment: '4',
subjectPage: '2',
subjectSize: '50',
subjectSort: 'code,asc'
}),
{ query: 'мат', departmentId: '4', page: 2, size: 50, sort: 'code,asc' }
);
assert.equal(normalizeSubjectsState({ subjectSize: '13', subjectSort: 'hacker,desc' }).size, 25);
assert.equal(normalizeSubjectsState({ subjectSize: '13', subjectSort: 'hacker,desc' }).sort, 'name,asc');
assert.equal(normalizeSubjectsState({ subjectPage: '-3' }).page, 0);
const state = { query: 'мат', departmentId: '4', page: 2, size: 50, sort: 'code,asc' };
assert.equal(
subjectsQueryParams(state),
'/api/subjects/page?query=%D0%BC%D0%B0%D1%82&page=2&size=50&sort=code&direction=asc&departmentId=4'
);
assert.equal(
subjectsQueryParams({ query: '', departmentId: '', page: 0, size: 25, sort: 'name,asc' }),
'/api/subjects/page?query=&page=0&size=25&sort=name&direction=asc'
);
assert.deepEqual(subjectsUrlState({ query: '', departmentId: '', page: 0, size: 25, sort: 'name,asc' }), {
subjectQuery: '',
subjectDepartment: '',
subjectPage: '',
subjectSize: '',
subjectSort: ''
});
assert.deepEqual(subjectsUrlState({ query: 'мат', departmentId: '4', page: 2, size: 50, sort: 'code,asc' }), {
subjectQuery: 'мат',
subjectDepartment: '4',
subjectPage: 2,
subjectSize: 50,
subjectSort: 'code,asc'
});
assert.equal(teacherAssignmentsEndpoint([1, 2]), '/api/teacher-subjects?subjectId=1&subjectId=2');
assert.equal(teachersSearchEndpoint('иван', ''), '/api/users/teachers/options?query=%D0%B8%D0%B2%D0%B0%D0%BD&limit=30');
assert.equal(teachersSearchEndpoint('', '5'), '/api/users/teachers/options?query=&limit=30&selectedId=5');
assert.equal(subjectsEmptyMessage({ query: '', departmentId: '' }), 'Дисциплин пока нет');
assert.equal(subjectsEmptyMessage({ query: 'мат', departmentId: '' }), 'Дисциплины не найдены');
});
test('метки кафедр и группировка преподавателей совпадают с legacy-поведением', () => {
const departments = [
{ id: 1, departmentName: 'Кафедра математики', departmentCode: 'М' },
{ id: 2, name: 'Кафедра физики' }
];
assert.equal(departmentLabel(departments, 1), 'Кафедра математики');
assert.equal(departmentLabel(departments, 2), 'Кафедра физики');
assert.equal(departmentLabel(departments, 99), 99);
assert.equal(departmentLabel([], ''), '-');
assert.equal(departmentOptionLabel(departments[0]), 'Кафедра математики (М)');
assert.equal(departmentOptionLabel({ id: 3 }), 'Без названия (3)');
const grouped = groupTeachersBySubject([
{ subjectId: 1, userId: 10 },
{ subjectId: 1, userId: 11 },
{ subjectId: 2, userId: 10 }
]);
assert.equal(grouped.get('1').length, 2);
assert.equal(grouped.get('2').length, 1);
assert.equal(grouped.get('3'), undefined);
});
test('контракты API дисциплин и привязок сохранены', async () => {
const client = recordingClient(() => ({ items: [], totalItems: 0, totalPages: 0 }));
await fetchSubjectsPage({ query: '', departmentId: '', page: 0, size: 25, sort: 'name,asc' }, client);
assert.equal(client.calls[0].endpoint, '/api/subjects/page?query=&page=0&size=25&sort=name&direction=asc');
await createSubject({ name: 'Математика', code: 'MATH101', departmentId: '4' }, client);
assert.deepEqual(client.calls[1].json, { name: 'Математика', code: 'MATH101', departmentId: 4 });
await assignTeacher({ userId: '10', subjectId: '3' }, client);
assert.deepEqual(client.calls[2].json, { userId: 10, subjectId: 3 });
await removeTeacherAssignment({ userId: '10', subjectId: '3' }, client);
assert.deepEqual(client.calls[3], {
method: 'DELETE',
endpoint: '/api/teacher-subjects',
options: {
json: { userId: 10, subjectId: 3 },
errorMessage: 'Ошибка удаления привязки'
}
});
});
test('метрики и слоты дашборда считаются по локальному времени', () => {
const groups = [{ id: 1, groupSize: 20 }, { id: 2, groupSize: 25 }, { id: 3 }];
assert.equal(totalStudents(groups), 45);
const classrooms = [
{ id: 1, isAvailable: true, status: 'ACTIVE', capacity: 30 },
{ id: 2, isAvailable: false, status: 'ACTIVE', capacity: 50 },
{ id: 3, isAvailable: true, status: 'ARCHIVED', capacity: 90 }
];
assert.deepEqual(activeClassrooms(classrooms).map(classroom => classroom.id), [1]);
const lessons = [{ classroomId: 1 }, { classroomId: 3 }];
// legacy считает занятые по всем урокам (включая архивные аудитории)
assert.equal(classroomOccupancyLabel(classrooms, lessons), '200% (2/1)');
assert.equal(classroomOccupancyLabel(classrooms, []), '0% (0/1)');
const slots = [
{ orderNumber: 2, startTime: '10:00:00', endTime: '11:35:00' },
{ orderNumber: 1, startTime: '08:30:00', endTime: '10:00:00' }
];
assert.deepEqual(sortTimeSlots(slots).map(slot => slot.orderNumber), [1, 2]);
const at = (hours, minutes, seconds) => new Date(2026, 8, 28, hours, minutes, seconds);
assert.equal(findCurrentSlot(slots, at(9, 0, 0))?.orderNumber, 1);
assert.equal(findCurrentSlot(slots, at(12, 0, 0)), null);
assert.equal(currentSlotLabel(slots[0]), 'Пара №2 (10:00 – 11:35)');
assert.equal(currentSlotLabel(null), 'Вне сетки пар');
const dayLessons = [
{ timeSlotOrder: 1, classroomId: 1 },
{ timeSlotOrder: 2, classroomId: 1 }
];
assert.deepEqual(lessonsForSlot(dayLessons, 1).map(lesson => lesson.classroomId), [1]);
assert.deepEqual(freeClassroomsForSlot(classrooms, dayLessons, null).map(room => room.id), [1]);
assert.deepEqual(freeClassroomsForSlot(classrooms, dayLessons, 1), []);
});
test('эффективные слоты откатываются к базовым слотам тенанта', async () => {
const client = recordingClient((method, endpoint) => {
if (endpoint.startsWith('/api/admin/time-slots/effective')) {
throw new Error('Отказ');
}
if (endpoint === '/api/admin/time-slots') {
return [
{ orderNumber: 1, scopeId: 1 },
{ orderNumber: 2, scopeId: 2 },
{ orderNumber: 3 }
];
}
return [];
});
const slots = await fetchEffectiveTimeSlots('2026-09-28', client);
assert.deepEqual(slots.map(slot => slot.orderNumber), [1, 3]);
});
test('выявление конфликтов воспроизводит legacy-логику Red Zone', () => {
const classrooms = [{ id: 1, capacity: 40 }, { id: 2, capacity: 100 }];
const lesson = (patch, base) => ({
date: '2026-09-28',
dayName: 'понедельник',
timeSlotOrder: 1,
teacherId: 10,
teacherName: 'Иванов И. И.',
subjectId: 5,
subjectName: 'Математика',
classroomId: 1,
classroomName: '101',
groupIds: [1],
groupNames: ['Группа А'],
...base,
...patch
});
// Поток в одной аудитории: один преподаватель, вместимости хватает
const noConflicts = detectScheduleConflicts([
lesson({ groupIds: [1], groupNames: ['Группа А'] }, {}),
lesson({ groupIds: [2], groupNames: ['Группа Б'] }, {})
], [{ id: 1, groupSize: 20 }, { id: 2, groupSize: 15 }], classrooms);
assert.deepEqual(noConflicts, []);
const teacherCollision = detectScheduleConflicts([
lesson({}, {}),
lesson({ classroomId: 2, classroomName: '102' }, {})
], [{ id: 1, groupSize: 30 }], classrooms);
assert.equal(teacherCollision.length, 1);
assert.equal(teacherCollision[0].type, 'TEACHER_COLLISION');
assert.equal(teacherCollision[0].severity, 'error');
assert.equal(teacherCollision[0].title, 'Накладка в расписании преподавателя');
assert.match(conflictDescriptionText(teacherCollision[0]), /Иванов И\. И\. назначен одновременно в разные места: ауд\. 101 \(группа Группа А\), ауд\. 102 \(группа Группа А\) на 2026-09-28 \(понедельник\), пара №1\./);
const classroomCollision = detectScheduleConflicts([
lesson({}, {}),
lesson({ teacherId: 11, teacherName: 'Петров П. П.', subjectId: 6, subjectName: 'Физика' }, {})
], [{ id: 1, groupSize: 30 }], classrooms);
assert.equal(classroomCollision.length, 1);
assert.equal(classroomCollision[0].type, 'CLASSROOM_COLLISION');
assert.match(conflictDescriptionText(classroomCollision[0]), /Аудитория 101 занята одновременно разными занятиями/);
const capacityOverflow = detectScheduleConflicts([
lesson({ groupIds: [1, 2], groupNames: ['Группа А', 'Группа Б'] }, {})
], [{ id: 1, groupSize: 30 }, { id: 2, groupSize: 25 }], classrooms);
assert.equal(capacityOverflow.length, 1);
assert.equal(capacityOverflow[0].type, 'CAPACITY_OVERFLOW');
assert.equal(capacityOverflow[0].severity, 'warning');
assert.match(conflictDescriptionText(capacityOverflow[0]), /назначено 55 человек/);
const combined = detectScheduleConflicts([
lesson({ classroomId: 2, classroomName: '102' }, {}),
lesson({
teacherId: 11,
teacherName: 'Петров П. П.',
subjectId: 6,
subjectName: 'Физика',
groupIds: [2],
groupNames: ['Группа Б'],
classroomId: 2,
classroomName: '102'
}, {}),
lesson({}, {})
], [{ id: 1, groupSize: 30 }, { id: 2, groupSize: 15 }], classrooms);
assert.deepEqual(combined.map(conflict => conflict.type), ['TEACHER_COLLISION', 'CLASSROOM_COLLISION']);
});
test('вкладка аудиторий рендерит форму, мультиселект и таблицу без inline-стилей', () => {
const markup = renderClassroomsTab();
assert.match(markup, /Новая аудитория/);
assert.match(markup, /Справочник оборудования/);
assert.match(markup, /custom-multi-select/);
assert.match(markup, /select-text/);
assert.match(markup, /Оборудование отсутствует/);
assert.match(markup, /Список аудиторий/);
assert.match(markup, /Загрузка\.\.\./);
assert.match(markup, /class="th-actions"/);
assert.doesNotMatch(markup, /style="/);
});
test('вкладка дисциплин рендерит реестр с поиском, сортировкой и пагинацией', () => {
const markup = renderSubjectsTab();
assert.match(markup, /Новая дисциплина/);
assert.match(markup, /Все дисциплины/);
assert.match(markup, /registry-toolbar/);
assert.match(markup, /По названию А–Я/);
assert.match(markup, /Загрузка дисциплин\.\.\./);
assert.match(markup, /id="subjects-search"/);
assert.match(markup, /id="subjects-sort"/);
assert.doesNotMatch(markup, /style="/);
});
test('вкладка дашборда рендерит метрики, мониторинг и Red Zone', () => {
const markup = renderDashboardTab();
assert.match(markup, /dashboard-metrics-grid/);
assert.match(markup, /Активные группы/);
assert.match(markup, /Всего студентов/);
assert.match(markup, /Загрузка аудиторий сегодня/);
assert.match(markup, /Мониторинг пар \(Идет сейчас\)/);
assert.match(markup, /Определение текущего времени\.\.\./);
assert.match(markup, /Свободные аудитории \(ближайшие\)/);
assert.match(markup, /Быстрый переход/);
assert.match(markup, /href="#schedule"/);
assert.match(markup, /href="#schedule-view"/);
assert.match(markup, /Центр предупреждений \(Red Zone\)/);
assert.match(markup, /Анализ расписания и выявление конфликтов\.\.\./);
assert.match(markup, /id="btn-recheck-conflicts"/);
assert.match(markup, /red-zone-card/);
assert.doesNotMatch(markup, /style="/);
});
test('состояние реестра пользователей нормализуется и сохраняется в URL', () => {
assert.deepEqual(initialUsersState({}), {
query: '',
role: '',
page: 0,
size: 25,
sort: 'fullName,asc'
});
assert.deepEqual(
normalizeUsersState({
userQuery: 'иван',
userRole: 'TEACHER',
userPage: '1',
userSize: '50',
userSort: 'username,asc'
}),
{ query: 'иван', role: 'TEACHER', page: 1, size: 50, sort: 'username,asc' }
);
assert.equal(normalizeUsersState({ userSize: '13', userSort: 'hacker,desc' }).size, 25);
assert.equal(normalizeUsersState({ userSize: '13', userSort: 'hacker,desc' }).sort, 'fullName,asc');
assert.equal(
usersQueryParams({ query: 'иван', role: 'TEACHER', page: 1, size: 50, sort: 'username,asc' }),
'/api/users/page?query=%D0%B8%D0%B2%D0%B0%D0%BD&page=1&size=50&sort=username&direction=asc&includeArchived=true&role=TEACHER'
);
assert.equal(
usersQueryParams({ query: '', role: '', page: 0, size: 25, sort: 'fullName,asc' }),
'/api/users/page?query=&page=0&size=25&sort=fullName&direction=asc&includeArchived=true'
);
assert.deepEqual(usersUrlState({ query: '', role: '', page: 0, size: 25, sort: 'fullName,asc' }), {
userQuery: '',
userRole: '',
userPage: '',
userSize: '',
userSort: ''
});
assert.deepEqual(usersUrlState({ query: 'иван', role: 'TEACHER', page: 2, size: 50, sort: 'role,asc' }), {
userQuery: 'иван',
userRole: 'TEACHER',
userPage: 2,
userSize: 50,
userSort: 'role,asc'
});
assert.equal(usersEmptyMessage({ query: '', role: '' }), 'Пользователей пока нет');
assert.equal(usersEmptyMessage({ query: 'иван', role: '' }), 'Пользователи не найдены');
assert.equal(roleLabel('ADMIN'), 'Администратор');
assert.equal(roleLabel('UNKNOWN'), 'UNKNOWN');
assert.equal(roleBadgeClass('DEPARTMENT'), 'badge-teacher');
assert.equal(roleBadgeClass('UNKNOWN'), '');
});
test('контракты API пользователей сохранены', async () => {
const client = recordingClient(() => ({ items: [], totalItems: 0, totalPages: 0 }));
await fetchUsersPage({ query: '', role: '', page: 0, size: 25, sort: 'fullName,asc' }, client);
assert.equal(client.calls[0].endpoint, '/api/users/page?query=&page=0&size=25&sort=fullName&direction=asc&includeArchived=true');
await createUser({
username: 'ivanov',
password: 'пароль123',
role: 'TEACHER',
fullName: 'Иванов И. И.',
jobTitle: 'Доцент',
departmentId: '4'
}, client);
assert.deepEqual(client.calls[1], {
method: 'POST',
endpoint: '/api/users',
json: {
username: 'ivanov',
password: 'пароль123',
role: 'TEACHER',
fullName: 'Иванов И. И.',
jobTitle: 'Доцент',
departmentId: 4
},
options: { errorMessage: 'Ошибка соединения' }
});
await archiveUser(7, client);
assert.deepEqual(client.calls[2], {
method: 'DELETE',
endpoint: '/api/users/7',
options: { errorMessage: 'Ошибка архивирования' }
});
await restoreUser(7, client);
assert.deepEqual(client.calls[3], {
method: 'POST',
endpoint: '/api/users/7/restore',
json: {},
options: { errorMessage: 'Ошибка восстановления' }
});
});
test('вкладка пользователей рендерит форму создания и реестр без inline-стилей', () => {
const markup = renderUsersTab();
assert.match(markup, /Новый пользователь/);
assert.match(markup, /Все пользователи/);
assert.match(markup, /id="new-password"/);
assert.match(markup, /autoComplete="new-password"/);
assert.match(markup, /id="users-search"/);
assert.match(markup, /id="users-role-filter"/);
assert.match(markup, /id="users-sort"/);
assert.match(markup, /Загрузка пользователей\.\.\./);
assert.doesNotMatch(markup, /style="/);
});
test('состояние реестра групп нормализуется с фильтром форм обучения', () => {
assert.deepEqual(initialGroupsState({}), {
query: '',
page: 0,
size: 25,
sort: 'name,asc',
educationFormIds: []
});
assert.deepEqual(
initialGroupsState({
groupQuery: 'ивт',
groupPage: '1',
groupSize: '50',
groupSort: 'groupSize,desc',
groupEducationForm: ['1', '3']
}),
{ query: 'ивт', page: 1, size: 50, sort: 'groupSize,desc', educationFormIds: ['1', '3'] }
);
assert.equal(initialGroupsState({ groupSize: '13', groupSort: 'hacker,desc' }).size, 25);
assert.equal(initialGroupsState({ groupSize: '13', groupSort: 'hacker,desc' }).sort, 'name,asc');
assert.equal(
groupsQueryParams({ query: 'ивт', page: 1, size: 50, sort: 'name,desc', educationFormIds: ['1', '3'] }),
'/api/groups/page?query=%D0%B8%D0%B2%D1%82&page=1&size=50&sort=name&direction=desc&includeArchived=true&educationFormId=1&educationFormId=3'
);
assert.deepEqual(groupsUrlState({ query: '', page: 0, size: 25, sort: 'name,asc', educationFormIds: [] }), {
groupQuery: '',
groupPage: '',
groupSize: '',
groupSort: '',
groupEducationForm: []
});
assert.equal(efFilterLabel([{ id: 1, name: 'Очная' }, { id: 2, name: 'Заочная' }], []), 'Все');
assert.equal(efFilterLabel([{ id: 1, name: 'Очная' }], ['1']), 'Очная');
assert.equal(efFilterLabel([{ id: 1, name: 'Очная' }], ['9']), 'Выберите форму обучения');
assert.equal(efFilterLabel([], ['1', '2']), 'Выбрано: 2');
});
test('валидация и расчёт подгрупп воспроизводят legacy-логику', () => {
assert.deepEqual(suggestedSubgroupCapacities(20, 0), []);
assert.deepEqual(suggestedSubgroupCapacities(21, 2), [11, 10]);
assert.deepEqual(suggestedSubgroupCapacities(22, 3), [8, 7, 7]);
assert.equal(
validateSubgroupCapacities([10, 10], 20, 2),
null
);
assert.equal(
validateSubgroupCapacities([10, 9], 20, 2),
'Сумма численностей подгрупп должна быть равна численности группы (20 чел.)'
);
assert.equal(
validateSubgroupCapacities([10, 0], 20, 2),
'Укажите положительную численность для каждой подгруппы'
);
assert.equal(naturalSubgroupOrder('Подгруппа 10'), '010');
assert.equal(naturalSubgroupOrder('Подгруппа 2'), '002');
assert.deepEqual(
sortSubgroups([{ id: 1, name: 'Подгруппа 2' }, { id: 2, name: 'Подгруппа 10' }])
.map(sub => sub.id),
[1, 2]
);
});
test('payload группы валидируется как в legacy-форме', () => {
const valid = groupPayloadFromFields({
name: 'ИВТ-21-1',
groupSize: '20',
educationFormId: '1',
departmentId: '2',
yearStartStudy: '2026',
specialtyId: '3',
specialtyProfileId: '4'
});
assert.deepEqual(valid, {
payload: {
name: 'ИВТ-21-1',
groupSize: 20,
educationFormId: 1,
departmentId: 2,
yearStartStudy: 2026,
specialtyId: 3,
specialtyProfileId: 4
}
});
assert.deepEqual(groupPayloadFromFields({ name: '', groupSize: '20' }), { error: 'Введите название группы' });
assert.deepEqual(
groupPayloadFromFields({ name: 'ИВТ', groupSize: '20', educationFormId: '1', departmentId: '2', yearStartStudy: '2026', specialtyId: '3', specialtyProfileId: '' }),
{ error: 'Выберите профиль обучения' }
);
});
test('контракты API групп, подгрупп и графиков сохранены', async () => {
const client = recordingClient(() => ({ id: 1, name: 'ИВТ-21-1' }));
await fetchGroupsPage({ query: '', page: 0, size: 25, sort: 'name,asc', educationFormIds: [] }, client);
assert.equal(client.calls[0].endpoint, '/api/groups/page?query=&page=0&size=25&sort=name&direction=asc&includeArchived=true');
await createGroup({ name: 'ИВТ-21-1', groupSize: 20 }, client);
assert.deepEqual(client.calls[1], {
method: 'POST',
endpoint: '/api/groups',
json: { name: 'ИВТ-21-1', groupSize: 20 },
options: { errorMessage: 'Ошибка создания' }
});
assert.equal(
calendarsOptionsEndpoint({ query: 'график', academicYearId: '2', specialtyId: '3', profileId: '4', studyFormId: '1', selectedId: '5' }),
'/api/admin/academic-calendars/options?query=%D0%B3%D1%80%D0%B0%D1%84%D0%B8%D0%BA&academicYearId=2&specialtyId=3&profileId=4&studyFormId=1&limit=30&selectedId=5'
);
assert.equal(
calendarsOptionsEndpoint({ query: '', academicYearId: '2', specialtyId: '3', profileId: '4', studyFormId: '1' }),
'/api/admin/academic-calendars/options?query=&academicYearId=2&specialtyId=3&profileId=4&studyFormId=1&limit=30'
);
});
test('синхронизация подгрупп соблюдает legacy-порядок операций', async () => {
const client = recordingClient(() => ({}));
const existing = [
{ id: 11, name: 'Подгруппа 1', studentCapacity: 10 },
{ id: 12, name: 'Подгруппа 2', studentCapacity: 10 },
{ id: 13, name: 'Подгруппа 3', studentCapacity: 10 }
];
await syncSubgroups(7, existing, [12, 8], client);
assert.deepEqual(client.calls.map(call => `${call.method} ${call.endpoint}`), [
'DELETE /api/groups/7/subgroups/13',
'PUT /api/groups/7/subgroups/12',
'PUT /api/groups/7/subgroups/11'
]);
// сначала применяется уменьшающая вместимость операция (legacy-порядок)
assert.deepEqual(client.calls[1].json, { name: 'Подгруппа 2', studentCapacity: 8 });
assert.deepEqual(client.calls[2].json, { name: 'Подгруппа 1', studentCapacity: 12 });
const createClient = recordingClient(() => ({}));
await syncSubgroups(7, [], [15, 15], createClient);
assert.deepEqual(createClient.calls.map(call => `${call.method} ${call.endpoint}`), [
'POST /api/groups/7/subgroups',
'POST /api/groups/7/subgroups'
]);
assert.deepEqual(createClient.calls[0].json, { name: 'Подгруппа 1', studentCapacity: 15 });
});
test('метки статусов, специальностей и дисциплин групп совпадают с legacy-поведением', () => {
const specialties = [
{ id: 1, specialityCode: '09.03.04', specialityName: 'Программная инженерия' },
{ id: 2, name: 'Физика' }
];
assert.equal(specialityLabel(specialties, 1), '09.03.04 — Программная инженерия');
assert.equal(specialityLabel(specialties, 2), '2 — Физика');
assert.equal(specialityLabel(specialties, 9), 9);
assert.equal(specialityLabel([], ''), '-');
assert.equal(subjectLabel({ subjectCode: 'MATH101', subjectName: 'Математика' }), 'MATH101 · Математика');
assert.equal(subjectLabel({ subjectId: 5 }), '5');
assert.equal(subjectLabel({}), 'Без названия');
assert.equal(statusLabel({ status: 'ARCHIVED' }), 'Архив');
assert.equal(statusLabel({ studyStateName: 'Выпустилась' }), 'Выпустилась');
assert.equal(statusLabel({ active: true }), 'Активна');
assert.equal(statusLabel({ active: false }), 'Неактивна');
assert.equal(statusBadgeClass({ status: 'ARCHIVED' }), 'badge-unavailable');
assert.equal(statusBadgeClass({ studyState: 'GRADUATED' }), 'badge-unavailable');
assert.equal(statusBadgeClass({ studyState: 'NOT_STARTED' }), 'badge-ef');
assert.equal(statusBadgeClass({ studyState: 'ACTIVE' }), 'badge-available');
assert.deepEqual(
assignmentSubjectsGroups([
{ semesterNumber: 2, subjectName: 'Физика' },
{ semesterNumber: 1, subjectName: 'Математика' },
{ semesterNumber: 1, subjectName: 'Логика' }
]),
[
{ semesterNumber: '1', items: [{ semesterNumber: 1, subjectName: 'Математика' }, { semesterNumber: 1, subjectName: 'Логика' }] },
{ semesterNumber: '2', items: [{ semesterNumber: 2, subjectName: 'Физика' }] }
]
);
assert.deepEqual(assignmentSubjectsGroups([]), []);
});
test('вкладка групп рендерит форму, фильтр форм обучения и реестр без inline-стилей', () => {
const markup = renderGroupsTab();
assert.match(markup, /Новая группа/);
assert.match(markup, /Все группы/);
assert.match(markup, /custom-multi-select/);
assert.match(markup, /id="filter-ef-box"/);
assert.match(markup, /Сбросить фильтры/);
assert.match(markup, /id="groups-search"/);
assert.match(markup, /id="groups-sort"/);
assert.match(markup, /Загрузка групп\.\.\./);
assert.doesNotMatch(markup, /style="/);
});
test('контракты API структуры вуза сохранены', async () => {
const client = recordingClient(() => ({ id: 1 }));
await createDepartment({ name: 'Кафедра ИБ', code: '1' }, client);
assert.deepEqual(client.calls[0], {
method: 'POST',
endpoint: '/api/departments',
json: { departmentName: 'Кафедра ИБ', departmentCode: 1 },
options: { errorMessage: 'Ошибка создания кафедры' }
});
await createSpecialty({ name: 'Программная инженерия', code: '09.03.04' }, client);
assert.deepEqual(client.calls[1], {
method: 'POST',
endpoint: '/api/specialties',
json: { specialityName: 'Программная инженерия', specialityCode: '09.03.04' },
options: { errorMessage: 'Ошибка создания специальности' }
});
await updateProfile(3, 5, { name: 'ИИ', description: 'Профиль' }, client);
assert.deepEqual(client.calls[2], {
method: 'PUT',
endpoint: '/api/specialties/3/profiles/5',
json: { id: 5, name: 'ИИ', description: 'Профиль' },
options: { errorMessage: 'Ошибка сохранения профиля' }
});
await fetchAllProfiles(client);
assert.equal(client.calls[3].endpoint, '/api/specialties/profiles');
});
test('клиентская фильтрация и пагинация списков структуры вуза как в legacy', () => {
const departments = [
{ id: 1, departmentName: 'Кафедра ИБ', departmentCode: 1 },
{ id: 2, name: 'Кафедра физики', code: 2 }
];
assert.deepEqual(filterListItems(departments, 'иб', departmentFields).map(d => d.id), [1]);
assert.deepEqual(filterListItems(departments, 'физики', departmentFields).map(d => d.id), [2]);
assert.deepEqual(filterListItems(departments, '', departmentFields).map(d => d.id), [1, 2]);
assert.deepEqual(filterListItems(null, '', departmentFields), []);
assert.equal(clampPage(5, 3), 2);
assert.equal(clampPage(-1, 3), 0);
assert.equal(clampPage(1, 3), 1);
const items = Array.from({ length: 30 }, (_, index) => ({ id: index }));
assert.equal(pageSlice(items, 0, 25).length, 25);
assert.equal(pageSlice(items, 1, 25).length, 5);
assert.equal(pageSlice(items, 2, 25).length, 0);
assert.equal(departmentName(departments[0]), 'Кафедра ИБ');
assert.equal(departmentName(departments[1]), 'Кафедра физики');
assert.equal(departmentCode(departments[0]), 1);
assert.equal(departmentCode(departments[1]), 2);
const speciality = { specialityCode: '09.03.04', specialityName: 'Программная инженерия' };
assert.equal(specialtyCode(speciality), '09.03.04');
assert.equal(specialtyName(speciality), 'Программная инженерия');
assert.equal(specialtyOptionLabel(speciality), '09.03.04 · Программная инженерия');
assert.equal(profileSpecialtyLabel({ specialityCode: '09.03.04', specialityName: 'Программная инженерия' }), '09.03.04 · Программная инженерия');
});
test('вкладка структуры вуза рендерит разделы, формы и списки без inline-стилей', () => {
const markup = renderUniversityStructureTab();
assert.match(markup, /university-structure-tabs/);
assert.match(markup, /Кафедры/);
assert.match(markup, /Специальности/);
assert.match(markup, /Профили/);
assert.match(markup, /Создание кафедры/);
assert.match(markup, /Создание специальности/);
assert.match(markup, /Создание профиля/);
assert.match(markup, /id="departments-search"/);
assert.match(markup, /id="specialties-search"/);
assert.match(markup, /id="profiles-search"/);
assert.match(markup, /Загрузка\.\.\./);
assert.doesNotMatch(markup, /style="/);
});
test('перенесённые вкладки не тянут legacy-модули и HTML-фрагменты', async () => {
const adminApp = await readFrontendFile('react/admin/AdminApp.jsx');
assert.match(adminApp, /REACT_TABS\[tab\]/);
assert.match(adminApp, /Suspense/);
assert.doesNotMatch(adminApp, /LegacyTabView/);
const reactTabs = await readFrontendFile('react/admin/react-tabs.js');
assert.doesNotMatch(reactTabs, /views\//);
const scheduleViewTab = await readFrontendFile('react/admin/tabs/ScheduleViewTab.jsx');
assert.doesNotMatch(scheduleViewTab, /views\/schedule-override-panel/);
const classroomsTab = await readFrontendFile('react/admin/tabs/ClassroomsTab.jsx');
assert.match(classroomsTab, /btn-restore-classroom/);
assert.doesNotMatch(classroomsTab, /initMultiSelect/);
const subjectsTab = await readFrontendFile('react/admin/tabs/SubjectsTab.jsx');
assert.match(subjectsTab, /AsyncCombobox/);
assert.match(subjectsTab, /реестр_дисциплин_загружен/);
});
test('состояние реестра заявок нормализуется и сохраняется в URL', () => {
assert.deepEqual(initialTeacherRequestsState({}), {
query: '',
status: 'PENDING',
page: 0,
size: 25
});
assert.deepEqual(
initialTeacherRequestsState({
requestQuery: 'иванов',
requestStatus: 'APPROVED',
requestPage: '1',
requestSize: '50'
}),
{ query: 'иванов', status: 'APPROVED', page: 1, size: 50 }
);
assert.equal(initialTeacherRequestsState({ requestSize: '13' }).size, 25);
assert.equal(initialTeacherRequestsState({ requestPage: '-2' }).page, 0);
assert.equal(
teacherRequestsQueryParams({ query: 'иванов', status: 'PENDING', page: 1, size: 50 }),
'/api/teacher-requests/page?query=%D0%B8%D0%B2%D0%B0%D0%BD%D0%BE%D0%B2&page=1&size=50&sort=createdAt&direction=desc&status=PENDING'
);
assert.equal(
teacherRequestsQueryParams({ query: '', status: '', page: 0, size: 25 }),
'/api/teacher-requests/page?query=&page=0&size=25&sort=createdAt&direction=desc'
);
assert.deepEqual(teacherRequestsUrlState({ query: '', status: 'PENDING', page: 0, size: 25 }), {
requestQuery: '',
requestStatus: '',
requestPage: '',
requestSize: ''
});
assert.deepEqual(teacherRequestsUrlState({ query: 'иванов', status: 'REJECTED', page: 2, size: 50 }), {
requestQuery: 'иванов',
requestStatus: 'REJECTED',
requestPage: 2,
requestSize: 50
});
assert.equal(teacherRequestsEmptyMessage({ query: 'иванов' }), 'Заявки не найдены');
assert.equal(teacherRequestsEmptyMessage({ query: '' }), 'Заявок с выбранным статусом нет');
assert.equal(reviewedStatusLabel('APPROVED'), 'Одобрена');
assert.equal(reviewedStatusLabel('REJECTED'), 'Отклонена');
assert.deepEqual(
REQUEST_STATUS_OPTIONS.map(option => option.value),
['PENDING', 'APPROVED', 'REJECTED', '']
);
// счётчик sidebar актуален только для нефильтрованного списка ожидающих заявок
assert.deepEqual(pendingCountDetail({ status: 'PENDING', query: '' }, 7), { count: 7 });
assert.deepEqual(pendingCountDetail({ status: 'PENDING', query: 'иван' }, 7), { count: undefined });
assert.deepEqual(pendingCountDetail({ status: 'APPROVED', query: '' }, 7), { count: undefined });
assert.equal(requestDepartmentLabel({ id: 1, departmentName: 'Кафедра ИБ' }), 'Кафедра ИБ');
assert.equal(requestDepartmentLabel({ id: 2, name: 'Кафедра физики' }), 'Кафедра физики');
assert.equal(requestDepartmentLabel({ id: 3 }), 3);
});
test('payload одобрения заявки валидируется как в legacy-реестре', () => {
const valid = approvePayloadFromFields({
departmentId: '4',
username: ' ivanov ',
fullName: ' Иванов И. И. ',
jobTitle: ' Доцент ',
password: 'пароль123'
});
assert.deepEqual(valid, {
payload: {
departmentId: 4,
username: 'ivanov',
fullName: 'Иванов И. И.',
jobTitle: 'Доцент',
password: 'пароль123'
}
});
assert.deepEqual(
approvePayloadFromFields({ departmentId: '', username: 'ivanov', fullName: 'Иванов', password: '12345678' }),
{ error: 'Заполните кафедру, логин, ФИО и пароль' }
);
assert.deepEqual(
approvePayloadFromFields({ departmentId: '4', username: 'ivanov', fullName: 'Иванов', password: 'short' }),
{ error: 'Пароль должен быть не короче 8 символов' }
);
});
test('контракты API заявок преподавателей сохранены', async () => {
const client = recordingClient(() => ({ items: [], totalItems: 0, totalPages: 0 }));
await fetchTeacherRequestsPage({ query: '', status: 'PENDING', page: 0, size: 25 }, client);
assert.equal(
client.calls[0].endpoint,
'/api/teacher-requests/page?query=&page=0&size=25&sort=createdAt&direction=desc&status=PENDING'
);
await approveRequest(9, {
departmentId: 4,
username: 'ivanov',
fullName: 'Иванов И. И.',
jobTitle: 'Доцент',
password: 'пароль123'
}, client);
assert.deepEqual(client.calls[1], {
method: 'POST',
endpoint: '/api/teacher-requests/9/approve',
json: { departmentId: 4, username: 'ivanov', fullName: 'Иванов И. И.', jobTitle: 'Доцент', password: 'пароль123' },
options: { errorMessage: 'Ошибка одобрения заявки' }
});
await rejectRequest(9, 'Дубликат', client);
assert.deepEqual(client.calls[2], {
method: 'POST',
endpoint: '/api/teacher-requests/9/reject',
json: { reviewComment: 'Дубликат' },
options: { errorMessage: 'Ошибка отклонения заявки' }
});
});
test('вкладка заявок рендерит реестр с фильтром статуса без inline-стилей', () => {
const markup = renderTeacherRequestsTab();
assert.match(markup, /Заявки на преподавателей/);
assert.match(markup, /teacher-request-toolbar/);
assert.match(markup, /id="teacher-requests-search"/);
assert.match(markup, /id="teacher-requests-status"/);
assert.match(markup, /Ожидают рассмотрения/);
assert.match(markup, /Все статусы/);
assert.match(markup, /id="teacher-requests-tbody"/);
assert.match(markup, /Загрузка заявок\.\.\./);
assert.match(markup, /0 заявок/);
assert.doesNotMatch(markup, /style="/);
});
test('эндпоинты кабинета кафедры совпадают с legacy-контрактами', () => {
assert.equal(departmentSubjectsEndpoint(4), '/api/department/subjects?departmentId=4');
assert.equal(departmentSubjectsEndpoint(''), '/api/department/subjects?departmentId=');
assert.equal(departmentTeachersEndpoint(4), '/api/department/teachers?departmentId=4');
assert.equal(departmentTeacherRequestsEndpoint(4), '/api/department/teacher-requests?departmentId=4');
assert.equal(
teacherWorkloadEndpoint({ startDate: '2026-09-01', endDate: '2026-09-14', departmentId: '4' }),
'/api/workload/teachers?startDate=2026-09-01&endDate=2026-09-14&departmentId=4'
);
assert.equal(
teacherWorkloadEndpoint({ startDate: '2026-09-01', endDate: '2026-09-14', departmentId: '' }),
'/api/workload/teachers?startDate=2026-09-01&endDate=2026-09-14'
);
});
test('payload форм кабинета кафедры валидируется как в legacy', () => {
assert.deepEqual(
importSubjectsPayload([{ code: 'DB201', name: 'Базы данных' }, { code: '', name: '' }], '4'),
{ payload: [{ code: 'DB201', name: 'Базы данных', departmentId: 4 }] }
);
assert.deepEqual(importSubjectsPayload([{ code: 'DB201', name: 'Базы данных' }], ''), { error: 'Выберите кафедру' });
assert.deepEqual(importSubjectsPayload([{ code: 'DB201', name: '' }], '4'), { error: 'Заполните название хотя бы одной дисциплины' });
assert.equal(assignTeacherError('', '5'), 'Выберите кафедру');
assert.equal(assignTeacherError('4', ''), 'Выберите преподавателя');
assert.equal(assignTeacherError('4', '5'), null);
assert.deepEqual(
teacherRequestPayload({ username: ' ivanov ', fullName: ' Иванов И. И. ', jobTitle: ' Доцент ', comment: ' Проверить ' }, '4'),
{
payload: {
departmentId: 4,
username: 'ivanov',
fullName: 'Иванов И. И.',
jobTitle: 'Доцент',
comment: 'Проверить'
}
}
);
assert.deepEqual(teacherRequestPayload({ username: 'ivanov', fullName: '' }, '4'), { error: 'Заполните логин и ФИО преподавателя' });
assert.deepEqual(teacherRequestPayload({ username: 'ivanov', fullName: 'Иванов' }, ''), { error: 'Выберите кафедру' });
});
test('контракты API кабинета кафедры сохранены', async () => {
const client = recordingClient(() => []);
await fetchDepartmentSubjects(4, client);
assert.equal(client.calls[0].endpoint, '/api/department/subjects?departmentId=4');
await importSubjects([{ code: 'DB201', name: 'Базы данных', departmentId: 4 }], client);
assert.deepEqual(client.calls[1], {
method: 'POST',
endpoint: '/api/department/subjects/import',
json: [{ code: 'DB201', name: 'Базы данных', departmentId: 4 }],
options: { errorMessage: 'Ошибка загрузки дисциплин' }
});
await fetchDepartmentTeachers(4, client);
assert.equal(client.calls[2].endpoint, '/api/department/teachers?departmentId=4');
await assignTeacherToDepartment(5, '4', '', client);
assert.deepEqual(client.calls[3], {
method: 'POST',
endpoint: '/api/department/teachers/5/assignments',
json: { departmentId: 4, comment: null },
options: { errorMessage: 'Ошибка привязки преподавателя' }
});
await fetchTeacherWorkload({ startDate: '2026-09-01', endDate: '2026-09-14', departmentId: '4' }, client);
assert.equal(client.calls[4].endpoint, '/api/workload/teachers?startDate=2026-09-01&endDate=2026-09-14&departmentId=4');
await fetchDepartmentTeacherRequests(4, client);
assert.equal(client.calls[5].endpoint, '/api/department/teacher-requests?departmentId=4');
await submitTeacherRequest({ departmentId: 4, username: 'ivanov', fullName: 'Иванов', jobTitle: '', comment: '' }, client);
assert.deepEqual(client.calls[6], {
method: 'POST',
endpoint: '/api/department/teacher-requests',
json: { departmentId: 4, username: 'ivanov', fullName: 'Иванов', jobTitle: '', comment: '' },
options: { errorMessage: 'Ошибка отправки заявки' }
});
});
test('сопоставление преподавателей и нагрузки воспроизводит legacy-логику', () => {
const teachers = [
{ id: 1, fullName: 'Иванов И. И.', username: 'ivanov', jobTitle: 'Доцент' },
{ id: 2, fullName: 'Петров П. П.', username: 'petrov' }
];
const workload = [
{ id: 1, fullName: 'Иванов И. И.', academicHours: 12, lessonCount: 6 },
{ id: 3, name: 'Сидоров С. С.', academicHours: 4, lessonCount: 2 }
];
const rows = teacherMetrics(teachers, workload);
assert.deepEqual(rows, [
{ name: 'Иванов И. И.', meta: 'Доцент', workload: { id: 1, fullName: 'Иванов И. И.', academicHours: 12, lessonCount: 6 } },
{ name: 'Петров П. П.', meta: 'Должность не указана', workload: undefined },
{ name: 'Сидоров С. С.', meta: 'Есть занятия в расписании кафедры', workload: { id: 3, name: 'Сидоров С. С.', academicHours: 4, lessonCount: 2 } }
]);
// пустая нагрузка — нулевая метрика; ошибка периода перекрывает значения
assert.deepEqual(teacherMetrics(teachers, []), [
{ name: 'Иванов И. И.', meta: 'Доцент', workload: undefined },
{ name: 'Петров П. П.', meta: 'Должность не указана', workload: undefined }
]);
assert.equal(workloadLabel(rows[0].workload, ''), '12 ак. ч., 6 занятий за период');
assert.equal(workloadLabel(undefined, ''), '0 ак. ч., 0 занятий за период');
assert.equal(workloadLabel(rows[0].workload, 'Ошибка расчёта нагрузки'), 'Ошибка расчёта нагрузки');
assert.deepEqual(teacherMetrics([], []), []);
const assigned = [{ id: 1 }, { id: 2 }];
assert.deepEqual(availableTeachers([{ id: 1 }, { id: 3 }, { id: 4 }], assigned).map(teacher => teacher.id), [3, 4]);
assert.equal(teacherOptionLabel({ id: 5, fullName: 'Иванов И. И.' }), 'Иванов И. И.');
assert.equal(teacherOptionLabel({ id: 5, username: 'ivanov' }), 'ivanov');
assert.equal(teacherOptionLabel({ id: 5 }), 'ID 5');
});
test('метки кабинета кафедры совпадают с legacy-поведением', () => {
assert.equal(subjectCountLabel(1), '1 дисциплина');
assert.equal(subjectCountLabel(3), '3 дисциплины');
assert.equal(subjectCountLabel(11), '11 дисциплин');
assert.equal(subjectCountLabel(21), '21 дисциплина');
assert.equal(subjectCountLabel(0), '0 дисциплин');
assert.equal(requestStatusLabel('PENDING'), 'На рассмотрении');
assert.equal(requestStatusLabel('APPROVED'), 'Одобрена');
assert.equal(requestStatusLabel('REJECTED'), 'Отклонена');
assert.equal(requestStatusLabel('UNKNOWN'), 'UNKNOWN');
assert.equal(requestStatusLabel(''), '-');
assert.equal(requestStatusClass('PENDING'), 'badge-ef');
assert.equal(requestStatusClass('APPROVED'), 'badge-student');
assert.equal(requestStatusClass('REJECTED'), 'badge-admin');
assert.equal(requestStatusClass('UNKNOWN'), 'badge-ef');
assert.equal(formatDateTime('2026-09-28T10:30:00'), '2026-09-28 10:30');
assert.equal(formatDateTime(null), '-');
});
test('вкладка кабинета кафедры рендерит формы, метрики и таблицы без inline-стилей', () => {
const markup = renderDepartmentWorkspaceTab();
assert.match(markup, /Рабочая область кафедры/);
assert.match(markup, /id="department-workspace-refresh"/);
assert.match(markup, /id="department-workspace-department"/);
assert.match(markup, /id="department-workspace-start"/);
assert.match(markup, /id="department-workspace-end"/);
assert.match(markup, /Загрузка дисциплин/);
assert.match(markup, /id="subject-import-add"/);
assert.match(markup, /subject-import-row/);
assert.match(markup, /Добавить преподавателя/);
assert.match(markup, /id="department-teacher-select"/);
assert.match(markup, /Заявка на преподавателя/);
assert.match(markup, /id="teacher-request-username"/);
assert.match(markup, /Преподаватели и нагрузка/);
assert.match(markup, /id="department-teachers-list"/);
assert.match(markup, /Заявки кафедры/);
assert.match(markup, /id="department-teacher-requests-tbody"/);
assert.match(markup, /Дисциплины кафедры/);
assert.match(markup, /id="department-subjects-tbody"/);
assert.match(markup, /Загрузка\.\.\./);
assert.doesNotMatch(markup, /style="/);
// карточка комментариев скрыта до выбора дисциплины
assert.doesNotMatch(markup, /department-comments-card/);
});
test('двухнедельный период загруженности считается по локальному времени', () => {
assert.deepEqual(twoWeekRange('2026-09-28'), { startDate: '2026-09-28', endDate: '2026-10-11' });
// среда 30.09 — период всё равно начинается с понедельника
assert.deepEqual(twoWeekRange('2026-09-30'), { startDate: '2026-09-28', endDate: '2026-10-11' });
assert.deepEqual(twoWeekRange('2026-10-04'), { startDate: '2026-09-28', endDate: '2026-10-11' });
assert.deepEqual(datesBetween('2026-09-28', '2026-09-30'), ['2026-09-28', '2026-09-29', '2026-09-30']);
assert.deepEqual(datesBetween('2026-09-28', '2026-09-28'), ['2026-09-28']);
// воскресенье даёт ISO-день 7, а не 0
assert.equal(isoDay('2026-09-27'), 7);
assert.equal(isoDay('2026-09-28'), 1);
assert.equal(formatDateShort('2026-09-28'), '28.09');
});
test('чётность недель и сетка дней воспроизводят legacy-логику', () => {
const semesters = [{ startDate: '2026-09-01', endDate: '2026-12-31' }];
assert.equal(weekNumberForDate('2026-09-01', semesters), 1);
assert.equal(weekNumberForDate('2026-09-07', semesters), 1);
assert.equal(weekNumberForDate('2026-09-08', semesters), 2);
assert.equal(weekNumberForDate('2027-01-15', semesters), null);
// чётность занятия перекрывает расчёт по семестру
assert.equal(parityForDate('2026-09-28', [{ date: '2026-09-28', parity: 'EVEN' }], semesters), 'EVEN');
assert.equal(parityForDate('2026-09-02', [], semesters), 'ODD');
assert.equal(parityForDate('2026-09-28', [], semesters), 'EVEN');
assert.equal(parityForDate('2026-09-28', [], []), null);
const dates = datesBetween('2026-09-28', '2026-10-11');
const grid = buildDateGridByDay(dates, [], []);
assert.equal(grid.unknownParityDates, 14);
assert.equal(grid.datesByDay[1].ODD, '2026-09-28');
assert.equal(grid.datesByDay[1].EVEN, '2026-10-05');
assert.equal(grid.datesByDay[7].EVEN, '2026-10-11');
const semesterGrid = buildDateGridByDay(dates, [], semesters);
assert.equal(semesterGrid.unknownParityDates, 0);
assert.deepEqual(uniqueWeekNumbers(['2026-09-01', '2026-09-02', '2026-09-08'], semesters), [1, 2]);
assert.deepEqual(uniqueWeekNumbers(['2027-01-15'], semesters), []);
assert.equal(
roomWeekCaption({ 1: { ODD: '2026-09-01', EVEN: '2026-09-08' } }, semesters),
'Нечётная сверху, учебная неделя 1; Чётная снизу, учебная неделя 2'
);
assert.equal(
roomWeekCaption({ 1: { ODD: '2027-01-15' } }, semesters),
'Нечётная сверху, неделя вне семестра; Чётная снизу, неделя вне семестра'
);
});
test('дедупликация и группировка занятий воспроизводят legacy-логику', () => {
const base = {
date: '2026-09-28',
timeSlotId: 2,
classroomId: 1,
subjectId: 5,
teacherId: 10
};
const merged = uniqueLessons([
{ ...base, groupNames: ['Группа Б'], groupIds: [2] },
{ ...base, groupNames: ['Группа А'], groupIds: [1] },
{ date: '2026-09-29', timeSlotId: null }
]);
assert.equal(merged.length, 1);
assert.deepEqual(merged[0].groupNames, ['Группа А', 'Группа Б']);
// legacy mergeIds не сортирует идентификаторы — только сливает без дублей
assert.deepEqual(merged[0].groupIds, [2, 1]);
const teachers = [
{ id: 10, fullName: 'Иванов И. И.', departmentId: 3 },
{ id: 11, fullName: 'Петров П. П.', departmentName: 'Кафедра ИБ' }
];
const departments = [
{ id: 3, departmentName: 'Кафедра математики' },
{ id: 4, departmentName: 'Кафедра ИБ' }
];
assert.equal(teacherDepartmentId(10, teachers, departments), 3);
assert.equal(teacherDepartmentId(11, teachers, departments), 4);
assert.equal(teacherDepartmentId(99, teachers, departments), null);
assert.deepEqual(entityIdsForLesson({ ...base }, TARGET_CLASSROOMS, teachers, departments), [1]);
assert.deepEqual(entityIdsForLesson({ ...base }, TARGET_TEACHERS, teachers, departments), [10]);
assert.deepEqual(entityIdsForLesson({ ...base, teacherId: 11 }, TARGET_DEPARTMENTS, teachers, departments), [4]);
assert.deepEqual(entityIdsForLesson({ ...base, teacherId: 99 }, TARGET_DEPARTMENTS, teachers, departments), []);
const grouped = groupLessonsByEntitySlot([
{ ...base, subjectName: 'Физика' },
{ ...base, subjectId: 6, subjectName: 'Математика', classroomId: 1 }
], TARGET_CLASSROOMS, teachers, departments);
assert.deepEqual(grouped.get('1:2').map(lesson => lesson.subjectName), ['Математика', 'Физика']);
});
test('фильтры и опции загруженности совпадают с legacy-поведением', () => {
const classrooms = [
{ id: 1, name: '101', building: 'Б', floor: 2, capacity: 30, equipments: [{ id: 1, name: 'Проектор' }] },
{ id: 2, name: '102', building: 'А', capacity: 50, equipments: [{ id: 1, name: 'Проектор' }, { id: 2, name: 'Доска' }] },
{ id: 3, name: '103' }
];
assert.deepEqual(buildingOptions(classrooms), [
{ value: 'А', label: 'А' },
{ value: 'Б', label: 'Б' },
{ value: '__no_building__', label: 'Корпус не указан' }
]);
assert.deepEqual(equipmentOptions([{ id: 2, name: 'Доска' }, { id: 1, name: 'Проектор' }], classrooms), [
{ id: '2', name: 'Доска' },
{ id: '1', name: 'Проектор' }
]);
// без справочника оборудование собирается из аудиторий без дублей
assert.deepEqual(equipmentOptions([], classrooms), [
{ id: '2', name: 'Доска' },
{ id: '1', name: 'Проектор' }
]);
assert.equal(parseMinCapacity(''), 0);
assert.equal(parseMinCapacity('abc'), 0);
assert.equal(parseMinCapacity('40'), 40);
assert.deepEqual(filteredClassrooms(classrooms, {}).map(room => room.id), [1, 2, 3]);
assert.deepEqual(filteredClassrooms(classrooms, { buildings: ['А'] }).map(room => room.id), [2]);
assert.deepEqual(filteredClassrooms(classrooms, { minCapacity: 30 }).map(room => room.id), [1, 2]);
assert.deepEqual(filteredClassrooms(classrooms, { equipment: ['1', '2'] }).map(room => room.id), [2]);
assert.deepEqual(filteredClassrooms(classrooms, { buildings: ['А'], minCapacity: 100 }), []);
assert.equal(entityOptionLabel(TARGET_CLASSROOMS, classrooms[0]), '101 · Б, 2 этаж');
assert.equal(entityOptionLabel(TARGET_CLASSROOMS, classrooms[2]), '103');
assert.equal(
entityOptionLabel(TARGET_TEACHERS, { fullName: 'Иванов И. И.', jobTitle: 'Доцент', departmentName: 'Кафедра ИБ' }),
'Иванов И. И. · Доцент, Кафедра ИБ'
);
assert.equal(entityOptionLabel(TARGET_TEACHERS, { username: 'ivanov' }), 'ivanov');
assert.equal(entityOptionLabel(TARGET_DEPARTMENTS, { departmentName: 'Кафедра ИБ', departmentCode: 'ИБ' }), 'Кафедра ИБ · код ИБ');
assert.deepEqual(entityAxisParts(TARGET_CLASSROOMS, classrooms[0]), {
title: '101',
subtitle: '30 мест',
note: 'Б, 2 этаж'
});
assert.deepEqual(entityAxisParts(TARGET_CLASSROOMS, classrooms[2]), {
title: '103',
subtitle: 'Вместимость не указана',
note: ''
});
assert.deepEqual(entityAxisParts(TARGET_TEACHERS, { fullName: 'Иванов И. И.', jobTitle: 'Доцент', departmentName: 'Кафедра ИБ' }), {
title: 'Иванов И. И.',
subtitle: 'Доцент',
note: 'Кафедра ИБ'
});
assert.deepEqual(entityAxisParts(TARGET_DEPARTMENTS, { departmentName: 'Кафедра ИБ' }), {
title: 'Кафедра ИБ',
subtitle: 'Код не указан',
note: ''
});
});
test('контракты API загруженности сохранены', async () => {
const client = recordingClient((method, endpoint) => {
if (endpoint.startsWith('/api/admin/time-slots/effective')) {
return [{ orderNumber: 2, startTime: '10:00:00', endTime: '11:35:00' }];
}
if (endpoint.startsWith('/api/schedule')) {
return [{ id: 1, date: '2026-09-28', timeSlotId: 2, classroomId: 1, subjectName: 'Математика' }];
}
return [];
});
assert.equal(effectiveTimeSlotsEndpoint('2026-09-28'), '/api/admin/time-slots/effective?date=2026-09-28');
await fetchWorkloadTimeSlots('2026-09-28', client);
assert.equal(client.calls[0].endpoint, '/api/admin/time-slots/effective?date=2026-09-28');
assert.equal(
groupScheduleEndpoint({ id: 7 }, '2026-09-28', '2026-10-11'),
'/api/schedule?groupId=7&startDate=2026-09-28&endDate=2026-10-11'
);
await fetchGroupSchedule({ id: 7 }, '2026-09-28', '2026-10-11', client);
assert.equal(client.calls[1].endpoint, '/api/schedule?groupId=7&startDate=2026-09-28&endDate=2026-10-11');
// сбой справочника учебных годов молча даёт пустой список
const failingClient = recordingClient(() => {
throw new Error('Отказ сети');
});
assert.deepEqual(await fetchAcademicYears(failingClient), []);
// сетки времени грузятся по датам независимо: сбой одной даты не рушит остальные
const slots = await loadSlotsForDates(['2026-09-28', '2026-09-29'], client);
assert.equal(slots.failedLoads, 0);
assert.deepEqual(slots.slotsByDate.get('2026-09-28').map(slot => slot.orderNumber), [2]);
const mixedClient = recordingClient((method, endpoint) => {
if (endpoint.endsWith('date=2026-09-29')) throw new Error('Отказ');
return [{ orderNumber: 1, startTime: '08:30:00', endTime: '10:00:00' }];
});
const mixed = await loadSlotsForDates(['2026-09-28', '2026-09-29'], mixedClient);
assert.equal(mixed.failedLoads, 1);
assert.deepEqual(mixed.slotsByDate.get('2026-09-29'), []);
const groups = [{ id: 1 }, { id: 2 }];
const overview = await loadOverviewLessons({
groups,
date: '2026-09-28',
target: TARGET_CLASSROOMS,
teachers: [],
departments: []
}, client);
assert.equal(overview.failedLoads, 0);
assert.deepEqual(client.calls.filter(call => call.endpoint.includes('endDate=2026-09-28')).map(call => call.endpoint), [
'/api/schedule?groupId=1&startDate=2026-09-28&endDate=2026-09-28',
'/api/schedule?groupId=2&startDate=2026-09-28&endDate=2026-09-28'
]);
assert.deepEqual(overview.lessonsByEntitySlot.get('1:2').map(lesson => lesson.subjectName), ['Математика']);
// без групп расписание не запрашивается
const emptyClient = recordingClient(() => []);
const emptyOverview = await loadOverviewLessons({
groups: [],
date: '2026-09-28',
target: TARGET_CLASSROOMS,
teachers: [],
departments: []
}, emptyClient);
assert.equal(emptyOverview.lessonsByEntitySlot.size, 0);
assert.equal(emptyClient.calls.length, 0);
const entity = await loadEntityLessons({
groups,
startDate: '2026-09-28',
endDate: '2026-10-11',
entityId: 1,
target: TARGET_CLASSROOMS,
teachers: [],
departments: []
}, client);
assert.equal(entity.failedLoads, 0);
assert.equal(entity.lessons.length, 1);
// parityLessons — сырой список без дедупликации (legacy-контракт)
assert.equal(entity.parityLessons.length, 2);
assert.deepEqual(entity.lessonsByDateSlot.get('2026-09-28:2').map(lesson => lesson.subjectName), ['Математика']);
const failingGroups = recordingClient(() => {
throw new Error('Отказ');
});
const failedEntity = await loadEntityLessons({
groups,
startDate: '2026-09-28',
endDate: '2026-10-11',
entityId: 1,
target: TARGET_CLASSROOMS,
teachers: [],
departments: []
}, failingGroups);
assert.equal(failedEntity.failedLoads, 2);
assert.deepEqual(failedEntity.lessons, []);
});
test('сводки, счётчики и строки слотов воспроизводят legacy-логику', () => {
assert.equal(
overviewSummaryText({
target: TARGET_CLASSROOMS,
dateValue: '2026-09-28',
entityCount: 3,
occupiedCells: 4,
totalCells: 12,
failedGroupLoads: 1
}),
'Дата: 28.09.2026, Пн. Аудиторий: 3. Занятых слотов: 4 из 12 (33%), ошибок загрузки групп: 1.'
);
assert.equal(
overviewSummaryText({
target: TARGET_TEACHERS,
dateValue: '',
entityCount: 0,
occupiedCells: 0,
totalCells: 0,
failedGroupLoads: 0,
selectedEntityId: '5'
}),
'Дата: -, -. Преподавателей: 0, выбран один объект. Занятых слотов: 0 из 0 (0%).'
);
const summary = roomSummaryParts({
target: TARGET_CLASSROOMS,
entity: { name: '101', capacity: 30, building: 'Б' },
range: { startDate: '2026-09-28', endDate: '2026-10-11' },
totalCells: 10,
occupiedCells: 5,
failedGroupLoads: 1,
failedSlotLoads: 2,
unknownParityDates: 3
});
assert.equal(summary.name, '101');
assert.equal(summary.details, '30 мест, Б');
assert.equal(summary.rangeLabel, '28.09.2026 - 11.10.2026');
assert.equal(summary.occupancyLabel, 'Занято: 5 из 10 (50%)');
assert.equal(summary.warnings, 'ошибок групп: 1, ошибок сеток: 2, часть дат вне семестра');
const timeSlots = [{ id: 1 }, { id: 2 }];
const lessonsByEntitySlot = new Map([['1:2', [{ subjectName: 'Математика' }]]]);
assert.equal(countOccupiedCells([{ id: 1 }, { id: 2 }], timeSlots, lessonsByEntitySlot), 1);
const slotsByDate = new Map([
['2026-09-28', [{ orderNumber: 1, startTime: '08:30:00', endTime: '10:00:00' }]]
]);
const lessonsByDateSlot = new Map([
['2026-09-28:2', [{ subjectName: 'Математика' }]],
['2026-09-29:1', []]
]);
assert.equal(countAvailableRoomCells(['2026-09-28', '2026-09-29'], slotsByDate, lessonsByDateSlot), 2);
assert.equal(countOccupiedRoomCells(lessonsByDateSlot), 1);
const slotRows = buildSlotRows(
['2026-09-28'],
slotsByDate,
[{ timeSlotOrder: 2, startTime: '10:00:00', endTime: '11:35:00' }]
);
assert.deepEqual(slotRows, [
{
key: '1',
orderNumber: 1,
startTime: '08:30:00',
endTime: '10:00:00',
orderLabel: '1 пара',
timeLabel: '08:30-10:00'
},
{
key: '2',
orderNumber: 2,
startTime: '10:00:00',
endTime: '11:35:00',
orderLabel: '2 пара',
timeLabel: '10:00-11:35'
}
]);
const row = { key: '1' };
assert.equal(roomSlotState(null, row, slotsByDate, lessonsByDateSlot).kind, 'empty');
assert.equal(roomSlotState('2026-09-28', row, slotsByDate, lessonsByDateSlot).kind, 'free');
assert.equal(roomSlotState('2026-09-28', { key: '2' }, slotsByDate, lessonsByDateSlot).kind, 'busy');
assert.equal(roomSlotState('2026-09-29', { key: '9' }, slotsByDate, lessonsByDateSlot).kind, 'missing');
const busy = { kind: 'busy', lessons: [{ subjectName: 'Математика', subjectId: 5 }] };
const sameBusy = { kind: 'busy', lessons: [{ subjectName: 'Математика', subjectId: 5 }] };
const otherBusy = { kind: 'busy', lessons: [{ subjectName: 'Физика', subjectId: 6 }] };
assert.equal(roomSlotStatesEqual(busy, sameBusy), true);
assert.equal(roomSlotStatesEqual(busy, otherBusy), false);
assert.equal(roomSlotStatesEqual(busy, { kind: 'free', lessons: [] }), false);
});
test('вкладка загруженности рендерит фильтры и сводную таблицу без inline-стилей', () => {
const markup = renderAuditoriumWorkloadTab();
assert.match(markup, /Загруженность/);
assert.match(markup, /id="workload-refresh"/);
assert.match(markup, /id="workload-target-select"/);
assert.match(markup, /<option value="classrooms"[^>]*>Аудитории<\/option>/);
assert.match(markup, /<option value="teachers">Преподаватели<\/option>/);
assert.match(markup, /<option value="departments">Кафедры<\/option>/);
assert.match(markup, /id="workload-display-select"/);
assert.match(markup, /<option value="all"[^>]*>Все аудитории<\/option>/);
assert.match(markup, /custom-multi-select/);
assert.match(markup, /Все корпуса/);
assert.match(markup, /Любое оборудование/);
assert.match(markup, /id="workload-min-capacity"/);
assert.match(markup, /id="workload-date"/);
assert.match(markup, /id="workload-alert"/);
assert.match(markup, /id="workload-overview-block"/);
assert.match(markup, /id="workload-summary"/);
assert.match(markup, /workload-table/);
assert.match(markup, /id="workload-header-row"/);
assert.match(markup, /top-left-cell/);
assert.match(markup, /id="workload-tbody"/);
assert.match(markup, /Загрузка справочников и расписания\.\.\./);
// блок двухнедельной сводки скрыт до выбора сущности в «Отображение»
assert.doesNotMatch(markup, /room-workload-block/);
assert.doesNotMatch(markup, /style="/);
});
test('контракты API календарных графиков сохранены', async () => {
const client = recordingClient(async (method, endpoint) => {
if (method === 'GET') return [];
return { id: 7 };
});
await fetchSpecialtyProfiles(7, client);
await fetchCalendarsPage({ page: 0, size: 25, query: '', academicYearId: '' }, client);
await fetchCalendarOptions('поиск', '', client);
await fetchCalendarGrid(7, client);
await fetchCalendarSubjects(7, client);
await saveCalendarGridRequest(7, [], client);
await saveCalendarSubjectsRequest(7, [], client);
await saveAcademicYearRequest({}, '', client);
await deleteCalendarRequest(7, client);
await deleteTimeSlotDateAssignmentRequest(4, client);
assert.equal(client.calls.length, 10);
client.calls.forEach(call => assert.ok(call.options.errorMessage));
assert.equal(client.calls[0].endpoint, '/api/specialties/7/profiles');
assert.equal(client.calls[1].endpoint, '/api/admin/academic-calendars/page?page=0&size=25&query=');
assert.equal(client.calls[2].endpoint, '/api/admin/academic-calendars/options?query=%D0%BF%D0%BE%D0%B8%D1%81%D0%BA&limit=20');
assert.equal(client.calls[3].endpoint, '/api/admin/academic-calendars/7/grid');
assert.equal(client.calls[4].endpoint, '/api/admin/academic-calendars/7/subjects');
assert.equal(client.calls[5].method, 'PUT');
assert.equal(client.calls[5].endpoint, '/api/admin/academic-calendars/7/grid');
assert.equal(client.calls[6].method, 'PUT');
assert.equal(client.calls[6].endpoint, '/api/admin/academic-calendars/7/subjects');
assert.equal(client.calls[7].method, 'POST');
assert.equal(client.calls[7].endpoint, '/api/admin/calendar/years');
assert.equal(client.calls[8].method, 'DELETE');
assert.equal(client.calls[8].endpoint, '/api/admin/academic-calendars/7');
assert.equal(client.calls[9].method, 'DELETE');
assert.equal(client.calls[9].endpoint, '/api/admin/time-slots/date-assignments/4');
assert.equal(specialtyProfilesEndpoint(7), '/api/specialties/7/profiles');
assert.equal(
calendarsPageEndpoint({ page: 1, size: 50, query: ' иванов ', academicYearId: 3 }),
'/api/admin/academic-calendars/page?page=1&size=50&query=%D0%B8%D0%B2%D0%B0%D0%BD%D0%BE%D0%B2&academicYearId=3'
);
assert.equal(
academicCalendarsOptionsEndpoint('физика', 12),
'/api/admin/academic-calendars/options?query=%D1%84%D0%B8%D0%B7%D0%B8%D0%BA%D0%B0&limit=20&selectedId=12'
);
assert.equal(academicCalendarsOptionsEndpoint('', ''), '/api/admin/academic-calendars/options?query=&limit=20');
assert.equal(calendarSubjectsEndpoint(5), '/api/admin/academic-calendars/5/subjects');
assert.equal(calendarGridEndpoint(5), '/api/admin/academic-calendars/5/grid');
});
test('ввод года начала пересчитывает семестры, а даты полей читаются с подписью', () => {
const result = startYearInputResult('2026');
assert.equal(result.startYear, '2026');
assert.equal(result.endYear, '2027');
assert.deepEqual(result.semesterDates, {
autumn: { start: '01.09.2026', end: '31.01.2027' },
spring: { start: '01.02.2027', end: '30.06.2027' }
});
assert.deepEqual(startYearInputResult('20a6'), { startYear: '206', endYear: '', semesterDates: null });
assert.deepEqual(startYearInputResult('9999'), { startYear: '9999', endYear: '', semesterDates: null });
assert.equal(readDisplayDateIso('01.09.2026', 'Начало осеннего семестра'), '2026-09-01');
assert.throws(
() => readDisplayDateIso('01.09.202', 'Начало осеннего семестра'),
/Начало осеннего семестра: введите дату полностью в формате ДД\.ММ\.ГГГГ/
);
});
test('подписи календарных графиков и семестров совпадают с legacy-поведением', () => {
assert.equal(
calendarSpecialityLabel({ specialityCode: '09.03.04', specialityName: 'Программная инженерия' }),
'09.03.04 - Программная инженерия'
);
assert.equal(calendarSpecialityLabel({ id: 5 }), '5');
assert.equal(
calendarOptionLabel({
title: 'Запасной титул',
specialtyCode: '09.03.04',
specialtyProfileName: 'Профиль',
studyFormName: 'Очная',
academicYearTitle: '2026-2027'
}),
'09.03.04 — Профиль — Очная — 2026-2027'
);
assert.equal(calendarOptionLabel({ title: 'Только название' }), 'Только название');
assert.equal(calendarSemesterCount({ courseCount: 4 }), 8);
assert.equal(calendarSemesterCount({}), 1);
assert.equal(semesterOptionLabel(1), '1 семестр · 1 курс, осенний');
assert.equal(semesterOptionLabel(2), '2 семестр · 1 курс, весенний');
assert.equal(semesterOptionLabel(3), '3 семестр · 2 курс, осенний');
assert.equal(subjectRowLabel({ subjectCode: 'ИСТ', subjectName: 'История' }), 'ИСТ · История');
assert.deepEqual(
sortCalendarSubjectRows([
{ semesterNumber: 2, subjectName: 'Физика', subjectCode: 'ФИЗ' },
{ semesterNumber: 1, subjectName: 'История', subjectCode: 'ИСТ' },
{ semesterNumber: 1, subjectName: 'Алгебра', subjectCode: 'АЛГ' }
]).map(row => row.subjectCode),
['АЛГ', 'ИСТ', 'ФИЗ']
);
});
test('сетка годового графика строится по датам учебного года и группируется по семестрам', () => {
const calendar = {
id: 9,
courseCount: 1,
academicYearId: 3,
academicYearStartDate: '2026-09-01',
academicYearEndDate: '2026-09-07'
};
const activityTypes = [
{ id: 3, code: 'Т', name: 'Теория', colorCode: '#0ea5e9' },
{ id: 4, code: 'Э', name: 'Экзамен', colorCode: '#f59e0b' }
];
const rows = buildDefaultGrid(calendar, activityTypes);
assert.equal(rows.length, 7);
assert.deepEqual(rows[0], {
id: null,
calendarId: 9,
courseNumber: 1,
date: '2026-09-01',
weekNumber: 1,
dayOfWeek: 2,
activityTypeId: 3,
activityCode: 'Т'
});
// 2026-09-07 — понедельник, начало второй недели учебного года
assert.equal(rows[6].date, '2026-09-07');
assert.equal(rows[6].dayOfWeek, 1);
assert.equal(rows[6].weekNumber, 2);
assert.equal(effectiveActivityTypeId({ activityTypeId: null, activityCode: 'Э' }, activityTypes), 4);
assert.equal(effectiveActivityTypeId({ activityTypeId: 5, activityCode: 'Э' }, activityTypes), 5);
const years = [{
id: 3,
semesters: [
{ semesterType: 'autumn', startDate: '2026-09-01', endDate: '2026-12-31' },
{ semesterType: 'spring', startDate: '2027-01-01', endDate: '2027-06-30' }
]
}];
const blocks = semesterBlocksForCourse(calendar, 1, rows, years);
assert.equal(blocks.length, 2);
assert.equal(blocks[0].semesterNumber, 1);
assert.equal(blocks[0].rows.length, 7);
assert.equal(blocks[0].caption, '01.09.2026 - 07.09.2026');
assert.equal(blocks[1].semesterNumber, 2);
assert.equal(blocks[1].rows.length, 0);
assert.equal(blocks[1].caption, '01.01.2027 - 30.06.2027');
assert.deepEqual(gridTotalsEntries(rows, activityTypes), [
{ code: 'Т', count: 7, colorClass: 'calendar-activity-color-0ea5e9' }
]);
assert.equal(commonActivityValue(rows), '3');
assert.equal(commonActivityValue([{ activityTypeId: 3 }, { activityTypeId: 4 }]), '');
assert.deepEqual(dayDialogTexts([rows[0]]), {
date: '01.09.2026 (Вт)',
course: '1 курс',
week: '1 неделя',
day: 'Вторник'
});
const multiTexts = dayDialogTexts([rows[0], rows[6]]);
assert.equal(multiTexts.date, '01.09.2026 - 07.09.2026');
assert.equal(multiTexts.week, '2 недель');
assert.equal(multiTexts.day, '2 ячеек');
assert.equal(dayDialogSummaryParts([rows[0]], activityTypes).kind, 'single');
const multi = dayDialogSummaryParts([{ activityTypeId: 4 }, { activityTypeId: 4 }], activityTypes);
assert.equal(multi.kind, 'multi');
assert.equal(multi.count, 2);
assert.equal(
dayDialogSummaryParts([{ activityTypeId: 3 }, { activityTypeId: 4 }], activityTypes).kind,
'multi-empty'
);
const scopes = [
{ id: 1, name: 'Вторник до 19:00', applyMode: 'WEEKDAY', dayOfWeek: 2 },
{ id: 2, name: 'Базовая сетка', applyMode: 'DEFAULT' },
{ id: 3, name: 'Сокращённая', applyMode: 'MANUAL' }
];
const tooltip = dayTooltipParts(rows[0], activityTypes, scopes, []);
assert.equal(tooltip.code, 'Т');
assert.equal(tooltip.activityName, 'Теория');
// как в legacy, префикс правила убирается, а скобки подписи остаются
assert.equal(tooltip.timeLabel, '(Вторник до 19:00)');
assert.equal(tooltip.colorClass, 'calendar-activity-color-0ea5e9');
const manual = dayTooltipParts(rows[0], activityTypes, scopes, [{ date: '2026-09-01', id: 11, scopeId: 3 }]);
assert.equal(manual.timeLabel, 'Сокращённая');
});
test('вкладка календарных графиков рендерит разделы и формы без inline-стилей', () => {
const markup = renderAcademicCalendarTab();
assert.match(markup, /academic-calendar-tabs/);
assert.match(markup, /Графики/);
assert.match(markup, /Сетки/);
assert.match(markup, /Дисциплины/);
assert.match(markup, /id="academic-years-tbody"/);
assert.match(markup, /id="academic-calendars-tbody"/);
assert.match(markup, /id="academic-calendars-search"/);
assert.match(markup, /id="academic-calendars-year-filter"/);
assert.match(markup, /id="academic-year-form-title"/);
assert.match(markup, /id="academic-calendar-form-title"/);
assert.match(markup, /id="calendar-editor-load"/);
assert.match(markup, /id="calendar-editor-save"/);
assert.match(markup, /id="calendar-fill-apply"/);
assert.match(markup, /id="calendar-totals"/);
assert.match(markup, /id="calendar-grid"/);
assert.match(markup, /id="calendar-subject-add"/);
assert.match(markup, /id="calendar-subject-save"/);
assert.match(markup, /id="calendar-subject-summary"/);
assert.match(markup, /Загрузка\.\.\./);
assert.match(markup, /Выберите календарный график/);
assert.match(markup, /Сохранить учебный год и семестры/);
assert.doesNotMatch(markup, /style="/);
});
test('контракты API просмотра расписаний сохранены', async () => {
const client = recordingClient(async (method, endpoint) => []);
const dictionaries = await fetchScheduleViewDictionaries(client);
assert.equal(client.calls[0].endpoint, '/api/departments');
assert.equal(client.calls[1].endpoint, '/api/subjects');
assert.equal(client.calls[2].endpoint, '/api/lesson-types');
assert.equal(client.calls[3].endpoint, '/api/schedule/semesters');
// Справочники дисциплин и типов занятий обязаны попадать в результат:
// без них вкладка падает на subjects.map (React error boundary).
assert.deepEqual(dictionaries.subjects, []);
assert.deepEqual(dictionaries.lessonTypes, []);
assert.deepEqual(dictionaries.departments, []);
assert.deepEqual(dictionaries.semesters, []);
await fetchScheduleLessons({
startDate: '2026-09-01',
endDate: '2026-09-14',
target: TARGET_GROUPS,
role: 'ADMIN',
groupId: '15',
subjectId: '3',
lessonTypeId: '2',
parity: 'ODD'
}, client);
assert.equal(
client.calls[4].endpoint,
'/api/schedule/search?startDate=2026-09-01&endDate=2026-09-14&groupId=15&subjectId=3&lessonTypeId=2&parity=ODD'
);
await fetchScheduleLessons({
startDate: '2026-09-01',
endDate: '2026-09-14',
target: SCHEDULE_TARGET_DEPARTMENTS,
role: ROLE_DEPARTMENT,
userDepartmentId: '4'
}, client);
assert.equal(
client.calls[5].endpoint,
'/api/schedule/search?startDate=2026-09-01&endDate=2026-09-14&departmentId=4'
);
await fetchScheduleLessons({
startDate: '2026-09-01',
endDate: '2026-09-14',
target: SCHEDULE_TARGET_DEPARTMENTS,
role: 'ADMIN',
departmentId: '7'
}, client);
assert.equal(
client.calls[6].endpoint,
'/api/schedule/search?startDate=2026-09-01&endDate=2026-09-14&departmentId=7'
);
await fetchScheduleOverrides({ startDate: '2026-09-01', endDate: '2026-09-14' }, client);
assert.equal(
client.calls[7].endpoint,
'/api/edu-office/schedule/overrides?startDate=2026-09-01&endDate=2026-09-14'
);
await fetchGroupOptions('поиск', '15', '4', client);
assert.equal(
client.calls[8].endpoint,
'/api/groups/options?query=%D0%BF%D0%BE%D0%B8%D1%81%D0%BA&limit=30&selectedId=15&departmentId=4'
);
await fetchTeacherOptions('иванов', '', client);
assert.equal(
client.calls[9].endpoint,
'/api/users/teachers/options?query=%D0%B8%D0%B2%D0%B0%D0%BD%D0%BE%D0%B2&limit=30'
);
await fetchClassroomOptions('', '3', client);
assert.equal(
client.calls[10].endpoint,
'/api/classrooms/options?query=&limit=30&selectedId=3'
);
assert.equal(client.calls.length, 11);
client.calls.forEach(call => assert.ok(call.options.errorMessage));
});
test('широкий поиск расписания без выбранной цели распознаётся заранее', () => {
// Без цели (группа/преподаватель/аудитория/кафедра) backend отвергает
// запрос «Укажите группу или кафедру» — вкладка обязана показать
// подсказку вместо заведомо неудачного запроса.
assert.equal(hasScheduleSearchTarget({ role: 'ADMIN', target: TARGET_GROUPS, groupId: '' }), false);
assert.equal(hasScheduleSearchTarget({ role: 'ADMIN', target: TARGET_GROUPS, groupId: '15' }), true);
assert.equal(hasScheduleSearchTarget({ role: 'ADMIN', target: SCHEDULE_TARGET_TEACHERS, teacherId: '' }), false);
assert.equal(hasScheduleSearchTarget({ role: 'ADMIN', target: SCHEDULE_TARGET_TEACHERS, teacherId: '3' }), true);
assert.equal(hasScheduleSearchTarget({ role: 'ADMIN', target: SCHEDULE_TARGET_CLASSROOMS, classroomId: '7' }), true);
assert.equal(hasScheduleSearchTarget({ role: 'ADMIN', target: SCHEDULE_TARGET_DEPARTMENTS, departmentId: '' }), false);
assert.equal(hasScheduleSearchTarget({ role: 'ADMIN', target: SCHEDULE_TARGET_DEPARTMENTS, departmentId: '2' }), true);
assert.equal(hasScheduleSearchTarget({ role: ROLE_DEPARTMENT, userDepartmentId: '4' }), true);
assert.equal(hasScheduleSearchTarget({ role: ROLE_DEPARTMENT, userDepartmentId: '' }), false);
assert.equal(hasScheduleSearchTarget({ role: 'ADMIN', target: 'unknown' }), false);
});
test('разделы расписания группируются по сущности цели просмотра', () => {
const lessons = [
{
teacherId: 1, teacherName: 'Иванов', classroomId: 10, classroomName: '101',
groupIds: [5], groupNames: ['ИС-31'], subjectName: 'Математика',
date: '2026-09-01', timeSlotOrder: 1
},
{
teacherId: 2, teacherName: 'Петров', classroomId: 11, classroomName: '102',
groupIds: [6], groupNames: ['ИС-32'], subjectName: 'Физика',
date: '2026-09-02', timeSlotOrder: 2
}
];
const teacherSections = buildScheduleSections(
lessons,
SCHEDULE_TARGET_TEACHERS,
buildSectionContext({ target: SCHEDULE_TARGET_TEACHERS, selectedGroupId: '', groups: [] })
);
assert.deepEqual(teacherSections.map(section => section.title), ['Иванов', 'Петров']);
assert.deepEqual(teacherSections.map(section => section.lessons.length), [1, 1]);
const groupContext = buildSectionContext({
target: TARGET_GROUPS,
selectedGroupId: '',
groups: [{ id: 5, name: 'ИС-31' }, { id: 6, name: 'ИС-32' }]
});
const groupSections = buildScheduleSections(lessons, TARGET_GROUPS, groupContext);
assert.deepEqual(groupSections.map(section => section.title), ['ИС-31', 'ИС-32']);
// Выбранная группа собирает все занятия в один раздел (фильтрует сервер),
// даже если имени группы нет в справочнике
const singleContext = buildSectionContext({ target: TARGET_GROUPS, selectedGroupId: '5', groups: [] });
const singleSections = buildScheduleSections(lessons, TARGET_GROUPS, singleContext);
assert.deepEqual(singleSections.map(section => section.title), ['Группа 5']);
assert.deepEqual(singleSections.map(section => section.lessons.length), [2]);
assert.equal(lessonCountLabel(1), '1 занятие');
assert.equal(lessonCountLabel(3), '3 занятия');
assert.equal(lessonCountLabel(5), '5 занятий');
assert.equal(lessonCountLabel(11), '11 занятий');
assert.equal(sectionCountLabel(2), '2 расписания');
assert.equal(sectionCountLabel(5), '5 расписаний');
});
test('двухнедельная сетка и матрица семестра строятся по датам и чётности', () => {
assert.deepEqual(scheduleTwoWeekRange('2026-09-03'), {
startDate: '2026-08-31',
endDate: '2026-09-13'
});
const lessons = [
{
date: '2026-09-01', dayOfWeek: 2, parity: 'ODD', weekNumber: 1,
timeSlotOrder: 1, timeSlotId: 10, startTime: '09:00:00', endTime: '10:30:00',
subjectName: 'Математика', scheduleRuleSlotId: 100
},
{
date: '2026-09-15', dayOfWeek: 2, parity: 'EVEN', weekNumber: 2,
timeSlotOrder: 1, timeSlotId: 10, startTime: '09:00:00', endTime: '10:30:00',
subjectName: 'Физика', scheduleRuleSlotId: 200
}
];
const slots = collectSlots(lessons);
assert.equal(slots.length, 1);
assert.equal(slots[0].orderLabel, '1 пара');
assert.equal(slots[0].timeLabel, '09:00-10:30');
const grouped = groupLessonsByDateSlot(lessons);
assert.equal(grouped.get('2026-09-01:1')?.length, 1);
assert.equal(grouped.get('2026-09-15:1')?.[0].subjectName, 'Физика');
const blocks = buildCombinedWeekBlocks('2026-08-31', '2026-09-13', lessons);
assert.equal(blocks.length, 1);
assert.equal(blocks[0].startDate, '2026-08-31');
assert.equal(blocks[0].endDate, '2026-09-13');
assert.equal(blocks[0].datesByDay[2].ODD, '2026-09-01');
assert.equal(blocks[0].datesByDay[2].EVEN, '2026-09-08');
assert.equal(scheduleParityForDate('2026-09-01', lessons), 'ODD');
assert.equal(scheduleParityForDate('2026-09-15', lessons), 'EVEN');
assert.equal(scheduleParityForDate('2026-12-01', lessons), null);
const matrix = buildSemesterLessonMatrix(lessons);
const states = slotStatesForSemester(2, slots[0], matrix);
assert.equal(states[0].state.kind, 'busy');
assert.equal(states[1].state.kind, 'busy');
assert.equal(slotStatesEqual(states[0].state, states[1].state), false);
assert.equal(states[0].state.lessons[0].subjectName, 'Математика');
assert.equal(states[1].state.lessons[0].subjectName, 'Физика');
assert.equal(states[0].state.lessons[0].displayStartWeek, 1);
assert.equal(states[0].state.lessons[0].displayEndWeek, 1);
const freeStates = slotStatesForSemester(3, slots[0], matrix);
assert.equal(freeStates[0].state.kind, 'free');
assert.equal(slotStatesEqual(freeStates[0].state, freeStates[1].state), true);
});
test('подпись недель занятия выводится из матрицы и рассчитывается по часам', () => {
assert.equal(getLessonWeeksText({ displayStartWeek: 3, displayEndWeek: 7 }), '(с 3 по 7 нед.)');
assert.equal(getLessonWeeksText({ displayStartWeek: 5, displayEndWeek: 5 }), '(5 нед.)');
assert.equal(getLessonWeeksText({ weekNumber: 4, lessonTypeAcademicHours: 30, parity: 'BOTH' }), '(с 4 по 18 нед.)');
assert.equal(getLessonWeeksText({ weekNumber: 2, lessonTypeAcademicHours: 8, parity: 'EVEN' }), '(с 2 по 8 нед.)');
assert.equal(
getLessonWeeksText({
weekNumber: 6,
lessonTypeAcademicHours: 30,
parity: 'BOTH',
consumedLessonTypeAcademicHoursBeforeLesson: 8
}),
'(с 2 по 16 нед.)'
);
assert.equal(getLessonWeeksText({}), '');
});
test('состояние фильтров просмотра расписаний восстанавливается и сохраняется в URL', () => {
assert.deepEqual(initialScheduleViewState({}), {
target: 'groups',
entity: '',
date: '',
yearId: '',
semesterId: '',
subjectId: '',
lessonTypeId: '',
parity: ''
});
assert.deepEqual(initialScheduleViewState({
scheduleTarget: 'teachers',
scheduleEntity: '7',
scheduleDate: '2026-09-01',
scheduleYear: '5',
scheduleSemester: '11',
scheduleSubject: '3',
scheduleLessonType: '2',
scheduleParity: 'ODD'
}), {
target: 'teachers',
entity: '7',
date: '2026-09-01',
yearId: '5',
semesterId: '11',
subjectId: '3',
lessonTypeId: '2',
parity: 'ODD'
});
assert.equal(entityValueForTarget('groups', { groupId: '15' }), '15');
assert.equal(entityValueForTarget('departments', { departmentId: '4' }), '4');
assert.equal(entityValueForTarget('teachers', {}), '');
assert.deepEqual(scheduleViewUrlState({
role: 'ADMIN',
target: 'teachers',
entityValue: '7',
date: '2026-09-01',
yearId: '5',
semesterId: '11',
subjectId: '3',
lessonTypeId: '2',
parity: 'ODD'
}), {
scheduleTarget: 'teachers',
scheduleEntity: '7',
scheduleDate: '2026-09-01',
scheduleYear: '5',
scheduleSemester: '11',
scheduleSubject: '3',
scheduleLessonType: '2',
scheduleParity: 'ODD'
});
// Кафедра не сохраняет цель: её цель зафиксирована ролью
const departmentState = scheduleViewUrlState({
role: ROLE_DEPARTMENT,
target: SCHEDULE_TARGET_DEPARTMENTS,
entityValue: '4',
date: '2026-09-01'
});
assert.equal(departmentState.scheduleTarget, '');
assert.equal(departmentState.scheduleEntity, '4');
});
test('справочники сущностей пополняются результатами поисковых списков', () => {
const dictionaries = { groups: [{ id: 1, name: 'ИС-31' }] };
mergeDictionaryItems(dictionaries, 'groups', [{ id: 2, name: 'ИС-32' }, { id: 1, name: 'ИС-31 (обновл.)' }]);
assert.deepEqual(dictionaries.groups, [
{ id: 1, name: 'ИС-31 (обновл.)' },
{ id: 2, name: 'ИС-32' }
]);
assert.equal(scheduleSemesterOptionLabel({
academicYearTitle: '2025/2026',
semesterType: 'spring',
startDate: '2026-02-09',
endDate: '2026-06-30'
}), '2025/2026 · Весенний семестр · 09.02.2026 — 30.06.2026');
});
test('вкладка просмотра расписаний рендерит фильтры, результаты и панель правок без inline-стилей', () => {
const markup = renderScheduleViewTab();
assert.match(markup, /Расписания/);
assert.match(markup, /id="schedule-view-load"/);
assert.match(markup, /id="schedule-view-target"/);
assert.match(markup, /<option value="groups"[^>]*>Группа<\/option>/);
assert.match(markup, /<option value="teachers"[^>]*>Преподаватель<\/option>/);
assert.match(markup, /<option value="classrooms"[^>]*>Аудитория<\/option>/);
assert.match(markup, /<option value="departments"[^>]*>Кафедра<\/option>/);
assert.match(markup, /id="schedule-view-department"/);
assert.match(markup, /async-combobox/);
assert.match(markup, /id="schedule-view-date"/);
assert.match(markup, /id="schedule-view-year"/);
assert.match(markup, /id="schedule-view-semester"/);
assert.match(markup, /id="schedule-view-subject"/);
assert.match(markup, /id="schedule-view-lesson-type"/);
assert.match(markup, /id="schedule-view-parity"/);
assert.match(markup, /Дополнительные фильтры/);
assert.match(markup, /id="schedule-view-alert"/);
assert.match(markup, /Найденные расписания/);
assert.match(markup, /id="schedule-view-count"[^>]*>0 занятий</);
assert.match(markup, /id="schedule-view-overrides-open"[^>]*hidden/);
assert.match(markup, /id="schedule-view-result-prev"/);
assert.match(markup, /id="schedule-view-result-next"/);
assert.match(markup, /id="schedule-view-active-label"[^>]*>Нет выбранного расписания</);
assert.match(markup, /schedule-view-empty-result/);
assert.match(markup, /Ничего не найдено/);
assert.match(markup, /id="schedule-view-table-title"[^>]*>Расписание</);
assert.match(markup, /id="schedule-view-table-caption"[^>]*>Выберите параметры и нажмите «Показать»</);
assert.match(markup, /id="schedule-view-day-tabs"[^>]*hidden/);
assert.match(markup, /id="schedule-view-tables"/);
assert.match(markup, /loading-row schedule-view-state/);
// Панель разовых правок: React-состояние вкладки, drawer всегда в разметке
assert.match(markup, /id="schedule-override-drawer"[^>]*hidden/);
assert.match(markup, /id="schedule-override-form"[^>]*hidden/);
assert.match(markup, /id="schedule-override-save"/);
assert.match(markup, /id="schedule-override-registry"/);
assert.match(markup, /Конкретное занятие/);
assert.doesNotMatch(markup, /style="/);
});
test('payload правила расписания валидируется как в legacy-конструкторе', () => {
const lessonTypes = [
{ id: 1, name: 'Лекция' },
{ id: 2, name: 'Лабораторная работа' }
];
const subgroups = [
{ id: 11, groupId: 5, name: '1 подгруппа', groupName: 'Группа А' },
{ id: 12, groupId: 5, name: '2 подгруппа', groupName: 'Группа А' },
{ id: 13, groupId: 6, name: '1 подгруппа', groupName: 'Группа Б' }
];
const slot = {
dayOfWeek: 2,
parity: 'BOTH',
timeSlotId: 3,
teacherId: 7,
classroomId: 9,
lessonTypeId: 1,
lessonFormat: 'Очно'
};
const payloads = formSlotsToPayloads([slot], { lessonTypes, subgroups, selectedGroupIds: [5, 6] });
assert.deepEqual(payloads, [{
id: null,
dayOfWeek: 2,
parity: 'BOTH',
timeSlotId: 3,
subgroupId: null,
subgroupIds: [],
teacherId: 7,
classroomId: 9,
lessonTypeId: 1,
lessonFormat: 'Очно'
}]);
const typeLimits = {
lectureAcademicHours: 2,
lectureStartWeek: 1,
laboratoryAcademicHours: 0,
laboratoryStartWeek: 1,
practiceAcademicHours: 0,
practiceStartWeek: 1
};
const payload = buildRulePayloadFromForm({
ruleId: 42,
subjectId: 3,
semesterId: 10,
versionId: 2,
typeLimits,
groupIds: [5, 6],
slots: payloads,
lessonTypes
});
assert.deepEqual(payload, {
id: 42,
subjectId: 3,
semesterId: 10,
scheduleVersionId: 2,
...typeLimits,
groupIds: [5, 6],
slots: payloads
});
assert.throws(
() => formSlotsToPayloads([{ dayOfWeek: 2 }], { lessonTypes, subgroups, selectedGroupIds: [5] }),
/Заполните слот 1/
);
assert.throws(
() => formSlotsToPayloads(
[{ ...slot, subgroupIds: [11] }],
{ lessonTypes, subgroups, selectedGroupIds: [5, 6] }
),
/подгруппа доступна только для лабораторных/
);
assert.throws(
() => formSlotsToPayloads(
[{ ...slot, lessonTypeId: 2, subgroupIds: [11, 12] }],
{ lessonTypes, subgroups, selectedGroupIds: [5, 6] }
),
/не больше одной подгруппы каждой группы/
);
assert.throws(
() => formSlotsToPayloads(
[{ ...slot, lessonTypeId: 2, subgroupIds: [13] }],
{ lessonTypes, subgroups, selectedGroupIds: [5] }
),
/подгруппа должна относиться к выбранным группам правила/
);
assert.throws(
() => validateTypeLimits(typeLimits, [], lessonTypes),
/Для типа "Лекции" добавьте хотя бы один слот/
);
assert.throws(
() => validateTypeLimits({ ...typeLimits, lectureAcademicHours: 0 }, [], lessonTypes),
/Укажите часы хотя бы для одного типа занятий/
);
assert.throws(
() => buildRulePayloadFromRule({}, []),
/Конфликтующее правило не найдено/
);
const conflictPayload = buildRulePayloadFromRule({
id: 9,
subjectId: 3,
semesterId: 10,
lectureAcademicHours: 4,
groupIds: [5]
}, payloads, 77);
assert.equal(conflictPayload.id, 9);
assert.equal(conflictPayload.scheduleVersionId, 77);
assert.equal(conflictPayload.lectureStartWeek, 1);
});
test('вкладка конструктора правил рендерит каскад периода, форму правила и визуальную матрицу без inline-стилей', () => {
const markup = renderScheduleTab();
assert.match(markup, /id="schedule-initial-state"/);
assert.match(markup, /id="schedule-initial-content"/);
assert.match(markup, /id="schedule-version-context"/);
assert.match(markup, /id="schedule-version-year"/);
assert.match(markup, /id="schedule-version-semester"/);
assert.match(markup, /id="schedule-version-select"/);
assert.match(markup, /id="schedule-version-create-draft"/);
assert.match(markup, /id="schedule-version-manage"/);
assert.match(markup, /Загрузка годов…/);
assert.match(markup, /id="schedule-rule-form"/);
assert.match(markup, /id="schedule-rule-subject"/);
assert.match(markup, /id="schedule-rule-semester"/);
assert.match(markup, /id="schedule-rule-groups"/);
assert.match(markup, /id="schedule-slot-add"/);
assert.match(markup, /id="schedule-rule-slots"/);
assert.match(markup, /id="schedule-rule-alert"/);
assert.match(markup, /id="schedule-rules-search"/);
assert.match(markup, /id="schedule-table"/);
assert.match(markup, /id="schedule-tbody"/);
assert.match(markup, /id="schedule-rules-pagination"/);
assert.match(markup, /id="schedule-visual-toggle"/);
assert.match(markup, /id="schedule-visual-backdrop"[^>]*hidden/);
assert.match(markup, /id="schedule-visual-drawer"[^>]*hidden/);
// Модалки закрыты в статическом рендере и не попадают в разметку
assert.doesNotMatch(markup, /id="schedule-draft-modal"/);
assert.doesNotMatch(markup, /id="schedule-conflict-modal"/);
assert.doesNotMatch(markup, /id="schedule-move-slot-modal"/);
// Контекстное меню визуального расписания всегда в DOM и скрыто
assert.match(markup, /id="schedule-visual-context-menu"[^>]*hidden/);
assert.doesNotMatch(markup, /style="/);
});
test('контракты API контура публикаций сохранены', async () => {
const client = recordingClient(async (method, endpoint, json) => ({ id: 1, name: 'Черновик', json }));
await fetchScheduleSemesters(client);
assert.equal(client.calls[0].method, 'GET');
assert.equal(client.calls[0].endpoint, '/api/schedule/semesters');
await fetchScheduleVersions(11, client);
assert.equal(client.calls[1].endpoint, '/api/edu-office/schedule/versions?semesterId=11');
await fetchScheduleVersionHistory(11, client);
assert.equal(client.calls[2].endpoint, '/api/edu-office/schedule/versions/history?semesterId=11');
await fetchScheduleVersionDiff(7, client);
assert.equal(client.calls[3].endpoint, '/api/edu-office/schedule/versions/7/diff');
await fetchScheduleVersionValidation(7, client);
assert.equal(client.calls[4].endpoint, '/api/edu-office/schedule/versions/7/validate');
await createScheduleDraftVersion({ semesterId: 11, name: 'Черновик', basedOnVersionId: 3 }, client);
assert.equal(client.calls[5].method, 'POST');
assert.equal(client.calls[5].endpoint, '/api/edu-office/schedule/versions');
assert.deepEqual(client.calls[5].json, { semesterId: 11, name: 'Черновик', basedOnVersionId: 3 });
await runScheduleVersionAction(7, 'publish', 'Причина', client);
assert.equal(client.calls[6].endpoint, '/api/edu-office/schedule/versions/7/publish');
assert.deepEqual(client.calls[6].json, { reason: 'Причина' });
await runScheduleVersionAction(9, 'restore', 'Возврат', client);
assert.equal(client.calls[7].endpoint, '/api/edu-office/schedule/versions/9/restore');
assert.deepEqual(client.calls[7].json, { reason: 'Возврат' });
assert.equal(client.calls.length, 8);
client.calls.forEach(call => assert.ok(call.options.errorMessage));
});
test('контур публикаций группирует версии и переводит действия на русский язык', () => {
const versions = [
{ id: 1, status: 'DRAFT', versionNumber: 2, basedOnVersionId: 2, name: 'Правка пар' },
{ id: 2, status: 'PUBLISHED', versionNumber: 1, name: 'Публикация' },
{ id: 3, status: 'ARCHIVED', versionNumber: 0, name: 'Старая' },
{ id: 4, status: 'DRAFT', versionNumber: 3, name: 'Новая правка' }
];
const split = splitScheduleVersions(versions);
assert.equal(split.published.id, 2);
assert.deepEqual(split.drafts.map(version => version.id), [1, 4]);
assert.deepEqual(split.archive.map(version => version.id), [3]);
assert.equal(historyActionLabel('CREATED'), 'Создан черновик');
assert.equal(historyActionLabel('PUBLISHED'), 'Опубликовано');
assert.equal(historyActionLabel('ARCHIVED'), 'Архивировано');
assert.equal(historyActionLabel('RESTORED'), 'Восстановлено');
assert.equal(historyActionLabel('UNKNOWN'), 'UNKNOWN');
assert.equal(ruleChangeTypeLabel('ADDED'), 'Добавлено');
assert.equal(ruleChangeTypeLabel('REMOVED'), 'Удалено');
assert.equal(ruleChangeTypeLabel('CHANGED'), 'Изменено');
assert.equal(draftBasisText(versions[0]), 'Основа: версия #2');
assert.equal(draftBasisText({ basedOnVersionId: null }), 'Основа: пустое расписание');
assert.equal(versionSemesterLabel({
academicYearTitle: '2025/2026',
semesterType: 'autumn'
}), '2025/2026 · Осенний семестр');
assert.equal(versionSemesterLabel({
academicYearTitle: '2025/2026',
semesterType: 'spring'
}), '2025/2026 · Весенний семестр');
assert.equal(formatVersionDateTime(''), 'дата не указана');
assert.equal(formatVersionDateTime(null), 'дата не указана');
assert.match(formatVersionDateTime('2026-09-01T10:30:00'), /^\d{2}\.\d{2}\.\d{4}/);
assert.equal(
publishValidationSummary(
{ valid: true, ruleCount: 12, slotCount: 30 },
{ addedLessonCount: 4, changedLessonCount: 2, removedLessonCount: 1 }
),
'Проверка пройдена: 12 правил, 30 слотов. Занятия: +4, ~2, −1.'
);
assert.equal(
publishValidationSummary({ valid: false, issues: ['Конфликт аудитории 101', 'Нет слотов'] }, {}),
'Публикация заблокирована: Конфликт аудитории 101; Нет слотов'
);
});
test('вкладка контура публикаций рендерит селекты периода, панели и статус без inline-стилей', () => {
const markup = renderScheduleVersionsTab();
assert.match(markup, /version-studio/);
assert.match(markup, /Контур публикации/);
assert.match(markup, /Расписание меняется за кулисами/);
assert.match(markup, /id="version-year"/);
assert.match(markup, /Загрузка годов…/);
assert.match(markup, /id="version-semester"/);
assert.match(markup, /Сначала выберите год/);
assert.match(markup, /id="version-create"[^>]*>Новый черновик/);
assert.match(markup, /id="version-status"[^>]*>Загружаем контур версий…</);
assert.match(markup, /id="version-current"/);
assert.match(markup, /id="version-draft-count"[^>]*>0</);
assert.match(markup, /id="version-drafts"/);
assert.match(markup, /Черновики/);
assert.match(markup, /id="version-diff"/);
assert.match(markup, /Сравнение версий/);
assert.match(markup, /Архив публикаций/);
assert.match(markup, /id="version-archive"/);
assert.match(markup, /Журнал действий/);
assert.match(markup, /id="version-history"/);
// До первой загрузки панели пусты, как в legacy HTML
assert.doesNotMatch(markup, /Нет активных черновиков/);
assert.doesNotMatch(markup, /Опубликованной версии нет/);
assert.doesNotMatch(markup, /version-modal/);
assert.doesNotMatch(markup, /style="/);
});
test('контракты API анализа качества сохранены', async () => {
const client = recordingClient(async () => ({ score: 90, problems: [] }));
assert.equal(
scheduleQualityEndpoint(11, 7),
'/api/edu-office/schedule/quality?semesterId=11&versionId=7'
);
await fetchScheduleQualityAnalysis(11, 7, client);
assert.equal(client.calls[0].method, 'GET');
assert.equal(client.calls[0].endpoint, '/api/edu-office/schedule/quality?semesterId=11&versionId=7');
assert.ok(client.calls[0].options.errorMessage);
});
test('анализ качества переводит статусы, семестры и проблемы на русский язык', () => {
assert.equal(qualityBand(94).code, 'excellent');
assert.equal(qualityBand(80).code, 'stable');
assert.equal(qualityBand(60).code, 'attention');
assert.equal(qualityBand(40).code, 'critical');
assert.equal(versionStatusLabel('DRAFT'), 'Черновик');
assert.equal(versionStatusLabel('PUBLISHED'), 'Опубликована');
assert.equal(versionStatusLabel('ARCHIVED'), 'Архив');
assert.equal(versionStatusLabel('UNKNOWN'), 'Версия');
assert.equal(severityLabel('CRITICAL'), 'Критично');
assert.equal(severityLabel('LOW'), 'Низкая');
assert.equal(qualitySemesterLabel({
academicYearTitle: '2025/2026',
semesterType: 'autumn',
startDate: '2026-09-01',
endDate: '2026-12-31'
}), '2025/2026 · Осенний · 01.09.2026—31.12.2026');
assert.equal(formatQualityDate('2026-09-01'), '01.09.2026');
assert.equal(formatQualityDate(''), 'дата не указана');
assert.equal(lessonWord(1), 'занятие');
assert.equal(lessonWord(3), 'занятия');
assert.equal(lessonWord(11), 'занятий');
assert.equal(
problemTotalLabel({ problemsTruncated: true, problems: [1, 2, 3], totalProblemCount: 12 }),
'Показаны первые 3 из 12 правил'
);
assert.equal(
problemTotalLabel({ problemsTruncated: false, problems: [1], totalProblemCount: 1 }),
'Правил с проблемами: 1'
);
});
test('вкладка анализа качества рендерит каскад периода и скрытые результаты', () => {
const markup = renderScheduleQualityTab();
assert.match(markup, /quality-lab/);
assert.match(markup, /Качество видно до перестановок/);
assert.match(markup, /Диагностика · публикация или черновик/);
assert.match(markup, /id="quality-year"/);
assert.match(markup, /Загрузка годов…/);
assert.match(markup, /id="quality-semester"/);
assert.match(markup, /Сначала выберите год/);
assert.match(markup, /id="quality-version"/);
assert.match(markup, /Сначала выберите семестр/);
assert.match(markup, /id="quality-run"[^>]*>Выполнить анализ</);
assert.match(markup, /id="quality-status"[^>]*>Выберите семестр, чтобы получить объяснимую оценку\.</);
assert.match(markup, /id="quality-results"[^>]*hidden/);
// До первого анализа результаты скрыты, как в legacy
assert.doesNotMatch(markup, /Правила, снижающие оценку/);
assert.doesNotMatch(markup, /style="/);
});
test('контракты API отсутствий преподавателей сохранены', async () => {
const client = recordingClient(async (method, endpoint, json) => ({ id: 1, json }));
await fetchTeacherAbsences('PENDING', client);
assert.equal(client.calls[0].endpoint, '/api/teacher-absences?status=PENDING');
await fetchTeacherAbsences('', client);
assert.equal(client.calls[1].endpoint, '/api/teacher-absences');
await createTeacherAbsence({ teacherId: 7, startDate: '2026-09-01', endDate: '2026-09-02', reason: 'Болезнь' }, client);
assert.equal(client.calls[2].method, 'POST');
assert.equal(client.calls[2].endpoint, '/api/teacher-absences');
assert.deepEqual(client.calls[2].json, { teacherId: 7, startDate: '2026-09-01', endDate: '2026-09-02', reason: 'Болезнь' });
await reviewTeacherAbsence(5, { approved: true, comment: 'Подтверждено' }, client);
assert.equal(client.calls[3].endpoint, '/api/teacher-absences/5/review');
await deleteTeacherAbsence(5, client);
assert.equal(client.calls[4].method, 'DELETE');
assert.equal(client.calls[4].endpoint, '/api/teacher-absences/5');
await fetchTeacherAbsenceWizard(5, client);
assert.equal(client.calls[5].endpoint, '/api/teacher-absences/5/wizard');
await resolveTeacherAbsence(5, [{ baseRuleSlotId: 3, lessonDate: '2026-09-01', resolution: 'CANCEL' }], client);
assert.equal(client.calls[6].endpoint, '/api/teacher-absences/5/resolve');
assert.deepEqual(client.calls[6].json, {
decisions: [{ baseRuleSlotId: 3, lessonDate: '2026-09-01', resolution: 'CANCEL' }]
});
await fetchTeacherPreferenceMeta(client);
assert.equal(client.calls[7].endpoint, '/api/teacher-preferences/meta');
assert.equal(teacherPreferencesEndpoint({ semesterId: 10, status: 'PENDING' }), '/api/teacher-preferences?semesterId=10&status=PENDING');
assert.equal(teacherPreferencesEndpoint({}), '/api/teacher-preferences');
await fetchTeacherPreferences({ semesterId: 10, status: 'PENDING' }, client);
assert.equal(client.calls[8].endpoint, '/api/teacher-preferences?semesterId=10&status=PENDING');
await reviewTeacherPreference(3, { approved: false, comment: 'Нет' }, client);
assert.equal(client.calls[9].endpoint, '/api/teacher-preferences/3/review');
assert.equal(teacherChangeRequestsEndpoint('PENDING'), '/api/teacher-change-requests?status=PENDING');
assert.equal(teacherChangeRequestsEndpoint(''), '/api/teacher-change-requests');
await fetchTeacherChangeRequests('PENDING', client);
assert.equal(client.calls[10].endpoint, '/api/teacher-change-requests?status=PENDING');
await reviewTeacherChangeRequest(4, { approved: true, comment: '' }, client);
assert.equal(client.calls[11].endpoint, '/api/teacher-change-requests/4/review');
await fetchAbsenceTeacherOptions({ query: 'Иванов', selectedId: '', role: '', departmentId: '' }, client);
assert.equal(client.calls[12].endpoint, '/api/users/teachers/options?query=%D0%98%D0%B2%D0%B0%D0%BD%D0%BE%D0%B2&limit=30');
assert.equal(client.calls.length, 13);
client.calls.forEach(call => assert.ok(call.options.errorMessage));
});
test('реестр отсутствий переводит статусы, решения и даты на русский язык', () => {
assert.equal(absenceStatusLabel('PENDING'), 'На подтверждении');
assert.equal(absenceStatusLabel('APPROVED'), 'Требует решения');
assert.equal(absenceStatusLabel('RESOLVED'), 'Разрешено');
assert.equal(absenceStatusLabel('UNKNOWN'), 'UNKNOWN');
assert.equal(statusClass('PENDING'), 'is-pending');
assert.equal(statusClass(null), 'is-');
assert.equal(genericStatusLabel('PENDING'), 'На рассмотрении');
assert.equal(genericStatusLabel('APPROVED'), 'Согласовано');
assert.equal(decisionStatusLabel('APPLIED'), 'Применено');
assert.equal(decisionStatusLabel('REJECTED'), 'Отклонено');
assert.equal(decisionCountLabel(1), '1 решение');
assert.equal(decisionCountLabel(3), '3 решения');
assert.equal(decisionCountLabel(12), '12 решений');
assert.equal(resolutionLabel('REPLACE_TEACHER'), 'Замена преподавателя');
assert.equal(resolutionLabel('REJECT'), 'Предложение отклонено');
assert.equal(changeTypeLabel('MOVE'), 'Перенос даты или времени');
assert.equal(changeTypeLabel('CANCEL'), 'Отмена занятия');
assert.equal(preferenceDescription({ preferenceType: 'HARD_UNAVAILABLE', preferenceDate: '2026-09-01' }), 'Строгая недоступность: 01.09.2026');
assert.equal(preferenceDescription({ preferenceType: 'NO_GAPS' }), 'Пожелание «без окон»');
assert.equal(absenceSemesterLabel({
academicYearTitle: '2025/2026',
semesterType: 'AUTUMN',
startDate: '2026-09-01',
endDate: '2026-12-31'
}), '2025/2026 · осенний · 01.09.2026–31.12.2026');
assert.equal(formatAbsenceDate('2026-09-01'), '01.09.2026');
assert.equal(formatAbsenceDate(''), '—');
assert.equal(dayOfMonth('2026-09-07'), '07');
assert.equal(dayOfMonth(''), '—');
assert.equal(monthShort('2026-09-07'), 'сен');
assert.equal(trimTime('10:40:00'), '10:40');
assert.deepEqual(absenceStats([
{ status: 'APPROVED' },
{ status: 'PENDING' },
{ status: 'PENDING' },
{ status: 'RESOLVED' },
{ status: 'REJECTED' }
]), { active: 1, pending: 2, resolved: 1 });
assert.equal(
changeReviewTarget({
requestType: 'MOVE',
targetLessonDate: '2026-09-22',
requestedStartTime: '10:40:00',
requestedEndTime: '12:10:00'
}),
'22.09.2026 · 10:40–12:10'
);
assert.equal(changeReviewTarget({ requestType: 'CHANGE_CLASSROOM', requestedClassroomName: 'А-201' }), 'А-201');
assert.equal(changeReviewTarget({ requestType: 'CANCEL' }), 'Отмена занятия');
});
test('вкладка отсутствий рендерит форму, реестр и панели согласования', () => {
const markup = renderTeacherAbsencesTab();
assert.match(markup, /absence-command-card/);
assert.match(markup, /От регистрации инцидента до готовой замены/);
assert.match(markup, /id="absence-stat-active"[^>]*>0</);
assert.match(markup, /id="absence-stat-pending"[^>]*>0</);
assert.match(markup, /id="absence-stat-resolved"[^>]*>0</);
assert.match(markup, /id="teacher-absence-form"/);
assert.match(markup, /Зарегистрировать отсутствие/);
assert.match(markup, /placeholder="Найдите преподавателя по ФИО"/);
assert.match(markup, /id="teacher-absence-start"/);
assert.match(markup, /id="teacher-absence-end"/);
assert.match(markup, /id="teacher-absence-reason"/);
assert.match(markup, /id="teacher-absence-status-filter"/);
assert.match(markup, /Все статусы/);
assert.match(markup, /id="teacher-absence-registry"/);
assert.match(markup, /Загрузка\.\.\./);
assert.match(markup, /id="teacher-absence-refresh"/);
assert.match(markup, /Пожелания на семестр/);
assert.match(markup, /id="teacher-preference-review-list"/);
assert.match(markup, /id="teacher-preference-pending-count"[^>]*>0</);
assert.match(markup, /Заявки на изменение/);
assert.match(markup, /id="teacher-change-review-list"/);
assert.match(markup, /id="teacher-change-pending-count"[^>]*>0</);
// До инициализации списки согласования пусты, мастер замены закрыт
assert.doesNotMatch(markup, /absence-wizard-overlay/);
assert.doesNotMatch(markup, /style="/);
});