Тесты и инфраструктура

- tests/: 104 теста — lessons (все форматы ячеек), xlsx (мержи/скрытые
  столбцы/красный текст/даты), parsing (группы/недели/индексы), API (TestClient,
  TTL/modified/stale-while-error, 404/400/502)
- xlsx_builder.py: генерация xlsx в памяти для тестов без openpyxl
- docker-compose: healthz-хелскчеки (не блокируют старт), SCHEDULE_LINK
- Makefile: target test
- requirements: закреплённые версии
- .gitignore: __pycache__/, .venv/ и т.д.
This commit is contained in:
2026-09-09 11:33:58 +03:00
parent 2febf9b4ff
commit 9c9875579c
14 changed files with 1122 additions and 18 deletions

8
.gitignore vendored
View File

@@ -1,4 +1,8 @@
**/*.idea __pycache__/
**/*.env *.py[cod]
.venv/
.pytest_cache/
*.idea
/.idea/ /.idea/
**/*.env
.env .env

View File

@@ -11,7 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt \
FROM base AS api FROM base AS api
COPY --chown=app:app src/backend/main.py src/backend/schedule_parser.py ./ COPY --chown=app:app src/backend/main.py ./
COPY --chown=app:app src/backend/schedule ./schedule
USER app USER app
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -1,4 +1,4 @@
.PHONY: build up down logs restart deploy ps dev .PHONY: build up down logs restart deploy ps dev test
build: ## Собрать образы микросервисов build: ## Собрать образы микросервисов
docker compose build docker compose build
@@ -21,9 +21,12 @@ ps: ## Статус микросервисов
deploy: ## Собрать и запустить (деплой на сервере) deploy: ## Собрать и запустить (деплой на сервере)
./deploy.sh ./deploy.sh
test: ## Прогнать тесты (локальный venv)
$(CURDIR)/.venv/bin/python -m pytest tests -q
dev: ## Запустить локально без Docker (бэкенд + фронт) dev: ## Запустить локально без Docker (бэкенд + фронт)
@echo "Запуск бэкенда на :8000 и фронта на :8080..." @echo "Запуск бэкенда на :8000 и фронта на :8080..."
@trap 'kill 0' EXIT; \ @trap 'kill 0' EXIT; \
cd src/backend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8000 & \ cd src/backend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8000 & \
cd src/frontend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --port 8080 & \ cd src/frontend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8080 & \
wait wait

View File

@@ -3,13 +3,19 @@ set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
if [ ! -f .env ]; then if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1;
echo "Ошибка: файл .env не найден. Скопируйте .env.example в .env и укажите schedule_link." >&2 then
echo "Ошибка: нужен Docker с compose-плагином (v2)." >&2
exit 1 exit 1
fi fi
if ! grep -qE '^schedule_link=.+' .env; then if [ ! -f .env ]; then
echo "Ошибка: в .env не заполнена переменная schedule_link." >&2 echo "Ошибка: файл .env не найден. Скопируйте .env.example в .env и укажите SCHEDULE_LINK." >&2
exit 1
fi
if ! grep -qE '^SCHEDULE_LINK=.+' .env; then
echo "Ошибка: в .env не заполнена переменная SCHEDULE_LINK." >&2
exit 1 exit 1
fi fi

View File

@@ -4,13 +4,14 @@ services:
context: . context: .
target: api target: api
environment: environment:
schedule_link: ${schedule_link} SCHEDULE_LINK: ${SCHEDULE_LINK}
SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900} SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900}
SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60}
ports: ports:
- "${API_PORT:-8000}:8000" - "${API_PORT:-8000}:8000"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/groups', timeout=5)"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5)"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -29,7 +30,7 @@ services:
api: api:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/', timeout=5)"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=5)"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@@ -1,2 +1,3 @@
-r requirements.txt -r requirements.txt
httpx2 pytest==9.1.1
httpx2==2.12.0

View File

@@ -1,4 +1,4 @@
fastapi fastapi==0.141.1
uvicorn uvicorn==0.52.4
python-dotenv python-dotenv==1.2.3
requests requests==2.34.2

6
tests/conftest.py Normal file
View File

@@ -0,0 +1,6 @@
import sys
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parents[1] / "src" / "backend"
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))

111
tests/fixtures.py Normal file
View File

@@ -0,0 +1,111 @@
"""Общая тестовая таблица: 2 группы, 2 дня, мержи, скрытый столбец, красный текст."""
from xlsx_builder import SheetSpec, build_workbook
MAIN = "Бакалавры и магистры"
ROOMS = "аудитории"
TEACHERS = "Преподаватели"
# серии Excel
# недели ч: 08.31-09.06, 14.09-20.09, 28.09-04.10, 12.10-18.10
# недели з: 07.09-13.09, 21.09-27.09, 05.10-11.10, 19.10-25.10
W1 = (46265, 46271, 46272, 46278)
W2 = (46279, 46285, 46286, 46292)
W3 = (46293, 46299, 46300, 46306)
W4 = (46307, 46313, 46314, 46320)
def main_sheet() -> SheetSpec:
g1 = "ИВТ-11БО\nсеместр до 11.01.27"
g2 = "ИТ-12БО\nсеместр до 29.12.26"
hidden = "СКРЫ-11БО\nсеместр до 1.1.27"
math_red = [
("Математика\nСмирнов А.В.\nауд. 215\n", False),
("пара 14.09 не будет", True),
]
rows = [
# 0: заголовок
["числитель", "", "знаменатель", "", "день недели", "пара\nвремя",
g1, g2, hidden, None],
# 1: с/по
["с", "по", "с", "по", "день недели", "пара\nвремя", None, None, None, None],
# 2: понедельник, пара 1, ч
[*W1, "понедельник", "1\n9:00-\n10:35", math_red, "Физика\nИванов И.И.\nауд. 301"],
# 3: понедельник, пара 1, з
[*W2, "", "", "Математика\nСмирнов А.В.\nауд. 216", None],
# 4: понедельник, пара 2, ч
[*W3, "", "2\n10:45- 12:20", "к.в. - 2\nФизика\nИванов И.И.\nауд. 301",
"Химия\nПетров П.П.\nонлайн"],
# 5: понедельник, пара 2, з
[*W4, "", "", None, "к.в.\nХимия\nПетров П.П.\nауд. 201"],
# 6: вторник, пара 1, ч
[*W1, "вторник", "1\n9:00-\n10:35", "Биология\nСидорова С.С.\nауд. 220", None],
# 7: вторник, пара 1, з
[*W2, "", "", None, None],
]
return SheetSpec(
rows=rows,
merges=["F3:F4", "F5:F6", "F7:F8"],
hidden_cols=[(8, 8)],
)
def rooms_sheet() -> SheetSpec:
rows = [
[None, None, None, None, None, None, "215", "301", "220"],
[None, None, None, None, None, None, "комп.класс, 12 мест",
"интерактивная панель, 24 места", "20 мест"],
]
return SheetSpec(rows=rows)
def teachers_sheet() -> SheetSpec:
rows = [
["числитель", "числитель", "знаменатель", "знаменатель", "день недели", "пара\nвремя"],
[None, None, None, None, None, None,
"Смирнов\nАлександр Викторович", "Иванов\nИван Иванович"],
[*W1, "понедельник", "1\n9:00-\n10:35",
"Математика\nСмирнов А.В.\nауд. 215", "Физика\nИванов И.И.\nауд. 301"],
]
return SheetSpec(rows=rows)
def fixture_sheets() -> dict[str, SheetSpec]:
return {MAIN: main_sheet(), ROOMS: rooms_sheet(), TEACHERS: teachers_sheet()}
def fixture_xlsx() -> bytes:
return build_workbook(fixture_sheets())
def make_sheets(sheets: dict[str, SheetSpec]):
"""SheetSpec-фикстуры -> те же объекты, что парсит parse_workbook."""
from schedule.xlsx import parse_workbook
return parse_workbook(build_workbook(sheets))
def make_cache(monkeypatch, xlsx: bytes | None, modified: str = "fixed-modified"):
"""ScheduleCache с заменёнными сетевыми функциями."""
import schedule.cache as cache_mod
from schedule import ScheduleCache
calls = {"download": 0, "modified": 0}
fail = {"download": False, "modified": False}
def fake_download(url, timeout=60.0):
calls["download"] += 1
if fail["download"]:
from schedule import ScheduleError
raise ScheduleError("сетевой сбой (тест)")
return xlsx
def fake_modified(url, timeout=20.0):
calls["modified"] += 1
return None if fail["modified"] else modified
monkeypatch.setattr(cache_mod, "download_schedule", fake_download)
monkeypatch.setattr(cache_mod, "fetch_table_modified", fake_modified)
cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60)
return cache, calls, fail

207
tests/test_api.py Normal file
View File

@@ -0,0 +1,207 @@
"""Тесты API через TestClient: маршруты, ошибки, кэш (TTL/modified/stale-while-error)."""
import time
from urllib.parse import quote
import pytest
from fastapi.testclient import TestClient
import main
from fixtures import fixture_xlsx, make_cache
from schedule import ScheduleCache, ScheduleError
from schedule import parsing
@pytest.fixture()
def cache(monkeypatch):
c, calls, fail = make_cache(monkeypatch, fixture_xlsx())
c.calls = calls
c.fail = fail
return c
@pytest.fixture()
def client(cache):
app = main.create_app(cache)
with TestClient(app) as c:
yield c
# --- обслуживание ----------------------------------------------------------------
def test_healthz(client):
assert client.get("/healthz").json() == {"ok": True}
def test_root_lists_endpoints(client):
body = client.get("/").json()
assert "/groups" in body["endpoints"]
# --- справочники ------------------------------------------------------------------
def test_groups(client):
assert client.get("/groups").json() == ["ИВТ-11БО", "ИТ-12БО"]
def test_teachers(client):
assert client.get("/teachers").json() == [
"Иванов И.И.", "Петров П.П.", "Сидорова С.С.", "Смирнов А.В.",
]
def test_rooms(client):
assert client.get("/rooms").json() == [
"ауд. 201", "ауд. 215", "ауд. 216", "ауд. 220", "ауд. 301",
]
def test_group_schedule(client):
body = client.get("/schedule/ИВТ-11БО").json()
assert body["semester_until"] == "11.01.27"
p1 = body["days"]["понедельник"]["1"]
assert p1["time"] == "9:00-10:35"
assert p1["ч"]["lessons"][0]["room"] == "ауд. 215"
assert p1["ч"]["lessons"][0]["note"] == "пара 14.09 не будет"
assert p1["з"]["lessons"][0]["room"] == "ауд. 216"
assert body["days"]["понедельник"]["2"]["ч"]["lessons"][0]["marker"] == "к.в. - 2"
def test_group_schedule_day(client):
body = client.get("/schedule/ИВТ-11БО/понедельник").json()
assert body["day"] == "понедельник"
assert "1" in body["periods"]
empty = client.get("/schedule/ИВТ-11БО/суббота").json()
assert empty["periods"] == {}
def test_group_not_found(client):
resp = client.get("/schedule/НЕТ-11БО")
assert resp.status_code == 404
def test_bad_day(client):
resp = client.get("/schedule/ИВТ-11БО/бука")
assert resp.status_code == 400
def test_today(client):
resp = client.get("/today", params={"group": "ИВТ-11БО"})
assert resp.status_code == 200
body = resp.json()
assert body["day"] in parsing.DAYS
assert body["group"] == "ИВТ-11БО"
assert client.get("/today", params={"group": "НЕТ"}).status_code == 404
def test_teacher(client):
body = client.get("/teacher/Смирнов А.В.").json()
assert body["full_name"] == "Смирнов Александр Викторович"
assert body["days"]["понедельник"]["1"]["ч"][0]["subject"] == "Математика"
assert client.get("/teacher/Нет А.Н.").status_code == 404
def test_room(client):
body = client.get("/room/" + quote("ауд. 215")).json()
assert body["info"] == "комп.класс, 12 мест"
assert body["days"]["понедельник"]["1"]["ч"][0]["subject"] == "Математика"
assert client.get("/room/" + quote("ауд. 999")).status_code == 404
def test_rooms_free(client):
free = client.get("/rooms-free", params={"day": "суббота", "period": 1}).json()
assert [r["room"] for r in free] == ["ауд. 201", "ауд. 215", "ауд. 216", "ауд. 220", "ауд. 301"]
busy_day = client.get("/rooms-free", params={"day": "понедельник", "period": 1}).json()
assert [r["room"] for r in busy_day] == ["ауд. 201", "ауд. 220"]
assert client.get("/rooms-free", params={"day": "суббота", "period": 9}).status_code == 400
assert client.get("/rooms-free", params={"day": "бука", "period": 1}).status_code == 400
def test_rooms_day(client):
body = client.get("/rooms-day", params={"day": "понедельник"}).json()
assert body["day"] == "понедельник"
rooms = {r["room"]: r for r in body["rooms"]}
assert set(rooms) == {"ауд. 201", "ауд. 215", "ауд. 216", "ауд. 220", "ауд. 301"}
room_301 = rooms["ауд. 301"]
assert room_301["periods"]["1"]["time"] == "9:00-10:35"
assert room_301["periods"]["1"]["ч"][0]["subject"] == "Физика"
assert rooms["ауд. 201"]["periods"]["2"]["з"][0]["subject"] == "Химия"
assert client.get("/rooms-day", params={"day": "бука"}).status_code == 400
def test_week(client):
body = client.get("/week").json()
assert len(body["weeks"]) == 8
assert body["weeks"][0] == {"parity": "ч", "start": "2026-08-31", "end": "2026-09-06"}
def test_status(client):
body = client.get("/status").json()
assert body["error"] is None
assert body["table_modified"] == "fixed-modified"
assert body["last_updated"]
assert body["cache_ttl_seconds"] == 3600
# --- кэш ---------------------------------------------------------------------------
def test_cache_no_repeated_download(client, cache):
client.get("/groups")
client.get("/teachers")
client.get("/rooms")
assert cache.calls["download"] == 1
def test_cache_modified_unchanged_no_download(cache):
cache._ttl = 0 # даже с истёкшим TTL
cache.get()
cache.get()
assert cache.calls["download"] == 1
assert cache.calls["modified"] >= 2
def test_cache_stale_while_error(cache):
cache._ttl = 0 # форсим повторную попытку
cache.get()
data_before, _ = cache.get()
# полный обрыв связи: и modified, и download
cache.fail["download"] = True
cache.fail["modified"] = True
try:
cache.get() # ошибка -> старые данные, без эксепсов
except ScheduleError:
pytest.fail("ожидается stale-ответ, а не ошибка")
status = cache.status()
assert status["error"] is not None
assert len(data_before) == 2
# повторная попытка в пределах retry_after не скачивает повторно
downloads = cache.calls["download"]
cache.get()
assert cache.calls["download"] == downloads
# после retry_after и исправления сети — успешное обновление
cache._retry_after = 0
cache.fail["download"] = False
cache.fail["modified"] = False
cache.get()
assert cache.status()["error"] is None
def test_cache_failure_without_data_raises(cache):
cache.fail["download"] = True
with pytest.raises(ScheduleError):
cache.get()
assert cache.status()["last_updated"] is None
def test_broken_cache_http(client=None):
from fastapi.testclient import TestClient as TC
app = main.create_app(main._BrokenCache())
with TC(app) as c:
assert c.get("/healthz").status_code == 200
assert c.get("/groups").status_code == 502
body = c.get("/status").json()
assert "SCHEDULE_LINK" in body["error"]

349
tests/test_lessons.py Normal file
View File

@@ -0,0 +1,349 @@
"""Тесты разбора ячеек занятий: каждый найденный в таблице формат."""
import pytest
from schedule.lessons import Lesson, parse_lesson_cell
R = "\u27EA" # ⟨
Cc = "\u27EB" # ⟩
def lessons(text):
return [l.to_dict() for l in parse_lesson_cell(text)]
def one(text):
ls = parse_lesson_cell(text)
assert len(ls) == 1, f"ожидалося 1 занятие, got: {ls}"
return ls[0].to_dict()
# --- базовые формы -------------------------------------------------------------
def test_simple_three_lines():
assert one("Математика\nСмирнов А.В.\nауд. 215") == {
"marker": "", "subject": "Математика", "teacher": "Смирнов А.В.",
"room": "ауд. 215", "note": "",
}
def test_two_lines_no_room():
assert one("ТВИМС\nКоролев Г.К") == {
"marker": "", "subject": "ТВИМС", "teacher": "Королев Г.К", "room": "", "note": "",
}
def test_single_word_subject():
assert one("ДАСП") == {
"marker": "", "subject": "ДАСП", "teacher": "", "room": "", "note": "",
}
def test_acronym_subject_not_org():
for subject in ("МАИС", "БЖД", "ООП", "ЯПМТ", "ТВиМС", "ФТД"):
result = one(f"{subject}\nГладков А.Н.\nауд. 207")
assert result["subject"] == subject, subject
assert result["teacher"] == "Гладков А.Н."
def test_multiple_teachers_slash():
result = one("Иностранный язык\nМастакова Н.К./Умнова И.В./Чвягина Т.В.\nауд. 305 / 207")
assert result["teacher"] == "Мастакова Н.К./Умнова И.В./Чвягина Т.В."
assert result["room"] == "ауд. 305 / 207"
def test_subject_lines_join():
result = one("Методы компьютерной лингвистики\nв ИИ - 2\nПарамонов И.В.\nауд. 231")
assert result["subject"] == "Методы компьютерной лингвистики в ИИ - 2"
assert result["teacher"] == "Парамонов И.В."
assert result["room"] == "ауд. 231"
# --- маркеры -------------------------------------------------------------------
@pytest.mark.parametrize(
"raw, expected",
[
("к.в. - 2\nФизика\nИванов И.И.\nауд. 301", "к.в. - 2"),
("к.в.-7\nСети Петри\nБашкин В.А.\nауд. 224", "к.в. - 7"),
("к.в.\nХимия\nПетров П.П.\nауд. 201", "к.в."),
("к.в. - 1 \nОсновы тестирования\nОкопный Марк Олегович\nонлайн", "к.в. - 1"),
("к.в.-2 Распределенные технологии\nКорсаков С.В.\nауд. 201", "к.в. - 2"),
],
)
def test_markers(raw, expected):
assert one(raw)["marker"] == expected
def test_marker_alone_line_then_blank():
ls = lessons("к.в. \n\nОсновы веб-технологий\nЛавровский В.А.\nауд. 210")
assert len(ls) == 1
assert ls[0]["marker"] == "к.в."
assert ls[0]["subject"] == "Основы веб-технологий"
def test_marker_inherited():
ls = lessons("к.в. - 3\nA\nБелов А.А.\nауд. 1\n\nB\nБелов А.А.\nауд. 2")
assert [l["marker"] for l in ls] == ["к.в. - 3", "к.в. - 3"]
# --- аудитории и локации ---------------------------------------------------------
def test_room_normalization():
result = one("Математика\nСмирнов А.В.\nауд312")
assert result["room"] == "ауд. 312"
def test_room_with_building():
result = one("Основы права (л)\nКолпазанова Е.В.\nюридический корпус, 3 этаж ауд. 22\nначало в 16:05")
assert result["subject"] == "Основы права (л)"
assert result["teacher"] == "Колпазанова Е.В."
assert result["room"] == "юридический корпус, 3 этаж ауд. 22"
assert result["note"] == "начало в 16:05"
def test_room_with_time_after():
result = one("Бизнес-аналитика\nЕлин А.С.\nонлайн\nс 17:00 до 20:00\nс 8.09.26")
assert result["room"] == "онлайн"
assert "с 17:00 до 20:00" in result["note"]
assert "с 8.09.26" in result["note"]
def test_room_online_with_time_inside():
result = one("Облачные сервисы\nБегунов А.В.\nонлайн (начало в 14:30)")
assert result["room"] == "онлайн"
assert result["note"] == "начало в 14:30"
def test_room_url():
result = one("Физкультура (онлайн)\nhttps://next.uniyar.ac.ru/course/view.php?id=407")
assert result["subject"] == "Физкультура (онлайн)"
assert result["room"].startswith("https://next.uniyar.ac.ru")
assert result["teacher"] == ""
def test_location_instead_of_room():
result = one("Прикладная физическая культура\nТкач О.С.\nпервый этаж, восточное крыло")
assert result["room"] == "первый этаж, восточное крыло"
assert result["note"] == ""
def test_room_paren_note():
result = one("Базы данных\nПолетаев А.Ю.\nауд. 223 (возможно и 221)")
assert result["room"] == "ауд. 223"
assert result["note"] == "возможно и 221"
def test_room_paren_alternative_kept():
result = one("Сетевые технологии Huawei\nКорсаков С.В.\nауд.201 (221)")
assert result["room"] == "ауд. 201 (221)"
assert result["note"] == ""
# --- преподаватели ---------------------------------------------------------------
def test_full_name_teacher():
result = one("Основы тестирования\nОкопный Марк Олегович (БИФИТ)\nонлайн\nначало в 12:00")
assert result["teacher"] == "Окопный Марк Олегович (БИФИТ)"
assert result["room"] == "онлайн"
assert result["note"] == "начало в 12:00"
def test_full_name_with_company():
result = one("Проектный практикум\nЛабеев Игорь Владимирович (Корус консалтинг)\nначало занятия в 18:30 онлайн\nссылка в эл. университете")
assert result["teacher"] == "Лабеев Игорь Владимирович (Корус консалтинг)"
assert result["room"] == "онлайн"
assert "начало занятия в 18:30" in result["note"]
assert "ссылка в эл. университете" in result["note"]
def test_teacher_with_substitute():
result = one("Параллельное программирование\nМещеряков И.А. (Федулов Д.Д.) (начало пары в 14:05)\nауд.213")
assert result["teacher"] == "Мещеряков И.А. (Федулов Д.Д.)"
assert result["room"] == "ауд. 213"
assert result["note"] == "начало пары в 14:05"
def test_teacher_lecture_marker_kept():
result = one("Теория автоматов\nСмирнов А.В. (л)\nауд.312")
assert result["teacher"] == "Смирнов А.В. (л)"
def test_org_as_teacher():
result = one("Управление ИТ-проектами\nПравоТех\nауд. 309")
assert result["teacher"] == "ПравоТех"
assert result["subject"] == "Управление ИТ-проектами"
def test_single_word_org_in_teacher_position():
result = one("Оценка качества ПО\nХинт")
assert result["teacher"] == "Хинт"
assert result["subject"] == "Оценка качества ПО"
def test_org_as_location():
ls = lessons(
"Сети Петри\nБашкин В.А.\nауд. 224\n\nСовременные инструменты промышленной разработки\nКоновал О.В.\nНПО Криста"
)
second = ls[1]
assert second["teacher"] == "Коновал О.В."
assert second["room"] == "НПО Криста"
def test_teacher_and_room_on_one_line():
result = one("Логические контроллеры\nКузьмин Е.В.ауд. 301 (начало в 12:30)")
assert result["teacher"] == "Кузьмин Е.В."
assert result["room"] == "ауд. 301"
assert result["note"] == "начало в 12:30"
def test_initials_without_space():
result = one("Методы сжатия КрасновМ.В.\nауд.231")
assert result["subject"] == "Методы сжатия"
assert result["teacher"] == "КрасновМ.В."
assert result["room"] == "ауд. 231"
def test_inline_subject_teacher_room():
result = one("Сети Петри Башкин В.А. ауд. 224")
assert result["subject"] == "Сети Петри"
assert result["teacher"] == "Башкин В.А."
assert result["room"] == "ауд. 224"
# --- заметки ---------------------------------------------------------------------
def test_red_note():
assert one(f"Математика\nСмирнов А.В.\nауд. 215\n{R}пара 14.09 не будет{Cc}")["note"] == "пара 14.09 не будет"
def test_red_note_on_room_line():
result = one(f"Русский язык\nШаманова М.В.\nауд.312 {R}с 7.09 по 20.09 пары не будет{Cc}")
assert result["room"] == "ауд. 312"
assert result["note"] == "с 7.09 по 20.09 пары не будет"
def test_red_note_multiline_with_embedded_lesson():
raw = (
"Теория автоматов\nСмирнов А.В. (л)\nауд.312\n"
f"{R}занятия начнутся с 21.09\nИностранный язык\nБугрова Е.И.\n"
f"ауд. 312 у ИТ-22БО 7.09 и 14.09 с 12:30{Cc}"
)
result = one(raw)
assert result["subject"] == "Теория автоматов"
assert result["teacher"] == "Смирнов А.В. (л)"
assert result["room"] == "ауд.312" or result["room"] == "ауд. 312"
assert "занятия начнутся с 21.09" in result["note"]
assert "Бугрова Е.И." in result["note"]
def test_red_note_between_teacher_and_room():
raw = f"Математика\nСмирнов А.В.\n{R}(пара 8.09 не будет)\n{Cc}ауд. 203"
result = one(raw)
assert result["teacher"] == "Смирнов А.В."
assert result["room"] == "ауд. 203"
assert result["note"] == "пара 8.09 не будет"
def test_only_red_text_gives_lesson_with_note():
result = one(f"{R}нет занятий{Cc}")
assert result["note"] == "нет занятий"
def test_date_note_not_room():
result = one("Практика в НПО Криста\nс 9.09.2026")
assert result["subject"] == "Практика в НПО Криста"
assert result["room"] == ""
assert result["note"] == "с 9.09.2026"
# --- несколько занятий в одной ячейке ---------------------------------------------
def test_two_lessons_blank_line():
ls = lessons("Сети Петри\nБашкин В.А.\nауд. 224\n\nСовременные инструменты\nКоновал О.В.\nауд. 301")
assert [l["subject"] for l in ls] == ["Сети Петри", "Современные инструменты"]
assert [l["room"] for l in ls] == ["ауд. 224", "ауд. 301"]
def test_two_lessons_room_subject_border():
ls = lessons("Логические контроллеры\nКузьмин Е.В.ауд. 301 (начало в 12:30)\nЧисленные методы\nГлызин С.Д. ауд. 312")
assert [l["subject"] for l in ls] == ["Логические контроллеры", "Численные методы"]
assert [l["teacher"] for l in ls] == ["Кузьмин Е.В.", "Глызин С.Д."]
assert [l["room"] for l in ls] == ["ауд. 301", "ауд. 312"]
def test_group_tag_after_room_is_note():
result = one("Алгебра и геометрия\nСедов А.Г.\nауд.219\nИТ-11")
assert result["room"] == "ауд. 219"
assert result["note"] == "ИТ-11"
def test_lesson_with_red_between_lessons():
ls = lessons(
"Введение в промышленную разработку\nПарамонов И.В.\n"
f"ауд. 220 {R}2.09 пара отменяется{Cc}\n\n"
"Язык UML и CASE-системы\nЛарина Ю.А.\nауд.210"
)
assert ls[0]["note"] == "2.09 пара отменяется"
assert ls[0]["room"] == "ауд. 220"
assert ls[1]["subject"] == "Язык UML и CASE-системы"
# --- пустые и экзотические ----------------------------------------------------------
def test_empty_cell():
assert parse_lesson_cell("") == []
assert parse_lesson_cell(" \n ") == []
def test_cell_with_underscores_subject():
lesson = one("Промышленное программирование\nПарамонов И.В.\nауд. 220")
assert lesson["subject"] == "Промышленное программирование"
def test_lesson_subject_only():
result = one(f"{R}не будет{Cc}")
assert result["subject"] == ""
assert result["note"] == "не будет"
def test_unclosed_paren_in_table():
# в таблице не закрыли скобку вокруг красной заметки
result = one("Основы веб-технологий\nЛавровский В.А. (" + R + "предмет начнется с 8.09)" + Cc + "\nауд. 210")
assert result["teacher"] == "Лавровский В.А."
assert result["room"] == "ауд. 210"
assert result["note"] == "предмет начнется с 8.09"
def test_inline_lesson_after_room_no_blank_line():
raw = (
"к.в. - 2\nАлгоритмы и структуры данных Волченков С.Г. "
+ R + "(14.09 по 30.09 пары нет) " + Cc
+ "ауд.219 \nМетоды сжатия КрасновМ.В. ауд.231"
)
ls = lessons(raw)
assert len(ls) == 2
assert ls[0]["room"] == "ауд. 219"
assert "14.09 по 30.09 пары нет" in ls[0]["note"]
assert ls[1]["subject"] == "Методы сжатия"
assert ls[1]["teacher"] == "КрасновМ.В."
assert ls[1]["room"] == "ауд. 231"
def test_plain_time_paren_line():
result = one("Параллельное программирование (пр)\nМещеряков И.А.\nауд. 213\n(начало пары в 12:30)")
assert result["note"] == "начало пары в 12:30"
def test_full_name_inline_after_subject():
result = one("Основы тестирования веб-приложений Денис Верховцев (Сигма)\nонлайн")
assert result["subject"] == "Основы тестирования веб-приложений"
assert result["teacher"] == "Денис Верховцев (Сигма)"
assert result["room"] == "онлайн"

173
tests/test_parsing.py Normal file
View File

@@ -0,0 +1,173 @@
"""Тесты разбора листов: группы, дни/пары/чётность, индексы, вспомогательные листы."""
from datetime import date
import pytest
from fixtures import fixture_sheets, make_sheets
from schedule import parsing
from schedule.errors import ScheduleError
@pytest.fixture()
def sheets():
return make_sheets(fixture_sheets())
@pytest.fixture()
def data(sheets):
return parsing.parse_schedule(sheets)
def test_hidden_group_excluded(data):
assert set(data) == {"ИВТ-11БО", "ИТ-12БО"}
def test_semester(data):
assert data["ИВТ-11БО"]["semester_until"] == "11.01.27"
assert data["ИТ-12БО"]["semester_until"] == "29.12.26"
def test_day_period_parity(data):
mon = data["ИВТ-11БО"]["days"]["понедельник"]
p1 = mon[1]
assert p1["time"] == "9:00-10:35"
assert p1["ч"] is not None and p1["з"] is not None
assert "Математика" in p1["ч"]
assert "216" in p1["з"]
def test_empty_parity_slot_kept(data):
mon = data["ИТ-12БО"]["days"]["понедельник"]
assert mon[1]["з"] is None
# время на пустой слот подставлено
assert mon[1]["time"] == "9:00-10:35"
def test_lessons_preparsed(data):
mon = data["ИВТ-11БО"]["days"]["понедельник"]
lesson = mon[1]["lessons"]["ч"][0]
assert lesson["subject"] == "Математика"
assert lesson["teacher"] == "Смирнов А.В."
assert lesson["room"] == "ауд. 215"
assert lesson["note"] == "пара 14.09 не будет"
# пустое parity -> None
assert data["ИТ-12БО"]["days"]["понедельник"][1]["lessons"]["з"] is None
def test_marker_parsed(data):
mon_g1 = data["ИВТ-11БО"]["days"]["понедельник"]
mon_g2 = data["ИТ-12БО"]["days"]["понедельник"]
assert mon_g1[2]["lessons"]["ч"][0]["marker"] == "к.в. - 2"
assert mon_g2[2]["lessons"]["з"][0]["marker"] == "к.в."
def test_tuesday_only_ch(data):
tue = data["ИВТ-11БО"]["days"]["вторник"]
assert tue[1]["ч"] and "Биология" in tue[1]["ч"]
assert tue[1]["з"] is None
def test_missing_main_sheet(sheets):
del sheets[parsing.MAIN_SHEET]
with pytest.raises(ScheduleError):
parsing.parse_schedule(sheets)
def test_odd_day_block_warns(caplog):
sheets = make_sheets(fixture_sheets())
# уберём вторую строку понедельника (пара 1, з) -> нечётное число строк
rows = sheets[parsing.MAIN_SHEET].rows
# строки: 0 header, 1 с/по, 2..5 понедельник, 6..7 вторник
del rows[3]
with caplog.at_level("WARNING"):
result = parsing.parse_schedule(sheets)
assert any("сдвинуться" in r.message for r in caplog.records)
# разбор всё равно завершается
assert "ИВТ-11БО" in result
def test_week_ranges(sheets):
weeks = parsing.extract_week_ranges(sheets)
assert len(weeks) == 8
assert weeks[0] == {"parity": "ч", "start": date(2026, 8, 31), "end": date(2026, 9, 6)}
assert weeks[1]["parity"] == "з"
assert weeks[-2]["parity"] == "ч"
def test_current_parity():
sheets = make_sheets(fixture_sheets())
weeks = parsing.extract_week_ranges(sheets)
assert parsing.current_parity(weeks, date(2026, 9, 1)) == "ч"
assert parsing.current_parity(weeks, date(2026, 9, 9)) == "з"
assert parsing.current_parity(weeks, date(2030, 1, 1)) is None
def test_teacher_room_index(data):
teachers, rooms = parsing.build_teacher_room_index(data)
assert set(teachers) == {"Смирнов А.В.", "Иванов И.И.", "Петров П.П.", "Сидорова С.С."}
smirnov = teachers["Смирнов А.В."]["понедельник"]["1"]
assert len(smirnov["ч"]) == 1 and len(smirnov["з"]) == 1
assert smirnov["ч"][0]["subject"] == "Математика"
assert smirnov["ч"][0]["room"] == "ауд. 215"
assert smirnov["ч"][0]["groups"] == ["ИВТ-11БО"]
petrov_zn = teachers["Петров П.П."]["понедельник"]["2"]["з"]
assert petrov_zn[0]["marker"] == "к.в."
assert set(rooms) == {"ауд. 215", "ауд. 216", "ауд. 301", "ауд. 220", "ауд. 201"}
room_301 = rooms["ауд. 301"]["понедельник"]
assert room_301["1"]["ч"][0]["subject"] == "Физика"
assert room_301["1"]["ч"][0]["teacher"] == "Иванов И.И."
assert room_301["2"]["ч"][0]["groups"] == ["ИВТ-11БО"]
def test_day_times(data):
times = parsing.build_day_times(data)
assert times["понедельник"] == {"1": "9:00-10:35", "2": "10:45-12:20"}
assert times["вторник"] == {"1": "9:00-10:35"}
def test_group_schedule_shape(data):
out = parsing.build_group_schedule(data, "ИВТ-11БО")
assert out["group"] == "ИВТ-11БО"
assert out["semester_until"] == "11.01.27"
p1 = out["days"]["понедельник"]["1"]
assert p1["time"] == "9:00-10:35"
assert "raw" in p1["ч"] and "lessons" in p1["ч"]
assert p1["з"]["lessons"][0]["room"] == "ауд. 216"
assert parsing.build_group_schedule(data, "НЕТ-11БО") is None
def test_rooms_info(sheets):
info = parsing.parse_rooms_info(sheets)
assert info == {
"ауд. 215": {"room": "ауд. 215", "info": "комп.класс, 12 мест"},
"ауд. 301": {"room": "ауд. 301", "info": "интерактивная панель, 24 места"},
"ауд. 220": {"room": "ауд. 220", "info": "20 мест"},
}
def test_teachers_full(sheets):
full = parsing.parse_teachers_full(sheets)
assert full == {
"Смирнов А.В.": "Смирнов Александр Викторович",
"Иванов И.И.": "Иванов Иван Иванович",
}
def test_sort_rooms():
assert parsing.sort_rooms(["ауд. 10", "ауд. 9", "ауд. 100", "зал"]) == [
"ауд. 9", "ауд. 10", "ауд. 100", "зал",
]
def test_build_room_schedule(data):
_, rooms = parsing.build_teacher_room_index(data)
times = parsing.build_day_times(data)
out = parsing.build_room_schedule(rooms, "ауд. 301", times)
# дни с расписанием плюс дни, где есть время пар (даже без занятий)
assert set(out["days"]) == {"понедельник", "вторник"}
assert out["days"]["понедельник"]["1"]["ч"][0]["subject"] == "Физика"
assert parsing.build_room_schedule(rooms, "ауд. 999", times) is None

78
tests/test_xlsx.py Normal file
View File

@@ -0,0 +1,78 @@
"""Тесты чтения xlsx: ячейки, мержи, скрытые столбцы, красный текст, даты."""
import zipfile
from datetime import date
import pytest
from fixtures import fixture_sheets, fixture_xlsx, MAIN
from schedule.errors import ScheduleError
from schedule.xlsx import excel_serial_to_date, parse_workbook
def test_parse_workbook_fixture():
sheets = parse_workbook(fixture_xlsx())
assert set(sheets) == {"Бакалавры и магистры", "аудитории", "Преподаватели"}
rows = sheets[MAIN].rows
# заголовок группы
assert rows[0][6] == "ИВТ-11БО\nсеместр до 11.01.27"
# скрытый столбец
assert 8 in sheets[MAIN].hidden_cols
# дата-серия как число
assert rows[2][0] == "46265"
# мерж F3:F4 -> значение пары попало во вторую строку
assert rows[3][5] == "1\n9:00-\n10:35"
assert rows[2][5] == "1\n9:00-\n10:35"
def test_red_runs_marked():
sheets = parse_workbook(fixture_xlsx())
cell = sheets[MAIN].rows[2][6]
assert cell.startswith("Математика\nСмирнов А.В.\nауд. 215\n")
assert "\u27EA" in cell and "\u27EB" in cell
assert cell.endswith("пара 14.09 не будет\u27EB")
def test_empty_cells_are_empty_strings():
sheets = parse_workbook(fixture_xlsx())
assert sheets[MAIN].rows[5][6] == ""
# все строки одной ширины
widths = {len(r) for r in sheets[MAIN].rows}
assert len(widths) == 1
def test_bad_zip_raises():
with pytest.raises(ScheduleError):
parse_workbook(b"this is not an xlsx file")
def test_truncated_zip_raises():
good = fixture_xlsx()
with pytest.raises(ScheduleError):
parse_workbook(good[:20])
@pytest.mark.parametrize(
"serial, expected",
[
("46265", date(2026, 8, 31)),
(46265, date(2026, 8, 31)),
("46278", date(2026, 9, 13)),
# до 1900: Excel считает несуществующий 29.02.1900
(1, date(1900, 1, 1)),
(59, date(1900, 2, 28)),
(61, date(1900, 3, 1)),
],
)
def test_excel_serial_to_date(serial, expected):
assert excel_serial_to_date(serial) == expected
@pytest.mark.parametrize("bad", ["", "текст", None, "0", "-5", "1,5x"])
def test_excel_serial_to_date_invalid(bad):
# "1,5x" не парсится; остальные — None
if bad == "1,5x":
assert excel_serial_to_date(bad) is None
else:
assert excel_serial_to_date(bad) is None

164
tests/xlsx_builder.py Normal file
View File

@@ -0,0 +1,164 @@
"""Минимальный сборщик xlsx в памяти для тестов (без openpyxl).
Поддерживает: shared strings (включая красные ранки), цифры, мержи,
скрытые столбцы. Формат достаточно строгий, чтобы покрывать реальный
парсер (он читает именно этот поднабор OOXML).
"""
from __future__ import annotations
import io
import zipfile
from dataclasses import dataclass, field
from xml.sax.saxutils import escape
def col_letter(idx: int) -> str:
"""0-based индекс столбца -> 'A', 'B', ..., 'AA'."""
s = ""
idx += 1
while idx:
idx, rem = divmod(idx - 1, 26)
s = chr(ord("A") + rem) + s
return s
@dataclass
class SheetSpec:
rows: list[list]
merges: list[str] = field(default_factory=list)
hidden_cols: list[tuple[int, int]] = field(default_factory=list) # 0-based, инклязивно
def build_workbook(sheets: dict[str, SheetSpec]) -> bytes:
shared: list[str] = []
shared_index: dict[str, int] = {}
red_runs: dict[int, list[tuple[str, bool]]] = {}
def intern(value) -> tuple[str, str, object]:
"""-> (тип ячейки, атрибут t, значение v / None)"""
if value is None or value == "":
return None, "", None
if isinstance(value, (int, float)):
return "n", "", str(value)
if isinstance(value, list):
idx = len(shared)
shared.append(None)
red_runs[idx] = value
return "s", "s", idx
idx = shared_index.get(value)
if idx is None:
idx = len(shared)
shared_index[value] = idx
shared.append(value)
return "s", "s", idx
sheet_xml: dict[str, str] = {}
rels = []
sheet_defs = []
for i, (name, spec) in enumerate(sheets.items(), start=1):
rel_id = f"rId{i}"
rels.append(
f'<Relationship Id="{rel_id}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
f'Target="worksheets/sheet{i}.xml"/>'
)
sheet_defs.append(
f'<sheet name="{escape(name)}" sheetId="{i}" r:id="{rel_id}"/>'
)
cols = "".join(
f'<col min="{a + 1}" max="{b + 1}" hidden="1"/>'
for a, b in spec.hidden_cols
)
cols_xml = f"<cols>{cols}</cols>" if cols else ""
row_xml = []
for r, row in enumerate(spec.rows, start=1):
cells = []
for c, value in enumerate(row):
kind, t_attr, v = intern(value)
if kind is None:
continue
t = f' t="{t_attr}"' if t_attr else ""
cells.append(f'<c r="{col_letter(c)}{r}"{t}><v>{escape(str(v))}</v></c>')
if cells:
row_xml.append(f'<row r="{r}">{"".join(cells)}</row>')
merges_xml = ""
if spec.merges:
mc = "".join(f'<mergeCell ref="{m}"/>' for m in spec.merges)
merges_xml = f'<mergeCells count="{len(spec.merges)}">{mc}</mergeCells>'
sheet_xml[f"sheet{i}.xml"] = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f"{cols_xml}<sheetData>{''.join(row_xml)}</sheetData>{merges_xml}"
"</worksheet>"
)
sst_parts = []
for i, value in enumerate(shared):
if i in red_runs:
runs = "".join(
f'<r>{("<rPr><color rgb=\"FFFF0000\"/></rPr>" if red else "")}'
f"<t xml:space='preserve'>{escape(text)}</t></r>"
for text, red in red_runs[i]
)
sst_parts.append(f"<si>{runs}</si>")
else:
sst_parts.append(f"<si><t xml:space='preserve'>{escape(value)}</t></si>")
sst = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
f'count="{len(shared)}" uniqueCount="{len(shared)}">'
f"{''.join(sst_parts)}</sst>"
)
content_types = ["<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>",
"<Default Extension=\"xml\" ContentType=\"application/xml\"/>",
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>']
for i in range(1, len(sheets) + 1):
content_types.append(
f'<Override PartName="/xl/worksheets/sheet{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
)
if shared:
content_types.append(
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>'
)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(
"[Content_Types].xml",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
+ "".join(content_types)
+ "</Types>",
)
zf.writestr(
"_rels/.rels",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
"</Relationships>",
)
zf.writestr(
"xl/workbook.xml",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
f"<sheets>{''.join(sheet_defs)}</sheets></workbook>",
)
zf.writestr(
"xl/_rels/workbook.xml.rels",
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
+ "".join(rels)
+ "</Relationships>",
)
for fname, xml in sheet_xml.items():
zf.writestr(f"xl/worksheets/{fname}", xml)
if shared:
zf.writestr("xl/sharedStrings.xml", sst)
return buf.getvalue()