All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Открытые данные АИС ППК «ФРТ» (бывш. Реформа ЖКХ), node 110 = реестр МКД региона 66: 41 790 строк, houseguid (ФИАС GUID) заполнен на 100% → join к houses.gar_house_guid без канонизации адреса. Анонимный GET, без ЕСИА. Заполняет ТОЛЬКО NULL-поля houses: year_built, material_walls, material_floors, total_floors, entrances, is_emergency, flat_count, heat_supply_type, gas_supply_type, hot_water + новые area_land, foundation_type, elevators_total. Замер по ЕКБ: wall_material 92.5% (было 75% от ДОМ.РФ), built_year 92.0% (было 86%), area_land 86.8% и is_emergency — признаков, которых не давал ни один из текущих источников. Осознанно НЕ мапится: - project_type → series_name: свободный текст и фактически дубль материала стен (пусто у 10 635 строк, «кирпичный» 1754, «нет данных» 1496); - energy_efficiency: решение миграции 284 + реальный класс лишь у ~10% домов (у 25 595 из 41 790 значение «Не присвоен»); - elevators_count → passenger_elevators: в источнике это ОБЩЕЕ число лифтов, в houses раздельно пассажирские и грузовые → отдельная колонка; - playground/sportsground: в источнике id справочника (498/499/500), не флаг. Дубликаты houseguid реальны и взаимодополняющи: 912 guid'ов, 968 лишних строк, у одной строки пары заполнен area_total, у парной нет. Строки СЛИВАЮТСЯ по полям (первое непустое побеждает), иначе бэкфилл терял бы данные ~2% домов. estimator.py не тронут: аналоги подбираются FROM listings без JOIN к houses, встраивание признаков в подбор когорты — отдельная задача. Миграции 290 (staging frt_mkd + колонки houses) и 291 (сид расписания, enabled=false, interval_days 30). robots.txt источника требует Crawl-delay 10.
387 lines
21 KiB
Python
387 lines
21 KiB
Python
"""Тесты АИС ППК ФРТ loader'а (app/services/frt_mkd_loader.py, мигр. 290/291, #frt-mkd).
|
||
|
||
Coverage:
|
||
- Чистые парс-хелперы: clean_text (мусорные значения-заглушки), parse_decimal_comma,
|
||
parse_int_field (+ sanity-гейт границ), parse_bool_da_net.
|
||
- parse_frt_mkd_csv — фикстуры собраны из РЕАЛЬНЫХ строк export-reestrmkd-66-20260901.csv
|
||
(id 6556123 «д. 41» — плотно заполненная строка; id 6477794 «д. 99» — почти вся строка
|
||
из значений-заглушек «Не заполнено»/пусто; id 9090614 — is_alarm='Да', project_type
|
||
в нижнем регистре 'нет').
|
||
- fetch_frt_mkd_csv — скачивание+распаковка zip, HTTP замокан через httpx.MockTransport.
|
||
- Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST),
|
||
_BACKFILL_HOUSES_SQL/_COUNT_SQL (COALESCE-only-NULL-гейт, houseguid COALESCE-матч,
|
||
без `:x::type`).
|
||
- backfill_houses — dry_run вызывает COUNT-SQL и не пишет, real-run читает rowcount
|
||
(MagicMock db).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import os
|
||
import re
|
||
import zipfile
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from app.services import frt_mkd_loader as fml
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Фикстуры: РЕАЛЬНЫЕ строки export-reestrmkd-66-20260901.csv (region 66)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
_HEADER = (
|
||
"id;region_id;area_id;city_id;street_id;shortname_region;formalname_region;"
|
||
"shortname_area;formalname_area;shortname_city;formalname_city;shortname_street;"
|
||
"formalname_street;house_number;building;block;letter;address;houseguid;"
|
||
"management_organization_id;built_year;exploitation_start_year;project_type;house_type;"
|
||
"is_alarm;method_of_forming_overhaul_fund;floor_count_max;floor_count_min;"
|
||
"entrance_count;elevators_count;energy_efficiency;quarters_count;"
|
||
"living_quarters_count;unliving_quarters_count;area_total;area_residential;"
|
||
"area_non_residential;area_common_property;area_land;parking_square;playground;"
|
||
"sportsground;other_beautification;foundation_type;floor_type;wall_material;"
|
||
"basement_area;chute_type;chute_count;electrical_type;electrical_entries_count;"
|
||
"heating_type;hot_water_type;cold_water_type;sewerage_type;"
|
||
"sewerage_cesspools_volume;gas_type;ventilation_type;firefighting_type;drainage_type"
|
||
)
|
||
# д. 41 — плотно заполненная строка, is_alarm='Нет', elevators_count=0.
|
||
_ROW_DENSE = (
|
||
'6556123;;;;;;;;;;;;;41;;;;"д. 41";d6bad775-f0c8-b0d6-87ab-07c43513529e;8915908;1917;'
|
||
'1917;Кирпичный;"Многоквартирный дом";Нет;"На счете регионального оператора";8;6;1;0;'
|
||
'"Не присвоен";13;13;0;930,60;930,60;0,00;80,40;1100,00;0,00;500;500;;Ленточный;'
|
||
"Деревянные;Кирпич;0,00;Отсутствует;0;Центральное;2;Центральное;"
|
||
'"Открытая с отбором сетевой воды на горячее водоснабжение из тепловой сети";'
|
||
'Центральное;Центральное;0,00;Отсутствует;"Приточная вентиляция";Отсутствует;'
|
||
'"Наружные водостоки"'
|
||
)
|
||
# д. 99 — почти вся строка из значений-заглушек «Не заполнено» / пусто.
|
||
_ROW_NOISE = (
|
||
'6477794;;;;;;;;;;;;;99;;;;"д. 99";c42aa407-54e8-0917-ca15-439b5b784903;;;1954;;'
|
||
'"Многоквартирный дом";Нет;"Не заполнено";;;;;"Не заполнено";0;;;10156,60;7039,70;;;;;'
|
||
'498;498;;"Не заполнено";"Не заполнено";"Не заполнено";;"Не заполнено";;'
|
||
'"Не заполнено";;"Не заполнено";"Не заполнено";"Не заполнено";"Не заполнено";;'
|
||
'"Не заполнено";"Не заполнено";"Не заполнено";"Не заполнено"'
|
||
)
|
||
# is_alarm='Да' (аварийный), project_type='нет' (нижний регистр — проверка noise-гейта).
|
||
_ROW_ALARM = (
|
||
"9090614;92b30014-4d52-4e2e-892d-928142b924bf;;a4ab722e-453a-4aed-bb73-728a05e2e27f;"
|
||
"36a4e8b6-5c87-4f67-9ea1-12ac63f32939;обл.;Свердловская;;;г.;Алапаевск;км;123;3;;;;"
|
||
'"обл. Свердловская, г. Алапаевск, км. 123, д. 3";'
|
||
"37e2d973-9a07-4faa-86f5-95e417f86059;8917294;1911;1911;нет;"
|
||
'"Многоквартирный дом";Да;"Не определен";1;1;0;0;"Не присвоен";5;5;0;110,50;110,50;'
|
||
"0,00;0,00;2400,00;0,00;500;500;нет;Ленточный;Деревянные;Деревянные;0,00;Отсутствует;"
|
||
"0;Центральное;1;Печное;Отсутствует;Отсутствует;Отсутствует;0,00;Отсутствует;"
|
||
"Отсутствует;Отсутствует;Отсутствует"
|
||
)
|
||
FRT_CSV = "\n".join([_HEADER, _ROW_DENSE, _ROW_NOISE, _ROW_ALARM]) + "\n"
|
||
|
||
|
||
@pytest.fixture
|
||
def frt_csv_path(tmp_path: Path) -> Path:
|
||
p = tmp_path / "frt_mkd.csv"
|
||
p.write_text(FRT_CSV, encoding="utf-8-sig")
|
||
return p
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# clean_text / parse_decimal_comma / parse_int_field / parse_bool_da_net
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_clean_text_filters_noise_values() -> None:
|
||
assert fml.clean_text("Кирпич") == "Кирпич"
|
||
assert fml.clean_text("") is None
|
||
assert fml.clean_text(None) is None
|
||
assert fml.clean_text("нет данных") is None
|
||
assert fml.clean_text("Не заполнено") is None
|
||
assert fml.clean_text("отсутствует") is None
|
||
assert fml.clean_text("данные отсутствуют") is None
|
||
assert fml.clean_text("нет") is None
|
||
assert fml.clean_text(" Кирпич ") == "Кирпич"
|
||
|
||
|
||
def test_parse_decimal_comma() -> None:
|
||
assert fml.parse_decimal_comma("930,60") == pytest.approx(930.60)
|
||
assert fml.parse_decimal_comma("100.5") == pytest.approx(100.5)
|
||
assert fml.parse_decimal_comma("") is None
|
||
assert fml.parse_decimal_comma(None) is None
|
||
assert fml.parse_decimal_comma("Не заполнено") is None
|
||
|
||
|
||
def test_parse_int_field() -> None:
|
||
assert fml.parse_int_field("1917") == 1917
|
||
assert fml.parse_int_field("8") == 8
|
||
assert fml.parse_int_field("") is None
|
||
assert fml.parse_int_field(None) is None
|
||
assert fml.parse_int_field("Не заполнено") is None
|
||
assert fml.parse_int_field("5,0") == 5
|
||
|
||
|
||
def test_parse_int_field_sanity_gate() -> None:
|
||
ymax = fml.plausible_year_max()
|
||
assert fml.parse_int_field("1200", min_value=1850, max_value=ymax) is None
|
||
assert fml.parse_int_field(str(ymax + 10), min_value=1850, max_value=ymax) is None
|
||
assert fml.parse_int_field("1917", min_value=1850, max_value=ymax) == 1917
|
||
|
||
|
||
def test_parse_bool_da_net() -> None:
|
||
assert fml.parse_bool_da_net("Да") is True
|
||
assert fml.parse_bool_da_net("да") is True
|
||
assert fml.parse_bool_da_net("Нет") is False
|
||
assert fml.parse_bool_da_net("") is None
|
||
assert fml.parse_bool_da_net(None) is None
|
||
assert fml.parse_bool_da_net("Не заполнено") is None
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# parse_frt_mkd_csv
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_parse_frt_mkd_csv_dense_row(frt_csv_path: Path) -> None:
|
||
rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66)
|
||
assert set(rows.keys()) == {
|
||
"d6bad775-f0c8-b0d6-87ab-07c43513529e",
|
||
"c42aa407-54e8-0917-ca15-439b5b784903",
|
||
"37e2d973-9a07-4faa-86f5-95e417f86059",
|
||
}
|
||
|
||
dense = rows["d6bad775-f0c8-b0d6-87ab-07c43513529e"]
|
||
assert dense.region_code == 66
|
||
assert dense.built_year == 1917
|
||
assert dense.is_alarm is False
|
||
assert dense.floor_count_max == 8
|
||
assert dense.entrance_count == 1
|
||
assert dense.elevators_count == 0
|
||
assert dense.wall_material == "Кирпич"
|
||
assert dense.floor_type == "Деревянные"
|
||
assert dense.area_land == pytest.approx(1100.00)
|
||
assert dense.area_total == pytest.approx(930.60)
|
||
assert dense.living_quarters_count == 13
|
||
assert dense.heating_type == "Центральное"
|
||
assert dense.playground == 500 # id справочника, не булев
|
||
assert dense.management_organization_id == 8915908
|
||
|
||
|
||
def test_parse_frt_mkd_csv_noise_row_collapses_to_none(frt_csv_path: Path) -> None:
|
||
rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66)
|
||
noise = rows["c42aa407-54e8-0917-ca15-439b5b784903"]
|
||
# Почти вся строка — "Не заполнено"/пусто → None, не должно попасть в staging как текст.
|
||
assert noise.wall_material is None
|
||
assert noise.foundation_type is None
|
||
assert noise.floor_type is None
|
||
assert noise.built_year is None # built_year пуст в этой строке
|
||
assert noise.exploitation_start_year == 1954
|
||
assert noise.area_total == pytest.approx(10156.60)
|
||
assert noise.is_alarm is False
|
||
|
||
|
||
def test_parse_frt_mkd_csv_alarm_row_and_lowercase_noise(frt_csv_path: Path) -> None:
|
||
rows = fml.parse_frt_mkd_csv(frt_csv_path, region_code=66)
|
||
alarm = rows["37e2d973-9a07-4faa-86f5-95e417f86059"]
|
||
assert alarm.is_alarm is True
|
||
# project_type='нет' (нижний регистр) — та же заглушка, что 'Нет'/'НЕТ' → None.
|
||
assert alarm.project_type is None
|
||
assert alarm.built_year == 1911
|
||
|
||
|
||
def test_parse_frt_mkd_csv_skips_blank_houseguid(tmp_path: Path) -> None:
|
||
csv_text = _HEADER + "\n" + (";" * _HEADER.count(";")) + "\n"
|
||
p = tmp_path / "blank.csv"
|
||
p.write_text(csv_text, encoding="utf-8-sig")
|
||
assert fml.parse_frt_mkd_csv(p, region_code=66) == {}
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# fetch_frt_mkd_csv — скачивание+распаковка zip, HTTP замокан (MockTransport)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def _zip_bytes(inner_name: str, content: str) -> bytes:
|
||
buf = io.BytesIO()
|
||
with zipfile.ZipFile(buf, "w") as zf:
|
||
zf.writestr(inner_name, content.encode("utf-8-sig"))
|
||
return buf.getvalue()
|
||
|
||
|
||
def test_fetch_frt_mkd_csv_downloads_and_extracts(tmp_path: Path) -> None:
|
||
zip_bytes = _zip_bytes("export-reestrmkd-66-20260901.csv", FRT_CSV)
|
||
expected_url = f"{fml.BASE_URL}/opendata/export/110"
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
if str(request.url) == expected_url:
|
||
return httpx.Response(200, content=zip_bytes)
|
||
raise AssertionError(f"unexpected URL {request.url}")
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
with httpx.Client(transport=transport) as client:
|
||
out_path = fml.fetch_frt_mkd_csv(tmp_path, node_id=110, client=client)
|
||
|
||
assert out_path.exists() and out_path.name.endswith(".csv")
|
||
rows = fml.parse_frt_mkd_csv(out_path, region_code=66)
|
||
assert len(rows) == 3
|
||
|
||
|
||
def test_extract_csv_from_zip_raises_on_no_csv_entry(tmp_path: Path) -> None:
|
||
buf = io.BytesIO()
|
||
with zipfile.ZipFile(buf, "w") as zf:
|
||
zf.writestr("readme.txt", "not a csv")
|
||
with pytest.raises(ValueError, match="csv"):
|
||
fml._extract_csv_from_zip(buf.getvalue(), tmp_path)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# load_frt_mkd — dry_run не пишет (упаковано в тонкий MagicMock-flow)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_load_frt_mkd_dry_run_parses_but_does_not_upsert(frt_csv_path: Path) -> None:
|
||
db = MagicMock()
|
||
result = fml.load_frt_mkd(db, src_path=frt_csv_path, region_code=66, dry_run=True)
|
||
assert result == {"rows": 3, "upserted": 0}
|
||
db.execute.assert_not_called()
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
_UPSERT_SQL = str(fml._UPSERT_SQL.text)
|
||
|
||
|
||
def test_upsert_sql_uses_psycopg_v3_cast_not_double_colon() -> None:
|
||
assert not re.search(r":\w+::", _UPSERT_SQL)
|
||
assert "CAST(:houseguid AS text)" in _UPSERT_SQL
|
||
assert "CAST(:built_year AS smallint)" in _UPSERT_SQL
|
||
assert "CAST(:is_alarm AS boolean)" in _UPSERT_SQL
|
||
assert "CAST(:area_land AS numeric)" in _UPSERT_SQL
|
||
|
||
|
||
def test_upsert_sql_idempotent_on_conflict() -> None:
|
||
flat = re.sub(r"\s+", " ", _UPSERT_SQL)
|
||
assert "INSERT INTO frt_mkd" in flat
|
||
assert "ON CONFLICT (houseguid) DO UPDATE SET" in flat
|
||
assert "frt_mkd.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat
|
||
assert "frt_mkd.area_land IS DISTINCT FROM EXCLUDED.area_land" in flat
|
||
|
||
|
||
def test_upsert_uses_savepoint_per_chunk_and_does_not_commit() -> None:
|
||
import inspect
|
||
|
||
src = inspect.getsource(fml.upsert_frt_mkd)
|
||
assert "with db.begin_nested():" in src
|
||
assert "db.commit()" not in src
|
||
|
||
|
||
def test_load_frt_mkd_does_not_commit() -> None:
|
||
import inspect
|
||
|
||
src = inspect.getsource(fml.load_frt_mkd)
|
||
assert "db.commit()" not in src
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Статические asserts: _BACKFILL_HOUSES_SQL / _COUNT_SQL
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
_BACKFILL_SQL = re.sub(r"\s+", " ", str(fml._BACKFILL_HOUSES_SQL.text))
|
||
_BACKFILL_COUNT_SQL = re.sub(r"\s+", " ", str(fml._BACKFILL_HOUSES_COUNT_SQL.text))
|
||
|
||
|
||
def test_backfill_sql_matches_by_houseguid_coalesce_priority() -> None:
|
||
assert (
|
||
"s.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)"
|
||
in _BACKFILL_SQL
|
||
)
|
||
|
||
|
||
def test_backfill_sql_never_overwrites_existing_non_null() -> None:
|
||
# Каждый таргет своим гейтом h.<col> IS NULL — существующее значение не трогается.
|
||
assert "WHEN h.year_built IS NULL" in _BACKFILL_SQL
|
||
assert "COALESCE(h.material_walls, s.wall_material)" in _BACKFILL_SQL
|
||
assert "COALESCE(h.material_floors, s.floor_type)" in _BACKFILL_SQL
|
||
assert "WHEN h.total_floors IS NULL" in _BACKFILL_SQL
|
||
assert "WHEN h.entrances IS NULL" in _BACKFILL_SQL
|
||
assert "WHEN h.elevators_total IS NULL" in _BACKFILL_SQL
|
||
assert "COALESCE(h.is_emergency, s.is_alarm)" in _BACKFILL_SQL
|
||
assert "WHEN h.flat_count IS NULL" in _BACKFILL_SQL
|
||
assert "COALESCE(h.heat_supply_type, s.heating_type)" in _BACKFILL_SQL
|
||
assert "COALESCE(h.gas_supply_type, s.gas_type)" in _BACKFILL_SQL
|
||
assert "COALESCE(h.hot_water, s.hot_water_type)" in _BACKFILL_SQL
|
||
assert "WHEN h.area_land IS NULL" in _BACKFILL_SQL
|
||
assert "COALESCE(h.foundation_type, s.foundation_type)" in _BACKFILL_SQL
|
||
|
||
|
||
def test_backfill_sql_frt_matched_at_set_once() -> None:
|
||
assert "frt_matched_at = COALESCE(h.frt_matched_at, now())" in _BACKFILL_SQL
|
||
assert "h.frt_matched_at IS NULL" in _BACKFILL_SQL
|
||
|
||
|
||
def test_backfill_sql_does_not_touch_elevators_or_series_name() -> None:
|
||
# НЕ мапим elevators_count в passenger_elevators, НЕ мапим project_type в series_name.
|
||
assert "passenger_elevators" not in _BACKFILL_SQL
|
||
assert "series_name" not in _BACKFILL_SQL
|
||
assert "energy_efficiency" not in _BACKFILL_SQL
|
||
|
||
|
||
def test_backfill_sql_no_psycopg_v3_colon_colon_cast() -> None:
|
||
assert not re.search(r":\w+::", _BACKFILL_SQL)
|
||
assert not re.search(r":\w+::", _BACKFILL_COUNT_SQL)
|
||
|
||
|
||
def test_backfill_houses_does_not_commit() -> None:
|
||
import inspect
|
||
|
||
src = inspect.getsource(fml.backfill_houses)
|
||
assert "db.commit()" not in src
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# backfill_houses — dry_run vs real-run (MagicMock db)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
def test_backfill_houses_real_run_reads_rowcount() -> None:
|
||
db = MagicMock()
|
||
db.execute.return_value.rowcount = 17
|
||
|
||
out = fml.backfill_houses(db, dry_run=False)
|
||
|
||
assert out == {"houses_updated": 17}
|
||
sql_text = str(db.execute.call_args[0][0])
|
||
assert "UPDATE houses" in sql_text
|
||
bind_params = db.execute.call_args[0][1]
|
||
assert bind_params["ymin"] == fml.YEAR_BUILT_MIN
|
||
assert bind_params["ymax"] == fml.plausible_year_max()
|
||
assert bind_params["area_land_max"] == fml.AREA_LAND_MAX_BOUND
|
||
|
||
|
||
def test_backfill_houses_dry_run_only_counts_no_update() -> None:
|
||
db = MagicMock()
|
||
db.execute.return_value.scalar_one.return_value = 9
|
||
|
||
out = fml.backfill_houses(db, dry_run=True)
|
||
|
||
assert out == {"would_update": 9, "houses_updated": 0}
|
||
sql_text = str(db.execute.call_args[0][0]).upper()
|
||
assert "UPDATE" not in sql_text
|
||
assert "SELECT COUNT(*)" in sql_text
|
||
|
||
|
||
def test_duplicate_houseguid_rows_are_merged_field_wise(tmp_path: Path) -> None:
|
||
"""Дубли houseguid в источнике взаимодополняющи — слить, а не затереть.
|
||
|
||
Реальная выгрузка региона 66 (2026-09-01): 41 790 строк, 912 повторяющихся
|
||
houseguid = 968 лишних строк, причём у одной строки пары заполнен area_total,
|
||
а у парной — нет. «Последняя побеждает» теряла бы эти значения.
|
||
"""
|
||
header = "houseguid;address;built_year;area_total;wall_material"
|
||
csv_text = "\n".join(
|
||
[
|
||
header,
|
||
"6809f339-cf37-4e2c-b689-7ded226a9e51;обл. Свердловская, г. Алапаевск;1960;1698,20;",
|
||
"6809f339-cf37-4e2c-b689-7ded226a9e51;обл. Свердловская, г. Алапаевск;;;Кирпич",
|
||
]
|
||
)
|
||
path = tmp_path / "dup.csv"
|
||
path.write_text(csv_text, encoding="utf-8-sig")
|
||
|
||
rows = fml.parse_frt_mkd_csv(path, region_code=66)
|
||
|
||
assert len(rows) == 1
|
||
row = rows["6809f339-cf37-4e2c-b689-7ded226a9e51"]
|
||
assert row.built_year == 1960
|
||
assert row.area_total is not None
|
||
assert row.wall_material == "Кирпич"
|