Задача Егора 2ч.
This commit is contained in:
@@ -246,6 +246,77 @@
|
||||
border-color: var(--button-secondary-border);
|
||||
}
|
||||
|
||||
.dashboard-check-state {
|
||||
--check-accent: var(--text-secondary);
|
||||
--check-surface: rgba(148, 163, 184, 0.08);
|
||||
--check-border: rgba(148, 163, 184, 0.22);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
align-items: start;
|
||||
padding: 1rem 1.1rem;
|
||||
color: var(--text-primary);
|
||||
background: linear-gradient(115deg, var(--check-surface), transparent 72%);
|
||||
border: 1px solid var(--check-border);
|
||||
border-left: 4px solid var(--check-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.dashboard-check-state--success {
|
||||
--check-accent: var(--success);
|
||||
--check-surface: rgba(16, 185, 129, 0.1);
|
||||
--check-border: rgba(16, 185, 129, 0.24);
|
||||
}
|
||||
|
||||
.dashboard-check-state--warning {
|
||||
--check-accent: var(--warning);
|
||||
--check-surface: rgba(245, 158, 11, 0.1);
|
||||
--check-border: rgba(245, 158, 11, 0.26);
|
||||
}
|
||||
|
||||
.dashboard-check-state--error {
|
||||
--check-accent: var(--error);
|
||||
--check-surface: rgba(239, 68, 68, 0.1);
|
||||
--check-border: rgba(239, 68, 68, 0.26);
|
||||
}
|
||||
|
||||
.dashboard-check-state__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--check-accent);
|
||||
background: var(--check-surface);
|
||||
border: 1px solid var(--check-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.dashboard-check-state__body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.dashboard-check-state__body strong {
|
||||
color: var(--check-accent);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dashboard-check-state__body > span {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-check-state__body small {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-metrics-grid {
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
|
||||
214
frontend/admin/js/dashboard-conflicts.js
Normal file
214
frontend/admin/js/dashboard-conflicts.js
Normal file
@@ -0,0 +1,214 @@
|
||||
export const CONFLICT_CHECK_STATE = Object.freeze({
|
||||
COMPLETE: 'COMPLETE',
|
||||
PARTIAL: 'PARTIAL',
|
||||
NOT_RUN: 'NOT_RUN'
|
||||
});
|
||||
|
||||
export const CONFLICT_CHECK_REASON = Object.freeze({
|
||||
NO_DEPARTMENTS: 'NO_DEPARTMENTS',
|
||||
ALL_REQUESTS_FAILED: 'ALL_REQUESTS_FAILED'
|
||||
});
|
||||
|
||||
/** Формирует date-only строку из локальных компонентов без UTC-конвертации. */
|
||||
export function formatLocalDate(date) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
throw new TypeError('Для форматирования требуется корректная дата');
|
||||
}
|
||||
return [
|
||||
String(date.getFullYear()).padStart(4, '0'),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
].join('-');
|
||||
}
|
||||
|
||||
/** Возвращает локальные границы недели с понедельника по воскресенье включительно. */
|
||||
export function currentWeekDateRange(referenceDate = new Date()) {
|
||||
if (!(referenceDate instanceof Date) || Number.isNaN(referenceDate.getTime())) {
|
||||
throw new TypeError('Для расчёта недели требуется корректная дата');
|
||||
}
|
||||
|
||||
const localDate = new Date(
|
||||
referenceDate.getFullYear(),
|
||||
referenceDate.getMonth(),
|
||||
referenceDate.getDate()
|
||||
);
|
||||
const dayOfWeek = localDate.getDay();
|
||||
const daysFromMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
const monday = addLocalDays(localDate, daysFromMonday);
|
||||
const sunday = addLocalDays(monday, 6);
|
||||
|
||||
return {
|
||||
startDate: formatLocalDate(monday),
|
||||
endDate: formatLocalDate(sunday)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает расписания независимо и сохраняет факт каждого отказа.
|
||||
* Технические причины намеренно не возвращаются в UI-модель.
|
||||
*/
|
||||
export async function loadDepartmentSchedules(departments, loadSchedule) {
|
||||
if (!Array.isArray(departments)) {
|
||||
throw new TypeError('Список кафедр должен быть массивом');
|
||||
}
|
||||
if (typeof loadSchedule !== 'function') {
|
||||
throw new TypeError('Функция загрузки расписания обязательна');
|
||||
}
|
||||
|
||||
if (departments.length === 0) {
|
||||
return {
|
||||
state: CONFLICT_CHECK_STATE.NOT_RUN,
|
||||
reason: CONFLICT_CHECK_REASON.NO_DEPARTMENTS,
|
||||
lessons: [],
|
||||
totalDepartmentCount: 0,
|
||||
checkedDepartmentCount: 0,
|
||||
failedDepartments: []
|
||||
};
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(departments.map(async department => {
|
||||
const lessons = await loadSchedule(department);
|
||||
if (!Array.isArray(lessons)) {
|
||||
throw new TypeError('API расписания вернул некорректный формат данных');
|
||||
}
|
||||
return lessons;
|
||||
}));
|
||||
|
||||
const lessons = [];
|
||||
const failedDepartments = [];
|
||||
let checkedDepartmentCount = 0;
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
checkedDepartmentCount += 1;
|
||||
lessons.push(...result.value);
|
||||
return;
|
||||
}
|
||||
failedDepartments.push(publicDepartment(departments[index], index));
|
||||
});
|
||||
|
||||
const state = checkedDepartmentCount === 0
|
||||
? CONFLICT_CHECK_STATE.NOT_RUN
|
||||
: failedDepartments.length > 0
|
||||
? CONFLICT_CHECK_STATE.PARTIAL
|
||||
: CONFLICT_CHECK_STATE.COMPLETE;
|
||||
|
||||
return {
|
||||
state,
|
||||
reason: state === CONFLICT_CHECK_STATE.NOT_RUN
|
||||
? CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED
|
||||
: null,
|
||||
lessons,
|
||||
totalDepartmentCount: departments.length,
|
||||
checkedDepartmentCount,
|
||||
failedDepartments
|
||||
};
|
||||
}
|
||||
|
||||
/** Возвращает безопасную UI-модель итогового состояния проверки. */
|
||||
export function conflictCheckPresentation(scheduleLoad, conflictCount, range) {
|
||||
assertPresentationInput(scheduleLoad, conflictCount, range);
|
||||
|
||||
const count = conflictCount;
|
||||
const startDate = range.startDate;
|
||||
const endDate = range.endDate;
|
||||
const rangeLabel = `${startDate} — ${endDate}`;
|
||||
|
||||
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.NOT_RUN) {
|
||||
const noDepartments = scheduleLoad.reason === CONFLICT_CHECK_REASON.NO_DEPARTMENTS;
|
||||
return {
|
||||
tone: 'error',
|
||||
title: 'Проверка не выполнена',
|
||||
description: noDepartments
|
||||
? 'Нет кафедр, расписание которых можно проверить.'
|
||||
: 'Не удалось получить расписание ни одной кафедры. Подтвердить отсутствие конфликтов нельзя.',
|
||||
meta: `Период: ${rangeLabel}`
|
||||
};
|
||||
}
|
||||
|
||||
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.PARTIAL) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Проверка выполнена частично',
|
||||
description: count > 0
|
||||
? 'Показаны найденные конфликты, но подтвердить полноту результата нельзя.'
|
||||
: 'В доступной части расписания конфликтов не найдено, но подтвердить их отсутствие во всём расписании нельзя.',
|
||||
meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из `
|
||||
+ `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}`
|
||||
};
|
||||
}
|
||||
|
||||
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.COMPLETE && count === 0) {
|
||||
return {
|
||||
tone: 'success',
|
||||
title: 'Конфликты расписания не обнаружены',
|
||||
description: 'Проверены накладки преподавателей и аудиторий, а также вместимость помещений.',
|
||||
meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из `
|
||||
+ `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}`
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function addLocalDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertPresentationInput(scheduleLoad, conflictCount, range) {
|
||||
if (!Number.isInteger(conflictCount) || conflictCount < 0) {
|
||||
throw new TypeError('Количество конфликтов должно быть целым неотрицательным числом');
|
||||
}
|
||||
|
||||
if (!isValidDateOnly(range?.startDate) || !isValidDateOnly(range?.endDate)
|
||||
|| range.startDate > range.endDate) {
|
||||
throw new TypeError('Для результата проверки требуется корректный локальный диапазон дат');
|
||||
}
|
||||
|
||||
const total = scheduleLoad?.totalDepartmentCount;
|
||||
const checked = scheduleLoad?.checkedDepartmentCount;
|
||||
if (!Number.isInteger(total) || total < 0
|
||||
|| !Number.isInteger(checked) || checked < 0 || checked > total) {
|
||||
throw new TypeError('Количество проверенных кафедр не соответствует результату проверки');
|
||||
}
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.COMPLETE) {
|
||||
if (total === 0 || checked !== total) {
|
||||
throw new TypeError('Полная проверка должна охватывать все кафедры');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.PARTIAL) {
|
||||
if (checked === 0 || checked === total) {
|
||||
throw new TypeError('Частичная проверка должна охватывать только часть кафедр');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN && checked === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new TypeError('Неизвестное состояние проверки конфликтов');
|
||||
}
|
||||
|
||||
function isValidDateOnly(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || '');
|
||||
if (!match) return false;
|
||||
|
||||
const [, year, month, day] = match.map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
return date.getFullYear() === year
|
||||
&& date.getMonth() === month - 1
|
||||
&& date.getDate() === day;
|
||||
}
|
||||
|
||||
function publicDepartment(department, index) {
|
||||
return {
|
||||
id: department?.id ?? null,
|
||||
name: department?.departmentName || department?.name || `Кафедра ${index + 1}`
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { api } from '../api.js';
|
||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
import {
|
||||
CONFLICT_CHECK_STATE,
|
||||
conflictCheckPresentation,
|
||||
currentWeekDateRange,
|
||||
formatLocalDate,
|
||||
loadDepartmentSchedules
|
||||
} from '../dashboard-conflicts.js';
|
||||
|
||||
export async function initDashboard() {
|
||||
// DOM Элементы
|
||||
@@ -20,16 +27,27 @@ export async function initDashboard() {
|
||||
let teachers = [];
|
||||
let todayLessons = [];
|
||||
let timeSlots = [];
|
||||
let dashboardDataReady = false;
|
||||
|
||||
btnRecheck.addEventListener('click', async () => {
|
||||
btnRecheck.disabled = true;
|
||||
btnRecheck.textContent = '...';
|
||||
await checkConflicts();
|
||||
btnRecheck.textContent = 'Перепроверить';
|
||||
btnRecheck.disabled = false;
|
||||
btnRecheck.setAttribute('aria-busy', 'true');
|
||||
btnRecheck.textContent = 'Проверка…';
|
||||
try {
|
||||
if (dashboardDataReady) {
|
||||
await checkConflicts();
|
||||
} else {
|
||||
await loadData();
|
||||
}
|
||||
} finally {
|
||||
btnRecheck.textContent = 'Перепроверить';
|
||||
btnRecheck.disabled = false;
|
||||
btnRecheck.removeAttribute('aria-busy');
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
dashboardDataReady = false;
|
||||
try {
|
||||
// Загружаем базовые справочники
|
||||
[departments, classrooms, groups, teachers] = await Promise.all([
|
||||
@@ -45,7 +63,7 @@ export async function initDashboard() {
|
||||
metricStudents.textContent = totalStudents;
|
||||
metricTeachers.textContent = teachers.length;
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const todayStr = formatLocalDate(new Date());
|
||||
|
||||
// Загружаем временные слоты и сегодняшнее расписание
|
||||
const lessonsPromises = departments.map(d =>
|
||||
@@ -75,10 +93,18 @@ export async function initDashboard() {
|
||||
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
|
||||
|
||||
updateRealtimeMonitoring();
|
||||
dashboardDataReady = true;
|
||||
await checkConflicts();
|
||||
} catch (e) {
|
||||
console.error("Dashboard data load error", e);
|
||||
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки панели: ${escapeHtml(e.message)}</div>`;
|
||||
dashboardDataReady = false;
|
||||
console.error('Не удалось загрузить данные панели', {
|
||||
errorType: e?.name || 'Error'
|
||||
});
|
||||
conflictsContainer.innerHTML = renderCheckStateCard({
|
||||
tone: 'error',
|
||||
title: 'Проверка не выполнена',
|
||||
description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,25 +173,25 @@ export async function initDashboard() {
|
||||
|
||||
// --- Анализ конфликтов (Red Zone) ---
|
||||
async function checkConflicts() {
|
||||
conflictsContainer.setAttribute('aria-busy', 'true');
|
||||
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
|
||||
|
||||
// Вычисляем даты начала и конца текущей недели
|
||||
const curr = new Date();
|
||||
const day = curr.getDay();
|
||||
const diff = curr.getDate() - day + (day === 0 ? -6 : 1); // Понедельник
|
||||
const monday = new Date(curr.setDate(diff));
|
||||
const sunday = new Date(curr.setDate(diff + 6));
|
||||
|
||||
const mondayStr = monday.toISOString().split('T')[0];
|
||||
const sundayStr = sunday.toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
// Загружаем расписание на всю неделю по кафедрам
|
||||
const weekLessonsPromises = departments.map(d =>
|
||||
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => [])
|
||||
const { startDate, endDate } = currentWeekDateRange(new Date());
|
||||
const scheduleLoad = await loadDepartmentSchedules(
|
||||
departments,
|
||||
department => api.get(
|
||||
`/api/schedule/search?departmentId=${department.id}`
|
||||
+ `&startDate=${startDate}&endDate=${endDate}`
|
||||
)
|
||||
);
|
||||
const weekLessonsArrays = await Promise.all(weekLessonsPromises);
|
||||
const weekLessons = weekLessonsArrays.flat();
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
|
||||
renderConflicts([], scheduleLoad, { startDate, endDate });
|
||||
return;
|
||||
}
|
||||
|
||||
const weekLessons = scheduleLoad.lessons;
|
||||
|
||||
const conflicts = [];
|
||||
|
||||
@@ -264,31 +290,40 @@ export async function initDashboard() {
|
||||
}
|
||||
});
|
||||
|
||||
renderConflicts(conflicts);
|
||||
renderConflicts(conflicts, scheduleLoad, { startDate, endDate });
|
||||
} catch (e) {
|
||||
console.error("Conflicts check error", e);
|
||||
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка при анализе конфликтов: ${escapeHtml(e.message)}</div>`;
|
||||
console.error('Не удалось выполнить проверку конфликтов', {
|
||||
errorType: e?.name || 'Error'
|
||||
});
|
||||
conflictsContainer.innerHTML = renderCheckStateCard({
|
||||
tone: 'error',
|
||||
title: 'Проверка не выполнена',
|
||||
description: 'Во время анализа произошла ошибка. Повторите проверку позже.'
|
||||
});
|
||||
} finally {
|
||||
conflictsContainer.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
|
||||
function renderConflicts(conflicts) {
|
||||
if (!conflicts.length) {
|
||||
conflictsContainer.innerHTML = `
|
||||
<div style="background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 8px; padding: 1.25rem; display: flex; align-items: center; gap: 1rem; color: #10b981;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<div>
|
||||
<strong style="display: block; font-size: 0.95rem; margin-bottom: 0.15rem;">Конфликты расписания не обнаружены!</strong>
|
||||
<span style="font-size: 0.85rem; opacity: 0.9;">Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
function renderConflicts(conflicts, scheduleLoad, range) {
|
||||
const stateCards = [];
|
||||
const presentation = conflictCheckPresentation(scheduleLoad, conflicts.length, range);
|
||||
|
||||
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
|
||||
conflictsContainer.innerHTML = renderCheckStateCard(presentation);
|
||||
return;
|
||||
}
|
||||
|
||||
conflictsContainer.innerHTML = conflicts.map(c => {
|
||||
if (presentation) {
|
||||
stateCards.push(renderCheckStateCard(presentation));
|
||||
}
|
||||
|
||||
if (!conflicts.length) {
|
||||
conflictsContainer.innerHTML = stateCards.join('');
|
||||
return;
|
||||
}
|
||||
|
||||
const conflictCards = conflicts.map(c => {
|
||||
const isError = c.severity === 'error';
|
||||
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
|
||||
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
|
||||
@@ -306,6 +341,27 @@ export async function initDashboard() {
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
conflictsContainer.innerHTML = stateCards.join('') + conflictCards;
|
||||
}
|
||||
|
||||
function renderCheckStateCard({ tone, title, description, meta = '' }) {
|
||||
const icon = tone === 'success'
|
||||
? '<path d="M20 6 9 17l-5-5" />'
|
||||
: tone === 'warning'
|
||||
? '<path d="M12 9v4m0 4h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />'
|
||||
: '<circle cx="12" cy="12" r="9" /><path d="M12 8v5m0 3h.01" />';
|
||||
return `
|
||||
<section class="dashboard-check-state dashboard-check-state--${tone}">
|
||||
<span class="dashboard-check-state__icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
|
||||
</span>
|
||||
<span class="dashboard-check-state__body">
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<span>${escapeHtml(description)}</span>
|
||||
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
|
||||
</span>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
// Загружаем данные при старте
|
||||
|
||||
@@ -135,10 +135,10 @@
|
||||
</h2>
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts">Перепроверить</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts" type="button">Перепроверить</button>
|
||||
</div>
|
||||
|
||||
<div id="conflicts-container" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
|
||||
<div id="conflicts-container" role="status" aria-live="polite" aria-atomic="true" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
|
||||
<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
12
frontend/package.json
Normal file
12
frontend/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "magistr-frontend-checks",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"check": "node --check admin/js/dashboard-conflicts.js && node --check admin/js/views/dashboard.js && node --test tests/*.test.mjs"
|
||||
}
|
||||
}
|
||||
151
frontend/tests/dashboard-conflicts.test.mjs
Normal file
151
frontend/tests/dashboard-conflicts.test.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
CONFLICT_CHECK_REASON,
|
||||
CONFLICT_CHECK_STATE,
|
||||
conflictCheckPresentation,
|
||||
currentWeekDateRange,
|
||||
formatLocalDate,
|
||||
loadDepartmentSchedules
|
||||
} from '../admin/js/dashboard-conflicts.js';
|
||||
|
||||
process.env.TZ = 'Europe/Moscow';
|
||||
|
||||
test('неделя для 2026-07-02 охватывает 2026-06-29…2026-07-05', () => {
|
||||
assert.deepEqual(
|
||||
currentWeekDateRange(new Date('2026-07-01T21:15:00Z')),
|
||||
{ startDate: '2026-06-29', endDate: '2026-07-05' }
|
||||
);
|
||||
});
|
||||
|
||||
test('неделя корректно пересекает границу года', () => {
|
||||
assert.deepEqual(
|
||||
currentWeekDateRange(new Date(2026, 0, 1, 8, 30, 0)),
|
||||
{ startDate: '2025-12-29', endDate: '2026-01-04' }
|
||||
);
|
||||
});
|
||||
|
||||
test('date-only формируется из локальных компонентов', () => {
|
||||
assert.equal(formatLocalDate(new Date('2026-01-01T21:15:00Z')), '2026-01-02');
|
||||
});
|
||||
|
||||
test('полный успех разрешает достоверный вывод по всем кафедрам', async () => {
|
||||
const result = await loadDepartmentSchedules(
|
||||
[{ id: 1 }, { id: 2 }],
|
||||
async department => [{ departmentId: department.id }]
|
||||
);
|
||||
|
||||
assert.equal(result.state, CONFLICT_CHECK_STATE.COMPLETE);
|
||||
assert.equal(result.checkedDepartmentCount, 2);
|
||||
assert.equal(result.totalDepartmentCount, 2);
|
||||
assert.deepEqual(result.lessons, [{ departmentId: 1 }, { departmentId: 2 }]);
|
||||
assert.deepEqual(result.failedDepartments, []);
|
||||
});
|
||||
|
||||
test('единичный отказ даёт частичный результат, а не пустой успешный ответ', async () => {
|
||||
const departments = [
|
||||
{ id: 1, departmentName: 'Физика' },
|
||||
{ id: 2, departmentName: 'Математика' }
|
||||
];
|
||||
const result = await loadDepartmentSchedules(departments, async department => {
|
||||
if (department.id === 2) {
|
||||
throw new Error('тестовая техническая причина');
|
||||
}
|
||||
return [{ departmentId: department.id }];
|
||||
});
|
||||
|
||||
assert.equal(result.state, CONFLICT_CHECK_STATE.PARTIAL);
|
||||
assert.equal(result.checkedDepartmentCount, 1);
|
||||
assert.deepEqual(result.lessons, [{ departmentId: 1 }]);
|
||||
assert.deepEqual(result.failedDepartments, [{ id: 2, name: 'Математика' }]);
|
||||
assert.equal('reason' in result.failedDepartments[0], false);
|
||||
});
|
||||
|
||||
test('отказ всех запросов означает, что проверка не выполнена', async () => {
|
||||
const result = await loadDepartmentSchedules(
|
||||
[{ id: 1 }, { id: 2 }],
|
||||
async () => { throw new Error('тестовый отказ'); }
|
||||
);
|
||||
|
||||
assert.equal(result.state, CONFLICT_CHECK_STATE.NOT_RUN);
|
||||
assert.equal(result.reason, CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED);
|
||||
assert.equal(result.checkedDepartmentCount, 0);
|
||||
assert.deepEqual(result.lessons, []);
|
||||
});
|
||||
|
||||
test('отсутствие кафедр не изображается как зелёная успешная проверка', async () => {
|
||||
const result = await loadDepartmentSchedules([], async () => []);
|
||||
|
||||
assert.equal(result.state, CONFLICT_CHECK_STATE.NOT_RUN);
|
||||
assert.equal(result.reason, CONFLICT_CHECK_REASON.NO_DEPARTMENTS);
|
||||
});
|
||||
|
||||
test('зелёная карточка разрешена только для полной проверки без конфликтов', () => {
|
||||
const complete = {
|
||||
state: CONFLICT_CHECK_STATE.COMPLETE,
|
||||
checkedDepartmentCount: 2,
|
||||
totalDepartmentCount: 2
|
||||
};
|
||||
const range = { startDate: '2026-06-29', endDate: '2026-07-05' };
|
||||
|
||||
assert.equal(conflictCheckPresentation(complete, 0, range).tone, 'success');
|
||||
assert.equal(conflictCheckPresentation(complete, 1, range), null);
|
||||
});
|
||||
|
||||
test('некорректный контракт результата не может породить зелёный статус', () => {
|
||||
const range = { startDate: '2026-06-29', endDate: '2026-07-05' };
|
||||
|
||||
assert.throws(
|
||||
() => conflictCheckPresentation({
|
||||
state: CONFLICT_CHECK_STATE.COMPLETE,
|
||||
checkedDepartmentCount: 1,
|
||||
totalDepartmentCount: 2
|
||||
}, 0, range),
|
||||
/Полная проверка должна охватывать все кафедры/
|
||||
);
|
||||
assert.throws(
|
||||
() => conflictCheckPresentation({
|
||||
state: CONFLICT_CHECK_STATE.COMPLETE,
|
||||
checkedDepartmentCount: 2,
|
||||
totalDepartmentCount: 2
|
||||
}, Number.NaN, range),
|
||||
/Количество конфликтов должно быть целым неотрицательным числом/
|
||||
);
|
||||
});
|
||||
|
||||
test('частичная проверка без конфликтов остаётся предупреждением', () => {
|
||||
const presentation = conflictCheckPresentation({
|
||||
state: CONFLICT_CHECK_STATE.PARTIAL,
|
||||
checkedDepartmentCount: 1,
|
||||
totalDepartmentCount: 2
|
||||
}, 0, { startDate: '2026-06-29', endDate: '2026-07-05' });
|
||||
|
||||
assert.equal(presentation.tone, 'warning');
|
||||
assert.equal(presentation.title, 'Проверка выполнена частично');
|
||||
assert.match(presentation.description, /во всём расписании нельзя/);
|
||||
});
|
||||
|
||||
test('частичная проверка с конфликтами предупреждает о неполноте результата', () => {
|
||||
const presentation = conflictCheckPresentation({
|
||||
state: CONFLICT_CHECK_STATE.PARTIAL,
|
||||
checkedDepartmentCount: 1,
|
||||
totalDepartmentCount: 3
|
||||
}, 2, { startDate: '2026-06-29', endDate: '2026-07-05' });
|
||||
|
||||
assert.equal(presentation.tone, 'warning');
|
||||
assert.match(presentation.description, /Показаны найденные конфликты/);
|
||||
});
|
||||
|
||||
test('полный отказ формирует явное состояние «Проверка не выполнена»', () => {
|
||||
const presentation = conflictCheckPresentation({
|
||||
state: CONFLICT_CHECK_STATE.NOT_RUN,
|
||||
reason: CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED,
|
||||
checkedDepartmentCount: 0,
|
||||
totalDepartmentCount: 2
|
||||
}, 0, { startDate: '2026-06-29', endDate: '2026-07-05' });
|
||||
|
||||
assert.equal(presentation.tone, 'error');
|
||||
assert.equal(presentation.title, 'Проверка не выполнена');
|
||||
assert.match(presentation.description, /Подтвердить отсутствие конфликтов нельзя/);
|
||||
});
|
||||
Reference in New Issue
Block a user