import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { groupsIn, parseDocx, parseWord, details, leadingDate, leadingTime } from "../server/parser.js"; import { parseWeekRanges } from "../shared/weeks.js"; import { decodeListing, fileMetadata } from "../server/source.js"; const fixture = (name) => readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url)); const fef = () => parseDocx(fixture("fef-23-ba"), { name: "23-ЛГ_23-БА.docx" }); test("ФЭФ: 23-БА не получает дисциплины 23-ЛГ", () => { const s = fef(), ba = s.lessons.filter((l) => l.groups.includes("23-БА")); assert.deepEqual(s.groups, ["23-ЛГ", "23-БА"]); assert.equal(ba.length, 33); assert.ok(ba.some((l) => l.subject === "Бизнес-анализ")); assert.ok( !ba.some((l) => /логистик|Экспедирование|Управление запасами/i.test(l.subject), ), ); assert.ok( s.lessons .filter((l) => l.groups.includes("23-ЛГ")) .some((l) => /Финансовая логистика/.test(l.subject)), ); }); test("ФЭФ: общая ячейка принадлежит обеим группам", () => { const shared = fef().lessons.filter((l) => l.subject.startsWith("Единый день информирования"), ); assert.equal(shared.length, 1); assert.deepEqual(shared[0].groups, ["23-ЛГ", "23-БА"]); assert.match(shared[0].raw, /15 октября/); assert.equal(shared[0].start, "13:40"); }); test("ФЭФ: варианты недель, цвет, преподаватель и аудитория сохранены", () => { const s = fef(); const lessons = s.lessons.filter( (l) => l.day === 4 && l.groups.includes("23-БА") && l.subject === "Бизнес-анализ", ); assert.deepEqual( lessons.map((l) => [l.condition, l.variant, l.room, l.teacher]), [ ["7,9,11н.", "upper", "430", "Войтенок Ж.Е."], ["4-12н", "lower", "430", "Войтенок Ж.Е."], ], ); assert.equal(lessons[1].sourceColor, "CCFFCC"); assert.equal(lessons[0].start, "10:05"); assert.equal(lessons[0].end, "11:30"); const plan = s.lessons.find( (l) => l.subject === "Бизнес-планирование" && l.start === "11:45" && l.day === 4, ); assert.equal(plan.condition, "1-8,10-17"); assert.ok(s.warnings.length); }); test("ФЭФ: кураторский час в 16.50 не становится невозможной датой", () => { const l = fef().lessons.find( (l) => l.groups.includes("23-БА") && l.day === 1 && l.subject.includes("кураторский"), ); assert.equal(l.start, "16:50"); assert.equal(l.end, null); assert.equal(l.date, null); assert.equal(l.condition, ""); for (const lesson of fef().lessons) if (lesson.date) assert.ok(Number.isFinite(new Date(lesson.date).getTime())); }); test("ФЭФ: объединённое занятие не дублируется на зелёной строке", () => { const s = fef(); const l = s.lessons.filter( (l) => l.day === 2 && l.groups.includes("23-БА") && l.subject === "Управление проектами" && l.start === "15:20", ); assert.equal(l.length, 1); assert.equal(l[0].variant, "all"); }); test("Дневные таблицы четырёх факультетов распознаются со своими группами", () => { for (const [name, groups] of [ ["fit-daily", ["24-ИТ-1", "24-ИТ-2"]], ["fkne-daily", ["24-КБ", "24-КФ"]], ["isf-daily", ["26-ПГС-1", "26-ПГС-2"]], ["gf-daily", ["25-РГФ-1", "25-РГФ-2"]], ]) { const s = parseDocx(fixture(name), { name }); assert.deepEqual(s.groups, groups); assert.ok(s.lessons.length > 20); assert.ok( s.lessons.every( (l) => l.day >= 1 && l.day <= 7 && /^\d{2}:\d{2}$/.test(l.start), ), ); assert.ok( s.lessons.every((l) => l.groups.every((g) => groups.includes(g))), ); } }); test("Даты практики в заголовке группы не становятся группами", () => { // Заголовки из каталога 2026–2027 (ГФ и ФКНЭ). assert.deepEqual( groupsIn("23-ДО\n(02.11-22.11-производственная практика)"), ["23-ДО"], ); assert.deepEqual( groupsIn("24-ПП\n(09.11 по 06.12-производственная практика)"), ["24-ПП"], ); assert.deepEqual( groupsIn("23-ТТиИ (16.11-29.11-учебная практика)"), ["23-ТТиИ"], ); assert.deepEqual(groupsIn("25-ИЯ(РА) 23 ИНС-1 24ДОи"), [ "25-ИЯ(РА)", "23-ИНС-1", "24-ДОи", ]); }); test("Сессии: отдельные колонки времени, аудитории и преподавателя", () => { const s = parseDocx(fixture("fit"), { name: "26-ИТзс.docx" }), l = s.lessons[0]; assert.deepEqual(s.groups, ["26-ИТзс"]); assert.equal(l.date, "2026-09-17"); assert.equal(l.start, "11:00"); assert.equal(l.end, "11:30"); assert.equal(l.teacher, "Петрович О.Н."); assert.equal(l.room, "220Д"); assert.deepEqual(l.subgroups, []); assert.match(l.raw, /Петрович/); assert.equal(s.period, "Установочная сессия"); const f = parseDocx(fixture("fkne"), {}); assert.deepEqual(f.groups, ["26-ЭЭТзс"]); assert.ok(f.lessons.every((l) => l.date)); }); test("Импорт отклоняет повреждённые и посторонние файлы", async () => { assert.throws(() => parseDocx(Buffer.from("broken")), /повреждён/); await assert.rejects( () => parseWord(Buffer.from("not a word file")), /настоящий файл Word/, ); }); test("DAV: метаданные вложенных документов и только доверенный каталог", () => { const xml = `/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/HTTP/1.1 200 OKhttps://evil.example/file.docx`; const entries = decodeListing(xml); assert.equal(entries.length, 1); assert.equal(entries[0].folder, true); const metadata = JSON.parse( readFileSync(new URL("./fixtures/fef-23-ba.docx.json", import.meta.url)), ); const file = fileMetadata(metadata); assert.equal(file.faculty, "Финансово-экономический факультет"); assert.equal(file.course, "4 курс"); assert.equal(file.name, "23-ЛГ_23-БА.docx"); }); // Laboratory-examination sessions of correspondence students (23-ИТзс): weeks side by side, // the group named only in the title, a date on every day. const itzs = (name) => parseDocx(fixture(name), { name: `${name}.docx` }); const at = (s, date, start) => s.lessons .filter((l) => l.date === date && l.start === start) .map((l) => [l.type, l.subject, l.teacher, l.room, l.subgroups.map((g) => g.label).join()]); test("Заочная сессия: группа из заголовка, недели рядом, даты из Word", () => { const s = itzs("itzs-winter-2024"); assert.deepEqual(s.groups, ["23-ИТзс"]); assert.equal(s.period, "Лабораторно-экзаменационная сессия"); // Every filled discipline cell of the three weeks is one lesson. assert.equal(s.lessons.length, 36); assert.ok(s.lessons.every((l) => l.date >= "2024-01-08" && l.date <= "2024-01-28")); // Two columns under one heading are the subgroups' laboratory classes. assert.deepEqual(at(s, "2024-01-08", "17:00"), [ ["Лабораторная", "Основы информационных технологий и компьютерной графики", "Михнович О.В.", "217Д", "Подгруппа 1"], ["Лабораторная", "Основы алгоритмизации и программирования", "Конаплева Г.Ф.", "218Д", "Подгруппа 2"], ]); assert.deepEqual(at(s, "2024-01-15", "17:00"), [ ["Установочная лекция", "Математический анализ", "Забелендик О.Н.", "220Д", ""], ]); // "Экзамен по учебной дисциплине «…»": the subject from the quotes, a room with a name. assert.deepEqual(at(s, "2024-01-18", "08:30"), [ ["Экзамен", "Основы алгоритмизации и программирования", "Чертков В.М.", "Медиа", ""], ]); assert.deepEqual(at(s, "2024-01-16", "13:40"), [ ["Защита курсовой работы", "Основы алгоритмизации и программирования", "Чертков В.М.", "254В", ""], ]); }); test("Заочная сессия: неровное деление столбцов и экзамен без конца", () => { const s = itzs("itzs-summer-2026"); assert.deepEqual(s.groups, ["23-ИТзс"]); assert.equal(s.lessons.length, 58); // The third week's heading spans three columns, split 2+1 or 1+2: left and right subgroup. assert.deepEqual(at(s, "2026-06-24", "08:30").map((l) => [l[1], l[4]]), [ ["Методы и алгоритмы принятия", "Подгруппа 1"], ["Распределенные вычисления", "Подгруппа 2"], ]); // "8.30" alone: the exam starts then; its end comes from the bell table. const exam = s.lessons.find((l) => l.date === "2026-06-18" && l.type === "Экзамен"); assert.deepEqual([exam.start, exam.end, exam.subject], ["08:30", null, "Базы данных"]); assert.equal(s.lessons.filter((l) => l.type === "Консультация").length, 4); }); test("Расписание зачетов: дата перед днём недели, одно время, зачёты по умолчанию", () => { const s = parseDocx(fixture("isf-credits-26-arh"), { name: "26-АРХ.docx" }); assert.deepEqual(s.groups, ["26-АРХ"]); assert.equal(s.period, "Зачёты"); assert.equal(s.lessons.length, 9); const row = (l) => [l.date, l.start, l.end, l.type, l.subject, l.subgroups.map((g) => g.label).join()]; const sorted = [...s.lessons].sort((a, b) => (a.date + a.start).localeCompare(b.date + b.start)); assert.deepEqual(sorted.slice(0, 2).map(row), [ ["2026-10-27", "17:00", null, "Консультация", "Введение в архитектурное проектирование", ""], ["2026-10-31", "08:30", null, "Зачёт", "Введение в архитектурное проектирование", ""], ]); // An empty date cell continues the day above: the second consultation on 18 December. assert.deepEqual( s.lessons.filter((l) => l.date === "2026-12-18").map((l) => [l.start, l.subject]), [ ["08:30", "Проектирование небольшого архитектурного сооружения"], ["15:20", "Рисунок"], ], ); // Two columns under the group: each subgroup takes its credit with its own teacher. assert.deepEqual( s.lessons.filter((l) => l.date === "2026-12-22").map((l) => [l.teacher, l.room, l.subgroups[0]?.label]), [ ["Кузякова О.Д.", "361", "Подгруппа 1"], ["Пискун А.А.", "370", "Подгруппа 2"], ], ); }); test("Пометки недель из нескольких частей через «;» и «,»", () => { for (const [cell, condition, ranges] of [ ["1-4н.;6-12н. Современная политэкономия (п.з.)", "1-4н.;6-12н.", [[1, 4], [6, 12]]], ["1-4;10-14н. Материаловедение и технологии (л.)", "1-4;10-14н.", [[1, 4], [10, 14]]], ["2,4н.;10-16н. Охрана труда (л.з.)", "2,4н.;10-16н.", [[2, 2], [4, 4], [10, 16]]], ["2-5 н., 10-17 н. Конструкция автомобиля (л.)", "2-5 н., 10-17 н.", [[2, 5], [10, 17]]], ]) { const d = details(cell); assert.equal(d.condition, condition, cell); assert.deepEqual(parseWeekRanges(d.condition), ranges, cell); assert.ok(!/^\d/.test(d.subject), d.subject); } }); test("Дата и время в начале ячейки", () => { assert.equal(leadingDate("3.12 Микроконтроллерные устройства", 2026).date, "2026-12-03"); assert.equal(leadingDate("24.11. Алгоритмы компьютерной графики", 2026).date, "2026-11-24"); assert.equal(leadingDate("09.09.Конструкционные материалы", 2026).date, "2026-09-09"); assert.equal(leadingDate("12.09.26г. Аналитическая геометрия", 2026).date, "2026-09-12"); assert.equal(leadingDate("14.02 Химия", 2026).date, "2027-02-14"); // Not dates: a time, an ambiguous one-digit day, a list handled by DATE_LIST. assert.equal(leadingDate("9.00 Информационный час", 2026), null); assert.equal(leadingDate("9.10 Химия", 2026), null); assert.equal(leadingDate("03.12, 17.12 Химия", 2026), null); assert.deepEqual(leadingTime("11:10-11:30 – информационный час"), { starts: ["11:10"], end: "11:30", length: 11 }); assert.deepEqual(leadingTime("9.00 Информационный час").starts, ["09:00"]); assert.deepEqual(leadingTime("18.35. Налоговое право").starts, ["18:35"]); assert.deepEqual(leadingTime("17.00 и 18.35 Русский язык").starts, ["17:00", "18:35"]); assert.deepEqual(leadingTime("18.35+20.10 Новополоцк").starts, ["18:35", "20:10"]); // "20.10" is also 20 October: only taken as a time when the caller says so. assert.equal(leadingTime("20.10 Теория государственного управления"), null); assert.deepEqual(leadingTime("20.10 Теория государственного управления", true).starts, ["20:10"]); assert.equal(leadingTime("3.12 Микроконтроллерные устройства", true), null); });