From 824423f19f8a422d20c42cd779d8aaf0dfac76b6 Mon Sep 17 00:00:00 2001 From: Dan Guido Date: Sun, 15 Feb 2026 17:56:07 -0500 Subject: [PATCH] Add tests for last30days scoring, dedup, normalization, and dates 115 tests covering the four pure-logic modules: - test_dates: date parsing, ranges, confidence, recency scoring - test_score: engagement computation, normalization, scoring for Reddit/X/YouTube/WebSearch, sort order and tiebreaking - test_dedupe: text normalization, n-grams, Jaccard similarity, duplicate detection, deduplication with score preservation - test_normalize: date range filtering, normalization of raw API data to schema objects for all four source types Co-Authored-By: Claude Opus 4.6 --- plugins/last30days/scripts/pyproject.toml | 10 + plugins/last30days/scripts/tests/__init__.py | 0 .../last30days/scripts/tests/test_dates.py | 134 +++++++ .../last30days/scripts/tests/test_dedupe.py | 200 ++++++++++ .../scripts/tests/test_normalize.py | 183 +++++++++ .../last30days/scripts/tests/test_score.py | 366 ++++++++++++++++++ 6 files changed, 893 insertions(+) create mode 100644 plugins/last30days/scripts/pyproject.toml create mode 100644 plugins/last30days/scripts/tests/__init__.py create mode 100644 plugins/last30days/scripts/tests/test_dates.py create mode 100644 plugins/last30days/scripts/tests/test_dedupe.py create mode 100644 plugins/last30days/scripts/tests/test_normalize.py create mode 100644 plugins/last30days/scripts/tests/test_score.py diff --git a/plugins/last30days/scripts/pyproject.toml b/plugins/last30days/scripts/pyproject.toml new file mode 100644 index 0000000..930670a --- /dev/null +++ b/plugins/last30days/scripts/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "last30days" +version = "0.0.0" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 diff --git a/plugins/last30days/scripts/tests/__init__.py b/plugins/last30days/scripts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/last30days/scripts/tests/test_dates.py b/plugins/last30days/scripts/tests/test_dates.py new file mode 100644 index 0000000..c5383d5 --- /dev/null +++ b/plugins/last30days/scripts/tests/test_dates.py @@ -0,0 +1,134 @@ +"""Tests for lib.dates module.""" + +from datetime import UTC, datetime, timedelta + +from lib import dates + + +class TestGetDateRange: + def test_default_30_days(self): + from_date, to_date = dates.get_date_range() + today = datetime.now(UTC).date() + assert to_date == today.isoformat() + expected_from = (today - timedelta(days=30)).isoformat() + assert from_date == expected_from + + def test_custom_days(self): + from_date, to_date = dates.get_date_range(7) + today = datetime.now(UTC).date() + expected_from = (today - timedelta(days=7)).isoformat() + assert from_date == expected_from + assert to_date == today.isoformat() + + def test_one_day(self): + from_date, to_date = dates.get_date_range(1) + today = datetime.now(UTC).date() + yesterday = (today - timedelta(days=1)).isoformat() + assert from_date == yesterday + assert to_date == today.isoformat() + + +class TestParseDate: + def test_yyyy_mm_dd(self): + result = dates.parse_date("2026-01-15") + assert result.year == 2026 + assert result.month == 1 + assert result.day == 15 + + def test_iso_with_time(self): + result = dates.parse_date("2026-01-15T10:30:00Z") + assert result.year == 2026 + assert result.hour == 10 + + def test_unix_timestamp(self): + result = dates.parse_date("1700000000") + assert result is not None + assert result.year == 2023 + + def test_none_input(self): + assert dates.parse_date(None) is None + + def test_empty_string(self): + assert dates.parse_date("") is None + + def test_garbage_input(self): + assert dates.parse_date("not-a-date") is None + + +class TestTimestampToDate: + def test_valid_timestamp(self): + # 2023-11-14 in UTC + result = dates.timestamp_to_date(1700000000) + assert result == "2023-11-14" + + def test_none(self): + assert dates.timestamp_to_date(None) is None + + def test_zero(self): + result = dates.timestamp_to_date(0) + assert result == "1970-01-01" + + +class TestGetDateConfidence: + def test_in_range_is_high(self): + assert dates.get_date_confidence("2026-01-15", "2026-01-01", "2026-01-31") == "high" + + def test_on_boundary_start_is_high(self): + assert dates.get_date_confidence("2026-01-01", "2026-01-01", "2026-01-31") == "high" + + def test_on_boundary_end_is_high(self): + assert dates.get_date_confidence("2026-01-31", "2026-01-01", "2026-01-31") == "high" + + def test_before_range_is_low(self): + assert dates.get_date_confidence("2025-12-31", "2026-01-01", "2026-01-31") == "low" + + def test_after_range_is_low(self): + assert dates.get_date_confidence("2026-02-01", "2026-01-01", "2026-01-31") == "low" + + def test_none_is_low(self): + assert dates.get_date_confidence(None, "2026-01-01", "2026-01-31") == "low" + + def test_invalid_format_is_low(self): + assert dates.get_date_confidence("garbage", "2026-01-01", "2026-01-31") == "low" + + +class TestDaysAgo: + def test_today_is_zero(self): + today = datetime.now(UTC).date().isoformat() + assert dates.days_ago(today) == 0 + + def test_yesterday(self): + yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat() + assert dates.days_ago(yesterday) == 1 + + def test_none(self): + assert dates.days_ago(None) is None + + def test_invalid(self): + assert dates.days_ago("not-a-date") is None + + +class TestRecencyScore: + def test_today_is_100(self): + today = datetime.now(UTC).date().isoformat() + assert dates.recency_score(today) == 100 + + def test_max_days_ago_is_zero(self): + old = (datetime.now(UTC).date() - timedelta(days=30)).isoformat() + assert dates.recency_score(old) == 0 + + def test_half_max_is_about_50(self): + half = (datetime.now(UTC).date() - timedelta(days=15)).isoformat() + assert dates.recency_score(half) == 50 + + def test_none_is_zero(self): + assert dates.recency_score(None) == 0 + + def test_future_date_is_100(self): + future = (datetime.now(UTC).date() + timedelta(days=5)).isoformat() + assert dates.recency_score(future) == 100 + + def test_custom_max_days(self): + one_week_ago = (datetime.now(UTC).date() - timedelta(days=7)).isoformat() + assert dates.recency_score(one_week_ago, max_days=7) == 0 + assert dates.recency_score(one_week_ago, max_days=14) == 50 diff --git a/plugins/last30days/scripts/tests/test_dedupe.py b/plugins/last30days/scripts/tests/test_dedupe.py new file mode 100644 index 0000000..b7be223 --- /dev/null +++ b/plugins/last30days/scripts/tests/test_dedupe.py @@ -0,0 +1,200 @@ +"""Tests for lib.dedupe module.""" + +from lib import dedupe, schema + + +class TestNormalizeText: + def test_lowercase(self): + assert dedupe.normalize_text("Hello World") == "hello world" + + def test_remove_punctuation(self): + assert dedupe.normalize_text("hello, world!") == "hello world" + + def test_collapse_whitespace(self): + assert dedupe.normalize_text("hello world") == "hello world" + + def test_combined(self): + assert dedupe.normalize_text(" Hello, World! ") == "hello world" + + def test_empty(self): + assert dedupe.normalize_text("") == "" + + +class TestGetNgrams: + def test_basic(self): + ngrams = dedupe.get_ngrams("hello") + # "hello" normalized -> "hello", 3-grams: hel, ell, llo + assert "hel" in ngrams + assert "ell" in ngrams + assert "llo" in ngrams + assert len(ngrams) == 3 + + def test_short_text(self): + ngrams = dedupe.get_ngrams("hi") + assert ngrams == {"hi"} + + def test_single_char(self): + ngrams = dedupe.get_ngrams("a") + assert ngrams == {"a"} + + +class TestJaccardSimilarity: + def test_identical_sets(self): + s = {"a", "b", "c"} + assert dedupe.jaccard_similarity(s, s) == 1.0 + + def test_disjoint_sets(self): + assert dedupe.jaccard_similarity({"a", "b"}, {"c", "d"}) == 0.0 + + def test_partial_overlap(self): + result = dedupe.jaccard_similarity({"a", "b", "c"}, {"b", "c", "d"}) + assert result == 2 / 4 # intersection=2, union=4 + + def test_empty_sets(self): + assert dedupe.jaccard_similarity(set(), set()) == 0.0 + + def test_one_empty(self): + assert dedupe.jaccard_similarity({"a"}, set()) == 0.0 + + +class TestFindDuplicates: + def _reddit(self, title, score=50): + return schema.RedditItem( + id="r1", + title=title, + url="", + subreddit="test", + score=score, + ) + + def test_exact_duplicates(self): + items = [self._reddit("Best AI tools 2026"), self._reddit("Best AI tools 2026")] + pairs = dedupe.find_duplicates(items) + assert len(pairs) == 1 + assert pairs[0] == (0, 1) + + def test_near_duplicates(self): + items = [ + self._reddit("Best AI tools for coding in 2026"), + self._reddit("Best AI tools for coding 2026"), + ] + pairs = dedupe.find_duplicates(items, threshold=0.7) + assert len(pairs) == 1 + + def test_different_items(self): + items = [ + self._reddit("Best AI tools for coding"), + self._reddit("How to make sourdough bread"), + ] + pairs = dedupe.find_duplicates(items) + assert len(pairs) == 0 + + def test_empty_list(self): + assert dedupe.find_duplicates([]) == [] + + def test_single_item(self): + assert dedupe.find_duplicates([self._reddit("test")]) == [] + + +class TestDedupeItems: + def _reddit(self, title, score=50): + return schema.RedditItem( + id="r1", + title=title, + url="", + subreddit="test", + score=score, + ) + + def test_keeps_higher_scored(self): + items = [ + self._reddit("Best AI tools 2026", score=90), + self._reddit("Best AI tools 2026", score=50), + ] + result = dedupe.dedupe_items(items) + assert len(result) == 1 + assert result[0].score == 90 + + def test_no_duplicates_unchanged(self): + items = [ + self._reddit("Best AI tools", score=90), + self._reddit("Sourdough bread recipe", score=80), + ] + result = dedupe.dedupe_items(items) + assert len(result) == 2 + + def test_empty_list(self): + assert dedupe.dedupe_items([]) == [] + + def test_single_item(self): + items = [self._reddit("test")] + assert dedupe.dedupe_items(items) == items + + def test_three_duplicates_keeps_one(self): + items = [ + self._reddit("Best AI tools 2026", score=90), + self._reddit("Best AI tools 2026", score=70), + self._reddit("Best AI tools 2026", score=50), + ] + result = dedupe.dedupe_items(items) + assert len(result) == 1 + assert result[0].score == 90 + + def test_custom_threshold(self): + items = [ + self._reddit("Best AI tools for coding in 2026"), + self._reddit("Best AI tools for coding 2026"), + ] + # High threshold: not considered duplicates + result_strict = dedupe.dedupe_items(items, threshold=0.99) + assert len(result_strict) == 2 + + # Low threshold: considered duplicates + result_loose = dedupe.dedupe_items(items, threshold=0.5) + assert len(result_loose) == 1 + + +class TestDedupeX: + def test_dedupes_x_items(self): + items = [ + schema.XItem( + id="1", + text="Claude Code is amazing", + url="", + author_handle="a", + score=90, + ), + schema.XItem( + id="2", + text="Claude Code is amazing!", + url="", + author_handle="b", + score=50, + ), + ] + result = dedupe.dedupe_x(items) + assert len(result) == 1 + assert result[0].score == 90 + + +class TestDedupeYoutube: + def test_dedupes_youtube_items(self): + items = [ + schema.YouTubeItem( + id="v1", + title="Claude Code Tutorial", + url="", + channel_name="TechChannel", + score=90, + ), + schema.YouTubeItem( + id="v2", + title="Claude Code Tutorial", + url="", + channel_name="TechChannel", + score=50, + ), + ] + result = dedupe.dedupe_youtube(items) + assert len(result) == 1 + assert result[0].score == 90 diff --git a/plugins/last30days/scripts/tests/test_normalize.py b/plugins/last30days/scripts/tests/test_normalize.py new file mode 100644 index 0000000..a40c61d --- /dev/null +++ b/plugins/last30days/scripts/tests/test_normalize.py @@ -0,0 +1,183 @@ +"""Tests for lib.normalize module.""" + +from lib import normalize, schema + + +class TestFilterByDateRange: + def _reddit(self, date=None): + return schema.RedditItem( + id="r1", + title="Test", + url="", + subreddit="test", + date=date, + ) + + def test_in_range_kept(self): + items = [self._reddit("2026-01-15")] + result = normalize.filter_by_date_range(items, "2026-01-01", "2026-01-31") + assert len(result) == 1 + + def test_before_range_excluded(self): + items = [self._reddit("2025-12-15")] + result = normalize.filter_by_date_range(items, "2026-01-01", "2026-01-31") + assert len(result) == 0 + + def test_after_range_excluded(self): + items = [self._reddit("2026-02-15")] + result = normalize.filter_by_date_range(items, "2026-01-01", "2026-01-31") + assert len(result) == 0 + + def test_on_boundary_start_kept(self): + items = [self._reddit("2026-01-01")] + result = normalize.filter_by_date_range(items, "2026-01-01", "2026-01-31") + assert len(result) == 1 + + def test_on_boundary_end_kept(self): + items = [self._reddit("2026-01-31")] + result = normalize.filter_by_date_range(items, "2026-01-01", "2026-01-31") + assert len(result) == 1 + + def test_none_date_kept_by_default(self): + items = [self._reddit(None)] + result = normalize.filter_by_date_range(items, "2026-01-01", "2026-01-31") + assert len(result) == 1 + + def test_none_date_excluded_when_required(self): + items = [self._reddit(None)] + result = normalize.filter_by_date_range( + items, + "2026-01-01", + "2026-01-31", + require_date=True, + ) + assert len(result) == 0 + + def test_mixed_items(self): + items = [ + self._reddit("2026-01-15"), # in range + self._reddit("2025-06-01"), # too old + self._reddit(None), # no date + self._reddit("2026-01-20"), # in range + ] + result = normalize.filter_by_date_range(items, "2026-01-01", "2026-01-31") + assert len(result) == 3 # 2 in range + 1 no date + + def test_empty_list(self): + assert normalize.filter_by_date_range([], "2026-01-01", "2026-01-31") == [] + + +class TestNormalizeRedditItems: + def test_basic_normalization(self): + raw = [ + { + "id": "abc123", + "title": "Test Post", + "url": "https://reddit.com/r/test/abc123", + "subreddit": "test", + "date": "2026-01-15", + "relevance": 0.9, + "why_relevant": "Very relevant", + "engagement": {"score": 100, "num_comments": 50, "upvote_ratio": 0.95}, + "top_comments": [ + { + "score": 42, + "date": "2026-01-15", + "author": "commenter", + "excerpt": "Great post", + "url": "https://reddit.com/r/test/abc123/comment", + }, + ], + }, + ] + result = normalize.normalize_reddit_items(raw, "2026-01-01", "2026-01-31") + assert len(result) == 1 + item = result[0] + assert isinstance(item, schema.RedditItem) + assert item.id == "abc123" + assert item.title == "Test Post" + assert item.relevance == 0.9 + assert item.engagement.score == 100 + assert len(item.top_comments) == 1 + assert item.date_confidence == "high" + + def test_missing_fields_use_defaults(self): + raw = [{"id": "x"}] + result = normalize.normalize_reddit_items(raw, "2026-01-01", "2026-01-31") + assert result[0].title == "" + assert result[0].relevance == 0.5 + assert result[0].engagement is None + + def test_empty_list(self): + assert normalize.normalize_reddit_items([], "2026-01-01", "2026-01-31") == [] + + +class TestNormalizeXItems: + def test_basic_normalization(self): + raw = [ + { + "id": "tweet1", + "text": "Test tweet about AI", + "url": "https://x.com/user/tweet1", + "author_handle": "testuser", + "date": "2026-01-15", + "relevance": 0.85, + "why_relevant": "On topic", + "engagement": {"likes": 500, "reposts": 50, "replies": 20, "quotes": 5}, + }, + ] + result = normalize.normalize_x_items(raw, "2026-01-01", "2026-01-31") + assert len(result) == 1 + item = result[0] + assert isinstance(item, schema.XItem) + assert item.text == "Test tweet about AI" + assert item.engagement.likes == 500 + assert item.date_confidence == "high" + + def test_missing_engagement(self): + raw = [{"id": "t1", "text": "No engagement"}] + result = normalize.normalize_x_items(raw, "2026-01-01", "2026-01-31") + assert result[0].engagement is None + + +class TestNormalizeYoutubeItems: + def test_basic_normalization(self): + raw = [ + { + "video_id": "dQw4w9WgXcQ", + "title": "AI Tutorial", + "url": "https://youtube.com/watch?v=dQw4w9WgXcQ", + "channel_name": "TechChannel", + "date": "2026-01-15", + "relevance": 0.9, + "engagement": {"views": 50000, "likes": 1000, "comments": 100}, + "transcript_snippet": "Today we'll learn about...", + }, + ] + result = normalize.normalize_youtube_items(raw, "2026-01-01", "2026-01-31") + assert len(result) == 1 + item = result[0] + assert isinstance(item, schema.YouTubeItem) + assert item.id == "dQw4w9WgXcQ" + assert item.channel_name == "TechChannel" + assert item.engagement.views == 50000 + assert item.date_confidence == "high" # YouTube always high + + +class TestItemsToDicts: + def test_reddit_roundtrip(self): + items = [ + schema.RedditItem( + id="r1", + title="Test", + url="https://reddit.com/r/test/r1", + subreddit="test", + date="2026-01-15", + relevance=0.8, + ), + ] + dicts = normalize.items_to_dicts(items) + assert len(dicts) == 1 + assert dicts[0]["id"] == "r1" + assert dicts[0]["title"] == "Test" + assert dicts[0]["relevance"] == 0.8 diff --git a/plugins/last30days/scripts/tests/test_score.py b/plugins/last30days/scripts/tests/test_score.py new file mode 100644 index 0000000..aab5609 --- /dev/null +++ b/plugins/last30days/scripts/tests/test_score.py @@ -0,0 +1,366 @@ +"""Tests for lib.score module.""" + +import math +from datetime import UTC, datetime + +from lib import schema, score + + +class TestLog1pSafe: + def test_positive_value(self): + assert score.log1p_safe(100) == math.log1p(100) + + def test_zero(self): + assert score.log1p_safe(0) == 0.0 + + def test_none(self): + assert score.log1p_safe(None) == 0.0 + + def test_negative(self): + assert score.log1p_safe(-5) == 0.0 + + +class TestNormalizeTo100: + def test_two_values(self): + result = score.normalize_to_100([0, 10]) + assert result == [0.0, 100.0] + + def test_three_values(self): + result = score.normalize_to_100([0, 5, 10]) + assert result == [0.0, 50.0, 100.0] + + def test_all_same(self): + result = score.normalize_to_100([5, 5, 5]) + assert result == [50, 50, 50] + + def test_with_none(self): + result = score.normalize_to_100([0, None, 10]) + assert result[0] == 0.0 + assert result[1] is None + assert result[2] == 100.0 + + def test_all_none(self): + result = score.normalize_to_100([None, None]) + assert result == [50, 50] + + def test_empty(self): + assert score.normalize_to_100([]) == [] + + def test_single_value(self): + result = score.normalize_to_100([42]) + assert result == [50] # Single value -> midpoint + + +class TestComputeRedditEngagementRaw: + def test_typical_post(self): + eng = schema.Engagement(score=100, num_comments=50, upvote_ratio=0.95) + result = score.compute_reddit_engagement_raw(eng) + assert result is not None + assert result > 0 + + def test_none_engagement(self): + assert score.compute_reddit_engagement_raw(None) is None + + def test_no_metrics(self): + eng = schema.Engagement() + assert score.compute_reddit_engagement_raw(eng) is None + + def test_only_score(self): + eng = schema.Engagement(score=100) + result = score.compute_reddit_engagement_raw(eng) + assert result is not None + + def test_higher_engagement_scores_higher(self): + low = schema.Engagement(score=10, num_comments=5, upvote_ratio=0.8) + high = schema.Engagement(score=1000, num_comments=500, upvote_ratio=0.95) + assert score.compute_reddit_engagement_raw(high) > score.compute_reddit_engagement_raw(low) + + +class TestComputeXEngagementRaw: + def test_typical_post(self): + eng = schema.Engagement(likes=500, reposts=50, replies=20, quotes=5) + result = score.compute_x_engagement_raw(eng) + assert result is not None + assert result > 0 + + def test_none_engagement(self): + assert score.compute_x_engagement_raw(None) is None + + def test_no_metrics(self): + eng = schema.Engagement() + assert score.compute_x_engagement_raw(eng) is None + + def test_higher_engagement_scores_higher(self): + low = schema.Engagement(likes=10, reposts=1) + high = schema.Engagement(likes=10000, reposts=1000) + assert score.compute_x_engagement_raw(high) > score.compute_x_engagement_raw(low) + + +class TestComputeYoutubeEngagementRaw: + def test_typical_video(self): + eng = schema.Engagement(views=50000, likes=1000, num_comments=100) + result = score.compute_youtube_engagement_raw(eng) + assert result is not None + assert result > 0 + + def test_none_engagement(self): + assert score.compute_youtube_engagement_raw(None) is None + + def test_views_dominate(self): + high_views = schema.Engagement(views=1000000, likes=10) + high_likes = schema.Engagement(views=100, likes=10000) + # Views have 0.50 weight vs likes at 0.35, and log dampens large values, + # but million views should still outweigh + assert score.compute_youtube_engagement_raw( + high_views + ) > score.compute_youtube_engagement_raw(high_likes) + + +class TestScoreRedditItems: + def _make_item(self, **kwargs): + today = datetime.now(UTC).date().isoformat() + defaults = { + "id": "abc", + "title": "Test post", + "url": "https://reddit.com/r/test/abc", + "subreddit": "test", + "date": today, + "date_confidence": "high", + "relevance": 0.8, + "engagement": schema.Engagement(score=100, num_comments=50, upvote_ratio=0.95), + } + defaults.update(kwargs) + return schema.RedditItem(**defaults) + + def test_empty_list(self): + assert score.score_reddit_items([]) == [] + + def test_single_item_gets_score(self): + item = self._make_item() + result = score.score_reddit_items([item]) + assert result[0].score > 0 + assert result[0].score <= 100 + + def test_score_is_int(self): + item = self._make_item() + result = score.score_reddit_items([item]) + assert isinstance(result[0].score, int) + + def test_subscores_populated(self): + item = self._make_item() + result = score.score_reddit_items([item]) + assert result[0].subs.relevance > 0 + assert result[0].subs.recency > 0 + + def test_high_relevance_beats_low(self): + high = self._make_item(relevance=0.95) + low = self._make_item(relevance=0.1) + scored = score.score_reddit_items([high, low]) + assert scored[0].score > scored[1].score + + def test_low_date_confidence_penalized(self): + high_conf = self._make_item(date_confidence="high") + low_conf = self._make_item(date_confidence="low") + score.score_reddit_items([high_conf]) + score.score_reddit_items([low_conf]) + assert high_conf.score > low_conf.score + + def test_no_engagement_gets_normalized_default(self): + """Single item with None engagement gets normalize_to_100 default (50).""" + item = self._make_item(engagement=None) + result = score.score_reddit_items([item]) + # normalize_to_100([None]) returns [50] as default + assert result[0].subs.engagement == 50 + + +class TestScoreXItems: + def _make_item(self, **kwargs): + today = datetime.now(UTC).date().isoformat() + defaults = { + "id": "123", + "text": "Test tweet", + "url": "https://x.com/user/123", + "author_handle": "user", + "date": today, + "date_confidence": "high", + "relevance": 0.8, + "engagement": schema.Engagement(likes=500, reposts=50, replies=20), + } + defaults.update(kwargs) + return schema.XItem(**defaults) + + def test_empty_list(self): + assert score.score_x_items([]) == [] + + def test_single_item_gets_score(self): + item = self._make_item() + result = score.score_x_items([item]) + assert 0 < result[0].score <= 100 + + def test_high_engagement_beats_low(self): + high = self._make_item( + engagement=schema.Engagement(likes=10000, reposts=1000), + ) + low = self._make_item( + engagement=schema.Engagement(likes=5, reposts=0), + ) + scored = score.score_x_items([high, low]) + assert scored[0].score > scored[1].score + + +class TestScoreWebSearchItems: + def _make_item(self, **kwargs): + today = datetime.now(UTC).date().isoformat() + defaults = { + "id": "w1", + "title": "Test page", + "url": "https://example.com/test", + "source_domain": "example.com", + "snippet": "A test page", + "date": today, + "date_confidence": "high", + "relevance": 0.8, + } + defaults.update(kwargs) + return schema.WebSearchItem(**defaults) + + def test_empty_list(self): + assert score.score_websearch_items([]) == [] + + def test_single_item_gets_score(self): + item = self._make_item() + result = score.score_websearch_items([item]) + assert result[0].score > 0 + + def test_engagement_subscore_is_zero(self): + item = self._make_item() + score.score_websearch_items([item]) + assert item.subs.engagement == 0 + + def test_high_confidence_bonus(self): + high = self._make_item(date_confidence="high") + low = self._make_item(date_confidence="low") + score.score_websearch_items([high]) + score.score_websearch_items([low]) + assert high.score > low.score + + def test_source_penalty_applied(self): + """WebSearch items get a 15-point source penalty.""" + item = self._make_item(relevance=0.8, date_confidence="high") + score.score_websearch_items([item]) + # Without penalty: 0.55*80 + 0.45*100 + 10 (high bonus) = 44+45+10 = 99 + # With penalty: 99 - 15 = 84 + assert item.score == 84 + + def test_web_lower_than_reddit_with_spread(self): + """With engagement spread, Reddit high-engagement beats web.""" + today = datetime.now(UTC).date().isoformat() + web = self._make_item(relevance=0.8, date=today, date_confidence="high") + # Multiple Reddit items so engagement normalization creates a range + reddit_high = schema.RedditItem( + id="r1", + title="High", + url="", + subreddit="test", + date=today, + date_confidence="high", + relevance=0.8, + engagement=schema.Engagement(score=1000, num_comments=500, upvote_ratio=0.95), + ) + reddit_low = schema.RedditItem( + id="r2", + title="Low", + url="", + subreddit="test", + date=today, + date_confidence="high", + relevance=0.8, + engagement=schema.Engagement(score=1, num_comments=0, upvote_ratio=0.5), + ) + score.score_websearch_items([web]) + score.score_reddit_items([reddit_high, reddit_low]) + assert reddit_high.score > web.score + + +class TestSortItems: + def test_sorts_by_score_descending(self): + a = schema.RedditItem( + id="a", + title="A", + url="", + subreddit="", + score=90, + ) + b = schema.RedditItem( + id="b", + title="B", + url="", + subreddit="", + score=50, + ) + result = score.sort_items([b, a]) + assert result[0].id == "a" + assert result[1].id == "b" + + def test_tiebreak_by_date(self): + a = schema.RedditItem( + id="a", + title="A", + url="", + subreddit="", + score=80, + date="2026-02-15", + ) + b = schema.RedditItem( + id="b", + title="B", + url="", + subreddit="", + score=80, + date="2026-02-10", + ) + result = score.sort_items([b, a]) + assert result[0].id == "a" # More recent first + + def test_tiebreak_by_source_priority(self): + reddit = schema.RedditItem( + id="r", + title="Same", + url="", + subreddit="", + score=80, + date="2026-02-15", + ) + x = schema.XItem( + id="x", + text="Same", + url="", + author_handle="", + score=80, + date="2026-02-15", + ) + result = score.sort_items([x, reddit]) + assert isinstance(result[0], schema.RedditItem) # Reddit > X + + def test_empty_list(self): + assert score.sort_items([]) == [] + + def test_none_date_handled(self): + a = schema.RedditItem( + id="a", + title="A", + url="", + subreddit="", + score=80, + date=None, + ) + b = schema.RedditItem( + id="b", + title="B", + url="", + subreddit="", + score=80, + date="2026-02-15", + ) + result = score.sort_items([a, b]) + assert result[0].id == "b" # Dated item first