test(suite): re-align drifted tests with current code (CI-rehab 2/3)
Suite was uncollectable for a while → code evolved, mocks didn't (42 failed / 5 errors → 1687 passed / 0 failed / 0 errors). TEST-ONLY, no app code changed, no real bugs found (code-reviewed ✅ — no test weakened to fake-green). - best_layouts: sum_deals→deals_window rename; velocity-scaling direction for new SF-01 divisors (last_month=1.0/last_year=12.0) - quarter_dump_lookup: 7→10-tuple mock rows (risks/opportunity/red_lines cols); fix early-exit call-counts for the cad_zouit fallback (#232) - cadastre_bulk: add xmin/ymin/xmax/ymax to harvest-quarter db mock - nspd_client: add required QuarterDump.opportunity - TopLayoutRow: add required is_oversold - analyze_{market_price,recent_permits,inline_weights}: SQL-signature dispatch instead of fragile positional db.execute indices (SF-B5 query reorder; also fixes a double-POST geom-starvation false-negative) - custom_pois: move centroid >15km so center_bonus=0 isolates custom-POI delta - layout/report PDF: runtime WeasyPrint probe (skip macOS dev, RUN on CI) - mv_layout SQL: normalize +psycopg DSN + connectivity-probe skipif - admin token tests: skip (X-Admin-Token gate removed #437/#426; protection is Caddy basic_auth + RBAC, covered by test_rbac) Refs #944.
This commit is contained in:
parent
0247f13fb6
commit
e9585bcd3b
14 changed files with 377 additions and 252 deletions
|
|
@ -296,8 +296,17 @@ def test_cancel_job_not_found_returns_404() -> None:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(
|
||||||
|
reason=(
|
||||||
|
"X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — "
|
||||||
|
"Caddy basic_auth + RBAC middleware достаточны). Admin endpoint'ы больше не "
|
||||||
|
"несут verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode "
|
||||||
|
"RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без "
|
||||||
|
"реверта security-решения #437. Тест проверял удалённое поведение."
|
||||||
|
)
|
||||||
|
)
|
||||||
def test_create_job_no_token_returns_401() -> None:
|
def test_create_job_no_token_returns_401() -> None:
|
||||||
"""Без X-Admin-Token → 401/503."""
|
"""Без X-Admin-Token → 401/503 (устарело: токен-гейт удалён в #437)."""
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/admin/cadastre/jobs",
|
"/api/v1/admin/cadastre/jobs",
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,17 @@ def test_trigger_invalid_year_returns_422(bad_year: int) -> None:
|
||||||
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"
|
assert response.status_code == 422, f"year={bad_year} должен возвращать 422"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(
|
||||||
|
reason=(
|
||||||
|
"X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — "
|
||||||
|
"Caddy basic_auth + RBAC middleware достаточны). trigger_ekburg_permits больше не "
|
||||||
|
"несёт verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode "
|
||||||
|
"RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без "
|
||||||
|
"реверта security-решения #437. Тест проверял удалённое поведение."
|
||||||
|
)
|
||||||
|
)
|
||||||
def test_trigger_no_token_returns_401_or_503() -> None:
|
def test_trigger_no_token_returns_401_or_503() -> None:
|
||||||
"""Без X-Admin-Token → 401 или 503."""
|
"""Без X-Admin-Token → 401 или 503 (устарело: токен-гейт удалён в #437)."""
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.post(ENDPOINT, json={})
|
response = client.post(ENDPOINT, json={})
|
||||||
assert response.status_code in (401, 503), response.text
|
assert response.status_code in (401, 503), response.text
|
||||||
|
|
|
||||||
|
|
@ -47,26 +47,14 @@ def _make_db_for_analyze(
|
||||||
) -> MagicMock:
|
) -> MagicMock:
|
||||||
"""Сконструировать mock DB Session для analyze_parcel.
|
"""Сконструировать mock DB Session для analyze_parcel.
|
||||||
|
|
||||||
Порядок db.execute calls в analyze_parcel:
|
Mock диспетчеризует строки по СИГНАТУРЕ SQL, а не по позиционному индексу.
|
||||||
0. UNION ALL geom + source → .mappings().first()
|
Это критично: weight-validation (422) в обработчике срабатывает ПОСЛЕ резолва
|
||||||
1. WKT query → .mappings().first()
|
геометрии (parcels.py:~1213 geom → ~1382 validation), а несколько тестов шлют
|
||||||
2. District → .mappings().first()
|
ДВА POST подряд на один mock — монотонный счётчик «съедал» бы geom-row на втором
|
||||||
3. POI rows → .mappings().all()
|
запросе → handler уходил в inline NSPD fetch (202 «fetching») вместо 422.
|
||||||
4. Competitor rows → .mappings().all()
|
Сигнатурный матчинг возвращает geom/wkt/district/centroid независимо от числа
|
||||||
5. Pipeline rows → .mappings().all()
|
предыдущих вызовов. Прочие запросы обёрнуты в try/except SAVEPOINT → дефолтный
|
||||||
6. Centroid lat/lon → .mappings().first()
|
пустой mock их не валит.
|
||||||
7. Noise rows → .mappings().all()
|
|
||||||
8. Hydrology → .mappings().all()
|
|
||||||
9. Utilities → .mappings().all()
|
|
||||||
10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2
|
|
||||||
11. Market trend → .mappings().first()
|
|
||||||
12. Zoning (begin_nested) → .mappings().first()
|
|
||||||
13. Success recommendation (begin_nested) → .mappings().all()
|
|
||||||
14. Market price (begin_nested) → .mappings().first()
|
|
||||||
15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5
|
|
||||||
16. _geotech_risk (industrial count) → .scalar()
|
|
||||||
17. _neighbors_summary (neighbor_rows) → .mappings().all()
|
|
||||||
18. _neighbors_summary (overlap_row) → .mappings().first()
|
|
||||||
|
|
||||||
begin_nested() — возвращаем context manager чтобы поддержать `with` statement.
|
begin_nested() — возвращаем context manager чтобы поддержать `with` statement.
|
||||||
"""
|
"""
|
||||||
|
|
@ -93,44 +81,28 @@ def _make_db_for_analyze(
|
||||||
|
|
||||||
_poi_rows = poi_rows or []
|
_poi_rows = poi_rows or []
|
||||||
|
|
||||||
# Счётчик вызовов execute — разводим first() / all() / scalar() по очерёдности
|
|
||||||
call_idx = [0]
|
|
||||||
# Ответы в порядке вызовов:
|
|
||||||
responses: list[Any] = [
|
|
||||||
("first", geom_row), # 0: geom UNION ALL
|
|
||||||
("first", wkt_row), # 1: WKT
|
|
||||||
("first", district_row), # 2: district
|
|
||||||
("all", _poi_rows), # 3: POI rows
|
|
||||||
("all", []), # 4: competitor rows
|
|
||||||
("all", []), # 5: pipeline rows
|
|
||||||
("first", centroid_row), # 6: centroid
|
|
||||||
("all", []), # 7: noise rows
|
|
||||||
("all", []), # 8: hydrology rows
|
|
||||||
("all", []), # 9: utilities rows
|
|
||||||
("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2
|
|
||||||
("first", None), # 11: market trend
|
|
||||||
("first", None), # 12: zoning (inside begin_nested)
|
|
||||||
("all", []), # 13: success recommendation (inside begin_nested)
|
|
||||||
("scalar", 0), # 14: geotech_risk industrial count
|
|
||||||
("all", []), # 15: neighbors
|
|
||||||
("first", None), # 16: overlap
|
|
||||||
]
|
|
||||||
|
|
||||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
idx = call_idx[0]
|
sql = " ".join(str(args[0]).split()) if args else ""
|
||||||
call_idx[0] += 1
|
|
||||||
if idx >= len(responses):
|
first_val: Any = None
|
||||||
# Безопасный fallback для непредусмотренных вызовов
|
all_val: list[Any] = []
|
||||||
r = MagicMock()
|
|
||||||
r.mappings.return_value.first.return_value = None
|
if "AS geom_geojson" in sql:
|
||||||
r.mappings.return_value.all.return_value = []
|
first_val = geom_row
|
||||||
r.scalar.return_value = 0
|
elif "AS wkt" in sql:
|
||||||
return r
|
first_val = wkt_row
|
||||||
kind, data = responses[idx]
|
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||||
|
first_val = district_row
|
||||||
|
elif "AS lon" in sql and "AS lat" in sql:
|
||||||
|
first_val = centroid_row
|
||||||
|
# POI rows — категория/имя/координаты в радиусе.
|
||||||
|
elif "category" in sql and "ST_Distance" in sql and "p.geom" in sql:
|
||||||
|
all_val = _poi_rows
|
||||||
|
|
||||||
r = MagicMock()
|
r = MagicMock()
|
||||||
r.mappings.return_value.first.return_value = data
|
r.mappings.return_value.first.return_value = first_val
|
||||||
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
|
r.mappings.return_value.all.return_value = all_val
|
||||||
r.scalar.return_value = data if kind == "scalar" else 0
|
r.scalar.return_value = 0
|
||||||
return r
|
return r
|
||||||
|
|
||||||
db.execute.side_effect = _execute_side_effect
|
db.execute.side_effect = _execute_side_effect
|
||||||
|
|
@ -298,9 +270,9 @@ def test_inline_weights_rejects_nan() -> None:
|
||||||
content=raw_body,
|
content=raw_body,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
assert (
|
assert resp.status_code == 422, (
|
||||||
resp.status_code == 422
|
f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}"
|
||||||
), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}"
|
)
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
_stop_patches()
|
_stop_patches()
|
||||||
|
|
|
||||||
|
|
@ -8,26 +8,15 @@
|
||||||
Стратегия mock: аналогична test_analyze_inline_weights.py — DB mock через
|
Стратегия mock: аналогична test_analyze_inline_weights.py — DB mock через
|
||||||
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
|
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.
|
||||||
|
|
||||||
Порядок db.execute calls в analyze_parcel (v3.7 + #33 + #29 G2):
|
Порядок db.execute в analyze_parcel меняется при добавлении блоков (SF-B5 добавил
|
||||||
0. UNION ALL geom + source → .mappings().first()
|
red_lines / district_price / risk_zones между market-trend и market-price), поэтому
|
||||||
1. WKT query → .mappings().first()
|
жёсткая нумерация быстро устаревает. Mock диспетчеризует строки по СИГНАТУРЕ SQL
|
||||||
2. District → .mappings().first()
|
(а не по позиционному индексу) — устойчиво к реордерингу:
|
||||||
3. POI rows → .mappings().all()
|
• geom UNION ALL / WKT / district / centroid — по содержимому SELECT
|
||||||
4. Competitor rows → .mappings().all()
|
• market-price (#33) — по уникальной сигнатуре "median_6m, median_12m, median_24m"
|
||||||
5. Pipeline rows → .mappings().all()
|
Прочие запросы (POI/competitors/noise/red_lines/risk/permits/neighbors) обёрнуты в
|
||||||
6. Centroid lat/lon → .mappings().first()
|
try/except SAVEPOINT в коде → дефолтный пустой mock (first=None, all=[], scalar=0)
|
||||||
7. Noise rows → .mappings().all()
|
не валит обработчик.
|
||||||
8. Hydrology → .mappings().all()
|
|
||||||
9. Utilities → .mappings().all()
|
|
||||||
10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2
|
|
||||||
11. Market trend → .mappings().first()
|
|
||||||
12. Zoning (begin_nested) → .mappings().first()
|
|
||||||
13. Success recommendation (begin_nested) → .mappings().all()
|
|
||||||
14. Market price (begin_nested) → .mappings().first() ← #33
|
|
||||||
15. Recent permits (begin_nested) → .mappings().all() ← #105 Phase 5
|
|
||||||
16. _geotech_risk (industrial count) → .scalar()
|
|
||||||
17. _neighbors_summary (neighbor_rows) → .mappings().all()
|
|
||||||
18. _neighbors_summary (overlap_row) → .mappings().first()
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -93,43 +82,30 @@ def _make_db_for_analyze(
|
||||||
|
|
||||||
mp_mock = _make_mapping(market_price_row) if market_price_row is not None else None
|
mp_mock = _make_mapping(market_price_row) if market_price_row is not None else None
|
||||||
|
|
||||||
call_idx = [0]
|
|
||||||
responses: list[Any] = [
|
|
||||||
("first", geom_row), # 0: geom UNION ALL
|
|
||||||
("first", wkt_row), # 1: WKT
|
|
||||||
("first", district_row), # 2: district
|
|
||||||
("all", []), # 3: POI rows
|
|
||||||
("all", []), # 4: competitor rows
|
|
||||||
("all", []), # 5: pipeline rows
|
|
||||||
("first", centroid_row), # 6: centroid
|
|
||||||
("all", []), # 7: noise rows
|
|
||||||
("all", []), # 8: hydrology rows
|
|
||||||
("all", []), # 9: utilities rows
|
|
||||||
("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2
|
|
||||||
("first", None), # 11: market trend
|
|
||||||
("first", None), # 12: zoning (begin_nested)
|
|
||||||
("all", []), # 13: success recommendation (begin_nested)
|
|
||||||
("first", mp_mock), # 14: market price (begin_nested) ← #33
|
|
||||||
("all", []), # 15: recent permits (begin_nested)
|
|
||||||
("scalar", 0), # 16: geotech_risk
|
|
||||||
("all", []), # 17: neighbors
|
|
||||||
("first", None), # 18: overlap
|
|
||||||
]
|
|
||||||
|
|
||||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
idx = call_idx[0]
|
# Нормализуем SQL для сигнатурного матчинга (collapse whitespace).
|
||||||
call_idx[0] += 1
|
sql = " ".join(str(args[0]).split()) if args else ""
|
||||||
if idx >= len(responses):
|
|
||||||
r = MagicMock()
|
first_val: Any = None
|
||||||
r.mappings.return_value.first.return_value = None
|
all_val: list[Any] = []
|
||||||
r.mappings.return_value.all.return_value = []
|
|
||||||
r.scalar.return_value = 0
|
# Структурные запросы (нужны чтобы обработчик дошёл до market-price без 404/202).
|
||||||
return r
|
if "AS geom_geojson" in sql:
|
||||||
kind, data = responses[idx]
|
first_val = geom_row
|
||||||
|
elif "AS wkt" in sql:
|
||||||
|
first_val = wkt_row
|
||||||
|
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||||
|
first_val = district_row
|
||||||
|
elif "AS lon" in sql and "AS lat" in sql:
|
||||||
|
first_val = centroid_row
|
||||||
|
# market-price (#33) — уникальная сигнатура трёх скользящих медиан.
|
||||||
|
elif "median_6m" in sql and "median_12m" in sql and "median_24m" in sql:
|
||||||
|
first_val = mp_mock
|
||||||
|
|
||||||
r = MagicMock()
|
r = MagicMock()
|
||||||
r.mappings.return_value.first.return_value = data
|
r.mappings.return_value.first.return_value = first_val
|
||||||
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
|
r.mappings.return_value.all.return_value = all_val
|
||||||
r.scalar.return_value = data if kind == "scalar" else 0
|
r.scalar.return_value = 0
|
||||||
return r
|
return r
|
||||||
|
|
||||||
db.execute.side_effect = _execute_side_effect
|
db.execute.side_effect = _execute_side_effect
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,14 @@
|
||||||
2. analyze с quarter без permits → recent_permits=[], summary={rns_count=0, ...}
|
2. analyze с quarter без permits → recent_permits=[], summary={rns_count=0, ...}
|
||||||
3. analyze 404 на invalid cad (no regression)
|
3. analyze 404 на invalid cad (no regression)
|
||||||
|
|
||||||
Порядок db.execute calls в analyze_parcel (после #105 Phase 5 + #29 G2):
|
Порядок db.execute в analyze_parcel меняется при добавлении блоков (SF-B5 вклинил
|
||||||
0. UNION ALL geom + source → .mappings().first()
|
red_lines / district_price / risk_zones), поэтому mock диспетчеризует строки по
|
||||||
1. WKT query → .mappings().first()
|
СИГНАТУРЕ SQL, а не по позиционному индексу — устойчиво к реордерингу:
|
||||||
2. District → .mappings().first()
|
• geom UNION ALL / WKT / district / centroid — по содержимому SELECT
|
||||||
3. POI rows → .mappings().all()
|
• recent permits (#105) — по уникальной сигнатуре "permit_type, permit_number ...
|
||||||
4. Competitor rows → .mappings().all()
|
developer_inn"
|
||||||
5. Pipeline rows → .mappings().all()
|
Прочие запросы обёрнуты в try/except SAVEPOINT в коде → дефолтный пустой mock
|
||||||
6. Centroid lat/lon → .mappings().first()
|
(first=None, all=[], scalar=0) их не валит.
|
||||||
7. Noise rows → .mappings().all()
|
|
||||||
8. Hydrology → .mappings().all()
|
|
||||||
9. Utilities → .mappings().all()
|
|
||||||
10. parcel_meta (cad_parcels) → .mappings().first() ← #29 G2
|
|
||||||
11. Market trend → .mappings().first()
|
|
||||||
12. Zoning (begin_nested) → .mappings().first()
|
|
||||||
13. Success recommendation (begin_nested) → .mappings().all()
|
|
||||||
14. Market price (begin_nested) → .mappings().first()
|
|
||||||
15. Recent permits (begin_nested) → .mappings().all() ← #105
|
|
||||||
16. _geotech_risk (industrial count) → .scalar()
|
|
||||||
17. _neighbors_summary (neighbor_rows) → .mappings().all()
|
|
||||||
18. _neighbors_summary (overlap_row) → .mappings().first()
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -84,43 +72,29 @@ def _make_db_for_analyze(
|
||||||
|
|
||||||
raw_permits = [_make_mapping(p) for p in (permits_rows or [])]
|
raw_permits = [_make_mapping(p) for p in (permits_rows or [])]
|
||||||
|
|
||||||
call_idx = [0]
|
|
||||||
responses: list[Any] = [
|
|
||||||
("first", geom_row), # 0: geom UNION ALL
|
|
||||||
("first", wkt_row), # 1: WKT
|
|
||||||
("first", district_row), # 2: district
|
|
||||||
("all", []), # 3: POI rows
|
|
||||||
("all", []), # 4: competitor rows
|
|
||||||
("all", []), # 5: pipeline rows
|
|
||||||
("first", centroid_row), # 6: centroid
|
|
||||||
("all", []), # 7: noise rows
|
|
||||||
("all", []), # 8: hydrology rows
|
|
||||||
("all", []), # 9: utilities rows
|
|
||||||
("first", None), # 10: parcel_meta (cad_parcels) ← #29 G2
|
|
||||||
("first", None), # 11: market trend
|
|
||||||
("first", None), # 12: zoning (begin_nested)
|
|
||||||
("all", []), # 13: success recommendation (begin_nested)
|
|
||||||
("first", None), # 14: market price (begin_nested)
|
|
||||||
("all", raw_permits), # 15: recent permits (begin_nested) ← #105
|
|
||||||
("scalar", 0), # 16: geotech_risk
|
|
||||||
("all", []), # 17: neighbors
|
|
||||||
("first", None), # 18: overlap
|
|
||||||
]
|
|
||||||
|
|
||||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||||
idx = call_idx[0]
|
sql = " ".join(str(args[0]).split()) if args else ""
|
||||||
call_idx[0] += 1
|
|
||||||
if idx >= len(responses):
|
first_val: Any = None
|
||||||
r = MagicMock()
|
all_val: list[Any] = []
|
||||||
r.mappings.return_value.first.return_value = None
|
|
||||||
r.mappings.return_value.all.return_value = []
|
# Структурные запросы (нужны чтобы обработчик дошёл до permits без 404/202).
|
||||||
r.scalar.return_value = 0
|
if "AS geom_geojson" in sql:
|
||||||
return r
|
first_val = geom_row
|
||||||
kind, data = responses[idx]
|
elif "AS wkt" in sql:
|
||||||
|
first_val = wkt_row
|
||||||
|
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||||
|
first_val = district_row
|
||||||
|
elif "AS lon" in sql and "AS lat" in sql:
|
||||||
|
first_val = centroid_row
|
||||||
|
# recent permits (#105) — уникальная сигнатура permit_type+permit_number+developer_inn.
|
||||||
|
elif "permit_type" in sql and "permit_number" in sql and "developer_inn" in sql:
|
||||||
|
all_val = raw_permits
|
||||||
|
|
||||||
r = MagicMock()
|
r = MagicMock()
|
||||||
r.mappings.return_value.first.return_value = data
|
r.mappings.return_value.first.return_value = first_val
|
||||||
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
|
r.mappings.return_value.all.return_value = all_val
|
||||||
r.scalar.return_value = data if kind == "scalar" else 0
|
r.scalar.return_value = 0
|
||||||
return r
|
return r
|
||||||
|
|
||||||
db.execute.side_effect = _execute_side_effect
|
db.execute.side_effect = _execute_side_effect
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,10 @@ def _make_db_for_analyze() -> MagicMock:
|
||||||
"dist_to_center": 1500.0,
|
"dist_to_center": 1500.0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
centroid_row = _make_mapping_analyze({"lat": 56.84, "lon": 60.605})
|
# Центроид УМЫШЛЕННО далеко (>15км) от центра ЕКБ (56.838011, 60.597474) —
|
||||||
|
# тогда center_bonus == 0.0 (parcels.py:~1594 порог <15км) и итоговый score
|
||||||
|
# изолирует ТОЛЬКО вклад custom-POI. ~29км на север (Δlat≈+0.26°).
|
||||||
|
centroid_row = _make_mapping_analyze({"lat": 57.10, "lon": 60.597})
|
||||||
|
|
||||||
call_idx = [0]
|
call_idx = [0]
|
||||||
responses: list[Any] = [
|
responses: list[Any] = [
|
||||||
|
|
@ -389,9 +392,9 @@ def test_analyze_custom_poi_increases_score() -> None:
|
||||||
# contribution = 2.0 * 0.5 = 1.0
|
# contribution = 2.0 * 0.5 = 1.0
|
||||||
assert items[0]["contribution"] == pytest.approx(1.0, abs=0.01)
|
assert items[0]["contribution"] == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
# score должен учитывать contribution custom POI
|
# Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score
|
||||||
# (базовый score без POI = center_bonus из 1500м → 1.5; + custom 1.0 = 2.5)
|
# == вклад custom-POI (1.0). Так тест изолирует custom-contribution.
|
||||||
assert body["score"] == pytest.approx(2.5, abs=0.1)
|
assert body["score"] == pytest.approx(1.0, abs=0.1)
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
_stop_analyze_patches()
|
_stop_analyze_patches()
|
||||||
|
|
@ -450,8 +453,9 @@ def test_analyze_custom_poi_negative_weight_decreases_score() -> None:
|
||||||
items = body["custom_poi_score_items"]
|
items = body["custom_poi_score_items"]
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert items[0]["contribution"] == pytest.approx(-1.5, abs=0.01)
|
assert items[0]["contribution"] == pytest.approx(-1.5, abs=0.01)
|
||||||
# center_bonus = 1.5 (1500м), custom = -1.5 → итого ≈ 0
|
# Центроид >15км от центра ЕКБ → center_bonus = 0.0, поэтому итоговый score
|
||||||
assert body["score"] == pytest.approx(0.0, abs=0.1)
|
# == вклад custom-POI (-1.5). score_final НЕ клампится снизу (parcels.py:2410).
|
||||||
|
assert body["score"] == pytest.approx(-1.5, abs=0.1)
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
_stop_analyze_patches()
|
_stop_analyze_patches()
|
||||||
|
|
|
||||||
|
|
@ -264,10 +264,15 @@ def test_min_velocity_per_month_filters_low_rows() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_time_window_velocity_scaling() -> None:
|
def test_time_window_velocity_scaling() -> None:
|
||||||
"""last_month vs last_year дают разный velocity_per_month для одних deals."""
|
"""last_month vs last_year дают разный velocity_per_month для одних deals.
|
||||||
# sum_deals=24 → last_month: 24/24=1.0, last_year: 24/2=12.0
|
|
||||||
|
Fix SF-01: velocity_per_month = deals_window / months_in_window. Делители
|
||||||
|
из _TIME_WINDOW_PARAMS: last_month=1.0, last_year=12.0. При одинаковом
|
||||||
|
deals_window короткое окно даёт ВЫШЕ месячную velocity (делитель меньше).
|
||||||
|
"""
|
||||||
|
# deals_window=24 → last_month: 24/1=24.0, last_year: 24/12=2.0
|
||||||
id_rows = [_obj_id_row(1)]
|
id_rows = [_obj_id_row(1)]
|
||||||
vel_rows_fixed = [_vel_row("2", sum_deals=24.0, obj_ids=[1])]
|
vel_rows_fixed = [_vel_row("2", deals_window=24.0, obj_ids=[1])]
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
|
@ -291,10 +296,10 @@ def test_time_window_velocity_scaling() -> None:
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
# last_year velocity должна быть выше (делитель меньше: 2 vs 24)
|
# last_month velocity выше (делитель меньше: 1 vs 12) при одинаковом deals_window
|
||||||
assert v_year > v_month
|
assert v_month > v_year
|
||||||
assert v_month == pytest.approx(1.0)
|
assert v_month == pytest.approx(24.0)
|
||||||
assert v_year == pytest.approx(12.0)
|
assert v_year == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
def test_obj_class_filter_passes_through() -> None:
|
def test_obj_class_filter_passes_through() -> None:
|
||||||
|
|
@ -341,8 +346,8 @@ def test_target_total_flats_fills_abs_units() -> None:
|
||||||
"""target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100."""
|
"""target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100."""
|
||||||
id_rows = [_obj_id_row(1), _obj_id_row(2)]
|
id_rows = [_obj_id_row(1), _obj_id_row(2)]
|
||||||
vel_rows = [
|
vel_rows = [
|
||||||
_vel_row("1", sum_deals=60.0, obj_ids=[1]),
|
_vel_row("1", deals_window=60.0, obj_ids=[1]),
|
||||||
_vel_row("2", sum_deals=40.0, obj_ids=[2]),
|
_vel_row("2", deals_window=40.0, obj_ids=[2]),
|
||||||
]
|
]
|
||||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
@ -365,8 +370,8 @@ def test_target_total_flats_fills_abs_units() -> None:
|
||||||
def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None:
|
def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None:
|
||||||
"""raw sold_pct > 100 → returned sold_pct_of_supply=100.0, is_oversold=True."""
|
"""raw sold_pct > 100 → returned sold_pct_of_supply=100.0, is_oversold=True."""
|
||||||
id_rows = [_obj_id_row(1)]
|
id_rows = [_obj_id_row(1)]
|
||||||
# sum_deals=199, supply=100 → raw = 199% (несопоставимые окна)
|
# deals_window=199, supply=100 → raw = 199% (несопоставимые окна)
|
||||||
vel_rows = [_vel_row("2", sum_deals=199.0, obj_ids=[1])]
|
vel_rows = [_vel_row("2", deals_window=199.0, obj_ids=[1])]
|
||||||
supply_rows = [_supply_row("2", "40-60", 100)]
|
supply_rows = [_supply_row("2", "40-60", 100)]
|
||||||
db = _make_db(
|
db = _make_db(
|
||||||
coord=_coord_row(),
|
coord=_coord_row(),
|
||||||
|
|
@ -394,8 +399,8 @@ def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None:
|
||||||
def test_sold_pct_below_100_is_not_oversold() -> None:
|
def test_sold_pct_below_100_is_not_oversold() -> None:
|
||||||
"""raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False."""
|
"""raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False."""
|
||||||
id_rows = [_obj_id_row(1)]
|
id_rows = [_obj_id_row(1)]
|
||||||
# sum_deals=50, supply=100 → raw = 50%
|
# deals_window=50, supply=100 → raw = 50%
|
||||||
vel_rows = [_vel_row("1", sum_deals=50.0, obj_ids=[1])]
|
vel_rows = [_vel_row("1", deals_window=50.0, obj_ids=[1])]
|
||||||
supply_rows = [_supply_row("1", "40-60", 100)]
|
supply_rows = [_supply_row("1", "40-60", 100)]
|
||||||
db = _make_db(
|
db = _make_db(
|
||||||
coord=_coord_row(),
|
coord=_coord_row(),
|
||||||
|
|
@ -424,7 +429,7 @@ def test_filter_competitor_obj_ids_applied() -> None:
|
||||||
"""filter_competitor_obj_ids=[1] оставляет только obj_id=1."""
|
"""filter_competitor_obj_ids=[1] оставляет только obj_id=1."""
|
||||||
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
|
id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
|
||||||
# После фильтрации остаётся только obj_id=1, velocity запрос получит [1]
|
# После фильтрации остаётся только obj_id=1, velocity запрос получит [1]
|
||||||
vel_rows = [_vel_row("2", sum_deals=24.0, obj_ids=[1])]
|
vel_rows = [_vel_row("2", deals_window=24.0, obj_ids=[1])]
|
||||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.services.exporters.report_pdf import (
|
from app.services.exporters.report_pdf import (
|
||||||
_ADVISORY_MARKER,
|
_ADVISORY_MARKER,
|
||||||
_TITLE_FUTURE_MARKET,
|
_TITLE_FUTURE_MARKET,
|
||||||
|
|
@ -44,6 +46,31 @@ from app.services.forecasting.report import (
|
||||||
SiteFinderReport,
|
SiteFinderReport,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _weasyprint_native_libs_available() -> tuple[bool, str]:
|
||||||
|
"""Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present).
|
||||||
|
|
||||||
|
WeasyPrint is lazy-imported inside export_report_pdf, so the missing-native-lib
|
||||||
|
OSError fires only at write_pdf() time (libpango/libgobject absent on macOS dev,
|
||||||
|
present in the Docker/CI image). We probe by rendering a trivial document; failure
|
||||||
|
⇒ skip the PDF-rendering tests on dev, while the HTML-only tests still run and the
|
||||||
|
PDF path still runs on CI. NB: the HTML-content tests (_build_html only) are NOT
|
||||||
|
skipped — they never touch WeasyPrint.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
|
HTML(string="<p>x</p>").write_pdf()
|
||||||
|
except (OSError, ImportError) as e:
|
||||||
|
return False, str(e)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
|
||||||
|
_skip_if_no_weasyprint = pytest.mark.skipif(
|
||||||
|
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
|
||||||
|
)
|
||||||
|
|
||||||
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
|
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
|
||||||
_EXPECTED_TITLES: tuple[str, ...] = (
|
_EXPECTED_TITLES: tuple[str, ...] = (
|
||||||
_TITLE_SUMMARY,
|
_TITLE_SUMMARY,
|
||||||
|
|
@ -133,6 +160,7 @@ def _full_report() -> SiteFinderReport:
|
||||||
# ── Полный отчёт: PDF-байты (магия %PDF), нетривиальная длина ───────────────────
|
# ── Полный отчёт: PDF-байты (магия %PDF), нетривиальная длина ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_weasyprint
|
||||||
class TestFullReportExport:
|
class TestFullReportExport:
|
||||||
def test_returns_pdf_magic_bytes(self) -> None:
|
def test_returns_pdf_magic_bytes(self) -> None:
|
||||||
payload = export_report_pdf(_full_report())
|
payload = export_report_pdf(_full_report())
|
||||||
|
|
@ -195,6 +223,7 @@ class TestRenderedHtmlContent:
|
||||||
|
|
||||||
|
|
||||||
class TestGracefulPartialReport:
|
class TestGracefulPartialReport:
|
||||||
|
@_skip_if_no_weasyprint
|
||||||
def test_empty_report_still_valid_pdf(self) -> None:
|
def test_empty_report_still_valid_pdf(self) -> None:
|
||||||
# Полностью дефолтный (пустой) отчёт — валидный PDF, не падает.
|
# Полностью дефолтный (пустой) отчёт — валидный PDF, не падает.
|
||||||
payload = export_report_pdf(SiteFinderReport())
|
payload = export_report_pdf(SiteFinderReport())
|
||||||
|
|
@ -208,6 +237,7 @@ class TestGracefulPartialReport:
|
||||||
# ADVISORY-маркер остаётся даже у пустого отчёта.
|
# ADVISORY-маркер остаётся даже у пустого отчёта.
|
||||||
assert "ADVISORY" in html_str
|
assert "ADVISORY" in html_str
|
||||||
|
|
||||||
|
@_skip_if_no_weasyprint
|
||||||
def test_partial_report_some_sections(self) -> None:
|
def test_partial_report_some_sections(self) -> None:
|
||||||
# Заполнены только meta + exec_summary — остальные пусты, PDF валиден.
|
# Заполнены только meta + exec_summary — остальные пусты, PDF валиден.
|
||||||
report = SiteFinderReport(
|
report = SiteFinderReport(
|
||||||
|
|
@ -220,6 +250,7 @@ class TestGracefulPartialReport:
|
||||||
assert "Тонкий анализ" in html_str
|
assert "Тонкий анализ" in html_str
|
||||||
assert "66:41:0000000:2" in html_str
|
assert "66:41:0000000:2" in html_str
|
||||||
|
|
||||||
|
@_skip_if_no_weasyprint
|
||||||
def test_garbage_input_does_not_crash(self) -> None:
|
def test_garbage_input_does_not_crash(self) -> None:
|
||||||
# Мусор (None / не-отчёт) → пустой, но валидный PDF (нормализация в {}).
|
# Мусор (None / не-отчёт) → пустой, но валидный PDF (нормализация в {}).
|
||||||
for junk in (None, 123, "not a report"):
|
for junk in (None, 123, "not a report"):
|
||||||
|
|
|
||||||
|
|
@ -391,7 +391,7 @@ def test_upsert_parcel_sql_uses_st_multi_for_multipolygon_schema() -> None:
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
executed_sqls: list[str] = []
|
executed_sqls: list[str] = []
|
||||||
db.execute = lambda stmt, params=None: (executed_sqls.append(str(stmt)) or MagicMock())
|
db.execute = lambda stmt, params=None: executed_sqls.append(str(stmt)) or MagicMock()
|
||||||
|
|
||||||
upsert_parcel(db, _make_parcel_feature(), source="search")
|
upsert_parcel(db, _make_parcel_feature(), source="search")
|
||||||
|
|
||||||
|
|
@ -423,13 +423,23 @@ async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> Non
|
||||||
)
|
)
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter
|
# Симулируем shared phase_state с phase=done от ДРУГОГО quarter.
|
||||||
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
|
# grid-walk + territorial_zones фазы корректно пропускаются (тест про
|
||||||
|
# snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ
|
||||||
|
# db.execute().mappings().first() в этом тесте.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
mappings=MagicMock(
|
mappings=MagicMock(
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
first=MagicMock(
|
first=MagicMock(
|
||||||
return_value={"phase_state": {"phase": "done", "quarter": "66:41:9999999"}}
|
return_value={
|
||||||
|
"phase_state": {"phase": "done", "quarter": "66:41:9999999"},
|
||||||
|
"xmin": None,
|
||||||
|
"ymin": None,
|
||||||
|
"xmax": None,
|
||||||
|
"ymax": None,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -479,10 +489,24 @@ async def test_harvest_quarter_calls_upsert_features() -> None:
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
# phase_state = None → начинаем с нуля
|
# phase_state = None → начинаем с нуля
|
||||||
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
mappings=MagicMock(
|
mappings=MagicMock(
|
||||||
return_value=MagicMock(first=MagicMock(return_value={"phase_state": None}))
|
return_value=MagicMock(
|
||||||
|
first=MagicMock(
|
||||||
|
return_value={
|
||||||
|
"phase_state": None,
|
||||||
|
"xmin": None,
|
||||||
|
"ymin": None,
|
||||||
|
"xmax": None,
|
||||||
|
"ymax": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -669,10 +693,24 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero()
|
||||||
)
|
)
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
mappings=MagicMock(
|
mappings=MagicMock(
|
||||||
return_value=MagicMock(first=MagicMock(return_value={"phase_state": None}))
|
return_value=MagicMock(
|
||||||
|
first=MagicMock(
|
||||||
|
return_value={
|
||||||
|
"phase_state": None,
|
||||||
|
"xmin": None,
|
||||||
|
"ymin": None,
|
||||||
|
"xmax": None,
|
||||||
|
"ymax": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -730,10 +768,24 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
mappings=MagicMock(
|
mappings=MagicMock(
|
||||||
return_value=MagicMock(first=MagicMock(return_value={"phase_state": None}))
|
return_value=MagicMock(
|
||||||
|
first=MagicMock(
|
||||||
|
return_value={
|
||||||
|
"phase_state": None,
|
||||||
|
"xmin": None,
|
||||||
|
"ymin": None,
|
||||||
|
"xmax": None,
|
||||||
|
"ymax": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -786,10 +838,24 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N
|
||||||
)
|
)
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
mappings=MagicMock(
|
mappings=MagicMock(
|
||||||
return_value=MagicMock(first=MagicMock(return_value={"phase_state": None}))
|
return_value=MagicMock(
|
||||||
|
first=MagicMock(
|
||||||
|
return_value={
|
||||||
|
"phase_state": None,
|
||||||
|
"xmin": None,
|
||||||
|
"ymin": None,
|
||||||
|
"xmax": None,
|
||||||
|
"ymax": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -1091,10 +1157,24 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
# xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None →
|
||||||
|
# grid-walk + territorial_zones фазы пропускаются (тесты про snapshot /
|
||||||
|
# per-cat-probe, не про geometry). Тот же dict на ВСЕ
|
||||||
|
# db.execute().mappings().first() вызовы.
|
||||||
db.execute = MagicMock(
|
db.execute = MagicMock(
|
||||||
return_value=MagicMock(
|
return_value=MagicMock(
|
||||||
mappings=MagicMock(
|
mappings=MagicMock(
|
||||||
return_value=MagicMock(first=MagicMock(return_value={"phase_state": None}))
|
return_value=MagicMock(
|
||||||
|
first=MagicMock(
|
||||||
|
return_value={
|
||||||
|
"phase_state": None,
|
||||||
|
"xmin": None,
|
||||||
|
"ymin": None,
|
||||||
|
"xmax": None,
|
||||||
|
"ymax": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,53 @@ Uses psycopg v3 (never psycopg2) with a temporary table containing known data.
|
||||||
All bind parameters use CAST(:x AS type) — never :x::type.
|
All bind parameters use CAST(:x AS type) — never :x::type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DSN + skip guard
|
||||||
|
#
|
||||||
|
# This is an INTEGRATION test — it needs a real PostgreSQL (it CREATE TEMP TABLE
|
||||||
|
# and runs the weighted-AVG SQL). On CI (Docker) Postgres is reachable; on a dev
|
||||||
|
# laptop without the SSH tunnel it is not. We:
|
||||||
|
# 1. Prefer TEST_DATABASE_URL (mirror tests/integration/conftest.py), then
|
||||||
|
# DATABASE_URL — but normalize the SQLAlchemy dialect suffix `+psycopg`,
|
||||||
|
# which psycopg.connect() cannot parse (other test modules set DATABASE_URL
|
||||||
|
# to `postgresql+psycopg://...` via os.environ.setdefault → ProgrammingError).
|
||||||
|
# 2. Probe connectivity at import time; if the DB is unreachable, SKIP the whole
|
||||||
|
# module cleanly (never ERROR). On CI the probe succeeds → tests run, so the
|
||||||
|
# #295 weighted-AVG regression stays guarded.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixture: connect to the test / local DB via env-var DATABASE_URL,
|
|
||||||
# fall back to the tunnel URL used in local dev.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _get_dsn() -> str:
|
def _get_dsn() -> str:
|
||||||
import os
|
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
|
||||||
return os.environ.get(
|
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"postgresql://gendesign@localhost:15432/gendesign",
|
"postgresql://gendesign@localhost:15432/gendesign",
|
||||||
)
|
)
|
||||||
|
# SQLAlchemy dialect form `postgresql+psycopg://` → plain libpq DSN for psycopg.
|
||||||
|
return raw.replace("+psycopg", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _db_reachable() -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
with psycopg.connect(_get_dsn(), connect_timeout=3):
|
||||||
|
return True, ""
|
||||||
|
except Exception as e: # OperationalError / ProgrammingError / etc.
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
_DB_OK, _DB_ERR = _db_reachable()
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
not _DB_OK,
|
||||||
|
reason=(
|
||||||
|
"Нет доступной plain-postgres БД (TEST_DATABASE_URL/DATABASE_URL) — "
|
||||||
|
f"интеграционный тест #295 пропущен off-CI: {_DB_ERR}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|
@ -79,6 +109,7 @@ ORDER BY room_bucket;
|
||||||
# Test cases
|
# Test cases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestWeightedAvgFormula:
|
class TestWeightedAvgFormula:
|
||||||
"""Unit-level verification of the count-weighted formula using a temp table."""
|
"""Unit-level verification of the count-weighted formula using a temp table."""
|
||||||
|
|
||||||
|
|
@ -98,8 +129,8 @@ class TestWeightedAvgFormula:
|
||||||
cur.execute(_CREATE_TEMP)
|
cur.execute(_CREATE_TEMP)
|
||||||
rows = [
|
rows = [
|
||||||
("studio", 10, 25.0, 180.0),
|
("studio", 10, 25.0, 180.0),
|
||||||
("studio", 0, 0.0, 0.0),
|
("studio", 0, 0.0, 0.0),
|
||||||
("studio", 0, 0.0, 0.0),
|
("studio", 0, 0.0, 0.0),
|
||||||
]
|
]
|
||||||
cur.executemany(_INSERT_ROW, rows)
|
cur.executemany(_INSERT_ROW, rows)
|
||||||
cur.execute(_WEIGHTED_QUERY)
|
cur.execute(_WEIGHTED_QUERY)
|
||||||
|
|
@ -131,9 +162,7 @@ class TestWeightedAvgFormula:
|
||||||
assert weighted_area != naive_area
|
assert weighted_area != naive_area
|
||||||
assert weighted_price != naive_price
|
assert weighted_price != naive_price
|
||||||
|
|
||||||
def test_weighted_differs_from_naive_sparse_project(
|
def test_weighted_differs_from_naive_sparse_project(self, conn: psycopg.Connection) -> None:
|
||||||
self, conn: psycopg.Connection
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Scenario inspired by real data: 3-room flat, project with 5 deals
|
Scenario inspired by real data: 3-room flat, project with 5 deals
|
||||||
spread across 5 active months out of 15 total months.
|
spread across 5 active months out of 15 total months.
|
||||||
|
|
@ -150,7 +179,7 @@ class TestWeightedAvgFormula:
|
||||||
result = cur.fetchone()
|
result = cur.fetchone()
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
room, total_deals, naive_area, weighted_area, naive_price, weighted_price = result
|
room, total_deals, naive_area, weighted_area, _naive_price, weighted_price = result
|
||||||
|
|
||||||
assert room == "3"
|
assert room == "3"
|
||||||
assert total_deals == 5
|
assert total_deals == 5
|
||||||
|
|
@ -164,13 +193,9 @@ class TestWeightedAvgFormula:
|
||||||
|
|
||||||
# Correction factor > 2x
|
# Correction factor > 2x
|
||||||
ratio = float(weighted_area) / float(naive_area)
|
ratio = float(weighted_area) / float(naive_area)
|
||||||
assert ratio > 2.5, (
|
assert ratio > 2.5, f"Expected correction factor > 2.5x for sparse project, got {ratio:.2f}"
|
||||||
f"Expected correction factor > 2.5x for sparse project, got {ratio:.2f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_no_zero_months_weighted_equals_naive(
|
def test_no_zero_months_weighted_equals_naive(self, conn: psycopg.Connection) -> None:
|
||||||
self, conn: psycopg.Connection
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
When every month has deals, weighted and naive AVG should be equal
|
When every month has deals, weighted and naive AVG should be equal
|
||||||
(within numeric(10,2) rounding) only if counts are uniform.
|
(within numeric(10,2) rounding) only if counts are uniform.
|
||||||
|
|
@ -199,9 +224,7 @@ class TestWeightedAvgFormula:
|
||||||
)
|
)
|
||||||
assert weighted_price == naive_price
|
assert weighted_price == naive_price
|
||||||
|
|
||||||
def test_nullif_prevents_division_by_zero(
|
def test_nullif_prevents_division_by_zero(self, conn: psycopg.Connection) -> None:
|
||||||
self, conn: psycopg.Connection
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
When all months have 0 deals, NULLIF(SUM(cnt), 0) → NULL instead of divide-by-zero.
|
When all months have 0 deals, NULLIF(SUM(cnt), 0) → NULL instead of divide-by-zero.
|
||||||
The buggy AVG() also returns 0 (not NULL) for all-zero rows, which is arguably
|
The buggy AVG() also returns 0 (not NULL) for all-zero rows, which is arguably
|
||||||
|
|
@ -218,7 +241,7 @@ class TestWeightedAvgFormula:
|
||||||
result = cur.fetchone()
|
result = cur.fetchone()
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
_, total_deals, naive_area, weighted_area, naive_price, weighted_price = result
|
_, total_deals, naive_area, weighted_area, _naive_price, weighted_price = result
|
||||||
|
|
||||||
assert total_deals == 0
|
assert total_deals == 0
|
||||||
# Weighted formula returns NULL for all-zero-deal case (correct — no real data)
|
# Weighted formula returns NULL for all-zero-deal case (correct — no real data)
|
||||||
|
|
@ -231,9 +254,7 @@ class TestWeightedAvgFormula:
|
||||||
# Naive AVG returns 0.00 — a misleading non-NULL value
|
# Naive AVG returns 0.00 — a misleading non-NULL value
|
||||||
assert naive_area == Decimal("0.00")
|
assert naive_area == Decimal("0.00")
|
||||||
|
|
||||||
def test_hand_computed_weighted_average(
|
def test_hand_computed_weighted_average(self, conn: psycopg.Connection) -> None:
|
||||||
self, conn: psycopg.Connection
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
End-to-end hand-computed check with multiple room buckets and
|
End-to-end hand-computed check with multiple room buckets and
|
||||||
mixed deal counts to verify the formula is exactly correct.
|
mixed deal counts to verify the formula is exactly correct.
|
||||||
|
|
@ -247,11 +268,11 @@ class TestWeightedAvgFormula:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(_CREATE_TEMP)
|
cur.execute(_CREATE_TEMP)
|
||||||
rows = [
|
rows = [
|
||||||
("studio", 8, 30.0, 160.0),
|
("studio", 8, 30.0, 160.0),
|
||||||
("studio", 12, 28.0, 170.0),
|
("studio", 12, 28.0, 170.0),
|
||||||
("studio", 3, 27.0, 155.0),
|
("studio", 3, 27.0, 155.0),
|
||||||
("1", 15, 38.0, 175.0),
|
("1", 15, 38.0, 175.0),
|
||||||
("1", 5, 40.0, 180.0),
|
("1", 5, 40.0, 180.0),
|
||||||
]
|
]
|
||||||
cur.executemany(_INSERT_ROW, rows)
|
cur.executemany(_INSERT_ROW, rows)
|
||||||
cur.execute(_WEIGHTED_QUERY)
|
cur.execute(_WEIGHTED_QUERY)
|
||||||
|
|
|
||||||
|
|
@ -404,8 +404,18 @@ def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> N
|
||||||
# ── Auth ───────────────────────────────────────────────────────────────────────
|
# ── Auth ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
_TOKEN_REMOVED_REASON = (
|
||||||
|
"App-level X-Admin-Token header удалён 2026-05-23 (см. docstring "
|
||||||
|
"app/api/v1/admin_weight_profiles.py: Caddy basic_auth PR #426 + RBAC достаточны, "
|
||||||
|
"двойная auth избыточна). Endpoint больше не несёт verify_admin_token dependency. "
|
||||||
|
"В test-mode RBAC bypass'ится (CI-rehab 1/3) → 401 здесь недостижим без реверта "
|
||||||
|
"security-решения. Тест проверял удалённое поведение."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON)
|
||||||
def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Запрос без X-Admin-Token → 401 (или 503 если токен не задан в settings)."""
|
"""Запрос без X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23)."""
|
||||||
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
|
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
r = client.get(
|
r = client.get(
|
||||||
|
|
@ -416,8 +426,9 @@ def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON)
|
||||||
def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""Неверный X-Admin-Token → 401."""
|
"""Неверный X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23)."""
|
||||||
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
|
monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN)
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
r = client.get(
|
r = client.get(
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,40 @@ import datetime as dt
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Attempt to import the module under test; skip entire module if native libs missing.
|
|
||||||
try:
|
|
||||||
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
|
||||||
except (OSError, ImportError) as _e: # GTK libs missing on Windows, or weasyprint not installed
|
|
||||||
pytest.skip(f"WeasyPrint deps missing: {_e}", allow_module_level=True)
|
|
||||||
|
|
||||||
from app.schemas.parcel import (
|
def _weasyprint_native_libs_available() -> tuple[bool, str]:
|
||||||
|
"""Probe whether WeasyPrint can actually render (native GTK/Pango/GObject libs).
|
||||||
|
|
||||||
|
WeasyPrint is lazy-imported inside render_layout_tz_pdf, so a plain module
|
||||||
|
import no longer surfaces the missing-native-lib OSError. The native libs
|
||||||
|
(libgobject/libpango) are present in the Docker/CI image (Linux) but absent
|
||||||
|
on macOS/Windows dev machines — the OSError fires only at write_pdf() time.
|
||||||
|
We probe by rendering a trivial document; failure ⇒ skip on dev, but the
|
||||||
|
suite still runs the PDF path on CI where the libs exist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
|
HTML(string="<p>x</p>").write_pdf()
|
||||||
|
except (OSError, ImportError) as e:
|
||||||
|
return False, str(e)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
|
||||||
|
if not _WP_OK:
|
||||||
|
pytest.skip(f"WeasyPrint native libs missing: {_WP_ERR}", allow_module_level=True)
|
||||||
|
|
||||||
|
# Импорты app-модулей ПОСЛЕ module-level skip guard выше — иначе на macOS без
|
||||||
|
# native libs импорт схемы не нужен (модуль skip'ается). E402 здесь намеренный.
|
||||||
|
from app.schemas.parcel import ( # noqa: E402
|
||||||
BestLayoutsResponse,
|
BestLayoutsResponse,
|
||||||
LayoutDataQuality,
|
LayoutDataQuality,
|
||||||
LayoutTzMixRow,
|
LayoutTzMixRow,
|
||||||
LayoutTzRecommendation,
|
LayoutTzRecommendation,
|
||||||
TopLayoutRow,
|
TopLayoutRow,
|
||||||
)
|
)
|
||||||
|
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _sample_response() -> BestLayoutsResponse:
|
def _sample_response() -> BestLayoutsResponse:
|
||||||
|
|
@ -40,6 +61,7 @@ def _sample_response() -> BestLayoutsResponse:
|
||||||
avg_area_m2=38.5,
|
avg_area_m2=38.5,
|
||||||
supply_units_in_radius=312,
|
supply_units_in_radius=312,
|
||||||
sold_pct_of_supply=21.5,
|
sold_pct_of_supply=21.5,
|
||||||
|
is_oversold=False,
|
||||||
),
|
),
|
||||||
TopLayoutRow(
|
TopLayoutRow(
|
||||||
rank=2,
|
rank=2,
|
||||||
|
|
@ -54,6 +76,7 @@ def _sample_response() -> BestLayoutsResponse:
|
||||||
avg_area_m2=22.0,
|
avg_area_m2=22.0,
|
||||||
supply_units_in_radius=100,
|
supply_units_in_radius=100,
|
||||||
sold_pct_of_supply=40.0,
|
sold_pct_of_supply=40.0,
|
||||||
|
is_oversold=False,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
recommendation_for_tz=LayoutTzRecommendation(
|
recommendation_for_tz=LayoutTzRecommendation(
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,7 @@ def test_quarter_dump_total_features() -> None:
|
||||||
"flooding": [_make_feat("fl1"), _make_feat("fl2"), _make_feat("fl3")], # 3
|
"flooding": [_make_feat("fl1"), _make_feat("fl2"), _make_feat("fl3")], # 3
|
||||||
"landslide": [_make_feat("ls1")], # 1
|
"landslide": [_make_feat("ls1")], # 1
|
||||||
},
|
},
|
||||||
|
opportunity={}, # 0
|
||||||
layers_fetched=("search", "parcels", "buildings"),
|
layers_fetched=("search", "parcels", "buildings"),
|
||||||
bbox_3857=(6700000.0, 7700000.0, 6800000.0, 7800000.0),
|
bbox_3857=(6700000.0, 7700000.0, 6800000.0, 7800000.0),
|
||||||
fetched_at_utc="2026-05-12T00:00:00+00:00",
|
fetched_at_utc="2026-05-12T00:00:00+00:00",
|
||||||
|
|
@ -432,6 +433,7 @@ def test_quarter_dump_frozen() -> None:
|
||||||
engineering_structures=[],
|
engineering_structures=[],
|
||||||
zouit={},
|
zouit={},
|
||||||
risks={},
|
risks={},
|
||||||
|
opportunity={},
|
||||||
layers_fetched=("search",),
|
layers_fetched=("search",),
|
||||||
bbox_3857=None,
|
bbox_3857=None,
|
||||||
fetched_at_utc="2026-05-12T00:00:00+00:00",
|
fetched_at_utc="2026-05-12T00:00:00+00:00",
|
||||||
|
|
|
||||||
|
|
@ -92,8 +92,9 @@ def test_get_quarter_dump_stale() -> None:
|
||||||
"""Дамп старше 180 дней → stale=True, harvest triggered, available=False."""
|
"""Дамп старше 180 дней → stale=True, harvest triggered, available=False."""
|
||||||
old_date = datetime.now(UTC) - timedelta(days=200)
|
old_date = datetime.now(UTC) - timedelta(days=200)
|
||||||
# row: (quarter_cad, fetched_at_utc, total_features, harvest_error,
|
# row: (quarter_cad, fetched_at_utc, total_features, harvest_error,
|
||||||
# territorial_zones_count, zouit_count, engineering_count)
|
# territorial_zones_count, zouit_count, engineering_count,
|
||||||
db = _make_db_mock(row=("66:41:0204016", old_date, 50, None, 5, 2, 1))
|
# risks_count, opportunity_count, red_lines_count)
|
||||||
|
db = _make_db_mock(row=("66:41:0204016", old_date, 50, None, 5, 2, 1, 0, 0, 0))
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.site_finder.quarter_dump_lookup._trigger_harvest",
|
"app.services.site_finder.quarter_dump_lookup._trigger_harvest",
|
||||||
|
|
@ -111,7 +112,7 @@ def test_get_quarter_dump_stale() -> None:
|
||||||
def test_get_quarter_dump_with_harvest_error() -> None:
|
def test_get_quarter_dump_with_harvest_error() -> None:
|
||||||
"""Дамп с harvest_error → harvest_triggered=True, available=False."""
|
"""Дамп с harvest_error → harvest_triggered=True, available=False."""
|
||||||
fresh_date = datetime.now(UTC) - timedelta(days=10)
|
fresh_date = datetime.now(UTC) - timedelta(days=10)
|
||||||
db = _make_db_mock(row=("66:41:0204016", fresh_date, 0, "WAF 429", 0, 0, 0))
|
db = _make_db_mock(row=("66:41:0204016", fresh_date, 0, "WAF 429", 0, 0, 0, 0, 0, 0))
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"app.services.site_finder.quarter_dump_lookup._trigger_harvest",
|
"app.services.site_finder.quarter_dump_lookup._trigger_harvest",
|
||||||
|
|
@ -130,7 +131,7 @@ def test_get_quarter_dump_with_harvest_error() -> None:
|
||||||
def test_get_quarter_dump_no_parcel_geometry() -> None:
|
def test_get_quarter_dump_no_parcel_geometry() -> None:
|
||||||
"""parcel_wkt=None → metadata only, нет spatial queries."""
|
"""parcel_wkt=None → metadata only, нет spatial queries."""
|
||||||
fresh_date = datetime.now(UTC) - timedelta(days=5)
|
fresh_date = datetime.now(UTC) - timedelta(days=5)
|
||||||
db = _make_db_mock(row=("66:41:0204016", fresh_date, 120, None, 10, 3, 2))
|
db = _make_db_mock(row=("66:41:0204016", fresh_date, 120, None, 10, 3, 2, 0, 0, 0))
|
||||||
|
|
||||||
result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=None)
|
result = get_quarter_dump_data(db, "66:41:0204016", parcel_wkt=None)
|
||||||
|
|
||||||
|
|
@ -162,9 +163,10 @@ def _make_db_mock_with_spatial(
|
||||||
|
|
||||||
Хрупко при реорганизации порядка запросов в production-коде. При изменении
|
Хрупко при реорганизации порядка запросов в production-коде. При изменении
|
||||||
порядка в get_quarter_dump_data — обновить индексы call_count здесь.
|
порядка в get_quarter_dump_data — обновить индексы call_count здесь.
|
||||||
dump_row должен быть 7-элементным tuple:
|
dump_row должен быть 10-элементным tuple:
|
||||||
(quarter_cad, fetched_at_utc, total_features, harvest_error,
|
(quarter_cad, fetched_at_utc, total_features, harvest_error,
|
||||||
territorial_zones_count, zouit_count, engineering_count).
|
territorial_zones_count, zouit_count, engineering_count,
|
||||||
|
risks_count, opportunity_count, red_lines_count).
|
||||||
"""
|
"""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
call_count = 0
|
call_count = 0
|
||||||
|
|
@ -196,7 +198,7 @@ def _make_db_mock_with_spatial(
|
||||||
def test_get_quarter_dump_fresh_zoning() -> None:
|
def test_get_quarter_dump_fresh_zoning() -> None:
|
||||||
"""Свежий дамп с territorial_zone → nspd_zoning populated."""
|
"""Свежий дамп с territorial_zone → nspd_zoning populated."""
|
||||||
fresh_date = datetime.now(UTC) - timedelta(days=30)
|
fresh_date = datetime.now(UTC) - timedelta(days=30)
|
||||||
dump_row = ("66:41:0204016", fresh_date, 100, None, 5, 0, 0)
|
dump_row = ("66:41:0204016", fresh_date, 100, None, 5, 0, 0, 0, 0, 0)
|
||||||
# zone_props JSONB в виде dict (SQLAlchemy вернёт dict)
|
# zone_props JSONB в виде dict (SQLAlchemy вернёт dict)
|
||||||
zone_props: dict[str, Any] = {
|
zone_props: dict[str, Any] = {
|
||||||
"reg_numb_border": "Ж-3",
|
"reg_numb_border": "Ж-3",
|
||||||
|
|
@ -225,7 +227,7 @@ def test_get_quarter_dump_fresh_zoning() -> None:
|
||||||
def test_get_quarter_dump_fresh_zoning_no_match() -> None:
|
def test_get_quarter_dump_fresh_zoning_no_match() -> None:
|
||||||
"""Свежий дамп, centroid вне всех territorial_zones → nspd_zoning=None."""
|
"""Свежий дамп, centroid вне всех territorial_zones → nspd_zoning=None."""
|
||||||
fresh_date = datetime.now(UTC) - timedelta(days=30)
|
fresh_date = datetime.now(UTC) - timedelta(days=30)
|
||||||
dump_row = ("66:41:0204016", fresh_date, 100, None, 3, 0, 0)
|
dump_row = ("66:41:0204016", fresh_date, 100, None, 3, 0, 0, 0, 0, 0)
|
||||||
|
|
||||||
db = _make_db_mock_with_spatial(
|
db = _make_db_mock_with_spatial(
|
||||||
dump_row=dump_row,
|
dump_row=dump_row,
|
||||||
|
|
@ -247,7 +249,7 @@ def test_get_quarter_dump_fresh_zouit_overlaps() -> None:
|
||||||
fresh_date = datetime.now(UTC) - timedelta(days=10)
|
fresh_date = datetime.now(UTC) - timedelta(days=10)
|
||||||
# territorial_zones_count > 0 чтобы все 3 spatial запроса вызывались
|
# territorial_zones_count > 0 чтобы все 3 spatial запроса вызывались
|
||||||
# (иначе call_count в mock смещается и zouit попадает не в тот слот)
|
# (иначе call_count в mock смещается и zouit попадает не в тот слот)
|
||||||
dump_row = ("66:41:0204016", fresh_date, 200, None, 1, 5, 0)
|
dump_row = ("66:41:0204016", fresh_date, 200, None, 1, 5, 0, 0, 0, 0)
|
||||||
|
|
||||||
props1: dict[str, Any] = {"name": "ОКН Дом купца", "subcategory": "okn"}
|
props1: dict[str, Any] = {"name": "ОКН Дом купца", "subcategory": "okn"}
|
||||||
props2: dict[str, Any] = {"name": "ОЗ газопровода", "subcategory": "engineering"}
|
props2: dict[str, Any] = {"name": "ОЗ газопровода", "subcategory": "engineering"}
|
||||||
|
|
@ -280,7 +282,7 @@ def test_get_quarter_dump_fresh_zouit_overlaps() -> None:
|
||||||
def test_get_quarter_dump_includes_nspd_fields() -> None:
|
def test_get_quarter_dump_includes_nspd_fields() -> None:
|
||||||
"""Базовая проверка что все 4 nspd-ключа присутствуют в возвращаемом dict."""
|
"""Базовая проверка что все 4 nspd-ключа присутствуют в возвращаемом dict."""
|
||||||
fresh_date = datetime.now(UTC) - timedelta(days=1)
|
fresh_date = datetime.now(UTC) - timedelta(days=1)
|
||||||
dump_row = ("66:41:0204016", fresh_date, 55, None, 2, 1, 0)
|
dump_row = ("66:41:0204016", fresh_date, 55, None, 2, 1, 0, 0, 0, 0)
|
||||||
|
|
||||||
db = _make_db_mock_with_spatial(
|
db = _make_db_mock_with_spatial(
|
||||||
dump_row=dump_row,
|
dump_row=dump_row,
|
||||||
|
|
@ -345,7 +347,7 @@ def test_early_exit_all_counts_zero_no_spatial_queries() -> None:
|
||||||
"""Если все denormalized counts == 0 — spatial queries не вызываются."""
|
"""Если все denormalized counts == 0 — spatial queries не вызываются."""
|
||||||
fresh_date = datetime.now(UTC) - timedelta(days=5)
|
fresh_date = datetime.now(UTC) - timedelta(days=5)
|
||||||
# territorial_zones_count=0, zouit_count=0, engineering_count=0
|
# territorial_zones_count=0, zouit_count=0, engineering_count=0
|
||||||
dump_row = ("66:41:0204016", fresh_date, 50, None, 0, 0, 0)
|
dump_row = ("66:41:0204016", fresh_date, 50, None, 0, 0, 0, 0, 0, 0)
|
||||||
|
|
||||||
db = _make_db_mock_with_spatial(dump_row=dump_row)
|
db = _make_db_mock_with_spatial(dump_row=dump_row)
|
||||||
|
|
||||||
|
|
@ -353,8 +355,11 @@ def test_early_exit_all_counts_zero_no_spatial_queries() -> None:
|
||||||
db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))"
|
db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Только 1 execute-вызов — чтение dump row; spatial helpers пропущены
|
# 2 execute-вызова: чтение dump row (0) + cad_zouit fallback (#232) — при
|
||||||
assert db.execute.call_count == 1
|
# zouit_count==0 _get_zouit_overlaps не early-exit'ит, а делает fallback на
|
||||||
|
# cad_zouit. Остальные spatial helpers (zoning/engineering/risk/opportunity/
|
||||||
|
# red_lines) пропущены по своим нулевым счётчикам.
|
||||||
|
assert db.execute.call_count == 2
|
||||||
assert result["nspd_zoning"] is None
|
assert result["nspd_zoning"] is None
|
||||||
assert result["nspd_zouit_overlaps"] == []
|
assert result["nspd_zouit_overlaps"] == []
|
||||||
assert result["nspd_engineering_nearby"] == []
|
assert result["nspd_engineering_nearby"] == []
|
||||||
|
|
@ -365,7 +370,7 @@ def test_early_exit_partial_counts() -> None:
|
||||||
"""territorial_zones_count > 0, zouit_count == 0 → zoning query вызывается,
|
"""territorial_zones_count > 0, zouit_count == 0 → zoning query вызывается,
|
||||||
zouit и engineering — нет."""
|
zouit и engineering — нет."""
|
||||||
fresh_date = datetime.now(UTC) - timedelta(days=5)
|
fresh_date = datetime.now(UTC) - timedelta(days=5)
|
||||||
dump_row = ("66:41:0204016", fresh_date, 30, None, 3, 0, 0)
|
dump_row = ("66:41:0204016", fresh_date, 30, None, 3, 0, 0, 0, 0, 0)
|
||||||
zone_props: dict[str, Any] = {"reg_numb_border": "Ж-1", "type_zone": "Зона жилая"}
|
zone_props: dict[str, Any] = {"reg_numb_border": "Ж-1", "type_zone": "Зона жилая"}
|
||||||
zoning_row_mock = MagicMock()
|
zoning_row_mock = MagicMock()
|
||||||
zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None
|
zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None
|
||||||
|
|
@ -381,8 +386,11 @@ def test_early_exit_partial_counts() -> None:
|
||||||
db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))"
|
db, "66:41:0204016", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))"
|
||||||
)
|
)
|
||||||
|
|
||||||
# dump lookup (0) + zoning (1) = 2 вызова; zouit и engineering — пропущены
|
# dump lookup (0) + zoning (1) + cad_zouit fallback (2) = 3 вызова;
|
||||||
assert db.execute.call_count == 2
|
# engineering/risk/opportunity/red_lines — пропущены по нулевым счётчикам.
|
||||||
|
# zouit_count==0 → _get_zouit_overlaps делает fallback на cad_zouit (#232),
|
||||||
|
# который возвращает [] (mock fetchall пуст).
|
||||||
|
assert db.execute.call_count == 3
|
||||||
assert result["nspd_zoning"] is not None
|
assert result["nspd_zoning"] is not None
|
||||||
assert result["nspd_zoning"]["zone_code"] == "Ж-1"
|
assert result["nspd_zoning"]["zone_code"] == "Ж-1"
|
||||||
assert result["nspd_zouit_overlaps"] == []
|
assert result["nspd_zouit_overlaps"] == []
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue