mirror of
https://github.com/trailofbits/skills-curated
synced 2026-06-21 14:12:04 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user