Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
258 lines
12 KiB
JavaScript
258 lines
12 KiB
JavaScript
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, /Удалить форму обучения\?/);
|
||
});
|