Files
magistr/frontend/tests/react-teacher.test.mjs
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00

441 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import 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), '—');
});