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'