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>
This commit is contained in:
144
test/places.test.js
Normal file
144
test/places.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
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, "&").replace(/</g, "<");
|
||||
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));
|
||||
});
|
||||
Reference in New Issue
Block a user