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
Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
291 lines
10 KiB
Python
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
|