/analyze passes the official ЕКБ admin district (ekb_districts polygon, e.g.
'Кировский'), but objective_lots/corpus_room_month store informal micro-districts
('Втузгородок','ЖБИ') -> admin name matched 0 rows -> silent empty forecast.
Add resolve_objective_districts() (site_finder/district_resolver.py) mapping an
admin name to its clean micros via ekb_district_alias (note IS NULL), with
None -> EKB-wide fallback and raw-micro pass-through. Wire into the objective_lots
district filters of market_metrics (§9.2 stock+sales), supply_layers L1 (§9.3),
and sales_series Sources A+B (crm shares the micro vocab, prod-verified),
switching the scalar filter to psycopg3-safe = ANY(CAST(:districts AS text[])).
supply_layers L2/L3 keep the admin name (domrf_kn_objects.district_name is admin vocab).
Prod: Кировский/Ленинский/Орджоникидзевский obj_count 0 -> 32/64/31.
Tests mutation-verified non-vacuous. 192 module tests pass; ruff clean. Refs #969 #949.
644 lines
27 KiB
Python
644 lines
27 KiB
Python
"""Unit-тесты supply-layers service (#950 EPIC 6, ТЗ §9.3).
|
||
|
||
Чистые тесты (без живой БД):
|
||
• pure-арифметика (_hidden_units clamp ≥ 0, _l2_confidence по coverage,
|
||
_round_or_none) — hand-built фикстуры, включая thin / NULL / negative-clamp.
|
||
• compute_layer1/2/3 через MagicMock-сессию — форма SQL (CAST(:x AS type) не :x::type),
|
||
params, whitelisted source/confidence, graceful-on-empty (→ [], no crash) и
|
||
КРИТИЧНО L2 mapping-free (никаких objective_lots/complexes в L2-SQL).
|
||
|
||
psycopg v3 правило проверяется явно: bind-параметры — CAST(:x AS type).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from collections.abc import Iterator
|
||
from datetime import date
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.site_finder.supply_layers import (
|
||
SupplyLayerRow,
|
||
_hidden_units,
|
||
_l2_confidence,
|
||
_round_or_none,
|
||
compute_all_layers,
|
||
compute_layer1_open,
|
||
compute_layer2_hidden,
|
||
compute_layer3_future,
|
||
)
|
||
|
||
# Разрешённые значения CHECK-констрейнтов supply_layers (м.125). Любой эмит ВНЕ
|
||
# этих множеств → INSERT worker'а #950 Step5 упадёт ГРОМКО, поэтому тестим явно.
|
||
_ALLOWED_SOURCES = {"objective", "domrf_declared", "domrf_multiphase"}
|
||
_ALLOWED_CONFIDENCE = {"high", "medium", "low"}
|
||
|
||
# Резолвер admin→micro (Step 2) — патчим в L1-тестах. ТОЛЬКО L1 (open) резолвится
|
||
# (objective_lots = микро-вокабуляр); L2/L3 бьют domrf_kn_objects.district_name, а это
|
||
# УЖЕ админ-вокабуляр (м.57 spatial-join) → их district НЕ резолвится.
|
||
_RESOLVE = "app.services.site_finder.supply_layers.resolve_objective_districts"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _patch_resolver() -> Iterator[MagicMock]:
|
||
"""По умолчанию резолвер = identity raw-микро (district→[district], None→None).
|
||
|
||
Так _mock_db остаётся валиден (резолвер не трогает db). L1-тесты, проверяющие
|
||
admin→micro-разворот, переопределяют .side_effect.
|
||
"""
|
||
with patch(_RESOLVE) as m:
|
||
m.side_effect = lambda _db, d: [d] if d is not None else None
|
||
yield m
|
||
|
||
|
||
# ── pure: _hidden_units (L2 clamp) ────────────────────────────────────────────
|
||
|
||
|
||
class TestHiddenUnits:
|
||
def test_basic(self) -> None:
|
||
# 100 в ПД − 30 выставлено = 70 скрыто
|
||
assert _hidden_units(100, 30) == 70
|
||
|
||
def test_clamp_never_negative(self) -> None:
|
||
# free_flats > flat_count (рассинхрон снапшотов) → 0, НИКОГДА не негатив
|
||
assert _hidden_units(30, 100) == 0
|
||
|
||
def test_equal_is_zero(self) -> None:
|
||
assert _hidden_units(50, 50) == 0
|
||
|
||
def test_zero_free_all_hidden(self) -> None:
|
||
# ничего не выставлено → весь ПД скрыт
|
||
assert _hidden_units(80, 0) == 80
|
||
|
||
def test_none_flat_count_returns_none(self) -> None:
|
||
assert _hidden_units(None, 10) is None
|
||
|
||
def test_none_free_flats_returns_none(self) -> None:
|
||
# free_flats IS NULL → объект исключается (нет sold-колонки → не вычислить)
|
||
assert _hidden_units(100, None) is None
|
||
|
||
def test_both_none(self) -> None:
|
||
assert _hidden_units(None, None) is None
|
||
|
||
|
||
# ── pure: _l2_confidence (coverage) ───────────────────────────────────────────
|
||
|
||
|
||
class TestL2Confidence:
|
||
def test_full_coverage_medium(self) -> None:
|
||
# все объекты с free_flats → надёжный hidden, но всё равно НЕ high (это оценка ПД)
|
||
assert _l2_confidence(10, 10) == "medium"
|
||
|
||
def test_majority_coverage_medium(self) -> None:
|
||
# 7/10 = 0.7 ≥ 0.6 порог
|
||
assert _l2_confidence(10, 7) == "medium"
|
||
|
||
def test_minority_coverage_low(self) -> None:
|
||
# 3/10 = 0.3 < 0.6 → много исключённых NULL-объектов → недооценка → low
|
||
assert _l2_confidence(10, 3) == "low"
|
||
|
||
def test_no_objects_low(self) -> None:
|
||
assert _l2_confidence(0, 0) == "low"
|
||
|
||
def test_zero_with_free_flats_low(self) -> None:
|
||
assert _l2_confidence(10, 0) == "low"
|
||
|
||
def test_never_high(self) -> None:
|
||
# L2 никогда не 'high' — даже при 100% coverage
|
||
assert _l2_confidence(1000, 1000) != "high"
|
||
|
||
|
||
# ── pure: _round_or_none ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestRoundOrNone:
|
||
def test_rounds(self) -> None:
|
||
assert _round_or_none(1234.5678, 1) == 1234.6
|
||
|
||
def test_none_passthrough(self) -> None:
|
||
assert _round_or_none(None, 1) is None
|
||
|
||
|
||
# ── SupplyLayerRow.as_dict ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestSupplyLayerRowAsDict:
|
||
def test_serialises_and_rounds_area(self) -> None:
|
||
row = SupplyLayerRow(
|
||
layer=2,
|
||
district_name="Автовокзал",
|
||
complex_id=None,
|
||
obj_class=None,
|
||
dev_group_name=None,
|
||
units_estimate=70,
|
||
area_estimate=1234.5678,
|
||
expected_online_date=None,
|
||
source="domrf_declared",
|
||
confidence="medium",
|
||
method="test",
|
||
snapshot_date=date(2026, 6, 3),
|
||
)
|
||
d = row.as_dict()
|
||
assert d["layer"] == 2
|
||
assert d["district_name"] == "Автовокзал"
|
||
assert d["dev_group_name"] is None
|
||
assert d["units_estimate"] == 70
|
||
assert d["area_estimate"] == 1234.6
|
||
assert d["source"] == "domrf_declared"
|
||
assert d["confidence"] == "medium"
|
||
assert d["snapshot_date"] == date(2026, 6, 3)
|
||
|
||
def test_dev_group_name_carried_for_l3(self) -> None:
|
||
# #970: L3-строка несёт непустой dev_group_name → попадает в as_dict (ключ UPSERT).
|
||
row = SupplyLayerRow(
|
||
layer=3,
|
||
district_name="Академический",
|
||
complex_id=None,
|
||
obj_class=None,
|
||
dev_group_name="Брусника",
|
||
units_estimate=900,
|
||
area_estimate=45000.0,
|
||
expected_online_date=date(2028, 1, 1),
|
||
source="domrf_multiphase",
|
||
confidence="low",
|
||
method="test",
|
||
snapshot_date=date(2026, 6, 3),
|
||
)
|
||
assert row.as_dict()["dev_group_name"] == "Брусника"
|
||
|
||
def test_none_area_survives(self) -> None:
|
||
row = SupplyLayerRow(
|
||
layer=2,
|
||
district_name="X",
|
||
complex_id=None,
|
||
obj_class=None,
|
||
dev_group_name=None,
|
||
units_estimate=0,
|
||
area_estimate=None,
|
||
expected_online_date=None,
|
||
source="domrf_declared",
|
||
confidence="low",
|
||
method="test",
|
||
snapshot_date=None,
|
||
)
|
||
assert row.as_dict()["area_estimate"] is None
|
||
|
||
|
||
# ── MagicMock-сессия helpers (зеркало test_market_metrics) ────────────────────
|
||
|
||
|
||
def _mock_db(rows: list[dict]) -> MagicMock:
|
||
"""Сессия с одним execute → .mappings().all() == rows."""
|
||
db = MagicMock()
|
||
result = MagicMock()
|
||
result.mappings.return_value.all.return_value = rows
|
||
db.execute.return_value = result
|
||
return db
|
||
|
||
|
||
def _executed_sql(db: MagicMock, call_index: int = 0) -> str:
|
||
args, _kwargs = db.execute.call_args_list[call_index]
|
||
return str(args[0])
|
||
|
||
|
||
def _executed_params(db: MagicMock, call_index: int = 0) -> dict:
|
||
args, _kwargs = db.execute.call_args_list[call_index]
|
||
return args[1]
|
||
|
||
|
||
def _assert_no_double_colon_cast(sql: str) -> None:
|
||
"""psycopg v3: запрещён :name::type (SQLAlchemy его молча роняет)."""
|
||
import re
|
||
|
||
assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}"
|
||
|
||
|
||
# ── Layer1 (open) ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestLayer1Open:
|
||
def test_sql_uses_cast_not_double_colon(self) -> None:
|
||
db = _mock_db([])
|
||
compute_layer1_open(db, district="Автовокзал")
|
||
sql = _executed_sql(db)
|
||
# District-фильтр L1 теперь массив резолвнутых микро (ANY), не скаляр.
|
||
assert "CAST(:districts AS text[])" in sql
|
||
_assert_no_double_colon_cast(sql)
|
||
|
||
def test_params(self) -> None:
|
||
db = _mock_db([])
|
||
compute_layer1_open(db, district="Автовокзал", premise_kind="квартира")
|
||
p = _executed_params(db)
|
||
# district резолвится в набор микро (identity → ['Автовокзал']) → ANY(:districts).
|
||
assert p["has_district"] is True
|
||
assert p["districts"] == ["Автовокзал"]
|
||
assert "district" not in p
|
||
assert p["premise_kind"] == "квартира"
|
||
|
||
def test_rows_built_with_whitelisted_values(self) -> None:
|
||
rows = [
|
||
{
|
||
"district": "Автовокзал",
|
||
"obj_class": "комфорт",
|
||
"units_available": 42,
|
||
"area_available": 2100.0,
|
||
},
|
||
{
|
||
"district": "Автовокзал",
|
||
"obj_class": "бизнес",
|
||
"units_available": 10,
|
||
"area_available": 700.0,
|
||
},
|
||
]
|
||
db = _mock_db(rows)
|
||
out = compute_layer1_open(db, district="Автовокзал")
|
||
assert len(out) == 2
|
||
for r in out:
|
||
assert r.layer == 1
|
||
assert r.source == "objective"
|
||
assert r.confidence == "high"
|
||
assert r.source in _ALLOWED_SOURCES
|
||
assert r.confidence in _ALLOWED_CONFIDENCE
|
||
assert r.expected_online_date is None
|
||
assert r.complex_id is None
|
||
assert r.dev_group_name is None # #970: L1 не имеет dev_group
|
||
first = out[0]
|
||
assert first.obj_class == "комфорт"
|
||
assert first.units_estimate == 42
|
||
assert first.area_estimate == 2100.0
|
||
|
||
def test_zero_units_skipped(self) -> None:
|
||
rows = [
|
||
{"district": "X", "obj_class": "комфорт", "units_available": 0, "area_available": 0}
|
||
]
|
||
db = _mock_db(rows)
|
||
assert compute_layer1_open(db, district="X") == []
|
||
|
||
def test_zero_area_becomes_none(self) -> None:
|
||
rows = [
|
||
{"district": "X", "obj_class": "комфорт", "units_available": 5, "area_available": 0}
|
||
]
|
||
db = _mock_db(rows)
|
||
out = compute_layer1_open(db, district="X")
|
||
assert out[0].area_estimate is None
|
||
|
||
def test_empty_graceful(self) -> None:
|
||
assert compute_layer1_open(_mock_db([]), district="Пусто") == []
|
||
|
||
def test_query_exception_graceful(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("db down")
|
||
assert compute_layer1_open(db, district="Сбой") == []
|
||
|
||
|
||
class TestLayer1DistrictResolution:
|
||
"""Step 2: L1 (open) резолвит админ-имя → микро-набор в SQL-фильтре.
|
||
|
||
Тот же баг, что в market_metrics: админ-имя по objective_lots.district давало 0
|
||
строк открытого предложения. Резолвер разворачивает его в чистые микро.
|
||
"""
|
||
|
||
def test_admin_resolves_to_micros_in_sql_params(self, _patch_resolver: MagicMock) -> None:
|
||
_patch_resolver.side_effect = lambda _db, d: (
|
||
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||
)
|
||
db = _mock_db([])
|
||
compute_layer1_open(db, district="Кировский")
|
||
_patch_resolver.assert_called_once()
|
||
assert _patch_resolver.call_args.args[1] == "Кировский"
|
||
p = _executed_params(db)
|
||
# КРИТИЧНО: фильтр содержит МИКРО, а НЕ админ-имя (иначе 0 строк).
|
||
assert p["has_district"] is True
|
||
assert p["districts"] == ["Втузгородок", "ЖБИ"]
|
||
assert "Кировский" not in p["districts"]
|
||
|
||
def test_resolver_none_drops_district_filter(self, _patch_resolver: MagicMock) -> None:
|
||
_patch_resolver.side_effect = lambda _db, _d: None
|
||
db = _mock_db([])
|
||
compute_layer1_open(db, district="не определён")
|
||
p = _executed_params(db)
|
||
assert p["has_district"] is False
|
||
assert p["districts"] == []
|
||
|
||
|
||
# ── Layer2 (hidden) — THE CRUX ────────────────────────────────────────────────
|
||
|
||
|
||
class TestLayer2Hidden:
|
||
def test_sql_uses_cast_not_double_colon(self) -> None:
|
||
db = _mock_db([])
|
||
compute_layer2_hidden(db, district="Автовокзал")
|
||
sql = _executed_sql(db)
|
||
assert "CAST(:district AS text)" in sql
|
||
_assert_no_double_colon_cast(sql)
|
||
|
||
def test_mapping_free_no_objective_or_complexes_join(self) -> None:
|
||
# КРИТИЧНО (ТЗ §9.3): L2 — mapping-free. domrf↔objective маппинг (2.5%) НЕ нужен,
|
||
# т.к. flat_count и free_flats на ОДНОЙ строке domrf_kn_objects (м.124).
|
||
db = _mock_db([])
|
||
compute_layer2_hidden(db, district="Автовокзал")
|
||
sql = _executed_sql(db).lower()
|
||
assert "domrf_kn_objects" in sql
|
||
assert "objective_lots" not in sql
|
||
assert "objective_complex_mapping" not in sql
|
||
assert "complex_sources" not in sql
|
||
assert "complexes" not in sql
|
||
|
||
def test_sql_clamps_at_zero(self) -> None:
|
||
# SQL должен клампить hidden ≥ 0 (GREATEST) — защита от free_flats > flat_count.
|
||
db = _mock_db([])
|
||
compute_layer2_hidden(db, district="X")
|
||
sql = _executed_sql(db).upper()
|
||
assert "GREATEST(0" in sql
|
||
|
||
def test_sql_excludes_null_free_flats(self) -> None:
|
||
# free_flats IS NULL объекты исключены из суммы (нет sold-колонки → не вычислить).
|
||
db = _mock_db([])
|
||
compute_layer2_hidden(db, district="X")
|
||
sql = _executed_sql(db)
|
||
assert "free_flats IS NOT NULL" in sql
|
||
|
||
def test_sql_dedups_latest_snapshot(self) -> None:
|
||
# КРИТИЧНО: domrf_kn_objects = UNIQUE(obj_id, snapshot_date), накапливает снапшоты.
|
||
# Без DISTINCT ON (obj_id) + snapshot_date DESC наивный SUM умножил бы hidden на
|
||
# число снапшотов (cross-snapshot double-count). Этот guard locks-in защиту.
|
||
db = _mock_db([])
|
||
compute_layer2_hidden(db, district="X")
|
||
sql = _executed_sql(db)
|
||
assert "DISTINCT ON (o.obj_id)" in sql
|
||
assert "snapshot_date DESC" in sql
|
||
|
||
def test_params_region_ekb(self) -> None:
|
||
db = _mock_db([])
|
||
compute_layer2_hidden(db, district="Автовокзал")
|
||
p = _executed_params(db)
|
||
assert p["region_cd"] == 66
|
||
assert p["district"] == "Автовокзал"
|
||
|
||
def test_district_not_resolved_admin_vocab_preserved(self, _patch_resolver: MagicMock) -> None:
|
||
# КРИТИЧНО: L2 бьёт domrf_kn_objects.district_name (УЖЕ админ-вокабуляр, м.57
|
||
# spatial-join) → резолвер НЕ вызывается, админ-имя идёт в фильтр как есть.
|
||
# Иначе резолв в микро дал бы 0 строк (domrf не знает микро-имён).
|
||
db = _mock_db([])
|
||
compute_layer2_hidden(db, district="Кировский")
|
||
_patch_resolver.assert_not_called()
|
||
p = _executed_params(db)
|
||
assert p["district"] == "Кировский"
|
||
assert "districts" not in p
|
||
|
||
def test_hidden_aggregate_medium_confidence(self) -> None:
|
||
rows = [
|
||
{
|
||
"district_name": "Автовокзал",
|
||
"n_objects_total": 10,
|
||
"n_with_free_flats": 9, # 0.9 coverage → medium
|
||
"hidden_units": 540,
|
||
}
|
||
]
|
||
db = _mock_db(rows)
|
||
out = compute_layer2_hidden(db, district="Автовокзал")
|
||
assert len(out) == 1
|
||
row = out[0]
|
||
assert row.layer == 2
|
||
assert row.source == "domrf_declared"
|
||
assert row.confidence == "medium"
|
||
assert row.units_estimate == 540
|
||
assert row.obj_class is None # district-агрегат класс-agnostic
|
||
assert row.dev_group_name is None # #970: L2 не имеет dev_group
|
||
assert row.area_estimate is None
|
||
assert row.expected_online_date is None
|
||
assert row.source in _ALLOWED_SOURCES
|
||
assert row.confidence in _ALLOWED_CONFIDENCE
|
||
|
||
def test_low_coverage_low_confidence(self) -> None:
|
||
rows = [
|
||
{
|
||
"district_name": "X",
|
||
"n_objects_total": 10,
|
||
"n_with_free_flats": 2, # 0.2 coverage → low
|
||
"hidden_units": 100,
|
||
}
|
||
]
|
||
db = _mock_db(rows)
|
||
out = compute_layer2_hidden(db, district="X")
|
||
assert out[0].confidence == "low"
|
||
|
||
def test_no_objects_with_free_flats_skipped(self) -> None:
|
||
# район где НИ один объект не имеет free_flats → строки нет (а не units=0)
|
||
rows = [
|
||
{
|
||
"district_name": "X",
|
||
"n_objects_total": 5,
|
||
"n_with_free_flats": 0,
|
||
"hidden_units": 0,
|
||
}
|
||
]
|
||
db = _mock_db(rows)
|
||
assert compute_layer2_hidden(db, district="X") == []
|
||
|
||
def test_units_never_negative_even_if_sql_leaks(self) -> None:
|
||
# двойная защита: даже если SQL вернёт негатив, Python клампит в 0
|
||
rows = [
|
||
{
|
||
"district_name": "X",
|
||
"n_objects_total": 4,
|
||
"n_with_free_flats": 4,
|
||
"hidden_units": -50,
|
||
}
|
||
]
|
||
db = _mock_db(rows)
|
||
out = compute_layer2_hidden(db, district="X")
|
||
assert out[0].units_estimate == 0
|
||
|
||
def test_empty_graceful(self) -> None:
|
||
assert compute_layer2_hidden(_mock_db([]), district="Пусто") == []
|
||
|
||
def test_query_exception_graceful(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("db down")
|
||
assert compute_layer2_hidden(db, district="Сбой") == []
|
||
|
||
|
||
# ── Layer3 (future) ───────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestLayer3Future:
|
||
def test_sql_uses_cast_not_double_colon(self) -> None:
|
||
db = _mock_db([])
|
||
compute_layer3_future(db, district="Академический")
|
||
sql = _executed_sql(db)
|
||
assert "CAST(:district AS text)" in sql
|
||
assert "CAST(:horizon AS interval)" in sql
|
||
_assert_no_double_colon_cast(sql)
|
||
|
||
def test_params(self) -> None:
|
||
db = _mock_db([])
|
||
compute_layer3_future(db, district="Академический")
|
||
p = _executed_params(db)
|
||
assert p["region_cd"] == 66
|
||
assert p["district"] == "Академический"
|
||
assert p["horizon"] == "12 months"
|
||
assert p["max_free_flats"] == 5
|
||
|
||
def test_sql_dedups_latest_snapshot(self) -> None:
|
||
# Тот же anti-double-count guard, что в L2: один объект может иметь несколько
|
||
# снапшотов → DISTINCT ON (obj_id) + snapshot_date DESC обязателен до агрегации.
|
||
db = _mock_db([])
|
||
compute_layer3_future(db, district="Академический")
|
||
sql = _executed_sql(db)
|
||
assert "DISTINCT ON (o.obj_id)" in sql
|
||
assert "snapshot_date DESC" in sql
|
||
|
||
def test_future_row_low_confidence_with_date(self) -> None:
|
||
rows = [
|
||
{
|
||
"district_name": "Академический",
|
||
"dev_group_name": "Брусника",
|
||
"n_objects": 3,
|
||
"units_future": 900,
|
||
"area_future": 45000.0,
|
||
"expected_online_date": date(2028, 1, 1),
|
||
}
|
||
]
|
||
db = _mock_db(rows)
|
||
out = compute_layer3_future(db, district="Академический")
|
||
assert len(out) == 1
|
||
row = out[0]
|
||
assert row.layer == 3
|
||
assert row.source == "domrf_multiphase"
|
||
assert row.confidence == "low"
|
||
assert row.units_estimate == 900
|
||
assert row.area_estimate == 45000.0
|
||
assert row.expected_online_date == date(2028, 1, 1)
|
||
# #970: dev_group_name из SQL-строки протянут в dataclass (ключ-колонка UPSERT)
|
||
assert row.dev_group_name == "Брусника"
|
||
assert row.source in _ALLOWED_SOURCES
|
||
assert row.confidence in _ALLOWED_CONFIDENCE
|
||
|
||
def test_same_district_different_dev_groups_distinct_keys(self) -> None:
|
||
# #970 (REGRESSION GUARD): два dev_group'а ОДНОГО района должны давать РАЗНЫЕ
|
||
# логические ключи (layer, district_name, complex_id, obj_class, dev_group_name,
|
||
# source, snapshot_date), иначе UPSERT воркера схлопнет их в один (был ~95.6%
|
||
# потерь L3). complex_id/obj_class у L3 всегда NULL → раньше единственным
|
||
# различителем был dev_group_name, но его НЕ было в строке → коллапс.
|
||
rows = [
|
||
{
|
||
"district_name": "Академический",
|
||
"dev_group_name": "Брусника",
|
||
"n_objects": 5,
|
||
"units_future": 900,
|
||
"area_future": 45000.0,
|
||
"expected_online_date": date(2028, 1, 1),
|
||
},
|
||
{
|
||
"district_name": "Академический",
|
||
"dev_group_name": "Атомстройкомплекс",
|
||
"n_objects": 7,
|
||
"units_future": 1200,
|
||
"area_future": 60000.0,
|
||
"expected_online_date": date(2029, 6, 1),
|
||
},
|
||
]
|
||
db = _mock_db(rows)
|
||
out = compute_layer3_future(db, district="Академический")
|
||
assert len(out) == 2
|
||
# каждый dev_group протянут в свою строку (не только в method-текст)
|
||
assert {r.dev_group_name for r in out} == {"Брусника", "Атомстройкомплекс"}
|
||
|
||
# ключевой инвариант: UPSERT-ключи двух строк РАЗЛИЧНЫ (не схлопываются)
|
||
def _key(r: SupplyLayerRow) -> tuple:
|
||
d = r.as_dict()
|
||
return (
|
||
d["layer"],
|
||
d["district_name"],
|
||
d["complex_id"],
|
||
d["obj_class"],
|
||
d["dev_group_name"],
|
||
d["source"],
|
||
d["snapshot_date"],
|
||
)
|
||
|
||
keys = [_key(r) for r in out]
|
||
assert len(set(keys)) == 2, f"dev_group keys collapsed: {keys}"
|
||
# всё, КРОМЕ dev_group_name, у них идентично — единственный различитель = dev_group
|
||
assert keys[0][:4] == keys[1][:4] # layer/district/complex_id/obj_class совпадают
|
||
assert keys[0][4] != keys[1][4] # dev_group_name различается
|
||
|
||
def test_zero_units_and_area_become_none(self) -> None:
|
||
rows = [
|
||
{
|
||
"district_name": "X",
|
||
"dev_group_name": None,
|
||
"n_objects": 1,
|
||
"units_future": 0,
|
||
"area_future": 0,
|
||
"expected_online_date": date(2029, 6, 1),
|
||
}
|
||
]
|
||
db = _mock_db(rows)
|
||
out = compute_layer3_future(db, district="X")
|
||
assert out[0].units_estimate is None
|
||
assert out[0].area_estimate is None
|
||
# строка всё равно эмитится — есть expected_online_date (полезный future-сигнал)
|
||
assert out[0].expected_online_date == date(2029, 6, 1)
|
||
|
||
def test_empty_graceful(self) -> None:
|
||
assert compute_layer3_future(_mock_db([]), district="Пусто") == []
|
||
|
||
def test_query_exception_graceful(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("db down")
|
||
assert compute_layer3_future(db, district="Сбой") == []
|
||
|
||
|
||
# ── compute_all_layers (объединённый) ─────────────────────────────────────────
|
||
|
||
|
||
class TestComputeAllLayers:
|
||
def test_combines_three_layers(self) -> None:
|
||
l1 = [
|
||
{"district": "A", "obj_class": "комфорт", "units_available": 5, "area_available": 250.0}
|
||
]
|
||
l2 = [
|
||
{"district_name": "A", "n_objects_total": 4, "n_with_free_flats": 4, "hidden_units": 80}
|
||
]
|
||
l3 = [
|
||
{
|
||
"district_name": "A",
|
||
"dev_group_name": "Dev",
|
||
"n_objects": 1,
|
||
"units_future": 100,
|
||
"area_future": 5000.0,
|
||
"expected_online_date": date(2028, 1, 1),
|
||
}
|
||
]
|
||
db = MagicMock()
|
||
results = []
|
||
for rows in (l1, l2, l3):
|
||
res = MagicMock()
|
||
res.mappings.return_value.all.return_value = rows
|
||
results.append(res)
|
||
db.execute.side_effect = results
|
||
|
||
out = compute_all_layers(db, district="A")
|
||
layers = sorted(r.layer for r in out)
|
||
assert layers == [1, 2, 3]
|
||
# все source/confidence whitelisted
|
||
assert all(r.source in _ALLOWED_SOURCES for r in out)
|
||
assert all(r.confidence in _ALLOWED_CONFIDENCE for r in out)
|
||
|
||
def test_one_layer_failing_does_not_kill_others(self) -> None:
|
||
# L1 падает, L2/L3 пустые — compute_all не бросает, возвращает []
|
||
db = MagicMock()
|
||
l1_fail = RuntimeError("l1 down")
|
||
l2_ok = MagicMock()
|
||
l2_ok.mappings.return_value.all.return_value = []
|
||
l3_ok = MagicMock()
|
||
l3_ok.mappings.return_value.all.return_value = []
|
||
db.execute.side_effect = [l1_fail, l2_ok, l3_ok]
|
||
out = compute_all_layers(db, district="A")
|
||
assert out == []
|