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, /