All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m17s
CI / backend-tests (pull_request) Successful in 17m20s
1. `load_water_reserves_from_docx` собирал `result` с ключом `period`,
печатал ПОЛНЫЙ словарь в лог, а возвращал
`{k: v for … if isinstance(v, int)}` — период это строка или None,
поэтому выбрасывался всегда. По логам казалось, что период отдаётся;
вызывающий не получал его ни разу.
Фильтр стоял ради аннотации `dict[str, int]` и ничего не защищал:
соседняя ветка `load_water_reserves` кладёт в тот же словарь
`{"error": str(...)}`, а единственный потребитель — задача
`sync_water_reserves` — результат логирует и возвращает как есть.
Период полезен: без него «загружено 42 записи» не отличить от
прошлогодних. Аннотация исправлена, иначе следующий проход mypy вернул
бы фильтр обратно — на это поставлен отдельный тест.
2. `get_sqlite_info` — TOCTOU: `p.exists()`, затем незащищённый
`p.stat()`. try/except покрывал только `sqlite3.connect` ниже, поэтому
OSError из stat улетал наружу и превращал диагностическую функцию в
источник отказа. Файл между проверками реально исчезает — его
переписывает выгрузка Объектива. Теперь отдаём то, что успели узнать,
с ключом `stat_error`.
3. `place_program` — предупреждение «участок мал» печатало КАТАЛОЖНЫЕ
`house.footprint_*`, хотя ставили по `fp_w`/`fp_d`. При
переопределённом в программе габарите сообщение называло размер,
которым никто не пытался ставить, и уводило от причины.
Двусторонне: против origin/main четыре теста красные с конкретными
значениями («период выброшен из ответа: {'records': 1, 'inserted': 1,
'updated': 0}», «аннотация всё ещё требует только int: dict[str, int]»).
Контроли зелёные с обеих сторон: отсутствующий файл по-прежнему даёт
exists=False без ошибки; `fp_w`/`fp_d` — действительно те размеры,
которыми ставят (иначе первый тест сверял бы имена, а не смысл).
pytest backend/tests/services/ — 3207 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
6.6 KiB
Python
129 lines
6.6 KiB
Python
"""Три места, где код делал не то, что говорил (#2464).
|
||
|
||
1. `load_water_reserves_from_docx` (vodokanal): собирал `result` с ключом `period`,
|
||
печатал полный словарь в лог, а возвращал `{k: v for … if isinstance(v, int)}` —
|
||
период это строка или None, поэтому он выбрасывался ВСЕГДА. По логам казалось,
|
||
что период отдаётся; вызывающий не получал его ни разу.
|
||
|
||
2. `get_sqlite_info` (objective_etl): TOCTOU — `p.exists()`, затем незащищённый
|
||
`p.stat()`. try/except покрывал только `sqlite3.connect` ниже, поэтому OSError
|
||
из stat улетал наружу и превращал диагностическую функцию в источник отказа.
|
||
|
||
3. `place_program` (placement): предупреждение «участок мал» печатало КАТАЛОЖНЫЕ
|
||
`house.footprint_*`, хотя ставили по `fp_w`/`fp_d`. Если элемент программы
|
||
переопределил габарит, сообщение называло размер, которым никто не пытался
|
||
ставить, — диагностика уводила от причины.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import inspect
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
# ── 1. period больше не выбрасывается ────────────────────────────────────────
|
||
|
||
|
||
def test_water_result_keeps_period() -> None:
|
||
"""Головной: период обязан доехать до вызывающего, раз он попал в лог.
|
||
|
||
На origin/main фильтр `isinstance(v, int)` выбрасывает его всегда.
|
||
"""
|
||
from app.services.site_finder import vodokanal_reserve_loader as mod
|
||
|
||
with (
|
||
patch.object(mod, "parse_docx_table_rows", lambda _x: [["шапка"]]),
|
||
patch.object(mod, "extract_water_rows", lambda _m: [{"name": "ЦСВ-1"}]),
|
||
patch.object(mod, "_dedupe_names", lambda r: r),
|
||
patch.object(mod, "infer_period", lambda _u: "III кв. 2025"),
|
||
patch.object(mod, "_upsert_water_rows", lambda *a, **k: {"inserted": 1, "updated": 0}),
|
||
patch.object(mod.zipfile, "ZipFile", MagicMock()),
|
||
):
|
||
res = mod.load_water_reserves_from_docx(MagicMock(), "supply", b"", "http://x")
|
||
assert (
|
||
res.get("period") == "III кв. 2025"
|
||
), f"период выброшен из ответа: {res} — по логам он есть, у вызывающего нет"
|
||
assert res.get("records") == 1 and res.get("inserted") == 1, res
|
||
|
||
|
||
def test_water_return_annotation_allows_non_int() -> None:
|
||
"""Контроль: фильтр стоял ради аннотации `dict[str, int]` — она тоже исправлена.
|
||
|
||
Иначе следующий проход mypy вернул бы фильтр обратно.
|
||
"""
|
||
from app.services.site_finder.vodokanal_reserve_loader import load_water_reserves_from_docx
|
||
|
||
ann = inspect.signature(load_water_reserves_from_docx).return_annotation
|
||
assert "int]" not in str(ann), f"аннотация всё ещё требует только int: {ann}"
|
||
|
||
|
||
# ── 2. TOCTOU в get_sqlite_info ──────────────────────────────────────────────
|
||
|
||
|
||
def test_sqlite_info_survives_file_vanishing_between_exists_and_stat() -> None:
|
||
"""Головной: файл исчез между exists() и stat() — функция не падает.
|
||
|
||
На origin/main OSError улетает наружу: try/except покрывает только connect.
|
||
"""
|
||
from app.services import objective_etl as mod
|
||
|
||
настоящий_stat = Path.stat
|
||
|
||
def _stat(self, *a, **k):
|
||
if str(self).endswith("исчезающий.sqlite"):
|
||
raise FileNotFoundError(2, "No such file or directory")
|
||
return настоящий_stat(self, *a, **k)
|
||
|
||
with (
|
||
patch.object(Path, "exists", lambda self: True),
|
||
patch.object(Path, "stat", _stat),
|
||
):
|
||
info = mod.get_sqlite_info("/tmp/исчезающий.sqlite")
|
||
|
||
assert info["exists"] is True
|
||
assert "stat_error" in info, f"ошибка stat не отражена в ответе: {info}"
|
||
assert "size_bytes" not in info, "размер выдуман при отсутствующем файле"
|
||
|
||
|
||
def test_sqlite_info_missing_file_unchanged() -> None:
|
||
"""Контроль: отсутствующий файл по-прежнему даёт exists=False без ошибок."""
|
||
from app.services.objective_etl import get_sqlite_info
|
||
|
||
info = get_sqlite_info("/tmp/такого-файла-нет-2464.sqlite")
|
||
assert info["exists"] is False
|
||
assert "stat_error" not in info, info
|
||
|
||
|
||
# ── 3. предупреждение placement называет фактические габариты ────────────────
|
||
|
||
|
||
def test_placement_warning_uses_actual_footprint_not_catalog() -> None:
|
||
"""Головной: в сообщении должны стоять fp_w/fp_d, а не house.footprint_*.
|
||
|
||
На origin/main при переопределённом габарите печатается каталожный размер.
|
||
"""
|
||
from app.services.generative import placement as mod
|
||
|
||
src = inspect.getsource(mod.place_program)
|
||
хвост = src[src.index("участок мал") :]
|
||
assert (
|
||
"fp_w," in хвост and "fp_d," in хвост
|
||
), f"в предупреждении не фактические габариты:\n{хвост[:320]}"
|
||
assert (
|
||
"house.footprint_w_m," not in хвост and "house.footprint_d_m," not in хвост
|
||
), f"в предупреждении остался каталожный размер:\n{хвост[:320]}"
|
||
|
||
|
||
@pytest.mark.parametrize("имя", ["fp_w", "fp_d"])
|
||
def test_placement_uses_those_names_for_placement(имя: str) -> None:
|
||
"""Контроль: fp_w/fp_d — это действительно те размеры, которыми ставят.
|
||
|
||
Без него тест выше проверял бы совпадение имён, а не смысл.
|
||
"""
|
||
from app.services.generative import placement as mod
|
||
|
||
src = inspect.getsource(mod.place_program)
|
||
assert "_centered_footprint(cell.cx, cell.cy, fp_w, fp_d)" in src, src[:200]
|
||
assert имя in src
|