Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
441 lines
18 KiB
JavaScript
441 lines
18 KiB
JavaScript
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), '—');
|
||
});
|