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
403 lines
12 KiB
Python
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()
|