diff --git a/.gitignore b/.gitignore index 011ea13..07a7fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -**/*.idea -**/*.env +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +*.idea /.idea/ -.env \ No newline at end of file +**/*.env +.env diff --git a/Dockerfile b/Dockerfile index 2ac1028..e5f1e4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt \ 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 EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/Makefile b/Makefile index 1f5f1fb..a528fde 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build up down logs restart deploy ps dev +.PHONY: build up down logs restart deploy ps dev test build: ## Собрать образы микросервисов docker compose build @@ -21,9 +21,12 @@ ps: ## Статус микросервисов deploy: ## Собрать и запустить (деплой на сервере) ./deploy.sh +test: ## Прогнать тесты (локальный venv) + $(CURDIR)/.venv/bin/python -m pytest tests -q + dev: ## Запустить локально без Docker (бэкенд + фронт) @echo "Запуск бэкенда на :8000 и фронта на :8080..." @trap 'kill 0' EXIT; \ 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 diff --git a/deploy.sh b/deploy.sh index 6b76268..d8f27bf 100755 --- a/deploy.sh +++ b/deploy.sh @@ -3,13 +3,19 @@ set -euo pipefail cd "$(dirname "$0")" -if [ ! -f .env ]; then - echo "Ошибка: файл .env не найден. Скопируйте .env.example в .env и укажите schedule_link." >&2 +if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; +then + echo "Ошибка: нужен Docker с compose-плагином (v2)." >&2 exit 1 fi -if ! grep -qE '^schedule_link=.+' .env; then - echo "Ошибка: в .env не заполнена переменная schedule_link." >&2 +if [ ! -f .env ]; then + 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 fi diff --git a/docker-compose.yml b/docker-compose.yml index 4fad8b5..d37650f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,14 @@ services: context: . target: api environment: - schedule_link: ${schedule_link} + SCHEDULE_LINK: ${SCHEDULE_LINK} SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900} + SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60} ports: - "${API_PORT:-8000}:8000" restart: unless-stopped 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 timeout: 5s retries: 3 @@ -29,7 +30,7 @@ services: api: condition: service_healthy 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 timeout: 5s retries: 3 diff --git a/requirements-dev.txt b/requirements-dev.txt index 428ebb2..7f11a94 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ -r requirements.txt -httpx2 +pytest==9.1.1 +httpx2==2.12.0 diff --git a/requirements.txt b/requirements.txt index 3fe0617..8da7e0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -fastapi -uvicorn -python-dotenv -requests +fastapi==0.141.1 +uvicorn==0.52.4 +python-dotenv==1.2.3 +requests==2.34.2 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7d3d939 --- /dev/null +++ b/tests/conftest.py @@ -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)) diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..4f1d997 --- /dev/null +++ b/tests/fixtures.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..3c1c858 --- /dev/null +++ b/tests/test_api.py @@ -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"] diff --git a/tests/test_lessons.py b/tests/test_lessons.py new file mode 100644 index 0000000..ed9e2ea --- /dev/null +++ b/tests/test_lessons.py @@ -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"] == "онлайн" diff --git a/tests/test_parsing.py b/tests/test_parsing.py new file mode 100644 index 0000000..64ca6a7 --- /dev/null +++ b/tests/test_parsing.py @@ -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 diff --git a/tests/test_xlsx.py b/tests/test_xlsx.py new file mode 100644 index 0000000..3207769 --- /dev/null +++ b/tests/test_xlsx.py @@ -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 diff --git a/tests/xlsx_builder.py b/tests/xlsx_builder.py new file mode 100644 index 0000000..e93b0f9 --- /dev/null +++ b/tests/xlsx_builder.py @@ -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'' + ) + sheet_defs.append( + f'' + ) + + cols = "".join( + f'' + for a, b in spec.hidden_cols + ) + cols_xml = f"{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'{escape(str(v))}') + if cells: + row_xml.append(f'{"".join(cells)}') + + merges_xml = "" + if spec.merges: + mc = "".join(f'' for m in spec.merges) + merges_xml = f'{mc}' + + sheet_xml[f"sheet{i}.xml"] = ( + '' + '' + f"{cols_xml}{''.join(row_xml)}{merges_xml}" + "" + ) + + sst_parts = [] + for i, value in enumerate(shared): + if i in red_runs: + runs = "".join( + f'{("" if red else "")}' + f"{escape(text)}" + for text, red in red_runs[i] + ) + sst_parts.append(f"{runs}") + else: + sst_parts.append(f"{escape(value)}") + sst = ( + '' + '' + f"{''.join(sst_parts)}" + ) + + content_types = ["", + "", + ''] + for i in range(1, len(sheets) + 1): + content_types.append( + f'' + ) + if shared: + content_types.append( + '' + ) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr( + "[Content_Types].xml", + '' + '' + + "".join(content_types) + + "", + ) + zf.writestr( + "_rels/.rels", + '' + '' + '' + "", + ) + zf.writestr( + "xl/workbook.xml", + '' + '' + f"{''.join(sheet_defs)}", + ) + zf.writestr( + "xl/_rels/workbook.xml.rels", + '' + '' + + "".join(rels) + + "", + ) + 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()