""" 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()