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,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestJson, ApiRequestError } from '../react/shared/api-client.js';
import { loadAuthorizedSession } from '../react/shared/auth-client.js';
import { resolveSessionState, SESSION_PHASES } from '../react/shared/session-policy.js';
import {
renderAsyncState,
renderErrorBoundaryContent,
renderFormAlert
} from '../dist/tests/react-shared-renderer.mjs';
test('общая политика сессии различает разрешённую и запрещённую роль', () => {
const ready = resolveSessionState({ role: 'STUDENT', userId: '7' }, ['STUDENT']);
const denied = resolveSessionState({ role: 'TEACHER', userId: '8' }, ['STUDENT']);
assert.equal(ready.phase, SESSION_PHASES.ready);
assert.equal(ready.session.userId, '7');
assert.equal(denied.phase, SESSION_PHASES.unauthorized);
assert.equal(denied.message, 'У вас нет доступа к этому разделу');
});
test('auth-адаптер возвращает безопасное состояние при ошибке восстановления', async () => {
const state = await loadAuthorizedSession(['STUDENT'], {
restoreSessionImpl: async () => {
throw new Error('Техническая причина не должна попасть в UI');
}
});
assert.equal(state.phase, SESSION_PHASES.error);
assert.equal(state.message, 'Не удалось восстановить сессию');
assert.doesNotMatch(state.message, /Техническая причина/);
});
test('общий API-клиент сериализует JSON и сохраняет same-origin auth-транспорт', async () => {
const calls = [];
const result = await requestJson('/api/example', {
json: { value: 'данные' },
method: 'POST'
}, {
fetchImpl: async (endpoint, options) => {
calls.push({ endpoint, options });
return new Response(JSON.stringify({ saved: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200
});
}
});
assert.deepEqual(result, { saved: true });
assert.equal(calls[0].endpoint, '/api/example');
assert.equal(calls[0].options.method, 'POST');
assert.equal(new Headers(calls[0].options.headers).get('Accept'), 'application/json');
assert.equal(new Headers(calls[0].options.headers).get('Content-Type'), 'application/json');
assert.deepEqual(JSON.parse(calls[0].options.body), { value: 'данные' });
});
test('общий API-клиент не выводит технический ответ сервера как пользовательскую ошибку', async () => {
await assert.rejects(
requestJson('/api/example', { errorMessage: 'Не удалось загрузить пример' }, {
fetchImpl: async () => new Response(JSON.stringify({ message: 'Internal failure' }), {
headers: { 'Content-Type': 'application/json' },
status: 500
})
}),
error => {
assert.ok(error instanceof ApiRequestError);
assert.equal(error.message, 'Не удалось загрузить пример');
assert.equal(error.status, 500);
assert.equal(error.data.message, 'Internal failure');
return true;
}
);
});
test('общие React-состояния имеют русские тексты и доступную семантику', () => {
const loading = renderAsyncState({ kind: 'loading' });
const failure = renderAsyncState({
kind: 'error',
message: 'Не удалось получить расписание',
onRetry: () => {}
});
const alert = renderFormAlert({
alert: { message: 'Проверьте поля', type: 'error' },
id: 'form-alert'
});
assert.match(loading, /role="status"/);
assert.match(loading, /Загружаем данные/);
assert.match(failure, /role="alert"/);
assert.match(failure, /Не удалось получить расписание/);
assert.match(failure, /Повторить/);
assert.match(alert, /id="form-alert"/);
assert.match(alert, /Проверьте поля/);
assert.match(renderErrorBoundaryContent(), /Рабочее содержимое/);
});