gendesign/backend/tests/services/test_2464_three_small_leaks.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

129 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Три места, где код делал не то, что говорил (#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