Files
learningcircuit--local-deep…/tests/news/test_base_recommender_behavior.py
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

403 lines
12 KiB
Python

"""
Deep behavioral tests for BaseRecommender.
Tests filtering, scoring, preferences, progress callbacks, and strategy info.
"""
from unittest.mock import Mock
import pytest
from local_deep_research.news.core.base_card import CardSource, NewsCard
from local_deep_research.news.recommender.base_recommender import (
BaseRecommender,
)
def _make_card(topic="Test", category="General", impact_score=5, **kwargs):
source = CardSource(type="news_item")
return NewsCard(
topic=topic,
source=source,
user_id="u1",
category=category,
impact_score=impact_score,
**kwargs,
)
class ConcreteRecommender(BaseRecommender):
"""Concrete implementation for testing abstract base class."""
def generate_recommendations(self, user_id, context=None):
return []
# --- Init tests ---
class TestBaseRecommenderInit:
"""Tests for BaseRecommender initialization."""
def test_all_deps_none_by_default(self):
r = ConcreteRecommender()
assert r.preference_manager is None
assert r.rating_system is None
assert r.topic_registry is None
assert r.search_system is None
def test_stores_preference_manager(self):
pm = Mock()
r = ConcreteRecommender(preference_manager=pm)
assert r.preference_manager is pm
def test_stores_rating_system(self):
rs = Mock()
r = ConcreteRecommender(rating_system=rs)
assert r.rating_system is rs
def test_stores_topic_registry(self):
tr = Mock()
r = ConcreteRecommender(topic_registry=tr)
assert r.topic_registry is tr
def test_stores_search_system(self):
ss = Mock()
r = ConcreteRecommender(search_system=ss)
assert r.search_system is ss
def test_progress_callback_none_initially(self):
r = ConcreteRecommender()
assert r.progress_callback is None
def test_strategy_name_is_class_name(self):
r = ConcreteRecommender()
assert r.strategy_name == "ConcreteRecommender"
# --- Progress callback tests ---
class TestRecommenderProgressCallback:
"""Tests for progress callback mechanism."""
def test_set_callback(self):
r = ConcreteRecommender()
cb = Mock()
r.set_progress_callback(cb)
assert r.progress_callback is cb
def test_update_progress_calls_callback(self):
r = ConcreteRecommender()
cb = Mock()
r.set_progress_callback(cb)
r._update_progress("step1", 25, {"key": "val"})
cb.assert_called_once_with("step1", 25, {"key": "val"})
def test_update_progress_no_callback_no_error(self):
r = ConcreteRecommender()
r._update_progress("msg") # Should not raise
def test_update_progress_default_metadata(self):
r = ConcreteRecommender()
cb = Mock()
r.set_progress_callback(cb)
r._update_progress("msg", 50)
cb.assert_called_once_with("msg", 50, {})
# --- Get user preferences ---
class TestGetUserPreferences:
"""Tests for _get_user_preferences method."""
def test_returns_empty_when_no_manager(self):
r = ConcreteRecommender()
assert r._get_user_preferences("u1") == {}
def test_calls_preference_manager(self):
pm = Mock()
pm.get_preferences.return_value = {"liked_categories": ["Tech"]}
r = ConcreteRecommender(preference_manager=pm)
result = r._get_user_preferences("u1")
pm.get_preferences.assert_called_once_with("u1")
assert result == {"liked_categories": ["Tech"]}
# --- Get user ratings ---
class TestGetUserRatings:
"""Tests for _get_user_ratings method."""
def test_returns_empty_when_no_system(self):
r = ConcreteRecommender()
assert r._get_user_ratings("u1") == []
def test_calls_rating_system(self):
rs = Mock()
rs.get_recent_ratings.return_value = [{"card_id": "c1", "rating": 5}]
r = ConcreteRecommender(rating_system=rs)
result = r._get_user_ratings("u1", limit=10)
rs.get_recent_ratings.assert_called_once_with("u1", 10)
assert len(result) == 1
def test_default_limit_50(self):
rs = Mock()
rs.get_recent_ratings.return_value = []
r = ConcreteRecommender(rating_system=rs)
r._get_user_ratings("u1")
rs.get_recent_ratings.assert_called_once_with("u1", 50)
# --- Execute search ---
class TestExecuteSearch:
"""Tests for _execute_search method."""
def test_returns_error_when_no_system(self):
r = ConcreteRecommender()
result = r._execute_search("query")
assert "error" in result
def test_calls_search_system(self):
ss = Mock()
ss.analyze_topic.return_value = {"findings": ["result1"]}
r = ConcreteRecommender(search_system=ss)
result = r._execute_search("AI news")
ss.analyze_topic.assert_called_once_with("AI news")
assert result == {"findings": ["result1"]}
def test_handles_search_exception(self):
ss = Mock()
ss.analyze_topic.side_effect = RuntimeError("search failed")
r = ConcreteRecommender(search_system=ss)
result = r._execute_search("query")
assert "error" in result
# --- Filter by preferences ---
class TestFilterByPreferences:
"""Tests for _filter_by_preferences method."""
def test_no_preferences_returns_all(self):
r = ConcreteRecommender()
cards = [_make_card(), _make_card()]
result = r._filter_by_preferences(cards, {})
assert len(result) == 2
def test_boosts_liked_categories(self):
r = ConcreteRecommender()
tech_card = _make_card(category="Tech")
general_card = _make_card(category="General")
prefs = {"liked_categories": ["Tech"]}
r._filter_by_preferences([tech_card, general_card], prefs)
assert tech_card.metadata.get("preference_boost") == 1.2
assert "preference_boost" not in general_card.metadata
def test_empty_liked_categories_no_boost(self):
r = ConcreteRecommender()
card = _make_card(category="Tech")
prefs = {"liked_categories": []}
r._filter_by_preferences([card], prefs)
assert "preference_boost" not in card.metadata
def test_filters_by_impact_threshold(self):
r = ConcreteRecommender()
high = _make_card(impact_score=8)
low = _make_card(impact_score=3)
prefs = {"impact_threshold": 5}
result = r._filter_by_preferences([high, low], prefs)
assert high in result
assert low not in result
def test_impact_threshold_exact_boundary(self):
r = ConcreteRecommender()
card = _make_card(impact_score=5)
prefs = {"impact_threshold": 5}
result = r._filter_by_preferences([card], prefs)
assert card in result
def test_filters_disliked_topics(self):
r = ConcreteRecommender()
sports = _make_card(topic="Sports News Today")
tech = _make_card(topic="Tech Innovation")
prefs = {"disliked_topics": ["sports"]}
result = r._filter_by_preferences([sports, tech], prefs)
assert tech in result
assert sports not in result
def test_disliked_topics_case_insensitive(self):
r = ConcreteRecommender()
card = _make_card(topic="SPORTS Update")
prefs = {"disliked_topics": ["sports"]}
result = r._filter_by_preferences([card], prefs)
assert card not in result
def test_combined_filters(self):
r = ConcreteRecommender()
good_tech = _make_card(
topic="AI Progress", category="Tech", impact_score=8
)
bad_sports = _make_card(
topic="Sports News", category="Sports", impact_score=3
)
low_tech = _make_card(
topic="Minor Tech", category="Tech", impact_score=2
)
prefs = {
"liked_categories": ["Tech"],
"impact_threshold": 5,
"disliked_topics": ["sports"],
}
result = r._filter_by_preferences(
[good_tech, bad_sports, low_tech], prefs
)
assert good_tech in result
assert bad_sports not in result
assert low_tech not in result
def test_empty_cards_list(self):
r = ConcreteRecommender()
result = r._filter_by_preferences([], {"impact_threshold": 5})
assert result == []
# --- Sort by relevance ---
class TestSortByRelevance:
"""Tests for _sort_by_relevance method."""
def test_empty_list(self):
r = ConcreteRecommender()
result = r._sort_by_relevance([], "u1")
assert result == []
def test_sorts_by_impact_score(self):
r = ConcreteRecommender()
low = _make_card(impact_score=3)
high = _make_card(impact_score=9)
mid = _make_card(impact_score=6)
result = r._sort_by_relevance([low, high, mid], "u1")
assert result[0] is high
assert result[1] is mid
assert result[2] is low
def test_preference_boost_affects_ordering(self):
r = ConcreteRecommender()
# Low impact but boosted
boosted = _make_card(impact_score=5)
boosted.metadata["preference_boost"] = 2.0
# High impact not boosted
normal = _make_card(impact_score=8)
result = r._sort_by_relevance([normal, boosted], "u1")
# boosted: 5/10 * 2.0 = 1.0; normal: 8/10 * 1.0 = 0.8
assert result[0] is boosted
def test_preserves_all_cards(self):
r = ConcreteRecommender()
cards = [_make_card(impact_score=i) for i in range(1, 6)]
result = r._sort_by_relevance(cards, "u1")
assert len(result) == 5
def test_single_card(self):
r = ConcreteRecommender()
card = _make_card(impact_score=7)
result = r._sort_by_relevance([card], "u1")
assert result == [card]
# --- Strategy info ---
class TestGetStrategyInfo:
"""Tests for get_strategy_info method."""
def test_includes_name(self):
r = ConcreteRecommender()
info = r.get_strategy_info()
assert info["name"] == "ConcreteRecommender"
def test_has_preference_manager_false(self):
r = ConcreteRecommender()
info = r.get_strategy_info()
assert info["has_preference_manager"] is False
def test_has_preference_manager_true(self):
r = ConcreteRecommender(preference_manager=Mock())
info = r.get_strategy_info()
assert info["has_preference_manager"] is True
def test_has_rating_system_false(self):
r = ConcreteRecommender()
info = r.get_strategy_info()
assert info["has_rating_system"] is False
def test_has_rating_system_true(self):
r = ConcreteRecommender(rating_system=Mock())
info = r.get_strategy_info()
assert info["has_rating_system"] is True
def test_has_search_system_false(self):
r = ConcreteRecommender()
info = r.get_strategy_info()
assert info["has_search_system"] is False
def test_has_search_system_true(self):
r = ConcreteRecommender(search_system=Mock())
info = r.get_strategy_info()
assert info["has_search_system"] is True
def test_includes_description(self):
r = ConcreteRecommender()
info = r.get_strategy_info()
assert "description" in info
# --- Abstract method enforcement ---
class TestBaseRecommenderAbstract:
"""Tests that BaseRecommender can't be instantiated directly."""
def test_cannot_instantiate_directly(self):
with pytest.raises(TypeError):
BaseRecommender()
def test_subclass_must_implement_generate_recommendations(self):
class Incomplete(BaseRecommender):
pass
with pytest.raises(TypeError):
Incomplete()