Files
psu-schedule/test/places.test.js
Zakhar Timoshenko 10c85e3bb9 feat: read "Занятия в Полоцке" as a day's town, show it in all clients
- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 01:26:54 +03:00

145 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import AdmZip from "adm-zip";
import { parseDocx } from "../server/parser.js";
import { placeNote, lessonPlace } from "../server/places.js";
import { placesOn, placesFor } from "../shared/places.js";
import { parseWeeksDocx, parseBellsDocx } from "../server/calendar.js";
import { mergeWeekTables } from "../server/source.js";
import { timetable } from "../server/timetable.js";
// "Занятия в Полоцке" over a day's first row, as 24-ИЯ (РА)и-4…6 write it: no lesson, but the
// town of that day's lessons in the column.
const fixture = (name) => readFileSync(new URL(`./fixtures/${name}.docx`, import.meta.url));
const calendar = {
...mergeWeekTables([parseWeeksDocx(fixture("weeks"))]),
bells: parseBellsDocx(fixture("bells")),
source: {},
};
const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
const cell = (text, span = 1) =>
`<w:tc><w:tcPr>${span > 1 ? `<w:gridSpan w:val="${span}"/>` : ""}</w:tcPr>${text
.split("\n")
.map((line) => `<w:p><w:r><w:t>${escape(line)}</w:t></w:r></w:p>`)
.join("")}</w:tc>`;
const docx = (rows) => {
const zip = new AdmZip();
zip.addFile(
"word/document.xml",
Buffer.from(
`<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
`<w:p><w:r><w:t>Расписание. Осенний семестр 2026/2027 учебного года</w:t></w:r></w:p>` +
`<w:tbl>${rows.map((r) => `<w:tr>${r.join("")}</w:tr>`).join("")}</w:tbl></w:body></w:document>`,
),
);
return zip.toBuffer();
};
const schedule = parseDocx(
docx([
[cell("Дни"), cell("Часы"), cell("26-ИЯи-1", 2), cell("26-ИЯи-2", 2)],
[
cell("Вторник"),
cell("8:30-9:55"),
cell("Занятия в Полоцке"),
cell("Занятия в Полоцке\nФункциональная грамматика (п.з.) Иванова А.А. 249Ж"),
cell("Педагогика (л.) Петров П.П. 412н", 2),
],
[cell(""), cell("10:05-11:30"), cell("Педагогика (п.з.) Сидоров С.С. 205Г", 2), cell("Философия (п.з.) Козлов К.К. 510н", 2)],
[
cell("Среда"),
cell("8:30-9:55"),
cell("Латинский язык (п.з.) Нестер Н.В. 246(Новополоцк)", 2),
cell("г.Новополоцк\nРусский язык как иностранный (п.з.) Кириллова В.С. 533н"),
cell(""),
],
[cell("Четверг"), cell("8:30-9:55"), cell("История Полоцка (л.) Борун Е.Н. 100", 4)],
]),
{ name: "26-ИЯи.docx", year: "2026-2027" },
);
const file = { id: "places", name: "26-ИЯи.docx" };
const lesson = (day, start, group, subject) =>
schedule.lessons.find((l) => l.day === day && l.start === start && l.groups.includes(group) && l.subject.startsWith(subject));
test("Места: «Занятия в Полоцке» — не занятие, а город дня в столбце", () => {
assert.ok(!schedule.lessons.some((l) => /Занятия в/.test(l.subject)), "заметка не становится занятием");
assert.deepEqual(
schedule.places.map((p) => [p.day, p.groups.join(), p.subgroups.map((s) => s.label).join(), p.place, p.text]),
[
[2, "26-ИЯи-1", "Подгруппа 1", "Полоцк", "Занятия в Полоцке"],
[2, "26-ИЯи-1", "Подгруппа 2", "Полоцк", "Занятия в Полоцке"],
],
);
// The lesson under the note, in the same cell.
const grammar = lesson(2, "08:30", "26-ИЯи-1", "Функциональная грамматика");
assert.equal(grammar.place, "Полоцк");
assert.equal(grammar.room, "249Ж");
// A lesson of the whole group: both subgroups have the note.
assert.equal(lesson(2, "10:05", "26-ИЯи-1", "Педагогика").place, "Полоцк");
// Another group the same day stays where it was.
assert.equal(lesson(2, "08:30", "26-ИЯи-2", "Педагогика").place, undefined);
assert.equal(lesson(2, "10:05", "26-ИЯи-2", "Философия").place, undefined);
});
test("Места: город в ячейке занятия — отдельной строкой или после аудитории", () => {
const latin = lesson(3, "08:30", "26-ИЯи-1", "Латинский язык");
assert.equal(latin.room, "246");
assert.equal(latin.place, "Новополоцк");
const russian = lesson(3, "08:30", "26-ИЯи-2", "Русский язык");
assert.equal(russian.subject, "Русский язык как иностранный");
assert.equal(russian.place, "Новополоцк");
// A subject that names a town is no place.
const history = lesson(4, "08:30", "26-ИЯи-1", "История Полоцка");
assert.equal(history.subject, "История Полоцка");
assert.equal(history.place, undefined);
});
test("Места: разбор заметок и строк с городом", () => {
assert.deepEqual(placeNote("Занятия в Полоцке"), { text: "Занятия в Полоцке", place: "Полоцк", rest: "" });
assert.deepEqual(placeNote("Занятия в г. Новополоцк\nИнформационный час ауд.В0-02"), {
text: "Занятия в г. Новополоцк",
place: "Новополоцк",
rest: "Информационный час ауд.В0-02",
});
assert.equal(placeNote("Занятия в Междуречье.").place, "Междуречье");
assert.equal(placeNote("Занятия в Полоцке Философия (л.)").rest, "Философия (л.)");
assert.equal(placeNote("История Полоцка (л.)"), null);
assert.equal(placeNote("Занятия в ауд. 412н"), null);
assert.deepEqual(lessonPlace("121 (г.Новополоцк)"), { place: "Новополоцк", rest: "121" });
// A venue after the town stays in the text.
assert.deepEqual(lessonPlace("г. Полоцк, Дом офицеров"), { place: "Полоцк", rest: "г. Полоцк, Дом офицеров" });
assert.deepEqual(lessonPlace("Философия (л.) Борун Е.Н. 100"), { place: null, rest: "Философия (л.) Борун Е.Н. 100" });
});
test("Места: заметки дня для подгруппы — общие для сервера и PWA", () => {
const notes = [
{ day: 4, date: null, groups: ["26-ИЯи-2"], subgroups: [{ group: "26-ИЯи-2", label: "Подгруппа 1" }], place: "Новополоцк", text: "Занятия в Новополоцке" },
];
const labels = ["Подгруппа 1", "Подгруппа 2"];
const one = placesOn(notes, "26-ИЯи-2", labels, 4);
assert.deepEqual(one, [{ place: "Новополоцк", text: "Занятия в Новополоцке", subgroup: "Подгруппа 1" }]);
// All subgroups shown: the note says whose it is; the first subgroup: their own day.
assert.deepEqual(placesFor(one, null), one);
assert.deepEqual(placesFor(one, "Подгруппа 1"), [{ place: "Новополоцк", text: "Занятия в Новополоцке", subgroup: null }]);
assert.deepEqual(placesFor(one, "Подгруппа 2"), []);
assert.deepEqual(placesOn(notes, "26-ИЯи-1", labels, 4), []);
// Both subgroups of 26-ИЯи-1 on Tuesday: one note for the whole group.
assert.deepEqual(placesOn(schedule.places, "26-ИЯи-1", labels, 2), [{ place: "Полоцк", text: "Занятия в Полоцке", subgroup: null }]);
});
test("Места в /api/timetable: город дня и занятия", () => {
// Tuesday 13 and Wednesday 14 October 2026.
const one = timetable({ file, schedule, calendar, group: "26-ИЯи-1", from: "2026-10-13", days: 2 });
const [tuesday, wednesday] = one.days;
// The note in both subgroups is the whole group's.
assert.deepEqual(tuesday.places, [{ place: "Полоцк", text: "Занятия в Полоцке", subgroup: null }]);
assert.ok(tuesday.lessons.every((l) => l.place === "Полоцк"));
assert.equal(wednesday.places, undefined);
assert.equal(wednesday.lessons[0].place, "Новополоцк");
assert.deepEqual(one.weekly.find((d) => d.day === 2).places, tuesday.places);
const two = timetable({ file, schedule, calendar, group: "26-ИЯи-2", from: "2026-10-13", days: 1 });
assert.equal(two.days[0].places, undefined);
assert.ok(two.days[0].lessons.every((l) => l.place === null));
});