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

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

164
tests/xlsx_builder.py Normal file
View File

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