gendesign/tradein-mvp/backend/tests/services/test_asking_sold_tier_ratio.py
lekss361 571980c56e
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 31s
Deploy Trade-In / build-backend (push) Successful in 44s
Deploy Trade-In / deploy (push) Successful in 36s
feat(tradein): ppm²-tier segmentation of asking→sold ratio (#928, flag OFF) (#934)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-05-31 21:22:39 +00:00

291 lines
10 KiB
Python

"""Unit tests for ppm2-tier asking->sold ratio lookup (#928).
Tests for the _get_asking_sold_ratio helper with tier_aware_ratio_enabled flag.
Five scenarios:
1. flag OFF -> uses old asking_to_sold_ratios table (byte-identical to pre-#928).
2. flag ON, anchor in low band -> returns shrunk tier ratio, basis startswith 'per_rooms_tier'.
3. flag ON, no tier row for that cell -> fallback to 'all' (basis 'per_rooms_all').
4. flag ON, no 'all' row either -> global (-1) fallback (basis 'global_all').
5. flag ON, no tables / empty -> (None, None), no raise.
Static callers audit: _get_asking_sold_ratio gained anchor_ppm2 param (default None),
so all existing call sites using positional (db, rooms) continue to work unchanged.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import MagicMock, patch
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
class _FakeRow:
"""Stand-in for a SQLAlchemy Row."""
def __init__(self, **kw: Any) -> None:
for k, v in kw.items():
setattr(self, k, v)
def _clear_ratio_cache() -> None:
from app.services import estimator
estimator._asking_sold_ratio_cache.clear()
# ── helper: build a MagicMock db returning consecutive fetchone() results ─────
def _db_sequence(rows: list[Any]) -> MagicMock:
"""MagicMock db whose execute().fetchone() calls return `rows` in order."""
db = MagicMock()
db.execute.return_value.fetchone.side_effect = rows
return db
# ── 1. Flag OFF -> old table, ignores anchor_ppm2 ────────────────────────────
def test_flag_off_uses_old_table_ignores_anchor_ppm2() -> None:
"""Flag OFF: _get_asking_sold_ratio(db, 2, anchor_ppm2=90000) hits asking_to_sold_ratios."""
from app.core.config import settings
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = _db_sequence([_FakeRow(ratio=0.80, basis="per_rooms")])
with patch.object(settings, "tier_aware_ratio_enabled", False):
ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)
assert ratio == 0.80
assert basis == "per_rooms"
# Only one DB query (old path, no tier tables touched)
assert db.execute.call_count == 1
# The query must target asking_to_sold_ratios (old table)
sql_str = str(db.execute.call_args_list[0].args[0])
assert "asking_to_sold_ratios" in sql_str
assert "tiered" not in sql_str
def test_flag_off_no_anchor_ppm2_still_works() -> None:
"""Flag OFF with no anchor_ppm2 (positional call) still works as before."""
from app.core.config import settings
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = _db_sequence([_FakeRow(ratio=0.82, basis="per_rooms")])
with patch.object(settings, "tier_aware_ratio_enabled", False):
ratio, basis = _get_asking_sold_ratio(db, 1)
assert ratio == 0.82
assert basis == "per_rooms"
def test_flag_off_cache_key_is_legacy_not_none() -> None:
"""Flag OFF must cache under (bucket, '_legacy'), NOT (bucket, None).
This prevents key collision with the flag-ON Step-3 'all' fallback which also
caches under (bucket, None). If the process flips tier_aware_ratio_enabled ON
without restart, the '_legacy' entry is not accidentally served as the 'all' row.
"""
from app.core.config import settings
from app.services import estimator
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
bucket = min(max(2, 0), 4)
db = _db_sequence([_FakeRow(ratio=0.77, basis="per_rooms")])
with patch.object(settings, "tier_aware_ratio_enabled", False):
_get_asking_sold_ratio(db, 2)
# Flag-OFF entry must be under "_legacy", not None.
assert (bucket, "_legacy") in estimator._asking_sold_ratio_cache
assert (bucket, None) not in estimator._asking_sold_ratio_cache
# ── 2. Flag ON, anchor in low band -> shrunk tier ratio ──────────────────────
def test_flag_on_low_band_returns_shrunk_tier_ratio() -> None:
"""Flag ON, anchor < t33 -> tier='low', shrunk = tier*w + all*(1-w)."""
from app.core.config import settings
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
# DB calls sequence:
# 1. bounds (t33=130000, t66=165000)
# 2. tier row (ratio=0.91, n_deals=200)
# 3. 'all' row (ratio=0.80)
db = _db_sequence(
[
_FakeRow(t33=130_000, t66=165_000), # bounds
_FakeRow(ratio=0.91, n_deals=200), # tier row
_FakeRow(ratio=0.80), # 'all' row
]
)
k = 150
w = 200 / (200 + k)
expected_ratio = 0.91 * w + 0.80 * (1.0 - w)
with (
patch.object(settings, "tier_aware_ratio_enabled", True),
patch.object(settings, "tier_ratio_shrink_k", k),
):
ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)
assert ratio is not None
assert abs(ratio - expected_ratio) < 1e-9
assert basis is not None
assert basis.startswith("per_rooms_tier")
assert "low" in basis
# ── 3. Flag ON, no tier row -> fallback to 'all' ─────────────────────────────
def test_flag_on_no_tier_row_falls_back_to_all() -> None:
"""Flag ON, anchor in mid band, but no tier row for (bucket,mid) -> 'all' fallback."""
from app.core.config import settings
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
# DB calls:
# 1. bounds (t33=130000, t66=165000)
# 2. tier row -> None (cell not populated)
# 3. 'all' row (ratio=0.80) -- used as both shrink-target AND fallback
# Since tier_row is None, we skip shrink and fall through to step 3.
db = _db_sequence(
[
_FakeRow(t33=130_000, t66=165_000), # bounds
None, # tier row missing
_FakeRow(ratio=0.80), # 'all' row (shrink target query)
_FakeRow(ratio=0.80), # 'all' row (fallback step 3)
]
)
with (
patch.object(settings, "tier_aware_ratio_enabled", True),
patch.object(settings, "tier_ratio_shrink_k", 150),
):
ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=145_000.0)
assert ratio == 0.80
assert basis == "per_rooms_all"
# ── 4. Flag ON, no 'all' row -> global fallback ───────────────────────────────
def test_flag_on_no_all_row_falls_back_to_global() -> None:
"""Flag ON, bounds present, tier+all rows missing -> global -1 fallback."""
from app.core.config import settings
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = _db_sequence(
[
_FakeRow(t33=130_000, t66=165_000), # bounds
None, # tier row -> None
None, # 'all' shrink-target -> None
None, # step-3 'all' fallback -> None
_FakeRow(ratio=0.79), # step-4 global (-1, all)
]
)
with patch.object(settings, "tier_aware_ratio_enabled", True):
ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=145_000.0)
assert ratio == 0.79
assert basis == "global_all"
# ── 5. Flag ON, no tables / empty -> (None, None), no raise ──────────────────
def test_flag_on_no_tables_returns_none_none() -> None:
"""Flag ON, db.execute raises (tables not migrated) -> (None, None), no raise."""
from app.core.config import settings
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = MagicMock()
db.execute.side_effect = RuntimeError("relation asking_to_sold_tier_bounds does not exist")
with patch.object(settings, "tier_aware_ratio_enabled", True):
result = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)
assert result == (None, None)
# rollback must be called on error
db.rollback.assert_called()
# ── 6. Flag ON, no bounds row -> step-3 'all' fallback (skip tier entirely) ──
def test_flag_on_no_bounds_row_falls_back_to_all() -> None:
"""Flag ON, bounds row None -> skip tier, go straight to 'all' step 3."""
from app.core.config import settings
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = _db_sequence(
[
None, # bounds -> None (bucket has no bound data)
_FakeRow(ratio=0.80), # step-3 'all' row
]
)
with patch.object(settings, "tier_aware_ratio_enabled", True):
ratio, basis = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)
assert ratio == 0.80
assert basis == "per_rooms_all"
# ── 7. Cache: (bucket, tier) key memoises per tier ───────────────────────────
def test_tier_cache_key_is_bucket_tier_tuple() -> None:
"""Second call with same (bucket, anchor in same tier) -> served from cache after bounds.
Flow for second call:
- cache_key starts as (bucket, None) -- miss
- fetches bounds (1 DB query) -> determines tier_key="low"
- cache_key = (bucket, "low") -- HIT (populated by first call)
- returns cached ratio without further DB queries
Total DB queries: first call (3) + second call (1 bounds) = 4.
"""
from app.core.config import settings
from app.services.estimator import _get_asking_sold_ratio
_clear_ratio_cache()
db = _db_sequence(
[
# First call: bounds + tier_row + all_row
_FakeRow(t33=130_000, t66=165_000),
_FakeRow(ratio=0.91, n_deals=200),
_FakeRow(ratio=0.80),
# Second call: bounds only (tier cache hit after that)
_FakeRow(t33=130_000, t66=165_000),
]
)
with (
patch.object(settings, "tier_aware_ratio_enabled", True),
patch.object(settings, "tier_ratio_shrink_k", 150),
):
first = _get_asking_sold_ratio(db, 2, anchor_ppm2=90_000.0)
second = _get_asking_sold_ratio(db, 2, anchor_ppm2=95_000.0) # same 'low' tier
# Both calls return the same result (same tier).
assert first == second
# First call: 3 queries (bounds + tier_row + all_row).
# Second call: 1 query (bounds) + cache hit on (bucket, "low").
assert db.execute.call_count == 4