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
This commit is contained in:
440
frontend/tests/react-teacher.test.mjs
Normal file
440
frontend/tests/react-teacher.test.mjs
Normal file
@@ -0,0 +1,440 @@
|
||||
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), '—');
|
||||
});
|
||||
Reference in New Issue
Block a user