Files
magistr/frontend/tests/react-settings.test.mjs
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

258 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, /Удалить форму обучения\?/);
});