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