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