'
diff --git a/backend/tests/scrapers/test_emiss_sdmx.py b/backend/tests/scrapers/test_emiss_sdmx.py
index 8b356e1d..6cba33ba 100644
--- a/backend/tests/scrapers/test_emiss_sdmx.py
+++ b/backend/tests/scrapers/test_emiss_sdmx.py
@@ -129,9 +129,9 @@ def test_income_extracts_sverdlovsk_only() -> None:
assert all(r.indicator_type == "income_per_capita" for r in rows)
assert all(r.unit == "руб" for r in rows)
assert all(r.frequency == "quarterly" for r in rows)
- assert all(
- r.period_type == "quarter" for r in rows
- ), "квартальные строки должны иметь period_type='quarter'"
+ assert all(r.period_type == "quarter" for r in rows), (
+ "квартальные строки должны иметь period_type='quarter'"
+ )
def test_income_concrete_values_and_dates() -> None:
diff --git a/backend/tests/scrapers/test_nspd_bulk_client.py b/backend/tests/scrapers/test_nspd_bulk_client.py
index bc57857a..ef93c852 100644
--- a/backend/tests/scrapers/test_nspd_bulk_client.py
+++ b/backend/tests/scrapers/test_nspd_bulk_client.py
@@ -602,6 +602,6 @@ async def test_list_objects_in_building_real() -> None:
assert listing.objdoc_id == 42065602
assert listing.flats_count > 150, f"Ожидали >150 помещений, получили {listing.flats_count}"
- assert (
- len(listing.flats_cad_nums) > 150
- ), f"Ожидали >150 cad_nums помещений, получили {len(listing.flats_cad_nums)}"
+ assert len(listing.flats_cad_nums) > 150, (
+ f"Ожидали >150 cad_nums помещений, получили {len(listing.flats_cad_nums)}"
+ )
diff --git a/backend/tests/scrapers/test_nspd_grid_walk.py b/backend/tests/scrapers/test_nspd_grid_walk.py
index 26d8e625..a34bceac 100644
--- a/backend/tests/scrapers/test_nspd_grid_walk.py
+++ b/backend/tests/scrapers/test_nspd_grid_walk.py
@@ -250,9 +250,9 @@ class TestGetFeaturesInBboxGrid:
return [good_feat]
result = self._grid(_wms)
- assert any(
- f.feature_id == "feat-ok" for f in result
- ), "успешные ячейки должны попасть в результат, даже если часть слоя упала"
+ assert any(f.feature_id == "feat-ok" for f in result), (
+ "успешные ячейки должны попасть в результат, даже если часть слоя упала"
+ )
def test_returns_nspd_feature_instances(self) -> None:
"""Метод возвращает list[NSPDFeature] а не NSPDBulkFeature."""
@@ -346,9 +346,9 @@ class TestClassifyEngineeringKind:
],
)
def test_classify(self, props: dict[str, Any], expected: str) -> None:
- assert (
- classify_engineering_kind(props) == expected
- ), f"props={props!r} → expected {expected!r}"
+ assert classify_engineering_kind(props) == expected, (
+ f"props={props!r} → expected {expected!r}"
+ )
def test_field_priority_params_name_over_purpose(self) -> None:
"""params_name проверяется раньше purpose."""
@@ -422,13 +422,13 @@ class TestFetchLayerDispatch:
called_layer_ids = [call.args[0] for call in mock_grid.call_args_list]
from app.services.scrapers.nspd_client import LAYERS
- assert (
- LAYERS["territorial_zones"] in called_layer_ids
- ), "territorial_zones должен использовать grid-walk"
+ assert LAYERS["territorial_zones"] in called_layer_ids, (
+ "territorial_zones должен использовать grid-walk"
+ )
assert LAYERS["red_lines"] in called_layer_ids, "red_lines должен использовать grid-walk"
- assert (
- LAYERS["engineering_structures"] in called_layer_ids
- ), "engineering_structures должен использовать grid-walk"
+ assert LAYERS["engineering_structures"] in called_layer_ids, (
+ "engineering_structures должен использовать grid-walk"
+ )
# parcels и buildings — legacy, не grid
called_legacy_ids = [call.args[0] for call in mock_legacy.call_args_list]
assert LAYERS["parcels"] in called_legacy_ids, "parcels должен идти через legacy"
diff --git a/backend/tests/scrapers/test_rosstat_emiss.py b/backend/tests/scrapers/test_rosstat_emiss.py
index bb3c2c30..525a2c13 100644
--- a/backend/tests/scrapers/test_rosstat_emiss.py
+++ b/backend/tests/scrapers/test_rosstat_emiss.py
@@ -42,7 +42,7 @@ CONSTRUCTION_XLSX = (_FIXTURES / "rosstat_construction_stroitel.xlsx").read_byte
FIXTURE_META = (
"property,value\n"
"standardversion,https://rosstat.gov.ru/storage/.../versiya_4.0.pdf\n"
- 'identifier,7708234640-population\n'
+ "identifier,7708234640-population\n"
'title,"Численность постоянного населения"\n'
"created,20180905\n"
"modified,20140919\n"
@@ -58,7 +58,7 @@ FIXTURE_META = (
# значение в 'total', год в 'year'. Запятая — разделитель полей; значения — целые.
FIXTURE_POPULATION_DATA = (
"number,year,kode,region,municipalities,total,urban,rural\n"
- '1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,'
+ "1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,"
"446406,209929,236477\n"
"2,2014,6500000000,Свердловская область,Муниципальные образования Свердловской области,"
"4320677,3500000,820677\n"
diff --git a/backend/tests/services/analysis_runs/test_repository_run_history.py b/backend/tests/services/analysis_runs/test_repository_run_history.py
index f5201e01..fe54cb16 100644
--- a/backend/tests/services/analysis_runs/test_repository_run_history.py
+++ b/backend/tests/services/analysis_runs/test_repository_run_history.py
@@ -60,8 +60,16 @@ def test_list_runs_for_light_projection_base_table_order_limit() -> None:
# LIGHT: result-блоб НЕ выбирается
assert "RESULT" not in upper, "list_runs_for НЕ должен тянуть тяжёлый result"
# выбраны именно метаданные
- for col in ("ID", "CAD_NUM", "CREATED_AT", "STATUS", "SCHEMA_VERSION",
- "DISTRICT", "CONFIDENCE", "CREATED_BY"):
+ for col in (
+ "ID",
+ "CAD_NUM",
+ "CREATED_AT",
+ "STATUS",
+ "SCHEMA_VERSION",
+ "DISTRICT",
+ "CONFIDENCE",
+ "CREATED_BY",
+ ):
assert col in upper, f"ожидали колонку {col} в LIGHT-проекции"
assert "ORDER BY CREATED_AT DESC" in flat
assert "LIMIT CAST(:LIMIT AS INTEGER)" in upper
diff --git a/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py b/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py
index e9d7a146..733ca4df 100644
--- a/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py
+++ b/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py
@@ -102,9 +102,9 @@ def test_ordinary_error_still_skips_only_that_quarter() -> None:
"""
outcome, touched = _run(RuntimeError("битый ответ одного квартала"))
- assert not isinstance(
- outcome, BaseException
- ), f"обычная ошибка обрушила весь прогон: {outcome!r}"
+ assert not isinstance(outcome, BaseException), (
+ f"обычная ошибка обрушила весь прогон: {outcome!r}"
+ )
assert len(touched) == 3, f"тронуто кварталов {len(touched)}, ожидалось 3: {touched}"
diff --git a/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py b/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py
index 7104d93a..711dec1b 100644
--- a/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py
+++ b/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py
@@ -118,7 +118,7 @@ def test_document_carries_the_measured_window_not_the_norm() -> None:
html = _build_html([_variant(schedule_is_default=False, sales_months=54.0)])
- assert (
- "распродажа 54 мес" in html
- ), "в методической сноске стоит не тот срок, по которому посчитан NPV"
+ assert "распродажа 54 мес" in html, (
+ "в методической сноске стоит не тот срок, по которому посчитан NPV"
+ )
assert "распродажа 30 мес," not in html
diff --git a/backend/tests/services/exporters/test_2464_zouit_count_label.py b/backend/tests/services/exporters/test_2464_zouit_count_label.py
index ec17ce91..f2c46189 100644
--- a/backend/tests/services/exporters/test_2464_zouit_count_label.py
+++ b/backend/tests/services/exporters/test_2464_zouit_count_label.py
@@ -101,9 +101,9 @@ def test_record_count_is_still_shown(pairs_fn: Any, fmt: str) -> None:
"""Контроль: само число записей из отчёта не пропало — подпись правится, не значение."""
pairs = pairs_fn()
counters = [(k, v) for k, v in pairs if re.fullmatch(r"\d+", v.strip())]
- assert any(
- int(v) == _RECORDS for _, v in counters
- ), f"{fmt}: число ЗОУИТ-записей ({_RECORDS}) исчезло из сводки: {counters}"
+ assert any(int(v) == _RECORDS for _, v in counters), (
+ f"{fmt}: число ЗОУИТ-записей ({_RECORDS}) исчезло из сводки: {counters}"
+ )
@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")])
@@ -111,6 +111,6 @@ def test_types_are_still_listed(pairs_fn: Any, fmt: str) -> None:
"""Контроль: перечисление типов на месте — читатель по-прежнему видит, какие они."""
pairs = pairs_fn()
joined = " ".join(f"{k} {v}" for k, v in pairs)
- assert (
- "Охранная зона ЛЭП" in joined and "Приаэродромная территория" in joined
- ), f"{fmt}: типы ЗОУИТ пропали из сводки"
+ assert "Охранная зона ЛЭП" in joined and "Приаэродромная территория" in joined, (
+ f"{fmt}: типы ЗОУИТ пропали из сводки"
+ )
diff --git a/backend/tests/services/exporters/test_2934_flood_row_honesty.py b/backend/tests/services/exporters/test_2934_flood_row_honesty.py
index 03fda10c..3ae8b247 100644
--- a/backend/tests/services/exporters/test_2934_flood_row_honesty.py
+++ b/backend/tests/services/exporters/test_2934_flood_row_honesty.py
@@ -48,9 +48,9 @@ def test_label_no_longer_claims_a_flood_verdict() -> None:
"""Метка называет измеренное, а не вывод, которого не делали."""
html = build_full_report_html_part_a(_result(flag=False), cad="00:00:0000000:0000")
- assert (
- _OLD_LABEL not in html
- ), "метка утверждает результат проверки зон затопления, которой не было"
+ assert _OLD_LABEL not in html, (
+ "метка утверждает результат проверки зон затопления, которой не было"
+ )
assert "ближе 200 м" in html, "метка должна называть измеренное — близость водотока"
@@ -75,6 +75,6 @@ def test_docx_twin_uses_the_same_label() -> None:
"""
from app.services.exporters import full_report_docx, full_report_html
- assert (
- full_report_docx.FLOOD_PROXIMITY_LABEL is full_report_html.FLOOD_PROXIMITY_LABEL
- ), "DOCX держит свою копию метки — форматы разъедутся при следующей правке"
+ assert full_report_docx.FLOOD_PROXIMITY_LABEL is full_report_html.FLOOD_PROXIMITY_LABEL, (
+ "DOCX держит свою копию метки — форматы разъедутся при следующей правке"
+ )
diff --git a/backend/tests/services/exporters/test_excel.py b/backend/tests/services/exporters/test_excel.py
index b639775f..549a075d 100644
--- a/backend/tests/services/exporters/test_excel.py
+++ b/backend/tests/services/exporters/test_excel.py
@@ -356,9 +356,9 @@ class TestContractKeysWritten:
payload_12mo = _scenario("base", deficit_12mo=0.34)
cell_value = _scenario_deficit_cell(payload_12mo)
# Для основного горизонта — голое число, не строка с «(гор. N мес)».
- assert not isinstance(
- cell_value, str
- ), f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'"
+ assert not isinstance(cell_value, str), (
+ f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'"
+ )
assert cell_value == 0.34
def test_overall_score_in_cells(self) -> None:
diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py
index b946e28f..572f0566 100644
--- a/backend/tests/services/forecasting/test_confidence_engine.py
+++ b/backend/tests/services/forecasting/test_confidence_engine.py
@@ -157,9 +157,9 @@ class TestCoverageFactor:
f = _coverage_factor(0.4)
assert "ближних ЖК" in f.note, f.note
assert "Objective" in f.note, f.note
- assert (
- "будущ" not in f.note
- ), "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами"
+ assert "будущ" not in f.note, (
+ "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами"
+ )
def test_high_coverage(self) -> None:
f = _coverage_factor(0.75)
diff --git a/backend/tests/services/forecasting/test_normalize.py b/backend/tests/services/forecasting/test_normalize.py
index f85229b3..be5cc9d7 100644
--- a/backend/tests/services/forecasting/test_normalize.py
+++ b/backend/tests/services/forecasting/test_normalize.py
@@ -150,9 +150,9 @@ class TestSeasonalFactors:
"""
months = _months(36)
adj = seasonal_factors(months, [0] * 36)
- assert (
- adj.n_full_years == 0
- ), f"expected 0 full years on all-zero series, got {adj.n_full_years}"
+ assert adj.n_full_years == 0, (
+ f"expected 0 full years on all-zero series, got {adj.n_full_years}"
+ )
assert adj.applied is False
assert all(f == 1.0 for f in adj.factors.values())
@@ -166,9 +166,9 @@ class TestSeasonalFactors:
# Только январь-июнь каждого года ненулевые → нет полного покрытия 12 мес.
units = [10 if d.month <= 6 else 0 for d in months]
adj = seasonal_factors(months, units)
- assert (
- adj.n_full_years == 0
- ), f"partial-coverage years should not count as full, got {adj.n_full_years}"
+ assert adj.n_full_years == 0, (
+ f"partial-coverage years should not count as full, got {adj.n_full_years}"
+ )
assert adj.applied is False
def test_real_nonzero_series_passes_guard_and_applies(self) -> None:
diff --git a/backend/tests/services/forecasting/test_scenarios.py b/backend/tests/services/forecasting/test_scenarios.py
index 94999a10..2588cb99 100644
--- a/backend/tests/services/forecasting/test_scenarios.py
+++ b/backend/tests/services/forecasting/test_scenarios.py
@@ -609,9 +609,7 @@ class TestDetectCollapsed:
def test_empty_forecasts_not_collapsed(self) -> None:
# Нет данных → нет вердикта «схлопнулось» (не помечаем пустой отчёт collapsed).
cons = _scenario("conservative", forecasts=[])
- base = _scenario(
- "base", forecasts=[_forecast_stub(horizon=12)]
- )
+ base = _scenario("base", forecasts=[_forecast_stub(horizon=12)])
aggr = _scenario("aggressive", forecasts=[])
assert _detect_collapsed([cons, base, aggr]) is False
@@ -750,9 +748,7 @@ class TestComputeScenariosCollapseDetection:
# demand aggressive на 1e-3 от cons/base — за пределами abs_tol=1e-6.
offset = 1e-3 if call_count["n"] == 3 else 0.0
return [
- _forecast_stub(
- horizon=h, deficit_index=0.05, projected_demand_units=100.0 + offset
- )
+ _forecast_stub(horizon=h, deficit_index=0.05, projected_demand_units=100.0 + offset)
for h in horizons
]
diff --git a/backend/tests/services/generative/test_market_price_lookup.py b/backend/tests/services/generative/test_market_price_lookup.py
index 5667f38e..abdd5fe3 100644
--- a/backend/tests/services/generative/test_market_price_lookup.py
+++ b/backend/tests/services/generative/test_market_price_lookup.py
@@ -61,9 +61,9 @@ def test_objective_median_sql_dedups_inline_not_via_view() -> None:
sql = str(concepts._OBJECTIVE_MEDIAN_SQL)
assert "DISTINCT ON (" in sql, "должен дедупить физлоты inline"
assert "snapshot_date DESC" in sql, "берём последний снапшот физлота"
- assert (
- "v_objective_lots_latest" not in sql
- ), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
+ assert "v_objective_lots_latest" not in sql, (
+ "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
+ )
def test_objective_median_selected_when_sample_large_enough() -> None:
diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py
index a8757ff4..0909f1f9 100644
--- a/backend/tests/services/generative/test_teap_financial.py
+++ b/backend/tests/services/generative/test_teap_financial.py
@@ -82,9 +82,7 @@ def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP:
def test_financial_revenue_includes_parking() -> None:
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
- model = financial.compute_financial(
- teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
- )
+ model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=50_000_000.0)
# revenue = жильё (1000 * 145_000) + паркинг comfort (10 * 1_300_000).
assert model.revenue_residential_rub == 1000.0 * 145_000.0
assert model.revenue_parking_rub == 10 * 1_300_000.0
@@ -93,9 +91,7 @@ def test_financial_revenue_includes_parking() -> None:
def test_financial_cost_cascade_includes_all_lines() -> None:
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
- model = financial.compute_financial(
- teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
- )
+ model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=50_000_000.0)
# СМР = GFA*СМР + паркинг comfort*себест (10 * 1_000_000).
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0
# Каждая статья каскада > 0 при ненулевых вводных.
@@ -299,15 +295,18 @@ def test_financial_parking_margin_positive_for_all_classes() -> None:
cost = financial._PARKING_COST_PER_SPOT[hc] # type: ignore[index]
assert price > cost, hc
# Конкретные маржи из спецификации: econom +450k, comfort +300k, business +100k.
- assert financial._PARKING_PRICE_PER_SPOT["econom"] - financial._PARKING_COST_PER_SPOT[
- "econom"
- ] == 450_000.0
- assert financial._PARKING_PRICE_PER_SPOT["comfort"] - financial._PARKING_COST_PER_SPOT[
- "comfort"
- ] == 300_000.0
- assert financial._PARKING_PRICE_PER_SPOT["business"] - financial._PARKING_COST_PER_SPOT[
- "business"
- ] == 100_000.0
+ assert (
+ financial._PARKING_PRICE_PER_SPOT["econom"] - financial._PARKING_COST_PER_SPOT["econom"]
+ == 450_000.0
+ )
+ assert (
+ financial._PARKING_PRICE_PER_SPOT["comfort"] - financial._PARKING_COST_PER_SPOT["comfort"]
+ == 300_000.0
+ )
+ assert (
+ financial._PARKING_PRICE_PER_SPOT["business"] - financial._PARKING_COST_PER_SPOT["business"]
+ == 100_000.0
+ )
def test_financial_econom_parking_cheaper_than_business() -> None:
@@ -399,12 +398,18 @@ def test_synthesize_program_zero_site_area_no_division_error() -> None:
def test_synthesize_program_sections_do_not_affect_teap() -> None:
# sections — метаданные программы; площади уже свёрнуты → ТЭП от них не зависит.
one = teap.synthesize_teap_from_program(
- total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0,
- housing_class="comfort", sections=1,
+ total_footprint_sqm=2000.0,
+ floors=10,
+ site_area_sqm=5000.0,
+ housing_class="comfort",
+ sections=1,
)
six = teap.synthesize_teap_from_program(
- total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0,
- housing_class="comfort", sections=6,
+ total_footprint_sqm=2000.0,
+ floors=10,
+ site_area_sqm=5000.0,
+ housing_class="comfort",
+ sections=6,
)
assert one == six
diff --git a/backend/tests/services/llm/test_client.py b/backend/tests/services/llm/test_client.py
index 838165a2..63c56f60 100644
--- a/backend/tests/services/llm/test_client.py
+++ b/backend/tests/services/llm/test_client.py
@@ -127,9 +127,7 @@ def test_call_cap_returns_fallback(_enabled: None, monkeypatch: pytest.MonkeyPat
"""call_index >= llm_max_calls_per_request → fallback, провайдер не вызывается."""
monkeypatch.setattr(settings, "llm_max_calls_per_request", 2)
prov = _FakeOpenAILike()
- res = complete(
- system_prompt="sys", payload=SafePayload(text="hi"), provider=prov, call_index=2
- )
+ res = complete(system_prompt="sys", payload=SafePayload(text="hi"), provider=prov, call_index=2)
assert res.reason == "call_cap"
assert prov.calls == 0
@@ -241,9 +239,7 @@ def test_rate_limited_retries_then_fallback(
assert prov.calls == 3
-def test_rate_limited_retry_after_capped(
- _enabled: None, monkeypatch: pytest.MonkeyPatch
-) -> None:
+def test_rate_limited_retry_after_capped(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""#1209: серверный Retry-After (86400с при quota-exhaustion) должен капаться
_MAX_BACKOFF_S — иначе time.sleep блокирует anyio-threadpool на часы.
"""
diff --git a/backend/tests/services/llm/test_provider.py b/backend/tests/services/llm/test_provider.py
index eccf3434..18ba9f5b 100644
--- a/backend/tests/services/llm/test_provider.py
+++ b/backend/tests/services/llm/test_provider.py
@@ -88,9 +88,7 @@ def test_complete_builds_request_body(monkeypatch: pytest.MonkeyPatch) -> None:
return _chat_response()
monkeypatch.setattr(OpenAIProvider, "_post", fake_post)
- resp = _provider().complete(
- [{"role": "user", "content": "привет"}], max_output_tokens=256
- )
+ resp = _provider().complete([{"role": "user", "content": "привет"}], max_output_tokens=256)
assert captured["body"]["model"] == "gpt-4o-mini"
assert captured["body"]["max_tokens"] == 256
diff --git a/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py b/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py
index ad58d072..151be295 100644
--- a/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py
+++ b/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py
@@ -103,7 +103,7 @@ def test_single_waf_does_not_abort() -> None:
Ловит «починку» через abort-on-first: тогда один переходный блок стоил бы
всей ночной докачки.
"""
- stats, touched = _run(lambda oid: (_waf(oid) if oid == 5 else True))
+ stats, touched = _run(lambda oid: _waf(oid) if oid == 5 else True)
assert len(touched) == len(_OBJ_IDS), f"прогон оборвался на одиночном блоке: {len(touched)}"
assert "aborted_on_waf" not in stats
@@ -116,7 +116,7 @@ def test_streak_resets_after_success() -> None:
Без сброса два разрозненных блока плюс третий где-то в конце оборвали бы прогон.
"""
blocked = {2, 4, 6, 8, 10}
- stats, touched = _run(lambda oid: (_waf(oid) if oid in blocked else True))
+ stats, touched = _run(lambda oid: _waf(oid) if oid in blocked else True)
assert len(touched) == len(_OBJ_IDS), f"прогон оборвался: тронуто {len(touched)}"
assert "aborted_on_waf" not in stats
diff --git a/backend/tests/services/scrapers/test_2464_detect_kind_position.py b/backend/tests/services/scrapers/test_2464_detect_kind_position.py
index 647ea28f..2fb124a1 100644
--- a/backend/tests/services/scrapers/test_2464_detect_kind_position.py
+++ b/backend/tests/services/scrapers/test_2464_detect_kind_position.py
@@ -38,9 +38,9 @@ def test_izyatie_in_title_wins_over_later_rezervirovanie() -> None:
f"{_ЗАГОЛОВОК_ИЗЪЯТИЕ}. Изъятию подлежат участки, ранее зарезервированные "
"постановлением о резервировании земель от 12.03.2019."
)
- assert (
- _detect_kind(text, "изъятие") == "изъятие"
- ), "документ об изъятии классифицирован по случайному упоминанию в теле"
+ assert _detect_kind(text, "изъятие") == "изъятие", (
+ "документ об изъятии классифицирован по случайному упоминанию в теле"
+ )
def test_all_parcels_of_the_document_are_affected() -> None:
diff --git a/backend/tests/services/scrapers/test_2464_status_negation.py b/backend/tests/services/scrapers/test_2464_status_negation.py
index c2a6fbcb..d54591ce 100644
--- a/backend/tests/services/scrapers/test_2464_status_negation.py
+++ b/backend/tests/services/scrapers/test_2464_status_negation.py
@@ -98,14 +98,14 @@ def test_negation_does_not_swallow_a_real_status_in_same_block() -> None:
Разбор по первому совпадению вернул бы None и потерял бы его.
"""
result = parse_catalog_flat(_html_with_badge("Квартира не продана. Статус: в продаже"))
- assert (
- result.get("status") == STATUS_FREE
- ), f"настоящий статус в блоке потерян: {result.get('status')!r}"
+ assert result.get("status") == STATUS_FREE, (
+ f"настоящий статус в блоке потерян: {result.get('status')!r}"
+ )
def test_ne_inside_another_word_is_not_a_negation() -> None:
"""Контроль границы слова: «не» внутри другого слова не считается отрицанием."""
result = parse_catalog_flat(_html_with_badge("Цене снижена, квартира продана"))
- assert (
- result.get("status") == STATUS_SOLD
- ), f"«не» в хвосте слова «Цене» съело настоящий статус: {result.get('status')!r}"
+ assert result.get("status") == STATUS_SOLD, (
+ f"«не» в хвосте слова «Цене» съело настоящий статус: {result.get('status')!r}"
+ )
diff --git a/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py b/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py
index 1394c84b..fc809698 100644
--- a/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py
+++ b/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py
@@ -58,9 +58,9 @@ def test_docstring_names_the_actual_behaviour() -> None:
хорошо». Ловит «починку» через вычёркивание неудобной фразы.
"""
doc = (inspect.getdoc(mod._page_contains_table) or "").lower()
- assert (
- "false positive" in doc or "оглавлен" in doc
- ), "докстрока не предупреждает о ложных срабатываниях на оглавлении"
+ assert "false positive" in doc or "оглавлен" in doc, (
+ "докстрока не предупреждает о ложных срабатываниях на оглавлении"
+ )
def test_toc_line_really_is_a_false_positive() -> None:
@@ -97,7 +97,7 @@ def test_seed_comment_does_not_send_to_a_dead_host() -> None:
src = Path(inspect.getsourcefile(mod)).resolve().parents[3]
текст = (src / "app" / "workers" / "tasks" / "ekb_ppt_tep_sync.py").read_text(encoding="utf-8")
- assert (
- "НЕ СУЩЕСТВУЕТ" in текст or "не резолв" in текст.lower()
- ), "комментарий сида не предупреждает, что gisogd.ekburg.ru мёртв"
+ assert "НЕ СУЩЕСТВУЕТ" in текст or "не резолв" in текст.lower(), (
+ "комментарий сида не предупреждает, что gisogd.ekburg.ru мёртв"
+ )
assert "gisogd66.midural.ru" in текст, "не назван живой портал ГИСОГД-СО"
diff --git a/backend/tests/services/scrapers/test_domrf_catalog_object.py b/backend/tests/services/scrapers/test_domrf_catalog_object.py
index 34012edd..30da2a2d 100644
--- a/backend/tests/services/scrapers/test_domrf_catalog_object.py
+++ b/backend/tests/services/scrapers/test_domrf_catalog_object.py
@@ -42,7 +42,7 @@ def test_extract_next_data_from_html() -> None:
def test_extract_next_data_single_quotes() -> None:
"""Тег с одинарными кавычками тоже должен парситься."""
- html = ""
+ html = ''
result = extract_next_data(html)
assert "props" in result
diff --git a/backend/tests/services/scrapers/test_domrf_catalog_parse.py b/backend/tests/services/scrapers/test_domrf_catalog_parse.py
index a9214814..786aec07 100644
--- a/backend/tests/services/scrapers/test_domrf_catalog_parse.py
+++ b/backend/tests/services/scrapers/test_domrf_catalog_parse.py
@@ -76,13 +76,7 @@ def _html_with_badge(badge_text: str, nav_text: str = "другие кварти
def _html_with_label(label: str, value: str, nav_text: str = "") -> str:
"""HTML с лейблом «Статус» и значением в следующем блоке."""
nav = f"{nav_text}" if nav_text else ""
- return (
- ""
- f"{nav}"
- f"{label}"
- f"{value}"
- ""
- )
+ return f"{nav}{label}{value}"
def _html_blocks_only(blocks: list[tuple[str, str]]) -> str:
diff --git a/backend/tests/services/scrapers/test_domrf_flat_plan_url.py b/backend/tests/services/scrapers/test_domrf_flat_plan_url.py
index ef613b7b..acdb2e0f 100644
--- a/backend/tests/services/scrapers/test_domrf_flat_plan_url.py
+++ b/backend/tests/services/scrapers/test_domrf_flat_plan_url.py
@@ -115,10 +115,7 @@ def test_plan_from_img_proximity_to_label() -> None:
"""
без plan-hint в атрибутах, но рядом с блоком «Планировка»."""
plain = f"{BASE_URL}/api/ext/file/imgabc.png"
html = (
- ""
- "Планировка
"
- f'
'
- ""
+ f'Планировка
'
)
got = extract_plan_image_url(html, _collector_for(html))
assert got == plain
diff --git a/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py b/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py
index c0f7de00..f31b97bf 100644
--- a/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py
+++ b/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py
@@ -60,14 +60,14 @@ class TestUpsertObjectSqlDoUpdateSet:
# clear back to NULL, not get stuck "still problematic" forever).
sql = " ".join(str(UPSERT_OBJECT_SQL).split())
for col in _OBJECT_PREVIOUSLY_OMITTED:
- assert (
- f"{col} = EXCLUDED.{col}" in sql
- ), f"{col} should be direct `= EXCLUDED.{col}` (not COALESCE):\n{sql}"
+ assert f"{col} = EXCLUDED.{col}" in sql, (
+ f"{col} should be direct `= EXCLUDED.{col}` (not COALESCE):\n{sql}"
+ )
# guard against a COALESCE(EXCLUDED.col, ...) formulation, which would
# permanently pin a stale value once ever set instead of refreshing it.
- assert (
- f"COALESCE(EXCLUDED.{col}," not in sql
- ), f"{col} must not be wrapped in COALESCE (needs to be able to clear to NULL)"
+ assert f"COALESCE(EXCLUDED.{col}," not in sql, (
+ f"{col} must not be wrapped in COALESCE (needs to be able to clear to NULL)"
+ )
def test_all_insert_columns_covered_by_do_update_set_or_conflict_target(self) -> None:
# Locks in the class of bug (INSERT populates a column, DO UPDATE SET
diff --git a/backend/tests/services/scrapers/test_okn_egrkn_client.py b/backend/tests/services/scrapers/test_okn_egrkn_client.py
index 6d1740c6..fb7a6d87 100644
--- a/backend/tests/services/scrapers/test_okn_egrkn_client.py
+++ b/backend/tests/services/scrapers/test_okn_egrkn_client.py
@@ -77,9 +77,9 @@ def test_coord_swap_ekb_point_inside_bbox() -> None:
wrong_lat = float(coords[1]) # 60.61 → это вне ЕКБ если использовать как lat
wrong_lon = float(coords[0]) # 56.83 → это вне ЕКБ если использовать как lon
# Финляндия/Норвегия: lat=60.61 формально в диапазоне 55–58.5 FALSE → lat перепутан
- assert not (
- 55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0
- ), f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}"
+ assert not (55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0), (
+ f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}"
+ )
def test_coord_swap_inverted_would_fail_bbox_check() -> None:
diff --git a/backend/tests/services/site_finder/test_2464_capacity_savepoints.py b/backend/tests/services/site_finder/test_2464_capacity_savepoints.py
index c43e1d77..760d448e 100644
--- a/backend/tests/services/site_finder/test_2464_capacity_savepoints.py
+++ b/backend/tests/services/site_finder/test_2464_capacity_savepoints.py
@@ -66,9 +66,9 @@ def test_savepoint_is_actually_entered(name: str) -> None:
"""
db = PostgresLikeSession(fail_on=(), rows=[]) # здоровый путь
_call(name, db)
- assert (
- db.savepoints_entered >= 1
- ), f"{name}: запрос выполнен вне SAVEPOINT — при сбое сессия останется aborted"
+ assert db.savepoints_entered >= 1, (
+ f"{name}: запрос выполнен вне SAVEPOINT — при сбое сессия останется aborted"
+ )
assert db.calls >= 1, f"{name}: запрос вообще не выполнялся"
diff --git a/backend/tests/services/site_finder/test_2464_default_profile_order.py b/backend/tests/services/site_finder/test_2464_default_profile_order.py
index b01e3352..6d23bb7a 100644
--- a/backend/tests/services/site_finder/test_2464_default_profile_order.py
+++ b/backend/tests/services/site_finder/test_2464_default_profile_order.py
@@ -27,6 +27,6 @@ def test_select_default_is_ordered() -> None:
"""
from app.services.site_finder.weight_profiles import _SELECT_DEFAULT
- assert re.search(
- r"ORDER BY\s+id\s+ASC", _SELECT_DEFAULT
- ), f"в _SELECT_DEFAULT нет тай-брейка по id:\n{_SELECT_DEFAULT}"
+ assert re.search(r"ORDER BY\s+id\s+ASC", _SELECT_DEFAULT), (
+ f"в _SELECT_DEFAULT нет тай-брейка по id:\n{_SELECT_DEFAULT}"
+ )
diff --git a/backend/tests/services/site_finder/test_2464_heat_loader_tx.py b/backend/tests/services/site_finder/test_2464_heat_loader_tx.py
index 304e6218..cb4cfad1 100644
--- a/backend/tests/services/site_finder/test_2464_heat_loader_tx.py
+++ b/backend/tests/services/site_finder/test_2464_heat_loader_tx.py
@@ -85,9 +85,9 @@ def test_borrowed_session_is_not_committed_per_organization() -> None:
db, visited, n_orgs = _run(own=False)
assert len(visited) == n_orgs
- assert (
- db.commits == 1
- ), f"на чужой сессии {db.commits} коммитов — транзакцией распоряжается вызывающий"
+ assert db.commits == 1, (
+ f"на чужой сессии {db.commits} коммитов — транзакцией распоряжается вызывающий"
+ )
assert not db.closed, "чужая сессия закрыта — её закрывает вызывающий"
diff --git a/backend/tests/services/site_finder/test_2464_teap_input_sanity.py b/backend/tests/services/site_finder/test_2464_teap_input_sanity.py
index 16a86ff1..e885f4a8 100644
--- a/backend/tests/services/site_finder/test_2464_teap_input_sanity.py
+++ b/backend/tests/services/site_finder/test_2464_teap_input_sanity.py
@@ -54,18 +54,18 @@ def test_far_is_used_when_pct_is_impossible() -> None:
"""Отбрасываем только испорченный параметр, остальной расчёт остаётся верным."""
t = _teap(max_building_pct=150.0, max_far=2.0)
assert t is not None
- assert t.total_floor_area_sqm == pytest.approx(
- _ПЛОЩАДЬ * 2.0
- ), f"GFA посчитана не по КСИТ: {t.total_floor_area_sqm}"
+ assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 2.0), (
+ f"GFA посчитана не по КСИТ: {t.total_floor_area_sqm}"
+ )
def test_impossible_far_is_dropped() -> None:
"""КСИТ 500 — признак порчи разбора, а не сверхплотной застройки."""
t = _teap(max_far=500.0, max_building_pct=40.0, max_floors=10)
assert t is not None
- assert t.total_floor_area_sqm == pytest.approx(
- _ПЛОЩАДЬ * 0.4 * 10
- ), f"использован невозможный КСИТ: GFA={t.total_floor_area_sqm}"
+ assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 0.4 * 10), (
+ f"использован невозможный КСИТ: GFA={t.total_floor_area_sqm}"
+ )
def test_impossible_floors_is_dropped() -> None:
@@ -127,6 +127,6 @@ def test_footprint_never_exceeds_parcel_even_without_pct_and_floors() -> None:
f"пятно {t.built_area_sqm} на участке {_ПЛОЩАДЬ} — нарушена геометрия, "
"а не только правдоподобие регламента"
)
- assert t.total_floor_area_sqm == pytest.approx(
- _ПЛОЩАДЬ * 2.0
- ), "GFA не должна меняться от ограничения пятна"
+ assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 2.0), (
+ "GFA не должна меняться от ограничения пятна"
+ )
diff --git a/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py b/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py
index 31c08f46..18f17a18 100644
--- a/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py
+++ b/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py
@@ -95,9 +95,9 @@ def test_label_is_independent_of_overlap_order() -> None:
"ZOUIT_NETWORK_OBREMENENIE",
)
assert d1 is not None and d2 is not None
- assert (
- d1["detail"] == d2["detail"]
- ), f"подпись зависит от порядка:\n{d1['detail']}\n{d2['detail']}"
+ assert d1["detail"] == d2["detail"], (
+ f"подпись зависит от порядка:\n{d1['detail']}\n{d2['detail']}"
+ )
def test_duplicate_kind_named_once() -> None:
diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py
index 8825c9c5..6ddc3906 100644
--- a/backend/tests/services/site_finder/test_best_layouts.py
+++ b/backend/tests/services/site_finder/test_best_layouts.py
@@ -543,14 +543,14 @@ def test_supply_joins_flats_per_object_latest_snapshot() -> None:
sql_text = str(_SUPPLY_BATCH_SQL.text)
# per-object последний снимок flats
assert "flats_latest" in sql_text, "нет flats_latest CTE → supply снова на глобал-max снимке"
- assert (
- "DISTINCT ON (f.obj_id)" in sql_text
- ), "flats не дедупятся per-object → нет per-obj снимка"
+ assert "DISTINCT ON (f.obj_id)" in sql_text, (
+ "flats не дедупятся per-object → нет per-obj снимка"
+ )
# НЕ должно быть джойна по единой внешней дате-параметру (регрессия #1944)
assert ":latest_snap" not in sql_text, "глобальный :latest_snap вернулся → supply=0 регрессия"
- assert (
- "MAX(snapshot_date)" not in sql_text
- ), "глобальный MAX(snapshot_date) → supply=0 регрессия"
+ assert "MAX(snapshot_date)" not in sql_text, (
+ "глобальный MAX(snapshot_date) → supply=0 регрессия"
+ )
# ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────
@@ -592,16 +592,16 @@ def test_cap_and_redistribute_invariants(
"""
result, cap_skipped = _cap_and_redistribute(pct_map)
- assert (
- cap_skipped == expect_pathological
- ), f"cap_skipped={cap_skipped} но ожидали {expect_pathological} для {pct_map}"
- assert (
- sum(result.values()) == 100
- ), f"sum={sum(result.values())} != 100 для {pct_map} → {result}"
+ assert cap_skipped == expect_pathological, (
+ f"cap_skipped={cap_skipped} но ожидали {expect_pathological} для {pct_map}"
+ )
+ assert sum(result.values()) == 100, (
+ f"sum={sum(result.values())} != 100 для {pct_map} → {result}"
+ )
if not expect_pathological:
- assert (
- max(result.values()) <= MAX_BUCKET_SHARE_PCT
- ), f"max={max(result.values())} > cap={MAX_BUCKET_SHARE_PCT} для {pct_map} → {result}"
+ assert max(result.values()) <= MAX_BUCKET_SHARE_PCT, (
+ f"max={max(result.values())} > cap={MAX_BUCKET_SHARE_PCT} для {pct_map} → {result}"
+ )
@pytest.mark.parametrize(
@@ -623,14 +623,14 @@ def test_cap_reproduced_failing_cases(
) -> None:
"""Review round-2 reproduced cases: 2-bucket — pathological, 3-bucket — fit cap."""
result, cap_skipped = _cap_and_redistribute(deals)
- assert (
- cap_skipped == expect_pathological
- ), f"cap_skipped={cap_skipped} ожидали {expect_pathological} для {label}"
+ assert cap_skipped == expect_pathological, (
+ f"cap_skipped={cap_skipped} ожидали {expect_pathological} для {label}"
+ )
assert sum(result.values()) == 100, f"sum != 100 для {label} → {result}"
if not expect_pathological:
- assert (
- max(result.values()) <= MAX_BUCKET_SHARE_PCT
- ), f"max={max(result.values())} > {MAX_BUCKET_SHARE_PCT} для {label} → {result}"
+ assert max(result.values()) <= MAX_BUCKET_SHARE_PCT, (
+ f"max={max(result.values())} > {MAX_BUCKET_SHARE_PCT} для {label} → {result}"
+ )
def test_cap_iteration_count_bounded() -> None:
@@ -773,9 +773,9 @@ def test_group_radius_objects_prod_case_groups() -> None:
assert len(groups) == 6, f"ожидалось 6 групп, получено {len(groups)}: {got}"
# load-bearing инвариант: 7 ключей и безымянные Эфесы разделены >300 м
assert frozenset({15731}) in got, "7 ключей должна быть отдельной группой"
- assert (
- frozenset({55320, 55321, 55322}) in got
- ), "3 безымянных Эфеса — один кластер, отдельный от 7 ключей"
+ assert frozenset({55320, 55321, 55322}) in got, (
+ "3 безымянных Эфеса — один кластер, отдельный от 7 ключей"
+ )
assert got == expected
@@ -1155,9 +1155,9 @@ def test_supply_only_velocity_group_not_duplicated() -> None:
]
for call in supply_calls:
params = call.args[1] if len(call.args) > 1 else call.kwargs
- assert "Траектория" not in (
- params.get("names") or []
- ), "velocity-проект не должен уходить в supply-only запрос"
+ assert "Траектория" not in (params.get("names") or []), (
+ "velocity-проект не должен уходить в supply-only запрос"
+ )
def test_supply_only_empty_snapshot_yields_empty_block() -> None:
diff --git a/backend/tests/services/site_finder/test_competitors_parking.py b/backend/tests/services/site_finder/test_competitors_parking.py
index 58fb795d..fecc42eb 100644
--- a/backend/tests/services/site_finder/test_competitors_parking.py
+++ b/backend/tests/services/site_finder/test_competitors_parking.py
@@ -133,18 +133,14 @@ def _competitors_response(*obj_ids: int) -> CompetitorsResponse:
def test_parking_wire_happy_path(monkeypatch: Any) -> None:
"""top-конкурент сматчился на здание → получает parking_ratio из НСПД."""
- monkeypatch.setattr(
- competitors_mod, "get_competitors", lambda **_: _competitors_response(101)
- )
+ monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(101))
monkeypatch.setattr(
competitors_mod,
"resolve_cad_for_domrf",
lambda *_a, **_k: BuildingMatch("66:41:0106036:183", 40995027, 12.3),
)
listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100)
- monkeypatch.setattr(
- competitors_mod, "get_building_premises_for_match", lambda _m: listing
- )
+ monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", lambda _m: listing)
out = competitors_mod.get_competitors_parking(
MagicMock(), "66:41:0303001:1", CompetitorsRequest()
@@ -192,9 +188,7 @@ def test_parking_wire_limits_to_top_n(monkeypatch: Any) -> None:
def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None:
"""geom-match промахнулся → конкурент в items с None-полями, matched_count=0."""
- monkeypatch.setattr(
- competitors_mod, "get_competitors", lambda **_: _competitors_response(7)
- )
+ monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(7))
monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: None)
premises = MagicMock()
monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", premises)
@@ -212,9 +206,7 @@ def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None:
def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None:
"""Здание сматчилось, но НСПД premises=None → cad есть, parking_ratio None."""
- monkeypatch.setattr(
- competitors_mod, "get_competitors", lambda **_: _competitors_response(9)
- )
+ monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(9))
monkeypatch.setattr(
competitors_mod,
"resolve_cad_for_domrf",
@@ -234,9 +226,7 @@ def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None:
def test_parking_wire_premises_exception_graceful(monkeypatch: Any) -> None:
"""Неожиданное исключение в premises-lookup → конкурент без паркинга, не 500."""
- monkeypatch.setattr(
- competitors_mod, "get_competitors", lambda **_: _competitors_response(11)
- )
+ monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(11))
monkeypatch.setattr(
competitors_mod,
"resolve_cad_for_domrf",
diff --git a/backend/tests/services/site_finder/test_pat_subzones.py b/backend/tests/services/site_finder/test_pat_subzones.py
index 826f8ef0..61b79e38 100644
--- a/backend/tests/services/site_finder/test_pat_subzones.py
+++ b/backend/tests/services/site_finder/test_pat_subzones.py
@@ -321,9 +321,9 @@ def test_json_path_candidates_end_with_expected_suffix() -> None:
expected_suffix = pathlib.Path("data") / "pat" / "koltsovo_367p_subzones.json"
for candidate in _JSON_PATH_CANDIDATES:
# последние 3 части пути совпадают с ожидаемыми
- assert (
- candidate.parts[-3:] == expected_suffix.parts
- ), f"Кандидат {candidate} не заканчивается на {expected_suffix}"
+ assert candidate.parts[-3:] == expected_suffix.parts, (
+ f"Кандидат {candidate} не заканчивается на {expected_suffix}"
+ )
def test_resolve_json_path_finds_real_file() -> None:
diff --git a/backend/tests/services/site_finder/test_permits_nearby.py b/backend/tests/services/site_finder/test_permits_nearby.py
index 36f82ae5..f39f8832 100644
--- a/backend/tests/services/site_finder/test_permits_nearby.py
+++ b/backend/tests/services/site_finder/test_permits_nearby.py
@@ -201,9 +201,9 @@ def test_sql_excludes_the_amendments_group() -> None:
держаться на том, что таких строк «пока нет».
"""
sql = str(_PERMITS_NEARBY_SQL)
- assert (
- "doc_group IN ('RS', 'RV')" in sql
- ), f"запрос не сужен по группе — строки 'IZ' сломают total_count:\n{sql}"
+ assert "doc_group IN ('RS', 'RV')" in sql, (
+ f"запрос не сужен по группе — строки 'IZ' сломают total_count:\n{sql}"
+ )
def test_total_equals_rs_plus_rv_even_if_iz_leaks_in() -> None:
diff --git a/backend/tests/services/site_finder/test_supply_layers.py b/backend/tests/services/site_finder/test_supply_layers.py
index 0e11e83f..7a10105f 100644
--- a/backend/tests/services/site_finder/test_supply_layers.py
+++ b/backend/tests/services/site_finder/test_supply_layers.py
@@ -395,12 +395,12 @@ class TestLayer2Hidden:
norm = " ".join(sql.split())
# Оба FILTER-предложения (n_with_free_flats COUNT и hidden_units SUM) должны
# гейтить flat_count IS NOT NULL наравне с free_flats IS NOT NULL.
- assert (
- norm.count("flat_count IS NOT NULL") == 2
- ), f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
- assert (
- norm.count("free_flats IS NOT NULL") == 2
- ), f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
+ assert norm.count("flat_count IS NOT NULL") == 2, (
+ f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
+ )
+ assert norm.count("free_flats IS NOT NULL") == 2, (
+ f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
+ )
def test_row_with_null_flat_count_and_positive_free_flats_excluded_end_to_end(
self,
diff --git a/backend/tests/services/test_2464_docstring_matches_code.py b/backend/tests/services/test_2464_docstring_matches_code.py
index 136bc1f5..b14e07cc 100644
--- a/backend/tests/services/test_2464_docstring_matches_code.py
+++ b/backend/tests/services/test_2464_docstring_matches_code.py
@@ -56,9 +56,9 @@ def test_quarter_dump_docstring_does_not_claim_17_requests() -> None:
from app.services.scrapers.nspd_client import QuarterDump
doc = inspect.getdoc(QuarterDump) or ""
- assert (
- "не сжигать rate-limit на 17 запросов" not in doc
- ), "в докстроке осталось число 17, противоречащее grid-walk"
+ assert "не сжигать rate-limit на 17 запросов" not in doc, (
+ "в докстроке осталось число 17, противоречащее grid-walk"
+ )
def test_on_demand_docstring_does_not_promise_a_60s_window() -> None:
@@ -105,6 +105,6 @@ def test_docstrings_state_the_actual_behaviour() -> None:
assert "include_zouit" in qd, "не назван фактический дефолт дампа"
od = inspect.getdoc(find_active_on_demand_job) or ""
- assert (
- "НИКОГДА" in od or "никогда" in od
- ), "не сказано, что failed не возвращается независимо от давности"
+ assert "НИКОГДА" in od or "никогда" in od, (
+ "не сказано, что failed не возвращается независимо от давности"
+ )
diff --git a/backend/tests/services/test_2464_three_small_leaks.py b/backend/tests/services/test_2464_three_small_leaks.py
index 57479248..a3a5279f 100644
--- a/backend/tests/services/test_2464_three_small_leaks.py
+++ b/backend/tests/services/test_2464_three_small_leaks.py
@@ -42,9 +42,9 @@ def test_water_result_keeps_period() -> None:
patch.object(mod.zipfile, "ZipFile", MagicMock()),
):
res = mod.load_water_reserves_from_docx(MagicMock(), "supply", b"", "http://x")
- assert (
- res.get("period") == "III кв. 2025"
- ), f"период выброшен из ответа: {res} — по логам он есть, у вызывающего нет"
+ assert res.get("period") == "III кв. 2025", (
+ f"период выброшен из ответа: {res} — по логам он есть, у вызывающего нет"
+ )
assert res.get("records") == 1 and res.get("inserted") == 1, res
@@ -108,12 +108,12 @@ def test_placement_warning_uses_actual_footprint_not_catalog() -> None:
src = inspect.getsource(mod.place_program)
хвост = src[src.index("участок мал") :]
- assert (
- "fp_w," in хвост and "fp_d," in хвост
- ), f"в предупреждении не фактические габариты:\n{хвост[:320]}"
- assert (
- "house.footprint_w_m," not in хвост and "house.footprint_d_m," not in хвост
- ), f"в предупреждении остался каталожный размер:\n{хвост[:320]}"
+ assert "fp_w," in хвост and "fp_d," in хвост, (
+ f"в предупреждении не фактические габариты:\n{хвост[:320]}"
+ )
+ assert "house.footprint_w_m," not in хвост and "house.footprint_d_m," not in хвост, (
+ f"в предупреждении остался каталожный размер:\n{хвост[:320]}"
+ )
@pytest.mark.parametrize("имя", ["fp_w", "fp_d"])
diff --git a/backend/tests/services/test_2464a_job_settings_savepoint.py b/backend/tests/services/test_2464a_job_settings_savepoint.py
index 5539f82c..c842536f 100644
--- a/backend/tests/services/test_2464a_job_settings_savepoint.py
+++ b/backend/tests/services/test_2464a_job_settings_savepoint.py
@@ -60,7 +60,7 @@ class _PostgresLikeDb:
def execute(self, *_args: Any, **_kwargs: Any) -> Any:
if self.aborted:
raise AbortedTransactionError(
- "current transaction is aborted, commands ignored until end of " "transaction block"
+ "current transaction is aborted, commands ignored until end of transaction block"
)
self.calls += 1
if self.calls == 1 and self._fail_first:
diff --git a/backend/tests/services/test_analytics_queries_domrf_dedup.py b/backend/tests/services/test_analytics_queries_domrf_dedup.py
index a6db1805..77e0edf1 100644
--- a/backend/tests/services/test_analytics_queries_domrf_dedup.py
+++ b/backend/tests/services/test_analytics_queries_domrf_dedup.py
@@ -116,9 +116,9 @@ class TestActiveCompetitorsCountSqlShape:
# site_status legitimately appears in the CTE's SELECT list (it's the
# column DISTINCT ON needs to expose) -- what must NOT appear is a
# filter predicate on it inside the CTE's WHERE.
- assert (
- "site_status = 'Строящиеся'" not in cte_body
- ), f"site_status must not pre-filter the DISTINCT ON CTE (volatile field):\n{cte_body}"
+ assert "site_status = 'Строящиеся'" not in cte_body, (
+ f"site_status must not pre-filter the DISTINCT ON CTE (volatile field):\n{cte_body}"
+ )
assert "site_status = 'Строящиеся'" in outer_body
def test_district_and_class_are_volatile_applied_after_distinct_on(self) -> None:
@@ -136,12 +136,12 @@ class TestActiveCompetitorsCountSqlShape:
)
cte_body, outer_body = _split_latest_cte(_executed_sql(db, 0))
# CTE WHERE must scope on ONLY the stable region_cd — no volatile predicate.
- assert (
- "district_name = :dn" not in cte_body
- ), f"district_name (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
- assert (
- "COALESCE(obj_class, obj_class_fallback) = :cls" not in cte_body
- ), f"obj_class (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
+ assert "district_name = :dn" not in cte_body, (
+ f"district_name (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
+ )
+ assert "COALESCE(obj_class, obj_class_fallback) = :cls" not in cte_body, (
+ f"obj_class (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
+ )
# Both live in the outer WHERE, applied to the deduped true-latest row.
assert "district_name = :dn" in outer_body
assert "COALESCE(obj_class, obj_class_fallback) = :cls" in outer_body
@@ -161,9 +161,9 @@ class TestActiveCompetitorsCountSqlShape:
where_start = cte_body.index("WHERE ") + len("WHERE ")
where_end = cte_body.index("ORDER BY", where_start)
where_clause = cte_body[where_start:where_end].strip()
- assert (
- where_clause == "region_cd = :rc"
- ), f"CTE WHERE must scope on ONLY stable region_cd, got: {where_clause!r}"
+ assert where_clause == "region_cd = :rc", (
+ f"CTE WHERE must scope on ONLY stable region_cd, got: {where_clause!r}"
+ )
def test_no_double_colon_cast(self) -> None:
import re
diff --git a/backend/tests/services/test_ekburg_permits.py b/backend/tests/services/test_ekburg_permits.py
index 51c47cd2..1bd8f8b9 100644
--- a/backend/tests/services/test_ekburg_permits.py
+++ b/backend/tests/services/test_ekburg_permits.py
@@ -397,12 +397,12 @@ class TestMsk66ToWgs84:
result = msk66_to_wgs84("1534814.7997", "394813.2001")
assert result is not None
lon, lat = result
- assert (
- abs(lon - 60.619637) < _TOL_LON_DEG
- ), f"lon={lon:.6f} далеко от эталонного 60.619637 (Δ={abs(lon - 60.619637):.6f}°)"
- assert (
- abs(lat - 56.871948) < _TOL_LAT_DEG
- ), f"lat={lat:.6f} далеко от эталонного 56.871948 (Δ={abs(lat - 56.871948):.6f}°)"
+ assert abs(lon - 60.619637) < _TOL_LON_DEG, (
+ f"lon={lon:.6f} далеко от эталонного 60.619637 (Δ={abs(lon - 60.619637):.6f}°)"
+ )
+ assert abs(lat - 56.871948) < _TOL_LAT_DEG, (
+ f"lat={lat:.6f} далеко от эталонного 56.871948 (Δ={abs(lat - 56.871948):.6f}°)"
+ )
def test_koltsovo_cad_crosscheck(self) -> None:
"""X=1544026.7997/Y=381585.4401 — сверка с centroid cad_parcels 66:41:0503018:248.
@@ -412,12 +412,12 @@ class TestMsk66ToWgs84:
result = msk66_to_wgs84("1544026.7997", "381585.4401")
assert result is not None
lon, lat = result
- assert (
- abs(lon - 60.768255) < _TOL_LON_DEG
- ), f"lon={lon:.6f} далеко от эталонного 60.768255 (Δ={abs(lon - 60.768255):.6f}°)"
- assert (
- abs(lat - 56.752330) < _TOL_LAT_DEG
- ), f"lat={lat:.6f} далеко от эталонного 56.752330 (Δ={abs(lat - 56.752330):.6f}°)"
+ assert abs(lon - 60.768255) < _TOL_LON_DEG, (
+ f"lon={lon:.6f} далеко от эталонного 60.768255 (Δ={abs(lon - 60.768255):.6f}°)"
+ )
+ assert abs(lat - 56.752330) < _TOL_LAT_DEG, (
+ f"lat={lat:.6f} далеко от эталонного 56.752330 (Δ={abs(lat - 56.752330):.6f}°)"
+ )
def test_smorodinovaya_cad_crosscheck(self) -> None:
"""X=1526550.1397/Y=387129.3801 — сверка с centroid cad_parcels 66:41:0306057:89.
@@ -427,12 +427,12 @@ class TestMsk66ToWgs84:
result = msk66_to_wgs84("1526550.1397", "387129.3801")
assert result is not None
lon, lat = result
- assert (
- abs(lon - 60.483224) < _TOL_LON_DEG
- ), f"lon={lon:.6f} далеко от эталонного 60.483224 (Δ={abs(lon - 60.483224):.6f}°)"
- assert (
- abs(lat - 56.803547) < _TOL_LAT_DEG
- ), f"lat={lat:.6f} далеко от эталонного 56.803547 (Δ={abs(lat - 56.803547):.6f}°)"
+ assert abs(lon - 60.483224) < _TOL_LON_DEG, (
+ f"lon={lon:.6f} далеко от эталонного 60.483224 (Δ={abs(lon - 60.483224):.6f}°)"
+ )
+ assert abs(lat - 56.803547) < _TOL_LAT_DEG, (
+ f"lat={lat:.6f} далеко от эталонного 56.803547 (Δ={abs(lat - 56.803547):.6f}°)"
+ )
def test_respublikanskaya_cad_crosscheck(self) -> None:
"""X=1531655.2797/Y=398430.2401 — ул. Республиканская 1а, cad 66:41:0106051:10.
@@ -443,12 +443,12 @@ class TestMsk66ToWgs84:
result = msk66_to_wgs84("1531655.2797", "398430.2401")
assert result is not None
lon, lat = result
- assert (
- abs(lon - 60.568320) < _TOL_LON_DEG
- ), f"lon={lon:.6f} далеко от эталонного 60.568320 (Δ={abs(lon - 60.568320):.6f}°)"
- assert (
- abs(lat - 56.904674) < _TOL_LAT_DEG
- ), f"lat={lat:.6f} далеко от эталонного 56.904674 (Δ={abs(lat - 56.904674):.6f}°)"
+ assert abs(lon - 60.568320) < _TOL_LON_DEG, (
+ f"lon={lon:.6f} далеко от эталонного 60.568320 (Δ={abs(lon - 60.568320):.6f}°)"
+ )
+ assert abs(lat - 56.904674) < _TOL_LAT_DEG, (
+ f"lat={lat:.6f} далеко от эталонного 56.904674 (Δ={abs(lat - 56.904674):.6f}°)"
+ )
def test_kosmonavtov_in_ekb(self) -> None:
"""Пр. Космонавтов — попадает в ЕКБ-регион."""
diff --git a/backend/tests/services/test_newbuilding_crossload.py b/backend/tests/services/test_newbuilding_crossload.py
index 232cc7f1..fecd061d 100644
--- a/backend/tests/services/test_newbuilding_crossload.py
+++ b/backend/tests/services/test_newbuilding_crossload.py
@@ -162,10 +162,10 @@ def test_upsert_sql_coalesce_external_ids():
assert "COALESCE" in do_update_section, "DO UPDATE должен содержать COALESCE"
# yandex_jk_id — строка вида "YANDEX_JK_ID = COALESCE("
- assert (
- "YANDEX_JK_ID = COALESCE(" in do_update_section
- ), "yandex_jk_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
+ assert "YANDEX_JK_ID = COALESCE(" in do_update_section, (
+ "yandex_jk_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
+ )
# cian_internal_house_id — строка вида "CIAN_INTERNAL_HOUSE_ID = COALESCE("
- assert (
- "CIAN_INTERNAL_HOUSE_ID = COALESCE(" in do_update_section
- ), "cian_internal_house_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
+ assert "CIAN_INTERNAL_HOUSE_ID = COALESCE(" in do_update_section, (
+ "cian_internal_house_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
+ )
diff --git a/backend/tests/services/test_recommend_mix_velocity.py b/backend/tests/services/test_recommend_mix_velocity.py
index 73ea6e0a..38804a2c 100644
--- a/backend/tests/services/test_recommend_mix_velocity.py
+++ b/backend/tests/services/test_recommend_mix_velocity.py
@@ -422,9 +422,9 @@ class TestRealisticSrokFallback:
)
srok = result["summary"]["months_to_sellout_total"]
assert srok is not None
- assert (
- lo <= srok <= hi
- ), f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]"
+ assert lo <= srok <= hi, (
+ f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]"
+ )
def test_scope_has_n_competitors(self) -> None:
"""scope.n_competitors присутствует и равен district+class competitors."""
@@ -522,9 +522,9 @@ class TestObjectivePerBucketPath:
# Studio: macro_mult = sat_factor × trend_factor = 1.0 × 1.0 = 1.0
studio = bkt_map.get("Студии 15-30")
assert studio is not None
- assert studio["velocity_per_month"] == pytest.approx(
- 3.5, rel=0.01
- ), f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
+ assert studio["velocity_per_month"] == pytest.approx(3.5, rel=0.01), (
+ f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
+ )
assert studio.get("velocity_source") == "objective_per_bucket"
def test_objective_velocities_vary(self) -> None:
diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py
index ed5b5f71..8e3f6a7c 100644
--- a/backend/tests/services/test_weather_cache.py
+++ b/backend/tests/services/test_weather_cache.py
@@ -553,9 +553,9 @@ class TestWindDirectionAllNone:
f"ожидался None, получено {wind['dominant_direction_deg']!r} — "
"вероятно, fabricated 0.0° из-за atan2(0,0)"
)
- assert (
- wind["dominant_direction_label"] is None
- ), f"ожидался None, получено {wind['dominant_direction_label']!r}"
+ assert wind["dominant_direction_label"] is None, (
+ f"ожидался None, получено {wind['dominant_direction_label']!r}"
+ )
def test_missing_wind_key_gives_none_direction(self) -> None:
"""winddirection_10m_dominant отсутствует в ответе → dominant_direction_deg/label = None."""
diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt
index d1c28220..0c08811c 100644
--- a/backend/tests/skip_allowlist.txt
+++ b/backend/tests/skip_allowlist.txt
@@ -165,3 +165,14 @@ tests/sql/test_2986_permits_source_key.py::test_old_key_collapses_permit_and_its
tests/sql/test_2986_permits_source_key.py::test_after_migration_both_documents_survive
tests/sql/test_2986_permits_source_key.py::test_cross_schema_duplicate_still_merges
tests/sql/test_2986_permits_source_key.py::test_migration_allows_the_izmeneniya_group
+
+# #2998 — горизонт партиций rosreestr_deals. Четыре DB-теста поднимают rosreestr_deals из
+# 01_schema в СХЕМЕ-ПЕСОЧНИЦЕ (герметично, прод не трогают), применяют миграцию 193 и
+# читают горизонт из pg_inherits: до 193 красно по значению (01-схема кончается на 2026q1),
+# после — партиция есть на публикуемый квартал + на следующий; 193 идемпотентна.
+# В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД. Календарный
+# test_calendar_helper_matches_known_publication базы НЕ требует и в список НЕ входит.
+tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_for_the_publishable_quarter
+tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
+tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
+tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead
diff --git a/backend/tests/sql/test_2464_act_date_backfill.py b/backend/tests/sql/test_2464_act_date_backfill.py
index bc6a0311..9442753f 100644
--- a/backend/tests/sql/test_2464_act_date_backfill.py
+++ b/backend/tests/sql/test_2464_act_date_backfill.py
@@ -106,9 +106,7 @@ def _run() -> dict[tuple[str, date], int]:
session.execute(text(_TEMP))
for cad, d, url in _SEED:
session.execute(
- text(
- "INSERT INTO land_reservation (cad_num, act_date, doc_url)" " VALUES (:c,:d,:u)"
- ),
+ text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"),
{"c": cad, "d": d, "u": url},
)
for chunk in _body():
diff --git a/backend/tests/sql/test_2464_land_reservation_dedup.py b/backend/tests/sql/test_2464_land_reservation_dedup.py
index f9e923ee..067302af 100644
--- a/backend/tests/sql/test_2464_land_reservation_dedup.py
+++ b/backend/tests/sql/test_2464_land_reservation_dedup.py
@@ -123,9 +123,9 @@ def test_plain_unique_does_not_deduplicate(db) -> None:
Без этой проверки зелёный тест выше неотличим от «оно и так работало».
"""
_add_constraint(db, nulls_not_distinct=False)
- assert (
- _insert_twice(db) == 2
- ), "обычный UNIQUE неожиданно поймал дубль — значит тест выше ничего не доказывает"
+ assert _insert_twice(db) == 2, (
+ "обычный UNIQUE неожиданно поймал дубль — значит тест выше ничего не доказывает"
+ )
def test_records_with_act_number_still_deduplicate(db) -> None:
@@ -159,9 +159,9 @@ def test_migration_dedup_statement_matches_the_key(db) -> None:
assert delete_stmt is not None, "в миграции нет DELETE — дедуп не выполняется"
body = delete_stmt.group(0)
assert "a.cad_num = b.cad_num" in body, "дедуп не по cad_num"
- assert (
- "a.act_number IS NULL" in body and "b.act_number IS NULL" in body
- ), "дедуп затрагивает записи С номером акта — они и так были уникальны"
+ assert "a.act_number IS NULL" in body and "b.act_number IS NULL" in body, (
+ "дедуп затрагивает записи С номером акта — они и так были уникальны"
+ )
assert "a.id > b.id" in body, "не задан выживающий (минимальный id)"
diff --git a/backend/tests/sql/test_2464_leads_stats_suffix_contract.py b/backend/tests/sql/test_2464_leads_stats_suffix_contract.py
index fc13f048..f83a868f 100644
--- a/backend/tests/sql/test_2464_leads_stats_suffix_contract.py
+++ b/backend/tests/sql/test_2464_leads_stats_suffix_contract.py
@@ -169,9 +169,9 @@ def test_revenue_and_deals_are_named_by_their_scope(seeded) -> None:
просто перестала показывать поле в UI.
"""
stats = _stats(seeded)
- assert (
- stats.get("revenue_window") == _IN_WINDOW_REVENUE
- ), f"revenue_window = {stats.get('revenue_window')}, ожидалось {_IN_WINDOW_REVENUE}"
+ assert stats.get("revenue_window") == _IN_WINDOW_REVENUE, (
+ f"revenue_window = {stats.get('revenue_window')}, ожидалось {_IN_WINDOW_REVENUE}"
+ )
assert stats.get("deals_window") == _IN_WINDOW_DEALS
diff --git a/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py b/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py
index 7193158c..c30077ec 100644
--- a/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py
+++ b/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py
@@ -110,9 +110,9 @@ def _nspd(db) -> dict:
payload = compute_freshness(db)
rows = [s for s in payload["sources"] if s["source"] == "nspd"]
- assert (
- len(rows) == 1
- ), f"источник nspd не найден в реестре: {[s['source'] for s in payload['sources']]}"
+ assert len(rows) == 1, (
+ f"источник nspd не найден в реестре: {[s['source'] for s in payload['sources']]}"
+ )
return rows[0]
@@ -181,7 +181,7 @@ def test_attempt_is_still_recorded(db) -> None:
assert src["last_attempt_at"] is not None
assert src["last_success_at"] is not None
assert src["last_attempt_at"] > src["last_success_at"], (
- "последняя попытка должна быть новее последнего успеха — иначе провалы " "не видны вообще"
+ "последняя попытка должна быть новее последнего успеха — иначе провалы не видны вообще"
)
diff --git a/backend/tests/sql/test_2986_permits_source_key.py b/backend/tests/sql/test_2986_permits_source_key.py
index b4b2d675..1214708c 100644
--- a/backend/tests/sql/test_2986_permits_source_key.py
+++ b/backend/tests/sql/test_2986_permits_source_key.py
@@ -141,9 +141,9 @@ def test_loader_knows_the_amendments_group() -> None:
"""
from app.services.scrapers.gisogd66 import GROUP_CODE
- assert (
- GROUP_CODE.get("DocIZ") == "IZ"
- ), f"группа изменений не грузится; GROUP_CODE = {GROUP_CODE}"
+ assert GROUP_CODE.get("DocIZ") == "IZ", (
+ f"группа изменений не грузится; GROUP_CODE = {GROUP_CODE}"
+ )
def test_loader_upserts_by_source_key() -> None:
@@ -158,9 +158,9 @@ def test_loader_upserts_by_source_key() -> None:
src = inspect.getsource(_upsert_permit)
assert "ON CONFLICT (source_key)" in src, "UPSERT конфликтует не по source_key"
- assert (
- "ON CONFLICT (doc_group, doc_num)" not in src
- ), "старый ключ всё ещё в запросе — разрешение и изменения схлопнутся"
+ assert "ON CONFLICT (doc_group, doc_num)" not in src, (
+ "старый ключ всё ещё в запросе — разрешение и изменения схлопнутся"
+ )
# ── Механизм и миграция: нужен живой Postgres ────────────────────────────────
@@ -184,9 +184,9 @@ def test_old_key_collapses_permit_and_its_amendment() -> None:
s.execute(text(_OLD_UPSERT), _ИЗМЕНЕНИЕ)
rows = s.execute(text("SELECT doc_name, source_key FROM gisogd_permits")).all()
assert len(rows) == 1, f"ожидали схлопывание, получили {len(rows)} строк"
- assert (
- "Изменения" in rows[0][0]
- ), f"вытеснено не то: осталось {rows[0][0]!r} — на проде остаётся именно изменение"
+ assert "Изменения" in rows[0][0], (
+ f"вытеснено не то: осталось {rows[0][0]!r} — на проде остаётся именно изменение"
+ )
finally:
s.rollback()
s.close()
diff --git a/backend/tests/sql/test_2998_rosreestr_partition_horizon.py b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py
new file mode 100644
index 00000000..1236bade
--- /dev/null
+++ b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py
@@ -0,0 +1,193 @@
+"""У rosreestr_deals есть партиция под каждый квартал, который Росреестр уже мог
+опубликовать (#2998).
+
+Таблица партиционирована по period_start_date, и партиции создавались списком в
+01_schema_rosreestr_deals.sql — «2024 Q3 — 2026 Q1». Дальше этого горизонта таблица
+ничего не знала, и никакой механизм новые партиции не создаёт. Q2 2026 вышел 10.07,
+poll заметил его 14.08, а загрузка 21.08 упала:
+
+ ERROR: no partition of relation "rosreestr_deals" found for row
+ DETAIL: Partition key of the failing row contains (period_start_date) = (2026-04-01).
+
+То есть даже оператор, запустив 02_load_all_quarters.sh по подсказке poll, получил
+бы отказ. Миграция 193 добавляет Q2–Q4 2026; этот тест следит, чтобы горизонт не
+отставал снова: партиция обязана существовать на ПОСЛЕДНИЙ квартал, который по
+календарю уже мог быть опубликован (публикация отстаёт от конца квартала ~на 10
+дней: Q2 2026 вышел 10.07), плюс на следующий — чтобы предупреждение приходило за
+квартал до отказа, а не в день публикации.
+
+Герметично, как соседние DB-тесты: в своей СХЕМЕ-песочнице поднимается
+rosreestr_deals из 01_schema (без индексов), применяется миграция 193, горизонт
+читается из pg_inherits по этой схеме. Прод не трогается; в CI идёт на
+postgres-сервисе. Без БД — skip с причиной.
+
+Красная сторона воспроизводима, а не предполагается: до миграции 193 горизонт
+01-схемы кончается на 2026q1, и головная проверка краснеет по значению
+(«нет партиции на квартал 2026-04-01»).
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import re
+import uuid
+from datetime import date, timedelta
+from pathlib import Path
+
+import pytest
+from sqlalchemy import create_engine, text
+
+_SQL_DIR = Path(__file__).resolve().parents[3] / "data" / "sql"
+_SCHEMA_01 = _SQL_DIR / "01_schema_rosreestr_deals.sql"
+_MIGRATION = _SQL_DIR / "193_partitions_rosreestr_2026_q2_q4.sql"
+
+# Публикация квартала отстаёт от его конца; берём запас, чтобы не требовать партицию
+# раньше, чем данные вообще могут появиться. Q2 2026 (конец 30.06) опубликован 10.07.
+_PUBLICATION_LAG_DAYS = 20
+
+
+def _dsn() -> str:
+ raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
+ "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
+ )
+ return raw.replace("postgresql://", "postgresql+psycopg://", 1)
+
+
+def _quarter_start(d: date) -> date:
+ return date(d.year, 3 * ((d.month - 1) // 3) + 1, 1)
+
+
+def _next_quarter(q: date) -> date:
+ return date(q.year + (q.month == 10), 1 if q.month == 10 else q.month + 3, 1)
+
+
+def _latest_publishable_quarter(today: date) -> date:
+ """Начало последнего квартала, чей дамп по календарю уже мог выйти."""
+ q = _quarter_start(today)
+ prev_q = _quarter_start(q - timedelta(days=1))
+ if (today - q).days >= _PUBLICATION_LAG_DAYS:
+ return prev_q
+ return _quarter_start(prev_q - timedelta(days=1))
+
+
+def _schema_01_without_indexes() -> str:
+ """01_schema без CREATE INDEX: индексы тесту не нужны, а часть их — по колонкам,
+ которых в песочнице проверять нечего. Партиции и сама таблица — целиком."""
+ sql = _SCHEMA_01.read_text(encoding="utf-8")
+ return re.sub(r"CREATE INDEX[^;]*;", "", sql, flags=re.S)
+
+
+def _partition_starts(conn, schema: str) -> set[date]:
+ rows = conn.execute(
+ text(
+ """
+ SELECT pg_get_expr(c.relpartbound, c.oid) AS bound
+ FROM pg_inherits i
+ JOIN pg_class c ON c.oid = i.inhrelid
+ JOIN pg_class p ON p.oid = i.inhparent
+ JOIN pg_namespace n ON n.oid = p.relnamespace
+ WHERE p.relname = 'rosreestr_deals' AND n.nspname = :schema
+ """
+ ),
+ {"schema": schema},
+ ).scalars()
+ out: set[date] = set()
+ for b in rows:
+ frm = b.split("FROM ('", 1)[1].split("'", 1)[0]
+ out.add(date.fromisoformat(frm))
+ return out
+
+
+@pytest.fixture(scope="module")
+def sandbox():
+ """Схема-песочница с rosreestr_deals из 01_schema. Удаляется в конце модуля."""
+ try:
+ eng = create_engine(_dsn(), future=True)
+ conn = eng.connect()
+ conn.execute(text("SELECT 1"))
+ conn.rollback() # SELECT 1 авто-открыл транзакцию — закрыть, иначе begin() падает
+ except Exception as e: # pragma: no cover - среда без БД
+ pytest.skip(f"нет Postgres для проверки партиций: {e}")
+ schema = f"t2998_{uuid.uuid4().hex[:10]}"
+ with conn.begin():
+ conn.execute(text(f"CREATE SCHEMA {schema}"))
+ conn.execute(text(f"SET LOCAL search_path TO {schema}"))
+ conn.execute(text(_schema_01_without_indexes()))
+ try:
+ yield conn, schema
+ finally:
+ conn.rollback()
+ with conn.begin():
+ conn.execute(text(f"DROP SCHEMA {schema} CASCADE"))
+ conn.close()
+
+
+def _apply_193(conn, schema: str) -> None:
+ conn.rollback() # предыдущие SELECT авто-открыли транзакцию
+ with conn.begin():
+ conn.execute(text(f"SET LOCAL search_path TO {schema}"))
+ conn.execute(text(_MIGRATION.read_text(encoding="utf-8")))
+
+
+def test_schema_01_alone_is_red_for_the_publishable_quarter(sandbox) -> None:
+ """Красная сторона: ДО миграции 193 партиции на публикуемый квартал нет.
+
+ Это ровно состояние origin/main на 21.08.2026, когда загрузка Q2 упала.
+ Проверка по значению: горизонт 01-схемы кончается на 2026q1.
+ """
+ conn, schema = sandbox
+ have = _partition_starts(conn, schema)
+ assert have, "песочница пуста — 01_schema не применилась"
+ assert max(have) == date(2026, 1, 1), (
+ f"горизонт 01-схемы ожидался 2026q1, есть {sorted(have)[-2:]}"
+ )
+ need = date(2026, 4, 1) # публикуемый квартал на дату инцидента
+ assert need not in have, "красная сторона не состоялась: 01-схема уже знает Q2 2026"
+
+
+def test_migration_193_is_idempotent_and_closes_the_gap(sandbox) -> None:
+ """Миграция применяется дважды без ошибки и добавляет Q2–Q4 2026."""
+ conn, schema = sandbox
+ _apply_193(conn, schema)
+ _apply_193(conn, schema)
+ have = _partition_starts(conn, schema)
+ assert {date(2026, 4, 1), date(2026, 7, 1), date(2026, 10, 1)} <= have, sorted(have)[-4:]
+
+
+def test_partition_exists_for_every_publishable_quarter(sandbox) -> None:
+ """Головной сторож горизонта: на каждый уже-публикуемый квартал есть партиция.
+
+ Сегодня это Q2 2026 (после 193 — есть). Когда календарь дойдёт до квартала,
+ которого миграции ещё не создали, тест покраснеет по значению — и это его работа.
+ """
+ conn, schema = sandbox
+ _apply_193(conn, schema)
+ have = _partition_starts(conn, schema)
+ need = _latest_publishable_quarter(date.today())
+ assert need in have, (
+ f"нет партиции на квартал {need} — загрузка опубликованного дампа упадёт с "
+ f"«no partition of relation rosreestr_deals found for row»; есть: {sorted(have)[-3:]}"
+ )
+
+
+def test_partition_exists_one_quarter_ahead(sandbox) -> None:
+ """Контроль горизонта: партиция на СЛЕДУЮЩИЙ квартал тоже есть — предупреждение
+ должно приходить за квартал до отказа, а не в день публикации."""
+ conn, schema = sandbox
+ _apply_193(conn, schema)
+ have = _partition_starts(conn, schema)
+ need = _next_quarter(_latest_publishable_quarter(date.today()))
+ assert need in have, (
+ f"нет партиции на следующий квартал {need} — запаса нет, следующая публикация "
+ f"снова упрётся в отсутствие партиции; есть: {sorted(have)[-3:]}"
+ )
+
+
+def test_calendar_helper_matches_known_publication() -> None:
+ """Контроль калибровки (без БД): 21.08.2026 → Q2 2026; 05.07 → ещё Q1; 25.07 → Q2."""
+ assert _latest_publishable_quarter(date(2026, 8, 21)) == date(2026, 4, 1)
+ assert _latest_publishable_quarter(date(2026, 7, 5)) == date(2026, 1, 1)
+ assert _latest_publishable_quarter(date(2026, 7, 25)) == date(2026, 4, 1)
diff --git a/backend/tests/sql/test_auth_sql_migrations.py b/backend/tests/sql/test_auth_sql_migrations.py
index 29a14105..cd446547 100644
--- a/backend/tests/sql/test_auth_sql_migrations.py
+++ b/backend/tests/sql/test_auth_sql_migrations.py
@@ -115,9 +115,9 @@ def test_migrations_are_transactional() -> None:
]
if not statements or statements[0] != "BEGIN;" or statements[-1] != "COMMIT;":
broken.append(path.name)
- assert (
- not broken
- ), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
+ assert not broken, (
+ f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
+ )
def test_no_concurrent_index_in_migrations() -> None:
@@ -177,6 +177,6 @@ def test_deploy_workflow_applies_auth_migrations() -> None:
f"В {_DEPLOY_WORKFLOW.name} нет цикла по data/sql/auth/*.sql — миграции БД auth "
"не применяются на деплое."
)
- assert (
- "ops/db-bootstrap/create_auth_db.sql" in workflow
- ), f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth."
+ assert "ops/db-bootstrap/create_auth_db.sql" in workflow, (
+ f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth."
+ )
diff --git a/backend/tests/sql/test_ddu_price_indicator.py b/backend/tests/sql/test_ddu_price_indicator.py
index df9bd034..9033a404 100644
--- a/backend/tests/sql/test_ddu_price_indicator.py
+++ b/backend/tests/sql/test_ddu_price_indicator.py
@@ -40,8 +40,7 @@ _DB_OK, _DB_ERR = _db_reachable()
pytestmark = pytest.mark.skipif(
not _DB_OK,
reason=(
- "Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — "
- f"тест #99 пропущен: {_DB_ERR}"
+ f"Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — тест #99 пропущен: {_DB_ERR}"
),
)
@@ -107,10 +106,7 @@ def conn():
def _insert_quarter(cur, q_start, bucket_area, price_m2, n) -> None:
"""Insert n single-flat ДДУ rows at given per-unit area + price/m²."""
- rows = [
- ("002001003000", "ДДУ", 66, q_start, bucket_area, 1, price_m2)
- for _ in range(n)
- ]
+ rows = [("002001003000", "ДДУ", 66, q_start, bucket_area, 1, price_m2) for _ in range(n)]
cur.executemany(
"INSERT INTO rd (realestate_type_code, doc_type, region_code, "
"period_start_date, area, deal_count, price_per_sqm) "
@@ -136,7 +132,7 @@ def _setup(cur: psycopg.Cursor) -> None:
# Bucket 4 (60-80 m²): 2025-Q3 present, 2025-Q4 SPARSE (<10 → filtered),
# 2026-Q1 present. index_previous for 2026-Q1 must compare to 2025-Q3.
_insert_quarter(cur, "2025-07-01", 70, 150000, 11)
- _insert_quarter(cur, "2025-10-01", 70, 999999, 3) # below min_deals → dropped
+ _insert_quarter(cur, "2025-10-01", 70, 999999, 3) # below min_deals → dropped
_insert_quarter(cur, "2026-01-01", 70, 165000, 11)
# Packaged-deal trap: one row area=350 deal_count=7 → per-unit 50 m² (bucket 3),
# NOT bucket 6. Price chosen mid-range so it doesn't move the median much.
diff --git a/backend/tests/sql/test_velocity_alerts.py b/backend/tests/sql/test_velocity_alerts.py
index 44b34bfe..7baf7631 100644
--- a/backend/tests/sql/test_velocity_alerts.py
+++ b/backend/tests/sql/test_velocity_alerts.py
@@ -39,8 +39,7 @@ _DB_OK, _DB_ERR = _db_reachable()
pytestmark = pytest.mark.skipif(
not _DB_OK,
reason=(
- "Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — "
- f"тест #17 пропущен: {_DB_ERR}"
+ f"Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — тест #17 пропущен: {_DB_ERR}"
),
)
@@ -107,8 +106,15 @@ def _setup(cur: psycopg.Cursor) -> None:
)
snap = "2026-04-28" # stale scrape date; data months end 2025-12 (4-mo gap)
months = [
- "2025-04-01", "2025-05-01", "2025-06-01", "2025-07-01", "2025-08-01",
- "2025-09-01", "2025-10-01", "2025-11-01", "2025-12-01",
+ "2025-04-01",
+ "2025-05-01",
+ "2025-06-01",
+ "2025-07-01",
+ "2025-08-01",
+ "2025-09-01",
+ "2025-10-01",
+ "2025-11-01",
+ "2025-12-01",
]
# obj 1 — sharp drop: prior ~15/mo, recent ~4/mo -> alert
dropper = [16, 14, 15, 17, 13, 14, 5, 4, 3]
diff --git a/backend/tests/test_2464c_photos_session_release.py b/backend/tests/test_2464c_photos_session_release.py
index 10b2a9db..dae3c691 100644
--- a/backend/tests/test_2464c_photos_session_release.py
+++ b/backend/tests/test_2464c_photos_session_release.py
@@ -153,9 +153,9 @@ def test_session_usable_after_close_for_thumb_update(
resp = photos.get_photo(db=_session_with_photo_row, obj_id=1, file_id="f1", size="thumb")
- assert (
- seen.get("in_transaction") is False
- ), "миниатюра генерируется при открытой транзакции — соединение пула занято"
+ assert seen.get("in_transaction") is False, (
+ "миниатюра генерируется при открытой транзакции — соединение пула занято"
+ )
assert getattr(resp, "path", None) == str(generated)
# Главное: запись ПОСЛЕ close() действительно доехала до БД — читаем ОТДЕЛЬНЫМ
diff --git a/backend/tests/test_2864_ruff_version_alignment.py b/backend/tests/test_2864_ruff_version_alignment.py
new file mode 100644
index 00000000..fb463f97
--- /dev/null
+++ b/backend/tests/test_2864_ruff_version_alignment.py
@@ -0,0 +1,71 @@
+"""pre-commit и uv.lock держат ОДНУ версию ruff (#2864).
+
+Хук `ruff-pre-commit` пинился на v0.7.4, а CI и `uv run ruff format` брали ruff из
+lock-файлов (0.15.12 в backend/, 0.15.20 в tradein-mvp/). Старый и новый ruff
+по-разному форматируют длинные `assert ..., "сообщение"` — хук на каждом коммите
+переписывал нетронутые строки, следующий `uv run ruff format` возвращал как было.
+`ruff format --check` намеренно не в CI-гейте, поэтому дрейф нигде не краснел.
+
+Гейт читает три источника истины и требует равенства. Красный на origin/main по
+значению: rev v0.7.4 против 0.15.12 и 0.15.20. Бампить втроём: rev в
+.pre-commit-config.yaml + `ruff==X` в обоих pyproject.toml + `uv lock`.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+КОРЕНЬ = Path(__file__).resolve().parents[2]
+
+_REV = re.compile(
+ r"repo:\s*https://github\.com/astral-sh/ruff-pre-commit\s*\n\s*rev:\s*v?([\w.]+)",
+)
+_LOCK_RUFF = re.compile(r'^name = "ruff"\nversion = "([\w.]+)"', re.MULTILINE)
+_PYPROJECT_RUFF = re.compile(r'"ruff([<>=!~]+[^"]*)"')
+
+
+def _precommit_rev() -> str:
+ текст = (КОРЕНЬ / ".pre-commit-config.yaml").read_text(encoding="utf-8")
+ m = _REV.search(текст)
+ assert m, "в .pre-commit-config.yaml нет блока ruff-pre-commit с rev"
+ return m.group(1)
+
+
+def _lock_version(lock: str) -> str:
+ m = _LOCK_RUFF.search((КОРЕНЬ / lock).read_text(encoding="utf-8"))
+ assert m, f"в {lock} нет пакета ruff"
+ return m.group(1)
+
+
+def test_precommit_rev_matches_both_locks() -> None:
+ """Головной: хук и оба lock'а — одна версия ruff."""
+ rev = _precommit_rev()
+ locks = {
+ "backend/uv.lock": _lock_version("backend/uv.lock"),
+ "tradein-mvp/uv.lock": _lock_version("tradein-mvp/uv.lock"),
+ }
+ расхождения = {k: v for k, v in locks.items() if v != rev}
+ assert not расхождения, (
+ f"pre-commit ruff rev=v{rev}, а в lock'ах {расхождения} — хук и "
+ "`uv run ruff format` будут форматировать по-разному (#2864)"
+ )
+
+
+def test_pyproject_pins_exact_ruff() -> None:
+ """Контроль от повторного дрейфа: `ruff==X` в обоих pyproject, X = rev.
+
+ С `>=` любой `uv lock --upgrade` молча уводит lock от хука — ровно так
+ и разъехались 0.7.4 / 0.15.12 / 0.15.20.
+ """
+ rev = _precommit_rev()
+ for f in ("backend/pyproject.toml", "tradein-mvp/backend/pyproject.toml"):
+ m = _PYPROJECT_RUFF.search((КОРЕНЬ / f).read_text(encoding="utf-8"))
+ assert m, f"в {f} нет зависимости ruff"
+ assert m.group(1) == f"=={rev}", f"{f}: ruff{m.group(1)}, ожидался ruff=={rev}"
+
+
+def test_gate_reads_real_values() -> None:
+ """Контроль самого гейта: парсеры возвращают версии, а не падают на пустом."""
+ assert re.fullmatch(r"\d+\.\d+\.\d+", _precommit_rev())
+ assert re.fullmatch(r"\d+\.\d+\.\d+", _lock_version("backend/uv.lock"))
diff --git a/backend/tests/test_2867_avg_area_nullable.py b/backend/tests/test_2867_avg_area_nullable.py
new file mode 100644
index 00000000..2f95a392
--- /dev/null
+++ b/backend/tests/test_2867_avg_area_nullable.py
@@ -0,0 +1,130 @@
+"""avg_area_m2 в топ-планировках — NULL вместо 0 м², когда сделок за окно нет (#2867).
+
+`_INLINE_VELOCITY_SQL` считал среднюю площадь как
+`COALESCE(SUM(area_weighted_sum) / NULLIF(SUM(deals_window), 0), 0)` — пустое окно
+продаж давало делитель NULL и COALESCE подставлял 0: «средняя площадь проданной
+квартиры — 0 м²» вместо «сделок не было, средней нет». Замер прода 13.08: 635 пустых
+пар (проект × комнатность) из 2083, у 80 проектов пусты все комнатности.
+
+Контракт исправлен насквозь: схема `float | None`, SQL без COALESCE (как у соседнего
+avg_price_per_m2_rub, #2464-B), сборка рядов не round'ит None, взвешенная площадь
+микса делится на сделки рядов С площадью, PDF печатает «—», фронт/типы — `number | null`.
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import inspect
+import re
+
+
+def test_velocity_sql_does_not_coalesce_area_to_zero() -> None:
+ """Головной (SQL): в deals_per_bucket нет COALESCE(... area ..., 0).
+
+ На origin/main он есть — с комментарием «остаётся намеренно… отдельным заходом: #2867».
+ """
+ from app.services.site_finder import best_layouts as m
+
+ sql = str(m._INLINE_VELOCITY_SQL)
+ blk = sql[sql.index("deals_per_bucket AS (") : sql.index("AS avg_area_m2")]
+ # Только код: SQL-комментарии (--) вырезаем — иначе гейт спорит с собственным
+ # объяснением правки, где слово COALESCE упомянуто (наступал на это трижды).
+ code = "\n".join(line for line in blk.splitlines() if not line.strip().startswith("--"))
+ assert "COALESCE(" not in code, (
+ "средняя площадь по-прежнему COALESCE'ится в 0 при пустом окне:\n" + code[-300:]
+ )
+
+
+def test_schema_declares_avg_area_nullable() -> None:
+ """Контракт API: TopLayoutRow.avg_area_m2 допускает None."""
+ from app.schemas.parcel import TopLayoutRow
+
+ ann = TopLayoutRow.model_fields["avg_area_m2"].annotation
+ assert ann is not float, f"avg_area_m2 объявлен как {ann!r} — None ронял бы контракт"
+ TopLayoutRow.model_validate(
+ {
+ "room_bucket": "1",
+ "area_bin": "25-40",
+ "project_name": "x",
+ "signature": "s",
+ "competitor_obj_ids": [],
+ "competitor_count": 0,
+ "total_sold_in_window": 0,
+ "velocity_per_month": 0.0,
+ "avg_price_per_m2_rub": None,
+ "avg_area_m2": None,
+ "supply_units_in_radius": 0,
+ "sold_pct_of_supply": None,
+ "is_oversold": False,
+ "rank": 1,
+ }
+ )
+
+
+def test_row_assembly_keeps_none_not_zero() -> None:
+ """Сборка рядов: NULL из БД остаётся None, а не превращается в 0.0 / round(None).
+
+ На origin/main — `else 0.0`: пустое окно отдаёт 0 м². Проверяем по исходнику
+ двух мест сборки, потому что собрать полный ответ без БД нельзя.
+ """
+ from app.services.site_finder import best_layouts as m
+
+ src = inspect.getsource(m)
+ assert 'if r["avg_area_m2"] is not None else None' in src, "в сборке ряда None → 0.0"
+ assert 'round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None' in src, (
+ "round(row['avg_area_m2']) без проверки на None"
+ )
+
+
+def test_mix_weighted_area_excludes_rows_without_area() -> None:
+ """Контроль смысла: взвешенная площадь микса делится на сделки рядов С площадью.
+
+ Иначе ряд без площади (None) считался бы сделками «с площадью 0» и занижал среднее.
+ То же правило, что у цены в соседних строках.
+ """
+ from app.services.site_finder import best_layouts as m
+
+ src = inspect.getsource(m)
+ assert "rb_area_total_deals" in src, "нет отдельного знаменателя для площади"
+ assert re.search(r"rb_area_weighted\[rb\]\s*/\s*rb_area_total_deals\[rb\]", src), (
+ "площадь по-прежнему делится на все сделки (rb_deals), а не на ряды с площадью"
+ )
+
+
+def test_pdf_renders_dash_for_missing_area() -> None:
+ """PDF: строка с avg_area_m2=None печатает «—», а не падает на :.1f.
+
+ На origin/main `f"{r.avg_area_m2:.1f}"` на None бросает TypeError — это отсутствие
+ возможности, а не неверное значение; поэтому рядом контроль: число печатается как
+ раньше с одним знаком.
+ """
+ from app.schemas.parcel import TopLayoutRow
+ from app.services.exporters import layout_tz_pdf as pdf
+
+ def row(area):
+ return TopLayoutRow(
+ room_bucket="2",
+ area_bin="40-60",
+ project_name="ЖК",
+ signature="sig",
+ competitor_obj_ids=[1],
+ competitor_count=1,
+ total_sold_in_window=3,
+ velocity_per_month=1.5,
+ avg_price_per_m2_rub=150000.0,
+ avg_area_m2=area,
+ supply_units_in_radius=10,
+ sold_pct_of_supply=30.0,
+ is_oversold=False,
+ rank=1,
+ )
+
+ assert row(None).avg_area_m2 is None and row(38.5).avg_area_m2 == 38.5
+ src = inspect.getsource(pdf)
+ assert "_area_cell" in src, "нет ячейки площади с обработкой None"
+ # прямая проверка самой ячейки через исходник функции — она вложенная в рендер
+ assert 'return "— | "' in src
+ assert "{val:.1f} | " in src, "число перестало печататься с одним знаком"
diff --git a/backend/tests/test_2950_latest_image_revision_gate.py b/backend/tests/test_2950_latest_image_revision_gate.py
new file mode 100644
index 00000000..ad75bc28
--- /dev/null
+++ b/backend/tests/test_2950_latest_image_revision_gate.py
@@ -0,0 +1,145 @@
+"""Гард свежести :latest перед деплоем (#2950) — коды выхода по значению.
+
+Сценарий, ради которого гард существует: Forgejo отменил ещё не стартовавший deploy
+run'а A, run B (билды пропущены) катит :latest, а :latest несёт ревизию СТАРШЕ
+последнего коммита по компоненту — билд A не доехал. Гард обязан ждать и, не
+дождавшись, падать; а при ревизии не старше последнего коммита — пропускать.
+
+`docker` подменяется шимом на PATH: он печатает конфиг образа с меткой ревизии из
+переменной окружения FAKE_REV (пусто — метки нет; FAKE_DOCKER_FAIL=1 — registry не
+ответил). Временный git-репозиторий даёт настоящие коммиты для `git log`/merge-base.
+"""
+
+from __future__ import annotations
+
+import os
+import stat
+import subprocess
+from pathlib import Path
+
+import pytest
+
+КОРЕНЬ = Path(__file__).resolve().parents[2]
+СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
+
+_SHIM = """#!/usr/bin/env bash
+# подменный docker: только `buildx imagetools inspect
--format ...`
+if [ "${FAKE_DOCKER_FAIL:-}" = "1" ]; then echo "unauthorized" >&2; exit 1; fi
+FMT='{"linux/amd64": {"config": {"Labels": {"org.opencontainers.image.revision": "%s"}}}}'
+if [ -n "${FAKE_REV:-}" ]; then
+ printf "$FMT" "$FAKE_REV"
+else
+ printf '{"linux/amd64": {"config": {"Labels": {}}}}'
+fi
+"""
+
+
+def _git(repo: Path, *args: str) -> str:
+ return subprocess.run(
+ ["git", *args],
+ cwd=repo,
+ check=True,
+ capture_output=True,
+ text=True,
+ env={
+ **os.environ,
+ "GIT_AUTHOR_NAME": "t",
+ "GIT_AUTHOR_EMAIL": "t@t",
+ "GIT_COMMITTER_NAME": "t",
+ "GIT_COMMITTER_EMAIL": "t@t",
+ },
+ ).stdout.strip()
+
+
+def _commit(repo: Path, rel: str, msg: str) -> str:
+ p = repo / rel
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(msg, encoding="utf-8")
+ _git(repo, "add", rel)
+ _git(repo, "commit", "-q", "-m", msg)
+ return _git(repo, "rev-parse", "HEAD")
+
+
+@pytest.fixture()
+def repo(tmp_path: Path) -> dict[str, object]:
+ r = tmp_path / "repo"
+ r.mkdir()
+ _git(r, "init", "-q")
+ c1 = _commit(r, "backend/a.py", "c1 backend")
+ c2 = _commit(r, "frontend/b.ts", "c2 frontend")
+ c3 = _commit(r, "backend/c.py", "c3 backend")
+ c4 = _commit(r, "frontend/d.ts", "c4 frontend")
+ shim_dir = tmp_path / "bin"
+ shim_dir.mkdir()
+ shim = shim_dir / "docker"
+ shim.write_text(_SHIM, encoding="utf-8")
+ shim.chmod(shim.stat().st_mode | stat.S_IEXEC)
+ return {"path": r, "c1": c1, "c2": c2, "c3": c3, "c4": c4, "bin": shim_dir}
+
+
+def _run(
+ repo: dict[str, object], rev: str | None, *paths: str, fail: bool = False, timeout: str = "1"
+) -> subprocess.CompletedProcess[str]:
+ env = {
+ **os.environ,
+ "PATH": f"{repo['bin']}{os.pathsep}{os.environ['PATH']}",
+ "CHECK_LATEST_SLEEP_S": "0.2",
+ "FAKE_REV": rev or "",
+ "FAKE_DOCKER_FAIL": "1" if fail else "",
+ }
+ return subprocess.run(
+ [str(СКРИПТ), "ghcr.io/x/img", timeout, "--", *paths],
+ cwd=repo["path"],
+ env=env,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_fresh_latest_passes(repo: dict[str, object]) -> None:
+ """:latest собран из последнего коммита по backend (c3) → 0."""
+ r = _run(repo, str(repo["c3"]), "backend")
+ assert r.returncode == 0, r.stdout + r.stderr
+ assert "содержит последний коммит" in r.stdout
+
+
+def test_newer_revision_passes(repo: dict[str, object]) -> None:
+ """Ревизия новее последнего коммита по компоненту (dispatch собрал голову c4) → 0."""
+ r = _run(repo, str(repo["c4"]), "backend")
+ assert r.returncode == 0, r.stdout + r.stderr
+
+
+def test_stale_latest_fails_after_timeout(repo: dict[str, object]) -> None:
+ """Головной: :latest из c1, а по backend последний — c3 → ждёт, затем 1 с ::error."""
+ # timeout=3: дедлайн считается целыми секундами (`date +%s`), и при 1 с первый
+ # опрос может прийти уже за дедлайном — гард честно падает сразу, а тест ждёт
+ # строки ожидания. 3 с при паузе 0.2 с дают её гарантированно.
+ r = _run(repo, str(repo["c1"]), "backend", timeout="3")
+ assert r.returncode == 1, r.stdout + r.stderr
+ assert "::error::" in r.stderr and "отстаёт" in r.stderr
+ assert "жду билд предшественника" in r.stdout, "гард обязан ЖДАТЬ, а не падать сразу"
+
+
+def test_revision_from_other_component_does_not_cover(repo: dict[str, object]) -> None:
+ """Контроль путей: для frontend последний — c4; :latest из c3 (до c4) → 1."""
+ r = _run(repo, str(repo["c3"]), "frontend")
+ assert r.returncode == 1, r.stdout + r.stderr
+
+
+def test_missing_label_fails(repo: dict[str, object]) -> None:
+ """Без метки ревизии нельзя утверждать свежесть → 1, а не молчаливый пропуск."""
+ r = _run(repo, None, "backend")
+ assert r.returncode == 1, r.stdout + r.stderr
+ assert "метки нет" in r.stderr
+
+
+def test_registry_error_fails(repo: dict[str, object]) -> None:
+ """Registry не отвечает → 1 (не «проверить нельзя — значит можно»)."""
+ r = _run(repo, str(repo["c3"]), "backend", fail=True)
+ assert r.returncode == 1, r.stdout + r.stderr
+
+
+def test_usage_errors_are_distinct(repo: dict[str, object]) -> None:
+ """Ошибка вызова — код 2, чтобы её не приняли за «отстаёт»."""
+ r = subprocess.run([str(СКРИПТ), "img"], cwd=repo["path"], capture_output=True, text=True)
+ assert r.returncode == 2
diff --git a/backend/tests/test_audit_middleware.py b/backend/tests/test_audit_middleware.py
index c02e684f..e046d1a0 100644
--- a/backend/tests/test_audit_middleware.py
+++ b/backend/tests/test_audit_middleware.py
@@ -87,9 +87,10 @@ def test_classify_path_forecast() -> None:
def test_classify_path_forecast_export_not_confused_with_forecast() -> None:
"""forecast/export должен дать action='export', НЕ 'forecast'."""
- assert audit_mod.classify_path(
- "/api/v1/parcels/66:41:0204016:10/forecast/export"
- ) == ("export", "66:41:0204016:10")
+ assert audit_mod.classify_path("/api/v1/parcels/66:41:0204016:10/forecast/export") == (
+ "export",
+ "66:41:0204016:10",
+ )
def test_classify_path_unmatched_returns_none() -> None:
@@ -134,9 +135,10 @@ def test_classify_path_insight_nested_path_not_matched() -> None:
def test_classify_path_parcels_method_ignored() -> None:
"""Parcels-паттерны не зависят от method — analyze матчится при любом методе."""
- assert audit_mod.classify_path(
- "/api/v1/parcels/66:41:0204016:10/analyze", "GET"
- ) == ("analyze", "66:41:0204016:10")
+ assert audit_mod.classify_path("/api/v1/parcels/66:41:0204016:10/analyze", "GET") == (
+ "analyze",
+ "66:41:0204016:10",
+ )
# ---------------------------------------------------------------------------
diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py
index 54687e72..4725dc3a 100644
--- a/backend/tests/test_gas_grs_loader.py
+++ b/backend/tests/test_gas_grs_loader.py
@@ -167,8 +167,7 @@ def test_parse_no_table_returns_empty() -> None:
def test_parse_no_header_row_returns_empty() -> None:
"""Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive)."""
html = (
- "| Колонка А | Колонка Б |
"
- "| 1 | 2 |
"
+ ""
)
assert gg.parse_grs_table(html) == []
diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py
index bc5bd67c..f0dfed29 100644
--- a/backend/tests/test_gate_verdict.py
+++ b/backend/tests/test_gate_verdict.py
@@ -129,9 +129,7 @@ def test_residential_main_vri_zh5_with_mkd_true():
def test_residential_main_vri_overrides_subcategory():
"""main_vri ИЖС перебивает даже raw_props subcategory=2 (authoritative wins)."""
- assert (
- is_residential_zone("Ж-2", None, {"subcategory": 2}, main_vri=_VRI_IZHS_ONLY) is False
- )
+ assert is_residential_zone("Ж-2", None, {"subcategory": 2}, main_vri=_VRI_IZHS_ONLY) is False
def test_residential_empty_main_vri_falls_back_to_regex():
diff --git a/backend/tests/test_poi_score.py b/backend/tests/test_poi_score.py
index ae5aeae3..97cfcffb 100644
--- a/backend/tests/test_poi_score.py
+++ b/backend/tests/test_poi_score.py
@@ -28,9 +28,9 @@ def test_category_weight_metro():
metro_w = _category_weight("metro_stop")
for cat in CATEGORY_WEIGHTS:
if cat != "metro_stop" and cat != "default":
- assert metro_w >= _category_weight(
- cat
- ), f"metro_stop weight {metro_w} должен быть >= {cat} weight {_category_weight(cat)}"
+ assert metro_w >= _category_weight(cat), (
+ f"metro_stop weight {metro_w} должен быть >= {cat} weight {_category_weight(cat)}"
+ )
def test_category_weight_unknown_returns_default():
@@ -130,9 +130,9 @@ def test_metro_beats_school_at_equal_distance():
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
- assert (
- result.top_poi[0].category == "metro_stop"
- ), "При равном расстоянии метро (category_weight=6.0) должно быть выше школы (5.0)"
+ assert result.top_poi[0].category == "metro_stop", (
+ "При равном расстоянии метро (category_weight=6.0) должно быть выше школы (5.0)"
+ )
def test_metro_first_when_close():
@@ -145,7 +145,7 @@ def test_metro_first_when_close():
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
assert result.top_poi[0].category == "metro_stop", (
"Метро (weight=6.0) в 50м должно быть впереди школы (weight=5.0) в 300м — "
- f"metro_weight={(1/(50+100))*6:.5f} vs school_weight={(1/(300+100))*5:.5f}"
+ f"metro_weight={(1 / (50 + 100)) * 6:.5f} vs school_weight={(1 / (300 + 100)) * 5:.5f}"
)
@@ -194,9 +194,9 @@ def test_score_contribution_in_range():
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
for item in result.top_poi:
- assert (
- 0.0 <= item.score_contribution <= 100.0
- ), f"{item.category} score_contribution={item.score_contribution} вне 0..100"
+ assert 0.0 <= item.score_contribution <= 100.0, (
+ f"{item.category} score_contribution={item.score_contribution} вне 0..100"
+ )
def test_metro_at_zero_distance_scores_high():
@@ -205,9 +205,9 @@ def test_metro_at_zero_distance_scores_high():
rows = [_make_row("Метро у дома", "metro_stop", 0.0)]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
- assert (
- result.poi_weighted_score >= 19.0
- ), f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}"
+ assert result.poi_weighted_score >= 19.0, (
+ f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}"
+ )
def test_score_contribution_sum_equals_total():
diff --git a/backend/tests/test_quarter_dump_lookup.py b/backend/tests/test_quarter_dump_lookup.py
index 15d1fdc1..bf58373b 100644
--- a/backend/tests/test_quarter_dump_lookup.py
+++ b/backend/tests/test_quarter_dump_lookup.py
@@ -399,7 +399,7 @@ def _make_zouit_row(
def test_cad_zouit_overlaps_includes_geom_geojson() -> None:
"""#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON)."""
geojson_str = (
- '{"type":"Polygon","coordinates":' "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}"
+ '{"type":"Polygon","coordinates":[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}'
)
rows = [
_make_zouit_row(
@@ -568,9 +568,9 @@ def test_empty_result_reports_unknown_risk_coverage() -> None:
"""
result = make_empty_result()
- assert (
- "risks_count" in result["nspd_dump"]
- ), "признак покрытия не отдаётся — фронт не сможет отличить «чисто» от «не спрашивали»"
+ assert "risks_count" in result["nspd_dump"], (
+ "признак покрытия не отдаётся — фронт не сможет отличить «чисто» от «не спрашивали»"
+ )
assert result["nspd_dump"]["risks_count"] is None
diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py
index 19ff11d1..6abc2b9c 100644
--- a/backend/tests/test_sentry_init.py
+++ b/backend/tests/test_sentry_init.py
@@ -476,9 +476,9 @@ def test_local_variables_never_reach_transport(module: str) -> None:
assert probe["counts"]["exception"] == 1
assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало"
- assert (
- probe["markers"]["local_var"] not in payload
- ), f"{module}: значение локальной переменной ушло в мониторинг"
+ assert probe["markers"]["local_var"] not in payload, (
+ f"{module}: значение локальной переменной ушло в мониторинг"
+ )
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
@@ -493,7 +493,7 @@ def test_scrub_failure_does_not_spawn_second_event(module: str) -> None:
"""
probe = json.loads(_probe(module))
- assert (
- probe["scrub_handler_entries"] == 1
- ), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг"
+ assert probe["scrub_handler_entries"] == 1, (
+ "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг"
+ )
assert probe["counts"]["scrub_failure"] == 1
diff --git a/backend/tests/workers/tasks/test_gas_outlet_sync.py b/backend/tests/workers/tasks/test_gas_outlet_sync.py
index 4cd5c056..2da47ad4 100644
--- a/backend/tests/workers/tasks/test_gas_outlet_sync.py
+++ b/backend/tests/workers/tasks/test_gas_outlet_sync.py
@@ -27,9 +27,9 @@ def test_beat_schedule_includes_gas_outlet_sync_weekly_tuesday() -> None:
with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
schedule = beat_schedule.build_beat_schedule()
- assert (
- "gas-outlet-points-sync-weekly" in schedule
- ), f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}"
+ assert "gas-outlet-points-sync-weekly" in schedule, (
+ f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}"
+ )
entry = schedule["gas-outlet-points-sync-weekly"]
assert entry["task"] == "tasks.connection_capacity_sync.sync_gas_outlet_points"
# crontab day_of_week = вторник (2).
diff --git a/backend/tests/workers/tasks/test_okn_objects_sync.py b/backend/tests/workers/tasks/test_okn_objects_sync.py
index 466ed6dd..5984ca3d 100644
--- a/backend/tests/workers/tasks/test_okn_objects_sync.py
+++ b/backend/tests/workers/tasks/test_okn_objects_sync.py
@@ -86,9 +86,9 @@ def test_extract_coords_inverted_would_fail_bbox() -> None:
inverted_lon = feature["geometry"]["coordinates"][0] # 56.83 → не в bbox [58–63]
inverted_lat = feature["geometry"]["coordinates"][1] # 60.61 → не в bbox [55–58.5]
inverted_inside = (55.0 <= inverted_lat <= 58.5) and (58.0 <= inverted_lon <= 63.0)
- assert (
- not inverted_inside
- ), "Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap"
+ assert not inverted_inside, (
+ "Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap"
+ )
# Проверяем что реальный _extract_coords возвращает КОРРЕКТНЫЕ (lon, lat)
result = _extract_coords(feature)
@@ -141,9 +141,9 @@ def test_address_passes_include_ekb_districts() -> None:
"Верх-Исетский",
"Железнодорожный",
}
- assert expected_districts <= set(
- _ADDRESS_PASSES
- ), f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}"
+ assert expected_districts <= set(_ADDRESS_PASSES), (
+ f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}"
+ )
def test_address_passes_order_city_before_district() -> None:
diff --git a/backend/tests/workers/test_2464_objective_zombie_sweep.py b/backend/tests/workers/test_2464_objective_zombie_sweep.py
index 25ce10d3..1291ea35 100644
--- a/backend/tests/workers/test_2464_objective_zombie_sweep.py
+++ b/backend/tests/workers/test_2464_objective_zombie_sweep.py
@@ -119,17 +119,17 @@ def test_finished_at_is_last_sign_of_life_not_now() -> None:
"""
sql = _objective_update(_run())
assert sql is not None
- assert (
- "COALESCE(heartbeat_at, started_at)" in sql
- ), f"finished_at ставится не по последнему признаку жизни:\n{sql}"
- assert (
- "finished_at = NOW()" not in sql
- ), f"finished_at = NOW() — время завершения соврано:\n{sql}"
+ assert "COALESCE(heartbeat_at, started_at)" in sql, (
+ f"finished_at ставится не по последнему признаку жизни:\n{sql}"
+ )
+ assert "finished_at = NOW()" not in sql, (
+ f"finished_at = NOW() — время завершения соврано:\n{sql}"
+ )
def test_kn_sweep_still_runs() -> None:
"""Контроль от регресса: добавление Объектива не сломало подметание kn."""
db = _run()
- assert any(
- "kn_scrape_runs" in s for s in db.sql
- ), f"подметание kn_scrape_runs пропало; выполнено: {db.sql}"
+ assert any("kn_scrape_runs" in s for s in db.sql), (
+ f"подметание kn_scrape_runs пропало; выполнено: {db.sql}"
+ )
diff --git a/backend/tests/workers/test_nspd_geo.py b/backend/tests/workers/test_nspd_geo.py
index 8ee58bfb..77e3381a 100644
--- a/backend/tests/workers/test_nspd_geo.py
+++ b/backend/tests/workers/test_nspd_geo.py
@@ -569,9 +569,9 @@ def test_soft_time_limit_exceeded_flushes_heartbeat(monkeypatch: Any) -> None:
for sql, params in captured
if "heartbeat_at = NOW()" in sql and "targets_done" in sql
]
- assert (
- heartbeat_updates
- ), "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise"
+ assert heartbeat_updates, (
+ "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise"
+ )
def test_soft_time_limit_exceeded_does_not_overwrite_paused_with_failed(
diff --git a/backend/tests/workers/test_scrape_freshness_check.py b/backend/tests/workers/test_scrape_freshness_check.py
index 4e05af2e..b014daf9 100644
--- a/backend/tests/workers/test_scrape_freshness_check.py
+++ b/backend/tests/workers/test_scrape_freshness_check.py
@@ -24,8 +24,11 @@ def _source(
def _payload(sources: list[dict[str, Any]], overall: str) -> dict[str, Any]:
- return {"generated_at": "2026-06-25T09:00:00+00:00", "overall_status": overall,
- "sources": sources}
+ return {
+ "generated_at": "2026-06-25T09:00:00+00:00",
+ "overall_status": overall,
+ "sources": sources,
+ }
def _run_with(payload_or_exc: Any) -> tuple[dict[str, Any], MagicMock]:
diff --git a/backend/uv.lock b/backend/uv.lock
index 1b93d369..edc9d2f7 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -877,7 +877,7 @@ dev = [
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
{ name = "pytest-cov", specifier = ">=5.0.0" },
- { name = "ruff", specifier = ">=0.5.0" },
+ { name = "ruff", specifier = "==0.15.20" },
{ name = "types-redis", specifier = ">=4.6.0" },
]
@@ -2515,27 +2515,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.15.12"
+version = "0.15.20"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
- { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
- { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
- { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
- { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
- { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
- { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
- { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
- { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
- { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
- { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
- { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
- { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
- { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
- { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
- { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
- { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
+ { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
+ { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
+ { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
+ { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
+ { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
+ { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
+ { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
+ { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
+ { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
]
[[package]]
diff --git a/data/sql/02_load_all_quarters.sh b/data/sql/02_load_all_quarters.sh
index 69326c0c..51f63383 100644
--- a/data/sql/02_load_all_quarters.sh
+++ b/data/sql/02_load_all_quarters.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# Loads all 7 quarters of dataset_СДЕЛКИ into rosreestr_deals via staging.
+# Loads quarters of dataset_СДЕЛКИ into rosreestr_deals via staging (see JOBS below).
# Q3 2024 uses ';' separator, all later quarters use '~'.
#
# Usage:
@@ -42,6 +42,12 @@ declare -a JOBS=(
"2025Q3:2025-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_3.csv:~"
"2025Q4:2025-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_4.csv:~"
"2026Q1:2026-01-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv:~"
+ # #2998: партиции под Q2–Q4 2026 — миграция 193. Загрузчик скипает квартал, для
+ # которого нет CSV в data/raw/, поэтому строки на ещё не опубликованные кварталы
+ # безопасны: они ждут файла, а не падают.
+ "2026Q2:2026-04-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv:~"
+ "2026Q3:2026-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_3.csv:~"
+ "2026Q4:2026-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_4.csv:~"
)
# Возможные имена zip-ов в data/raw/ (можно класть как есть, скрипт распакует).
@@ -82,7 +88,12 @@ resolve_csv() {
for job in "${JOBS[@]}"; do
IFS=':' read -r SRC_Q PERIOD FILE_CANDIDATES SEP <<< "$job"
- FILE=$(resolve_csv "$FILE_CANDIDATES" "$SRC_Q")
+ # `|| true` обязателен (#2998): resolve_csv сигналит «файла нет» кодом 1, а скрипт
+ # идёт под `set -e` — без этого первый же квартал без CSV в data/raw/ молча ронял
+ # ВЕСЬ прогон до строки SKIP, и до реально лежащего файла (например, одного Q2 на
+ # VPS) загрузчик не доходил никогда. Проверено на проде 21.08: с одним Q2-файлом в
+ # каталоге скрипт завершался с rc=0, не напечатав ни одной строки.
+ FILE=$(resolve_csv "$FILE_CANDIDATES" "$SRC_Q" || true)
if [[ -z "$FILE" ]]; then
echo "=== $SRC_Q SKIP — нет CSV в data/raw/ (искал: $FILE_CANDIDATES; sdelki_${SRC_Q,,}.csv.zip)"
continue
diff --git a/data/sql/193_partitions_rosreestr_2026_q2_q4.sql b/data/sql/193_partitions_rosreestr_2026_q2_q4.sql
new file mode 100644
index 00000000..d0a26ef8
--- /dev/null
+++ b/data/sql/193_partitions_rosreestr_2026_q2_q4.sql
@@ -0,0 +1,29 @@
+-- Партиции rosreestr_deals на Q2–Q4 2026 (#2998).
+--
+-- Почему это понадобилось. Схема 01_schema_rosreestr_deals.sql создавала партиции
+-- списком «2024 Q3 — 2026 Q1» — дальше этого горизонта таблица ничего не знала, и
+-- никакой механизм новые партиции не создаёт. Q2 2026 опубликован Росреестром
+-- 10.07, poll заметил его 14.08, а загрузка упёрлась бы в
+-- ERROR: no partition of relation "rosreestr_deals" found for row
+-- DETAIL: Partition key of the failing row contains (period_start_date) = (2026-04-01)
+-- — ровно это и случилось при первой попытке загрузить 21.08. То есть даже оператор,
+-- запустив 02_load_all_quarters.sh по подсказке poll, получил бы отказ.
+--
+-- Создаём с запасом на три квартала вперёд, чтобы каждый новый квартал не начинался
+-- с этой же ошибки. Q1 2027 и далее — следующая такая же миграция (или автосоздание,
+-- если до него дойдут руки; пока его нет нигде — проверено grep'ом по app/ и data/sql/).
+--
+-- Индексы НЕ перечисляем: партиции наследуют четыре индекса родителя автоматически
+-- (проверено по rosreestr_deals_2026q1: pkey(id, period_start_date), doc_type partial,
+-- realestate_type_code, (region_code, quarter_cad_number)).
+--
+-- Range FROM inclusive, TO exclusive — [start_q, start_next_q). Идемпотентно.
+
+SET LOCAL lock_timeout = '5s';
+
+CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q2 PARTITION OF rosreestr_deals
+ FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
+CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q3 PARTITION OF rosreestr_deals
+ FOR VALUES FROM ('2026-07-01') TO ('2026-10-01');
+CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q4 PARTITION OF rosreestr_deals
+ FOR VALUES FROM ('2026-10-01') TO ('2027-01-01');
diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx
index c42d70fa..620bd97d 100644
--- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx
+++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx
@@ -348,7 +348,9 @@ function TopLayoutsTable({
fontVariantNumeric: "tabular-nums",
}}
>
- {row.avg_area_m2.toFixed(1)}
+ {row.avg_area_m2 != null
+ ? row.avg_area_m2.toFixed(1)
+ : "—"}
100% (incompatible time windows)
diff --git a/ops/backup.sh b/ops/backup.sh
index cd0d49f5..997c9558 100755
--- a/ops/backup.sh
+++ b/ops/backup.sh
@@ -71,7 +71,7 @@ verify_dump_integrity() {
log "ERROR: ${label} failed gzip integrity check -> $file" >&2
return 1
fi
- if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF "$trailer"; then
+ if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then
log "ERROR: ${label} missing trailer '${trailer}' -> $file (truncated dump?)" >&2
return 1
fi
@@ -168,7 +168,7 @@ if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" &&
-v "$LOCAL_BACKUP_DIR":/backup:ro \
amazon/aws-cli:latest \
--endpoint-url "$S3_ENDPOINT" \
- s3 cp "/backup/$(basename "$f")" "s3://${S3_BUCKET}/"
+ s3 cp --no-progress "/backup/$(basename "$f")" "s3://${S3_BUCKET}/"
done
log "S3 upload OK"
else
diff --git a/scripts/check-latest-image-revision.sh b/scripts/check-latest-image-revision.sh
new file mode 100755
index 00000000..d69b2084
--- /dev/null
+++ b/scripts/check-latest-image-revision.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# Гард свежести образа :latest перед деплоем (#2950).
+#
+# ЗАЧЕМ. Деплой катит IMAGE_TAG=latest. Forgejo отменяет ещё не стартовавший
+# `deploy` предыдущего run'а той же concurrency-группы, когда приходит новый push
+# (cancel-in-progress: false объявлен, но на pending-job не действует — 21.08.2026
+# 10:35:13 «Has been cancelled» через 4 с после старта следующего run'а). Следующий
+# run — например ops-only, с пропущенными билдами — катит :latest как есть. 21.08
+# прод получил новый код только потому, что билды предшественника успели за 70 с
+# до pull'а; иначе голова main была бы зелёной, а прод — на старом образе.
+#
+# ЧТО ПРОВЕРЯЕТ. Образ :latest в registry несёт метку org.opencontainers.image.revision
+# (ставится build-push-action в deploy.yml / deploy-tradein.yml). Гард требует, чтобы
+# эта ревизия СОДЕРЖАЛА последний коммит, трогавший пути компонента (git log по
+# checkout'у run'а): то есть :latest не старше того, что обязано было собраться.
+# Ревизия новее — нормально (workflow_dispatch собирает всё на голове).
+# Пока условие не выполнено — ждёт (билд предшественника ещё пушит) до таймаута,
+# затем падает громко: лучше красный деплой, чем тихий откат.
+#
+# Использование: check-latest-image-revision.sh -- ...
+# CHECK_LATEST_SLEEP_S — пауза между опросами (по умолчанию 20; тесты ставят меньше).
+# Коды: 0 — свежий; 1 — отстаёт/метки нет/registry не ответил до таймаута; 2 — usage.
+set -euo pipefail
+
+IMAGE="${1:-}"; TIMEOUT="${2:-}"
+if [ -z "$IMAGE" ] || [ -z "$TIMEOUT" ] || [ "${3:-}" != "--" ] || [ $# -lt 4 ]; then
+ echo "usage: $0 -- ..." >&2; exit 2
+fi
+shift 3
+SLEEP="${CHECK_LATEST_SLEEP_S:-20}"
+
+LAST="$(git log -1 --format=%H -- "$@" || true)"
+if [ -z "$LAST" ]; then
+ echo "::error::$IMAGE — нет ни одного коммита по путям: $*" >&2; exit 2
+fi
+
+read_revision() {
+ # Печатает ревизию из метки образа или пустую строку. .Image для multi-arch —
+ # карта по платформам, для single-arch — сам конфиг; разбираем оба вида.
+ docker buildx imagetools inspect "$IMAGE:latest" --format '{{json .Image}}' 2>/dev/null \
+ | python3 -c '
+import json, sys
+try:
+ d = json.load(sys.stdin)
+except Exception:
+ sys.exit(0)
+cands = [d] if isinstance(d, dict) and "config" in d else [v for v in d.values() if isinstance(v, dict)]
+for c in cands:
+ rev = ((c.get("config") or {}).get("Labels") or {}).get("org.opencontainers.image.revision")
+ if rev:
+ print(rev); break
+' || true
+}
+
+deadline=$(( $(date +%s) + TIMEOUT ))
+while :; do
+ REV="$(read_revision)"
+ if [ -n "$REV" ] && git cat-file -e "${REV}^{commit}" 2>/dev/null \
+ && git merge-base --is-ancestor "$LAST" "$REV"; then
+ echo "✓ $IMAGE:latest — ревизия ${REV:0:10} содержит последний коммит по компоненту ${LAST:0:10}"
+ exit 0
+ fi
+ now=$(date +%s)
+ if [ "$now" -ge "$deadline" ]; then
+ echo "::error::$IMAGE:latest несёт ревизию '${REV:-<метки нет / registry не ответил>}', а последний коммит, трогавший [$*] — ${LAST:0:10}. :latest отстаёт (билд предшественника не доехал или отменён) — деплой остановлен, иначе прод получил бы старый образ при зелёной голове (#2950). Если билд того коммита упал — чини его или запусти workflow_dispatch полного деплоя." >&2
+ exit 1
+ fi
+ echo "… $IMAGE:latest — ревизия '${REV:-<метки нет>}' ещё не содержит ${LAST:0:10}; жду билд предшественника ($(( deadline - now )) с)"
+ sleep "$SLEEP"
+done
diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py
index aa251ffe..cfb63fd3 100644
--- a/tradein-mvp/backend/app/api/v1/admin.py
+++ b/tradein-mvp/backend/app/api/v1/admin.py
@@ -1288,6 +1288,15 @@ class CianFullLoadRequest(BaseModel):
"cian_full_load прогона для resume. Без этого поля — full walk с нуля."
),
)
+ secondary_only: bool = Field(
+ default=True,
+ description=(
+ "True (дефолт, поведение без изменений) — новостройки отбрасываются "
+ "после разбора. False — сохраняются вместе со вторичкой. Дополнительных "
+ "HTTP-запросов не стоит: страницы с ними всё равно скачиваются, "
+ "totalOffers бакета считает обе категории (#1781)."
+ ),
+ )
@router.post("/scrape/cian-full-load", response_model=CitySweepStartResponse)
@@ -1326,6 +1335,7 @@ async def start_cian_full_load(
enrich_detail=payload.enrich_detail,
detail_top_n=payload.detail_top_n,
resume_run_id=payload.resume_run_id,
+ secondary_only=payload.secondary_only,
)
except Exception:
logger.exception("cian-full-load background task run_id=%d crashed", run_id)
@@ -2654,6 +2664,113 @@ async def update_scraper_pacing(
) from exc
+# ── Дома, уехавшие от собственных объявлений (GET /scraper/mislocated-houses) ─
+#
+# Отдельная ручка ПО ТРЕБОВАНИЮ, а не поле в /scraper/data-quality (#2996).
+# Замер прода 21.08.2026: запрос стоит ~445 мс на тёплом кэше, тогда как обе
+# существующие выборки data-quality вместе — 27 мс, а фронт опрашивает её каждые
+# 120 с. Класть сюда 17-кратное удорожание ради числа, которое меняется раз в
+# месяцы, неправильно; поэтому проверка живёт отдельно и вызывается руками.
+#
+# Что ловим. Прод: 23 дома стоят вне Свердловской области, к ним привязано 591
+# объявление; адреса екатеринбургские, координаты — Варшава, Белград, Таллин,
+# Владивосток, Ижевск. 22 из 23 несут в raw_payload след разового бэкфилла
+# `full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без резолва
+# города. Живой путь чист: 0 записей вне региона в geocode_cache из 10 448.
+#
+# 17 из 22 при этом помечены геокодером `precision: "exact"` — точность отвечает
+# на «нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть
+# не может. Поэтому проверяется ПРИНАДЛЕЖНОСТЬ, а не «точность».
+#
+# Инвариант нарочно НЕ географический: «дом рядом со своими объявлениями», а не
+# «дом внутри рамки области». Рамка сломалась бы при выходе в Москву (#2996) —
+# этот инвариант расширение переживёт. Он же строго сильнее: ловит 25 домов
+# против 23 у рамки, и оба лишних при проверке оказались настоящими
+# («Ул. Белинского/Фурманова» за 207 км, 34 объявления).
+#
+# Порог не подобран на глаз. Замер по 9 052 домам с объявлениями:
+# ближе 1 км 8 914 (98.5 %) · 1-5 км 64 · 5-25 км 40
+# 25-100 км 9 — настоящие пригороды (Сарапулка, Кедровка, Чусовское
+# Озеро, Ревда, Первоуральск), самый дальний 46.7 км
+# дальше 100 км 25 — ближайший 119.2 км, максимум 5 079 км
+# Между 46.7 и 119.2 км нет НИ ОДНОГО дома: 100 лежит в середине пустого
+# промежутка, а не на краю распределения.
+_MISLOCATED_KM = 100.0
+
+_MISLOCATED_SQL = text("""
+ WITH pairs AS (
+ SELECT h.id AS house_id,
+ h.address,
+ h.source,
+ ST_Distance(
+ h.geom::geography,
+ ST_SetSRID(ST_MakePoint(
+ percentile_disc(0.5) WITHIN GROUP (ORDER BY l.lon),
+ percentile_disc(0.5) WITHIN GROUP (ORDER BY l.lat)
+ ), 4326)::geography
+ ) / 1000.0 AS km,
+ count(*) AS listings_count
+ FROM houses h
+ JOIN listings l ON l.house_id_fk = h.id
+ WHERE h.geom IS NOT NULL AND l.lat IS NOT NULL AND l.lon IS NOT NULL
+ GROUP BY h.id, h.geom, h.address, h.source
+ )
+ SELECT house_id, address, source, round(km::numeric, 1) AS km, listings_count
+ FROM pairs
+ WHERE km >= CAST(:km AS double precision)
+ ORDER BY km DESC
+""")
+
+
+class MislocatedHouse(BaseModel):
+ """Дом, стоящий дальше порога от медианы координат своих объявлений."""
+
+ house_id: int
+ address: str | None
+ source: str | None
+ km: float
+ listings_count: int
+
+
+class MislocatedHousesResponse(BaseModel):
+ threshold_km: float
+ houses_count: int
+ listings_count: int
+ houses: list[MislocatedHouse]
+
+
+@router.get("/scraper/mislocated-houses", response_model=MislocatedHousesResponse)
+def get_mislocated_houses(
+ db: Annotated[Session, Depends(get_db)],
+) -> MislocatedHousesResponse:
+ """Дома, уехавшие от собственных объявлений дальше порога (#2996).
+
+ Ноль — ожидаемое значение. Ненулевое означает, что чьи-то координаты пришли
+ из геокода в чужой город: живой путь резолвит город, но разовые скрипты
+ ходят в API напрямую и эту защиту обходят.
+
+ Запрос тяжёлый (~445 мс на проде) — поэтому ручка отдельная и вызывается
+ руками, а не подмешана в опрашиваемую каждые 2 минуты /scraper/data-quality.
+ """
+ rows = db.execute(_MISLOCATED_SQL, {"km": _MISLOCATED_KM}).mappings().all()
+ houses = [
+ MislocatedHouse(
+ house_id=int(r["house_id"]),
+ address=r["address"],
+ source=r["source"],
+ km=float(r["km"]),
+ listings_count=int(r["listings_count"]),
+ )
+ for r in rows
+ ]
+ return MislocatedHousesResponse(
+ threshold_km=_MISLOCATED_KM,
+ houses_count=len(houses),
+ listings_count=sum(h.listings_count for h in houses),
+ houses=houses,
+ )
+
+
# ── Data-quality coverage (GET /scraper/data-quality) ────────────────────────
diff --git a/tradein-mvp/backend/app/api/v1/search.py b/tradein-mvp/backend/app/api/v1/search.py
index 9580c2af..26d34ba3 100644
--- a/tradein-mvp/backend/app/api/v1/search.py
+++ b/tradein-mvp/backend/app/api/v1/search.py
@@ -47,7 +47,9 @@ async def search(
if cached is not None:
logger.info(
"search cache HIT key=%s page=%d size=%d",
- cache_key[:24], params.page, params.page_size,
+ cache_key[:24],
+ params.page,
+ params.page_size,
)
resp = SearchResponse.model_validate(cached)
resp.cache_hit = True
@@ -73,6 +75,10 @@ async def search(
await cache.set(cache_key, response.model_dump(mode="json"), ttl=cache.TTL_SEARCH)
logger.info(
"search MISS page=%d size=%d total=%d items=%d elapsed=%.1fms",
- params.page, params.page_size, total, len(items), elapsed_ms,
+ params.page,
+ params.page_size,
+ total,
+ len(items),
+ elapsed_ms,
)
return response
diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py
index cac916e8..42067d74 100644
--- a/tradein-mvp/backend/app/core/config.py
+++ b/tradein-mvp/backend/app/core/config.py
@@ -562,6 +562,33 @@ class Settings(BaseSettings):
# sparse-coverage выше).
estimate_kitchen_ceiling_signal_min_n: int = 5
#
+ # ── #2936: штраф за НЕИЗВЕСТНЫЙ year_built / house_type (default OFF) ──────
+ # В SQL-формуле relevance_score кандидат без year_built получает штраф 0 —
+ # столько же, сколько точное попадание в год, и ЛУЧШЕ, чем кандидат с
+ # известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие
+ # данных выигрывает у знания, и это возвышает источник с худшей полнотой:
+ # замер 19.08 — avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 %
+ # доли в пуле, yandex (год 99 %) — 9 % при 37 %.
+ #
+ # Влияние на цену измерено дважды (#2936): систематического смещения НЕТ
+ # (медиана сдвига 0.29 %), но у 39 % целей медиана сопоставимых уходит >5 %
+ # в зависимости от того, как оценено незнание — шум от полноты сбора.
+ # Чего замеры НЕ говорят: какой из двух отборов ТОЧНЕЕ. Поэтому флаг, а не
+ # правка формулы: включать — только после бэктеста на сделках (MAPE), как
+ # требует тот же контракт, что у kitchen/ceiling (#2012) выше.
+ #
+ # Механизм: НЕ наказание, но и не награда — кандидат с NULL получает
+ # МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов, у которых он
+ # известен (self-referential, как kitchen/ceiling). Пул = кандидаты тира
+ # после SQL (до 300), ДО сортировки и LIMIT 50. Известное ограничение:
+ # per-address cap (rn_addr ≤ MAX_ANALOGS_PER_ADDRESS) в SQL уже отработал
+ # без штрафа — Python-слой переранжирует то, что SQL оставил, как и #2012.
+ # Sparse-safe: если известных значений меньше min_n — сигнал пропускается
+ # целиком (медиана по трём строкам — не «типичный штраф», а шум).
+ # ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED.
+ estimate_unknown_attr_penalty_enabled: bool = False
+ estimate_unknown_attr_penalty_min_n: int = 5
+ #
# is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
# корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
# #1186 (`listing_segment`) в _COMMON_WHERE. Апартаменты — юридически иной
diff --git a/tradein-mvp/backend/app/services/cache.py b/tradein-mvp/backend/app/services/cache.py
index 7de08ac0..80b2465e 100644
--- a/tradein-mvp/backend/app/services/cache.py
+++ b/tradein-mvp/backend/app/services/cache.py
@@ -48,9 +48,7 @@ class SearchCache:
async def set(self, key: str, value: dict[str, Any], ttl: int) -> None:
try:
- await self._client.set(
- key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl
- )
+ await self._client.set(key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl)
except Exception as e:
logger.warning("redis SET failed key=%s: %s", key[:24], e)
diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
index 5d42d82f..73880ec1 100644
--- a/tradein-mvp/backend/app/services/estimator.py
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -1091,7 +1091,7 @@ def _save_yandex_history_items(
if skipped_area > 0:
logger.info(
- "yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None" " (addr=%r)",
+ "yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None (addr=%r)",
skipped_area,
len(result.history_items),
result.address,
@@ -3328,8 +3328,7 @@ def _price_from_inputs(
)
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
logger.info(
- "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f)"
- " for %s",
+ "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
same_quarter_ratio,
settings.estimate_quarter_match_skip_ratio,
target_quarter,
@@ -5473,6 +5472,54 @@ def _adjust_relevance_by_pool_deviation(
c["relevance_score"] = (c.get("relevance_score") or 0.0) + penalty
+def _apply_unknown_attr_penalty(
+ candidates: list[dict[str, Any]],
+ *,
+ target_year: int | None,
+ target_house_type: str | None,
+ min_n: int,
+) -> None:
+ """#2936: неизвестный year_built / house_type получает МЕДИАННЫЙ по пулу штраф.
+
+ SQL-формула relevance_score даёт кандидату без year_built штраф 0 — столько
+ же, сколько точному попаданию в год, и лучше, чем известному году с разницей
+ 24 (2.0). Отсутствие данных выигрывает у знания и возвышает источник с худшей
+ полнотой (см. комментарий к estimate_unknown_attr_penalty_enabled в config).
+
+ Штраф за NULL = медиана штрафов того же признака среди кандидатов пула, у
+ которых он известен: не наказание и не награда. Считается ТОЛЬКО по тем
+ членам пула, где SQL реально начислил штраф, — то есть воспроизводит ровно
+ SQL-термы abs(year_built - target)/12.0 и 1.5-за-несовпадение, без своего
+ понимания «насколько далеко». Если target неизвестен, SQL штраф не начисляет
+ никому — и здесь тоже (симметрия: CASE WHEN :target_... IS NOT NULL).
+
+ Sparse-safe: известных значений меньше ``min_n`` → сигнал пропущен целиком.
+ Мутирует relevance_score IN PLACE — та же конвенция, что у kitchen/ceiling.
+ """
+ if target_year is not None:
+ known = [
+ abs(int(c["year_built"]) - int(target_year)) / 12.0
+ for c in candidates
+ if c.get("year_built") is not None
+ ]
+ if len(known) >= min_n:
+ median_pen = statistics.median(known)
+ for c in candidates:
+ if c.get("year_built") is None:
+ c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen
+ if target_house_type is not None:
+ known_ht = [
+ 1.5 if str(c["house_type"]) != str(target_house_type) else 0.0
+ for c in candidates
+ if c.get("house_type") is not None
+ ]
+ if len(known_ht) >= min_n:
+ median_pen = statistics.median(known_ht)
+ for c in candidates:
+ if c.get("house_type") is None:
+ c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen
+
+
def _apply_kitchen_ceiling_signal(candidates: list[dict[str, Any]]) -> None:
"""#2012: apply the kitchen_area_m2 / ceiling_height_m comp-scoring signals.
@@ -5511,7 +5558,16 @@ _ANALOG_SELECT_COLS = """
-- Tier H/W применяют их (см. _apply_kitchen_ceiling_signal) — Tier S
-- (same building) не трогают, симметрично house_type/year_built, которые
-- тоже не участвуют в Tier S relevance (там фиксированный 0.0).
- kitchen_area_m2, ceiling_height_m
+ kitchen_area_m2, ceiling_height_m,
+ -- #2936: year_built / house_type ВЫБИРАЮТСЯ, а не только участвуют в SQL-CASE.
+ -- До этого SQL считал по ним relevance_score, но в словарь кандидата колонки не
+ -- попадали: Python-слой видел None у ВСЕХ и штрафовать «неизвестное» не мог по
+ -- построению — прогон A/B с флагом дал 0 из 5 915 сменённых слотов при 55.7 %
+ -- avito в топ-50. Замечено probe-логом в оверлее: pool=30 null_year=30
+ -- null_type=30. Для путей, которые эти ключи не читают, добавление байт-
+ -- идентично; фикстура бэктеста реплеится с замороженными listings и гейта не
+ -- трогает.
+ year_built, house_type
"""
_COMMON_WHERE = """
@@ -5879,7 +5935,10 @@ def _fetch_analogs(
listing_date, days_on_market, photo_urls,
scraped_at, distance_m, relevance_score,
building_cadastral_number,
- kitchen_area_m2, ceiling_height_m
+ kitchen_area_m2, ceiling_height_m,
+ -- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует
+ -- внутренний, без этого Python-слой не видит год и тип дома.
+ year_built, house_type
FROM base
WHERE rn_addr <= :max_per_addr
{dup_filter}
@@ -5912,6 +5971,13 @@ def _fetch_analogs(
tier_h = [dict(r) for r in tier_h_rows]
_apply_kitchen_ceiling_signal(tier_h)
+ if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF
+ _apply_unknown_attr_penalty(
+ tier_h,
+ target_year=year_built,
+ target_house_type=house_type,
+ min_n=settings.estimate_unknown_attr_penalty_min_n,
+ )
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
if len(tier_h) >= 5:
logger.info(
@@ -5945,6 +6011,9 @@ def _fetch_analogs(
scraped_at,
building_cadastral_number,
kitchen_area_m2, ceiling_height_m,
+ -- #2936: Tier W строит base явным списком (не _ANALOG_SELECT_COLS),
+ -- поэтому год и тип дома добавляются здесь отдельно.
+ year_built, house_type,
id,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
AS distance_m,
@@ -6039,6 +6108,9 @@ def _fetch_analogs(
scraped_at,
building_cadastral_number,
kitchen_area_m2, ceiling_height_m,
+ -- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует
+ -- внутренний, без этого Python-слой не видит год и тип дома.
+ year_built, house_type,
distance_m,
relevance_score
FROM base
@@ -6076,6 +6148,13 @@ def _fetch_analogs(
candidates: list[dict[str, Any]] = [dict(r) for r in tier_w_rows]
_apply_kitchen_ceiling_signal(candidates)
+ if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF
+ _apply_unknown_attr_penalty(
+ candidates,
+ target_year=year_built,
+ target_house_type=house_type,
+ min_n=settings.estimate_unknown_attr_penalty_min_n,
+ )
candidates.sort(key=lambda r: r.get("relevance_score") or 0.0)
logger.info("analogs tier=W radius=%dm → %d candidates", radius_m, len(candidates))
return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W"
@@ -6900,7 +6979,7 @@ def _enforce_zero_analog_low(
"""
if n_analogs == 0 and confidence != "low":
logger.warning(
- "ghost_anchor_guard #1871: forcing confidence 'low' (was %s, " "median=%s, sources=%s)",
+ "ghost_anchor_guard #1871: forcing confidence 'low' (was %s, median=%s, sources=%s)",
confidence,
median_price,
sources_used,
diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index ef896560..ca2aff59 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -1665,9 +1665,12 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
| Количество сделок по аналогичном объектам |
{_mono(f"{n_deals} шт.")} |
- {f'''| Сделки |
+ {
+ f''' | Сделки |
{_mono(deals_as_of)} | '''
- if deals_as_of else ""}
+ if deals_as_of
+ else ""
+ }
Источники данных
diff --git a/tradein-mvp/backend/app/services/house_metadata.py b/tradein-mvp/backend/app/services/house_metadata.py
index ff2d1f87..c69844c8 100644
--- a/tradein-mvp/backend/app/services/house_metadata.py
+++ b/tradein-mvp/backend/app/services/house_metadata.py
@@ -27,9 +27,9 @@ from app.core.config import settings
logger = logging.getLogger(__name__)
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
-_CACHE_RADIUS_M = 40 # дом в пределах 40 м от точки — считаем тем же
-_OVERPASS_RADIUS_M = 25 # ищем здание в 25 м от геокодированной точки
-_CAD_RADIUS_M = 60 # радиус поиска здания в cad_buildings (#393)
+_CACHE_RADIUS_M = 40 # дом в пределах 40 м от точки — считаем тем же
+_OVERPASS_RADIUS_M = 25 # ищем здание в 25 м от геокодированной точки
+_CAD_RADIUS_M = 60 # радиус поиска здания в cad_buildings (#393)
@dataclass(frozen=True, slots=True)
@@ -38,15 +38,18 @@ class HouseMetadata:
lon: float
year_built: int | None
total_floors: int | None
- house_type: str | None # panel / brick / monolith / monolith_brick / other
+ house_type: str | None # panel / brick / monolith / monolith_brick / other
total_units: int | None
- source: str # 'osm' / 'cache'
+ source: str # 'osm' / 'cache'
# ── Парсинг тегов OSM ────────────────────────────────────────────────────────
_YEAR_KEYS = (
- "start_date", "construction_date", "building:year",
- "year_of_construction", "building:start_date",
+ "start_date",
+ "construction_date",
+ "building:year",
+ "year_of_construction",
+ "building:start_date",
)
@@ -110,9 +113,12 @@ def _cache_get(db: Session, lat: float, lon: float) -> HouseMetadata | None:
if row is None:
return None
return HouseMetadata(
- lat=row.lat, lon=row.lon,
- year_built=row.year_built, total_floors=row.total_floors,
- house_type=row.house_type, total_units=row.total_units,
+ lat=row.lat,
+ lon=row.lon,
+ year_built=row.year_built,
+ total_floors=row.total_floors,
+ house_type=row.house_type,
+ total_units=row.total_units,
source="cache",
)
@@ -129,9 +135,12 @@ def _cache_put(db: Session, meta: HouseMetadata, raw: dict) -> None:
"""
),
{
- "lat": meta.lat, "lon": meta.lon,
- "year": meta.year_built, "floors": meta.total_floors,
- "htype": meta.house_type, "units": meta.total_units,
+ "lat": meta.lat,
+ "lon": meta.lon,
+ "year": meta.year_built,
+ "floors": meta.total_floors,
+ "htype": meta.house_type,
+ "units": meta.total_units,
"raw": json.dumps(raw, ensure_ascii=False),
},
)
@@ -184,10 +193,11 @@ def _cad_buildings_get(db: Session, lat: float, lon: float) -> HouseMetadata | N
if row is None:
return None
return HouseMetadata(
- lat=lat, lon=lon,
+ lat=lat,
+ lon=lon,
year_built=row.year_built,
total_floors=row.floors,
- house_type=None, # purpose не маппится в panel/brick
+ house_type=None, # purpose не маппится в panel/brick
total_units=None,
source="cadastre",
)
@@ -219,7 +229,8 @@ async def _overpass_lookup(lat: float, lon: float) -> tuple[HouseMetadata, dict]
best = min(elements, key=_dist2)
tags = best.get("tags", {})
meta = HouseMetadata(
- lat=lat, lon=lon,
+ lat=lat,
+ lon=lon,
year_built=_parse_year(tags),
total_floors=_parse_int(tags, "building:levels"),
house_type=_parse_house_type(tags),
@@ -242,7 +253,10 @@ async def get_house_metadata(lat: float, lon: float, db: Session) -> HouseMetada
if cad is not None:
logger.info(
"house_metadata: cad_buildings (%.5f, %.5f) → year=%s floors=%s",
- lat, lon, cad.year_built, cad.total_floors,
+ lat,
+ lon,
+ cad.year_built,
+ cad.total_floors,
)
return cad
@@ -264,13 +278,15 @@ async def get_house_metadata(lat: float, lon: float, db: Session) -> HouseMetada
_cache_put(db, meta, raw)
logger.info(
"house_metadata OSM: (%.5f, %.5f) → year=%s floors=%s type=%s",
- lat, lon, meta.year_built, meta.total_floors, meta.house_type,
+ lat,
+ lon,
+ meta.year_built,
+ meta.total_floors,
+ meta.house_type,
)
return meta
except Exception:
- logger.warning(
- "house_metadata enrichment failed at (%.5f, %.5f)", lat, lon, exc_info=True
- )
+ logger.warning("house_metadata enrichment failed at (%.5f, %.5f)", lat, lon, exc_info=True)
try:
db.rollback()
except Exception:
diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py
index 1effa723..531cb1fc 100644
--- a/tradein-mvp/backend/app/services/matching/houses.py
+++ b/tradein-mvp/backend/app/services/matching/houses.py
@@ -191,8 +191,7 @@ def match_or_create_house(
row = (
db.execute(
text(
- "SELECT house_id FROM house_sources "
- "WHERE ext_source = :s AND ext_id = :e LIMIT 1"
+ "SELECT house_id FROM house_sources WHERE ext_source = :s AND ext_id = :e LIMIT 1"
),
{"s": ext_source, "e": str(ext_id)},
)
diff --git a/tradein-mvp/backend/app/services/matching/listings.py b/tradein-mvp/backend/app/services/matching/listings.py
index ceaae20c..398bbefa 100644
--- a/tradein-mvp/backend/app/services/matching/listings.py
+++ b/tradein-mvp/backend/app/services/matching/listings.py
@@ -287,6 +287,26 @@ def _upsert_listing_source(
floor = COALESCE(EXCLUDED.floor, listing_sources.floor),
rooms_count = COALESCE(EXCLUDED.rooms_count, listing_sources.rooms_count),
raw_payload = COALESCE(EXCLUDED.raw_payload, listing_sources.raw_payload)
+ -- #2992: симметричный гейт к listings-апсерту (scraper_kit/base.py). Вторая
+ -- по нагрузке таблица базы: 10.27 млн апдейтов на 102 тыс. строк, HOT 0.10 %.
+ -- Апдейт идёт, если итоговое значение хоть одной колонки отличается ИЛИ
+ -- last_seen_at ещё не сегодняшний по МСК. Тот же МСК-критерий, что у listings,
+ -- — иначе разъехались бы listings.last_seen_at и listing_sources.last_seen_at,
+ -- равные сегодня у 100 % пар (см. докстринг выше).
+ WHERE (
+ listing_sources.confidence, listing_sources.price_rub,
+ listing_sources.area_m2, listing_sources.floor,
+ listing_sources.rooms_count, listing_sources.raw_payload
+ ) IS DISTINCT FROM (
+ GREATEST(EXCLUDED.confidence, listing_sources.confidence),
+ COALESCE(EXCLUDED.price_rub, listing_sources.price_rub),
+ COALESCE(EXCLUDED.area_m2, listing_sources.area_m2),
+ COALESCE(EXCLUDED.floor, listing_sources.floor),
+ COALESCE(EXCLUDED.rooms_count, listing_sources.rooms_count),
+ COALESCE(EXCLUDED.raw_payload, listing_sources.raw_payload)
+ )
+ OR (listing_sources.last_seen_at AT TIME ZONE 'Europe/Moscow')::date
+ IS DISTINCT FROM (statement_timestamp() AT TIME ZONE 'Europe/Moscow')::date
"""),
{
"lid": listing_id,
diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
index 00d91cf9..affa088b 100644
--- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
+++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py
@@ -125,8 +125,7 @@ def _alert_domclick_cookies(db: Session, run_id: int) -> None:
detail = "кук DomClick нет в БД"
elif expires_at <= now:
detail = (
- f"куки DomClick протухли {expires_at:%Y-%m-%d} "
- f"({(now - expires_at).days} дн. назад)"
+ f"куки DomClick протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)"
)
else:
detail = "куки DomClick помечены невалидными (last_invalid_at)"
@@ -292,7 +291,7 @@ async def run_domclick_detail_backfill(
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
if shutdown_requested():
logger.info(
- "domclick_detail_backfill: run_id=%d SIGTERM-drain — stopping at " "#%d/%d",
+ "domclick_detail_backfill: run_id=%d SIGTERM-drain — stopping at #%d/%d",
run_id,
idx,
len(snapshot),
@@ -331,8 +330,7 @@ async def run_domclick_detail_backfill(
consecutive_blocks += 1
counters.blocked += 1
logger.warning(
- "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d "
- "(consecutive=%d): %s",
+ "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
run_id,
idx + 1,
len(snapshot),
diff --git a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py
index ba1c8d73..f54537c8 100644
--- a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py
+++ b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py
@@ -129,10 +129,30 @@ _SELECT_PENDING_HOUSES = """
SELECT 1 FROM market.yandex_jk_enrichment e WHERE e.ext_id = hs.ext_id
)
)
- ORDER BY h.id, hs.ext_id NULLS LAST
+ -- #2924: дом, у которого резолв уже не удался, не берётся повторно раньше чем
+ -- через :retry_days (образец — geocode_tried_at, задача geocode_missing).
+ AND (
+ CAST(:force AS boolean) = TRUE
+ OR h.yandex_jk_resolve_tried_at IS NULL
+ OR h.yandex_jk_resolve_tried_at
+ < NOW() - make_interval(days => CAST(:retry_days AS integer))
+ )
+ -- #2924: неудавшиеся — в КОНЕЦ очереди, а не в начало: иначе пять первых по h.id
+ -- занимали все пять слотов каждую неделю.
+ ORDER BY h.id, h.yandex_jk_resolve_tried_at NULLS FIRST, hs.ext_id NULLS LAST
LIMIT :lim
"""
+# #2924: сколько дней не повторять резолв дома, у которого он не удался. Семь — как у
+# geocode_tried_at: разметка Яндекса могла поменяться, дом мог появиться в SERP позже.
+RESOLVE_RETRY_DAYS = 7
+
+_MARK_RESOLVE_TRIED = """
+ UPDATE houses
+ SET yandex_jk_resolve_tried_at = NOW()
+ WHERE id = CAST(:hid AS bigint)
+"""
+
_UPDATE_SLUG = """
UPDATE houses
SET yandex_jk_slug = CAST(:slug AS text)
@@ -251,7 +271,14 @@ async def enrich_yandex_newbuilding_sweep(
result.fetchable = sizing["fetchable"]
result.pending = sizing["pending"]
- rows = db.execute(text(_SELECT_PENDING_HOUSES), {"force": force, "lim": limit}).mappings().all()
+ rows = (
+ db.execute(
+ text(_SELECT_PENDING_HOUSES),
+ {"force": force, "lim": limit, "retry_days": RESOLVE_RETRY_DAYS},
+ )
+ .mappings()
+ .all()
+ )
logger.info(
"yandex-nb-sweep: total=%d fetchable=%d pending=%d; "
@@ -321,6 +348,7 @@ async def enrich_yandex_newbuilding_sweep(
if not resolved:
logger.warning("slug unresolved house_id=%s ext_id=%s — skip", house_id, ext_id)
result.failed_resolve += 1
+ _mark_resolve_tried(db, house_id)
continue
# Persist slug под SAVEPOINT
@@ -335,6 +363,7 @@ async def enrich_yandex_newbuilding_sweep(
"persist slug failed house_id=%s ext_id=%s: %s", house_id, ext_id, exc
)
result.failed_resolve += 1
+ _mark_resolve_tried(db, house_id)
continue
jk_slug = resolved
@@ -448,6 +477,22 @@ async def enrich_yandex_newbuilding_sweep(
return result
+def _mark_resolve_tried(db: Session, house_id: int) -> None:
+ """#2924: зафиксировать неудачную попытку резолва — иначе дом снова первый в очереди.
+
+ Отдельный SAVEPOINT: отметка не должна ронять прогон, а её потеря — не фатальна
+ (дом просто вернётся в очередь на следующем прогоне, как и раньше).
+ """
+ sp = db.begin_nested()
+ try:
+ db.execute(text(_MARK_RESOLVE_TRIED), {"hid": house_id})
+ sp.commit()
+ db.commit()
+ except Exception as exc:
+ sp.rollback()
+ logger.warning("mark resolve tried failed house_id=%s: %s", house_id, exc)
+
+
async def _sleep_with_jitter(delay: float, idx: int, total: int, *, force: bool = False) -> None:
"""Polite anti-bot sleep с ±20% jitter.
diff --git a/tradein-mvp/backend/data/sql/269_houses_yandex_jk_resolve_tried_at.sql b/tradein-mvp/backend/data/sql/269_houses_yandex_jk_resolve_tried_at.sql
new file mode 100644
index 00000000..b990d581
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/269_houses_yandex_jk_resolve_tried_at.sql
@@ -0,0 +1,21 @@
+-- #2924: неудача резолва yandex_jk_slug ничего не помечала — выборка yandex_newbuilding_sweep
+-- каждый прогон брала те же дома в том же порядке (ORDER BY h.id), и пять первых
+-- занимали все пять слотов (limit 5) неделю за неделей. Замер 19.08: 397 ожидающих,
+-- первые пять по h.id — 6706, 6741, 6757, 6774, 6785 — одни и те же в каждом прогоне.
+--
+-- Маркер «пробовали резолвить» — по образцу listings.geocode_tried_at (миграция 005):
+-- выборка повторяет попытку не раньше чем через N дней и ставит неудавшиеся в конец
+-- очереди (ORDER BY tried_at NULLS FIRST), а не в начало.
+--
+-- Идемпотентно. Колонка nullable, без дефолта — существующие строки = «не пробовали».
+-- lock_timeout (#2752): ALTER TABLE берёт ACCESS EXCLUSIVE на houses — при живом
+-- писателе (свип) ждал бы его бесконечно и копил очередь за собой; 5с — и миграция
+-- падает честно, деплой перезапускается, а не вешает базу.
+-- SET LOCAL действует только внутри транзакции — вне BEGIN он молча ничего не делает
+-- (гейт check-migration-lock-timeout это проверяет; на этом я и споткнулся).
+BEGIN;
+SET LOCAL lock_timeout = '5s';
+ALTER TABLE houses ADD COLUMN IF NOT EXISTS yandex_jk_resolve_tried_at timestamptz;
+COMMENT ON COLUMN houses.yandex_jk_resolve_tried_at IS
+ '#2924: когда yandex_newbuilding_sweep последний раз пытался разрезолвить yandex_jk_slug и не смог; NULL = не пробовали или резолв удался';
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/270_houses_geog_gist_idx.sql b/tradein-mvp/backend/data/sql/270_houses_geog_gist_idx.sql
new file mode 100644
index 00000000..0b869139
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/270_houses_geog_gist_idx.sql
@@ -0,0 +1,27 @@
+-- 270_houses_geog_gist_idx.sql
+-- #2997: у houses нет индекса под geography. Весь матчинг ходит через
+-- ST_DWithin(geom::geography, …) (app/services/matching/houses.py:300, :352, :554),
+-- а живой houses_geom_idx построен по geometry. Планировщик берёт его только как
+-- bitmap по `geom IS NOT NULL` и дальше фильтрует все ~9.4k строк с координатами.
+-- Замер на проде 21.08.2026 (EXPLAIN ANALYZE, точка в центре ЕКБ, 150 м):
+-- Bitmap Index Scan on houses_geom_idx, Index Cond: (geom IS NOT NULL)
+-- Rows Removed by Filter: 4718 на воркер, Execution Time: 70.7 ms
+-- pg_stat_statements за 13 ч: 66 вызовов × 39.9 мс и 5 × 53.3 мс на этот класс запросов.
+--
+-- Функциональный GiST по (geom::geography) совпадает с выражением в запросах
+-- (h.geom::geography ≡ (geom)::geography), и ST_DWithin на geography начинает
+-- идти по индексу. houses_geom_idx НЕ трогаем: его дроп — отдельное решение по
+-- эпику #2989 (см. #2997, там же — почему дубль на listings тоже пока не снят).
+--
+-- CONCURRENTLY и без BEGIN/COMMIT — по образцу 225: раннер деплоя исполняет файл
+-- через psql autocommit по операторам; CIC нельзя внутри транзакции, а
+-- lock_timeout ему вреден (гейт scripts/check-migration-lock-timeout.py
+-- CONCURRENTLY-формы не требует и не терпит). Таблица маленькая (10 111 строк),
+-- но её читает матчинг каждого объявления — ACCESS EXCLUSIVE даже на секунду не
+-- нужен. Если CIC оборвётся, останется INVALID-индекс — его ловит шаг проверки
+-- невалидных индексов в deploy-tradein.yml, и деплой краснеет, а не молчит.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS houses_geog_gist_idx
+ ON houses USING gist ((geom::geography));
+
+COMMENT ON INDEX houses_geog_gist_idx IS
+ '#2997: GiST по (geom::geography) под ST_DWithin(geom::geography, …) матчинга (matching/houses.py). До него планировщик шёл через houses_geom_idx как bitmap по geom IS NOT NULL и фильтровал все строки (~70 мс на запрос, прод 21.08.2026).';
diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml
index 29d7c22c..ee679c0b 100644
--- a/tradein-mvp/backend/pyproject.toml
+++ b/tradein-mvp/backend/pyproject.toml
@@ -40,7 +40,7 @@ dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"pytest-timeout>=2.3.0",
- "ruff>=0.5.0",
+ "ruff==0.15.20",
]
[tool.pytest.ini_options]
diff --git a/tradein-mvp/backend/scripts/backfill_houses_dadata.py b/tradein-mvp/backend/scripts/backfill_houses_dadata.py
index ec7dd2be..cabbd302 100644
--- a/tradein-mvp/backend/scripts/backfill_houses_dadata.py
+++ b/tradein-mvp/backend/scripts/backfill_houses_dadata.py
@@ -158,14 +158,11 @@ class Stats:
# для `--priority listings`. Correlated EXISTS бьёт по partial-индексу
# `listings_house_id_fk_idx (house_id_fk) WHERE house_id_fk IS NOT NULL`.
_HAS_ACTIVE_LISTINGS_EXPR = (
- "EXISTS (SELECT 1 FROM listings l "
- "WHERE l.house_id_fk = houses.id AND l.is_active)"
+ "EXISTS (SELECT 1 FROM listings l WHERE l.house_id_fk = houses.id AND l.is_active)"
)
-def _select_candidates(
- db: Session, *, priority: str, limit: int
-) -> list[tuple[HouseRow, str]]:
+def _select_candidates(db: Session, *, priority: str, limit: int) -> list[tuple[HouseRow, str]]:
"""Возвращает список (HouseRow, priority_bucket) для enrichment.
priority:
@@ -477,9 +474,7 @@ async def _run_backfill(
db.rollback()
stats.failed += 1
stats.bump(priority, "failed")
- logger.warning(
- "db_write failed for house_id=%s: %s", row.id, exc
- )
+ logger.warning("db_write failed for house_id=%s: %s", row.id, exc)
if i % _LOG_EVERY == 0:
logger.info(
@@ -568,9 +563,7 @@ async def main(argv: list[str] | None = None) -> int:
db = SessionLocal()
try:
candidates = _select_candidates(db, priority=args.priority, limit=args.limit)
- logger.info(
- "loaded candidates: %d (priority=%s)", len(candidates), args.priority
- )
+ logger.info("loaded candidates: %d (priority=%s)", len(candidates), args.priority)
if not candidates:
logger.info(
"nothing to do — нет rows с dadata_enriched_at IS NULL для priority=%s",
@@ -578,9 +571,7 @@ async def main(argv: list[str] | None = None) -> int:
)
return 0
- stats = await _run_backfill(
- db, candidates, batch=args.batch, dry_run=args.dry_run
- )
+ stats = await _run_backfill(db, candidates, batch=args.batch, dry_run=args.dry_run)
logger.info(
"done: batch=%s processed=%d enriched=%d no_change=%d "
diff --git a/tradein-mvp/backend/scripts/geocode_deals_from_houses.py b/tradein-mvp/backend/scripts/geocode_deals_from_houses.py
index fc1acc15..d924e67d 100644
--- a/tradein-mvp/backend/scripts/geocode_deals_from_houses.py
+++ b/tradein-mvp/backend/scripts/geocode_deals_from_houses.py
@@ -293,17 +293,21 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]:
would require duplicating the regex logic in plpgsql and risk drift.
8,600 rows is trivial to hold in memory.
"""
- rows = db.execute(
- text(
- "SELECT address, lat, lon "
- "FROM houses "
- "WHERE geom IS NOT NULL "
- " AND lat IS NOT NULL "
- " AND lon IS NOT NULL "
- " AND address IS NOT NULL "
- " AND length(trim(address)) > 0"
+ rows = (
+ db.execute(
+ text(
+ "SELECT address, lat, lon "
+ "FROM houses "
+ "WHERE geom IS NOT NULL "
+ " AND lat IS NOT NULL "
+ " AND lon IS NOT NULL "
+ " AND address IS NOT NULL "
+ " AND length(trim(address)) > 0"
+ )
)
- ).mappings().all()
+ .mappings()
+ .all()
+ )
# street_key → running [lat_sum, lon_sum, n]
acc: dict[str, list[float]] = {}
@@ -328,18 +332,22 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]:
Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful
UPDATE sets lat NOT NULL, dropping the row out on the next run.
"""
- rows = db.execute(
- text(
- "SELECT id, address "
- "FROM deals "
- "WHERE lat IS NULL "
- " AND address IS NOT NULL "
- " AND length(trim(address)) > 0 "
- "ORDER BY id "
- "LIMIT CAST(:lim AS int)"
- ),
- {"lim": limit},
- ).mappings().all()
+ rows = (
+ db.execute(
+ text(
+ "SELECT id, address "
+ "FROM deals "
+ "WHERE lat IS NULL "
+ " AND address IS NOT NULL "
+ " AND length(trim(address)) > 0 "
+ "ORDER BY id "
+ "LIMIT CAST(:lim AS int)"
+ ),
+ {"lim": limit},
+ )
+ .mappings()
+ .all()
+ )
return [DealRow(id=r["id"], address=r["address"]) for r in rows]
@@ -415,9 +423,7 @@ def _run_backfill(
else:
try:
with db.begin_nested():
- _update_deal_coords(
- db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon
- )
+ _update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon)
# Per-row commit so resume picks up exactly where we crashed.
db.commit()
stats.geocoded += 1
@@ -470,9 +476,7 @@ def _report_dry_run(
logger.info("─" * 60)
logger.info("DRY-RUN SUMMARY (no DB writes)")
logger.info("distinct streets with a house centroid: %d", distinct_streets)
- logger.info(
- "deals scanned this run (lat IS NULL, capped by --limit): %d", scanned
- )
+ logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned)
logger.info("deals matched to a centroid: %d", matched)
logger.info("deals with no street match: %d", stats.no_street_match)
logger.info("match rate on scanned slice: %.1f%%", match_rate * 100.0)
@@ -550,9 +554,7 @@ def main(argv: list[str] | None = None) -> int:
centroids = _build_centroid_map(db)
logger.info("built centroid map: %d distinct streets", len(centroids))
if not centroids:
- logger.warning(
- "no house centroids — houses table has no geocoded rows; nothing to do"
- )
+ logger.warning("no house centroids — houses table has no geocoded rows; nothing to do")
return 0
deals = _select_deals_without_coords(db, args.limit)
@@ -561,9 +563,7 @@ def main(argv: list[str] | None = None) -> int:
logger.info("nothing to do — no deals with lat IS NULL and an address")
return 0
- stats = _run_backfill(
- db, deals, centroids, batch=args.batch, dry_run=args.dry_run
- )
+ stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run)
if args.dry_run:
total_null = _count_deals_null(db)
diff --git a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py
index c6876d83..8bdeda9a 100644
--- a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py
+++ b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py
@@ -461,8 +461,7 @@ def _maybe_log_progress(i: int, groups: list[AddressGroup], batch: str, stats: S
"""Emit a progress line every `_LOG_EVERY` distinct addresses."""
if i % _LOG_EVERY == 0:
logger.info(
- "batch=%s progress %d/%d geocoded=%d failed=%d deals_updated=%d "
- "cache=(hit=%d miss=%d)",
+ "batch=%s progress %d/%d geocoded=%d failed=%d deals_updated=%d cache=(hit=%d miss=%d)",
batch,
i,
len(groups),
diff --git a/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py b/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py
index ea4711bf..a1633511 100644
--- a/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py
+++ b/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py
@@ -251,8 +251,12 @@ def test_update_house_enriched_passes_full_payload():
"""UPDATE houses содержит lat/lon/cadnum/fias/qc-codes и dadata_enriched_at = NOW()."""
db = MagicMock()
res = _make_dadata_result(
- qc_geo=0, qc_house=2, lat=56.838, lon=60.586,
- cadnum="66:41:0704045:350", fias="fias-uuid-here",
+ qc_geo=0,
+ qc_house=2,
+ lat=56.838,
+ lon=60.586,
+ cadnum="66:41:0704045:350",
+ fias="fias-uuid-here",
)
_update_house_enriched(db, house_id=42, result=res)
@@ -304,9 +308,7 @@ async def test_run_backfill_qc_geo_0_writes_enriched_update():
"scripts.backfill_houses_dadata.clean_address",
new=AsyncMock(return_value=fake),
):
- stats = await _run_backfill(
- db, [(row, "coords")], batch="b1", dry_run=False
- )
+ stats = await _run_backfill(db, [(row, "coords")], batch="b1", dry_run=False)
assert stats.enriched == 1
assert stats.no_change == 0
@@ -338,9 +340,7 @@ async def test_run_backfill_qc_geo_3_records_attempt_only():
"scripts.backfill_houses_dadata.clean_address",
new=AsyncMock(return_value=fake),
):
- stats = await _run_backfill(
- db, [(row, "coords")], batch="b2", dry_run=False
- )
+ stats = await _run_backfill(db, [(row, "coords")], batch="b2", dry_run=False)
assert stats.enriched == 0
assert stats.no_change == 1
@@ -366,9 +366,7 @@ async def test_run_backfill_dadata_returns_none_records_attempt():
"scripts.backfill_houses_dadata.clean_address",
new=AsyncMock(return_value=None),
):
- stats = await _run_backfill(
- db, [(row, "coords")], batch="b3", dry_run=False
- )
+ stats = await _run_backfill(db, [(row, "coords")], batch="b3", dry_run=False)
assert stats.no_change == 1
assert stats.enriched == 0
@@ -425,9 +423,7 @@ async def test_run_backfill_dry_run_skips_db_writes():
"scripts.backfill_houses_dadata.clean_address",
new=AsyncMock(return_value=fake),
):
- stats = await _run_backfill(
- db, [(row, "coords")], batch="dry", dry_run=True
- )
+ stats = await _run_backfill(db, [(row, "coords")], batch="dry", dry_run=True)
assert stats.processed == 1
assert stats.enriched == 1
diff --git a/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py b/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py
index b94e1202..a7c05e8e 100644
--- a/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py
+++ b/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py
@@ -105,10 +105,7 @@ def test_street_key_three_spec_variants_collapse_to_same_key():
def test_street_key_strips_region_and_house_number():
- assert (
- _street_key("Свердловская обл., Екатеринбург, ул. Большакова, 17")
- == "большакова"
- )
+ assert _street_key("Свердловская обл., Екатеринбург, ул. Большакова, 17") == "большакова"
def test_street_key_strips_district_marker():
@@ -139,10 +136,7 @@ def test_street_key_does_not_eat_name_starting_like_type_token():
def test_street_key_strips_korpus_and_kv_suffix():
assert _street_key("Екатеринбург, ул. Крауля, 48, корп. 2") == "крауля"
- assert (
- _street_key("РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12")
- == "ленина"
- )
+ assert _street_key("РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12") == "ленина"
def test_street_key_does_not_eat_names_starting_like_apt_suffix():
diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py
index cee70c8c..9fa62103 100644
--- a/tradein-mvp/backend/tests/services/test_location_index.py
+++ b/tradein-mvp/backend/tests/services/test_location_index.py
@@ -173,9 +173,9 @@ def test_freshness_window_is_the_estimator_constant_not_a_copy() -> None:
from app.services import estimator
- assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource(
- lc
- ), "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator"
+ assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource(lc), (
+ "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator"
+ )
assert lc.LISTINGS_FRESH_DAYS == estimator.LISTINGS_FRESH_DAYS
diff --git a/tradein-mvp/backend/tests/services/test_proxy_rotation.py b/tradein-mvp/backend/tests/services/test_proxy_rotation.py
index a311b7c9..b4fb5b0b 100644
--- a/tradein-mvp/backend/tests/services/test_proxy_rotation.py
+++ b/tradein-mvp/backend/tests/services/test_proxy_rotation.py
@@ -538,9 +538,9 @@ async def test_token_never_appears_in_log_messages_or_sentry_text(
await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type]
for record in caplog.records:
- assert (
- SECRET_TOKEN not in record.getMessage()
- ), f"scenario={name}: token leaked into log message args"
+ assert SECRET_TOKEN not in record.getMessage(), (
+ f"scenario={name}: token leaked into log message args"
+ )
assert sentry_texts, "expected at least one Sentry capture (401 scenario)"
assert all(SECRET_TOKEN not in text for text in sentry_texts)
diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt
index ee3c6e89..4883fa72 100644
--- a/tradein-mvp/backend/tests/skip_allowlist.txt
+++ b/tradein-mvp/backend/tests/skip_allowlist.txt
@@ -93,3 +93,15 @@ tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_a
# `days_on_market <= :max_age_days` встречается в SQL дважды). На мок-лэйне
# (deploy-tradein.yml, DSN-заглушка) реальной БД нет — self-skip.
tests/test_coverage_probe_endpoint.py::test_max_age_outlier_excluded_from_median_live
+
+# #2992 — гейт неизменной строки в апсертах listings / listing_sources. Шесть live-тестов
+# идут через реальный save_listings / upsert_listing_source на живом Postgres и меряют,
+# переписана ли строка, по ctid (save_listings коммитит — транзакционные счётчики
+# бесполезны). Без БД — skip; в CI Trade-In идут на postgres-сервисе (#2745). Свои
+# строки t2992-* тесты удаляют явно в finally.
+tests/test_2992_upsert_unchanged_gate.py::test_unchanged_rescrape_same_day_does_not_update_the_row
+tests/test_2992_upsert_unchanged_gate.py::test_changed_price_still_updates
+tests/test_2992_upsert_unchanged_gate.py::test_coalesce_backfill_still_updates
+tests/test_2992_upsert_unchanged_gate.py::test_next_day_rescrape_updates_even_if_unchanged
+tests/test_2992_upsert_unchanged_gate.py::test_skipped_row_still_yields_listing_id_for_downstream
+tests/test_2992_upsert_unchanged_gate.py::test_listing_sources_unchanged_rescrape_same_day_does_not_update
diff --git a/tradein-mvp/backend/tests/support/identity_modes.py b/tradein-mvp/backend/tests/support/identity_modes.py
index 73c5672a..66d885ec 100644
--- a/tradein-mvp/backend/tests/support/identity_modes.py
+++ b/tradein-mvp/backend/tests/support/identity_modes.py
@@ -111,8 +111,7 @@ def assert_reads_access_state(sql: str, names: SqlNames) -> None:
expected = f"{names.access_state_column} AS access_state"
if expected not in sql:
raise AssertionError(
- f"запрос к реестру не читает колонку состояния текущего режима "
- f"({expected!r}): {sql!r}"
+ f"запрос к реестру не читает колонку состояния текущего режима ({expected!r}): {sql!r}"
)
diff --git a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py
index 0e786ffb..fe362774 100644
--- a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py
+++ b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py
@@ -680,9 +680,9 @@ def test_estimator_fetch_analogs_includes_avito() -> None:
from app.services import estimator
source = inspect.getsource(estimator._fetch_analogs)
- assert (
- "source <> 'avito'" not in source
- ), "Avito exclusion должен быть удалён из estimator._fetch_analogs"
+ assert "source <> 'avito'" not in source, (
+ "Avito exclusion должен быть удалён из estimator._fetch_analogs"
+ )
# ── Admin endpoint smoke (schema-only, no real DB) ────────────────────────────
diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py
index 2e59eb54..ff51444b 100644
--- a/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py
+++ b/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py
@@ -38,9 +38,9 @@ def test_extract_address_standard_title() -> None:
)
addr = _extract_address_from_title(html)
assert addr is not None, "Should extract address from standard title"
- assert _RE_HAS_HOUSE_NUMBER.search(
- addr
- ), f"Extracted address should contain house number: {addr!r}"
+ assert _RE_HAS_HOUSE_NUMBER.search(addr), (
+ f"Extracted address should contain house number: {addr!r}"
+ )
assert "Екатеринбург" in addr
assert "Горького" in addr
assert "36" in addr
@@ -54,9 +54,9 @@ def test_extract_address_with_zhk_prefix() -> None:
)
addr = _extract_address_from_title(html)
assert addr is not None, "Should extract address when ЖК prefix is present"
- assert _RE_HAS_HOUSE_NUMBER.search(
- addr
- ), f"Extracted address should contain house number: {addr!r}"
+ assert _RE_HAS_HOUSE_NUMBER.search(addr), (
+ f"Extracted address should contain house number: {addr!r}"
+ )
assert "Екатеринбург" in addr
@@ -97,7 +97,7 @@ def test_extract_address_beryozovsky_with_zhk() -> None:
def test_extract_address_ekb_no_zhk_still_matches() -> None:
"""EKB variant from docstring (no ЖК prefix) still matches after city-agnostic change."""
- html = "Продажа квартиры — Екатеринбург, улица Горького, 36" " — id 7654321"
+ html = "Продажа квартиры — Екатеринбург, улица Горького, 36 — id 7654321"
addr = _extract_address_from_title(html)
assert addr is not None, "EKB no-ЖК title should still match"
assert _RE_HAS_HOUSE_NUMBER.search(addr), f"Should contain house number: {addr!r}"
@@ -116,9 +116,9 @@ def test_extract_address_no_house_number_guard() -> None:
addr = _extract_address_from_title(html)
# If the regex matches at all, the extracted value must not contain a house number.
if addr is not None:
- assert not _RE_HAS_HOUSE_NUMBER.search(
- addr
- ), f"Street-only addr must fail house-number guard: {addr!r}"
+ assert not _RE_HAS_HOUSE_NUMBER.search(addr), (
+ f"Street-only addr must fail house-number guard: {addr!r}"
+ )
def test_extract_address_nbsp_replaced() -> None:
@@ -129,9 +129,9 @@ def test_extract_address_nbsp_replaced() -> None:
)
addr = _extract_address_from_title(html)
assert addr is not None, "\\xa0 should be replaced before regex"
- assert _RE_HAS_HOUSE_NUMBER.search(
- addr
- ), f"Extracted address should contain house number: {addr!r}"
+ assert _RE_HAS_HOUSE_NUMBER.search(addr), (
+ f"Extracted address should contain house number: {addr!r}"
+ )
def test_extract_address_no_title_returns_none() -> None:
diff --git a/tradein-mvp/backend/tests/test_1781_secondary_only_param.py b/tradein-mvp/backend/tests/test_1781_secondary_only_param.py
new file mode 100644
index 00000000..c4d474ab
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_1781_secondary_only_param.py
@@ -0,0 +1,123 @@
+"""`secondary_only` — параметр расписания, а не хардкод; выброшенное считается (#1781).
+
+`run_cian_full_load` передавал `secondary_only=True` жёстко (`pipeline.py:3328`), поэтому
+включить новостройки в полный обход можно было только деплоем. При этом новостройки
+НЕ пропускаются при запросе — они скачиваются, разбираются и выбрасываются последним
+шагом (`cian/serp.py:671`), потому что SERP-параметр `object_type=1` у Cian ненадёжен
+(~5 % выдачи) и фильтруют по authoritative `listing_segment` уже после парсинга.
+
+Следствие, проверенное по коду: проба бакета берёт `totalOffers` из Redux-состояния
+SERP и считает `pages_needed = ceil(totalOffers / offers_per_page)`; `totalOffers`
+включает обе категории. То есть страницы с новостройками уже скачаны, лимит страниц и
+антибан-бюджет за них уже заплачены — включение стоит НОЛЬ дополнительных запросов.
+
+Дефолт НЕ меняется: `True`, поведение прода прежнее. Меняется только то, что решение
+становится правкой одной ячейки `scrape_schedules.default_params`, а не деплоем.
+
+Замер прода 21.08.2026, ради которого это и делается: месячный охват свипа
+cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и avito/novostroyki;
+11 993 активные строки, медианный возраст 81 сутки.
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import inspect
+
+
+def test_full_load_no_longer_hardcodes_secondary_only() -> None:
+ """Головной: значение берётся из параметра, а не из литерала.
+
+ На origin/main в теле стоит `secondary_only=True` — включить новостройки
+ можно только правкой кода.
+ """
+ from scraper_kit.orchestration import pipeline
+
+ src = inspect.getsource(pipeline.run_cian_full_load)
+ assert "secondary_only=secondary_only" in src, "значение не пробрасывается из параметра"
+ assert "secondary_only=True," not in src, (
+ "в теле остался хардкод — расписание на него повлиять не сможет"
+ )
+
+
+def test_default_is_unchanged() -> None:
+ """Контроль: дефолт остаётся True — поведение прода не меняется этим PR.
+
+ Без этого контроля правка «сделать параметром» могла бы тихо включить сбор
+ новостроек на проде, а это отдельное решение с замером (#1781, #2994).
+ """
+ from scraper_kit.orchestration.pipeline import run_cian_full_load
+
+ params = inspect.signature(run_cian_full_load).parameters
+ assert "secondary_only" in params, (
+ "параметра нет в сигнатуре — значение задано в теле, расписание на него "
+ f"повлиять не может; параметры: {list(params)}"
+ )
+ default = params["secondary_only"].default
+ assert default is True, f"дефолт изменён на {default!r} — это не входило в правку"
+
+
+def test_admin_request_exposes_the_flag_with_same_default() -> None:
+ """Параметр доезжает до расписания: у эндпоинта есть поле с тем же дефолтом."""
+ from app.api.v1.admin import CianFullLoadRequest
+
+ поля = CianFullLoadRequest.model_fields
+ assert "secondary_only" in поля, (
+ "поля нет в запросе — параметр не доедет из scrape_schedules.default_params; "
+ f"поля: {sorted(поля)}"
+ )
+ assert поля["secondary_only"].default is True
+ req = CianFullLoadRequest()
+ assert req.secondary_only is True
+ assert CianFullLoadRequest(secondary_only=False).secondary_only is False
+
+
+def test_counters_persist_dropped_novostroyki() -> None:
+ """Выброшенное обязано сохраняться, а не только логироваться.
+
+ Счётчик `dropped_nb` печатался в лог, но в `scrape_runs.counters` не попадал, и
+ ответить «сколько инвентаря выбрасывает полный обход» задним числом было нечем:
+ логи за нужную дату уже ротировались. Тот же довод, по которому рядом заведён
+ `partial_buckets`.
+ """
+ from scraper_kit.orchestration.pipeline import CianFullLoadCounters
+
+ c = CianFullLoadCounters()
+ assert "dropped_novostroyki" in c.to_dict(), c.to_dict()
+ c.dropped_novostroyki = 7
+ assert c.to_dict()["dropped_novostroyki"] == 7
+
+
+def test_scraper_resets_the_counter_per_run() -> None:
+ """Контроль: счётчик сбрасывается на каждый прогон.
+
+ Инстанс скрапера переиспользуется; без сброса второй прогон унаследовал бы
+ число первого и записал бы в counters завышенное значение.
+ """
+ from scraper_kit.providers.cian.serp import CianScraper
+
+ src = inspect.getsource(CianScraper.fetch_all_secondary)
+ assert "self.last_dropped_nb = 0" in src, "нет сброса в начале прогона"
+ assert "self.last_dropped_nb += " in inspect.getsource(CianScraper._paginate_leaf_bucket), (
+ "накопление не там, где считается dropped_nb"
+ )
+ assert getattr(CianScraper, "last_dropped_nb", None) == 0, (
+ "нет класс-дефолта: атрибут не прочитается, если прогон упал до первого бакета"
+ )
+
+
+def test_filter_still_drops_when_flag_is_on() -> None:
+ """Контроль от переусердствования: при secondary_only=True фильтр остаётся.
+
+ Правка делает флаг управляемым, а не отменяет его.
+ """
+ from scraper_kit.providers.cian.serp import CianScraper
+
+ # Фильтр лежит в _paginate_leaf_bucket — том методе, что собирает страницы
+ # бакета; fetch_all_secondary только раздаёт флаг вниз.
+ src = inspect.getsource(CianScraper._paginate_leaf_bucket)
+ assert 'lot.listing_segment != "novostroyki"' in src, "фильтр пропал"
+ assert "if secondary_only:" in src, "фильтр перестал зависеть от флага"
diff --git a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py
index 188b185d..0a9c69aa 100644
--- a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py
+++ b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py
@@ -142,9 +142,9 @@ def test_house_suggestions_insert_covers_every_declared_column() -> None:
(_SQL_DIR / "064_house_imv_phase_c.sql").read_text("utf-8"), "house_suggestions"
)
written = _insert_columns(inspect.getsource(hib.save_imv_result), "house_suggestions")
- assert (
- declared - {"id"} <= written
- ), f"колонки без писателя: {sorted(declared - {'id'} - written)}"
+ assert declared - {"id"} <= written, (
+ f"колонки без писателя: {sorted(declared - {'id'} - written)}"
+ )
def test_save_imv_result_binds_image_link_and_metrics() -> None:
diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
index a6fb3820..8384a28a 100644
--- a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
+++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py
@@ -147,9 +147,9 @@ async def test_price_history_takes_pool_node_despite_flag_off() -> None:
"""
from app.core.config import settings
- assert (
- settings.use_proxy_pool_curl is False
- ), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL"
+ assert settings.use_proxy_pool_curl is False, (
+ "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL"
+ )
pool = _SpyPool()
await _run_price_history(pool, status_code=200)
assert pool.acquire_calls == ["cian"]
diff --git a/tradein-mvp/backend/tests/test_2924_yandex_resolve_tried_at.py b/tradein-mvp/backend/tests/test_2924_yandex_resolve_tried_at.py
new file mode 100644
index 00000000..fbb26d18
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2924_yandex_resolve_tried_at.py
@@ -0,0 +1,180 @@
+"""Неудача резолва yandex_jk_slug помечает дом, а очередь не упирается в те же пять (#2924).
+
+`_SELECT_PENDING_HOUSES` брал ожидающие дома `ORDER BY h.id` и уходил из очереди только
+попавший в витрину. Неудачная попытка не писала ничего — ни времени, ни счётчика, — и
+следующий прогон брал те же дома в том же порядке. Замер 19.08: 397 ожидающих, первые
+пять по h.id (6706, 6741, 6757, 6774, 6785) занимали все пять слотов каждый прогон.
+
+Маркер — `houses.yandex_jk_resolve_tried_at` (миграция 269), по образцу
+`listings.geocode_tried_at`: выборка не берёт дом раньше чем через RESOLVE_RETRY_DAYS,
+а неудавшиеся идут в КОНЕЦ очереди (`NULLS FIRST`).
+
+Тесты идут через реальный `enrich_yandex_newbuilding_sweep` с двойником сессии: он отдаёт
+заданные строки на SELECT и запоминает весь выполненный SQL. Резолвер патчится на None —
+это «неудача», ради которой маркер и нужен. На origin/main маркер не пишется ни разу.
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
+
+from contextlib import contextmanager
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class _Result:
+ def __init__(self, rows: list[dict], scalar: Any = 0) -> None:
+ self._rows = rows
+ self._scalar = scalar
+
+ def mappings(self) -> _Result:
+ return self
+
+ def all(self) -> list[dict]:
+ return self._rows
+
+ def fetchone(self) -> Any:
+ return self._rows[0] if self._rows else None
+
+ def first(self) -> Any:
+ return self._rows[0] if self._rows else None
+
+ def scalar(self) -> Any:
+ return self._scalar
+
+ def scalar_one(self) -> Any:
+ return self._scalar
+
+ def scalar_one_or_none(self) -> Any:
+ return self._scalar
+
+
+class _Session:
+ """Отдаёт pending-строки на SELECT и запоминает все (sql, params)."""
+
+ def __init__(self, pending: list[dict]) -> None:
+ self._pending = pending
+ self.sql: list[tuple[str, dict]] = []
+
+ def execute(self, statement: Any = None, params: Any = None, *a: Any, **kw: Any) -> _Result:
+ text_ = str(statement)
+ self.sql.append((text_, dict(params or {})))
+ # Выборка ожидающих — единственный запрос с DISTINCT ON (h.id); COUNT-запросы
+ # тоже читают houses h + yandex_realty_nb, их путать с выборкой нельзя.
+ if "DISTINCT ON (h.id)" in text_:
+ return _Result(self._pending)
+ return _Result([])
+
+ def begin_nested(self) -> Any:
+ sp = MagicMock()
+
+ @contextmanager
+ def _cm():
+ yield sp
+
+ # код зовёт и `sp = db.begin_nested(); sp.commit()` и `with db.begin_nested():`
+ sp.__enter__ = lambda *_: sp
+ sp.__exit__ = lambda *_: False
+ return sp
+
+ def commit(self) -> None:
+ pass
+
+ def rollback(self) -> None:
+ pass
+
+
+async def _run(pending: list[dict], *, force: bool = False, resolved: str | None = None):
+ from app.tasks import yandex_newbuilding_sweep as mod
+
+ db = _Session(pending)
+ # Резолвер и конфиг импортируются ВНУТРИ функции (late import) — патчим модуль-источник.
+ with (
+ patch(
+ "scraper_kit.providers.yandex.newbuilding.resolve_yandex_jk_slug",
+ AsyncMock(return_value=resolved),
+ ),
+ patch.object(mod, "_sleep_with_jitter", AsyncMock(return_value=None)),
+ patch("app.services.scraper_adapters.RealScraperConfig", MagicMock()),
+ ):
+ await mod.enrich_yandex_newbuilding_sweep(
+ db, city="ekaterinburg", limit=5, force=force, request_delay_sec=0
+ )
+ return db
+
+
+def _tried_updates(db: _Session) -> list[int]:
+ return [
+ int(p["hid"])
+ for s, p in db.sql
+ if "UPDATE houses" in s and "yandex_jk_resolve_tried_at" in s and "hid" in p
+ ]
+
+
+def _select_sql(db: _Session) -> str:
+ for s, _ in db.sql:
+ if "DISTINCT ON (h.id)" in s:
+ return s
+ raise AssertionError("выборка ожидающих домов не выполнялась")
+
+
+def _select_params(db: _Session) -> dict:
+ for s, p in db.sql:
+ if "DISTINCT ON (h.id)" in s:
+ return p
+ raise AssertionError("выборка ожидающих домов не выполнялась")
+
+
+@pytest.mark.asyncio
+async def test_failed_resolve_marks_the_house() -> None:
+ """Головной: резолв не удался → дом получает yandex_jk_resolve_tried_at.
+
+ На origin/main такого UPDATE нет вовсе — дом останется первым в очереди навсегда.
+ """
+ db = await _run(
+ [{"house_id": 6706, "yandex_jk_slug": None, "yandex_jk_id": None, "ext_id": "286394"}]
+ )
+ assert _tried_updates(db) == [6706], (
+ f"неудача резолва не помечена; выполненный SQL: {[s[:50] for s, _ in db.sql]}"
+ )
+
+
+@pytest.mark.asyncio
+async def test_successful_resolve_does_not_mark() -> None:
+ """Контроль: удачный резолв маркер НЕ ставит — иначе удачные дома тоже уходили
+ бы в конец очереди и ждали бы неделю до обогащения."""
+ db = await _run(
+ [{"house_id": 6706, "yandex_jk_slug": None, "yandex_jk_id": None, "ext_id": "286394"}],
+ resolved="uspenskij",
+ )
+ assert _tried_updates(db) == [], "маркер поставлен при удачном резолве"
+
+
+@pytest.mark.asyncio
+async def test_selection_skips_recently_tried_and_pushes_tried_to_the_end() -> None:
+ """Выборка: не берёт дом, у которого попытка свежее RESOLVE_RETRY_DAYS, и ставит
+ пробованные в конец (NULLS FIRST). Проверяется по тексту запроса, который реально
+ ушёл в БД, и по параметрам — retry_days доезжает, а не захардкожен в строке."""
+ db = await _run([])
+ sql = _select_sql(db)
+ assert "yandex_jk_resolve_tried_at IS NULL" in sql, "нет фильтра по маркеру"
+ assert ":retry_days" in sql, "срок повтора не параметризован"
+ assert "yandex_jk_resolve_tried_at NULLS FIRST" in sql, "пробованные не уходят в конец очереди"
+ assert _select_params(db).get("retry_days") == 7, _select_params(db)
+
+
+@pytest.mark.asyncio
+async def test_force_bypasses_the_marker() -> None:
+ """Контроль: force=True игнорирует маркер — ручной полный проход остаётся возможным."""
+ db = await _run([], force=True)
+ sql = _select_sql(db)
+ # Тот же OR-блок, что и у гейта «уже обогащён»: CAST(:force AS boolean) = TRUE OR …
+ assert sql.count("CAST(:force AS boolean) = TRUE") >= 2, (
+ "маркер не обходится через force — второго OR-блока с :force нет"
+ )
+ assert _select_params(db).get("force") is True
diff --git a/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py b/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py
new file mode 100644
index 00000000..db4cd3f4
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py
@@ -0,0 +1,139 @@
+"""Неизвестный year_built / house_type перестаёт считаться идеальным совпадением (#2936).
+
+SQL-формула relevance_score даёт кандидату без year_built штраф 0 — столько же,
+сколько точному попаданию, и ЛУЧШЕ, чем известному году с разницей 24 (2.0).
+Отсутствие данных выигрывает у знания, и это возвышает источник с худшей
+полнотой: avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 % доли в пуле.
+
+Флаг, а не правка формулы: влияние на цену измерено дважды — смещения нет, есть
+шум (39 % целей сдвигаются >5 %). Какой отбор ТОЧНЕЕ — отвечает только бэктест на
+сделках, и включать можно только по его итогу (тот же контракт, что у #2012).
+
+Механизм: NULL получает МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов,
+у которых он известен, — не наказание и не награда.
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+from app.services.estimator import _apply_unknown_attr_penalty
+
+
+def _pool() -> list[dict]:
+ # Штрафы за год при target=2000: 0.0 / 1.0 / 2.0 / 3.0 / 4.0 → медиана 2.0.
+ # Типы при target='panel': известные — 3 совпадения (0) и 2 несовпадения (1.5) →
+ # медиана 0.0; добавим ещё одно несовпадение, чтобы медиана стала ненулевой.
+ return [
+ {"id": 1, "year_built": 2000, "house_type": "panel", "relevance_score": 0.5},
+ {"id": 2, "year_built": 2012, "house_type": "panel", "relevance_score": 0.5},
+ {"id": 3, "year_built": 2024, "house_type": "brick", "relevance_score": 0.5},
+ {"id": 4, "year_built": 1964, "house_type": "brick", "relevance_score": 0.5},
+ {"id": 5, "year_built": 1952, "house_type": "brick", "relevance_score": 0.5},
+ {"id": 6, "year_built": None, "house_type": None, "relevance_score": 0.5}, # NULL
+ {"id": 7, "year_built": 2000, "house_type": None, "relevance_score": 0.5}, # NULL тип
+ ]
+
+
+def test_null_year_gets_pool_median_penalty() -> None:
+ """Головной: NULL-год получает медиану штрафов известных, а не 0.
+
+ На origin/main функции нет — кандидат 6 остаётся с 0.5 и обгоняет
+ кандидатов 3–5 с известным годом.
+ """
+ pool = _pool()
+ _apply_unknown_attr_penalty(pool, target_year=2000, target_house_type=None, min_n=5)
+ by = {c["id"]: c["relevance_score"] for c in pool}
+ # Известных годов ШЕСТЬ (ид. 1,2,3,4,5 и 7): штрафы 0,1,2,3,4 и 0 → медиана 1.5.
+ # (Первая редакция теста считала пятерых и ждала 2.0 — арифметика, не код.)
+ assert by[6] == 0.5 + 1.5, (
+ f"NULL-год должен получить медианный штраф 1.5, получил {by[6] - 0.5}"
+ )
+ assert by[1] == 0.5 and by[3] == 0.5, (
+ "известный год не должен трогаться — его уже оштрафовал SQL"
+ )
+
+
+def test_null_house_type_gets_pool_median_penalty() -> None:
+ """NULL-тип: медиана штрафов известных типов (3×0, 3×1.5 → медиана 0.75)."""
+ pool = _pool()
+ # Делаем 3 совпадения и 3 несовпадения: 1,2 panel; 3,4,5 brick; добавим ещё panel.
+ pool.append({"id": 8, "year_built": 2000, "house_type": "panel", "relevance_score": 0.5})
+ _apply_unknown_attr_penalty(pool, target_year=None, target_house_type="panel", min_n=5)
+ by = {c["id"]: c["relevance_score"] for c in pool}
+ assert by[6] == 0.5 + 0.75 and by[7] == 0.5 + 0.75, f"NULL-тип: {by[6] - 0.5}, {by[7] - 0.5}"
+ assert by[3] == 0.5, "известный несовпадающий тип уже оштрафован SQL — не трогаем"
+
+
+def test_unknown_target_penalizes_nobody() -> None:
+ """Симметрия с SQL: если target неизвестен, SQL не штрафует никого — и мы тоже."""
+ pool = _pool()
+ _apply_unknown_attr_penalty(pool, target_year=None, target_house_type=None, min_n=5)
+ assert all(c["relevance_score"] == 0.5 for c in pool)
+
+
+def test_sparse_pool_is_skipped() -> None:
+ """Контроль от шума: меньше min_n известных значений → сигнал пропущен целиком."""
+ pool = [*_pool()[:3], {"id": 9, "year_built": None, "house_type": None, "relevance_score": 0.5}]
+ _apply_unknown_attr_penalty(pool, target_year=2000, target_house_type="panel", min_n=5)
+ assert all(c["relevance_score"] == 0.5 for c in pool), "медиана по трём строкам — не сигнал"
+
+
+def test_flag_default_off_means_no_call() -> None:
+ """Контроль: дефолт флага OFF — поведение прода не меняется этим PR."""
+ from app.core.config import settings
+
+ assert settings.estimate_unknown_attr_penalty_enabled is False
+
+
+def test_penalty_reproduces_sql_terms_not_its_own_idea_of_distance() -> None:
+ """Штраф считается по ТЕМ ЖЕ термам, что SQL: abs(Δyear)/12.0 и 1.5 за тип.
+
+ Ловит «починку», где NULL получал бы произвольную константу вместо медианы
+ реально начисленных SQL-штрафов.
+ """
+ pool = [
+ {"id": i, "year_built": 2000 + 12 * i, "house_type": "x", "relevance_score": 0.0}
+ for i in range(1, 6)
+ ] + [{"id": 99, "year_built": None, "house_type": "x", "relevance_score": 0.0}]
+ # штрафы 1,2,3,4,5 → медиана 3.0 (= 36 лет / 12)
+ _apply_unknown_attr_penalty(pool, target_year=2000, target_house_type=None, min_n=5)
+ assert next(c for c in pool if c["id"] == 99)["relevance_score"] == 3.0
+
+
+def test_tier_h_and_w_outer_selects_carry_year_and_house_type() -> None:
+ """Сторож на «мёртвый флаг»: внешние SELECT тиров H и W обязаны выбирать
+ year_built и house_type — иначе Python-слой видит None у ВСЕХ и не штрафует
+ никого по построению.
+
+ Именно так флаг и был мёртв в первой редакции: _ANALOG_SELECT_COLS дополнили,
+ а внешние списки — нет; A/B на 300 целях ЕКБ дал 0 из 5 915 сменённых слотов,
+ probe-лог показал pool=30 null_year=30. Проверка по исходнику запроса, а не по
+ живой БД: это инвариант текста SQL.
+ """
+ import inspect
+ import re
+
+ from app.services import estimator as m
+
+ src = inspect.getsource(m)
+ # Внешний SELECT тира — блок между `\n<отступ>SELECT\n` и `\n<отступ>FROM base` с ТЕМ
+ # ЖЕ отступом (иначе rfind цепляет SELECT внутри оконной функции/CASE).
+ outer_blocks = []
+ for mt in re.finditer(r"\n(\s+)FROM base\n", src):
+ i = mt.start()
+ j = src.rfind("\n" + mt.group(1) + "SELECT\n", 0, i)
+ if j >= 0:
+ outer_blocks.append(src[j:i])
+ assert len(outer_blocks) >= 3, f"найдено внешних SELECT: {len(outer_blocks)}"
+ # Штрафуемые тиры — ровно те, куда #2012 добавил kitchen/ceiling: H и W.
+ # Tier S (same building) и anchor-тир штрафов не применяют — их внешние
+ # списки kitchen_area_m2 не содержат, и гейт их не трогает.
+ penalized = [b for b in outer_blocks if "kitchen_area_m2" in b]
+ assert len(penalized) == 2, f"ожидал 2 внешних SELECT тиров H и W, нашёл {len(penalized)}"
+ for b in penalized:
+ assert "year_built" in b and "house_type" in b, (
+ "внешний SELECT тира не выбирает year_built/house_type — флаг #2936 мёртв:\n" + b[-400:]
+ )
diff --git a/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py b/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py
index cbe363a7..49c6bd60 100644
--- a/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py
+++ b/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py
@@ -84,8 +84,7 @@ async def test_consecutive_queries_are_spaced(throttle_reset: None) -> None:
assert len(stamps) == 3, f"ожидали 3 запроса, ушло {len(stamps)}"
gaps = _gaps(stamps)
assert all(g >= _TEST_INTERVAL * 0.9 for g in gaps), (
- f"запросы идут вплотную: зазоры {[round(g, 4) for g in gaps]}, "
- f"ожидалось ≥ {_TEST_INTERVAL}"
+ f"запросы идут вплотную: зазоры {[round(g, 4) for g in gaps]}, ожидалось ≥ {_TEST_INTERVAL}"
)
@@ -108,8 +107,7 @@ async def test_failed_lookup_does_not_reset_the_pace(throttle_reset: None) -> No
await geocoder._nominatim_lookup("ненаходимый адрес два")
assert boundary_index > 0 and len(stamps) > boundary_index, (
- f"оба поиска должны были сходить в сеть: {len(stamps)} запросов, "
- f"граница {boundary_index}"
+ f"оба поиска должны были сходить в сеть: {len(stamps)} запросов, граница {boundary_index}"
)
boundary_gap = stamps[boundary_index] - stamps[boundary_index - 1]
assert boundary_gap >= _TEST_INTERVAL * 0.9, (
diff --git a/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py b/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py
new file mode 100644
index 00000000..dbe3c0ca
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py
@@ -0,0 +1,258 @@
+"""Повторный скрейп неизменившегося объявления не переписывает строку (#2992).
+
+Апсерт listings безусловно присваивал 41 колонку при каждом повторном скрейпе —
+заново тостил description и плодил TOAST-чанки. Прод 2026-08-20: n_tup_upd
+20.87 млн на 107 тыс. строк (198 апдейтов на строку за 91 день), HOT 0.44 %,
+TOAST 15 ГБ при ~230 МБ полезного, WAL 7 ГБ/сутки.
+
+Гейт в `ON CONFLICT DO UPDATE … WHERE`: апдейт идёт, если итоговое
+(post-COALESCE) значение хоть одной контентной колонки отличается ИЛИ
+last_seen_at ещё не сегодняшний по МСК. Второе условие обязательно: метка
+живости должна сдвигаться хотя бы раз в сутки — на неё завязаны деактиватор,
+снапшоты, эстиматор (#2206), монитор свежести.
+
+Проверяется на ЖИВОМ Postgres через реальный save_listings, в транзакции с
+откатом. Счётчик апдейтов — pg_stat_xact_user_tables (текущая транзакция),
+то есть ровно «повторный скрейп не увеличивает n_tup_upd» из acceptance.
+Без БД — skip (как соседние live-тесты; в CI Trade-In есть postgres-сервис).
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import uuid
+from typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from scraper_kit.base import ScrapedLot, save_listings
+from sqlalchemy import text
+
+
+def _live_session() -> Any | None:
+ try:
+ from sqlalchemy import create_engine
+ 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
+
+
+def _matcher() -> MagicMock:
+ m = MagicMock()
+ m.match_or_create_house.return_value = (None, 0.0, "no_address")
+ m.upsert_listing_source.return_value = None
+ return m
+
+
+def _lot(
+ src_id: str,
+ price: int = 5_000_000,
+ description: str | None = "тестовое описание",
+ address: str | None = None,
+) -> ScrapedLot:
+ return ScrapedLot(
+ source="cian",
+ source_url=f"https://ekb.cian.ru/sale/flat/t2992-{src_id}/",
+ source_id=f"t2992-{src_id}",
+ price_rub=price,
+ description=description,
+ address=address,
+ )
+
+
+def _row(db: Any, src_id: str) -> Any:
+ """Строка + её физическая версия (ctid). save_listings КОММИТИТ, поэтому
+ транзакционные счётчики pg_stat_xact_* тут бесполезны (сбрасываются). UPDATE
+ всегда создаёт новую версию строки → ctid меняется; пропущенный апдейт → ctid
+ тот же. Это и есть прямой замер «переписана ли строка»."""
+ return db.execute(
+ text(
+ "SELECT id, ctid::text AS ctid, price_rub, city, description, last_seen_at "
+ "FROM listings WHERE source = 'cian' AND source_id = :sid"
+ ),
+ {"sid": f"t2992-{src_id}"},
+ ).fetchone()
+
+
+def _cleanup(db: Any) -> None:
+ """save_listings коммитит — rollback не спасает. Убираем свои строки явно."""
+ try:
+ db.rollback()
+ db.execute(
+ text(
+ "DELETE FROM listings_snapshots WHERE listing_id IN "
+ "(SELECT id FROM listings WHERE source='cian' AND source_id LIKE 't2992-%')"
+ )
+ )
+ db.execute(text("DELETE FROM listing_sources WHERE ext_id LIKE 't2992-%'"))
+ db.execute(text("DELETE FROM listings WHERE source='cian' AND source_id LIKE 't2992-%'"))
+ db.commit()
+ finally:
+ db.close()
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+def test_unchanged_rescrape_same_day_does_not_update_the_row() -> None:
+ """Головной: второй апсерт неизменного лота в тот же МСК-день — n_tup_upd не растёт.
+
+ На origin/main (без гейта) второй save_listings переписывает строку — счётчик +1.
+ """
+ db = _live_session()
+ sid = uuid.uuid4().hex[:8]
+ try:
+ save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
+ first = _row(db, sid)
+ assert first is not None, "первая вставка не прошла"
+ ins, upd = save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
+ second = _row(db, sid)
+ assert second.ctid == first.ctid, (
+ f"повторный скрейп неизменного лота переписал строку: ctid {first.ctid}→{second.ctid}"
+ )
+ assert (ins, upd) == (0, 0), f"счётчики: inserted={ins} updated={upd}, ждали 0/0"
+ assert second.last_seen_at == first.last_seen_at, "last_seen_at сдвинулся без нужды"
+ finally:
+ _cleanup(db)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+def test_changed_price_still_updates() -> None:
+ """Контроль: изменение контента проходит — гейт не глушит настоящие изменения."""
+ db = _live_session()
+ sid = uuid.uuid4().hex[:8]
+ try:
+ save_listings(db, [_lot(sid, price=5_000_000)], matcher=_matcher(), region_code=66)
+ first = _row(db, sid)
+ save_listings(db, [_lot(sid, price=5_100_000)], matcher=_matcher(), region_code=66)
+ second = _row(db, sid)
+ assert second.ctid != first.ctid, "изменение цены не записалось (строка не переписана)"
+ assert second.price_rub == 5_100_000
+ finally:
+ _cleanup(db)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+def test_coalesce_backfill_still_updates() -> None:
+ """Контроль: COALESCE-дозаполнение (город NULL → значение) проходит.
+
+ Именно за это забраковали гейт по card_hash: он бы блокировал дозаполнение.
+ Сравнение идёт по ИТОГОВЫМ post-COALESCE значениям — если дописал, строка
+ отличается.
+ """
+ db = _live_session()
+ sid = uuid.uuid4().hex[:8]
+ try:
+ # city приходит ПАРАМЕТРОМ save_listings (развёртка города), не полем лота.
+ save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66, city=None)
+ first = _row(db, sid)
+ assert first.city is None
+ save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66, city="Екатеринбург")
+ second = _row(db, sid)
+ assert second.ctid != first.ctid, "дозаполнение города заблокировано гейтом"
+ assert second.city == "Екатеринбург"
+ finally:
+ _cleanup(db)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+def test_next_day_rescrape_updates_even_if_unchanged() -> None:
+ """Живость: если last_seen_at вчерашний — апдейт идёт и без изменений контента.
+
+ Иначе деактиватор/снапшоты/эстиматор потеряли бы живой инвентарь. Симулируем
+ «вчера», отодвинув last_seen_at у только что вставленной строки.
+ """
+ db = _live_session()
+ sid = uuid.uuid4().hex[:8]
+ try:
+ save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
+ db.execute(
+ text(
+ "UPDATE listings SET last_seen_at = last_seen_at - interval '1 day' "
+ "WHERE source='cian' AND source_id = :sid"
+ ),
+ {"sid": f"t2992-{sid}"},
+ )
+ db.commit()
+ first = _row(db, sid)
+ save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
+ second = _row(db, sid)
+ assert second.ctid != first.ctid, "суточное ре-подтверждение живости заблокировано"
+ assert second.last_seen_at > first.last_seen_at, "last_seen_at не сдвинулся"
+ finally:
+ _cleanup(db)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+def test_skipped_row_still_yields_listing_id_for_downstream() -> None:
+ """При пропуске апдейта listing_id всё равно известен — downstream (снапшот,
+ матчинг, listing_sources) идёт; иначе гейт молча отключил бы сопоставление."""
+ db = _live_session()
+ sid = uuid.uuid4().hex[:8]
+ m = _matcher()
+ lot = _lot(sid, address="Екатеринбург, ул. Тестовая, 1") # матчинг идёт только с адресом/гео
+ try:
+ save_listings(db, [lot], matcher=m, region_code=66)
+ calls_before = m.match_or_create_house.call_count
+ assert calls_before == 1, (
+ "контроль сконструирован неверно: матчинг не зовётся и в первый раз"
+ )
+ save_listings(db, [lot], matcher=m, region_code=66)
+ assert m.match_or_create_house.call_count == calls_before + 1, (
+ "при пропущенном апдейте матчинг не вызван — listing_id потерян"
+ )
+ finally:
+ _cleanup(db)
+
+
+@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
+def test_listing_sources_unchanged_rescrape_same_day_does_not_update() -> None:
+ """Симметричный гейт на listing_sources — второй по нагрузке таблице (10.27 млн
+ апдейтов, HOT 0.10 %). Повторный upsert_listing_source того же лота в тот же
+ МСК-день не переписывает строку; изменение цены — переписывает."""
+ from app.services.matching.listings import upsert_listing_source
+
+ db = _live_session()
+ sid = uuid.uuid4().hex[:8]
+ try:
+ save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66)
+ lid = int(_row(db, sid).id)
+
+ def ls_row() -> Any:
+ return db.execute(
+ text(
+ "SELECT ctid::text AS ctid, price_rub, last_seen_at FROM listing_sources "
+ "WHERE ext_source='cian' AND ext_id = :e"
+ ),
+ {"e": f"t2992-{sid}"},
+ ).fetchone()
+
+ kw = dict(ext_source="cian", ext_id=f"t2992-{sid}", method="test", confidence=0.9)
+ upsert_listing_source(db, listing_id=lid, price_rub=5_000_000, **kw)
+ db.commit()
+ first = ls_row()
+ assert first is not None
+ upsert_listing_source(db, listing_id=lid, price_rub=5_000_000, **kw)
+ db.commit()
+ second = ls_row()
+ assert second.ctid == first.ctid, (
+ f"listing_sources переписана без изменений: ctid {first.ctid}→{second.ctid}"
+ )
+ upsert_listing_source(db, listing_id=lid, price_rub=5_100_000, **kw)
+ db.commit()
+ third = ls_row()
+ assert third.ctid != second.ctid and third.price_rub == 5_100_000, (
+ "изменение цены не записалось"
+ )
+ finally:
+ _cleanup(db)
diff --git a/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py b/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py
new file mode 100644
index 00000000..a216f3c8
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py
@@ -0,0 +1,112 @@
+"""Сторож на дома, уехавшие от собственных объявлений (#2996).
+
+Прод 21.08.2026: 23 дома стоят вне Свердловской области, к ним привязано 591
+объявление. Адреса екатеринбургские («Ул. 8 Марта», «Крауля», «Амундсена»),
+координаты — Варшава, Белград, Таллин, Владивосток, Ижевск.
+
+Причина найдена и она НЕ в живом пути: 22 из 23 несут в `raw_payload` след
+разового бэкфилла `full_backfill_2026-05-27`, который звал Yandex-геокодер
+напрямую, без резолва города. Живой путь чист — 0 записей вне региона в
+`geocode_cache` из 10 448 и 2 из 88 544 у `listings`.
+
+**17 из 22 помечены геокодером `precision: "exact"`.** Точность отвечает на
+«нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть не
+может. Поэтому сторож проверяет ПРИНАДЛЕЖНОСТЬ, а не «точность».
+
+Инвариант нарочно не географический — «дом рядом со своими объявлениями», а не
+«дом внутри рамки области». Рамка сломалась бы при выходе в Москву; этот
+инвариант переживёт расширение.
+
+Порог 100 км не подобран на глаз. Замер по 9 052 домам с объявлениями:
+
+ ближе 1 км 8 914 (98.5 %)
+ 1–5 км 64
+ 5–25 км 40
+ 25–100 км 9 ← настоящие пригороды: Сарапулка, Кедровка,
+ Чусовское Озеро, Ревда, Первоуральск (макс. 46.7)
+ дальше 100 км 25 ← ближайший 119.2, максимум 5 079
+
+Между 46.7 и 119.2 км нет НИ ОДНОГО дома. 100 лежит в середине пустого
+промежутка, и ловит на два дома больше, чем рамка по области (оба проверены —
+настоящие, «Ул. Белинского/Фурманова» за 207 км с 34 объявлениями).
+"""
+
+from __future__ import annotations
+
+import os
+
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
+
+import inspect
+import re
+
+
+def test_threshold_sits_in_the_measured_gap() -> None:
+ """Головной: порог задан и лежит между пригородами и патологией.
+
+ 46.7 км — самый дальний настоящий пригород, 119.2 — ближайший дефект.
+ Порог вне этого промежутка либо ловит пригороды, либо пропускает дефекты.
+ """
+ from app.api.v1 import admin
+
+ порог = getattr(admin, "_MISLOCATED_KM", None)
+ assert порог is not None, (
+ "порога нет вовсе — сторож не задан, дома в Варшаве и Таллине никем не считаются"
+ )
+ assert 46.7 < порог < 119.2, (
+ f"порог {порог} вне измеренного промежутка (46.7, 119.2): ниже — ловит "
+ "настоящие пригороды, выше — пропускает дефекты"
+ )
+
+
+def test_endpoint_exists_and_reports_scale() -> None:
+ """Ручка обязана отдавать не только счётчик, но и МАСШТАБ.
+
+ Одно число «25» не даёт принять решение об очистке: нужно, сколько
+ объявлений привязано к этим домам (на проде 591) и какие это дома.
+ """
+ from app.api.v1 import admin
+
+ ручка = getattr(admin, "get_mislocated_houses", None)
+ модель = getattr(admin, "MislocatedHousesResponse", None)
+ assert ручка is not None and модель is not None, (
+ "ручки нет — дома в Варшаве и Таллине не перечисляет никто; "
+ f"есть ли обработчик: {ручка is not None}, есть ли модель: {модель is not None}"
+ )
+ поля = модель.model_fields
+ for нужное in ("threshold_km", "houses_count", "listings_count", "houses"):
+ assert нужное in поля, f"нет поля {нужное}; есть: {sorted(поля)}"
+
+
+def test_query_compares_house_to_its_own_listings() -> None:
+ """Контроль смысла: инвариант «рядом со своими», а не «внутри рамки».
+
+ Рамка по области сломалась бы при выходе в Москву — ровно то, ради чего
+ заведена #2996. Ловит «починку» через координатный BETWEEN.
+ """
+ from app.api.v1 import admin
+
+ запрос = getattr(admin, "_MISLOCATED_SQL", None)
+ assert запрос is not None, "запроса нет — проверять принадлежность нечем"
+ src = str(запрос)
+ assert "l.house_id_fk = h.id" in src, "дом не сравнивается со СВОИМИ объявлениями"
+ assert "percentile_disc(0.5)" in src, "берётся не медиана координат объявлений"
+ assert not re.search(r"ST_Y\(.*BETWEEN", src), (
+ "появилась географическая рамка — она не переживёт расширение региона"
+ )
+
+
+def test_check_stays_out_of_the_polled_endpoint() -> None:
+ """Контроль цены: тяжёлая проверка не должна попасть в опрашиваемую ручку.
+
+ Замер прода: этот запрос ~445 мс, обе выборки /scraper/data-quality вместе —
+ 27 мс, а фронт опрашивает её каждые 120 с. Первая редакция этой правки клала
+ проверку туда и давала 17-кратное удорожание; замер это и остановил.
+ """
+ from app.api.v1 import admin
+
+ src = inspect.getsource(admin.get_data_quality)
+ assert "percentile_disc" not in src, (
+ "тяжёлый агрегат вернулся в опрашиваемую каждые 2 минуты ручку"
+ )
+ assert "mislocated" not in src.lower(), "проверка снова подмешана в data-quality"
diff --git a/tradein-mvp/backend/tests/test_781_quality_gate.py b/tradein-mvp/backend/tests/test_781_quality_gate.py
index ac689067..26452d64 100644
--- a/tradein-mvp/backend/tests/test_781_quality_gate.py
+++ b/tradein-mvp/backend/tests/test_781_quality_gate.py
@@ -166,9 +166,9 @@ def test_755_anchor_n2_does_not_fire_headline_stays_radius() -> None:
"anchor with n=2 comps must NOT fire (min_comps=4 post-#755)"
)
# Confidence from 5 radius analogs must not be "high" (unique_addr < 7 threshold).
- assert (
- est.confidence != "high"
- ), f"Confidence should not be 'high' with 5 radius analogs, got {est.confidence!r}"
+ assert est.confidence != "high", (
+ f"Confidence should not be 'high' with 5 radius analogs, got {est.confidence!r}"
+ )
def test_755_anchor_n2_pure_unit_confidence_never_high() -> None:
@@ -250,9 +250,9 @@ def test_753_dedup_hash_stable_across_reprice_with_source_id() -> None:
lot_original = _make_scraped_lot(source_url=url, source_id=sid, price_rub=5_000_000)
lot_repriced = _make_scraped_lot(source_url=url, source_id=sid, price_rub=4_900_000)
- assert (
- lot_original.compute_dedup_hash() == lot_repriced.compute_dedup_hash()
- ), "dedup_hash must be stable across reprice when source_id is present (#753)"
+ assert lot_original.compute_dedup_hash() == lot_repriced.compute_dedup_hash(), (
+ "dedup_hash must be stable across reprice when source_id is present (#753)"
+ )
def test_753_dedup_hash_stable_across_context_query_no_source_id() -> None:
@@ -275,9 +275,9 @@ def test_753_dedup_hash_stable_across_context_query_no_source_id() -> None:
source_id=None,
price_rub=5_000_000,
)
- assert (
- lot_v1.compute_dedup_hash() == lot_v2.compute_dedup_hash()
- ), "dedup_hash must strip ?context= query and match for same listing URL (#753)"
+ assert lot_v1.compute_dedup_hash() == lot_v2.compute_dedup_hash(), (
+ "dedup_hash must strip ?context= query and match for same listing URL (#753)"
+ )
def test_753_dedup_hash_differs_for_distinct_listings() -> None:
@@ -312,9 +312,9 @@ def test_753_dedup_hash_source_id_takes_priority_over_url() -> None:
source_url="https://www.avito.ru/ru/offer/9999?some=other",
source_id="9999",
)
- assert (
- lot_canonical.compute_dedup_hash() == lot_redirect.compute_dedup_hash()
- ), "source_id takes priority: same source_id -> same hash regardless of URL (#753)"
+ assert lot_canonical.compute_dedup_hash() == lot_redirect.compute_dedup_hash(), (
+ "source_id takes priority: same source_id -> same hash regardless of URL (#753)"
+ )
# ---------------------------------------------------------------------------
@@ -353,12 +353,12 @@ def test_773_expected_sold_positive_on_anchor_only_path(
# Anchor gave a valid headline.
assert est.median_price_rub > 0, "anchor must produce non-zero headline"
# expected_sold must also be computed (not skipped by empty listings_clean).
- assert (
- est.expected_sold_price_rub is not None
- ), "expected_sold_price_rub must not be None on anchor-only path when ratio present (#773)"
- assert (
- est.expected_sold_price_rub > 0
- ), f"expected_sold_price_rub must be > 0, got {est.expected_sold_price_rub} (#773)"
+ assert est.expected_sold_price_rub is not None, (
+ "expected_sold_price_rub must not be None on anchor-only path when ratio present (#773)"
+ )
+ assert est.expected_sold_price_rub > 0, (
+ f"expected_sold_price_rub must be > 0, got {est.expected_sold_price_rub} (#773)"
+ )
# Consistency: sold = asking * ratio.
assert est.expected_sold_price_rub == round(est.median_price_rub * 0.92)
@@ -376,9 +376,9 @@ def test_773_expected_sold_null_when_no_ratio_anchor_only() -> None:
ratio_tuple=(None, None),
)
assert est.median_price_rub > 0 # anchor headline is valid
- assert (
- est.expected_sold_price_rub is None
- ), "expected_sold must be None when ratio is None, even on anchor-only path (#773)"
+ assert est.expected_sold_price_rub is None, (
+ "expected_sold must be None when ratio is None, even on anchor-only path (#773)"
+ )
# ---------------------------------------------------------------------------
@@ -437,9 +437,9 @@ async def test_754_block_page_http200_page_gt1_graceful_return() -> None:
result = await scraper.fetch_around(56.838, 60.605, radius_m=1000, pages=2)
# The important invariant: no exception raised, result is a list.
- assert isinstance(
- result, list
- ), "fetch_around must return a list, not raise, when page>1 returns 0 cards (#754)"
+ assert isinstance(result, list), (
+ "fetch_around must return a list, not raise, when page>1 returns 0 cards (#754)"
+ )
# ---------------------------------------------------------------------------
@@ -476,9 +476,9 @@ def test_740_median_zero_gives_insufficient_data_true() -> None:
actual_deals=[],
expires_at=datetime(2026, 6, 1, tzinfo=UTC),
)
- assert (
- est.insufficient_data is True
- ), "insufficient_data must be True when median_price_rub=0 (#740)"
+ assert est.insufficient_data is True, (
+ "insufficient_data must be True when median_price_rub=0 (#740)"
+ )
assert est.median_price_rub == 0
@@ -505,9 +505,9 @@ def test_740_positive_median_gives_insufficient_data_false() -> None:
actual_deals=[],
expires_at=datetime(2026, 6, 1, tzinfo=UTC),
)
- assert (
- est.insufficient_data is False
- ), "insufficient_data must be False when median_price_rub>0 (#740)"
+ assert est.insufficient_data is False, (
+ "insufficient_data must be False when median_price_rub>0 (#740)"
+ )
def test_740_insufficient_data_serialized_in_model_dump() -> None:
diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py
index 22324746..82b0b0f7 100644
--- a/tradein-mvp/backend/tests/test_alerts_become_events.py
+++ b/tradein-mvp/backend/tests/test_alerts_become_events.py
@@ -155,9 +155,9 @@ def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None:
now=datetime(2026, 8, 6, tzinfo=UTC),
)
assert out["alert"] == 1
- assert any(
- "sber freshness" in t for t in event_texts(events)
- ), "отставание загрузки не стало событием — WARNING до GlitchTip не долетает"
+ assert any("sber freshness" in t for t in event_texts(events)), (
+ "отставание загрузки не стало событием — WARNING до GlitchTip не долетает"
+ )
def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None:
diff --git a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py
index 671d8f58..b12d02a5 100644
--- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py
+++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py
@@ -494,13 +494,13 @@ def test_migration_098_band_matches_settings_default() -> None:
from app.tasks.asking_to_sold_ratio import _PPM2_MIN
# Lower bound — matches the 30000 literal in migration 098.
- assert (
- _PPM2_MIN == 30_000
- ), f"_PPM2_MIN changed ({_PPM2_MIN}); update migration 098 seed literals to match"
+ assert _PPM2_MIN == 30_000, (
+ f"_PPM2_MIN changed ({_PPM2_MIN}); update migration 098 seed literals to match"
+ )
# Upper bound — matches the 1200000 literal in migration 098.
- assert (
- Settings().asking_ratio_ppm2_max == 1_200_000
- ), "asking_ratio_ppm2_max default changed; update migration 098 seed literals to match"
+ assert Settings().asking_ratio_ppm2_max == 1_200_000, (
+ "asking_ratio_ppm2_max default changed; update migration 098 seed literals to match"
+ )
# ── area_bucket() Python twin matches _AREA_ROOMS_BUCKET_SQL (#2620) ─────────
diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py
index 9cd7b5cd..9436d1f7 100644
--- a/tradein-mvp/backend/tests/test_auth_api.py
+++ b/tradein-mvp/backend/tests/test_auth_api.py
@@ -826,18 +826,18 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive(
# этого вердикта, а не вместо него.
assert probe_latencies, "проба не сделала ни одного запроса"
probe_latencies.sort()
- assert (
- probe_latencies[-1] < 0.5
- ), f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа"
+ assert probe_latencies[-1] < 0.5, (
+ f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа"
+ )
median_probe = probe_latencies[len(probe_latencies) // 2]
assert median_probe < verify_s, (
f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной "
f"сверки — цикл занят проверкой пароля, API стоит"
)
# Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс.
- assert (
- len(probe_latencies) >= 10
- ), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
+ assert len(probe_latencies) >= 10, (
+ f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят"
+ )
# 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает
# потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик).
diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py
index 8a386aa4..178986ab 100644
--- a/tradein-mvp/backend/tests/test_backfill_wave2.py
+++ b/tradein-mvp/backend/tests/test_backfill_wave2.py
@@ -59,17 +59,13 @@ class TestYandexTitleExtract:
assert not result.endswith(",")
def test_nbsp_replaced(self):
- html = (
- "— Екатеринбург,\xa0улица Горького, 36 — id 12 " "на Яндекс.Недвижимости"
- )
+ html = "— Екатеринбург,\xa0улица Горького, 36 — id 12 на Яндекс.Недвижимости"
result = self._extract(html)
assert result is not None
assert "\xa0" not in result
def test_case_insensitive_city(self):
- html = (
- "— ЕКАТЕРИНБУРГ, улица Горького, 36 — id 777 " "на Яндекс.Недвижимости"
- )
+ html = "— ЕКАТЕРИНБУРГ, улица Горького, 36 — id 777 на Яндекс.Недвижимости"
# regex is case-insensitive; city name retains original case
result = self._extract(html)
assert result is not None
@@ -101,8 +97,7 @@ async def test_backfill_yandex_addresses_saves_enriched():
from app.services.yandex_address_backfill import backfill_yandex_addresses
html_with_addr = (
- "Продажа — Екатеринбург, улица Горького, 36 — id 11 "
- "на Яндекс.Недвижимости"
+ "Продажа — Екатеринбург, улица Горького, 36 — id 11 на Яндекс.Недвижимости"
)
mock_resp = MagicMock()
diff --git a/tradein-mvp/backend/tests/test_backtest_regression_gate.py b/tradein-mvp/backend/tests/test_backtest_regression_gate.py
index 51a26de8..0275db49 100644
--- a/tradein-mvp/backend/tests/test_backtest_regression_gate.py
+++ b/tradein-mvp/backend/tests/test_backtest_regression_gate.py
@@ -46,9 +46,9 @@ _ABS_TOL = 1e-6
def _assert_match(path: str, expected: object, actual: object) -> None:
if isinstance(expected, dict):
assert isinstance(actual, dict), f"{path}: expected dict, got {type(actual).__name__}"
- assert (
- expected.keys() == actual.keys()
- ), f"{path}: key set differs\n expected={sorted(expected)}\n actual= {sorted(actual)}"
+ assert expected.keys() == actual.keys(), (
+ f"{path}: key set differs\n expected={sorted(expected)}\n actual= {sorted(actual)}"
+ )
for k in expected:
_assert_match(f"{path}.{k}", expected[k], actual[k])
elif isinstance(expected, list):
diff --git a/tradein-mvp/backend/tests/test_cian_state_parser.py b/tradein-mvp/backend/tests/test_cian_state_parser.py
index e8d7e4c4..a8665672 100644
--- a/tradein-mvp/backend/tests/test_cian_state_parser.py
+++ b/tradein-mvp/backend/tests/test_cian_state_parser.py
@@ -28,9 +28,7 @@ def _concat_push(mfe: str, entries: list[tuple[str, str]]) -> str:
(не эскейпленных дважды — `.concat([...])` несёт валидный JSON-массив как есть).
"""
items = ",".join(f'{{"key":"{k}","value":{v},"priority":0}}' for k, v in entries)
- return (
- f"window._cianConfig['{mfe}'] = (window._cianConfig['{mfe}'] || [])" f".concat([{items}]);"
- )
+ return f"window._cianConfig['{mfe}'] = (window._cianConfig['{mfe}'] || []).concat([{items}]);"
def _push(mfe: str, key: str, value: str) -> str:
diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_avito.py b/tradein-mvp/backend/tests/test_deactivate_stale_avito.py
index 9dffac4c..fcac27cf 100644
--- a/tradein-mvp/backend/tests/test_deactivate_stale_avito.py
+++ b/tradein-mvp/backend/tests/test_deactivate_stale_avito.py
@@ -253,17 +253,17 @@ def test_migration_100_updates_correct_source() -> None:
def test_migration_100_sets_enabled_true() -> None:
sql = _MIGRATION_100.read_text("utf-8")
# UPDATE … SET enabled = true
- assert re.search(
- r"enabled\s*=\s*true", sql, re.IGNORECASE
- ), "migration 100 must SET enabled = true"
+ assert re.search(r"enabled\s*=\s*true", sql, re.IGNORECASE), (
+ "migration 100 must SET enabled = true"
+ )
def test_migration_100_is_idempotent() -> None:
"""WHERE enabled = false ensures re-running after fix → 0 rows matched."""
sql = _MIGRATION_100.read_text("utf-8")
- assert re.search(
- r"enabled\s*=\s*false", sql, re.IGNORECASE
- ), "migration 100 must guard with AND enabled = false for idempotency"
+ assert re.search(r"enabled\s*=\s*false", sql, re.IGNORECASE), (
+ "migration 100 must guard with AND enabled = false for idempotency"
+ )
def test_migration_100_is_transactional() -> None:
diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py
index fbfc49f5..5ea5fce5 100644
--- a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py
+++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py
@@ -157,9 +157,9 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey
)
_, update_params = db.update_query
assert update_params is not None
- assert (
- update_params["ttl_days"] == effective
- ), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен"
+ assert update_params["ttl_days"] == effective, (
+ f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен"
+ )
def _read_cap_mult_from_migration(filename: str, *, source: str) -> int:
diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py
index 5e6e0129..9e437ef9 100644
--- a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py
+++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py
@@ -82,8 +82,7 @@ def test_domrf_window_does_not_collide_with_matview_refresh() -> None:
start, end = hours[0], hours[1]
matview_start, matview_end = 3, 4 # прод-значение scrape_schedules на 2026-08-06
assert end <= matview_start or start >= matview_end, (
- f"окно {start}-{end} пересекается с refresh_search_matview "
- f"{matview_start}-{matview_end}"
+ f"окно {start}-{end} пересекается с refresh_search_matview {matview_start}-{matview_end}"
)
@@ -248,9 +247,9 @@ def test_price_divergence_is_documented_as_structurally_empty() -> None:
"""Оставленный задел обязан говорить, чем он НЕ является сегодня."""
sql = MIGRATION.read_text(encoding="utf-8")
comment = sql.split("COMMENT ON VIEW v_price_divergence IS", 1)[1].split(";", 1)[0]
- assert (
- "match_or_create_listing" in comment
- ), "комментарий не называет причину пустоты — без неё это просто «пока пусто»"
+ assert "match_or_create_listing" in comment, (
+ "комментарий не называет причину пустоты — без неё это просто «пока пусто»"
+ )
# ─────────────────────────────────────────────────────────────────────────────
diff --git a/tradein-mvp/backend/tests/test_estimator_cohort.py b/tradein-mvp/backend/tests/test_estimator_cohort.py
index b6c572fb..0a4682c0 100644
--- a/tradein-mvp/backend/tests/test_estimator_cohort.py
+++ b/tradein-mvp/backend/tests/test_estimator_cohort.py
@@ -3,6 +3,7 @@
PR 10 (2026-05-24) — covers _target_cohort_range edge cases:
None, out-of-range, exact boundaries, first-match semantics.
"""
+
import os
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
@@ -16,26 +17,26 @@ from app.services.estimator import _target_cohort_range
@pytest.mark.parametrize(
("year", "expected"),
[
- (None, None), # no year → no cohort
- (1900, None), # out-of-range below → no cohort
- (1954, None), # below khrushchev floor
- (1955, (1955, 1969)), # khrushchev floor
- (1960, (1955, 1969)), # khrushchev mid
- (1969, (1955, 1969)), # khrushchev ceiling
- (1970, (1970, 1989)), # brezhnev floor
- (1978, (1970, 1989)), # brezhnev (target audit case)
- (1988, (1970, 1989)), # brezhnev pre-overlap fix held
- (1989, (1970, 1989)), # brezhnev ceiling
- (1990, (1990, 1999)), # late_soviet floor (post-PR10 fix)
- (1995, (1990, 1999)), # late_soviet mid
- (1999, (1990, 1999)), # late_soviet ceiling
- (2000, (2000, 2010)), # 2000s floor
- (2010, (2000, 2010)), # 2000s ceiling
- (2011, (2011, 2100)), # modern floor
- (2024, (2011, 2100)), # modern current
- (2100, (2011, 2100)), # modern ceiling
- (2101, None), # above modern ceiling → no cohort
- (2200, None), # far future → no cohort
+ (None, None), # no year → no cohort
+ (1900, None), # out-of-range below → no cohort
+ (1954, None), # below khrushchev floor
+ (1955, (1955, 1969)), # khrushchev floor
+ (1960, (1955, 1969)), # khrushchev mid
+ (1969, (1955, 1969)), # khrushchev ceiling
+ (1970, (1970, 1989)), # brezhnev floor
+ (1978, (1970, 1989)), # brezhnev (target audit case)
+ (1988, (1970, 1989)), # brezhnev pre-overlap fix held
+ (1989, (1970, 1989)), # brezhnev ceiling
+ (1990, (1990, 1999)), # late_soviet floor (post-PR10 fix)
+ (1995, (1990, 1999)), # late_soviet mid
+ (1999, (1990, 1999)), # late_soviet ceiling
+ (2000, (2000, 2010)), # 2000s floor
+ (2010, (2000, 2010)), # 2000s ceiling
+ (2011, (2011, 2100)), # modern floor
+ (2024, (2011, 2100)), # modern current
+ (2100, (2011, 2100)), # modern ceiling
+ (2101, None), # above modern ceiling → no cohort
+ (2200, None), # far future → no cohort
],
)
def test_target_cohort_range(year, expected):
diff --git a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py
index 16815488..ccaec2ae 100644
--- a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py
+++ b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py
@@ -57,9 +57,9 @@ def test_very_low_reliability_forces_confidence_low() -> None:
"""reliability == 'very_low' → confidence forced to 'low', regardless of
what _compute_confidence originally scored."""
for original in ("high", "medium", "low"):
- assert (
- _cap_confidence_by_reliability(original, "very_low") == "low"
- ), f"original={original!r} must be forced to 'low' under very_low reliability"
+ assert _cap_confidence_by_reliability(original, "very_low") == "low", (
+ f"original={original!r} must be forced to 'low' under very_low reliability"
+ )
def test_low_reliability_caps_confidence_at_medium() -> None:
diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold.py b/tradein-mvp/backend/tests/test_estimator_expected_sold.py
index 51e8a675..c0dece68 100644
--- a/tradein-mvp/backend/tests/test_estimator_expected_sold.py
+++ b/tradein-mvp/backend/tests/test_estimator_expected_sold.py
@@ -68,9 +68,9 @@ def test_bucket_clamping() -> None:
_get_asking_sold_ratio(db, rooms)
# First positional bind param is {"b": bucket}.
bind = db.execute.call_args_list[0].args[1]
- assert (
- bind["b"] == expected_bucket
- ), f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}"
+ assert bind["b"] == expected_bucket, (
+ f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}"
+ )
def test_bucket_keyed_by_area_when_area_known() -> None:
diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py
index ca8842ac..8cc771d6 100644
--- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py
+++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py
@@ -134,13 +134,13 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1(
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
assert est.median_price_rub > 0, "headline должен быть задан аналогами"
assert est.expected_sold_price_rub is not None
- assert (
- est.expected_sold_price_rub <= est.median_price_rub
- ), f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}"
+ assert est.expected_sold_price_rub <= est.median_price_rub, (
+ f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}"
+ )
assert est.expected_sold_per_m2 is not None
- assert (
- est.expected_sold_per_m2 <= est.median_price_per_m2
- ), "expected_sold_per_m2 превышает median_price_per_m2"
+ assert est.expected_sold_per_m2 <= est.median_price_per_m2, (
+ "expected_sold_per_m2 превышает median_price_per_m2"
+ )
# #1966: expected_sold range is now a calibrated ~80% PI around the point
# (point × [p10, p90] of sold/expected_sold), so it is NO LONGER bounded by the
# asking-IQR band — the high arm (point × 1.392) legitimately exceeds range_high.
diff --git a/tradein-mvp/backend/tests/test_estimator_floor_optional.py b/tradein-mvp/backend/tests/test_estimator_floor_optional.py
index 718cfe79..addcb2d9 100644
--- a/tradein-mvp/backend/tests/test_estimator_floor_optional.py
+++ b/tradein-mvp/backend/tests/test_estimator_floor_optional.py
@@ -4,6 +4,7 @@ Uses MagicMock for DB (consistent with other test_estimator_* tests — no live
The test validates schema construction + that floor=None is accepted without ValueError.
The actual NOT NULL guard is covered by migration 065.
"""
+
from __future__ import annotations
import os
diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
index a4033a0f..d3df789a 100644
--- a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
+++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py
@@ -298,9 +298,9 @@ def test_repair_coefficient_now_applies_to_thin_sample() -> None:
geo=_geo(),
dadata_qc_geo=None,
)
- assert (
- pr.median_price != pr_no_repair.median_price
- ), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample"
+ assert pr.median_price != pr_no_repair.median_price, (
+ "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample"
+ )
# ─────────────────────────────────────────────────────────────────────────────
diff --git a/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py b/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py
index fecc2c63..95ded602 100644
--- a/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py
+++ b/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py
@@ -77,18 +77,14 @@ def test_estimate_null_floor_skips_imv_and_cian() -> None:
async def _run() -> None:
with (
- patch("app.services.estimator.geocode",
- new=AsyncMock(return_value=_make_fake_geo())),
- patch("app.services.estimator.get_house_metadata",
- new=AsyncMock(return_value=None)),
+ patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
+ patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._get_or_fetch_imv_cached", new=imv_mock),
- patch("app.services.estimator._get_or_fetch_yandex_valuation_cached",
- new=yandex_mock),
+ patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=yandex_mock),
patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock),
- patch("app.services.estimator._get_asking_sold_ratio",
- return_value=(None, None)),
+ patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
result = await estimate_quality(payload, db)
@@ -120,18 +116,14 @@ def test_estimate_full_floor_awaits_imv_and_cian() -> None:
async def _run() -> None:
with (
- patch("app.services.estimator.geocode",
- new=AsyncMock(return_value=_make_fake_geo())),
- patch("app.services.estimator.get_house_metadata",
- new=AsyncMock(return_value=None)),
+ patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
+ patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._get_or_fetch_imv_cached", new=imv_mock),
- patch("app.services.estimator._get_or_fetch_yandex_valuation_cached",
- new=yandex_mock),
+ patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=yandex_mock),
patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock),
- patch("app.services.estimator._get_asking_sold_ratio",
- return_value=(None, None)),
+ patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
result = await estimate_quality(payload, db)
@@ -161,20 +153,19 @@ def test_estimate_yandex_timeout_degrades_no_5xx() -> None:
async def _run() -> None:
with (
- patch("app.services.estimator.geocode",
- new=AsyncMock(return_value=_make_fake_geo())),
- patch("app.services.estimator.get_house_metadata",
- new=AsyncMock(return_value=None)),
+ patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
+ patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
patch("app.services.estimator._fetch_deals", return_value=[]),
- patch("app.services.estimator._get_or_fetch_imv_cached",
- new=AsyncMock(return_value=None)),
- patch("app.services.estimator._get_or_fetch_yandex_valuation_cached",
- new=yandex_mock),
- patch("app.services.estimator.estimate_via_cian_valuation",
- new=AsyncMock(return_value=None)),
- patch("app.services.estimator._get_asking_sold_ratio",
- return_value=(None, None)),
+ patch(
+ "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
+ ),
+ patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=yandex_mock),
+ patch(
+ "app.services.estimator.estimate_via_cian_valuation",
+ new=AsyncMock(return_value=None),
+ ),
+ patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
result = await estimate_quality(payload, db)
@@ -203,26 +194,23 @@ def test_estimate_yandex_httpx_timeout_at_scraper_degrades_no_5xx() -> None:
fake_scraper = MagicMock()
fake_scraper.__aenter__ = AsyncMock(return_value=fake_scraper)
fake_scraper.__aexit__ = AsyncMock(return_value=False)
- fake_scraper.fetch_house_history = AsyncMock(
- side_effect=httpx.TimeoutException("read timeout")
- )
+ fake_scraper.fetch_house_history = AsyncMock(side_effect=httpx.TimeoutException("read timeout"))
async def _run() -> None:
with (
- patch("app.services.estimator.geocode",
- new=AsyncMock(return_value=_make_fake_geo())),
- patch("app.services.estimator.get_house_metadata",
- new=AsyncMock(return_value=None)),
+ patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
+ patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
patch("app.services.estimator._fetch_deals", return_value=[]),
- patch("app.services.estimator._get_or_fetch_imv_cached",
- new=AsyncMock(return_value=None)),
- patch("app.services.estimator.YandexValuationScraper",
- return_value=fake_scraper),
- patch("app.services.estimator.estimate_via_cian_valuation",
- new=AsyncMock(return_value=None)),
- patch("app.services.estimator._get_asking_sold_ratio",
- return_value=(None, None)),
+ patch(
+ "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
+ ),
+ patch("app.services.estimator.YandexValuationScraper", return_value=fake_scraper),
+ patch(
+ "app.services.estimator.estimate_via_cian_valuation",
+ new=AsyncMock(return_value=None),
+ ),
+ patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
):
result = await estimate_quality(payload, db)
diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py
index 1551978e..aba07b00 100644
--- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py
+++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py
@@ -135,9 +135,9 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
floor_ppm2 = 150_000 * 0.8 # 120_000
- assert (
- est.median_price_per_m2 >= floor_ppm2
- ), f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}"
+ assert est.median_price_per_m2 >= floor_ppm2, (
+ f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}"
+ )
# ── тест 2: radius median выше floor → no-op ─────────────────────────────────
@@ -164,9 +164,9 @@ def test_radius_median_above_dkp_floor_unchanged() -> None:
floor_ppm2 = 150_000 * 0.8 # 120_000
assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)"
# Медиана соответствует аналогам (~200k), а не floor
- assert (
- 180_000 <= est.median_price_per_m2 <= 220_000
- ), f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)"
+ assert 180_000 <= est.median_price_per_m2 <= 220_000, (
+ f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)"
+ )
# ── тест 3: dkp_raw is None → no-op ─────────────────────────────────────────
@@ -182,6 +182,6 @@ def test_no_dkp_raw_no_floor() -> None:
est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8)
# median ~80k, без dkp_raw floor не поднимает
- assert (
- est.median_price_per_m2 < 100_000
- ), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
+ assert est.median_price_per_m2 < 100_000, (
+ f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
+ )
diff --git a/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py b/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py
index 0e9dab86..2a717175 100644
--- a/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py
+++ b/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py
@@ -150,9 +150,9 @@ def _run_estimate_with_ratio_spy(
def test_ratio_called_exactly_once() -> None:
"""_get_asking_sold_ratio должен вызываться ровно один раз — после headline."""
_est, calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (0.80, "per_rooms"))
- assert (
- len(calls) == 1
- ), f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз"
+ assert len(calls) == 1, (
+ f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз"
+ )
# ── тест 2: anchor поднял headline → ratio вызван с финальным (high) ppm² ────
@@ -298,9 +298,9 @@ def test_ratio_tier_uses_final_headline_after_anchor() -> None:
ppm2_used = captured_anchor_ppm2[0]
assert ppm2_used is not None
# Финальный headline должен быть в зоне anchor (~300k), а НЕ в зоне radius (~105k).
- assert (
- ppm2_used > 200_000
- ), f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}"
+ assert ppm2_used > 200_000, (
+ f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}"
+ )
# ── тест 3: graceful — нет ratio → expected_sold_* = None, headline не изменён ──
diff --git a/tradein-mvp/backend/tests/test_estimator_repair_coef.py b/tradein-mvp/backend/tests/test_estimator_repair_coef.py
index 83393181..886c08a9 100644
--- a/tradein-mvp/backend/tests/test_estimator_repair_coef.py
+++ b/tradein-mvp/backend/tests/test_estimator_repair_coef.py
@@ -142,9 +142,9 @@ def test_excellent_to_needs_repair_ratio_matches_coef() -> None:
actual_ratio = excellent.median_price_rub / needs_repair.median_price_rub
# int() truncation on both medians ⇒ allow a small tolerance.
- assert (
- abs(actual_ratio - expected_ratio) < 0.005
- ), f"excellent/needs_repair ratio {actual_ratio:.5f} != coef ratio {expected_ratio:.5f}"
+ assert abs(actual_ratio - expected_ratio) < 0.005, (
+ f"excellent/needs_repair ratio {actual_ratio:.5f} != coef ratio {expected_ratio:.5f}"
+ )
def test_standard_is_baseline_noop() -> None:
diff --git a/tradein-mvp/backend/tests/test_estimator_source_quota.py b/tradein-mvp/backend/tests/test_estimator_source_quota.py
index 9b02b85c..a1745b7f 100644
--- a/tradein-mvp/backend/tests/test_estimator_source_quota.py
+++ b/tradein-mvp/backend/tests/test_estimator_source_quota.py
@@ -172,9 +172,9 @@ def test_source_quota_includes_all_when_supply_below_min() -> None:
result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000)
cian_count = sum(1 for r in result if r["source"] == "cian")
- assert (
- cian_count == 3
- ), f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}"
+ assert cian_count == 3, (
+ f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}"
+ )
assert len(result) == 8 # 5 avito + 3 cian
diff --git a/tradein-mvp/backend/tests/test_extract_short_addr.py b/tradein-mvp/backend/tests/test_extract_short_addr.py
index eb8b2b58..198a1810 100644
--- a/tradein-mvp/backend/tests/test_extract_short_addr.py
+++ b/tradein-mvp/backend/tests/test_extract_short_addr.py
@@ -8,15 +8,14 @@ from app.services.estimator import _extract_short_addr
def test_full_admin_chain() -> None:
- assert _extract_short_addr(
- "Свердловская область, г. Екатеринбург, Склад, ул. Заводская, д. 44-а"
- ) == "ул. Заводская, д. 44-а"
+ assert (
+ _extract_short_addr("Свердловская область, г. Екатеринбург, Склад, ул. Заводская, д. 44-а")
+ == "ул. Заводская, д. 44-а"
+ )
def test_russia_prefix() -> None:
- assert _extract_short_addr(
- "Россия, Екатеринбург, ул. Малышева, 1"
- ) == "ул. Малышева, 1"
+ assert _extract_short_addr("Россия, Екатеринбург, ул. Малышева, 1") == "ул. Малышева, 1"
def test_apt_stripped() -> None:
diff --git a/tradein-mvp/backend/tests/test_geo_precision.py b/tradein-mvp/backend/tests/test_geo_precision.py
index f0f2da67..bece66b0 100644
--- a/tradein-mvp/backend/tests/test_geo_precision.py
+++ b/tradein-mvp/backend/tests/test_geo_precision.py
@@ -76,9 +76,9 @@ async def test_geocode_missing_sets_city_precision_for_bare_city_address() -> No
# Inspect the UPDATE call parameters: geo_precision must be 'city'.
update_call = db.execute.call_args_list[1]
update_params = update_call[0][1] # positional arg[1] = params dict
- assert (
- update_params.get("precision") == "city"
- ), f"Expected precision='city' for bare city address, got {update_params.get('precision')!r}"
+ assert update_params.get("precision") == "city", (
+ f"Expected precision='city' for bare city address, got {update_params.get('precision')!r}"
+ )
@pytest.mark.asyncio
@@ -107,9 +107,9 @@ async def test_geocode_missing_sets_none_precision_for_precise_address() -> None
update_call = db.execute.call_args_list[1]
update_params = update_call[0][1]
- assert (
- update_params.get("precision") is None
- ), f"Expected precision=None for precise address, got {update_params.get('precision')!r}"
+ assert update_params.get("precision") is None, (
+ f"Expected precision=None for precise address, got {update_params.get('precision')!r}"
+ )
@pytest.mark.asyncio
@@ -138,9 +138,9 @@ async def test_geocode_missing_sets_city_precision_for_locality_confidence() ->
update_call = db.execute.call_args_list[1]
update_params = update_call[0][1]
- assert (
- update_params.get("precision") == "city"
- ), f"Expected precision='city' for locality confidence, got {update_params.get('precision')!r}"
+ assert update_params.get("precision") == "city", (
+ f"Expected precision='city' for locality confidence, got {update_params.get('precision')!r}"
+ )
# ── (b) estimator radius-analog SQL contains geo_precision exclusion ──────────
diff --git a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
index b414aefc..f6bd4605 100644
--- a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
+++ b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py
@@ -203,9 +203,9 @@ async def test_explicit_only_status_still_takes_exhausted_houses() -> None:
{"ids": list(_IDS)},
)
}
- assert (
- statuses[_H_EXHAUSTED] != "transient_error"
- ), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит"
+ assert statuses[_H_EXHAUSTED] != "transient_error", (
+ "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит"
+ )
# Автоповтора поверх явного запроса нет: pending не тронут.
assert statuses[_H_PENDING] == "pending"
assert result.retried == 0
diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py
index e0066101..a1d7e7ef 100644
--- a/tradein-mvp/backend/tests/test_matching.py
+++ b/tradein-mvp/backend/tests/test_matching.py
@@ -439,13 +439,13 @@ def test_match_house_advisory_lock_called_first():
sql_obj = first_call[0][0] # TextClause
bind = first_call[0][1] # dict
- assert "pg_advisory_xact_lock" in str(
- sql_obj
- ), f"first execute must be advisory lock, got: {sql_obj}"
+ assert "pg_advisory_xact_lock" in str(sql_obj), (
+ f"first execute must be advisory lock, got: {sql_obj}"
+ )
assert "fp" in bind, f"lock bind must include fp, got: {bind}"
- assert (
- isinstance(bind["fp"], str) and len(bind["fp"]) == 32
- ), f"fp must be 32-char sha256 hex, got: {bind.get('fp')!r}"
+ assert isinstance(bind["fp"], str) and len(bind["fp"]) == 32, (
+ f"fp must be 32-char sha256 hex, got: {bind.get('fp')!r}"
+ )
# ---------------------------------------------------------------------------
@@ -484,9 +484,9 @@ def test_geo_match_does_not_register_alias():
db, "n1", "ext-geo", address="улица Новая 3", lat=56.83, lon=60.59
)
assert (house_id, conf, method) == (22, 0.7, "geo_proximity")
- assert not any(
- "INSERT INTO house_address_aliases" in s for s in _executed_sqls(db)
- ), "geo match must not write an alias (P3)"
+ assert not any("INSERT INTO house_address_aliases" in s for s in _executed_sqls(db)), (
+ "geo match must not write an alias (P3)"
+ )
def test_geo_match_rejected_when_house_number_differs():
@@ -539,18 +539,18 @@ def test_bare_street_numberless_no_house_created():
)
assert (house_id, conf, method) == (None, 0.0, "no_house_number")
sqls = _executed_sqls(db)
- assert not any(
- "normalized_address = :na" in s for s in sqls
- ), "bare-street address must not run Tier 2b normalized_address lookup (P1)"
- assert not any(
- "INSERT INTO houses" in s for s in sqls
- ), "numberless address must not create a house (P1 extended)"
- assert not any(
- "INSERT INTO house_address_aliases" in s for s in sqls
- ), "numberless address must not register an alias (P1)"
- assert not any(
- "INSERT INTO house_sources" in s for s in sqls
- ), "numberless refusal must not upsert house_sources"
+ assert not any("normalized_address = :na" in s for s in sqls), (
+ "bare-street address must not run Tier 2b normalized_address lookup (P1)"
+ )
+ assert not any("INSERT INTO houses" in s for s in sqls), (
+ "numberless address must not create a house (P1 extended)"
+ )
+ assert not any("INSERT INTO house_address_aliases" in s for s in sqls), (
+ "numberless address must not register an alias (P1)"
+ )
+ assert not any("INSERT INTO house_sources" in s for s in sqls), (
+ "numberless refusal must not upsert house_sources"
+ )
def test_numberless_none_address_with_coords_no_house_created():
@@ -575,9 +575,9 @@ def test_numberless_none_address_with_coords_no_house_created():
db, "yandex", "ext-none-addr", address=None, lat=56.83, lon=60.59
)
assert (house_id, conf, method) == (None, 0.0, "no_house_number")
- assert not any(
- "INSERT INTO houses" in s for s in _executed_sqls(db)
- ), "address-NULL coords-only listing must not create a house (P1 extended)"
+ assert not any("INSERT INTO houses" in s for s in _executed_sqls(db)), (
+ "address-NULL coords-only listing must not create a house (P1 extended)"
+ )
def test_numberless_address_with_cadastral_creates_house():
@@ -611,9 +611,9 @@ def test_numberless_address_with_cadastral_creates_house():
building_cadastral_number="66:41:0000000:12345",
)
assert (house_id, conf, method) == (321, 1.0, "new")
- assert any(
- "INSERT INTO houses" in s for s in _executed_sqls(db)
- ), "numberless + cadastral must still create a house (cadastral = identity)"
+ assert any("INSERT INTO houses" in s for s in _executed_sqls(db)), (
+ "numberless + cadastral must still create a house (cadastral = identity)"
+ )
def test_insert_alias_noop_for_bare_street():
@@ -735,9 +735,9 @@ def test_tier2b_no_coords_no_city_token_skips_and_creates_new():
db, "avito", "ext-2b-bare", address="улица Ленина 100"
)
assert (house_id, conf, method) == (910, 1.0, "new")
- assert not any(
- "normalized_address = :na" in s for s in _executed_sqls(db)
- ), "bare common-street with no coords/city must not run a Tier-2b lookup"
+ assert not any("normalized_address = :na" in s for s in _executed_sqls(db)), (
+ "bare common-street with no coords/city must not run a Tier-2b lookup"
+ )
def test_tier2b_no_coords_with_city_token_matches():
@@ -792,12 +792,12 @@ def test_tier2a_coord_less_non_ekb_city_skips_both_lookups_and_creates_new():
)
assert (house_id, conf, method) == (701, 1.0, "new")
sqls = _executed_sqls(db)
- assert not any(
- "fingerprint = :fp" in s for s in sqls
- ), "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup"
- assert not any(
- "normalized_address = :na" in s for s in sqls
- ), "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup"
+ assert not any("fingerprint = :fp" in s for s in sqls), (
+ "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup"
+ )
+ assert not any("normalized_address = :na" in s for s in sqls), (
+ "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup"
+ )
def test_tier2a_coord_less_ekb_city_still_matches():
@@ -818,9 +818,9 @@ def test_tier2a_coord_less_ekb_city_still_matches():
db, "avito", "ext-2a-ekb", address="Екатеринбург, ул. Ленина, 5"
)
assert (house_id, conf, method) == (88, 0.9, "fingerprint")
- assert any(
- "fingerprint = :fp" in s for s in _executed_sqls(db)
- ), "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup"
+ assert any("fingerprint = :fp" in s for s in _executed_sqls(db)), (
+ "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup"
+ )
def test_tier2a_coord_less_bare_street_still_runs_tier2a():
@@ -872,9 +872,9 @@ def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups():
)
assert (house_id, conf, method) == (2777, 1.0, "new")
sqls = _executed_sqls(db)
- assert not any(
- "fingerprint = :fp" in s for s in sqls
- ), "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»"
+ assert not any("fingerprint = :fp" in s for s in sqls), (
+ "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»"
+ )
assert not any("normalized_address = :na" in s for s in sqls)
@@ -1085,13 +1085,13 @@ def test_field_priority_sources_are_lists_or_known_string_rules():
rules alongside list entries. Both are valid per LISTING_FIELD_PRIORITY type hint.
"""
for col, sources in HOUSE_FIELD_PRIORITY.items():
- assert (
- isinstance(sources, list) or sources in VALID_STRING_RULES
- ), f"HOUSE_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}"
+ assert isinstance(sources, list) or sources in VALID_STRING_RULES, (
+ f"HOUSE_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}"
+ )
for col, sources in LISTING_FIELD_PRIORITY.items():
- assert (
- isinstance(sources, list) or sources in VALID_STRING_RULES
- ), f"LISTING_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}"
+ assert isinstance(sources, list) or sources in VALID_STRING_RULES, (
+ f"LISTING_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}"
+ )
def test_update_canonical_fields_is_callable():
diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py
index b16be85b..51107d0c 100644
--- a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py
+++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py
@@ -65,9 +65,9 @@ def test_fias_tier_is_gone_from_create_path_but_alive_in_readonly() -> None:
# Ищем именно литерал method-значения ('"fias_exact"'), а не слово в комментарии:
# комментарий-надгробие про удалённый тир остаться должен, ветка — нет.
- assert '"fias_exact"' not in inspect.getsource(
- match_or_create_house
- ), "мёртвая fias-ветка вернулась в путь создания домов"
+ assert '"fias_exact"' not in inspect.getsource(match_or_create_house), (
+ "мёртвая fias-ветка вернулась в путь создания домов"
+ )
assert '"fias_exact"' in inspect.getsource(match_house_readonly)
@@ -104,9 +104,9 @@ def test_house_key_never_accepts_flat_cadastre() -> None:
assert "building_cadastral_number" in _params(match_or_create_house)
src = inspect.getsource(match_or_create_house)
- assert (
- "cad = building_cadastral_number\n" in src
- ), "в ключ дома вернулся фолбэк на кадастр квартиры"
+ assert "cad = building_cadastral_number\n" in src, (
+ "в ключ дома вернулся фолбэк на кадастр квартиры"
+ )
def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None:
@@ -128,6 +128,6 @@ def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None:
)
# save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать
# именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен.
- assert "city=lot_city" in inspect.getsource(
- save_listings
- ), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён"
+ assert "city=lot_city" in inspect.getsource(save_listings), (
+ "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён"
+ )
diff --git a/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py b/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py
index 72500fe3..34af6701 100644
--- a/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py
+++ b/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py
@@ -51,9 +51,9 @@ def test_migration_201_deletes_by_domain_not_id() -> None:
flat = _flat(_executable_sql())
assert "delete from scrape_proxies" in flat
assert "where url like '%mobileproxy.space%'" in flat
- assert (
- re.search(r"where\s+id\s*(=|in)", flat) is None
- ), "миграция не должна фильтровать по id — id разъезжается между средами"
+ assert re.search(r"where\s+id\s*(=|in)", flat) is None, (
+ "миграция не должна фильтровать по id — id разъезжается между средами"
+ )
def test_migration_201_is_idempotent_by_construction() -> None:
diff --git a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py
index 75da91a6..492fdb1c 100644
--- a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py
+++ b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py
@@ -300,9 +300,9 @@ def test_mikhaylovsk_has_only_cian_row() -> None:
РОВНО одну строку (cian), НЕ три."""
rows = _row_sources(_MIGRATION_262)
mikhaylovsk_providers = {p for p, s in rows if s == "mikhaylovsk"}
- assert mikhaylovsk_providers == {
- "cian"
- }, f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}"
+ assert mikhaylovsk_providers == {"cian"}, (
+ f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}"
+ )
# ── CITY_ANCHORS parity ─────────────────────────────────────────────────────
@@ -332,9 +332,9 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None:
slug for _p, slug in _row_sources(_MIGRATION_262)
}
orphaned = set(CITY_ANCHORS) - seeded_slugs
- assert (
- not orphaned
- ), f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
+ assert not orphaned, (
+ f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}"
+ )
def test_city_anchors_wave2_count_and_content() -> None:
@@ -398,9 +398,9 @@ def test_migration_262_windows_are_one_hour() -> None:
полночь, см. scraper_kit.orchestration.scheduler)."""
for provider, slug, start, end in _row_windows(_MIGRATION_262):
expected = (start + 1) % 24
- assert (
- end == expected
- ), f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})"
+ assert end == expected, (
+ f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})"
+ )
def test_migration_262_window_hours_satisfy_db_check_constraint() -> None:
diff --git a/tradein-mvp/backend/tests/test_password.py b/tradein-mvp/backend/tests/test_password.py
index a03e5c9f..8d1dd9e1 100644
--- a/tradein-mvp/backend/tests/test_password.py
+++ b/tradein-mvp/backend/tests/test_password.py
@@ -285,9 +285,9 @@ async def test_one_key_cannot_take_more_than_its_share(monkeypatch: pytest.Monke
async def _wait_inflight(n: int) -> None:
deadline = time.monotonic() + 5
while password_mod._verify_inflight < n:
- assert (
- time.monotonic() < deadline
- ), f"слотов занято {password_mod._verify_inflight} < {n}"
+ assert time.monotonic() < deadline, (
+ f"слотов занято {password_mod._verify_inflight} < {n}"
+ )
await asyncio.sleep(0.005)
flood = [
diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py
index 7d6d21ed..7789b8e0 100644
--- a/tradein-mvp/backend/tests/test_pdf_security.py
+++ b/tradein-mvp/backend/tests/test_pdf_security.py
@@ -522,9 +522,9 @@ def test_brand_not_taken_from_query_param_docstring() -> None:
sig = inspect.signature(estimate_pdf)
param_names = list(sig.parameters.keys())
- assert (
- "brand" not in param_names
- ), "estimate_pdf should NOT have a 'brand' query param after #7 fix"
+ assert "brand" not in param_names, (
+ "estimate_pdf should NOT have a 'brand' query param after #7 fix"
+ )
# ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ──
diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py
index a49f1a96..f539f67d 100644
--- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py
+++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py
@@ -435,7 +435,7 @@ def test_real_purge_deletes_only_anonymous_expired_estimates() -> None:
remaining_ids = {
str(r)
for r in db.execute(
- _t("SELECT id FROM trade_in_estimates " "WHERE id = ANY(CAST(:ids AS uuid[]))"),
+ _t("SELECT id FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"),
{"ids": [str(anon_id), str(pilot_id)]},
)
.scalars()
@@ -497,9 +497,9 @@ def test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly() -> None:
{"order_id": healthy_order, "id": str(healthy_id)},
)
db.commit()
- assert (
- task_mod._preflight_paid_candidates(db) == baseline
- ), "healthy paid row (retain_until set) must NOT raise the pre-flight count"
+ assert task_mod._preflight_paid_candidates(db) == baseline, (
+ "healthy paid row (retain_until set) must NOT raise the pre-flight count"
+ )
# Anomaly: retain_until NULL despite a payments row existing -- exactly
# the case the two DELETE safeguards exist for. Must raise by exactly one.
diff --git a/tradein-mvp/backend/tests/test_ratelimit.py b/tradein-mvp/backend/tests/test_ratelimit.py
index b9b71c1a..fdb75f74 100644
--- a/tradein-mvp/backend/tests/test_ratelimit.py
+++ b/tradein-mvp/backend/tests/test_ratelimit.py
@@ -183,9 +183,9 @@ def test_notify_path_bypasses_general_limiter_400_requests_zero_429(notify_clien
statuses = [
notify_client.post("/api/v1/trade-in/payments/notify").status_code for _ in range(400)
]
- assert all(
- code == 200 for code in statuses
- ), f"notify получил 429 хотя бы раз: {[c for c in statuses if c != 200]}"
+ assert all(code == 200 for code in statuses), (
+ f"notify получил 429 хотя бы раз: {[c for c in statuses if c != 200]}"
+ )
def test_general_limiter_still_active_for_other_paths(notify_client):
diff --git a/tradein-mvp/backend/tests/test_sber_index.py b/tradein-mvp/backend/tests/test_sber_index.py
index 61b7f1c7..ae70ad4f 100644
--- a/tradein-mvp/backend/tests/test_sber_index.py
+++ b/tradein-mvp/backend/tests/test_sber_index.py
@@ -203,8 +203,7 @@ def test_active_dashboards_match_captured_secondary_series() -> None:
d = _DASH[slug]
encoded = build_sber_route(d.slug, "643", d.extra_filter)
assert encoded in captured_b64, (
- f"active dashboard {slug!r} route not in captured-live set — "
- f"filter={d.extra_filter}"
+ f"active dashboard {slug!r} route not in captured-live set — filter={d.extra_filter}"
)
@@ -345,15 +344,15 @@ def test_decode_sber_response_real_fixture() -> None:
# Verify region label decoded correctly
regions = {r["ref_area"] for r in rows}
- assert (
- "Свердловская область" in regions
- ), f"Expected 'Свердловская область' in ref_area values, got: {regions}"
+ assert "Свердловская область" in regions, (
+ f"Expected 'Свердловская область' in ref_area values, got: {regions}"
+ )
# Verify secondary-market segment present
realty_vals = {r["realty"] for r in rows}
- assert any(
- "тори" in str(v) or "Вторичн" in str(v) for v in realty_vals
- ), f"Expected secondary-market label in realty field, got: {realty_vals}"
+ assert any("тори" in str(v) or "Вторичн" in str(v) for v in realty_vals), (
+ f"Expected secondary-market label in realty field, got: {realty_vals}"
+ )
# ---------------------------------------------------------------------------
@@ -434,9 +433,9 @@ async def test_pull_sber_indices_upsert_on_conflict_idempotent() -> None:
# Must NOT contain :: type casts (psycopg v3 rule)
import re
- assert not re.search(
- r":[a-z_]+::[a-z]", upsert_sql
- ), "SQL must not contain ::type casts — use CAST(... AS type) instead"
+ assert not re.search(r":[a-z_]+::[a-z]", upsert_sql), (
+ "SQL must not contain ::type casts — use CAST(... AS type) instead"
+ )
@pytest.mark.asyncio
@@ -532,9 +531,9 @@ async def test_pull_sber_indices_asking_benchmark_logged(caplog: pytest.LogCaptu
dashboards=[_DASH["dinamika-tsen-obyavlenii"]],
)
- assert any(
- "benchmark" in record.message for record in caplog.records
- ), "Expected a benchmark log line after upserting dinamika-tsen-obyavlenii"
- assert any(
- "115000" in record.message for record in caplog.records
- ), "Benchmark log should include the latest asking index value"
+ assert any("benchmark" in record.message for record in caplog.records), (
+ "Expected a benchmark log line after upserting dinamika-tsen-obyavlenii"
+ )
+ assert any("115000" in record.message for record in caplog.records), (
+ "Benchmark log should include the latest asking index value"
+ )
diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py
index 813d78a2..ea7f0b5d 100644
--- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py
+++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py
@@ -477,9 +477,9 @@ def test_data_quality_pct_in_range(client: TestClient) -> None:
for src in body["sources"]:
for field_name, pct in src["fields"].items():
- assert (
- 0.0 <= pct <= 100.0
- ), f"source={src['source']} field={field_name} pct={pct} вне [0,100]"
+ assert 0.0 <= pct <= 100.0, (
+ f"source={src['source']} field={field_name} pct={pct} вне [0,100]"
+ )
# ── Security: текст исключения не утекает в HTTP-ответ (#2234) ────────────────
diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py
index b973e19a..781dd7c9 100644
--- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py
+++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py
@@ -164,9 +164,9 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None:
real_registry = build_registry(build_product_handlers(ctx=None)) # type: ignore[arg-type]
for source in _PRODUCT_SOURCES | _KIT_NATIVE_SOURCES:
- assert (
- resolve_handler(source, real_registry) is not None
- ), f"real build_product_handlers()/build_registry() misses source={source}"
+ assert resolve_handler(source, real_registry) is not None, (
+ f"real build_product_handlers()/build_registry() misses source={source}"
+ )
def test_kit_native_handler_set() -> None:
diff --git a/tradein-mvp/backend/tests/test_segment_guard_1186.py b/tradein-mvp/backend/tests/test_segment_guard_1186.py
index 289ff36d..78f7a465 100644
--- a/tradein-mvp/backend/tests/test_segment_guard_1186.py
+++ b/tradein-mvp/backend/tests/test_segment_guard_1186.py
@@ -40,9 +40,9 @@ def _norm(s: str) -> str:
def test_common_where_has_canonical_guard() -> None:
"""_COMMON_WHERE используется Tier S и Tier H — должен содержать канон-предикат."""
- assert _GUARD_RE.search(
- est_mod._COMMON_WHERE
- ), "_COMMON_WHERE lacks novostroyki guard — Tier S/H comp set contaminated"
+ assert _GUARD_RE.search(est_mod._COMMON_WHERE), (
+ "_COMMON_WHERE lacks novostroyki guard — Tier S/H comp set contaminated"
+ )
def test_common_where_no_old_neq_form() -> None:
@@ -95,18 +95,18 @@ _REDERIVE_SQL_TEXT = str(ratio_mod._REDERIVE_SQL.text)
def test_ask_side_cte_has_guard() -> None:
"""ask_side CTE в _REDERIVE_SQL (per-rooms asking медиана) — guard обязателен."""
- assert _GUARD_RE.search(
- _REDERIVE_SQL_TEXT
- ), "ask_side CTE in _REDERIVE_SQL lacks novostroyki guard"
+ assert _GUARD_RE.search(_REDERIVE_SQL_TEXT), (
+ "ask_side CTE in _REDERIVE_SQL lacks novostroyki guard"
+ )
def test_ask_global_cte_has_guard() -> None:
"""ask_global CTE в _REDERIVE_SQL (global fallback asking медиана) — guard обязателен."""
# _REDERIVE_SQL содержит два `ask_global`-блока; ищем оба через count.
matches = len(_GUARD_RE.findall(_REDERIVE_SQL_TEXT))
- assert (
- matches >= 2
- ), f"Expected ≥2 guard occurrences in _REDERIVE_SQL (ask_side + ask_global), got {matches}"
+ assert matches >= 2, (
+ f"Expected ≥2 guard occurrences in _REDERIVE_SQL (ask_side + ask_global), got {matches}"
+ )
# ── Поведенческие тесты: _fetch_analogs (mock DB) ────────────────────────────
@@ -164,9 +164,9 @@ def test_fetch_analogs_sql_guard_present_novostroyki_excluded() -> None:
"""
src = inspect.getsource(est_mod._fetch_analogs)
# Guard должен присутствовать хотя бы один раз в теле функции.
- assert _GUARD_RE.search(
- src
- ), "_fetch_analogs SQL no longer contains novostroyki guard — guard was removed!"
+ assert _GUARD_RE.search(src), (
+ "_fetch_analogs SQL no longer contains novostroyki guard — guard was removed!"
+ )
def test_null_segment_listing_not_excluded_by_guard() -> None:
@@ -185,9 +185,9 @@ def test_null_segment_listing_not_excluded_by_guard() -> None:
)
# NULL-segment listing должен присутствовать в результате (не отброшен Python-стороной).
- assert any(
- r.get("source") == "avito" for r in result
- ), "NULL-segment listing was unexpectedly excluded from comp set"
+ assert any(r.get("source") == "avito" for r in result), (
+ "NULL-segment listing was unexpectedly excluded from comp set"
+ )
def test_vtorichka_segment_listing_included() -> None:
@@ -234,12 +234,12 @@ def test_no_neq_novostroyki_form_in_estimator() -> None:
Канон: IS NULL OR = 'vtorichka'.
"""
src = _estimator_full_src()
- assert (
- "<> 'novostroyki'" not in src
- ), "estimator.py contains deprecated `<> 'novostroyki'` form — use canonical guard"
- assert (
- "!= 'novostroyki'" not in src
- ), "estimator.py contains deprecated `!= 'novostroyki'` form — use canonical guard"
+ assert "<> 'novostroyki'" not in src, (
+ "estimator.py contains deprecated `<> 'novostroyki'` form — use canonical guard"
+ )
+ assert "!= 'novostroyki'" not in src, (
+ "estimator.py contains deprecated `!= 'novostroyki'` form — use canonical guard"
+ )
def test_anchor_comps_no_dead_listing_segment_param() -> None:
@@ -269,6 +269,6 @@ def test_fetch_anchor_comps_tier_c_canonical_guard_not_parametric() -> None:
"must be hardcoded canonical guard"
)
# И канон-guard на месте (Tier C-блок).
- assert _GUARD_RE.search(
- src
- ), "_fetch_anchor_comps lacks canonical guard after removing parametric form"
+ assert _GUARD_RE.search(src), (
+ "_fetch_anchor_comps lacks canonical guard after removing parametric form"
+ )
diff --git a/tradein-mvp/backend/tests/test_snapshot_writer.py b/tradein-mvp/backend/tests/test_snapshot_writer.py
index a779c98d..6cde5c51 100644
--- a/tradein-mvp/backend/tests/test_snapshot_writer.py
+++ b/tradein-mvp/backend/tests/test_snapshot_writer.py
@@ -303,9 +303,9 @@ def test_save_detail_enrichment_oph_on_conflict_constraint():
sqls = _get_all_sqls(db)
oph_sqls = [s for s in sqls if "offer_price_history" in s]
assert oph_sqls, "INSERT offer_price_history не найден"
- assert (
- "offer_price_history_listing_change_uq" in oph_sqls[0]
- ), "ON CONFLICT должен ссылаться на UNIQUE constraint"
+ assert "offer_price_history_listing_change_uq" in oph_sqls[0], (
+ "ON CONFLICT должен ссылаться на UNIQUE constraint"
+ )
def test_save_detail_enrichment_skips_price_change_without_change_time():
diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py
index 003883e1..570711db 100644
--- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py
+++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py
@@ -317,15 +317,15 @@ def test_street_regex_word_boundary_no_false_positive() -> None:
# Must NOT match — 'мира' appears inside 'Макарова' substring check skipped
# but more critically — 'мира' appears inside 'Адмирала Макарова'
false_positive_addr = "улица Адмирала Макарова"
- assert not re.search(
- pattern, false_positive_addr, re.IGNORECASE
- ), f"Pattern {pattern!r} should NOT match {false_positive_addr!r}"
+ assert not re.search(pattern, false_positive_addr, re.IGNORECASE), (
+ f"Pattern {pattern!r} should NOT match {false_positive_addr!r}"
+ )
# Must match — exact word
true_positive_addr = "улица Мира 5"
- assert re.search(
- pattern, true_positive_addr, re.IGNORECASE
- ), f"Pattern {pattern!r} should match {true_positive_addr!r}"
+ assert re.search(pattern, true_positive_addr, re.IGNORECASE), (
+ f"Pattern {pattern!r} should match {true_positive_addr!r}"
+ )
def test_street_regex_param_passed_to_db(trade_in_app: FastAPI) -> None:
@@ -355,6 +355,6 @@ def test_street_regex_param_passed_to_db(trade_in_app: FastAPI) -> None:
assert "street_regex" in params, f"street_regex not in params: {params}"
regex_val = params["street_regex"]
# Must contain word-boundary anchors
- assert (
- r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m")
- ), f"Expected word-boundary in regex, got: {regex_val!r}"
+ assert r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m"), (
+ f"Expected word-boundary in regex, got: {regex_val!r}"
+ )
diff --git a/tradein-mvp/backend/tests/test_yandex_city_sweep.py b/tradein-mvp/backend/tests/test_yandex_city_sweep.py
index b50729a9..fb3f895a 100644
--- a/tradein-mvp/backend/tests/test_yandex_city_sweep.py
+++ b/tradein-mvp/backend/tests/test_yandex_city_sweep.py
@@ -219,6 +219,6 @@ def test_combos_sweep_timeout_substantially_larger_than_anchor_timeout() -> None
f"combos-mode will still timeout mid-sweep"
)
# И намного > ANCHOR_TIMEOUT_SEC (240s)
- assert (
- sweep_timeout > ANCHOR_TIMEOUT_SEC * 4
- ), f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s"
+ assert sweep_timeout > ANCHOR_TIMEOUT_SEC * 4, (
+ f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s"
+ )
diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py
index c6196e84..638c5042 100644
--- a/tradein-mvp/backend/tests/test_yandex_valuation.py
+++ b/tradein-mvp/backend/tests/test_yandex_valuation.py
@@ -416,9 +416,9 @@ def test_area_regex_rejects_year_concat():
item = YandexValuationScraper._parse_item_text(text)
# The "202452,2" token has digits jammed before it (no separator), so the
# tightened regex must NOT match it.
- assert (
- item is None or item.area_m2 is None
- ), f"expected no area match for concat token, got {item.area_m2 if item else 'None item'}"
+ assert item is None or item.area_m2 is None, (
+ f"expected no area match for concat token, got {item.area_m2 if item else 'None item'}"
+ )
def test_area_regex_accepts_isolated_token():
@@ -474,9 +474,9 @@ def test_area_regex_two_komn_chunk():
text = "8 000 000 ₽ за м²2-комнатная 52,2 м² 5 этаж 10.05.2024 8 000 000 ₽ В продаже"
item = YandexValuationScraper._parse_item_text(text)
assert item is not None
- assert (
- item.area_m2 == 52.2
- ), f"2-комн area must parse despite preceding tokens, got {item.area_m2}"
+ assert item.area_m2 == 52.2, (
+ f"2-комн area must parse despite preceding tokens, got {item.area_m2}"
+ )
def test_area_regex_still_blocks_year_concat():
diff --git a/tradein-mvp/deploy/backup-tradein-db.sh b/tradein-mvp/deploy/backup-tradein-db.sh
index 39eb3644..95bfea43 100755
--- a/tradein-mvp/deploy/backup-tradein-db.sh
+++ b/tradein-mvp/deploy/backup-tradein-db.sh
@@ -5,8 +5,10 @@
# Запускается из cron на прод-хосте. pg_dump идёт через `docker exec`
# (локальный сокет внутри контейнера — пароль не нужен).
#
-# Crontab пример (ежедневно в 04:30 UTC):
-# 30 4 * * * /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /tmp/tradein-backup.log 2>&1
+# Crontab (ежедневно в 04:30 по времени хоста = 01:30 UTC; на проде именно так, #3005).
+# Вызывать через `bash `, а не сырым путём — тогда потеря бита +x не сломает
+# бэкап молча (инцидент #71 у основного бэкапа):
+# 30 4 * * * bash /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /tmp/tradein-backup.log 2>&1
#
# Restore:
# gunzip -c tradein-YYYYMMDD-HHMMSS.sql.gz | \
@@ -56,7 +58,7 @@ verify_dump_integrity() {
log "ОШИБКА: ${label} не прошёл проверку gzip -> $file" >&2
return 1
fi
- if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF "$trailer"; then
+ if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then
log "ОШИБКА: ${label} без трейлера '${trailer}' -> $file (дамп оборван?)" >&2
return 1
fi
@@ -133,7 +135,7 @@ if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" &&
-v "$BACKUP_DIR":/backup:ro \
amazon/aws-cli:latest \
--endpoint-url "$S3_ENDPOINT" \
- s3 cp "/backup/$(basename "$f")" "s3://${S3_BUCKET}/"
+ s3 cp --no-progress "/backup/$(basename "$f")" "s3://${S3_BUCKET}/"
done
log "Выгрузка в S3 ok"
else
diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml
index c68d74c2..2f9914bd 100644
--- a/tradein-mvp/docker-compose.prod.yml
+++ b/tradein-mvp/docker-compose.prod.yml
@@ -125,6 +125,70 @@ services:
# тяжёлых PostGIS-сортировок бэктеста/эстиматора + autovacuum)
mem_limit: 3g
memswap_limit: 3g
+ # ⚠️ При переезде на выделенный сервер (#2989) mem_limit поднимать ДО правки
+ # shared_buffers, а не после: лимит контейнера срабатывает РАНЬШЕ postgresql.conf,
+ # и shared_buffers=16GB при mem_limit=3g даёт OOM-kill на старте. Целевое на
+ # 64 ГБ — 40-48g, а не 64g: нужен запас под page cache ВНЕ контейнера.
+ shm_size: 512m
+ # /dev/shm по умолчанию 64 МБ. Параллельные воркеры кладут туда shared memory
+ # segments; на тяжёлых PostGIS-сортировках это «could not resize shared memory».
+ #
+ # ── Конфигурация Postgres (#2991) ─────────────────────────────────────────
+ # До этого контейнер шёл на ПОЛНОСТЬЮ стоковых настройках. Замер прода 2026-08-20:
+ # 169 млрд blks_read за 91,75 сут ≈ 175 МБ/с мимо кеша при shared_buffers=128 МБ.
+ #
+ # Значения подобраны под ТЕКУЩИЙ сервер и НЕ выходят за mem_limit=3g.
+ # После переезда пересчитать под 64 ГБ.
+ command:
+ - postgres
+ # Память. 768MB shared_buffers — 6× от стоковых 128MB, с запасом внутри 3g
+ # (idle-замер контейнера был 292 МБ, work_mem-спайки и autovacuum сверху).
+ - -c
+ - shared_buffers=768MB
+ # effective_cache_size — подсказка планировщику, НЕ аллокация. На хосте
+ # MemAvailable 6,1 ГБ, поэтому 6GB честно отражает доступный page cache.
+ - -c
+ - effective_cache_size=6GB
+ - -c
+ - work_mem=16MB
+ # maintenance_work_mem: autovacuum по listings идёт 193 раза в сутки,
+ # с 64MB каждый проход перечитывает индексы лишними итерациями.
+ - -c
+ - maintenance_work_mem=256MB
+ # Чекпойнты. ГЛАВНОЕ: 288 чекпойнтов в сутки — это ровно 24ч/288 = 5 минут,
+ # то есть дефолтный checkpoint_timeout, а НЕ max_wal_size. При WAL 7 ГБ/сут
+ # лимит в 1 ГБ дал бы 7 чекпойнтов, а не 288. Поэтому поднимаем ИМЕННО
+ # timeout — иначе правка max_wal_size ничего бы не изменила.
+ # Реже чекпойнты → реже full-page images (сейчас 55-86% всего объёма WAL).
+ - -c
+ - checkpoint_timeout=30min
+ - -c
+ - checkpoint_completion_target=0.9
+ # max_wal_size=4GB, а не 8GB: на диске сейчас всего 38 ГБ свободно, а pg_wal
+ # растёт до этого значения. При timeout=30min и 7 ГБ/сут между чекпойнтами
+ # накапливается ~0,15 ГБ, так что 4GB — потолок с большим запасом.
+ - -c
+ - max_wal_size=4GB
+ - -c
+ - min_wal_size=1GB
+ # Самая дешёвая победа при доле FPI 55-86%. zstd доступен с PG15, у нас 16.
+ - -c
+ - wal_compression=zstd
+ # Диск NVMe, а стоковый random_page_cost=4 — настройка под HDD: планировщик
+ # систематически недооценивает индексные сканы и уходит в Seq Scan.
+ - -c
+ - random_page_cost=1.1
+ - -c
+ - effective_io_concurrency=200
+ # pg_stat_statements: расширение есть в образе, но не подключено. Без него
+ # следующий разбор снова придётся вести по косвенным признакам.
+ # shared_preload_libraries требует рестарта — поэтому именно здесь.
+ - -c
+ - shared_preload_libraries=pg_stat_statements
+ - -c
+ - pg_stat_statements.max=5000
+ - -c
+ - pg_stat_statements.track=top
logging: *default-logging
environment:
POSTGRES_DB: tradein
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py
index 58ad9b0d..d250af19 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py
@@ -382,6 +382,7 @@ def save_listings(
updated = 0
skipped = 0
reconciled = 0 # UPDATE by (source,source_id) при dedup_hash-дрейфе
+ unchanged_today = 0 # #2992: гейт апсерта пропустил неизменную, уже виденную сегодня строку
matched = 0
match_failures = 0
geo_guard_dropped = 0 # city NULL'ен из-за geo-guard (лот вне city_radius_km от anchor'а)
@@ -423,7 +424,7 @@ def save_listings(
# never the old ones, so a lightweight SELECT is the clean way.
# None → row did not exist yet (fresh insert) → always process.
prior_row = db.execute(
- text("SELECT card_hash, last_seen_at FROM listings WHERE dedup_hash = :dedup"),
+ text("SELECT id, card_hash, last_seen_at FROM listings WHERE dedup_hash = :dedup"),
{"dedup": dedup},
).fetchone()
prior_card_hash = prior_row.card_hash if prior_row is not None else None
@@ -681,6 +682,81 @@ def save_listings(
EXCLUDED.newbuilding_url, listings.newbuilding_url
),
card_hash = EXCLUDED.card_hash
+ -- #2992: гейт неизменной строки. Без него каждый повторный скрейп
+ -- переписывал 41 колонку, заново тостил description и плодил TOAST-чанки:
+ -- прод 2026-08-20 — n_tup_upd 20.87 млн на 107 тыс. строк (198 апдейтов на
+ -- строку за 91 день), HOT 0.44 %, TOAST 15 ГБ при ~230 МБ полезного, WAL
+ -- 7 ГБ/сутки. Детектор изменений (card_hash) есть рядом, но гейта в апсерте
+ -- не было.
+ --
+ -- ДВА условия, при любом из которых апдейт ИДЁТ:
+ -- (1) итоговое (post-COALESCE) значение хотя бы одной контентной колонки
+ -- отличается от текущего — IS DISTINCT FROM по кортежу, NULL-safe.
+ -- Сравниваются ИТОГОВЫЕ значения, а не EXCLUDED: так COALESCE-
+ -- дозаполнение (адрес, город, сегмент…) по-прежнему проходит — если
+ -- дописал, строка отличается. Гейт по card_hash был забракован именно
+ -- за то, что ломает дозаполнение.
+ -- (2) last_seen_at ещё не сегодняшний по МСК — метка живости ОБЯЗАНА
+ -- сдвигаться хотя бы раз в сутки: на неё завязаны деактиватор (TTL),
+ -- снапшоты (is_active = last_seen_at > now()-7d), эстиматор
+ -- (scraped_at > NOW()-14d, #2206), монитор свежести. Это та же
+ -- семантика, что у skip_seen_today (МСК-день), но в SQL, race-free и
+ -- для ВСЕХ путей записи, а не трёх full_load.
+ --
+ -- При пропуске RETURNING пуст → listing_id берётся из pre-read (prior_row.id),
+ -- downstream (снапшот, матчинг, listing_sources) идёт как прежде.
+ -- listing_sources получает симметричный гейт (matching/listings.py) — иначе
+ -- разъехались бы listings.last_seen_at и listing_sources.last_seen_at,
+ -- которые сегодня равны у 100 % пар.
+ WHERE (
+ listings.price_rub, listings.price_per_m2, listings.living_area_m2,
+ listings.bedrooms_count, listings.balconies_count, listings.loggias_count,
+ listings.description_minhash, listings.cadastral_number,
+ listings.building_cadastral_number, listings.phones, listings.is_homeowner,
+ listings.is_pro_seller, listings.bargain_allowed, listings.sale_type,
+ listings.metro_stations, listings.listing_date, listings.area_m2,
+ listings.address, listings.city, listings.kitchen_area_m2,
+ listings.ceiling_height_m, listings.mortgage_available,
+ listings.is_apartments, listings.is_rosreestr_checked,
+ listings.listing_segment, listings.publish_date, listings.days_on_market,
+ listings.description, listings.agency_name, listings.yandex_offer_id,
+ listings.predicted_price_rub, listings.predicted_price_min,
+ listings.predicted_price_max, listings.price_trend,
+ listings.price_previous_rub, listings.newbuilding_id,
+ listings.newbuilding_url, listings.card_hash, listings.is_active
+ ) IS DISTINCT FROM (
+ EXCLUDED.price_rub, EXCLUDED.price_per_m2, EXCLUDED.living_area_m2,
+ EXCLUDED.bedrooms_count, EXCLUDED.balconies_count, EXCLUDED.loggias_count,
+ EXCLUDED.description_minhash, EXCLUDED.cadastral_number,
+ EXCLUDED.building_cadastral_number, EXCLUDED.phones, EXCLUDED.is_homeowner,
+ EXCLUDED.is_pro_seller, EXCLUDED.bargain_allowed, EXCLUDED.sale_type,
+ EXCLUDED.metro_stations,
+ COALESCE(EXCLUDED.listing_date, listings.listing_date),
+ COALESCE(EXCLUDED.area_m2, listings.area_m2),
+ COALESCE(listings.address, EXCLUDED.address),
+ COALESCE(EXCLUDED.city, listings.city),
+ COALESCE(EXCLUDED.kitchen_area_m2, listings.kitchen_area_m2),
+ COALESCE(EXCLUDED.ceiling_height_m, listings.ceiling_height_m),
+ COALESCE(EXCLUDED.mortgage_available, listings.mortgage_available),
+ COALESCE(EXCLUDED.is_apartments, listings.is_apartments),
+ COALESCE(EXCLUDED.is_rosreestr_checked, listings.is_rosreestr_checked),
+ COALESCE(EXCLUDED.listing_segment, listings.listing_segment),
+ COALESCE(EXCLUDED.publish_date, listings.publish_date),
+ COALESCE(EXCLUDED.days_on_market, listings.days_on_market),
+ COALESCE(EXCLUDED.description, listings.description),
+ COALESCE(EXCLUDED.agency_name, listings.agency_name),
+ COALESCE(EXCLUDED.yandex_offer_id, listings.yandex_offer_id),
+ COALESCE(EXCLUDED.predicted_price_rub, listings.predicted_price_rub),
+ COALESCE(EXCLUDED.predicted_price_min, listings.predicted_price_min),
+ COALESCE(EXCLUDED.predicted_price_max, listings.predicted_price_max),
+ COALESCE(EXCLUDED.price_trend, listings.price_trend),
+ COALESCE(EXCLUDED.price_previous_rub, listings.price_previous_rub),
+ COALESCE(EXCLUDED.newbuilding_id, listings.newbuilding_id),
+ COALESCE(EXCLUDED.newbuilding_url, listings.newbuilding_url),
+ EXCLUDED.card_hash, true
+ )
+ OR (listings.last_seen_at AT TIME ZONE 'Europe/Moscow')::date
+ IS DISTINCT FROM (statement_timestamp() AT TIME ZONE 'Europe/Moscow')::date
RETURNING id, (xmax = 0) AS inserted
"""
)
@@ -809,6 +885,11 @@ def save_listings(
inserted += 1
else:
updated += 1
+ elif prior_row is not None:
+ # #2992: гейт пропустил апдейт — строка не изменилась и уже видена
+ # сегодня. listing_id известен из pre-read; downstream идёт как прежде.
+ listing_id = int(prior_row.id)
+ unchanged_today += 1
# ── Snapshot: point-in-time observation in listings_snapshots ───
# Fault-tolerant: failure here MUST NOT abort the listings batch.
@@ -881,11 +962,12 @@ def save_listings(
db.commit()
logger.info(
- "save_listings: source=%s inserted=%d updated=%d reconciled=%d "
+ "save_listings: source=%s inserted=%d updated=%d unchanged_today=%d reconciled=%d "
"skipped_seen_today=%d matched=%d match_failures=%d geo_guard_dropped=%d (total %d)",
lots[0].source if lots else "?",
inserted,
updated,
+ unchanged_today,
reconciled,
skipped,
matched,
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
index 0f1bfefb..31647a6b 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py
@@ -3147,6 +3147,14 @@ class CianFullLoadCounters:
# Без этого счётчика «сколько бакетов чекпоинт не покрывает» видно только грепом
# логов, которые теряются при редеплое.
partial_buckets: int = 0
+ # Новостройки, отброшенные фильтром secondary_only ПОСЛЕ скачивания и разбора
+ # (#1781). Ровно тот же довод, что у partial_buckets выше: счётчик логировался
+ # (`dropped_nb=` в cian/serp.py), но не сохранялся, и ответить «сколько инвентаря
+ # выбрасывает полный обход» задним числом было нечем — логи за нужную дату уже
+ # ротировались. Заодно это цена вопроса при обсуждении secondary_only=False:
+ # страницы с этими лотами УЖЕ скачаны, лимит страниц и антибан-бюджет за них
+ # уже заплачены, выбрасывается только результат разбора.
+ dropped_novostroyki: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
@@ -3167,6 +3175,7 @@ async def run_cian_full_load(
concurrency: int = 5,
resume_run_id: int | None = None,
region_code: int = DEFAULT_REGION_CODE,
+ secondary_only: bool = True,
) -> CianFullLoadCounters:
"""Exhaustive региональный сбор Cian ЕКБ вторички (БЕЗ anchor'ов).
@@ -3325,18 +3334,21 @@ async def run_cian_full_load(
await scraper.fetch_all_secondary(
price_cap_per_bucket=price_cap_per_bucket,
concurrency=concurrency,
- secondary_only=True,
+ secondary_only=secondary_only,
on_bucket=_on_bucket,
on_progress=_on_progress,
skip_buckets=skip_set if skip_set else None,
)
+ counters.dropped_novostroyki = getattr(scraper, "last_dropped_nb", 0)
logger.info(
- "cian-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d",
+ "cian-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d "
+ "dropped_novostroyki=%d",
run_id,
counters.unique_fetched,
counters.saved_inserted,
counters.saved_updated,
+ counters.dropped_novostroyki,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py
index 67b5c302..97cddd8e 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py
@@ -122,6 +122,10 @@ class CianScraper(BaseScraper):
base_url = "https://ekb.cian.ru"
# Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра.
request_delay_sec = 5.0 # консервативно: Cian менее агрессивен чем Avito, но 5s безопасно
+ # Сколько новостроек отброшено фильтром secondary_only за последний прогон
+ # fetch_all_secondary (#1781). Класс-дефолт нужен, чтобы атрибут читался даже
+ # если прогон упал до первого бакета.
+ last_dropped_nb: int = 0
def __init__(
self,
@@ -401,6 +405,9 @@ class CianScraper(BaseScraper):
"""
_buckets = rooms_buckets if rooms_buckets is not None else _DEFAULT_ROOMS_BUCKETS
seen: dict[str, ScrapedLot] = {}
+ # Сброс на КАЖДЫЙ прогон (#1781): инстанс скрапера переиспользуется, и без
+ # сброса счётчик копился бы между вызовами и врал бы в counters второго.
+ self.last_dropped_nb = 0
for rooms in _buckets:
room_label = f"room{'_'.join(str(r) for r in rooms)}"
@@ -671,6 +678,10 @@ class CianScraper(BaseScraper):
filtered = [lot for lot in bucket_lots if lot.listing_segment != "novostroyki"]
dropped_nb = collected_this_bucket - len(filtered)
bucket_lots = filtered
+ # Копим по всему прогону в атрибут инстанса (#1781): вызывающий кладёт это
+ # в counters прогона. Атрибут, а не аргумент on_bucket — у колбэка уже есть
+ # внешние реализации, менять его сигнатуру ради счётчика нельзя.
+ self.last_dropped_nb += dropped_nb
# Дедуп в общий seen
for lot in bucket_lots:
diff --git a/tradein-mvp/uv.lock b/tradein-mvp/uv.lock
index 66012765..7d3380a4 100644
--- a/tradein-mvp/uv.lock
+++ b/tradein-mvp/uv.lock
@@ -1742,7 +1742,7 @@ dev = [
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24.0" },
{ name = "pytest-timeout", specifier = ">=2.3.0" },
- { name = "ruff", specifier = ">=0.5.0" },
+ { name = "ruff", specifier = "==0.15.20" },
]
[[package]]
|