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:
191
frontend/tests/react-student.test.mjs
Normal file
191
frontend/tests/react-student.test.mjs
Normal 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, /Очистить выбор/);
|
||||
});
|
||||
Reference in New Issue
Block a user