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,257 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyModeLabel,
buildSlotPayload,
createScope,
createTenant,
dayShort,
defaultScopeId,
defaultSettingsTabForRole,
deleteTenant,
fetchDatabaseStatus,
fetchEducationForms,
fetchScopes,
fetchTenants,
fetchTimeSlots,
normalizeDomain,
resolveActiveScopeId,
saveTimeSlot,
scopeBadgeClass,
scopeBadgeLabel,
scopeOptionLabel,
scopeSummaryText,
settingsTabsForRole,
slotDurationMinutes,
slotsForScope,
testTenantConnection,
validateSlotPayload,
validateTenantFields
} from '../react/settings/settings-service.js';
import {
renderConfirmDialog,
renderDatabaseTab,
renderEduFormsTab,
renderGeneralTab,
renderTimeSlotsTab
} from '../dist/tests/react-settings-renderer.mjs';
const SCOPES = [
{ applyMode: 'DEFAULT', id: 1, name: 'Базовая сетка' },
{ applyMode: 'WEEKDAY', dayOfWeek: 6, id: 2, name: 'Субботняя сетка' },
{ applyMode: 'MANUAL', id: 3, name: 'Праздничная', systemScope: false }
];
const SLOTS = [
{ endTime: '10:10:00', id: 11, orderNumber: 2, scopeId: 1, startTime: '08:30:00' },
{ endTime: '10:10:00', id: 10, orderNumber: 1, scopeId: 1, startTime: '08:30:00' },
{ endTime: '13:30:00', id: 12, orderNumber: 1, scopeId: 2, startTime: '12:00:00' }
];
test('вкладки настроек фильтруются по единой матрице ролей', () => {
assert.deepEqual(
settingsTabsForRole('ADMIN').map(tab => tab.id),
['general', 'time-slots', 'edu-forms', 'database']
);
assert.deepEqual(
settingsTabsForRole('EDUCATION_OFFICE').map(tab => tab.id),
['time-slots', 'edu-forms']
);
assert.deepEqual(settingsTabsForRole('DEPARTMENT'), []);
assert.deepEqual(settingsTabsForRole('UNKNOWN_ROLE'), []);
assert.equal(defaultSettingsTabForRole('ADMIN'), 'general');
assert.equal(defaultSettingsTabForRole('EDUCATION_OFFICE'), 'time-slots');
assert.equal(defaultSettingsTabForRole('DEPARTMENT'), null);
});
test('метки сеток времени переводятся на русский', () => {
assert.equal(applyModeLabel('DEFAULT'), 'применяется по умолчанию');
assert.equal(applyModeLabel('WEEKDAY'), 'применяется автоматически');
assert.equal(applyModeLabel('MANUAL'), 'применяется вручную');
assert.equal(dayShort(6), 'Сб');
assert.equal(dayShort(1), 'Пн');
assert.equal(dayShort(99), '-');
assert.equal(scopeOptionLabel(SCOPES[0]), 'Базовая сетка - применяется по умолчанию');
assert.equal(scopeOptionLabel(SCOPES[1]), 'Субботняя сетка - применяется автоматически, Сб');
assert.equal(scopeBadgeLabel(SCOPES[0]), 'Базовая');
assert.equal(scopeBadgeLabel(SCOPES[1]), 'Сб');
assert.equal(scopeBadgeLabel(SCOPES[2]), 'Ручная');
assert.equal(scopeBadgeClass(SCOPES[0]), 'scope-badge scope-badge-base');
assert.equal(scopeBadgeClass(SCOPES[2]), 'scope-badge');
assert.equal(scopeSummaryText(SCOPES[0], SLOTS), 'применяется по умолчанию. Слотов: 2.');
assert.equal(scopeSummaryText(SCOPES[1], SLOTS), 'применяется автоматически, Сб. Слотов: 1.');
});
test('активная сетка fallback-ится на базовую, слоты сортируются по номеру пары', () => {
assert.equal(defaultScopeId(SCOPES), 1);
assert.equal(resolveActiveScopeId(SCOPES, 3), 3);
assert.equal(resolveActiveScopeId(SCOPES, 999), 1);
assert.equal(resolveActiveScopeId([], 1), null);
const scoped = slotsForScope(SLOTS, 1);
assert.deepEqual(scoped.map(slot => slot.id), [10, 11]);
assert.deepEqual(slotsForScope(SLOTS, 3), []);
});
test('валидация слота проверяет сетку, номер пары и время', () => {
assert.equal(validateSlotPayload({}), 'Выберите сетку времени');
assert.equal(
validateSlotPayload({ orderNumber: 0, scopeId: 1 }),
'Укажите номер пары'
);
assert.equal(
validateSlotPayload({ orderNumber: 1, scopeId: 1, startTime: '', endTime: '' }),
'Укажите время начала и окончания'
);
assert.equal(
validateSlotPayload({ endTime: '08:30', orderNumber: 1, scopeId: 1, startTime: '10:10' }),
'Время начала должно быть раньше времени окончания'
);
assert.equal(
validateSlotPayload({ endTime: '10:10', orderNumber: 1, scopeId: 1, startTime: '08:30' }),
''
);
});
test('длительность слота считается в минутах и отвергает обратное время', () => {
assert.equal(slotDurationMinutes('08:30', '10:10'), 100);
assert.equal(slotDurationMinutes('12:00', '12:00'), null);
assert.equal(slotDurationMinutes('10:10', '08:30'), null);
assert.equal(slotDurationMinutes('', '10:10'), null);
});
test('payload слота различает создание и редактирование', () => {
assert.deepEqual(
buildSlotPayload({ endTime: '10:10', orderNumber: '2', scopeId: 1, startTime: '08:30' }),
{ endTime: '10:10', id: null, orderNumber: 2, scopeId: 1, startTime: '08:30' }
);
assert.deepEqual(
buildSlotPayload({ endTime: '10:10', id: '10', orderNumber: '1', scopeId: 1, startTime: '08:30' }),
{ endTime: '10:10', id: 10, orderNumber: 1, scopeId: 1, startTime: '08:30' }
);
});
test('валидация тенанта проверяет обязательные поля, домен нормализуется', () => {
assert.equal(validateTenantFields({ domain: 'swsu', name: 'ЮЗГУ', url: 'jdbc:postgresql://x' }), '');
assert.equal(validateTenantFields({ domain: ' ', name: 'ЮЗГУ', url: 'jdbc:postgresql://x' }), 'Заполните все обязательные поля');
assert.equal(validateTenantFields({}), 'Заполните все обязательные поля');
assert.equal(normalizeDomain(' SwSu '), 'swsu');
});
test('сервис настроек сохраняет контракты API', async () => {
const calls = [];
const client = {
get: async (endpoint, options) => {
calls.push({ endpoint, options });
return [];
},
post: async (endpoint, json, options) => {
calls.push({ endpoint, json, options });
return { success: true };
},
put: async (endpoint, json, options) => {
calls.push({ endpoint, json, options });
return {};
},
delete: async (endpoint, options) => {
calls.push({ endpoint, options });
return {};
}
};
await fetchScopes(client);
await createScope('Праздничная', client);
await fetchTimeSlots(client);
await saveTimeSlot({ endTime: '10:10', orderNumber: 1, scopeId: 1, startTime: '08:30' }, client);
await saveTimeSlot({ endTime: '10:10', id: 10, orderNumber: 1, scopeId: 1, startTime: '08:30' }, client);
await fetchEducationForms(client);
await fetchDatabaseStatus(client);
await fetchTenants(client);
await testTenantConnection({ password: 'p', url: 'jdbc:x', username: 'u' }, client);
await createTenant({ domain: 'swsu', name: 'ЮЗГУ', password: 'p', url: 'jdbc:x', username: 'u' }, client);
await deleteTenant('swsu', client);
assert.equal(calls[0].endpoint, '/api/admin/time-slots/scopes');
assert.equal(calls[1].endpoint, '/api/admin/time-slots/scopes');
assert.deepEqual(calls[1].json, { name: 'Праздничная' });
assert.equal(calls[2].endpoint, '/api/admin/time-slots');
assert.equal(calls[3].endpoint, '/api/admin/time-slots');
assert.equal(calls[4].endpoint, '/api/admin/time-slots/10');
assert.equal(calls[4].json.id, 10);
assert.equal(calls[5].endpoint, '/api/education-forms');
assert.equal(calls[6].endpoint, '/api/database/status');
assert.equal(calls[7].endpoint, '/api/database/tenants');
assert.equal(calls[8].endpoint, '/api/database/test');
assert.deepEqual(calls[8].json, { password: 'p', url: 'jdbc:x', username: 'u' });
assert.equal(calls[9].endpoint, '/api/database/tenants');
assert.equal(calls[10].endpoint, '/api/database/tenants/swsu');
assert.ok(calls.every(call => call.options && call.options.errorMessage));
});
test('вкладка общих настроек показывает заглушку разработки', () => {
const markup = renderGeneralTab();
assert.match(markup, /Общие настройки/);
assert.match(markup, /Этот раздел находится в разработке/);
assert.match(markup, /settings-placeholder/);
});
test('вкладка временных слотов рендерит сетку, форму и таблицу', () => {
const markup = renderTimeSlotsTab();
assert.match(markup, /Временные слоты/);
assert.match(markup, /Область действия/);
assert.match(markup, /Новая ручная сетка/);
assert.match(markup, /Новый слот/);
assert.match(markup, /Рассчитывается по времени начала и окончания/);
assert.match(markup, /Загрузка\.\.\./);
assert.match(markup, /Как применяется/);
});
test('вкладка форм обучения рендерит форму создания и таблицу', () => {
const markup = renderEduFormsTab();
assert.match(markup, /Новая форма обучения/);
assert.match(markup, /Все формы обучения/);
assert.match(markup, /Загрузка\.\.\./);
assert.match(markup, /required/);
});
test('вкладка базы данных рендерит статус, тенантов и форму без inline-стилей', async () => {
const { readFile } = await import('node:fs/promises');
const source = await readFile(
new URL('../react/settings/DatabaseTab.jsx', import.meta.url),
'utf8'
);
const markup = renderDatabaseTab();
assert.match(markup, /Текущее подключение/);
assert.match(markup, /Подключённые университеты/);
assert.match(markup, /Добавить подключение/);
assert.match(markup, /Загрузка\.\.\./);
assert.match(source, /db-status-grid/);
assert.match(markup, /autoComplete="new-password"/);
assert.doesNotMatch(markup, /style="/);
assert.doesNotMatch(source, /style="/);
});
test('диалог подтверждения переиспользует общие классы и семантику', () => {
const markup = renderConfirmDialog({
confirmLabel: 'Удалить',
danger: true,
message: 'Удалить форму обучения?',
title: 'Удаление формы обучения',
onClose: () => {},
onConfirm: () => {}
});
assert.match(markup, /project-dialog-backdrop/);
assert.match(markup, /role="dialog"/);
assert.match(markup, /aria-modal="true"/);
assert.match(markup, /aria-labelledby="project-dialog-title"/);
assert.match(markup, /btn-danger/);
assert.match(markup, /Удалить форму обучения\?/);
});