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:
257
frontend/tests/react-settings.test.mjs
Normal file
257
frontend/tests/react-settings.test.mjs
Normal 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, /Удалить форму обучения\?/);
|
||||
});
|
||||
Reference in New Issue
Block a user