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, /Удалить форму обучения\?/); });