Тесты и инфраструктура
- 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
|
||||
**/*.env
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
*.idea
|
||||
/.idea/
|
||||
.env
|
||||
**/*.env
|
||||
.env
|
||||
|
||||
@@ -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"]
|
||||
|
||||
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: ## Собрать образы микросервисов
|
||||
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
|
||||
|
||||
14
deploy.sh
14
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
-r requirements.txt
|
||||
httpx2
|
||||
pytest==9.1.1
|
||||
httpx2==2.12.0
|
||||
|
||||
@@ -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
|
||||
|
||||
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