"""Тесты ГАР-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 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 = """ """ _HOUSES_XML = """ """ _APARTMENTS_XML = """ """ # Иерархия: пара (OBJECTID лежит под PARENTOBJID). _MUN_HIER_XML = """ """ @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 = непосредственный родитель (улица), « ». 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) # Tie-break: на norm_address — строка с макс flat_count, затем меньший house_guid. assert "DISTINCT ON (norm_address)" in flat assert "ORDER BY norm_address, flat_count DESC, house_guid" in flat # houses-сторона: тот же нормализатор поверх COALESCE(short/full/address). assert ( "tradein_normalize_short_addr( COALESCE(h.short_address, h.full_address, h.address) )" in flat ) # Идемпотентность повторного прогона. assert "h.gar_flat_count IS DISTINCT FROM gp.flat_count" in flat assert "gar_match_method = 'norm_address'" 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 # ───────────────────────────────────────────────────────────────────────────── # Опциональный 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')); $$; """ @pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД") def test_upsert_and_match_populates_gar_flat_count(gar_dir: str) -> None: """С реальной Postgres: upsert ГАР-строк + match → houses.gar_flat_count проставлен.""" from sqlalchemy import text db = _live_session() assert db is not None try: db.execute(text(_NORMALIZER_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" ) ) # Дом «ул Шаумяна, 10» совпадёт с ГАР h-a (flat_count=3); «ул Ленина, 7» — h-c (0) не матч. db.execute( text( "INSERT INTO houses (short_address) VALUES " "('ул Шаумяна, 10'), ('ул Ленина, 7')" ) ) 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». norm = db.execute( text("SELECT norm_address FROM gar_house_flats WHERE house_guid='h-a'") ).scalar() assert norm == "ул Шаумяна, 10" 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] == "norm_address" # Дом без квартир (flat_count=0) НЕ матчится. lenina = db.execute( text("SELECT gar_flat_count FROM houses WHERE short_address='ул Ленина, 7'") ).scalar() assert lenina is None finally: db.rollback() db.close()