538 lines
28 KiB
Python
538 lines
28 KiB
Python
"""Тесты ГАР-loader'а знаменателя (app/services/gar_flats_loader.py, мигр. 143).
|
||
|
||
Структура (mirror'ит test_cadastral_geo_match):
|
||
- ДЕТЕРМИНИРОВАННОЕ ядро (без БД): синтетические крошечные ГАР-XML фикстуры в tmp_path →
|
||
парс → assert apartment-count на дом, сборка адреса, фильтрация ISACTUAL/ISACTIVE,
|
||
дом с 0 квартир → flat_count 0, мульти-сегментная сборка адреса.
|
||
- СТАТИЧЕСКИЕ asserts по SQL (_INSERT_SQL / _MATCH_SQL): ON CONFLICT, psycopg-v3 CAST,
|
||
DISTINCT ON tie-break, идемпотентный gate.
|
||
- ОПЦИОНАЛЬНЫЙ real-Postgres тест upsert+match → houses.gar_flat_count (self-SKIP без БД,
|
||
создаёт TEMP-таблицы + fn в транзакции и откатывает).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import inspect
|
||
import os
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
# settings нужен DSN на импорте (как в sibling-тестах); ядро статично/без БД.
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import gar_flats_loader as gfl
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Фикстуры: крошечный синтетический ГАР-дамп
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Иерархия:
|
||
# 1 регион «обл Свердловская»
|
||
# └─ 2 город «г Екатеринбург»
|
||
# ├─ 3 улица «ул Шаумяна»
|
||
# │ ├─ 100 дом «10» → квартиры 1001,1002,1003 (3 активные) + 1004(ISACTIVE=0)
|
||
# │ └─ 200 дом «5 к1» → квартиры 2001,2002 (2 активные)
|
||
# └─ 30 улица «ул Ленина»
|
||
# └─ 300 дом «7» → 0 квартир
|
||
# inactive: адр-объект 9 (ISACTIVE=0), дом 900 (ISACTUAL=0), кв 1005 (ISACTUAL=0)
|
||
|
||
_ADDR_OBJ_XML = """<?xml version="1.0" encoding="utf-8"?>
|
||
<ADDRESSOBJECTS>
|
||
<OBJECT OBJECTID="1" OBJECTGUID="g-region" NAME="Свердловская" TYPENAME="обл"
|
||
LEVEL="1" ISACTUAL="1" ISACTIVE="1"/>
|
||
<OBJECT OBJECTID="2" OBJECTGUID="g-city" NAME="Екатеринбург" TYPENAME="г"
|
||
LEVEL="5" ISACTUAL="1" ISACTIVE="1"/>
|
||
<OBJECT OBJECTID="3" OBJECTGUID="g-shaumyana" NAME="Шаумяна" TYPENAME="ул"
|
||
LEVEL="8" ISACTUAL="1" ISACTIVE="1"/>
|
||
<OBJECT OBJECTID="30" OBJECTGUID="g-lenina" NAME="Ленина" TYPENAME="ул"
|
||
LEVEL="8" ISACTUAL="1" ISACTIVE="1"/>
|
||
<OBJECT OBJECTID="9" OBJECTGUID="g-dead" NAME="Снесённая" TYPENAME="ул"
|
||
LEVEL="8" ISACTUAL="1" ISACTIVE="0"/>
|
||
</ADDRESSOBJECTS>
|
||
"""
|
||
|
||
_HOUSES_XML = """<?xml version="1.0" encoding="utf-8"?>
|
||
<HOUSES>
|
||
<HOUSE OBJECTID="100" OBJECTGUID="h-a" HOUSENUM="10" HOUSETYPE="2"
|
||
ISACTUAL="1" ISACTIVE="1"/>
|
||
<HOUSE OBJECTID="200" OBJECTGUID="h-b" HOUSENUM="5" ADDNUM1="1" ADDTYPE1="1"
|
||
HOUSETYPE="2" ISACTUAL="1" ISACTIVE="1"/>
|
||
<HOUSE OBJECTID="300" OBJECTGUID="h-c" HOUSENUM="7" HOUSETYPE="2"
|
||
ISACTUAL="1" ISACTIVE="1"/>
|
||
<HOUSE OBJECTID="900" OBJECTGUID="h-dead" HOUSENUM="99" HOUSETYPE="2"
|
||
ISACTUAL="0" ISACTIVE="1"/>
|
||
</HOUSES>
|
||
"""
|
||
|
||
_APARTMENTS_XML = """<?xml version="1.0" encoding="utf-8"?>
|
||
<APARTMENTS>
|
||
<APARTMENT OBJECTID="1001" NUMBER="1" APARTTYPE="2" ISACTUAL="1" ISACTIVE="1"/>
|
||
<APARTMENT OBJECTID="1002" NUMBER="2" APARTTYPE="2" ISACTUAL="1" ISACTIVE="1"/>
|
||
<APARTMENT OBJECTID="1003" NUMBER="3" APARTTYPE="2" ISACTUAL="1" ISACTIVE="1"/>
|
||
<APARTMENT OBJECTID="1004" NUMBER="4" APARTTYPE="2" ISACTUAL="1" ISACTIVE="0"/>
|
||
<APARTMENT OBJECTID="1005" NUMBER="5" APARTTYPE="2" ISACTUAL="0" ISACTIVE="1"/>
|
||
<APARTMENT OBJECTID="2001" NUMBER="1" APARTTYPE="2" ISACTUAL="1" ISACTIVE="1"/>
|
||
<APARTMENT OBJECTID="2002" NUMBER="2" APARTTYPE="2" ISACTUAL="1" ISACTIVE="1"/>
|
||
</APARTMENTS>
|
||
"""
|
||
|
||
# Иерархия: пара (OBJECTID лежит под PARENTOBJID).
|
||
_MUN_HIER_XML = """<?xml version="1.0" encoding="utf-8"?>
|
||
<ITEMS>
|
||
<ITEM OBJECTID="2" PARENTOBJID="1" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="3" PARENTOBJID="2" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="30" PARENTOBJID="2" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="100" PARENTOBJID="3" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="200" PARENTOBJID="3" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="300" PARENTOBJID="30" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="1001" PARENTOBJID="100" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="1002" PARENTOBJID="100" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="1003" PARENTOBJID="100" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="1004" PARENTOBJID="100" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="2001" PARENTOBJID="200" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="2002" PARENTOBJID="200" ISACTIVE="1"/>
|
||
<ITEM OBJECTID="9999" PARENTOBJID="100" ISACTIVE="0"/>
|
||
</ITEMS>
|
||
"""
|
||
|
||
|
||
@pytest.fixture
|
||
def gar_dir(tmp_path: Path) -> str:
|
||
"""Пишет синтетический ГАР-каталог, возвращает путь."""
|
||
(tmp_path / "AS_ADDR_OBJ_20260601_x.XML").write_text(_ADDR_OBJ_XML, encoding="utf-8")
|
||
(tmp_path / "AS_HOUSES_20260601_x.XML").write_text(_HOUSES_XML, encoding="utf-8")
|
||
(tmp_path / "AS_APARTMENTS_20260601_x.XML").write_text(_APARTMENTS_XML, encoding="utf-8")
|
||
(tmp_path / "AS_MUN_HIERARCHY_20260601_x.XML").write_text(_MUN_HIER_XML, encoding="utf-8")
|
||
return str(tmp_path)
|
||
|
||
|
||
def _rows_by_guid(rows: list[gfl.GarHouseRow]) -> dict[str, gfl.GarHouseRow]:
|
||
return {r.house_guid: r for r in rows}
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Парс: отдельные файлы
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_parse_addr_objects_filters_inactive(gar_dir: str) -> None:
|
||
paths = gfl._find_files(gar_dir, gfl.ADDR_OBJ_PREFIX)
|
||
addr = gfl.parse_addr_objects(paths)
|
||
assert set(addr.keys()) == {1, 2, 3, 30} # снесённая (9, ISACTIVE=0) исключена
|
||
assert addr[3].name == "Шаумяна" and addr[3].typename == "ул"
|
||
|
||
|
||
def test_parse_houses_filters_non_actual(gar_dir: str) -> None:
|
||
paths = gfl._find_files(gar_dir, gfl.HOUSES_PREFIX)
|
||
houses = gfl.parse_houses(paths)
|
||
assert set(houses.keys()) == {100, 200, 300} # 900 (ISACTUAL=0) исключён
|
||
assert houses[200].housenum == "5" and houses[200].addnum1 == "1"
|
||
|
||
|
||
def test_parse_apartment_ids_excludes_inactive_and_non_actual(gar_dir: str) -> None:
|
||
paths = gfl._find_files(gar_dir, gfl.APARTMENTS_PREFIX)
|
||
apts = gfl.parse_apartment_ids(paths)
|
||
# 1004 (ISACTIVE=0) и 1005 (ISACTUAL=0) исключены.
|
||
assert apts == {1001, 1002, 1003, 2001, 2002}
|
||
|
||
|
||
def test_parse_hierarchy_counts_apartments_per_house(gar_dir: str) -> None:
|
||
addr = gfl.parse_addr_objects(gfl._find_files(gar_dir, gfl.ADDR_OBJ_PREFIX))
|
||
houses = gfl.parse_houses(gfl._find_files(gar_dir, gfl.HOUSES_PREFIX))
|
||
apts = gfl.parse_apartment_ids(gfl._find_files(gar_dir, gfl.APARTMENTS_PREFIX))
|
||
hier_paths = gfl._find_files(gar_dir, gfl.MUN_HIER_PREFIX)
|
||
apt_count, child_to_parent = gfl.parse_hierarchy(hier_paths, apts, set(houses), set(addr))
|
||
# ISACTIVE=0 строка иерархии (9999) не учитывается; 1004 не в apartment_ids → не считается.
|
||
assert apt_count.get(100) == 3
|
||
assert apt_count.get(200) == 2
|
||
assert apt_count.get(300, 0) == 0 # дом без квартир
|
||
# child_to_parent заполнен ТОЛЬКО для домов и адр-объектов (не для квартир).
|
||
assert child_to_parent[100] == 3
|
||
assert child_to_parent[3] == 2
|
||
assert 1001 not in child_to_parent
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Сборка адреса + строки
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_build_rows_apartment_counts_and_zero_house(gar_dir: str) -> None:
|
||
rows, stats = gfl.parse_gar_region(gar_dir, "66", "2026-06-01")
|
||
by_guid = _rows_by_guid(rows)
|
||
assert by_guid["h-a"].flat_count == 3
|
||
assert by_guid["h-b"].flat_count == 2
|
||
assert by_guid["h-c"].flat_count == 0 # дом с 0 квартир → flat_count 0
|
||
assert stats.houses == 3
|
||
assert stats.apartments == 5 # суммарно учтено под домами
|
||
assert stats.rows == 3
|
||
|
||
|
||
def test_multi_segment_address_assembly(gar_dir: str) -> None:
|
||
rows, _ = gfl.parse_gar_region(gar_dir, "66", "2026-06-01")
|
||
a = _rows_by_guid(rows)["h-a"]
|
||
# street_name = непосредственный родитель (улица), «<TYPENAME> <NAME>».
|
||
assert a.street_name == "ул Шаумяна"
|
||
assert a.house_num == "10"
|
||
# full_address — мульти-сегмент регион→город→улица + номер (регион — name-first).
|
||
assert a.full_address == "Свердловская обл, г Екатеринбург, ул Шаумяна, 10"
|
||
# norm_src (вход нормализатора) — «улица, номер».
|
||
assert a.norm_src == "ул Шаумяна, 10"
|
||
|
||
|
||
def test_house_num_with_korpus(gar_dir: str) -> None:
|
||
rows, _ = gfl.parse_gar_region(gar_dir, "66", "2026-06-01")
|
||
b = _rows_by_guid(rows)["h-b"]
|
||
# ADDNUM1=1 ADDTYPE1=1 (Корпус) → «5 к1».
|
||
assert b.house_num == "5 к1"
|
||
assert b.street_name == "ул Шаумяна"
|
||
|
||
|
||
def test_region_and_version_propagated(gar_dir: str) -> None:
|
||
rows, _ = gfl.parse_gar_region(gar_dir, "66", "2026-06-01")
|
||
assert all(r.region_code == "66" for r in rows)
|
||
assert all(r.gar_version == "2026-06-01" for r in rows)
|
||
|
||
|
||
def test_render_house_num_unit() -> None:
|
||
h = gfl.GarHouse(
|
||
object_id=1,
|
||
guid="x",
|
||
housenum="12",
|
||
addnum1="3",
|
||
addnum2="",
|
||
addtype1="2",
|
||
addtype2="",
|
||
)
|
||
assert gfl.render_house_num(h) == "12 стр3" # ADDTYPE1=2 = Строение
|
||
|
||
|
||
def test_adm_hierarchy_fallback(tmp_path: Path) -> None:
|
||
"""Без AS_MUN_HIERARCHY использует AS_ADM_HIERARCHY (fallback)."""
|
||
(tmp_path / "AS_ADDR_OBJ_x.XML").write_text(_ADDR_OBJ_XML, encoding="utf-8")
|
||
(tmp_path / "AS_HOUSES_x.XML").write_text(_HOUSES_XML, encoding="utf-8")
|
||
(tmp_path / "AS_APARTMENTS_x.XML").write_text(_APARTMENTS_XML, encoding="utf-8")
|
||
(tmp_path / "AS_ADM_HIERARCHY_x.XML").write_text(_MUN_HIER_XML, encoding="utf-8")
|
||
rows, _ = gfl.parse_gar_region(str(tmp_path), "66", "2026-06-01")
|
||
assert _rows_by_guid(rows)["h-a"].flat_count == 3
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Статические asserts по SQL (psycopg v3, ON CONFLICT, tie-break)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
_INSERT_SQL = str(gfl._INSERT_SQL.text)
|
||
_MATCH_SQL = str(gfl._MATCH_SQL.text)
|
||
|
||
|
||
def test_insert_is_idempotent_on_conflict() -> None:
|
||
flat = re.sub(r"\s+", " ", _INSERT_SQL)
|
||
assert "INSERT INTO gar_house_flats" in flat
|
||
assert "ON CONFLICT (house_guid) DO UPDATE SET" in flat
|
||
# norm_address считает SQL-fn в INSERT (= «UPDATE после insert»).
|
||
assert "tradein_normalize_short_addr(CAST(:norm_src AS text))" in flat
|
||
|
||
|
||
def test_match_distinct_on_tiebreak_max_flat_count() -> None:
|
||
flat = re.sub(r"\s+", " ", _MATCH_SQL)
|
||
# Канонический ключ (мигр. 144), а не точное равенство norm_address.
|
||
assert "DISTINCT ON (canon)" in flat
|
||
assert "tradein_canon_addr(norm_address) AS canon" in flat
|
||
# Tie-break: на canon — строка с макс flat_count, затем меньший house_guid.
|
||
assert "ORDER BY canon, flat_count DESC, house_guid" in flat
|
||
# houses-сторона: тот же канон поверх COALESCE(short/full/address).
|
||
houses_side = (
|
||
"tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon"
|
||
)
|
||
assert houses_side in flat
|
||
# ЕКБ-ограничение по full_address (защита от cross-town коллизий).
|
||
assert "full_address ILIKE '%' || CAST(:city AS text) || '%'" in flat
|
||
# Идемпотентность повторного прогона.
|
||
assert "h.gar_flat_count IS DISTINCT FROM gp.flat_count" in flat
|
||
assert "gar_match_method = 'canon_addr'" in flat
|
||
# Только дома с известным знаменателем матчим.
|
||
assert "flat_count > 0" in flat
|
||
|
||
|
||
def test_no_psycopg_v3_colon_colon_cast() -> None:
|
||
# Только исполняемый SQL (модульный docstring специально содержит анти-паттерн как памятку).
|
||
assert not re.search(r":\w+::", _INSERT_SQL)
|
||
assert not re.search(r":\w+::", _MATCH_SQL)
|
||
|
||
|
||
def test_upsert_uses_savepoint_per_batch() -> None:
|
||
src = inspect.getsource(gfl.upsert_gar_houses)
|
||
assert "with db.begin_nested():" in src
|
||
# upsert НЕ коммитит сам (коммитит caller).
|
||
assert "db.commit()" not in src
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Канонический ключ адреса tradein_canon_addr (мигр. 144) — DB-free мирроринг
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Чистый Python-зеркало SQL-функции tradein_canon_addr (мигр. 144) для юнит-теста БЕЗ БД.
|
||
# Канонический источник — SQL-функция в data/sql/144; зеркало точно повторяет её regexp-шаги
|
||
# и проверено на тех же кейсах (порядок альтернатив «длинная|короткая» совпадает с POSIX
|
||
# longest-match Postgres; границы [^а-я] идентичны после lower()+ё→е). Для ground-truth на
|
||
# живой БД те же утверждения дублируются в real-Postgres-тесте ниже.
|
||
_CANON_TYPES = (
|
||
"улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|"
|
||
"набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр"
|
||
)
|
||
_CANON_RN_RE = re.compile(r"р-н[^,]*,")
|
||
_CANON_TYPE_RE = re.compile(r"(^|[^а-я])(" + _CANON_TYPES + r")([^а-я]|$)")
|
||
_CANON_KEEP_RE = re.compile(r"[^а-я0-9]")
|
||
|
||
|
||
def _canon_addr_py(s: str | None) -> str:
|
||
"""Python-зеркало SQL tradein_canon_addr(text) (мигр. 144)."""
|
||
s = (s or "").lower().replace("ё", "е")
|
||
s = _CANON_RN_RE.sub("", s)
|
||
s = _CANON_TYPE_RE.sub(r"\1\3", s)
|
||
s = _CANON_KEEP_RE.sub("", s)
|
||
return s
|
||
|
||
|
||
def test_canon_addr_street_type_and_punct_collapse() -> None:
|
||
# «ул. Шаумяна,20» и «ул. Шаумяна, 20» канон-равны (тип улицы + запятая/пробел схлопнуты).
|
||
assert _canon_addr_py("ул. Шаумяна,20") == _canon_addr_py("ул. Шаумяна, 20")
|
||
assert _canon_addr_py("ул. Шаумяна,20") == "шаумяна20"
|
||
|
||
|
||
def test_canon_addr_strips_rayon_and_korpus_slash() -> None:
|
||
# «р-н Ленинский, улица Цвиллинга, 7/6» → «цвиллинга76» (район срезан, слэш выброшен).
|
||
assert _canon_addr_py("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга76"
|
||
|
||
|
||
def test_canon_addr_keeps_letter_suffix_distinct() -> None:
|
||
# Литера корпуса «6Б» — ДРУГОЕ здание: не должно схлопываться до «6».
|
||
a = _canon_addr_py("улица Машиностроителей, 6Б")
|
||
b = _canon_addr_py("ул. Машиностроителей, 6")
|
||
assert a != b
|
||
assert a == "машиностроителей6б"
|
||
assert b == "машиностроителей6"
|
||
|
||
|
||
def test_canon_addr_handles_none_and_empty() -> None:
|
||
assert _canon_addr_py(None) == ""
|
||
assert _canon_addr_py("") == ""
|
||
# Строка из одного типа улицы без названия схлопывается в пустую.
|
||
assert _canon_addr_py("улица") == ""
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# CLI: --match-only флаг (app/tasks/gar_flats_load.py)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_match_only_does_not_require_dir() -> None:
|
||
"""--match-only парсится без --dir (парс/загрузка пропускаются)."""
|
||
from app.tasks import gar_flats_load as task
|
||
|
||
args = task.build_parser().parse_args(["--match-only", "--region", "66"])
|
||
assert args.match_only is True
|
||
assert args.dir is None
|
||
assert args.region == "66"
|
||
|
||
|
||
def test_main_match_only_skips_load(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""main(--match-only) вызывает только run_gar_match_only, НЕ run_gar_flats_load."""
|
||
from app.tasks import gar_flats_load as task
|
||
|
||
calls: dict[str, Any] = {}
|
||
|
||
def fake_match_only(region_code: str, *, city_filter: str | None = None) -> dict[str, int]:
|
||
calls["match_only"] = (region_code, city_filter)
|
||
return {"houses_matched": 5}
|
||
|
||
def fake_load(*args: Any, **kwargs: Any) -> dict[str, int]:
|
||
calls["load"] = True
|
||
return {}
|
||
|
||
monkeypatch.setattr(task, "run_gar_match_only", fake_match_only)
|
||
monkeypatch.setattr(task, "run_gar_flats_load", fake_load)
|
||
monkeypatch.setattr(sys, "argv", ["gar_flats_load", "--match-only", "--region", "66"])
|
||
|
||
task.main()
|
||
|
||
assert "load" not in calls # парс/загрузка пропущены
|
||
assert calls["match_only"] == ("66", "Екатеринбург")
|
||
|
||
|
||
def test_main_match_only_empty_city_disables_filter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Пустой --city → city_filter=None (фильтр отключён, матч за пределами ЕКБ)."""
|
||
from app.tasks import gar_flats_load as task
|
||
|
||
captured: dict[str, Any] = {}
|
||
|
||
def fake_match_only(region_code: str, *, city_filter: str | None = None) -> dict[str, int]:
|
||
captured["city_filter"] = city_filter
|
||
return {"houses_matched": 0}
|
||
|
||
monkeypatch.setattr(task, "run_gar_match_only", fake_match_only)
|
||
monkeypatch.setattr(sys, "argv", ["gar_flats_load", "--match-only", "--city", ""])
|
||
|
||
task.main()
|
||
|
||
assert captured["city_filter"] is None
|
||
|
||
|
||
def test_main_requires_dir_without_match_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Без --match-only и без --dir — parser.error → SystemExit."""
|
||
from app.tasks import gar_flats_load as task
|
||
|
||
monkeypatch.setattr(sys, "argv", ["gar_flats_load", "--region", "66"])
|
||
with pytest.raises(SystemExit):
|
||
task.main()
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Опциональный real-Postgres тест upsert+match (self-skip без БД)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def _live_session() -> Any | None:
|
||
try:
|
||
from sqlalchemy import create_engine, text
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||
if not dsn or "localhost:5432/test" in dsn:
|
||
return None
|
||
engine = create_engine(dsn, future=True)
|
||
conn = engine.connect()
|
||
conn.execute(text("SELECT 1"))
|
||
conn.close()
|
||
return sessionmaker(bind=engine, future=True)()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# Тот же нормализатор, что в мигр. 063 — создаём в транзакции теста, откатываем.
|
||
_NORMALIZER_FN = """
|
||
CREATE OR REPLACE FUNCTION tradein_normalize_short_addr(addr text)
|
||
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||
SELECT trim(both ' ,.' FROM
|
||
regexp_replace(
|
||
regexp_replace(
|
||
regexp_replace(
|
||
regexp_replace(
|
||
regexp_replace(
|
||
regexp_replace(addr, '^\\s*(?:Россия|РФ|Российская\\s+Федерация)\\s*,\\s*', '', 'i'),
|
||
'^[А-ЯЁа-яё][А-ЯЁа-яё\\s-]+\\s+обл(?:асть|\\.)\\s*,\\s*', '', 'i'),
|
||
'^г\\.?\\s*[А-ЯЁ][А-ЯЁа-яё-]+\\s*,\\s*', '', 'i'),
|
||
'\\s*·\\s*.+$', '', 'i'),
|
||
',\\s*(?:кв\\.?|корп\\.?|к\\.?|оф\\.?|пом\\.?|подъезд)\\s*\\d+.*$', '', 'i'),
|
||
'\\s{2,}', ' ', 'g'));
|
||
$$;
|
||
"""
|
||
|
||
# Канон-fn мигр. 144 — создаём в транзакции теста, откатываем. raw-строка: \1\3 литеральны.
|
||
_CANON_FN = r"""
|
||
CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$
|
||
SELECT regexp_replace(
|
||
regexp_replace(
|
||
regexp_replace(
|
||
translate(lower(coalesce(s, '')), 'ё', 'е'),
|
||
'р-н[^,]*,', '', 'g'),
|
||
'(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)',
|
||
'\1\3', 'g'),
|
||
'[^а-я0-9]', '', 'g');
|
||
$func$ LANGUAGE sql IMMUTABLE;
|
||
"""
|
||
|
||
|
||
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
|
||
def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None:
|
||
"""С реальной Postgres: upsert ГАР + КАНОН-match → houses.gar_flat_count (мигр. 144).
|
||
|
||
Проверяет: канон бридит формат-расхождение, которого точный матч не видел
|
||
(«ул. Шаумяна,10» ↔ ГАР «ул Шаумяна, 10»); ЕКБ-фильтр исключает same-canon не-ЕКБ
|
||
строку; идемпотентность повторного прогона; метод 'canon_addr'.
|
||
"""
|
||
from sqlalchemy import text
|
||
|
||
db = _live_session()
|
||
assert db is not None
|
||
try:
|
||
db.execute(text(_NORMALIZER_FN))
|
||
db.execute(text(_CANON_FN))
|
||
db.execute(
|
||
text(
|
||
"CREATE TEMP TABLE gar_house_flats ("
|
||
" house_guid text PRIMARY KEY, object_id bigint, region_code text NOT NULL,"
|
||
" flat_count int, full_address text, norm_address text, street_name text,"
|
||
" house_num text, loaded_at timestamptz NOT NULL DEFAULT now(), gar_version text"
|
||
") ON COMMIT DROP"
|
||
)
|
||
)
|
||
db.execute(
|
||
text(
|
||
"CREATE TEMP TABLE houses ("
|
||
" id serial PRIMARY KEY, short_address text, full_address text, address text,"
|
||
" gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz,"
|
||
" gar_match_method text"
|
||
") ON COMMIT DROP"
|
||
)
|
||
)
|
||
# Format-divergent адреса: «ул. Шаумяна,10» (точка/без пробела) канон-совпадёт с ГАР
|
||
# h-a «ул Шаумяна, 10» (flat=3), хотя точный матч их РАЗЛИЧИЛ бы. «ул Ленина, 7» — h-c
|
||
# (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже).
|
||
db.execute(
|
||
text(
|
||
"INSERT INTO houses (short_address) VALUES "
|
||
"('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')"
|
||
)
|
||
)
|
||
|
||
rows, _ = gfl.parse_gar_region(gar_dir, "66", "2026-06-01")
|
||
upserted = gfl.upsert_gar_houses(db, rows, batch_size=2)
|
||
assert upserted == 3
|
||
|
||
# norm_address посчитан SQL-fn = «ул Шаумяна, 10» (точный матч с houses провалился бы).
|
||
norm = db.execute(
|
||
text("SELECT norm_address FROM gar_house_flats WHERE house_guid='h-a'")
|
||
).scalar()
|
||
assert norm == "ул Шаумяна, 10"
|
||
|
||
# Канон-матч (ЕКБ default): Шаумяна канон-совпал, хотя формат адреса различался.
|
||
matched = gfl.match_houses_to_gar(db, region_code="66")
|
||
assert matched == 1
|
||
# Идемпотентность: повторный матч ничего не трогает.
|
||
assert gfl.match_houses_to_gar(db, region_code="66") == 0
|
||
|
||
shaumyana = db.execute(
|
||
text(
|
||
"SELECT gar_flat_count, gar_match_method FROM houses "
|
||
"WHERE short_address='ул. Шаумяна,10'"
|
||
)
|
||
).fetchone()
|
||
assert shaumyana is not None and shaumyana[0] == 3
|
||
assert shaumyana[1] == "canon_addr"
|
||
# Дом без квартир (flat_count=0) НЕ матчится.
|
||
lenina = db.execute(
|
||
text("SELECT gar_flat_count FROM houses WHERE short_address='ул Ленина, 7'")
|
||
).scalar()
|
||
assert lenina is None
|
||
|
||
# ── ЕКБ-фильтр: не-ЕКБ ГАР-строка с тем же каноном «мира5» (Берёзовский, flat=4) ──
|
||
db.execute(
|
||
text(
|
||
"INSERT INTO gar_house_flats "
|
||
"(house_guid, region_code, flat_count, full_address, norm_address) VALUES "
|
||
"('h-berez', '66', 4, 'обл Свердловская, г Берёзовский, ул Мира, 5', 'ул Мира, 5')"
|
||
)
|
||
)
|
||
# Default ЕКБ-фильтр: full_address без «Екатеринбург» исключён → Мира НЕ матчится.
|
||
assert gfl.match_houses_to_gar(db, region_code="66") == 0
|
||
mira_before = db.execute(
|
||
text("SELECT gar_flat_count FROM houses WHERE short_address='ул. Мира,5'")
|
||
).scalar()
|
||
assert mira_before is None
|
||
# city_filter=None отключает фильтр → не-ЕКБ строка матчится (canon «мира5»).
|
||
assert gfl.match_houses_to_gar(db, region_code="66", city_filter=None) == 1
|
||
mira_after = db.execute(
|
||
text(
|
||
"SELECT gar_flat_count, gar_match_method FROM houses "
|
||
"WHERE short_address='ул. Мира,5'"
|
||
)
|
||
).fetchone()
|
||
assert mira_after is not None and mira_after[0] == 4
|
||
assert mira_after[1] == "canon_addr"
|
||
finally:
|
||
db.rollback()
|
||
db.close()
|