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:
dipatrik10
2026-09-28 23:15:04 +03:00
parent c6083b35e0
commit 97bf3cc50a
167 changed files with 31283 additions and 19033 deletions

View 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), '—');
});