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,191 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
addDays,
buildLessonChips,
buildWeekStatusMessage,
fetchGroupOptions,
fetchSchedule,
formatWeekLabel,
groupLessonsByDate,
groupOptionDescription,
parseIsoDate,
resolveSelectedGroup,
startOfWeek,
toIsoDate,
weekDates,
weekEndIso
} from '../react/student/student-service.js';
import {
renderAsyncCombobox,
renderScheduleOverview,
renderWeekGrid,
weekStartOf
} from '../dist/tests/react-student-renderer.mjs';
test('неделя студента начинается с понедельника, включая воскресенье', () => {
assert.equal(toIsoDate(startOfWeek(parseIsoDate('2026-09-30'))), '2026-09-28');
assert.equal(toIsoDate(startOfWeek(parseIsoDate('2026-10-04'))), '2026-09-28');
assert.equal(toIsoDate(startOfWeek(parseIsoDate('2026-10-10'))), '2026-10-05');
});
test('недельная сетка содержит семь дней и подпись границ недели', () => {
const weekStart = startOfWeek(parseIsoDate('2026-09-28'));
const days = weekDates(weekStart);
assert.equal(days.length, 7);
assert.equal(toIsoDate(days[0]), '2026-09-28');
assert.equal(toIsoDate(days[6]), '2026-10-04');
assert.equal(weekEndIso(weekStart), '2026-10-04');
assert.equal(formatWeekLabel(weekStart), '28.09.2026 - 04.10.2026');
});
test('занятия группируются по датам без потери пустых дней', () => {
const byDate = groupLessonsByDate([
{ date: '2026-09-28', subjectName: 'Лекции' },
{ date: '2026-09-28', subjectName: 'Практика' },
{ date: '2026-09-30', subjectName: 'Лаборатория' }
]);
assert.equal(byDate.get('2026-09-28').length, 2);
assert.equal(byDate.get('2026-09-30').length, 1);
assert.equal(byDate.get('2026-10-01'), undefined);
});
test('чипы занятия содержат тип, подгруппы, формат, преподавателя, аудиторию и группы', () => {
const chips = buildLessonChips({
classroomName: '301',
groupNames: ['ИС-41', 'ИС-42'],
lessonFormat: 'Очно',
lessonTypeName: 'Лекция',
subgroupNames: ['1 подгруппа'],
teacherName: 'Иванов И. И.'
});
assert.deepEqual(chips, ['Лекция', '1 подгруппа', 'Очно', 'Иванов И. И.', '301', 'ИС-41, ИС-42']);
});
test('подгруппа-строка используется, когда массив подгрупп пуст', () => {
const chips = buildLessonChips({
lessonTypeName: 'Практика',
subgroupNames: [],
subgroupName: '2 подгруппа',
teacherName: 'Петрова П. П.'
});
assert.deepEqual(chips, ['Практика', '2 подгруппа', 'Петрова П. П.']);
});
test('статус недели различает найденные занятия и пустую неделю', () => {
assert.equal(buildWeekStatusMessage([{ date: '2026-09-28' }]), 'Найдено занятий: 1');
assert.equal(buildWeekStatusMessage([]), 'На этой неделе занятий нет');
});
test('выбор группы сохраняет сохранённый ID и падает обратно на первую группу', () => {
const groups = [{ id: 1, name: 'ИС-41' }, { id: 7, name: 'ИС-42' }];
assert.equal(resolveSelectedGroup(groups, '7').name, 'ИС-42');
assert.equal(resolveSelectedGroup(groups, '999').name, 'ИС-41');
assert.equal(resolveSelectedGroup([], '7'), null);
});
test('описание группы собирает код, профиль и курс через точку', () => {
assert.equal(
groupOptionDescription({ course: 3, specialtyCode: '09.03.03', specialtyProfileName: 'Информационные системы' }),
'09.03.03 · Информационные системы · 3 курс'
);
assert.equal(groupOptionDescription({ specialtyCode: '09.03.03' }), '09.03.03');
});
test('сервис студента сохраняет прежние контракты API', async () => {
const calls = [];
const client = {
get: async (endpoint, options) => {
calls.push({ endpoint, options });
if (endpoint.startsWith('/api/groups/options')) return [{ id: 5, name: 'ИС-41' }];
return [{ date: '2026-09-28', subjectName: 'Лекция' }];
}
};
const groups = await fetchGroupOptions({ query: 'ИС', selectedId: '5' }, client);
const lessons = await fetchSchedule({ groupId: 5, startDate: '2026-09-28', endDate: '2026-10-04' }, client);
assert.deepEqual(groups, [{ id: 5, name: 'ИС-41' }]);
assert.deepEqual(lessons, [{ date: '2026-09-28', subjectName: 'Лекция' }]);
assert.match(calls[0].endpoint, /^\/api\/groups\/options\?query=%D0%98%D0%A1&limit=20&selectedId=5$/);
assert.equal(calls[0].options.errorMessage, 'Не удалось загрузить группы');
assert.equal(calls[1].endpoint, '/api/schedule?groupId=5&startDate=2026-09-28&endDate=2026-10-04');
assert.equal(calls[1].options.errorMessage, 'Не удалось загрузить расписание');
});
test('сетка недели рендерит дни, занятия и пустые дни с русскими текстами', () => {
const markup = renderWeekGrid({
lessons: [
{
classroomName: '301',
date: '2026-09-28',
endTime: '10:10',
lessonFormat: 'Очно',
lessonTypeName: 'Лекция',
startTime: '08:30',
subjectName: 'Базы данных',
teacherName: 'Иванов И. И.'
}
],
weekStart: weekStartOf('2026-09-30')
});
assert.match(markup, /Понедельник/);
assert.match(markup, /Базы данных/);
assert.match(markup, /08:30 - 10:10/);
assert.match(markup, /class="chip"/);
assert.match(markup, /Занятий нет/);
assert.match(markup, /aria-live="polite"/);
});
test('обзор ближайших занятий выделяет сегодня и следующую пару', () => {
const now = new Date('2026-09-28T09:00:00');
const markup = renderScheduleOverview({
lessons: [
{ date: '2026-09-28', startTime: '08:30', endTime: '10:10', subjectName: 'Базы данных', teacherName: 'Иванов И. И.', classroomName: '301' },
{ date: '2026-09-30', startTime: '12:00', endTime: '13:30', subjectName: 'Тестирование', teacherName: 'Петрова П. П.', classroomName: '205' }
],
now,
onGoToday: () => {}
});
assert.match(markup, /Быстрый обзор/);
assert.match(markup, /1 занятие/);
assert.match(markup, /Следующая пара/);
assert.match(markup, /Идёт сейчас/);
assert.match(markup, /Открыть текущую неделю/);
});
test('обзор без ближайших занятий показывает пустые состояния', () => {
const markup = renderScheduleOverview({ lessons: [], now: new Date('2026-09-28T09:00:00') });
assert.match(markup, /Свободный день/);
assert.match(markup, /Пока ничего не запланировано/);
assert.match(markup, /В ближайшие 14 дней занятий нет\./);
});
test('поисковый список групп рендерится с доступной семантикой combobox', () => {
const markup = renderAsyncCombobox({
allowClear: false,
ariaLabel: 'Группа',
placeholder: 'Найдите группу по названию или специальности',
selectedLabel: 'ИС-41',
selectedValue: '5',
load: () => Promise.resolve([]),
onSelect: () => {}
});
assert.match(markup, /role="combobox"/);
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /aria-autocomplete="list"/);
assert.match(markup, /aria-label="Группа"/);
assert.match(markup, /value="ИС-41"/);
assert.doesNotMatch(markup, /async-combobox-clear/);
assert.doesNotMatch(markup, /Очистить выбор/);
});