initial commit

This commit is contained in:
2026-10-16 22:08:45 +03:00
commit 19ee41411b
97 changed files with 15141 additions and 0 deletions

168
test/directory.test.js Normal file
View File

@@ -0,0 +1,168 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseDocx, details } from "../server/parser.js";
import { parseWeeksDocx } from "../server/calendar.js";
import { mergeWeekTables } from "../server/source.js";
import {
peopleAndRooms,
buildIndex,
occurrence,
search,
dayOf,
freeRooms,
examsOf,
isPhysicalRoom,
} from "../server/directory.js";
const fixture = (name) =>
readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
const calendar = {
...mergeWeekTables([parseWeeksDocx(fixture("weeks"))]),
bells: [
{ pair: 1, start: "08:30", end: "09:55", halves: [] },
{ pair: 2, start: "10:05", end: "11:30", halves: [] },
{ pair: 3, start: "11:45", end: "13:10", halves: [] },
],
source: {},
};
const doc = (id, schedule) => ({ id, file: { id, name: `${id}.docx` }, schedule });
const fef = doc("fef", parseDocx(fixture("fef-23-ba"), { name: "23-ЛГ_23-БА.docx", faculty: "ФЭФ" }));
const rooms = (raw, room = "") => peopleAndRooms({ raw, room, teacher: "" }, "2026");
test("Аудитории: реальные формулировки из Word", () => {
assert.deepEqual(rooms("Охрана труда (л.з.) Лемачко С.Н. ауд.205;6,8н.- Горунова С.А.", "205").rooms, ["205"]);
const moved = rooms("История (л.) Сумко Е.В. ауд. 432 14.10. -ауд. 333", "432");
assert.deepEqual(moved.rooms, ["432"]);
assert.deepEqual(moved.overrides, [{ date: "2026-10-14", room: "333" }]);
assert.deepEqual(rooms("Материаловедение (л.) Фруцкий В.А. ауд. 106н 17.10. -210 мк").overrides, [
{ date: "2026-10-17", room: "210МК" },
]);
// A typo glues the room to the date: "22417.09." is room 224 and a move on 17 September.
const glued = rooms("Информационные технологии (л.) Моняк Т.М. ауд. 22417.09.- ауд.332", "22417");
assert.deepEqual([glued.rooms, glued.overrides[0].room], [["224"], "332"]);
assert.deepEqual(rooms("Единый день информирования 10.09;15.10;19.11;17.12", "12").rooms, []);
assert.deepEqual(rooms("Информационный час ауд. В0-05", "05").rooms, ["В0-05"]);
assert.deepEqual(rooms("Физкультура ауд. 526н325н502н").rooms, ["526Н", "325Н", "502Н"]);
assert.deepEqual(rooms("Предмет (л.) Иванов И.И. ауд.129, Петров П.П. ауд. 130").teachers, [
"Иванов И.И.",
"Петров П.П.",
]);
assert.equal(rooms("Линейная алгебра (л.)\nЗабелендик О.Н.\n200 (ДО)", "200 (ДО)").distance, true);
assert.equal(rooms("Сети (л.) Лукьянов А.О. 200ДО").rooms.length, 0);
assert.deepEqual(rooms("История (л) Емельянчик О.А. 21/28 (дискозал)").rooms, ["21/28"]);
assert.ok(isPhysicalRoom("207Г") && isPhysicalRoom("А1-15") && !isPhysicalRoom("3"));
});
test("Занятость по дате учитывает недели, цвет строки и семестр", () => {
const idx = buildIndex([fef]);
const find = (subject, day) =>
idx.entries.find((e) => e.lesson.subject === subject && e.lesson.day === day && e.lesson.groups.includes("23-БА"));
// Friday 16 October 2026 is week 7: "1-4,6-11н." runs, "12-15н." does not.
assert.equal(occurrence(find("PR-менеджмент", 5), "2026-10-16", calendar), "yes");
const later = idx.entries.find((e) => e.lesson.condition === "12-15н." && e.lesson.day === 5);
assert.equal(occurrence(later, "2026-10-16", calendar), "no");
assert.equal(occurrence(later, "2026-11-20", calendar), "yes");
// An autumn schedule does not run in March.
assert.equal(occurrence(find("PR-менеджмент", 5), "2027-03-05", calendar), "no");
// A spring document without a spring week table: nothing is guessed.
const spring = doc("spring", { ...fef.schedule, period: "Весенний семестр" });
const entry = { ...find("PR-менеджмент", 5), doc: spring };
assert.equal(occurrence(entry, "2027-03-05", calendar), "unknown");
});
test("Поиск, день преподавателя и свободные аудитории", () => {
const idx = buildIndex([fef]);
idx.docs = [fef];
idx.total = 1;
idx.failed = [];
idx.year = "2026–2027";
assert.equal(search(idx, "селез").teachers[0].name, "Селезнева А.А.");
assert.equal(search(idx, "4").teachers.length, 0);
const day = dayOf(idx, calendar, { teacher: "Селезнева А.А." }, "2026-10-16");
assert.deepEqual(day.lessons.map((l) => [l.start, l.subject, l.rooms.join()]), [
["08:30", "Инновационный менеджмент", "333"],
]);
const room = dayOf(idx, calendar, { room: "129" }, "2026-10-16");
assert.ok(room.lessons.some((l) => l.subject === "PR-менеджмент"));
const free = freeRooms(idx, calendar, "2026-10-16", 2);
const freeList = free.free.flatMap((g) => g.rooms);
assert.ok(!freeList.includes("129"), "PR-менеджмент занимает 129 на 2-й паре");
assert.ok(freeList.includes("333"), "333 занята только на 1-й паре");
assert.equal(free.coverage.documents, 1);
assert.equal(freeRooms(idx, calendar, "2026-10-16", 9), null);
});
test("Экзамены, зачёты и консультации распознаются и собираются по группе", () => {
assert.deepEqual(
["Экзамен. Финансы организаций Иванов И.И. ауд.129", "Финансы (экз.) Иванов И.И.", "Зачет: Физическая культура Петров П.П.", "Консультация Финансы Иванов И.И."]
.map((raw) => [details(raw).type, details(raw).subject]),
[
["Экзамен", "Финансы организаций"],
["Экзамен", "Финансы"],
["Зачёт", "Физическая культура"],
["Консультация", "Финансы"],
],
);
assert.equal(details("Психология профессионального консультирования (л.) Сидоров С.С.").type, "Лекция");
const exam = (date, start, type, subject) => ({
id: date + start,
day: 1,
date,
start,
end: null,
subject,
teacher: "Иванов И.И.",
room: "129",
type,
condition: "",
groups: ["23-БА"],
subgroups: [],
variant: "all",
raw: `${type} ${subject} Иванов И.И. ауд.129`,
});
const session = doc("session", {
lessons: [
exam("2027-01-14", "09:00", "Экзамен", "Финансы"),
exam("2027-01-13", "15:00", "Консультация", "Финансы"),
exam("2027-01-14", "09:00", "Экзамен", "Финансы"),
{ ...exam("2027-01-20", "09:00", "Экзамен", "Маркетинг"), groups: ["23-ЛГ"] },
],
source: { faculty: "ФЭФ" },
period: "Экзаменационная сессия",
});
const idx = buildIndex([fef, session]);
const list = examsOf(idx, "23-БА", "ФЭФ");
assert.deepEqual(list.map((e) => [e.date, e.type, e.subject, e.rooms.join()]), [
["2027-01-13", "Консультация", "Финансы", "129"],
["2027-01-14", "Экзамен", "Финансы", "129"],
]);
assert.equal(examsOf(idx, "23-БА", "ФИТ").length, 0);
});
test("Поиск группы: любое написание, без учёта фильтров, с фасетами каталога", async () => {
const { findGroups, groupKey } = await import("../server/directory.js");
const fit = parseDocx(fixture("fit-daily"), { name: "24-ИТ.docx" });
const docs = [
{ id: "fef", file: { id: "fef", name: "23-ЛГ_23-БА.docx", faculty: "ФЭФ", course: "4 курс", form: "Дневная форма" }, schedule: fef.schedule },
{ id: "fit", file: { id: "fit", name: "24-ИТ.docx", faculty: "ФИТ", course: "3 курс", form: "Дневная форма" }, schedule: fit },
];
const idx = { docs, year: "2026–2027" };
assert.equal(groupKey("23 ба"), groupKey("23-БА"));
for (const q of ["23-БА", "23 ба", "23ба"])
assert.deepEqual(findGroups(idx, { q }).results.map((r) => r.group), ["23-БА"]);
// The query ignores the faculty filter: a student must find their group anywhere.
assert.deepEqual(
findGroups(idx, { q: "24-ит", faculty: "ФЭФ" }).results.map((r) => r.group),
["24-ИТ-1", "24-ИТ-2"],
);
const browse = findGroups(idx, { faculty: "ФИТ" });
assert.deepEqual(browse.results.map((r) => [r.group, r.fileId]), [
["24-ИТ-1", "fit"],
["24-ИТ-2", "fit"],
]);
assert.deepEqual(browse.facets.faculties.map((f) => f.name), ["ФИТ", "ФЭФ"]);
assert.deepEqual(browse.facets.courses.map((c) => c.name), ["3 курс"]);
assert.equal(findGroups(idx, {}).results.length, 0);
assert.equal(findGroups(idx, {}).total, 4);
});

BIN
test/fixtures/bells.docx vendored Normal file

Binary file not shown.

6
test/fixtures/bells.docx.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "Расписание звонков.docx",
"url": "https://nextcloud.psu.by/public.php/dav/files/no5zKbMrns6j5jQ/%D0%A0%D0%B0%D1%81%D0%BF%D0%B8%D1%81%D0%B0%D0%BD%D0%B8%D0%B5%20%D0%B7%D0%B2%D0%BE%D0%BD%D0%BA%D0%BE%D0%B2.docx",
"modified": "Wed, 12 Feb 2025 07:55:59 GMT",
"size": 16744
}

BIN
test/fixtures/fef-23-ba.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fef-23-ba.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b8%d0%bd%d0%b0%d0%bd%d1%81%d0%be%d0%b2%d0%be-%d1%8d%d0%ba%d0%be%d0%bd%d0%be%d0%bc%d0%b8%d1%87%d0%b5%d1%81%d0%ba%d0%b8%d0%b9%20%d1%84%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/4%20%d0%ba%d1%83%d1%80%d1%81/23-%d0%9b%d0%93_23-%d0%91%d0%90.docx",
"name": "23-ЛГ_23-БА.docx",
"folder": false,
"modified": "Mon, 12 Oct 2026 11:45:13 GMT",
"size": "40019"
}

BIN
test/fixtures/fit-daily.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fit-daily.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82%20%d0%b8%d0%bd%d1%84%d0%be%d1%80%d0%bc%d0%b0%d1%86%d0%b8%d0%be%d0%bd%d0%bd%d1%8b%d1%85%20%d1%82%d0%b5%d1%85%d0%bd%d0%be%d0%bb%d0%be%d0%b3%d0%b8%d0%b9/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/3%20%d0%ba%d1%83%d1%80%d1%81/24-%d0%98%d0%a2.docx",
"name": "24-ИТ.docx",
"folder": false,
"modified": "Mon, 12 Oct 2026 06:29:18 GMT",
"size": "44719"
}

BIN
test/fixtures/fit.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fit.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82%20%d0%b8%d0%bd%d1%84%d0%be%d1%80%d0%bc%d0%b0%d1%86%d0%b8%d0%be%d0%bd%d0%bd%d1%8b%d1%85%20%d1%82%d0%b5%d1%85%d0%bd%d0%be%d0%bb%d0%be%d0%b3%d0%b8%d0%b9/%d0%97%d0%b0%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a3%d1%81%d1%82%d0%b0%d0%bd%d0%be%d0%b2%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%81%d0%b5%d1%81%d1%81%d0%b8%d1%8f%20%d0%be%d1%81%d0%b5%d0%bd%d1%8c%202026-2027%20%d1%83%d1%87.%d0%b3/26-%d0%98%d0%a2%d0%b7%d1%81(ver.8.0).docx",
"name": "26-ИТзс(ver.8.0).docx",
"folder": false,
"modified": "Fri, 11 Sep 2026 11:38:07 GMT",
"size": "38898"
}

BIN
test/fixtures/fkne-daily.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fkne-daily.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82%20%d0%ba%d0%be%d0%bc%d0%bf%d1%8c%d1%8e%d1%82%d0%b5%d1%80%d0%bd%d1%8b%d1%85%20%d0%bd%d0%b0%d1%83%d0%ba%20%d0%b8%20%d1%8d%d0%bb%d0%b5%d0%ba%d1%82%d1%80%d0%be%d0%bd%d0%b8%d0%ba%d0%b8/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/3%20%d0%ba%d1%83%d1%80%d1%81/24-%d0%9a%d0%91_24-%d0%9a%d0%a4.docx",
"name": "24-КБ_24-КФ.docx",
"folder": false,
"modified": "Fri, 09 Oct 2026 12:15:18 GMT",
"size": "47417"
}

BIN
test/fixtures/fkne.docx vendored Normal file

Binary file not shown.

7
test/fixtures/fkne.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%a4%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82%20%d0%ba%d0%be%d0%bc%d0%bf%d1%8c%d1%8e%d1%82%d0%b5%d1%80%d0%bd%d1%8b%d1%85%20%d0%bd%d0%b0%d1%83%d0%ba%20%d0%b8%20%d1%8d%d0%bb%d0%b5%d0%ba%d1%82%d1%80%d0%be%d0%bd%d0%b8%d0%ba%d0%b8/%d0%97%d0%b0%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a3%d1%81%d1%82%d0%b0%d0%bd%d0%be%d0%b2%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%81%d0%b5%d1%81%d1%81%d0%b8%d1%8f%20%d0%be%d1%81%d0%b5%d0%bd%d1%8c%202026-2027%20%d1%83%d1%87.%d0%b3/26-%d0%ad%d0%ad%d0%a2%d0%b7%d1%81%20%20%20(%d1%83%d1%81%d1%82%d0%b0%d0%bd%d0%be%d0%b2%d0%be%d1%87%d0%bd%d0%b0%d1%8f%20%d1%81%d0%b5%d1%81%d1%81%d0%b8%d1%8f%20%202026-%202027)%20(3).docx",
"name": "26-ЭЭТзс (установочная сессия 2026- 2027) (3).docx",
"folder": false,
"modified": "Mon, 14 Sep 2026 13:05:15 GMT",
"size": "32155"
}

BIN
test/fixtures/gf-daily.docx vendored Normal file

Binary file not shown.

7
test/fixtures/gf-daily.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%93%d1%83%d0%bc%d0%b0%d0%bd%d0%b8%d1%82%d0%b0%d1%80%d0%bd%d1%8b%d0%b9%20%d1%84%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/2%20%d0%ba%d1%83%d1%80%d1%81/25-%d0%a0%d0%93%d0%a4-1_25-%d0%a0%d0%93%d0%a4-2.docx",
"name": "25-РГФ-1_25-РГФ-2.docx",
"folder": false,
"modified": "Wed, 16 Sep 2026 09:24:04 GMT",
"size": "39149"
}

BIN
test/fixtures/isf-daily.docx vendored Normal file

Binary file not shown.

7
test/fixtures/isf-daily.docx.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"path": "/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/%d0%98%d0%bd%d0%b6%d0%b5%d0%bd%d0%b5%d1%80%d0%bd%d0%be-%d1%81%d1%82%d1%80%d0%be%d0%b8%d1%82%d0%b5%d0%bb%d1%8c%d0%bd%d1%8b%d0%b9%20%d1%84%d0%b0%d0%ba%d1%83%d0%bb%d1%8c%d1%82%d0%b5%d1%82/%d0%94%d0%bd%d0%b5%d0%b2%d0%bd%d0%b0%d1%8f%20%d1%84%d0%be%d1%80%d0%bc%d0%b0/%d0%a0%d0%b0%d1%81%d0%bf%d0%b8%d1%81%d0%b0%d0%bd%d0%b8%d0%b5%20%d1%83%d1%87%d0%b5%d0%b1%d0%bd%d1%8b%d1%85%20%d0%b7%d0%b0%d0%bd%d1%8f%d1%82%d0%b8%d0%b9/1%20%d0%ba%d1%83%d1%80%d1%81/26-%d0%9f%d0%93%d0%a1-1_26-%d0%9f%d0%93%d0%a1-2%20(ver.13.0).docx",
"name": "26-ПГС-1_26-ПГС-2 (ver.13.0).docx",
"folder": false,
"modified": "Mon, 05 Oct 2026 09:11:46 GMT",
"size": "43263"
}

BIN
test/fixtures/weeks.docx vendored Normal file

Binary file not shown.

6
test/fixtures/weeks.docx.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "Расписание белых и зеленых недель.docx",
"url": "https://nextcloud.psu.by/public.php/dav/files/no5zKbMrns6j5jQ/%D0%A0%D0%B0%D1%81%D0%BF%D0%B8%D1%81%D0%B0%D0%BD%D0%B8%D0%B5%20%D0%B1%D0%B5%D0%BB%D1%8B%D1%85%20%D0%B8%20%D0%B7%D0%B5%D0%BB%D0%B5%D0%BD%D1%8B%D1%85%20%D0%BD%D0%B5%D0%B4%D0%B5%D0%BB%D1%8C.docx",
"modified": "Wed, 26 Aug 2026 10:38:45 GMT",
"size": 16051
}

82
test/lessons.test.js Normal file
View File

@@ -0,0 +1,82 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseDocx } from "../server/parser.js";
import {
lessonKey,
diffSchedules,
changeCount,
personalFilter,
subgroupsOf,
} from "../shared/lessons.js";
const fef = () =>
parseDocx(readFileSync(new URL("./fixtures/fef-23-ba.docx", import.meta.url)), {
name: "23-ЛГ_23-БА.docx",
});
const find = (s, group, test) =>
s.lessons.find((l) => l.groups.includes(group) && test(l));
test("Ключ занятия не зависит от строки таблицы и переживает перепубликацию", () => {
const a = fef(),
b = fef();
// Same content parsed twice: same keys, no changes.
assert.deepEqual(
a.lessons.map((l) => lessonKey(l, "23-БА")),
b.lessons.map((l) => lessonKey(l, "23-БА")),
);
assert.equal(changeCount(diffSchedules(a, b, "23-БА")), 0);
const moved = { ...a.lessons[0], id: "other-row" };
assert.equal(lessonKey(moved, "23-БА"), lessonKey(a.lessons[0], "23-БА"));
});
test("Сравнение версий: аудитория, перенос, новое и удалённое занятие", () => {
const before = fef(),
after = fef();
const room = find(after, "23-БА", (l) => l.day === 5 && l.start === "08:30");
room.room = "333А";
const pr = find(after, "23-БА", (l) => l.day === 5 && l.subject === "PR-менеджмент");
pr.start = "11:45";
const risk = find(after, "23-БА", (l) => l.day === 3 && l.subject === "Риск-менеджмент");
after.lessons.splice(after.lessons.indexOf(risk), 1);
after.lessons.push({ ...pr, id: "new", day: 6, start: "08:30", subject: "Новый предмет" });
// A change for the other group must not reach 23-БА.
find(after, "23-ЛГ", (l) => l.room).room = "999";
const diff = diffSchedules(before, after, "23-БА");
assert.deepEqual(diff.added.map((l) => l.subject), ["Новый предмет"]);
assert.deepEqual(diff.removed.map((l) => l.subject), ["Риск-менеджмент"]);
assert.deepEqual(
diff.changed.map((c) => [c.after.subject, c.fields.map((f) => `${f.field}:${f.from}→${f.to}`)]),
[
["Инновационный менеджмент", ["room:333→333А"]],
["PR-менеджмент", ["start:10:05→11:45"]],
],
);
assert.equal(changeCount(diff), 4);
});
test("Изменённая пометка недель видна как изменение, а не как новое занятие", () => {
const before = fef(),
after = fef();
find(after, "23-БА", (l) => l.condition === "6-13н").condition = "6-14н";
const diff = diffSchedules(before, after, "23-БА");
assert.equal(diff.added.length + diff.removed.length, 0);
assert.deepEqual(diff.changed[0].fields.map((f) => [f.label, f.from, f.to]), [
["Недели", "6-13н", "6-14н"],
]);
});
test("Подгруппа и скрытые занятия фильтруются только для своей группы", () => {
const s = fef();
assert.deepEqual(subgroupsOf(s, "23-ЛГ"), ["Подгруппа 1", "Подгруппа 2"]);
assert.deepEqual(subgroupsOf(s, "23-БА"), []);
const first = find(s, "23-ЛГ", (l) => l.subgroups.some((g) => g.label === "Подгруппа 1"));
const second = find(s, "23-ЛГ", (l) => l.subgroups.some((g) => g.label === "Подгруппа 2"));
const whole = find(s, "23-ЛГ", (l) => !l.subgroups.length);
const prefs = { subgroup: "Подгруппа 2", hidden: [lessonKey(whole, "23-ЛГ")] };
assert.equal(personalFilter(first, "23-ЛГ", prefs), "other-subgroup");
assert.equal(personalFilter(second, "23-ЛГ", prefs), "shown");
assert.equal(personalFilter(whole, "23-ЛГ", prefs), "hidden");
assert.equal(personalFilter(first, "23-ЛГ", null), "shown");
});

142
test/parser.test.js Normal file
View File

@@ -0,0 +1,142 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseDocx, parseWord } from "../server/parser.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("Сессии: отдельные колонки времени, аудитории и преподавателя", () => {
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 = `<d:multistatus xmlns:d="DAV:"><d:response><d:href>/public.php/dav/files/no5zKbMrns6j5jQ/2026-2027/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>https://evil.example/file.docx</d:href></d:response></d:multistatus>`;
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");
});

183
test/sources.test.js Normal file
View File

@@ -0,0 +1,183 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
pickYearFolder,
pickReferences,
isWeeksTable,
isBellSchedule,
mergeWeekTables,
fileMetadata,
decodeListing,
} from "../server/source.js";
import { parseWeeksDocx } from "../server/calendar.js";
import { parseDocx } from "../server/parser.js";
import {
addDays,
semesterFor,
semesterView,
lessonDates,
academicStartYear,
} from "../shared/weeks.js";
import { scheduleUpdate, namesGroup } from "../shared/updates.js";
const fixture = (name) =>
readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
const autumn = parseWeeksDocx(fixture("weeks"));
// A spring table in the same format: numbering restarts at 1 on Monday 8 February.
const spring = Array.from({ length: 17 }, (_, i) => ({
n: i + 1,
start: addDays("2027-02-08", i * 7),
end: addDays("2027-02-08", i * 7 + 6),
color: i % 2 ? "green" : "white",
}));
const bothSemesters = { ...mergeWeekTables([spring, autumn]), bells: [], source: {} };
const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/";
test("Учебный год: папка меняется 1 сентября, без новой папки остаётся прошлая", () => {
const folders = ["2025-2026", "2026-2027", "Майноры", "2026-2028"];
assert.equal(academicStartYear("2026-08-31"), 2025);
assert.equal(pickYearFolder(folders, "2026-08-31").name, "2025-2026");
assert.deepEqual(pickYearFolder(folders, "2026-09-01"), {
name: "2026-2027",
label: "2026–2027",
current: true,
expected: "2026-2027",
});
const late = pickYearFolder(folders, "2027-09-01");
assert.equal(late.name, "2026-2027");
assert.equal(late.current, false);
assert.equal(late.expected, "2027-2028");
// Only next year's folder published early: still better than nothing.
assert.equal(pickYearFolder(["2027-2028"], "2027-08-20").name, "2027-2028");
assert.equal(pickYearFolder(["Майноры"], "2026-10-16"), null);
});
test("Справочные файлы находятся по смыслу имени, папка года важнее корня", () => {
assert.ok(isWeeksTable("Расписание белых и зеленых недель.docx"));
assert.ok(isWeeksTable("Расписание белых и зелёных недель 2027.docx"));
assert.ok(!isWeeksTable("Расписание белых и зеленых недель.doc"));
assert.ok(!isWeeksTable("23-ЛГ_23-БА.docx"));
assert.ok(isBellSchedule("Расписание звонков (новое).docx"));
const entry = (path, modified, folder = false) => ({ path, modified, folder, size: 1 });
const refs = pickReferences(
[
entry(SHARE + encodeURIComponent("Расписание белых и зеленых недель.docx"), "Wed, 26 Aug 2026 10:00:00 GMT"),
entry(SHARE + encodeURIComponent("Расписание звонков.docx"), "Wed, 12 Feb 2025 07:55:59 GMT"),
entry(SHARE + "2026-2027/", null, true),
],
[
entry(SHARE + "2026-2027/" + encodeURIComponent("Белые и зелёные недели, весна.docx"), "Mon, 25 Jan 2027 09:00:00 GMT"),
],
);
assert.deepEqual(
refs.weeks.map((f) => [f.name, f.scope]),
[
["Белые и зелёные недели, весна.docx", "year"],
["Расписание белых и зеленых недель.docx", "root"],
],
);
assert.equal(refs.bells.name, "Расписание звонков.docx");
});
test("DAV: год берётся из пути документа, чужие ссылки отбрасываются", () => {
const xml = `<d:multistatus xmlns:d="DAV:"><d:response><d:href>${SHARE}2027-2028/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>https://evil.example/file.docx</d:href></d:response></d:multistatus>`;
assert.equal(decodeListing(xml).length, 1);
const metadata = JSON.parse(
readFileSync(new URL("./fixtures/fef-23-ba.docx.json", import.meta.url)),
);
const file = fileMetadata(metadata);
assert.equal(file.year, "2026–2027");
assert.equal(file.faculty, "Финансово-экономический факультет");
});
test("Таблица с двумя семестрами делится по сбросу нумерации", () => {
const { semesters, weeks } = bothSemesters;
assert.deepEqual(
semesters.map((s) => [s.id, s.label, s.start, s.end]),
[
["2026-autumn", "Осенний семестр 2026/27", "2026-09-01", "2027-01-03"],
["2026-spring", "Весенний семестр 2026/27", "2027-02-08", "2027-06-06"],
],
);
assert.equal(weeks.filter((w) => w.semester === "2026-spring").length, 17);
// The same table read twice (root and year folder) does not duplicate weeks.
assert.equal(mergeWeekTables([autumn, autumn]).weeks.length, 18);
});
test("Расписание получает недели своего семестра, а не первого попавшегося", () => {
const fef = parseDocx(fixture("fef-23-ba"), { name: "23-ЛГ_23-БА.docx" });
assert.equal(semesterFor(bothSemesters, fef, "2027-03-01").id, "2026-autumn");
const springSchedule = { ...fef, period: "Весенний семестр" };
const view = semesterView(bothSemesters, springSchedule, "2026-10-16");
assert.equal(view.semester.id, "2026-spring");
const lesson = { day: 1, date: null, condition: "1-15н", variant: "upper" };
assert.equal(lessonDates(lesson, view)[0], "2027-02-08");
assert.equal(lessonDates(lesson, semesterView(bothSemesters, fef, "2026-10-16"))[0], "2026-09-14");
// Spring table not published yet: no guessed dates.
const onlyAutumn = { ...mergeWeekTables([autumn]), bells: [], source: {} };
assert.equal(semesterFor(onlyAutumn, springSchedule, "2027-01-20"), null);
// Next year's autumn schedule against this year's table: no match either.
assert.equal(semesterFor(onlyAutumn, { ...fef, academicYear: "2027" }, "2027-09-10"), null);
// Unknown period: the semester that contains today, none between semesters.
const plain = { lessons: [{ date: null }], period: "Расписание из Word" };
assert.equal(semesterFor(bothSemesters, plain, "2026-11-02").id, "2026-autumn");
assert.equal(semesterFor(bothSemesters, plain, "2027-01-20"), null);
// A session schedule follows its own dates.
const session = { lessons: [{ date: "2027-02-10" }, { date: "2027-02-11" }], period: "Установочная сессия" };
assert.equal(semesterFor(bothSemesters, session, "2026-12-01").id, "2026-spring");
// A calendar cached before semesters existed still works.
assert.equal(
semesterView({ weeks: autumn, bells: [], source: {} }, fef, "2026-10-16").weeks.length,
18,
);
});
test("Обновления: тот же файл перечитывается, новый документ группы предлагается", () => {
const saved = {
schemaVersion: 5,
source: {
fileId: "a",
modified: "Mon, 12 Oct 2026 11:45:13 GMT",
importedAt: "2026-10-16T10:00:00Z",
faculty: "ФЭФ",
},
};
const file = (id, name, modified, faculty = "ФЭФ") => ({ id, name, modified, faculty });
const base = file("a", "23-ЛГ_23-БА.docx", saved.source.modified);
const options = { schemaVersion: 5 };
assert.equal(scheduleUpdate(saved, { files: [base] }, "23-БА", options), null);
const republished = { ...base, modified: "Tue, 20 Oct 2026 08:00:00 GMT" };
assert.equal(
scheduleUpdate(saved, { files: [republished] }, "23-БА", options).changed,
republished,
);
assert.equal(
scheduleUpdate({ ...saved, schemaVersion: 4 }, { files: [base] }, "23-БА", options).changed,
base,
);
const spring = file("b", "23-БА весна.docx", "Mon, 25 Jan 2027 09:00:00 GMT");
const others = [
file("c", "23-БАз.docx", "Mon, 25 Jan 2027 09:00:00 GMT"),
file("d", "23-БА.docx", "Mon, 25 Jan 2027 09:00:00 GMT", "ФИТ"),
file("e", "23-БА старое.docx", "Mon, 01 Sep 2026 09:00:00 GMT"),
];
const found = scheduleUpdate(saved, { files: [base, spring, ...others] }, "23-БА", options);
assert.equal(found.newer, spring);
assert.equal(found.changed, null);
assert.equal(
scheduleUpdate(saved, { files: [base, spring] }, "23-БА", { ...options, dismissed: ["b"] }),
null,
);
// New academic year: the old path is gone.
assert.deepEqual(scheduleUpdate(saved, { files: [] }, "23-БА", options), {
changed: null,
newer: null,
missing: true,
});
// An offline copy of the catalog proves nothing.
assert.equal(scheduleUpdate(saved, { files: [], stale: true }, "23-БА", options), null);
assert.ok(namesGroup("23-ЛГ_23-БА.docx", "23-БА"));
assert.ok(!namesGroup("123-БА.docx", "23-БА"));
assert.ok(!namesGroup("23-БАз.docx", "23-БА"));
});

123
test/weeks.test.js Normal file
View File

@@ -0,0 +1,123 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { parseDocx } from "../server/parser.js";
import { parseWeeksDocx, parseBellsDocx } from "../server/calendar.js";
import {
parseWeekRanges,
parseTextDates,
lessonWeeks,
lessonDates,
dateInWeek,
weekIndexOf,
todayIso,
} from "../shared/weeks.js";
const fixture = (name) =>
readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
const calendar = {
weeks: parseWeeksDocx(fixture("weeks")),
bells: parseBellsDocx(fixture("bells")),
};
const fef = () =>
parseDocx(fixture("fef-23-ba"), { name: "23-ЛГ_23-БА.docx" }).lessons.filter(
(l) => l.groups.includes("23-БА"),
);
test("Таблица недель: 18 недель, нечётные белые, неделя 1 со вторника", () => {
const { weeks } = calendar;
assert.equal(weeks.length, 18);
assert.deepEqual(weeks[0], {
n: 1,
start: "2026-09-01",
end: "2026-09-06",
color: "white",
});
assert.deepEqual(weeks.at(-1), {
n: 18,
start: "2026-12-28",
end: "2027-01-03",
color: "green",
});
assert.ok(weeks.every((w) => (w.n % 2 ? "white" : "green") === w.color));
assert.equal(dateInWeek(weeks[0], 1), null);
assert.equal(dateInWeek(weeks[0], 2), "2026-09-01");
assert.equal(weekIndexOf(calendar, "2026-10-16"), 6);
assert.equal(weekIndexOf(calendar, "2027-02-01"), -1);
assert.equal(todayIso(new Date("2026-10-15T22:30:00Z")), "2026-10-16");
});
test("Звонки: восемь пар с половинами", () => {
assert.equal(calendar.bells.length, 8);
assert.deepEqual(calendar.bells[1], {
pair: 2,
start: "10:05",
end: "11:30",
halves: [
["10:05", "10:45"],
["10:50", "11:30"],
],
});
});
test("Пометки недель: диапазоны, «с N», опечатки остаются для сверки", () => {
assert.deepEqual(parseWeekRanges("1-4,6-11н."), [
[1, 4],
[6, 11],
]);
assert.deepEqual(parseWeekRanges("со 2 нед."), [[2, null]]);
assert.deepEqual(parseWeekRanges("с 4-16н"), [[4, 16]]);
assert.deepEqual(parseWeekRanges("1-8,10-17"), [
[1, 8],
[10, 17],
]);
assert.equal(parseWeekRanges("2,6-12-16н"), null);
assert.equal(parseWeekRanges("26.10"), null);
assert.equal(parseWeekRanges(""), null);
});
test("Цвет строки ограничивает диапазон чётностью недели", () => {
const white = { day: 2, date: null, condition: "13-17н", variant: "upper" };
const green = { day: 4, date: null, condition: "4-12н", variant: "lower" };
const every = { day: 5, date: null, condition: "6-13н", variant: "all" };
const plain = { day: 1, date: null, condition: "", variant: "lower" };
assert.deepEqual(lessonWeeks(white, 18), [13, 15, 17]);
assert.deepEqual(lessonWeeks(green, 18), [4, 6, 8, 10, 12]);
assert.deepEqual(lessonWeeks(every, 18), [6, 7, 8, 9, 10, 11, 12, 13]);
assert.deepEqual(lessonWeeks(plain, 18), [2, 4, 6, 8, 10, 12, 14, 16, 18]);
assert.equal(
lessonWeeks({ ...white, condition: "2,6-12-16н" }, 18),
null,
);
assert.deepEqual(lessonDates(every, calendar).slice(0, 2), [
"2026-10-09",
"2026-10-16",
]);
});
test("Парсер: 23-БА получает номера недель и даты дня информирования", () => {
const lessons = fef();
const friday = lessons.filter((l) => l.day === 5 && l.start === "10:05");
assert.deepEqual(
friday.map((l) => l.weekRanges),
[[[1, 4], [6, 11]], [[12, 15]], [[16, 16]]],
);
const week7 = friday.filter((l) => lessonWeeks(l, 18).includes(7));
assert.deepEqual(
week7.map((l) => l.subject),
["PR-менеджмент"],
);
const info = lessons.find((l) =>
l.subject.startsWith("Единый день информирования"),
);
assert.deepEqual(info.dates, [
"2026-09-10",
"2026-10-15",
"2026-11-19",
"2026-12-17",
]);
assert.deepEqual(parseTextDates("17 декабря и 14 января", 2026), [
"2026-12-17",
"2027-01-14",
]);
// Every coloured-row mark in the real document agrees with the week table.
for (const l of lessons.filter((l) => l.variant !== "all" && l.weekRanges))
for (const [a, b] of l.weekRanges)
for (const n of [a, b ?? a])
assert.equal(n % 2, l.variant === "upper" ? 1 : 0, l.raw);
});