import assert from 'node:assert/strict'; import test from 'node:test'; import { absenceStatusClass, absenceStatusLabel, activeDatePreferences, activeWeekPreferences, buildChangeRequestPayload, buildDesktopWeekModel, buildTeacherLessonChips, candidateOptions, canRequestChange, changeRequestTarget, changeTypeLabel, collectSlots, createAbsence, createPreference, deletePreference, fetchAbsences, fetchChangeRequestCandidates, fetchChangeRequests, fetchPreferenceMeta, fetchPreferences, fetchTeacherSchedule, findCompactPreference, findWeekPreference, formatInstant, lessonsByDate, pendingCount, preferenceStatusLabel, preferenceTypeClass, preferenceTypeLabel, preferenceTypeSymbol, resolveCurrentSemester, semesterLabel, validateAbsence } from '../react/teacher/teacher-service.js'; import { renderAbsencePanel, renderPreferencesTab, renderRequestDialog, renderRequestsTab, renderTeacherWeekGrid, weekStartOf } from '../dist/tests/react-teacher-renderer.mjs'; const LESSONS = [ { classroomName: '301', date: '2026-09-28', endTime: '10:10', groupNames: ['ИС-41', 'ИС-42'], lessonTypeName: 'Лекция', scheduleRuleSlotId: 11, startTime: '08:30', subjectName: 'Базы данных', subgroupNames: ['1 подгруппа'], timeSlotOrder: 1 }, { classroomName: '205', date: '2026-09-29', endTime: '13:30', groupNames: ['ИС-41'], lessonTypeName: 'Практика', scheduleRuleSlotId: 12, startTime: '12:00', subjectName: 'Тестирование', subgroupName: '2 подгруппа', timeSlotOrder: 2 } ]; test('слоты недели собираются без дубликатов и сортируются по порядку', () => { const slots = collectSlots([ { timeSlotOrder: 2, startTime: '12:00', endTime: '13:30' }, { timeSlotOrder: 1, startTime: '08:30', endTime: '10:10' }, { timeSlotOrder: 1, startTime: '08:30', endTime: '10:10' } ]); assert.deepEqual(slots, [ { order: 1, label: '08:30 - 10:10' }, { order: 2, label: '12:00 - 13:30' } ]); assert.deepEqual(collectSlots([]), [{ order: 1, label: 'Расписание' }]); }); test('модель недели преподавателя строит дни и матрицу занятий', () => { const weekStart = weekStartOf('2026-09-30'); const model = buildDesktopWeekModel(weekStart, LESSONS); assert.equal(model.days.length, 7); assert.equal(model.days[0].isoDate, '2026-09-28'); assert.equal(model.days[6].isoDate, '2026-10-04'); assert.equal(model.slots.length, 2); assert.equal(model.lessonsByKey.get('2026-09-28:1').subjectName, 'Базы данных'); assert.equal(model.lessonsByKey.get('2026-09-29:2').subjectName, 'Тестирование'); assert.equal(model.lessonsByKey.get('2026-09-30:1'), undefined); }); test('занятия преподавателя группируются по датам', () => { const byDate = lessonsByDate(LESSONS); assert.equal(byDate.get('2026-09-28').length, 1); assert.equal(byDate.get('2026-09-29').length, 1); assert.equal(byDate.get('2026-10-01'), undefined); }); test('чипы занятия преподавателя начинаются с времени и не содержат имени преподавателя', () => { const chips = buildTeacherLessonChips(LESSONS[0]); assert.deepEqual(chips, ['08:30 - 10:10', 'Лекция', '1 подгруппа', '301', 'ИС-41, ИС-42']); assert.ok(!chips.includes('Иванов И. И.')); }); test('заявку можно создать только для занятия из правила без ручного изменения', () => { assert.equal(canRequestChange(LESSONS[0]), true); assert.equal(canRequestChange({ ...LESSONS[0], scheduleOverrideId: 7 }), false); assert.equal(canRequestChange({ ...LESSONS[0], scheduleRuleSlotId: null }), false); }); test('статусы отсутствия переводятся на русский с классами-модификаторами', () => { assert.equal(absenceStatusLabel('PENDING'), 'На подтверждении'); assert.equal(absenceStatusLabel('APPROVED'), 'Согласовано'); assert.equal(absenceStatusLabel('RESOLVED'), 'Замены выполнены'); assert.equal(absenceStatusLabel(''), '—'); assert.equal(absenceStatusClass('PENDING'), 'is-pending'); assert.equal(absenceStatusClass('APPROVED'), 'is-approved'); }); test('типы пожеланий переводятся на русский с символами и классами', () => { assert.equal(preferenceTypeLabel('HARD_UNAVAILABLE'), 'Строго недоступен'); assert.equal(preferenceTypeLabel('SOFT_PREFERRED'), 'Предпочтительно'); assert.equal(preferenceTypeLabel('CONSECUTIVE'), 'Пары подряд'); assert.equal(preferenceTypeClass('HARD_UNAVAILABLE'), 'is-hard'); assert.equal(preferenceTypeClass('CONSECUTIVE'), ''); assert.equal(preferenceTypeSymbol('HARD_UNAVAILABLE'), '×'); assert.equal(preferenceTypeSymbol('SOFT_PREFERRED'), '✓'); assert.equal(preferenceTypeSymbol('SOFT_UNWANTED'), '!'); assert.equal(preferenceStatusLabel('PENDING'), 'На рассмотрении'); assert.equal(preferenceStatusLabel('APPROVED'), 'Согласовано'); }); test('цель заявки форматируется по типу изменения', () => { assert.equal(changeTypeLabel('MOVE'), 'Перенос'); assert.equal(changeTypeLabel('CHANGE_CLASSROOM'), 'Смена аудитории'); assert.equal(changeTypeLabel('CANCEL'), 'Отмена'); const move = { requestType: 'MOVE', lessonDate: '2026-09-28', targetLessonDate: '2026-10-01', requestedStartTime: '12:00', requestedEndTime: '13:30' }; assert.equal(changeRequestTarget(move), '01.10.2026, 12:00–13:30'); assert.equal( changeRequestTarget({ requestType: 'CHANGE_CLASSROOM', requestedClassroomName: '305' }), '305' ); assert.equal(changeRequestTarget({ requestType: 'CANCEL' }), 'Отмена занятия'); }); test('метка семестра содержит год, тип и границы', () => { const label = semesterLabel({ academicYearTitle: '2026/2027', endDate: '2027-01-31', semesterType: 'AUTUMN', startDate: '2026-09-01' }); assert.equal(label, '2026/2027 · осенний · 01.09.2026–31.01.2027'); assert.equal( resolveCurrentSemester( [{ id: 1, startDate: '2026-09-01', endDate: '2026-12-31' }], '2026-09-28' ).id, 1 ); assert.equal(resolveCurrentSemester([], '2026-09-28'), null); }); test('активные пожелания фильтруются по статусу и типу', () => { const preferences = [ { id: 1, status: 'PENDING', dayOfWeek: 1, timeSlotId: 2, preferenceType: 'SOFT_PREFERRED' }, { id: 2, status: 'APPROVED', dayOfWeek: 3, timeSlotId: 2, preferenceType: 'HARD_UNAVAILABLE' }, { id: 3, status: 'REJECTED', dayOfWeek: 5, timeSlotId: 2, preferenceType: 'SOFT_UNWANTED' }, { id: 4, status: 'CANCELLED', dayOfWeek: 2, timeSlotId: 2, preferenceType: 'SOFT_PREFERRED' }, { id: 5, status: 'PENDING', preferenceDate: '2026-11-04', preferenceType: 'HARD_UNAVAILABLE' }, { id: 6, status: 'APPROVED', preferenceType: 'CONSECUTIVE' } ]; assert.deepEqual(activeWeekPreferences(preferences).map(item => item.id), [1, 2]); assert.deepEqual(activeDatePreferences(preferences).map(item => item.id), [5]); assert.equal(findCompactPreference(preferences, 'CONSECUTIVE').id, 6); assert.equal(findCompactPreference(preferences, 'NO_GAPS'), null); assert.equal(findWeekPreference(preferences, 1, 2).id, 1); assert.equal(findWeekPreference(preferences, 5, 2), null); assert.equal(pendingCount(preferences), 2); }); test('валидация отсутствия проверяет заполненность и порядок дат', () => { assert.equal(validateAbsence({}), 'Заполните период и причину отсутствия'); assert.equal( validateAbsence({ startDate: '2026-10-05', endDate: '2026-10-01', reason: ' ' }), 'Заполните период и причину отсутствия' ); assert.equal( validateAbsence({ startDate: '2026-10-05', endDate: '2026-10-01', reason: 'Командировка' }), 'Дата окончания не может быть раньше даты начала' ); assert.equal(validateAbsence({ startDate: '2026-10-01', endDate: '2026-10-05', reason: 'Командировка' }), ''); }); test('payload заявки на изменение зависит от типа', () => { const lesson = { date: '2026-09-28', scheduleRuleSlotId: 11 }; assert.deepEqual( buildChangeRequestPayload({ classroomId: '', lesson, reason: ' Болезнь ', requestType: 'MOVE', targetDate: '2026-10-01', timeSlotId: '3' }), { baseRuleSlotId: 11, lessonDate: '2026-09-28', requestType: 'MOVE', requestedClassroomId: null, requestedTimeSlotId: 3, targetLessonDate: '2026-10-01', reason: 'Болезнь' } ); assert.deepEqual( buildChangeRequestPayload({ classroomId: '9', lesson, reason: 'Ремонт', requestType: 'CHANGE_CLASSROOM' }), { baseRuleSlotId: 11, lessonDate: '2026-09-28', requestType: 'CHANGE_CLASSROOM', requestedClassroomId: 9, requestedTimeSlotId: null, targetLessonDate: null, reason: 'Ремонт' } ); assert.deepEqual( buildChangeRequestPayload({ lesson, reason: 'Отмена', requestType: 'CANCEL' }), { baseRuleSlotId: 11, lessonDate: '2026-09-28', requestType: 'CANCEL', requestedClassroomId: null, requestedTimeSlotId: null, targetLessonDate: null, reason: 'Отмена' } ); }); test('варианты кандидатов отдают только доступные с запасным текстом', () => { const items = [ { id: 1, label: '08:30 - 10:10', available: true }, { id: 2, label: '10:20 - 12:00', available: false } ]; assert.deepEqual(candidateOptions(items, 'Нет слотов'), [items[0]]); assert.deepEqual( candidateOptions([{ id: 2, available: false }], 'Нет свободных аудиторий'), [{ id: '', label: 'Нет свободных аудиторий' }] ); }); test('сервис преподавателя сохраняет контракты API', async () => { const calls = []; const client = { get: async (endpoint, options) => { calls.push({ endpoint, options }); return []; }, post: async (endpoint, body, options) => { calls.push({ endpoint, body, options }); return {}; }, delete: async (endpoint, options) => { calls.push({ endpoint, options }); return {}; } }; await fetchTeacherSchedule({ teacherId: 5, startDate: '2026-09-28', endDate: '2026-10-04' }, client); await fetchPreferenceMeta(client); await fetchPreferences(3, client); await createPreference({ semesterId: 3, preferenceType: 'NO_GAPS' }, client); await deletePreference(7, client); await fetchChangeRequests(client); await fetchChangeRequestCandidates({ baseRuleSlotId: 11, lessonDate: '2026-09-28', targetDate: '2026-10-01' }, client); await fetchAbsences(client); await createAbsence({ startDate: '2026-10-01', endDate: '2026-10-02', reason: 'Конференция' }, client); assert.equal(calls[0].endpoint, '/api/schedule?teacherId=5&startDate=2026-09-28&endDate=2026-10-04'); assert.equal(calls[1].endpoint, '/api/teacher-preferences/meta'); assert.equal(calls[2].endpoint, '/api/teacher-preferences?semesterId=3'); assert.equal(calls[3].endpoint, '/api/teacher-preferences'); assert.deepEqual(calls[3].body, { semesterId: 3, preferenceType: 'NO_GAPS' }); assert.equal(calls[4].endpoint, '/api/teacher-preferences/7'); assert.equal(calls[5].endpoint, '/api/teacher-change-requests'); assert.equal( calls[6].endpoint, '/api/teacher-change-requests/candidates?baseRuleSlotId=11&lessonDate=2026-09-28&targetDate=2026-10-01' ); assert.equal(calls[7].endpoint, '/api/teacher-absences'); assert.equal(calls[8].endpoint, '/api/teacher-absences'); assert.deepEqual(calls[8].body, { startDate: '2026-10-01', endDate: '2026-10-02', reason: 'Конференция' }); assert.ok(calls.every(call => call.options && call.options.errorMessage)); }); test('сетка недели преподавателя рендерит матрицу с заявкой на изменение', () => { const markup = renderTeacherWeekGrid({ lessons: LESSONS, onRequestLesson: () => {}, weekStart: weekStartOf('2026-09-30') }); assert.match(markup, /Понедельник/); assert.match(markup, /Базы данных/); assert.match(markup, /08:30 - 10:10/); assert.match(markup, /Запросить изменение/); assert.match(markup, /Нет занятия/); assert.match(markup, /aria-live="polite"/); }); test('сетка недели без занятий показывает пустую неделю', () => { const markup = renderTeacherWeekGrid({ emptyMessage: 'Ошибка загрузки', lessons: [], onRequestLesson: () => {}, weekStart: weekStartOf('2026-09-30') }); assert.match(markup, /Неделя/); assert.match(markup, /Ошибка загрузки/); assert.doesNotMatch(markup, /Запросить изменение/); }); test('панель отсутствия рендерит скрытую форму и состояние загрузки', () => { const markup = renderAbsencePanel(); assert.match(markup, /Сообщить об отсутствии/); assert.match(markup, /Новая заявка/); assert.match(markup, /hidden/); assert.match(markup, /Загрузка заявок\.\.\./); assert.match(markup, /aria-expanded="false"/); }); test('вкладка пожеланий без настроенной сетки показывает пустое состояние', () => { const markup = renderPreferencesTab(); assert.match(markup, /Недельный шаблон/); assert.match(markup, /Режим отметки/); assert.match(markup, /Базовая сетка времени не настроена/); assert.match(markup, /preference-grid/); }); test('журнал заявок рендерит записи с целью изменения и историей', () => { const markup = renderRequestsTab({ items: [{ groupNames: ['ИС-41'], history: [{ comment: 'Принято в работу', createdAt: '2026-09-28T10:00:00', status: 'PENDING' }], id: 1, lessonDate: '2026-09-28', reason: 'Болезнь', requestType: 'MOVE', requestedStartTime: '12:00', requestedEndTime: '13:30', sourceClassroomName: '301', sourceEndTime: '10:10', sourceStartTime: '08:30', status: 'PENDING', subjectName: 'Базы данных', targetLessonDate: '2026-10-01' }], onCancel: () => {}, onRefresh: () => {}, stateKind: 'ready' }); assert.match(markup, /Базы данных/); assert.match(markup, /Перенос/); assert.match(markup, /01\.10\.2026, 12:00–13:30/); assert.match(markup, /На рассмотрении/); assert.match(markup, /Отозвать/); assert.match(markup, /История решения · 1/); }); test('журнал заявок показывает пустое и ошибочное состояния', () => { const empty = renderRequestsTab({ items: [], onCancel: () => {}, onRefresh: () => {}, stateKind: 'ready' }); const error = renderRequestsTab({ items: [], onCancel: () => {}, onRefresh: () => {}, stateKind: 'error' }); assert.match(empty, /Заявок на изменение пока нет/); assert.match(error, /Не удалось загрузить заявки/); }); test('диалог заявки рендерит типы изменения и поля обоснования', () => { const markup = renderRequestDialog({ lesson: { classroomName: '301', date: '2026-09-28', endTime: '10:10', scheduleRuleSlotId: 11, startTime: '08:30', subjectName: 'Базы данных' }, onClose: () => {}, onSubmitted: async () => true }); assert.match(markup, /Заявка на изменение/); assert.match(markup, /Перенести дату или время/); assert.match(markup, /Сменить аудиторию/); assert.match(markup, /Отменить занятие/); assert.match(markup, /Обоснование/); assert.match(markup, /Отправить в учебный отдел/); assert.match(markup, /role="dialog"/); }); test('мгновенный timestamp форматируется в русской локали', () => { const formatted = formatInstant('2026-09-28T10:00:00'); assert.match(formatted, /28\.09\.2026/); assert.match(formatted, /10:00/); assert.equal(formatInstant(null), '—'); });