From bc7fe9b0b91d1cc03e64fd6300f36dcab0199b58 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:57:04 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BA=D0=B2=D0=B0=D1=80=D1=82=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BB=D0=BE=D0=B2=D1=8F=D1=82=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=20=D0=BD=D0=B0=20=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20(#2379)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью #3556: тест значений держит число в константе, но не то, что код её читает. Подмена min_n_deals=5 на обоих местах вызова проходила весь сьют. Хуже: Guard-2 и bimodal-гард снимались целиком (`if False:`) при зелёном test_estimator_quarter_index — оба теста были пустыми. Guard-2: заглушка отдавала аналогам индекс цели, factor=1.0. Bimodal: аналоги без кадастра, поправку гасил Guard-5 (#2583) независимо от гарда. - оба теста получили аналоги с известным уровнем 1.0 — без гарда цена сдвигается; - границы: доля ровно 0.6 поправку даёт, индекс ровно 2.0 даёт, 2.01 — нет; - заглушки lookup'ов пишут min_n_deals, тест требует 10 на обоих вызовах. Co-Authored-By: Claude Opus 5 --- .../tests/test_estimator_quarter_index.py | 81 ++++++++++++++++--- 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index 13b89e2d..5cb7c1bd 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -389,6 +389,7 @@ def _run_estimate_qi( anchor_tier_override: str | None = None, analog_indexes: dict[str, float] | None = None, imv_anchor: dict[str, Any] | None = None, + seen_min_n: list[int] | None = None, ): """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate. @@ -396,6 +397,9 @@ def _run_estimate_qi( когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0. Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога. + + seen_min_n — сюда пишется min_n_deals каждого вызова обоих lookup'ов: порог + фильтрует строки в SQL, и заглушка его иначе теряет. """ from app.services.estimator import estimate_quality @@ -406,7 +410,14 @@ def _run_estimate_qi( # Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс, # что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict. + def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals): + if seen_min_n is not None: + seen_min_n.append(min_n_deals) + return qi_lookup_result + def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals): + if seen_min_n is not None: + seen_min_n.append(min_n_deals) if analog_indexes is not None: return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes} if qi_lookup_result is None: @@ -451,7 +462,7 @@ def _run_estimate_qi( # Stub singular target-quarter lookup patch( "app.services.estimator._lookup_quarter_index", - return_value=qi_lookup_result, + side_effect=_fake_lookup_index, ), # Stub batched analog-quarter lookup patch( @@ -532,11 +543,51 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None: analogs=analogs, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip + # #2379: уровень аналогов 1.0 — без гарда было бы ×1.5. Без явной карты стаб + # отдаёт аналогам индекс цели, factor=1.0, и тест зеленел при снятом гарде. + analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0}, ) # Медиана НЕ должна изменяться assert est.median_price_rub == base_median +def test_guard2_ratio_exactly_0_6_still_corrects() -> None: + """#2379: граница Guard-2 строгая — 3 из 5 аналогов (0.6) в квартале цели не skip.""" + analogs = [ + _make_listing_qi( + price_per_m2=_BASE_PPM2, + building_cadastral_number=f"{_TARGET_QUARTER if i < 3 else _OTHER_QUARTER}:100", + floor=1 + i, + ) + for i in range(5) + ] + est = _run_estimate_qi( + analogs=analogs, + dadata_cadnum=f"{_TARGET_QUARTER}:350", + qi_lookup_result=(1.5, 30), + analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0}, + ) + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5) + + +def test_quarter_lookups_filter_by_ten_deals() -> None: + """#2379: оба lookup'а (квартал цели и кварталы аналогов) просят индекс от 10 сделок. + + Порог уходит параметром в SQL (`n_deals >= :min_n`), в заглушке его не видно: + подмена числа на месте вызова проходила весь сьют. + """ + seen: list[int] = [] + est = _run_estimate_qi( + analogs=_ANALOGS_OTHER_QUARTER, + dadata_cadnum=f"{_TARGET_QUARTER}:350", + qi_lookup_result=(1.2, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, + seen_min_n=seen, + ) + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.2) # оба lookup'а вызваны + assert seen == [10, 10] + + # ───────────────────────────────────────────────────────────────────────────── # Sparse fallback: нет строки → no-op # ───────────────────────────────────────────────────────────────────────────── @@ -659,19 +710,27 @@ def test_c3_one_indexed_analog_is_enough_to_correct() -> None: # ───────────────────────────────────────────────────────────────────────────── -def test_bimodal_guard_skips_high_index_small_n() -> None: - """Bimodal guard: price_index=3.5, n_deals=20 → no-op.""" - analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) - for i in range(5) - ] - base_median = round(_BASE_PPM2 * _AREA) +@pytest.mark.parametrize( + ("target_qi", "expected_factor"), + [ + (3.5, 1.0), # index > 2.0 AND n < 50 → bimodal guard, no-op + (2.01, 1.0), + (2.0, 1.8), # ровно 2.0 — не больше порога: поправка есть, factor 2.0 → кламп 1.8 + ], +) +def test_bimodal_guard_skips_high_index_small_n(target_qi: float, expected_factor: float) -> None: + """Bimodal guard: price_index > 2.0 при n_deals=20 → no-op. + + #2379: аналоги с ИЗВЕСТНЫМ уровнем 1.0. Раньше здесь были аналоги без кадастра — + поправку на них гасит и Guard-5 (#2583), и тест зеленел при снятом bimodal-гарде. + """ est = _run_estimate_qi( - analogs=analogs_no_cadnum, + analogs=_ANALOGS_OTHER_QUARTER, dadata_cadnum=f"{_TARGET_QUARTER}:350", - qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard + qi_lookup_result=(target_qi, 20), + analog_indexes={_OTHER_QUARTER: 1.0}, ) - assert est.median_price_rub == base_median + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * expected_factor) def test_bimodal_guard_allows_high_index_large_n() -> None: