Тесты и инфраструктура
- 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:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,4 +1,8 @@
|
|||||||
**/*.idea
|
__pycache__/
|
||||||
**/*.env
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
*.idea
|
||||||
/.idea/
|
/.idea/
|
||||||
.env
|
**/*.env
|
||||||
|
.env
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
7
Makefile
7
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: ## Собрать образы микросервисов
|
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
|
||||||
|
|||||||
14
deploy.sh
14
deploy.sh
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
httpx2
|
pytest==9.1.1
|
||||||
|
httpx2==2.12.0
|
||||||
|
|||||||
@@ -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
6
tests/conftest.py
Normal 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
111
tests/fixtures.py
Normal 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
207
tests/test_api.py
Normal 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
349
tests/test_lessons.py
Normal 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
173
tests/test_parsing.py
Normal 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
78
tests/test_xlsx.py
Normal 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
164
tests/xlsx_builder.py
Normal 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()
|
||||||
Reference in New Issue
Block a user