feat(site-finder): weight profiles service — Pydantic + CRUD (#114 sub-PR 2/4)
Per #114 sub-PR 2/4. Pydantic v2 models + CRUD service. 14 passing tests. API: list/get/get_default/create/update/delete_profile + resolve_weights fallback to system defaults. Validation: ALLOWED_CATEGORIES guard + weight bounds [-2.0, 3.0]. Default-uniqueness в одной transaction (job_settings.py pattern). Vault: code/modules/Module_Weight_Profiles_Service.md. Refs: #114
This commit is contained in:
parent
f3f1e7f6bc
commit
be3b60e9c0
2 changed files with 553 additions and 0 deletions
338
backend/app/services/site_finder/weight_profiles.py
Normal file
338
backend/app/services/site_finder/weight_profiles.py
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
"""User weight profiles для Site Finder POI scoring.
|
||||||
|
|
||||||
|
Per #114 (Макс feedback): кастомизируемые веса для каждой POI категории.
|
||||||
|
Table schema: data/sql/90_user_weight_profiles.sql.
|
||||||
|
|
||||||
|
API surface:
|
||||||
|
- list_profiles(db, user_id) → list[WeightProfile]
|
||||||
|
- get_profile(db, user_id, profile_id) → WeightProfile | None
|
||||||
|
- get_default_profile(db, user_id) → WeightProfile | None
|
||||||
|
- create_profile(db, payload) → WeightProfile
|
||||||
|
- update_profile(db, user_id, profile_id, payload) → WeightProfile | None
|
||||||
|
- delete_profile(db, user_id, profile_id) → bool
|
||||||
|
- resolve_weights(db, user_id, profile_id) → dict[str, float]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Allowed POI categories — mirrors _POI_WEIGHTS keys in api/v1/parcels.py
|
||||||
|
ALLOWED_CATEGORIES: set[str] = {
|
||||||
|
"school",
|
||||||
|
"kindergarten",
|
||||||
|
"pharmacy",
|
||||||
|
"hospital",
|
||||||
|
"shop_mall",
|
||||||
|
"shop_supermarket",
|
||||||
|
"shop_small",
|
||||||
|
"park",
|
||||||
|
"bus_stop",
|
||||||
|
"metro_stop",
|
||||||
|
"tram_stop",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Weight value bounds (per #114 spec)
|
||||||
|
MIN_WEIGHT: float = -2.0
|
||||||
|
MAX_WEIGHT: float = 3.0
|
||||||
|
|
||||||
|
# System defaults — keep in sync with _POI_WEIGHTS in parcels.py
|
||||||
|
_SYSTEM_POI_WEIGHTS: dict[str, float] = {
|
||||||
|
"school": 1.5,
|
||||||
|
"kindergarten": 1.5,
|
||||||
|
"pharmacy": 0.8,
|
||||||
|
"hospital": 0.6,
|
||||||
|
"shop_mall": 1.2,
|
||||||
|
"shop_supermarket": 1.0,
|
||||||
|
"shop_small": 0.5,
|
||||||
|
"park": 1.8,
|
||||||
|
"bus_stop": 0.3,
|
||||||
|
"metro_stop": 1.5,
|
||||||
|
"tram_stop": -0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── SQL helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SELECT_COLS = """
|
||||||
|
id, user_id, profile_name, weights, is_default, description,
|
||||||
|
created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_BY_USER = f"""
|
||||||
|
SELECT {_SELECT_COLS}
|
||||||
|
FROM user_weight_profiles
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
ORDER BY is_default DESC, id ASC
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_BY_ID = f"""
|
||||||
|
SELECT {_SELECT_COLS}
|
||||||
|
FROM user_weight_profiles
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
AND id = :profile_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SELECT_DEFAULT = f"""
|
||||||
|
SELECT {_SELECT_COLS}
|
||||||
|
FROM user_weight_profiles
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
AND is_default = TRUE
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
_UNSET_DEFAULT = """
|
||||||
|
UPDATE user_weight_profiles
|
||||||
|
SET is_default = FALSE
|
||||||
|
WHERE user_id = :user_id
|
||||||
|
AND is_default = TRUE
|
||||||
|
"""
|
||||||
|
|
||||||
|
_INSERT = """
|
||||||
|
INSERT INTO user_weight_profiles
|
||||||
|
(user_id, profile_name, weights, is_default, description)
|
||||||
|
VALUES
|
||||||
|
(:user_id, :profile_name, :weights::jsonb, :is_default, :description)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic models ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_weights_dict(v: dict[str, float]) -> dict[str, float]:
|
||||||
|
"""Shared validation logic for weights dict."""
|
||||||
|
bad_keys = set(v.keys()) - ALLOWED_CATEGORIES
|
||||||
|
if bad_keys:
|
||||||
|
raise ValueError(f"Unknown POI categories: {sorted(bad_keys)}")
|
||||||
|
for k, w in v.items():
|
||||||
|
if not isinstance(w, int | float):
|
||||||
|
raise ValueError(f"Weight for '{k}' must be number, got {type(w).__name__}")
|
||||||
|
if w < MIN_WEIGHT or w > MAX_WEIGHT:
|
||||||
|
raise ValueError(f"Weight for '{k}' = {w} out of bounds [{MIN_WEIGHT}, {MAX_WEIGHT}]")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class WeightProfileBase(BaseModel):
|
||||||
|
profile_name: str = Field(..., min_length=1, max_length=64)
|
||||||
|
weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
is_default: bool = False
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
@field_validator("weights")
|
||||||
|
@classmethod
|
||||||
|
def _validate_weights(cls, v: dict[str, float]) -> dict[str, float]:
|
||||||
|
return _validate_weights_dict(v)
|
||||||
|
|
||||||
|
|
||||||
|
class WeightProfileCreate(WeightProfileBase):
|
||||||
|
user_id: str = Field(..., min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class WeightProfileUpdate(BaseModel):
|
||||||
|
profile_name: str | None = Field(None, min_length=1, max_length=64)
|
||||||
|
weights: dict[str, float] | None = None
|
||||||
|
is_default: bool | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
@field_validator("weights")
|
||||||
|
@classmethod
|
||||||
|
def _validate_weights(cls, v: dict[str, float] | None) -> dict[str, float] | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
return _validate_weights_dict(v)
|
||||||
|
|
||||||
|
|
||||||
|
class WeightProfile(WeightProfileBase):
|
||||||
|
id: int
|
||||||
|
user_id: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Row mapper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_profile(r: Any) -> WeightProfile:
|
||||||
|
"""Map SQLAlchemy mapping row → WeightProfile."""
|
||||||
|
weights_raw = r["weights"]
|
||||||
|
if isinstance(weights_raw, str):
|
||||||
|
weights_raw = json.loads(weights_raw)
|
||||||
|
weights: dict[str, float] = weights_raw or {}
|
||||||
|
|
||||||
|
return WeightProfile(
|
||||||
|
id=r["id"],
|
||||||
|
user_id=r["user_id"],
|
||||||
|
profile_name=r["profile_name"],
|
||||||
|
weights=weights,
|
||||||
|
is_default=bool(r["is_default"]),
|
||||||
|
description=r["description"],
|
||||||
|
created_at=r["created_at"],
|
||||||
|
updated_at=r["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD service ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles(db: Any, user_id: str) -> list[WeightProfile]:
|
||||||
|
"""Вернуть все профили пользователя, default первым."""
|
||||||
|
rows = db.execute(text(_SELECT_BY_USER), {"user_id": user_id}).mappings().all()
|
||||||
|
return [_row_to_profile(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile(db: Any, user_id: str, profile_id: int) -> WeightProfile | None:
|
||||||
|
"""Вернуть профиль по id (scoped к пользователю)."""
|
||||||
|
row = (
|
||||||
|
db.execute(text(_SELECT_BY_ID), {"user_id": user_id, "profile_id": profile_id})
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _row_to_profile(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_profile(db: Any, user_id: str) -> WeightProfile | None:
|
||||||
|
"""Вернуть профиль-default пользователя или None."""
|
||||||
|
row = db.execute(text(_SELECT_DEFAULT), {"user_id": user_id}).mappings().first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return _row_to_profile(row)
|
||||||
|
|
||||||
|
|
||||||
|
def create_profile(db: Any, payload: WeightProfileCreate) -> WeightProfile:
|
||||||
|
"""Создать новый профиль.
|
||||||
|
|
||||||
|
Если is_default=True — снять is_default у всех существующих профилей
|
||||||
|
пользователя в одной транзакции.
|
||||||
|
"""
|
||||||
|
if payload.is_default:
|
||||||
|
db.execute(text(_UNSET_DEFAULT), {"user_id": payload.user_id})
|
||||||
|
|
||||||
|
result = (
|
||||||
|
db.execute(
|
||||||
|
text(_INSERT),
|
||||||
|
{
|
||||||
|
"user_id": payload.user_id,
|
||||||
|
"profile_name": payload.profile_name,
|
||||||
|
"weights": json.dumps(payload.weights, ensure_ascii=False),
|
||||||
|
"is_default": payload.is_default,
|
||||||
|
"description": payload.description,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert result is not None, "INSERT RETURNING вернул пустой результат"
|
||||||
|
|
||||||
|
return WeightProfile(
|
||||||
|
id=result["id"],
|
||||||
|
user_id=payload.user_id,
|
||||||
|
profile_name=payload.profile_name,
|
||||||
|
weights=payload.weights,
|
||||||
|
is_default=payload.is_default,
|
||||||
|
description=payload.description,
|
||||||
|
created_at=result["created_at"],
|
||||||
|
updated_at=result["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_profile(
|
||||||
|
db: Any, user_id: str, profile_id: int, payload: WeightProfileUpdate
|
||||||
|
) -> WeightProfile | None:
|
||||||
|
"""Обновить поля профиля (PATCH-style). Вернуть None если не найден.
|
||||||
|
|
||||||
|
При установке is_default=True — снять is_default у остальных профилей
|
||||||
|
пользователя в одной транзакции.
|
||||||
|
"""
|
||||||
|
existing = get_profile(db, user_id, profile_id)
|
||||||
|
if existing is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sets: list[str] = ["updated_at = NOW()"]
|
||||||
|
params: dict[str, Any] = {"user_id": user_id, "profile_id": profile_id}
|
||||||
|
|
||||||
|
if payload.profile_name is not None:
|
||||||
|
sets.append("profile_name = :profile_name")
|
||||||
|
params["profile_name"] = payload.profile_name
|
||||||
|
|
||||||
|
if payload.weights is not None:
|
||||||
|
sets.append("weights = :weights::jsonb")
|
||||||
|
params["weights"] = json.dumps(payload.weights, ensure_ascii=False)
|
||||||
|
|
||||||
|
if payload.description is not None:
|
||||||
|
sets.append("description = :description")
|
||||||
|
params["description"] = payload.description
|
||||||
|
|
||||||
|
if payload.is_default is not None:
|
||||||
|
sets.append("is_default = :is_default")
|
||||||
|
params["is_default"] = payload.is_default
|
||||||
|
if payload.is_default:
|
||||||
|
db.execute(text(_UNSET_DEFAULT), {"user_id": user_id})
|
||||||
|
|
||||||
|
if len(sets) > 1: # есть что обновлять кроме updated_at
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
f"UPDATE user_weight_profiles SET {', '.join(sets)}"
|
||||||
|
" WHERE user_id = :user_id AND id = :profile_id"
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return get_profile(db, user_id, profile_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_profile(db: Any, user_id: str, profile_id: int) -> bool:
|
||||||
|
"""Удалить профиль. Вернуть True если удалён, False если не найден."""
|
||||||
|
result = db.execute(
|
||||||
|
text(
|
||||||
|
"DELETE FROM user_weight_profiles"
|
||||||
|
" WHERE user_id = :user_id AND id = :profile_id"
|
||||||
|
" RETURNING id"
|
||||||
|
),
|
||||||
|
{"user_id": user_id, "profile_id": profile_id},
|
||||||
|
).first()
|
||||||
|
if result is None:
|
||||||
|
return False
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]:
|
||||||
|
"""Вернуть эффективные веса для analyze_parcel.
|
||||||
|
|
||||||
|
Порядок приоритетов:
|
||||||
|
1. profile_id задан → загрузить именно этот профиль
|
||||||
|
2. user_id задан → загрузить default-профиль пользователя
|
||||||
|
3. Иначе → вернуть системные значения _SYSTEM_POI_WEIGHTS
|
||||||
|
"""
|
||||||
|
if profile_id is not None and user_id is not None:
|
||||||
|
profile = get_profile(db, user_id, profile_id)
|
||||||
|
if profile is not None and profile.weights:
|
||||||
|
logger.debug(
|
||||||
|
"resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id
|
||||||
|
)
|
||||||
|
return dict(profile.weights)
|
||||||
|
|
||||||
|
if user_id is not None:
|
||||||
|
profile = get_default_profile(db, user_id)
|
||||||
|
if profile is not None and profile.weights:
|
||||||
|
logger.debug("resolve_weights: user=%s → default profile weights", user_id)
|
||||||
|
return dict(profile.weights)
|
||||||
|
|
||||||
|
logger.debug("resolve_weights: returning system defaults")
|
||||||
|
return dict(_SYSTEM_POI_WEIGHTS)
|
||||||
215
backend/tests/test_weight_profiles.py
Normal file
215
backend/tests/test_weight_profiles.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""Unit tests для WeightProfile Pydantic models + resolve_weights.
|
||||||
|
|
||||||
|
Mock-based — без реальной БД. Проверяет:
|
||||||
|
- WeightProfileCreate: отклонение неизвестных категорий
|
||||||
|
- WeightProfileCreate: отклонение весов за пределами MIN/MAX
|
||||||
|
- WeightProfileCreate: корректные веса проходят валидацию
|
||||||
|
- WeightProfileUpdate: weights=None не вызывает ошибку
|
||||||
|
- resolve_weights: нет user_id и profile_id → системные дефолты
|
||||||
|
- resolve_weights: user_id задан, default-профиль есть → его веса
|
||||||
|
- resolve_weights: profile_id задан → его веса
|
||||||
|
- resolve_weights: профиль не найден → системные дефолты (fallback)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.services.site_finder.weight_profiles import (
|
||||||
|
_SYSTEM_POI_WEIGHTS,
|
||||||
|
WeightProfile,
|
||||||
|
WeightProfileCreate,
|
||||||
|
WeightProfileUpdate,
|
||||||
|
resolve_weights,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Validation tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_unknown_category() -> None:
|
||||||
|
"""Неизвестная POI-категория → ValidationError."""
|
||||||
|
with pytest.raises(ValidationError, match="Unknown POI categories"):
|
||||||
|
WeightProfileCreate(
|
||||||
|
user_id="user-1",
|
||||||
|
profile_name="test",
|
||||||
|
weights={"unknown": 1.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_weight_bounds_too_high() -> None:
|
||||||
|
"""Вес > MAX_WEIGHT → ValidationError."""
|
||||||
|
with pytest.raises(ValidationError, match="out of bounds"):
|
||||||
|
WeightProfileCreate(
|
||||||
|
user_id="user-1",
|
||||||
|
profile_name="test",
|
||||||
|
weights={"school": 99.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_weight_negative_too_low() -> None:
|
||||||
|
"""Вес < MIN_WEIGHT → ValidationError."""
|
||||||
|
with pytest.raises(ValidationError, match="out of bounds"):
|
||||||
|
WeightProfileCreate(
|
||||||
|
user_id="user-1",
|
||||||
|
profile_name="test",
|
||||||
|
weights={"tram_stop": -3.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_valid_weights() -> None:
|
||||||
|
"""Корректные веса проходят валидацию без ошибок."""
|
||||||
|
profile = WeightProfileCreate(
|
||||||
|
user_id="user-1",
|
||||||
|
profile_name="my profile",
|
||||||
|
weights={"school": 1.5, "tram_stop": -0.5, "park": 2.9},
|
||||||
|
)
|
||||||
|
assert profile.weights["school"] == 1.5
|
||||||
|
assert profile.weights["tram_stop"] == -0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_empty_weights() -> None:
|
||||||
|
"""Пустой dict weights допустим (используются системные дефолты)."""
|
||||||
|
profile = WeightProfileCreate(
|
||||||
|
user_id="user-1",
|
||||||
|
profile_name="empty",
|
||||||
|
weights={},
|
||||||
|
)
|
||||||
|
assert profile.weights == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_boundary_values() -> None:
|
||||||
|
"""Граничные значения MIN_WEIGHT и MAX_WEIGHT включительно — допустимы."""
|
||||||
|
profile = WeightProfileCreate(
|
||||||
|
user_id="user-1",
|
||||||
|
profile_name="boundary",
|
||||||
|
weights={"school": -2.0, "park": 3.0},
|
||||||
|
)
|
||||||
|
assert profile.weights["school"] == -2.0
|
||||||
|
assert profile.weights["park"] == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_update_weights_none() -> None:
|
||||||
|
"""WeightProfileUpdate с weights=None не вызывает ошибку валидации."""
|
||||||
|
upd = WeightProfileUpdate(weights=None)
|
||||||
|
assert upd.weights is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_update_unknown_category() -> None:
|
||||||
|
"""WeightProfileUpdate с неизвестной категорией → ValidationError."""
|
||||||
|
with pytest.raises(ValidationError, match="Unknown POI categories"):
|
||||||
|
WeightProfileUpdate(weights={"bogus_category": 1.0})
|
||||||
|
|
||||||
|
|
||||||
|
# ── resolve_weights tests ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_weights_system_default() -> None:
|
||||||
|
"""Оба аргумента None → возвращаются системные веса."""
|
||||||
|
db = MagicMock()
|
||||||
|
result = resolve_weights(db, user_id=None, profile_id=None)
|
||||||
|
assert result == _SYSTEM_POI_WEIGHTS
|
||||||
|
# db не должен вызываться вообще
|
||||||
|
db.execute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_weights_system_default_returns_copy() -> None:
|
||||||
|
"""Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS."""
|
||||||
|
db = MagicMock()
|
||||||
|
result = resolve_weights(db, user_id=None, profile_id=None)
|
||||||
|
result["school"] = 999.0
|
||||||
|
# Оригинал не изменён
|
||||||
|
assert _SYSTEM_POI_WEIGHTS["school"] == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def _make_profile_mock(weights: dict[str, float]) -> WeightProfile:
|
||||||
|
"""Вспомогательная функция: создать WeightProfile с заданными weights."""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
return WeightProfile(
|
||||||
|
id=1,
|
||||||
|
user_id="user-1",
|
||||||
|
profile_name="test",
|
||||||
|
weights=weights,
|
||||||
|
is_default=True,
|
||||||
|
description=None,
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_weights_uses_default_profile() -> None:
|
||||||
|
"""user_id задан, default-профиль найден → его веса."""
|
||||||
|
custom_weights = {"school": 2.0, "park": 0.5}
|
||||||
|
profile = _make_profile_mock(custom_weights)
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
import app.services.site_finder.weight_profiles as wp_module
|
||||||
|
|
||||||
|
original = wp_module.get_default_profile
|
||||||
|
wp_module.get_default_profile = lambda _db, uid: profile
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = resolve_weights(db, user_id="user-1", profile_id=None)
|
||||||
|
finally:
|
||||||
|
wp_module.get_default_profile = original
|
||||||
|
|
||||||
|
assert result == custom_weights
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_weights_uses_specific_profile() -> None:
|
||||||
|
"""profile_id задан → веса конкретного профиля."""
|
||||||
|
custom_weights = {"kindergarten": 1.8, "metro_stop": 1.0}
|
||||||
|
profile = _make_profile_mock(custom_weights)
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
import app.services.site_finder.weight_profiles as wp_module
|
||||||
|
|
||||||
|
original = wp_module.get_profile
|
||||||
|
wp_module.get_profile = lambda _db, uid, pid: profile
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = resolve_weights(db, user_id="user-1", profile_id=42)
|
||||||
|
finally:
|
||||||
|
wp_module.get_profile = original
|
||||||
|
|
||||||
|
assert result == custom_weights
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_weights_profile_not_found_fallback() -> None:
|
||||||
|
"""Профиль не найден (get_profile → None) → системные дефолты."""
|
||||||
|
db = MagicMock()
|
||||||
|
import app.services.site_finder.weight_profiles as wp_module
|
||||||
|
|
||||||
|
original_get = wp_module.get_profile
|
||||||
|
original_default = wp_module.get_default_profile
|
||||||
|
wp_module.get_profile = lambda _db, uid, pid: None
|
||||||
|
wp_module.get_default_profile = lambda _db, uid: None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = resolve_weights(db, user_id="user-1", profile_id=999)
|
||||||
|
finally:
|
||||||
|
wp_module.get_profile = original_get
|
||||||
|
wp_module.get_default_profile = original_default
|
||||||
|
|
||||||
|
assert result == _SYSTEM_POI_WEIGHTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_weights_empty_profile_weights_fallback() -> None:
|
||||||
|
"""Профиль найден но weights пустой → системные дефолты."""
|
||||||
|
profile = _make_profile_mock({}) # пустые weights
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
import app.services.site_finder.weight_profiles as wp_module
|
||||||
|
|
||||||
|
original_default = wp_module.get_default_profile
|
||||||
|
wp_module.get_default_profile = lambda _db, uid: profile
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = resolve_weights(db, user_id="user-1", profile_id=None)
|
||||||
|
finally:
|
||||||
|
wp_module.get_default_profile = original_default
|
||||||
|
|
||||||
|
assert result == _SYSTEM_POI_WEIGHTS
|
||||||
Loading…
Add table
Reference in a new issue