feat: v3.0.0 - intelligent search, GitHub person/project mode, ELI5, 13+ sources

v3 rewrites the search engine from the ground up:

- Intelligent pre-research: resolves X handles, GitHub repos, subreddits,
  TikTok hashtags, and YouTube channels before searching
- GitHub person-mode: PR velocity, top repos by stars, release notes
- GitHub project-mode: live star counts, README, releases, top issues
- ELI5 mode: plain language synthesis, no jargon
- 13+ sources: Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket,
  GitHub, Threads, Pinterest, Perplexity, Bluesky, Web
- Free Reddit comments via public JSON (no API key needed)
- Fun judge v2: humor scoring baked into narrative
- Cookie consent before browser scanning
- 10,000 free ScrapeCreators calls
- 1,012 tests

Thank you to the community contributors whose issues and PRs shaped v3:
@uppinote20 (#143), @zerone0x (#134, #136), @thinkun (#116),
@thomasmktong (#124), @fanispoulinakisai-boop (#100), @pejmanjohn (#78),
@zl190 (#115), @hnshah (#84, #85, #86)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-04-08 10:52:23 -07:00
parent 61904b31e3
commit 0a9ff16dfc
397 changed files with 21427 additions and 53106 deletions
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""E2E comparison: run sample queries on both v3 (current branch) and v2.9.5 (main).
Usage:
python3 tests/e2e_comparison.py [--v2-script PATH]
Outputs a markdown comparison table with per-query metrics.
"""
import json
import subprocess
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
V3_SCRIPT = str(REPO / "scripts" / "last30days.py")
# v2.9.5 from plugin cache (main branch equivalent)
V2_SCRIPT = str(
Path.home()
/ ".claude/plugins/cache/last30days/last30days/2.9.5/scripts/last30days.py"
)
EVAL_TOPICS_FILE = REPO / "fixtures" / "eval_topics.json"
def _load_queries() -> list[tuple[str, str]]:
if EVAL_TOPICS_FILE.exists():
rows = json.loads(EVAL_TOPICS_FILE.read_text())
return [(row["topic"], row["query_type"]) for row in rows]
return [
("openclaw vs nanoclaw vs ironclaw", "comparison"),
("how to deploy on Fly.io", "how_to"),
("kanye west", "breaking_news"),
("odds of recession", "prediction"),
("explain transformer architecture", "concept"),
]
QUERIES = _load_queries()
def run_query(script: str, topic: str, timeout: int = 180) -> dict:
"""Run a query and return parsed JSON + timing."""
start = time.time()
try:
result = subprocess.run(
[sys.executable, script, topic, "--emit=json"],
capture_output=True,
text=True,
timeout=timeout,
)
elapsed = time.time() - start
if result.returncode != 0:
return {
"error": result.stderr[:200],
"elapsed": elapsed,
"sources": 0,
"candidates": 0,
"intent": "error",
"subqueries": 0,
}
data = json.loads(result.stdout)
# v3 shape
if "query_plan" in data:
items_by_source = data.get("items_by_source", {})
return {
"elapsed": elapsed,
"sources": sum(1 for v in items_by_source.values() if v),
"total_items": sum(len(v) for v in items_by_source.values()),
"candidates": len(data.get("ranked_candidates", [])),
"clusters": len(data.get("clusters", [])),
"intent": data["query_plan"].get("intent", "?"),
"subqueries": len(data["query_plan"].get("subqueries", [])),
"errors": list(data.get("errors_by_source", {}).keys()),
}
# v2 shape
sources_with_items = 0
total_items = 0
for key in ["reddit", "x", "youtube", "tiktok", "instagram", "hackernews",
"bluesky", "truthsocial", "polymarket", "web"]:
items = data.get(key, [])
if items:
sources_with_items += 1
total_items += len(items)
return {
"elapsed": elapsed,
"sources": sources_with_items,
"total_items": total_items,
"candidates": total_items,
"clusters": 0,
"intent": data.get("mode", "?"),
"subqueries": 0,
"errors": [k for k in ["reddit_error", "x_error", "youtube_error",
"tiktok_error", "instagram_error"]
if data.get(k)],
}
except subprocess.TimeoutExpired:
return {
"error": "timeout",
"elapsed": timeout,
"sources": 0,
"candidates": 0,
"intent": "timeout",
"subqueries": 0,
}
except Exception as exc:
return {
"error": str(exc)[:200],
"elapsed": time.time() - start,
"sources": 0,
"candidates": 0,
"intent": "error",
"subqueries": 0,
}
def main():
v2_script = V2_SCRIPT
if len(sys.argv) > 2 and sys.argv[1] == "--v2-script":
v2_script = sys.argv[2]
if not Path(v2_script).exists():
print(f"v2 script not found at {v2_script}", file=sys.stderr)
print("Use --v2-script PATH to specify", file=sys.stderr)
sys.exit(1)
print("# E2E Comparison: v3.0.0 (branch) vs v2.9.5 (main)")
print()
print(f"- v3 script: {V3_SCRIPT}")
print(f"- v2 script: {v2_script}")
print(f"- Queries: {len(QUERIES)}")
print()
results = []
for i, (topic, expected_intent) in enumerate(QUERIES, 1):
print(f"[{i}/{len(QUERIES)}] {topic}", file=sys.stderr)
sys.stderr.flush()
print(f" v3...", end="", file=sys.stderr)
sys.stderr.flush()
v3 = run_query(V3_SCRIPT, topic)
print(f" {v3.get('elapsed', 0):.1f}s", file=sys.stderr)
sys.stderr.flush()
print(f" v2...", end="", file=sys.stderr)
sys.stderr.flush()
v2 = run_query(v2_script, topic)
print(f" {v2.get('elapsed', 0):.1f}s", file=sys.stderr)
sys.stderr.flush()
results.append({
"topic": topic,
"expected_intent": expected_intent,
"v3": v3,
"v2": v2,
})
# Print comparison table
print("| Query | Intent | v3 sources | v2 sources | v3 items | v2 items | v3 time | v2 time | v3 errors | v2 errors |")
print("|-------|--------|-----------|-----------|---------|---------|---------|---------|-----------|-----------|")
for r in results:
v3, v2 = r["v3"], r["v2"]
v3_err = ", ".join(v3.get("errors", [])) or "-"
v2_err = ", ".join(v2.get("errors", [])) or "-"
print(
f"| {r['topic'][:45]} | {v3.get('intent', '?')} | "
f"{v3.get('sources', 0)} | {v2.get('sources', 0)} | "
f"{v3.get('total_items', 0)} | {v2.get('total_items', 0)} | "
f"{v3.get('elapsed', 0):.1f}s | {v2.get('elapsed', 0):.1f}s | "
f"{v3_err} | {v2_err} |"
)
# Summary
print()
v3_total_sources = sum(r["v3"].get("sources", 0) for r in results)
v2_total_sources = sum(r["v2"].get("sources", 0) for r in results)
v3_total_items = sum(r["v3"].get("total_items", 0) for r in results)
v2_total_items = sum(r["v2"].get("total_items", 0) for r in results)
v3_total_time = sum(r["v3"].get("elapsed", 0) for r in results)
v2_total_time = sum(r["v2"].get("elapsed", 0) for r in results)
v3_errors = sum(len(r["v3"].get("errors", [])) for r in results)
v2_errors = sum(len(r["v2"].get("errors", [])) for r in results)
print("## Summary")
print()
print(f"| Metric | v3.0.0 | v2.9.5 | Delta |")
print(f"|--------|--------|--------|-------|")
print(f"| Total sources with items | {v3_total_sources} | {v2_total_sources} | {v3_total_sources - v2_total_sources:+d} |")
print(f"| Total items retrieved | {v3_total_items} | {v2_total_items} | {v3_total_items - v2_total_items:+d} |")
print(f"| Total wall time | {v3_total_time:.1f}s | {v2_total_time:.1f}s | {v3_total_time - v2_total_time:+.1f}s |")
print(f"| Source errors | {v3_errors} | {v2_errors} | {v3_errors - v2_errors:+d} |")
print(f"| Avg sources/query | {v3_total_sources/len(results):.1f} | {v2_total_sources/len(results):.1f} | |")
print(f"| Avg items/query | {v3_total_items/len(results):.1f} | {v2_total_items/len(results):.1f} | |")
print(f"| Avg time/query | {v3_total_time/len(results):.1f}s | {v2_total_time/len(results):.1f}s | |")
if __name__ == "__main__":
main()
+198
View File
@@ -0,0 +1,198 @@
"""Adversarial query tests for the v3 planner.
These target edge cases found during regression analysis: slash-separated
comparisons, 'difference between X and Y' phrasing, trailing context
leaking into entities, degenerate inputs, and false-positive resistance.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import planner
class TestSlashSeparatedComparison(unittest.TestCase):
"""'React/Vue/Svelte' should be detected as comparison intent
and produce entity subqueries."""
def test_slash_triggers_comparison_intent(self):
self.assertEqual(planner._infer_intent("React/Vue/Svelte"), "comparison")
def test_slash_extracts_entities(self):
entities = planner._comparison_entities("React/Vue/Svelte")
self.assertEqual(len(entities), 3)
self.assertIn("React", entities)
self.assertIn("Vue", entities)
self.assertIn("Svelte", entities)
def test_slash_forces_deterministic(self):
self.assertTrue(planner._should_force_deterministic_plan("React/Vue"))
def test_url_slash_does_not_trigger_comparison(self):
self.assertNotEqual(planner._infer_intent("https://example.com/path"), "comparison")
def test_slash_trailing_context_stripped(self):
entities = planner._comparison_entities("React/Vue/Svelte for frontend in 2026")
for entity in entities:
self.assertNotIn("frontend", entity.lower(),
f"Trailing context leaked: '{entity}'")
class TestDifferenceBetweenPhrasing(unittest.TestCase):
"""'difference between X and Y' should extract both entities."""
def test_intent_is_comparison(self):
self.assertEqual(
planner._infer_intent("difference between OpenClaw and NanoClaw"),
"comparison",
)
def test_entities_extracted(self):
entities = planner._comparison_entities("difference between OpenClaw and NanoClaw")
self.assertEqual(len(entities), 2)
self.assertIn("OpenClaw", entities)
self.assertIn("NanoClaw", entities)
def test_forces_deterministic(self):
self.assertTrue(
planner._should_force_deterministic_plan("difference between OpenClaw and NanoClaw")
)
class TestAndFalsePositive(unittest.TestCase):
"""'and' must not split entities outside 'difference between' context."""
def test_pros_and_cons_no_entities(self):
entities = planner._comparison_entities("pros and cons of AI")
self.assertEqual(entities, [])
def test_react_and_vue_no_entities(self):
# No "vs" or "difference between" -- just "and"
entities = planner._comparison_entities("React and Vue")
self.assertEqual(entities, [])
class TestTrailingContextStripping(unittest.TestCase):
"""Trailing preposition phrases must not leak into entity strings."""
def test_for_stripped(self):
entities = planner._comparison_entities("A vs B for production use")
self.assertNotIn("production", entities[-1].lower())
def test_in_stripped(self):
entities = planner._comparison_entities("A vs B in 2026")
self.assertNotIn("2026", entities[-1])
def test_with_stripped(self):
entities = planner._comparison_entities("A vs B with better security")
self.assertNotIn("security", entities[-1].lower())
def test_core_entity_preserved(self):
entities = planner._comparison_entities("Fly.io vs Railway.app for deployment")
self.assertTrue(any("Fly" in e for e in entities))
self.assertTrue(any("Railway" in e for e in entities))
class TestDuplicateEntities(unittest.TestCase):
def test_deduped(self):
entities = planner._comparison_entities("OpenClaw vs OpenClaw")
self.assertEqual(len(entities), 1)
class TestFiveWayComparison(unittest.TestCase):
def test_capped_at_max(self):
topic = "A vs B vs C vs D vs E vs F"
entities = planner._comparison_entities(topic)
self.assertLessEqual(len(entities), planner._max_subqueries("comparison"))
def test_does_not_crash(self):
plan = planner.plan_query(
topic="A vs B vs C vs D vs E",
available_sources=["reddit", "x", "grounding"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertLessEqual(len(plan.subqueries), 4)
class TestDegenerateInputs(unittest.TestCase):
def test_single_word(self):
plan = planner.plan_query(
topic="Bitcoin",
available_sources=["reddit"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertGreater(len(plan.subqueries), 0)
def test_empty_vs_split(self):
plan = planner.plan_query(
topic="vs vs vs",
available_sources=["reddit"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
for sq in plan.subqueries:
self.assertTrue(sq.search_query.strip())
def test_very_long_comparison(self):
topic = " vs ".join(f"Tool{i}" for i in range(20))
plan = planner.plan_query(
topic=topic,
available_sources=["reddit", "x"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertLessEqual(len(plan.subqueries), 4)
class TestMixedCaseAndPunctuation(unittest.TestCase):
def test_uppercase_vs_period(self):
self.assertEqual(
planner._infer_intent("OpenClaw VS. NanoClaw VS. IronClaw"),
"comparison",
)
def test_entities_preserved_with_mixed_case(self):
entities = planner._comparison_entities("OpenClaw VS. NanoClaw VS. IronClaw")
self.assertGreaterEqual(len(entities), 3)
class TestSubstringEntity(unittest.TestCase):
def test_react_vs_react_native_not_collapsed(self):
entities = planner._comparison_entities("React vs React Native")
self.assertEqual(len(entities), 2)
self.assertTrue(any("Native" in e for e in entities))
class TestNoiseWordEntities(unittest.TestCase):
"""Entities that are also common English words (Swift, Rust, Go)
must survive entity extraction."""
def test_swift_preserved(self):
entities = planner._comparison_entities("Swift vs Rust vs Go")
self.assertGreaterEqual(len(entities), 3)
def test_go_not_stripped(self):
entities = planner._comparison_entities("Swift vs Rust vs Go")
self.assertTrue(any("Go" in e for e in entities))
if __name__ == "__main__":
unittest.main()
+51 -88
View File
@@ -1,101 +1,64 @@
"""Tests for bird_x module."""
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import bird_x
from lib.bird_x import parse_bird_response
class TestExtractCoreSubject(unittest.TestCase):
def tearDown(self):
bird_x._credentials.clear()
class TestBirdXEngagementZero(unittest.TestCase):
def test_zero_likes_preserved(self):
tweets = [
{
"id": "1",
"text": "test",
"permanent_url": "https://x.com/u/status/1",
"likeCount": 0,
"retweetCount": 5,
}
]
items = parse_bird_response(tweets, "test query")
self.assertEqual(0, items[0]["engagement"]["likes"])
self.assertEqual(5, items[0]["engagement"]["reposts"])
def test_strips_trending_noise(self):
result = bird_x._extract_core_subject("trendiest Claude Code skills")
self.assertNotIn("trendiest", result)
self.assertIn("claude", result.lower())
def test_none_likes_when_missing(self):
tweets = [
{
"id": "1",
"text": "test tweet with no engagement fields",
"permanent_url": "https://x.com/u/status/1",
# no likeCount, like_count, or favorite_count
}
]
items = parse_bird_response(tweets, "test query")
self.assertIsNone(items[0]["engagement"]["likes"])
def test_strips_tool_noise(self):
result = bird_x._extract_core_subject("best AI tools for coding")
self.assertNotIn("tools", result)
self.assertNotIn("best", result)
def test_fallback_to_second_key(self):
tweets = [
{
"id": "1",
"text": "test",
"permanent_url": "https://x.com/u/status/1",
"like_count": 7,
}
]
items = parse_bird_response(tweets, "test query")
self.assertEqual(7, items[0]["engagement"]["likes"])
def test_strips_skill_noise(self):
result = bird_x._extract_core_subject("top claude code skills")
self.assertNotIn("skills", result)
self.assertNotIn("top", result)
class TestBirdSearchRetries(unittest.TestCase):
def tearDown(self):
bird_x._credentials.clear()
def test_last_chance_retry_uses_strongest_token(self):
"""When shorter retry also returns 0, uses longest non-noise token."""
empty = {"items": []}
with mock.patch.object(bird_x, "_extract_core_subject", return_value="best codex skill plugin"), \
mock.patch.object(bird_x, "parse_bird_response", return_value=[]), \
mock.patch.object(bird_x, "_run_bird_search", return_value=empty) as run_mock:
bird_x.search_x("best codex skill plugin", "2026-01-01", "2026-01-31", depth="quick")
# Should try: original, shorter (2-word), last-chance (strongest token)
self.assertEqual(run_mock.call_count, 3)
queries = [call.args[0] for call in run_mock.call_args_list]
# Last call should use "codex" (longest non-noise word)
self.assertIn("codex", queries[2])
def test_no_retry_when_first_query_has_results(self):
"""No retry when first query succeeds."""
result = {"items": [{"id": "1"}]}
with mock.patch.object(bird_x, "_extract_core_subject", return_value="nano banana"), \
mock.patch.object(bird_x, "parse_bird_response", return_value=[{"id": "1"}]), \
mock.patch.object(bird_x, "_run_bird_search", return_value=result) as run_mock:
bird_x.search_x("nano banana prompting", "2026-01-01", "2026-01-31")
self.assertEqual(run_mock.call_count, 1)
class TestBirdAuthEnvironment(unittest.TestCase):
def tearDown(self):
bird_x._credentials.clear()
def test_subprocess_env_disables_browser_cookie_fallback_when_injected(self):
bird_x.set_credentials("auth-token", "ct0-token")
env = bird_x._subprocess_env()
self.assertEqual(env["AUTH_TOKEN"], "auth-token")
self.assertEqual(env["CT0"], "ct0-token")
self.assertEqual(env["BIRD_DISABLE_BROWSER_COOKIES"], "1")
def test_is_bird_authenticated_short_circuits_when_credentials_injected(self):
bird_x.set_credentials("auth-token", "ct0-token")
with mock.patch.object(bird_x, "is_bird_installed", return_value=True), \
mock.patch.object(bird_x.subprocess, "run") as run_mock:
result = bird_x.is_bird_authenticated()
self.assertEqual(result, "env AUTH_TOKEN")
run_mock.assert_not_called()
def test_search_handles_passes_injected_credentials_to_subprocess(self):
bird_x.set_credentials("auth-token", "ct0-token")
proc = mock.Mock()
proc.communicate.return_value = ("[]", "")
proc.returncode = 0
with mock.patch.object(bird_x.subprocess, "Popen", return_value=proc) as popen_mock:
bird_x.search_handles(["openai"], "codex vs claude code", "2026-01-01", count_per=1)
env = popen_mock.call_args.kwargs["env"]
self.assertEqual(env["AUTH_TOKEN"], "auth-token")
self.assertEqual(env["CT0"], "ct0-token")
self.assertEqual(env["BIRD_DISABLE_BROWSER_COOKIES"], "1")
def test_zero_does_not_fall_through(self):
"""likeCount=0 should not fall through to like_count=10."""
tweets = [
{
"id": "1",
"text": "test",
"permanent_url": "https://x.com/u/status/1",
"likeCount": 0,
"like_count": 10,
}
]
items = parse_bird_response(tweets, "test query")
self.assertEqual(0, items[0]["engagement"]["likes"])
if __name__ == "__main__":
-197
View File
@@ -1,197 +0,0 @@
"""Tests for Brave Search module, including LLM Context endpoint."""
import sys
import os
import unittest
# Ensure scripts/ is on path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
from lib.brave_search import (
_normalize_results,
_normalize_llm_context,
_days_between,
_brave_freshness,
_parse_brave_date,
EXCLUDED_DOMAINS,
)
class TestDaysBetween(unittest.TestCase):
def test_same_day(self):
self.assertEqual(_days_between("2026-03-01", "2026-03-01"), 1)
def test_one_week(self):
self.assertEqual(_days_between("2026-03-01", "2026-03-08"), 7)
def test_invalid_dates(self):
self.assertEqual(_days_between("bad", "dates"), 30)
class TestBraveFreshness(unittest.TestCase):
def test_one_day(self):
self.assertEqual(_brave_freshness(1), "pd")
def test_one_week(self):
self.assertEqual(_brave_freshness(7), "pw")
def test_one_month(self):
self.assertEqual(_brave_freshness(31), "pm")
def test_longer_returns_range(self):
result = _brave_freshness(60)
self.assertIn("to", result)
def test_none(self):
self.assertIsNone(_brave_freshness(None))
class TestParseBraveDate(unittest.TestCase):
def test_hours_ago(self):
result = _parse_brave_date("3 hours ago", None)
self.assertIsNotNone(result)
self.assertRegex(result, r"\d{4}-\d{2}-\d{2}")
def test_days_ago(self):
result = _parse_brave_date("5 days ago", None)
self.assertIsNotNone(result)
def test_weeks_ago(self):
result = _parse_brave_date("2 weeks ago", None)
self.assertIsNotNone(result)
def test_iso_date(self):
self.assertEqual(_parse_brave_date("2026-03-10T12:00:00", None), "2026-03-10")
def test_none(self):
self.assertIsNone(_parse_brave_date(None, None))
class TestNormalizeResults(unittest.TestCase):
def test_merges_news_and_web(self):
response = {
"news": {"results": [
{"url": "https://news.example.com/a", "title": "News A", "description": "News desc"},
]},
"web": {"results": [
{"url": "https://blog.example.com/b", "title": "Blog B", "description": "Blog desc"},
]},
}
items = _normalize_results(response, "2026-03-01", "2026-03-10")
self.assertEqual(len(items), 2)
self.assertEqual(items[0]["title"], "News A")
self.assertEqual(items[1]["title"], "Blog B")
def test_excludes_reddit_and_x(self):
response = {
"web": {"results": [
{"url": "https://www.reddit.com/r/test/123", "title": "Reddit", "description": "text"},
{"url": "https://x.com/user/status/1", "title": "X post", "description": "text"},
{"url": "https://example.com/ok", "title": "OK", "description": "text"},
]},
}
items = _normalize_results(response, "2026-03-01", "2026-03-10")
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["title"], "OK")
def test_default_relevance(self):
response = {"web": {"results": [
{"url": "https://a.com", "title": "A", "description": "desc"},
]}}
items = _normalize_results(response, "2026-03-01", "2026-03-10")
self.assertEqual(items[0]["relevance"], 0.6)
class TestNormalizeLlmContext(unittest.TestCase):
def _make_response(self, generic=None, sources=None):
return {
"grounding": {"generic": generic or []},
"sources": sources or {},
}
def test_basic_result(self):
resp = self._make_response(
generic=[{
"url": "https://docs.example.com/page",
"title": "Example Page",
"snippets": ["First chunk of text.", "Second chunk of text."],
}],
sources={
"https://docs.example.com/page": {
"title": "Example Page",
"hostname": "docs.example.com",
"age": ["2026-03-05", "5 days ago"],
}
},
)
items = _normalize_llm_context(resp)
self.assertEqual(len(items), 1)
item = items[0]
self.assertEqual(item["title"], "Example Page")
self.assertEqual(item["url"], "https://docs.example.com/page")
self.assertIn("First chunk", item["snippet"])
self.assertIn("Second chunk", item["snippet"])
self.assertEqual(item["date"], "2026-03-05")
self.assertEqual(item["date_confidence"], "med")
self.assertEqual(item["relevance"], 0.7)
self.assertEqual(item["source_domain"], "docs.example.com")
def test_excludes_reddit(self):
resp = self._make_response(
generic=[
{"url": "https://www.reddit.com/r/test", "title": "Reddit", "snippets": ["text"]},
{"url": "https://example.com", "title": "OK", "snippets": ["text"]},
],
)
items = _normalize_llm_context(resp)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["title"], "OK")
def test_empty_grounding(self):
resp = self._make_response()
items = _normalize_llm_context(resp)
self.assertEqual(items, [])
def test_snippet_truncation(self):
long_snippet = "x" * 2000
resp = self._make_response(
generic=[{"url": "https://a.com", "title": "A", "snippets": [long_snippet]}],
)
items = _normalize_llm_context(resp)
self.assertLessEqual(len(items[0]["snippet"]), 1500)
def test_no_date_gives_low_confidence(self):
resp = self._make_response(
generic=[{"url": "https://a.com", "title": "A", "snippets": ["text"]}],
sources={"https://a.com": {"hostname": "a.com", "age": None}},
)
items = _normalize_llm_context(resp)
self.assertIsNone(items[0]["date"])
self.assertEqual(items[0]["date_confidence"], "low")
def test_multiple_age_entries_picks_first_valid(self):
resp = self._make_response(
generic=[{"url": "https://a.com", "title": "A", "snippets": ["text"]}],
sources={"https://a.com": {
"hostname": "a.com",
"age": ["Monday, March 10, 2026", "2026-03-10", "1 day ago"],
}},
)
items = _normalize_llm_context(resp)
self.assertEqual(items[0]["date"], "2026-03-10")
def test_ids_are_sequential(self):
resp = self._make_response(
generic=[
{"url": "https://a.com", "title": "A", "snippets": ["a"]},
{"url": "https://b.com", "title": "B", "snippets": ["b"]},
{"url": "https://c.com", "title": "C", "snippets": ["c"]},
],
)
items = _normalize_llm_context(resp)
ids = [item["id"] for item in items]
self.assertEqual(ids, ["W1", "W2", "W3"])
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import briefing
import store
class BriefingV3Tests(unittest.TestCase):
def test_generate_daily_uses_utc_for_last_run(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "research.db"
briefs_dir = Path(tmpdir) / "briefs"
old_db_override = store._db_override
old_briefs_dir = briefing.BRIEFS_DIR
try:
store._db_override = db_path
briefing.BRIEFS_DIR = briefs_dir
topic = store.add_topic("test topic")
store.record_run(topic["id"], source_mode="v3", status="completed")
result = briefing.generate_daily()
self.assertEqual(result["status"], "ok")
self.assertEqual(result["topics"][0]["name"], "test topic")
self.assertIsNotNone(result["topics"][0]["hours_ago"])
self.assertGreaterEqual(result["topics"][0]["hours_ago"], 0.0)
finally:
store._db_override = old_db_override
briefing.BRIEFS_DIR = old_briefs_dir
if __name__ == "__main__":
unittest.main()
-59
View File
@@ -1,59 +0,0 @@
"""Tests for cache module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import cache
class TestGetCacheKey(unittest.TestCase):
def test_returns_string(self):
result = cache.get_cache_key("test topic", "2026-01-01", "2026-01-31", "both")
self.assertIsInstance(result, str)
def test_consistent_for_same_inputs(self):
key1 = cache.get_cache_key("test topic", "2026-01-01", "2026-01-31", "both")
key2 = cache.get_cache_key("test topic", "2026-01-01", "2026-01-31", "both")
self.assertEqual(key1, key2)
def test_different_for_different_inputs(self):
key1 = cache.get_cache_key("topic a", "2026-01-01", "2026-01-31", "both")
key2 = cache.get_cache_key("topic b", "2026-01-01", "2026-01-31", "both")
self.assertNotEqual(key1, key2)
def test_key_length(self):
key = cache.get_cache_key("test", "2026-01-01", "2026-01-31", "both")
self.assertEqual(len(key), 16)
class TestCachePath(unittest.TestCase):
def test_returns_path(self):
result = cache.get_cache_path("abc123")
self.assertIsInstance(result, Path)
def test_has_json_extension(self):
result = cache.get_cache_path("abc123")
self.assertEqual(result.suffix, ".json")
class TestCacheValidity(unittest.TestCase):
def test_nonexistent_file_is_invalid(self):
fake_path = Path("/nonexistent/path/file.json")
result = cache.is_cache_valid(fake_path)
self.assertFalse(result)
class TestModelCache(unittest.TestCase):
def test_get_cached_model_returns_none_for_missing(self):
# Clear any existing cache first
result = cache.get_cached_model("nonexistent_provider")
# May be None or a cached value, but should not error
self.assertTrue(result is None or isinstance(result, str))
if __name__ == "__main__":
unittest.main()
+190
View File
@@ -0,0 +1,190 @@
# ruff: noqa: E402
import json
import io
import tempfile
import subprocess
import sys
import types
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import last30days as cli
from lib import schema
class CliV3Tests(unittest.TestCase):
def make_report(self) -> schema.Report:
return schema.Report(
topic="OpenClaw vs NanoClaw",
range_from="2026-02-14",
range_to="2026-03-16",
generated_at="2026-03-16T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="gemini",
planner_model="gemini-3.1-flash-lite-preview",
rerank_model="gemini-3.1-flash-lite-preview",
),
query_plan=schema.QueryPlan(
intent="comparison",
freshness_mode="balanced_recent",
cluster_mode="debate",
raw_topic="OpenClaw vs NanoClaw",
subqueries=[
schema.SubQuery(
label="primary",
search_query="openclaw vs nanoclaw",
ranking_query="How does OpenClaw compare to NanoClaw?",
sources=["grounding"],
)
],
source_weights={"grounding": 1.0},
),
clusters=[],
ranked_candidates=[],
items_by_source={"grounding": []},
errors_by_source={},
)
def test_mock_json_cli(self):
result = subprocess.run(
[sys.executable, "scripts/last30days.py", "test topic", "--mock", "--emit=json"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
payload = json.loads(result.stdout)
self.assertIn("query_plan", payload)
self.assertIn("ranked_candidates", payload)
self.assertIn("clusters", payload)
def test_parse_search_flag_normalizes_aliases_and_dedupes(self):
self.assertEqual(
["grounding", "reddit", "hackernews"],
cli.parse_search_flag("web, reddit, hn, web"),
)
def test_parse_search_flag_rejects_invalid_or_empty_inputs(self):
with self.assertRaises(SystemExit):
cli.parse_search_flag("unknown")
with self.assertRaises(SystemExit):
cli.parse_search_flag(" , ")
def test_missing_sources_for_promo_prefers_reddit_x_then_web(self):
self.assertEqual(
"both",
cli._missing_sources_for_promo({"available_sources": ["youtube"]}),
)
self.assertEqual(
"web",
cli._missing_sources_for_promo({"available_sources": ["reddit", "x"]}),
)
self.assertIsNone(
cli._missing_sources_for_promo({"available_sources": ["reddit", "x", "grounding"]}),
)
def test_slugify_and_emit_output_cover_supported_modes(self):
report = self.make_report()
self.assertEqual("openclaw-vs-nanoclaw", cli.slugify(report.topic))
compact = cli.emit_output(report, "compact")
json_output = cli.emit_output(report, "json")
context = cli.emit_output(report, "context")
self.assertIn("# last30days-3 v3.0.0-alpha", compact)
self.assertIn('"topic": "OpenClaw vs NanoClaw"', json_output)
self.assertIsInstance(context, str)
with self.assertRaises(SystemExit):
cli.emit_output(report, "bad-mode")
def test_save_output_writes_expected_extension(self):
report = self.make_report()
with tempfile.TemporaryDirectory() as tmp:
path = cli.save_output(report, "json", tmp)
self.assertEqual(".json", path.suffix)
payload = json.loads(path.read_text())
self.assertEqual("OpenClaw vs NanoClaw", payload["topic"])
def test_persist_report_updates_run_status_on_success_and_failure(self):
report = self.make_report()
success_store = types.SimpleNamespace(
init_db=mock.Mock(),
add_topic=mock.Mock(return_value={"id": 7}),
record_run=mock.Mock(return_value=11),
findings_from_report=mock.Mock(return_value=[{"title": "x"}]),
store_findings=mock.Mock(return_value={"new": 2, "updated": 1}),
update_run=mock.Mock(),
)
with mock.patch.dict(sys.modules, {"store": success_store}):
counts = cli.persist_report(report)
self.assertEqual({"new": 2, "updated": 1}, counts)
success_store.update_run.assert_called_once_with(
11,
status="completed",
findings_new=2,
findings_updated=1,
)
failure_store = types.SimpleNamespace(
init_db=mock.Mock(),
add_topic=mock.Mock(return_value={"id": 7}),
record_run=mock.Mock(return_value=12),
findings_from_report=mock.Mock(side_effect=RuntimeError("boom")),
store_findings=mock.Mock(),
update_run=mock.Mock(),
)
with mock.patch.dict(sys.modules, {"store": failure_store}):
with self.assertRaises(RuntimeError):
cli.persist_report(report)
failure_store.update_run.assert_called_once()
_, kwargs = failure_store.update_run.call_args
self.assertEqual("failed", kwargs["status"])
self.assertIn("boom", kwargs["error_message"])
def test_main_wires_banner_and_progress_display(self):
report = self.make_report()
diag = {
"available_sources": ["grounding", "youtube"],
"providers": {"google": True, "openai": False, "xai": False},
"x_backend": None,
"bird_installed": True,
"bird_authenticated": False,
"bird_username": None,
"native_web_backend": "brave",
}
fake_progress = mock.Mock()
with mock.patch.object(cli.env, "get_config", return_value={}), \
mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \
mock.patch.object(cli.pipeline, "run", return_value=report), \
mock.patch.object(cli.ui, "show_diagnostic_banner") as banner, \
mock.patch.object(cli.ui, "ProgressDisplay", return_value=fake_progress) as progress_cls, \
mock.patch.object(cli, "emit_output", return_value="# rendered"), \
mock.patch.object(sys, "argv", ["last30days.py", "test", "topic"]):
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
rc = cli.main()
self.assertEqual(0, rc)
banner.assert_not_called() # Banner moved to post-research
progress_cls.assert_called_once_with("test topic", show_banner=True)
fake_progress.start_processing.assert_called_once()
fake_progress.end_processing.assert_called_once()
fake_progress.show_complete.assert_called_once_with(
source_counts={"grounding": 0},
display_sources=["grounding"],
)
fake_progress.show_promo.assert_called_once_with("both", diag=diag)
self.assertIn("# rendered", stdout.getvalue())
if __name__ == "__main__":
unittest.main()
+201
View File
@@ -0,0 +1,201 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import cluster, schema
def make_candidate(candidate_id: str, source: str, title: str, snippet: str, score: float) -> schema.Candidate:
return schema.Candidate(
candidate_id=candidate_id,
item_id=candidate_id,
source=source,
title=title,
url=f"https://example.com/{candidate_id}",
snippet=snippet,
subquery_labels=["primary"],
native_ranks={"primary:reddit": 1},
local_relevance=0.8,
freshness=80,
engagement=10,
source_quality=0.7,
rrf_score=0.02,
rerank_score=score,
final_score=score,
)
class ClusterV3Tests(unittest.TestCase):
def test_singleton_clusters_for_non_clustered_plan(self):
plan = schema.QueryPlan(
intent="how_to",
freshness_mode="balanced_recent",
cluster_mode="none",
raw_topic="docker setup",
subqueries=[schema.SubQuery(label="primary", search_query="docker setup", ranking_query="How do I set up Docker?", sources=["reddit"])],
source_weights={"reddit": 1.0},
)
candidates = [
make_candidate("c1", "reddit", "Docker setup guide", "Step by step setup", 80),
make_candidate("c2", "youtube", "Docker install video", "Video walkthrough", 75),
]
clusters = cluster.cluster_candidates(candidates, plan)
self.assertEqual(2, len(clusters))
self.assertEqual(["c1"], clusters[0].representative_ids)
self.assertEqual(["c2"], clusters[1].representative_ids)
def test_breaking_news_clusters_related_items(self):
plan = schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="model launch",
subqueries=[schema.SubQuery(label="primary", search_query="model launch", ranking_query="What happened in the model launch?", sources=["reddit", "x"])],
source_weights={"reddit": 0.5, "x": 0.5},
)
candidates = [
make_candidate("c1", "reddit", "Open model launch reactions", "People are reacting to the open model launch today.", 88),
make_candidate("c2", "x", "Open model launch update", "People are reacting to the open model launch today on X.", 84),
make_candidate("c3", "youtube", "Different topic", "A separate discussion about hardware benchmarks.", 70),
]
clusters = cluster.cluster_candidates(candidates, plan)
self.assertEqual(2, len(clusters))
self.assertEqual(2, len(clusters[0].candidate_ids))
self.assertIn("c1", clusters[0].candidate_ids)
self.assertIn("c2", clusters[0].candidate_ids)
class TestCrossSourceMerging(unittest.TestCase):
"""Test the entity-based second pass that merges same-story clusters across sources."""
def _plan(self, intent="breaking_news"):
return schema.QueryPlan(
intent=intent,
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="test",
subqueries=[schema.SubQuery(label="primary", search_query="test", ranking_query="test", sources=["reddit", "x", "tiktok"])],
source_weights={"reddit": 0.5, "x": 0.5, "tiktok": 0.5},
)
def test_same_story_different_phrasing_merges(self):
"""Wireless Festival example: same event, different wording, different sources."""
candidates = [
make_candidate("c1", "reddit", "Kanye West to headline all three nights of Wireless Festival 2026", "Big announcement for Wireless.", 80),
make_candidate("c2", "x", "BREAKING: Kanye West is making his massive UK comeback at Wireless Festival this July", "Ye returns to UK.", 75),
make_candidate("c3", "youtube", "Kanye West BULLY Album Review - Knox Hill Reacts", "Full album reaction and breakdown.", 70),
]
clusters = cluster.cluster_candidates(candidates, self._plan())
# c1 and c2 should merge (Kanye + Wireless + Festival overlap), c3 should stay separate
self.assertEqual(2, len(clusters))
wireless_cluster = next(cl for cl in clusters if len(cl.candidate_ids) == 2)
self.assertIn("c1", wireless_cluster.candidate_ids)
self.assertIn("c2", wireless_cluster.candidate_ids)
self.assertEqual(sorted(["reddit", "x"]), wireless_cluster.sources)
# Multi-source cluster should not have "single-source" uncertainty
self.assertNotEqual("single-source", wireless_cluster.uncertainty)
def test_different_stories_dont_merge(self):
"""Different topics should stay separate even with some entity overlap (e.g., 'Kanye')."""
candidates = [
make_candidate("c1", "reddit", "Kanye West BULLY Album First Impressions Thread", "What do you think of BULLY?", 80),
make_candidate("c2", "x", "Kanye West apology for antisemitism in Wall Street Journal ad", "Full page WSJ ad.", 75),
make_candidate("c3", "tiktok", "Kanye West Wireless Festival ticket prices breakdown", "How much for Wireless tickets?", 70),
]
clusters = cluster.cluster_candidates(candidates, self._plan())
# These are 3 different stories, should remain as 3 clusters
self.assertEqual(3, len(clusters))
def test_same_source_clusters_dont_merge(self):
"""Two single-source clusters from the same source should not merge via entity pass."""
candidates = [
make_candidate("c1", "reddit", "Kanye West Wireless Festival headline announcement", "Three nights!", 80),
make_candidate("c2", "reddit", "Kanye West returning to Wireless Festival confirmed", "UK comeback.", 70),
]
clusters = cluster.cluster_candidates(candidates, self._plan())
# The initial greedy pass may or may not merge these (depends on token similarity).
# But if they end up as separate clusters, the entity pass should NOT merge them
# since they're both from reddit.
for cl in clusters:
self.assertTrue(len(cl.sources) >= 1) # basic sanity
class TestPolymarketIsolation(unittest.TestCase):
"""Polymarket clusters must not merge with non-Polymarket clusters via entity overlap."""
def _plan(self):
return schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="test",
subqueries=[schema.SubQuery(label="primary", search_query="test", ranking_query="test", sources=["reddit", "x", "polymarket"])],
source_weights={"reddit": 0.5, "x": 0.5, "polymarket": 0.5},
)
def test_polymarket_does_not_merge_into_news_cluster(self):
"""A Polymarket prediction about Sam Altman should not merge into a news cluster about Sam Altman."""
candidates = [
make_candidate("c1", "reddit", "Sam Altman personal rivalry with Elon Musk escalates", "The feud between Sam Altman and Elon Musk continues.", 80),
make_candidate("c2", "polymarket", "Sam Altman equity stake in OpenAI valued at $500M", "Will Sam Altman receive equity in OpenAI restructuring?", 75),
]
clusters = cluster.cluster_candidates(candidates, self._plan())
self.assertEqual(2, len(clusters), "Polymarket and news clusters should remain separate")
# Each cluster should have exactly one candidate
for cl in clusters:
self.assertEqual(1, len(cl.candidate_ids))
def test_two_polymarket_clusters_not_blocked_by_poly_guard(self):
"""Two Polymarket items about the same topic are not blocked by the Polymarket guard.
Note: same-source clusters are still blocked by the existing same-source
guard, so we verify the poly guard specifically by checking that two
polymarket items with high text similarity merge via the greedy pass.
"""
candidates = [
make_candidate("c1", "polymarket", "Sam Altman equity stake in OpenAI restructuring", "Will Sam Altman get equity in the OpenAI restructuring deal?", 80),
make_candidate("c2", "polymarket", "Sam Altman equity stake in OpenAI restructuring odds", "Will Sam Altman get equity in the OpenAI restructuring deal? Current odds.", 75),
]
clusters = cluster.cluster_candidates(candidates, self._plan())
# High text similarity means greedy pass merges them
self.assertEqual(1, len(clusters))
self.assertEqual(2, len(clusters[0].candidate_ids))
def test_neither_polymarket_still_merges(self):
"""Non-Polymarket clusters with entity overlap should still merge (existing behavior)."""
candidates = [
make_candidate("c1", "reddit", "Sam Altman OpenAI restructuring announcement details", "Sam Altman announces major OpenAI restructuring.", 80),
make_candidate("c2", "x", "Sam Altman reveals OpenAI restructuring plan for 2026", "Major OpenAI restructuring coming says Sam Altman.", 75),
]
clusters = cluster.cluster_candidates(candidates, self._plan())
self.assertEqual(1, len(clusters))
self.assertEqual(2, len(clusters[0].candidate_ids))
class TestClusterUncertainty(unittest.TestCase):
def test_single_source_returns_single_source(self):
candidates = [make_candidate("c1", "reddit", "Title", "Body", 80)]
result = cluster._cluster_uncertainty(candidates)
self.assertEqual("single-source", result)
def test_multi_source_high_score_returns_none(self):
candidates = [
make_candidate("c1", "reddit", "Title", "Body", 80),
make_candidate("c2", "x", "Title2", "Body2", 70),
]
result = cluster._cluster_uncertainty(candidates)
self.assertIsNone(result)
def test_multi_source_low_score_returns_thin_evidence(self):
candidates = [
make_candidate("c1", "reddit", "Title", "Body", 30),
make_candidate("c2", "x", "Title2", "Body2", 40),
]
result = cluster._cluster_uncertainty(candidates)
self.assertEqual("thin-evidence", result)
if __name__ == "__main__":
unittest.main()
-203
View File
@@ -1,203 +0,0 @@
"""Tests for Codex auth integration (env.py + openai_reddit.py)."""
import base64
import json
import os
import sys
import time
import unittest
from pathlib import Path
from unittest.mock import patch
# Add scripts directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import env, openai_reddit
def _make_jwt(payload: dict) -> str:
"""Build a fake JWT with the given payload (no signature verification)."""
header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=")
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=")
return f"{header.decode()}.{body.decode()}.fakesig"
class TestDecodeJwtPayload(unittest.TestCase):
def test_valid_jwt(self):
token = _make_jwt({"sub": "user123", "exp": 9999999999})
result = env._decode_jwt_payload(token)
self.assertEqual(result["sub"], "user123")
def test_invalid_jwt(self):
self.assertIsNone(env._decode_jwt_payload("not-a-jwt"))
def test_empty_string(self):
self.assertIsNone(env._decode_jwt_payload(""))
class TestTokenExpired(unittest.TestCase):
def test_not_expired(self):
token = _make_jwt({"exp": int(time.time()) + 3600})
self.assertFalse(env._token_expired(token))
def test_expired(self):
token = _make_jwt({"exp": int(time.time()) - 100})
self.assertTrue(env._token_expired(token))
def test_no_exp_claim(self):
token = _make_jwt({"sub": "user"})
self.assertFalse(env._token_expired(token))
class TestExtractChatgptAccountId(unittest.TestCase):
def test_extracts_account_id(self):
token = _make_jwt({
"https://api.openai.com/auth": {
"chatgpt_account_id": "acct_abc123"
}
})
self.assertEqual(env.extract_chatgpt_account_id(token), "acct_abc123")
def test_missing_auth_claim(self):
token = _make_jwt({"sub": "user"})
self.assertIsNone(env.extract_chatgpt_account_id(token))
def test_missing_account_id_in_claim(self):
token = _make_jwt({
"https://api.openai.com/auth": {"other_field": "value"}
})
self.assertIsNone(env.extract_chatgpt_account_id(token))
class TestGetOpenaiAuth(unittest.TestCase):
@patch.dict(os.environ, {}, clear=True)
def test_api_key_takes_priority(self):
"""OPENAI_API_KEY in env file is used when env var is not set."""
file_env = {"OPENAI_API_KEY": "sk-test123"}
auth = env.get_openai_auth(file_env)
self.assertEqual(auth.source, "api_key")
self.assertEqual(auth.status, "ok")
self.assertEqual(auth.token, "sk-test123")
self.assertIsNone(auth.account_id)
@patch.dict(os.environ, {"OPENAI_API_KEY": "sk-from-env"}, clear=False)
def test_env_var_takes_priority(self):
"""OPENAI_API_KEY env var should be preferred over file."""
file_env = {}
auth = env.get_openai_auth(file_env)
self.assertEqual(auth.source, "api_key")
self.assertEqual(auth.token, "sk-from-env")
def test_no_keys_returns_none_source(self):
"""No API key and no Codex auth → source=none."""
fake_path = Path("/tmp/nonexistent_codex_auth_test.json")
with patch.object(env, 'CODEX_AUTH_FILE', fake_path):
# Also patch get_codex_access_token to avoid reading real auth file
with patch.object(env, 'get_codex_access_token', return_value=(None, "missing")):
environ_copy = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"}
with patch.dict(os.environ, environ_copy, clear=True):
auth = env.get_openai_auth({})
self.assertEqual(auth.source, "none")
self.assertIsNone(auth.token)
class TestLoadCodexAuth(unittest.TestCase):
def test_nonexistent_file(self):
result = env.load_codex_auth(Path("/tmp/nonexistent_codex_auth.json"))
self.assertEqual(result, {})
def test_valid_json(self):
import tempfile
data = {"tokens": {"access_token": "tok123"}}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(data, f)
f.flush()
result = env.load_codex_auth(Path(f.name))
os.unlink(f.name)
self.assertEqual(result["tokens"]["access_token"], "tok123")
class TestGetAvailableSourcesWithAuth(unittest.TestCase):
@patch("lib.bird_x.is_bird_installed", return_value=False)
def test_codex_auth_ok_counts_as_openai(self, _mock_bird):
config = {
"OPENAI_API_KEY": "codex-token",
"OPENAI_AUTH_STATUS": "ok",
"XAI_API_KEY": None,
}
result = env.get_available_sources(config)
self.assertIn("reddit", result)
@patch("lib.bird_x.is_bird_installed", return_value=False)
def test_codex_auth_expired_not_counted(self, _mock_bird):
config = {
"OPENAI_API_KEY": None,
"OPENAI_AUTH_STATUS": "expired",
"XAI_API_KEY": None,
}
result = env.get_available_sources(config)
# Reddit is available via public JSON fallback even without OpenAI auth
self.assertEqual(result, "reddit")
class TestParseCodexStream(unittest.TestCase):
def test_response_completed_event(self):
"""Should extract response from response.completed SSE event."""
sse = (
'data: {"type":"response.created","response":{"id":"r1"}}\n\n'
'data: {"type":"response.completed","response":{"id":"r1","output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}]}}\n\n'
)
result = openai_reddit._parse_codex_stream(sse)
self.assertIn("output", result)
def test_delta_fallback(self):
"""Should reconstruct text from delta events."""
sse = (
'data: {"delta":"hel"}\n\n'
'data: {"delta":"lo"}\n\n'
)
result = openai_reddit._parse_codex_stream(sse)
self.assertIn("output", result)
text = result["output"][0]["content"][0]["text"]
self.assertEqual(text, "hello")
def test_empty_stream(self):
result = openai_reddit._parse_codex_stream("")
self.assertEqual(result, {})
class TestBuildPayload(unittest.TestCase):
def test_api_key_payload(self):
payload = openai_reddit._build_payload(
"gpt-4o", "instructions", "input text", "api_key"
)
self.assertEqual(payload["model"], "gpt-4o")
self.assertEqual(payload["input"], "input text")
self.assertNotIn("stream", payload)
def test_codex_payload_has_stream(self):
payload = openai_reddit._build_payload(
"gpt-4o", "instructions", "input text", env.AUTH_SOURCE_CODEX
)
self.assertTrue(payload["stream"])
# Input should be structured message format for Codex
self.assertIsInstance(payload["input"], list)
self.assertEqual(payload["input"][0]["role"], "user")
def test_codex_payload_has_store_false(self):
payload = openai_reddit._build_payload(
"gpt-4o", "inst", "text", env.AUTH_SOURCE_CODEX
)
self.assertFalse(payload["store"])
if __name__ == "__main__":
unittest.main()
-186
View File
@@ -1,186 +0,0 @@
"""Tests for cross-source linking."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import dedupe, schema
class TestCrossSourceLink(unittest.TestCase):
def _make_reddit(self, id, title, score=50):
item = schema.RedditItem(id=id, title=title, url="", subreddit="test")
item.score = score
return item
def _make_hn(self, id, title, score=50):
item = schema.HackerNewsItem(id=id, title=title, url="", hn_url="", author="user")
item.score = score
return item
def _make_x(self, id, text, score=50):
item = schema.XItem(id=id, text=text, url="", author_handle="user")
item.score = score
return item
def _make_yt(self, id, title, score=50):
item = schema.YouTubeItem(id=id, title=title, url="", channel_name="ch")
item.score = score
return item
def _make_web(self, id, title, score=50):
item = schema.WebSearchItem(id=id, title=title, url="", source_domain="example.com", snippet="")
item.score = score
return item
def test_no_crossrefs_for_unrelated(self):
reddit = [self._make_reddit("R1", "Best Claude Code Tips")]
hn = [self._make_hn("HN1", "Python Django Release Notes")]
dedupe.cross_source_link(reddit, hn)
self.assertEqual(reddit[0].cross_refs, [])
self.assertEqual(hn[0].cross_refs, [])
def test_bidirectional_link(self):
reddit = [self._make_reddit("R1", "OpenAI launches GPT-5 with new features")]
hn = [self._make_hn("HN1", "OpenAI launches GPT-5 with new features")]
dedupe.cross_source_link(reddit, hn)
self.assertIn("HN1", reddit[0].cross_refs)
self.assertIn("R1", hn[0].cross_refs)
def test_multi_source_link(self):
reddit = [self._make_reddit("R1", "Claude Code gets new skill system")]
hn = [self._make_hn("HN1", "Claude Code gets new skill system")]
yt = [self._make_yt("YT1", "Claude Code gets new skill system")]
dedupe.cross_source_link(reddit, hn, yt)
# All three should reference each other
self.assertEqual(len(reddit[0].cross_refs), 2)
self.assertEqual(len(hn[0].cross_refs), 2)
self.assertEqual(len(yt[0].cross_refs), 2)
def test_same_source_not_linked(self):
reddit = [
self._make_reddit("R1", "OpenAI GPT-5 launch details"),
self._make_reddit("R2", "OpenAI GPT-5 launch details"),
]
dedupe.cross_source_link(reddit)
self.assertEqual(reddit[0].cross_refs, [])
self.assertEqual(reddit[1].cross_refs, [])
def test_x_text_truncation_helps(self):
# Truncation increases similarity vs full tweet.
# A near-identical short tweet should match a Reddit title.
reddit = [self._make_reddit("R1", "Anthropic releases Claude 4 model")]
x_short = [self._make_x("X1", "Anthropic releases Claude 4 model today!")]
dedupe.cross_source_link(reddit, x_short)
self.assertIn("X1", reddit[0].cross_refs)
self.assertIn("R1", x_short[0].cross_refs)
def test_long_x_text_may_not_match(self):
# When a tweet diverges significantly after the shared prefix,
# Jaccard drops below 0.5 even with truncation. This is expected.
reddit = [self._make_reddit("R1", "Anthropic releases Claude 4 model")]
x_long = [self._make_x("X1",
"Anthropic releases Claude 4 model and it's incredible. "
"The reasoning capabilities are next level. Just tested it "
"on my entire codebase and it understood everything."
)]
dedupe.cross_source_link(reddit, x_long)
# May or may not match depending on trigram overlap - just verify no crash
self.assertIsInstance(reddit[0].cross_refs, list)
def test_empty_lists(self):
# Should not crash
dedupe.cross_source_link([], [], [])
def test_single_item(self):
reddit = [self._make_reddit("R1", "Test item")]
dedupe.cross_source_link(reddit)
self.assertEqual(reddit[0].cross_refs, [])
def test_no_duplicate_refs(self):
reddit = [self._make_reddit("R1", "Same exact title repeated")]
hn = [self._make_hn("HN1", "Same exact title repeated")]
# Call twice - should not duplicate refs
dedupe.cross_source_link(reddit, hn)
dedupe.cross_source_link(reddit, hn)
self.assertEqual(reddit[0].cross_refs.count("HN1"), 1)
self.assertEqual(hn[0].cross_refs.count("R1"), 1)
def test_web_items_linked(self):
web = [self._make_web("W1", "Claude Code skill system overview")]
hn = [self._make_hn("HN1", "Claude Code skill system overview")]
dedupe.cross_source_link(web, hn)
self.assertIn("HN1", web[0].cross_refs)
self.assertIn("W1", hn[0].cross_refs)
def _make_pm(self, id, title, score=50):
item = schema.PolymarketItem(id=id, title=title, question="Q?", url="")
item.score = score
return item
def test_polymarket_to_reddit_link(self):
reddit = [self._make_reddit("R1", "Will Arizona win the Big 12 Championship?")]
pm = [self._make_pm("PM1", "Will Arizona win the Big 12 Championship?")]
dedupe.cross_source_link(reddit, pm)
self.assertIn("PM1", reddit[0].cross_refs)
self.assertIn("R1", pm[0].cross_refs)
def test_polymarket_multi_source(self):
reddit = [self._make_reddit("R1", "Iran nuclear deal prediction markets")]
hn = [self._make_hn("HN1", "Iran nuclear deal prediction markets")]
pm = [self._make_pm("PM1", "Iran nuclear deal prediction markets")]
dedupe.cross_source_link(reddit, hn, pm)
self.assertEqual(len(reddit[0].cross_refs), 2)
self.assertEqual(len(pm[0].cross_refs), 2)
class TestCrossRefsSchemaRoundTrip(unittest.TestCase):
def test_reddit_roundtrip(self):
item = schema.RedditItem(id="R1", title="Test", url="", subreddit="test",
cross_refs=["HN1", "X2"])
d = item.to_dict()
self.assertEqual(d['cross_refs'], ["HN1", "X2"])
def test_reddit_empty_crossrefs_omitted(self):
item = schema.RedditItem(id="R1", title="Test", url="", subreddit="test")
d = item.to_dict()
self.assertNotIn('cross_refs', d)
def test_report_roundtrip(self):
report = schema.Report(
topic="test", range_from="2026-01-01", range_to="2026-02-01",
generated_at="2026-02-01T00:00:00Z", mode="both",
reddit=[schema.RedditItem(id="R1", title="T", url="", subreddit="s",
cross_refs=["HN1"])],
hackernews=[schema.HackerNewsItem(id="HN1", title="T", url="", hn_url="",
author="u", cross_refs=["R1"])],
)
d = report.to_dict()
restored = schema.Report.from_dict(d)
self.assertEqual(restored.reddit[0].cross_refs, ["HN1"])
self.assertEqual(restored.hackernews[0].cross_refs, ["R1"])
class TestGetCrossSourceText(unittest.TestCase):
def test_x_truncated(self):
item = schema.XItem(id="X1", text="A" * 200, url="", author_handle="u")
result = dedupe._get_cross_source_text(item)
self.assertEqual(len(result), 100)
def test_reddit_uses_title(self):
item = schema.RedditItem(id="R1", title="My Title", url="", subreddit="s")
result = dedupe._get_cross_source_text(item)
self.assertEqual(result, "My Title")
def test_web_uses_title(self):
item = schema.WebSearchItem(id="W1", title="Web Title", url="",
source_domain="example.com", snippet="snip")
result = dedupe._get_cross_source_text(item)
self.assertEqual(result, "Web Title")
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
import sys
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import dates
class DatesV3Tests(unittest.TestCase):
def test_get_date_range_returns_iso_window(self):
start, end = dates.get_date_range(7)
self.assertRegex(start, r"^\d{4}-\d{2}-\d{2}$")
self.assertRegex(end, r"^\d{4}-\d{2}-\d{2}$")
self.assertEqual(7, (datetime.fromisoformat(end) - datetime.fromisoformat(start)).days)
def test_parse_date_supports_timestamps_and_iso_variants(self):
unix_parsed = dates.parse_date("1710460800")
self.assertEqual("2024-03-15T00:00:00+00:00", unix_parsed.isoformat())
plain = dates.parse_date("2026-03-16")
self.assertEqual("2026-03-16T00:00:00+00:00", plain.isoformat())
zulu = dates.parse_date("2026-03-16T12:34:56Z")
self.assertEqual("2026-03-16T12:34:56+00:00", zulu.isoformat())
fractional = dates.parse_date("2026-03-16T12:34:56.123456+02:00")
self.assertEqual("2026-03-16T10:34:56.123456+00:00", fractional.isoformat())
def test_parse_date_rejects_empty_and_invalid_values(self):
self.assertIsNone(dates.parse_date(None))
self.assertIsNone(dates.parse_date(""))
self.assertIsNone(dates.parse_date("not-a-date"))
def test_timestamp_to_date_handles_valid_and_invalid_values(self):
self.assertEqual("2024-03-15", dates.timestamp_to_date(1710460800))
self.assertIsNone(dates.timestamp_to_date(None))
self.assertIsNone(dates.timestamp_to_date("bad"))
def test_get_date_confidence_distinguishes_in_range_and_invalid(self):
self.assertEqual("high", dates.get_date_confidence("2026-03-10", "2026-03-01", "2026-03-16"))
self.assertEqual("low", dates.get_date_confidence("2026-02-10", "2026-03-01", "2026-03-16"))
self.assertEqual("low", dates.get_date_confidence("2026-03-20", "2026-03-01", "2026-03-16"))
self.assertEqual("low", dates.get_date_confidence("bad", "2026-03-01", "2026-03-16"))
self.assertEqual("low", dates.get_date_confidence(None, "2026-03-01", "2026-03-16"))
def test_days_ago_and_recency_score_cover_edge_cases(self):
today = datetime.now(timezone.utc).date()
old = (today - timedelta(days=45)).isoformat()
future = (today + timedelta(days=1)).isoformat()
self.assertEqual(0, dates.days_ago(today.isoformat()))
self.assertIsNone(dates.days_ago("bad"))
self.assertEqual(100, dates.recency_score(today.isoformat()))
self.assertEqual(0, dates.recency_score(old))
self.assertEqual(100, dates.recency_score(future))
self.assertEqual(0, dates.recency_score(None))
if __name__ == "__main__":
unittest.main()
-111
View File
@@ -1,111 +0,0 @@
"""Tests for dedupe module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import dedupe, schema
class TestNormalizeText(unittest.TestCase):
def test_lowercase(self):
result = dedupe.normalize_text("HELLO World")
self.assertEqual(result, "hello world")
def test_removes_punctuation(self):
result = dedupe.normalize_text("Hello, World!")
# Punctuation replaced with space, then whitespace collapsed
self.assertEqual(result, "hello world")
def test_collapses_whitespace(self):
result = dedupe.normalize_text("hello world")
self.assertEqual(result, "hello world")
class TestGetNgrams(unittest.TestCase):
def test_short_text(self):
result = dedupe.get_ngrams("ab", n=3)
self.assertEqual(result, {"ab"})
def test_normal_text(self):
result = dedupe.get_ngrams("hello", n=3)
self.assertIn("hel", result)
self.assertIn("ell", result)
self.assertIn("llo", result)
class TestJaccardSimilarity(unittest.TestCase):
def test_identical_sets(self):
set1 = {"a", "b", "c"}
result = dedupe.jaccard_similarity(set1, set1)
self.assertEqual(result, 1.0)
def test_disjoint_sets(self):
set1 = {"a", "b", "c"}
set2 = {"d", "e", "f"}
result = dedupe.jaccard_similarity(set1, set2)
self.assertEqual(result, 0.0)
def test_partial_overlap(self):
set1 = {"a", "b", "c"}
set2 = {"b", "c", "d"}
result = dedupe.jaccard_similarity(set1, set2)
self.assertEqual(result, 0.5) # 2 overlap / 4 union
def test_empty_sets(self):
result = dedupe.jaccard_similarity(set(), set())
self.assertEqual(result, 0.0)
class TestFindDuplicates(unittest.TestCase):
def test_no_duplicates(self):
items = [
schema.RedditItem(id="R1", title="Completely different topic A", url="", subreddit=""),
schema.RedditItem(id="R2", title="Another unrelated subject B", url="", subreddit=""),
]
result = dedupe.find_duplicates(items)
self.assertEqual(result, [])
def test_finds_duplicates(self):
items = [
schema.RedditItem(id="R1", title="Best practices for Claude Code skills", url="", subreddit=""),
schema.RedditItem(id="R2", title="Best practices for Claude Code skills guide", url="", subreddit=""),
]
result = dedupe.find_duplicates(items, threshold=0.7)
self.assertEqual(len(result), 1)
self.assertEqual(result[0], (0, 1))
class TestDedupeItems(unittest.TestCase):
def test_keeps_higher_scored(self):
items = [
schema.RedditItem(id="R1", title="Best practices for skills", url="", subreddit="", score=90),
schema.RedditItem(id="R2", title="Best practices for skills guide", url="", subreddit="", score=50),
]
result = dedupe.dedupe_items(items, threshold=0.6)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].id, "R1")
def test_keeps_all_unique(self):
items = [
schema.RedditItem(id="R1", title="Topic about apples", url="", subreddit="", score=90),
schema.RedditItem(id="R2", title="Discussion of oranges", url="", subreddit="", score=50),
]
result = dedupe.dedupe_items(items)
self.assertEqual(len(result), 2)
def test_empty_list(self):
result = dedupe.dedupe_items([])
self.assertEqual(result, [])
def test_single_item(self):
items = [schema.RedditItem(id="R1", title="Test", url="", subreddit="")]
result = dedupe.dedupe_items(items)
self.assertEqual(len(result), 1)
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
"""Unit tests for dedupe.py: text normalization, similarity metrics, and deduplication."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import dedupe
from lib.schema import SourceItem
def _item(title: str, body: str = "", source: str = "reddit", item_id: str = "t1") -> SourceItem:
return SourceItem(
item_id=item_id, source=source, title=title, body=body,
url="https://example.com", engagement={}, metadata={},
)
# ---------------------------------------------------------------------------
# normalize_text
# ---------------------------------------------------------------------------
class TestNormalizeText(unittest.TestCase):
def test_lowercases(self):
self.assertEqual(dedupe.normalize_text("Hello World"), "hello world")
def test_strips_punctuation(self):
self.assertEqual(dedupe.normalize_text("it's a test!"), "it s a test")
def test_collapses_whitespace(self):
self.assertEqual(dedupe.normalize_text("a b\t\nc"), "a b c")
def test_empty_string(self):
self.assertEqual(dedupe.normalize_text(""), "")
# ---------------------------------------------------------------------------
# get_ngrams
# ---------------------------------------------------------------------------
class TestGetNgrams(unittest.TestCase):
def test_simple_trigrams(self):
ngrams = dedupe.get_ngrams("abcde")
self.assertEqual(ngrams, {"abc", "bcd", "cde"})
def test_short_text_returns_whole(self):
ngrams = dedupe.get_ngrams("ab")
self.assertEqual(ngrams, {"ab"})
def test_empty_returns_empty_set(self):
self.assertEqual(dedupe.get_ngrams(""), set())
def test_normalizes_before_ngrams(self):
# "A!B" -> "a b" after normalization -> {"a b"}
ngrams = dedupe.get_ngrams("A!B")
self.assertEqual(ngrams, {"a b"})
# ---------------------------------------------------------------------------
# jaccard_similarity
# ---------------------------------------------------------------------------
class TestJaccardSimilarity(unittest.TestCase):
def test_identical_sets(self):
self.assertAlmostEqual(dedupe.jaccard_similarity({"a", "b"}, {"a", "b"}), 1.0)
def test_disjoint_sets(self):
self.assertAlmostEqual(dedupe.jaccard_similarity({"a"}, {"b"}), 0.0)
def test_partial_overlap(self):
result = dedupe.jaccard_similarity({"a", "b", "c"}, {"b", "c", "d"})
self.assertAlmostEqual(result, 2.0 / 4.0)
def test_empty_left(self):
self.assertAlmostEqual(dedupe.jaccard_similarity(set(), {"a"}), 0.0)
def test_both_empty(self):
self.assertAlmostEqual(dedupe.jaccard_similarity(set(), set()), 0.0)
# ---------------------------------------------------------------------------
# token_jaccard
# ---------------------------------------------------------------------------
class TestTokenJaccard(unittest.TestCase):
def test_identical_texts(self):
self.assertAlmostEqual(dedupe.token_jaccard("hello world", "hello world"), 1.0)
def test_completely_different(self):
self.assertAlmostEqual(dedupe.token_jaccard("alpha beta", "gamma delta"), 0.0)
def test_filters_stopwords(self):
# "the" and "a" are stopwords, so "big cat" vs "big dog" should compare on {big, cat} vs {big, dog}
result = dedupe.token_jaccard("the big cat", "a big dog")
self.assertAlmostEqual(result, 1.0 / 3.0) # {big} / {big, cat, dog}
def test_filters_single_char_tokens(self):
# Single char tokens like "I" are filtered (len > 1)
result = dedupe.token_jaccard("I am great", "I am terrible")
# "am" is len 2, "great"/"terrible" are content
self.assertGreater(result, 0.0)
# ---------------------------------------------------------------------------
# hybrid_similarity
# ---------------------------------------------------------------------------
class TestHybridSimilarity(unittest.TestCase):
def test_identical_texts(self):
self.assertAlmostEqual(dedupe.hybrid_similarity("same text", "same text"), 1.0)
def test_completely_different(self):
result = dedupe.hybrid_similarity("aaaaaa", "zzzzzz")
self.assertLess(result, 0.1)
def test_takes_max_of_both_methods(self):
text_a = "OpenClaw security issues discussion"
text_b = "OpenClaw security issues thread"
ngram_sim = dedupe.jaccard_similarity(
dedupe.get_ngrams(text_a), dedupe.get_ngrams(text_b)
)
token_sim = dedupe.token_jaccard(text_a, text_b)
self.assertAlmostEqual(
dedupe.hybrid_similarity(text_a, text_b),
max(ngram_sim, token_sim),
)
# ---------------------------------------------------------------------------
# item_text
# ---------------------------------------------------------------------------
class TestItemText(unittest.TestCase):
def test_combines_fields(self):
item = _item("My Title", "My Body")
text = dedupe.item_text(item)
self.assertIn("My Title", text)
self.assertIn("My Body", text)
def test_skips_none_fields(self):
item = _item("Title", "")
item.author = None
item.container = None
text = dedupe.item_text(item)
self.assertEqual(text, "Title")
def test_includes_author_and_container(self):
item = _item("Title", "Body")
item.author = "john"
item.container = "r/python"
text = dedupe.item_text(item)
self.assertIn("john", text)
self.assertIn("r/python", text)
# ---------------------------------------------------------------------------
# dedupe_items
# ---------------------------------------------------------------------------
class TestDedupeItems(unittest.TestCase):
def test_keeps_unique_items(self):
items = [
_item("OpenClaw is amazing", item_id="a"),
_item("NanoClaw security review", item_id="b"),
_item("IronClaw Rust architecture", item_id="c"),
]
result = dedupe.dedupe_items(items)
self.assertEqual(len(result), 3)
def test_removes_near_duplicates(self):
items = [
_item("OpenClaw vs NanoClaw comparison review", item_id="a"),
_item("OpenClaw vs NanoClaw comparison review thread", item_id="b"),
]
result = dedupe.dedupe_items(items)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].item_id, "a") # keeps first
def test_keeps_first_of_duplicates(self):
items = [
_item("Same title here", item_id="first"),
_item("Same title here", item_id="second"),
]
result = dedupe.dedupe_items(items)
self.assertEqual(result[0].item_id, "first")
def test_empty_body_items_kept(self):
item = SourceItem(
item_id="empty", source="reddit", title="", body="",
url="", engagement={}, metadata={},
)
result = dedupe.dedupe_items([item])
self.assertEqual(len(result), 1)
def test_threshold_respected(self):
items = [
_item("OpenClaw security analysis", item_id="a"),
_item("OpenClaw security review", item_id="b"),
]
# With threshold=1.0, only exact matches are removed
result = dedupe.dedupe_items(items, threshold=1.0)
self.assertEqual(len(result), 2)
# With threshold=0.3, these similar items collapse
result_loose = dedupe.dedupe_items(items, threshold=0.3)
self.assertEqual(len(result_loose), 1)
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import entity_extract
class TestExtractSubreddits(unittest.TestCase):
def test_extracts_primary_subreddit(self):
items = [{"subreddit": "r/MachineLearning"}]
result = entity_extract._extract_subreddits(items)
self.assertIn("MachineLearning", result)
def test_extracts_subreddit_without_prefix(self):
items = [{"subreddit": "localLLaMA"}]
result = entity_extract._extract_subreddits(items)
self.assertIn("localLLaMA", result)
def test_extracts_cross_references_from_comment_insights(self):
items = [
{
"subreddit": "technology",
"comment_insights": ["check out r/MachineLearning and r/LocalLLaMA for more"],
}
]
result = entity_extract._extract_subreddits(items)
self.assertIn("MachineLearning", result)
self.assertIn("LocalLLaMA", result)
def test_extracts_cross_references_from_top_comments(self):
items = [
{
"subreddit": "AI",
"top_comments": [
{"excerpt": "see r/StableDiffusion for image gen stuff"},
],
}
]
result = entity_extract._extract_subreddits(items)
self.assertIn("StableDiffusion", result)
def test_ranks_by_frequency(self):
items = [
{"subreddit": "A"},
{"subreddit": "A"},
{"subreddit": "B"},
]
result = entity_extract._extract_subreddits(items)
self.assertEqual(result[0], "A")
def test_empty_items(self):
self.assertEqual([], entity_extract._extract_subreddits([]))
if __name__ == "__main__":
unittest.main()
+44 -94
View File
@@ -1,15 +1,16 @@
"""Tests for browser cookie extraction integration in env.py."""
from unittest.mock import patch, MagicMock
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from scripts.lib.env import extract_browser_credentials, COOKIE_DOMAINS
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib.env import extract_browser_credentials, COOKIE_DOMAINS
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_config(**overrides):
"""Return a minimal config dict with common defaults."""
@@ -24,155 +25,105 @@ def _base_config(**overrides):
return cfg
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestExtractBrowserCredentials:
"""Unit tests for extract_browser_credentials()."""
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_auto_with_setup_complete_populates_credentials(self, mock_extract):
"""FROM_BROWSER=auto, SETUP_COMPLETE=true, mock returns valid cookies
-> config now contains AUTH_TOKEN and CT0."""
mock_extract.return_value = ({"auth_token": "tok123", "ct0": "ct0val"}, "chrome")
config = _base_config(FROM_BROWSER="auto", SETUP_COMPLETE="true")
@patch("lib.cookie_extract.extract_cookies")
def test_auto_populates_credentials(self, mock_extract):
mock_extract.return_value = {"auth_token": "tok123", "ct0": "ct0val"}
config = _base_config(FROM_BROWSER="auto")
result = extract_browser_credentials(config)
assert result["AUTH_TOKEN"] == "tok123"
assert result["CT0"] == "ct0val"
# Should have been called for x domain
mock_extract.assert_any_call("auto", ".x.com", ["auth_token", "ct0"])
# auto mode tries firefox first, then safari, then chrome
mock_extract.assert_any_call("firefox", ".x.com", ["auth_token", "ct0"])
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("lib.cookie_extract.extract_cookies")
def test_explicit_auth_token_skips_x_extraction(self, mock_extract):
"""Config already has AUTH_TOKEN and CT0 from env var
-> cookie extraction skipped for X (explicit takes priority)."""
# Return None for any non-X domains that still get checked (e.g. Truth Social)
mock_extract.return_value = None
config = _base_config(
AUTH_TOKEN="explicit_token",
CT0="explicit_ct0",
AUTH_TOKEN="explicit_token", CT0="explicit_ct0",
FROM_BROWSER="auto",
SETUP_COMPLETE="true",
)
result = extract_browser_credentials(config)
# X cookies should not appear in result (already set)
assert "AUTH_TOKEN" not in result
assert "CT0" not in result
# extract_cookies should NOT have been called for .x.com
for call in mock_extract.call_args_list:
assert call[0][1] != ".x.com", "Should not extract cookies for X when credentials already set"
assert call[0][1] != ".x.com"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("lib.cookie_extract.extract_cookies")
def test_from_browser_off_skips_all(self, mock_extract):
"""FROM_BROWSER=off -> no cookie extraction attempted."""
config = _base_config(FROM_BROWSER="off", SETUP_COMPLETE="true")
config = _base_config(FROM_BROWSER="off")
result = extract_browser_credentials(config)
assert result == {}
mock_extract.assert_not_called()
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_no_setup_complete_no_from_browser_defaults_off(self, mock_extract):
"""FROM_BROWSER not set and SETUP_COMPLETE not set
-> no extraction (wizard hasn't run)."""
config = _base_config() # both None
@patch("lib.cookie_extract.extract_cookies")
def test_no_from_browser_defaults_to_silent(self, mock_extract):
"""Default (no FROM_BROWSER): tries Firefox and Safari only, skips Chrome."""
mock_extract.return_value = None
config = _base_config()
result = extract_browser_credentials(config)
assert result == {}
mock_extract.assert_not_called()
# Should try firefox and safari but NOT chrome
browser_args = [call[0][0] for call in mock_extract.call_args_list]
assert "firefox" in browser_args
assert "safari" in browser_args
assert "chrome" not in browser_args
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_setup_complete_no_from_browser_defaults_auto(self, mock_extract):
"""SETUP_COMPLETE is set but FROM_BROWSER is not
-> defaults to 'auto'."""
mock_extract.return_value = ({"auth_token": "found", "ct0": "found_ct0"}, "firefox")
config = _base_config(SETUP_COMPLETE="true") # FROM_BROWSER=None
result = extract_browser_credentials(config)
assert result["AUTH_TOKEN"] == "found"
# extract_cookies should have been called with 'auto'
mock_extract.assert_any_call("auto", ".x.com", ["auth_token", "ct0"])
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("lib.cookie_extract.extract_cookies")
def test_from_browser_firefox_only(self, mock_extract):
"""FROM_BROWSER=firefox -> only Firefox extraction attempted."""
mock_extract.return_value = ({"auth_token": "ff_tok", "ct0": "ff_ct0"}, "firefox")
config = _base_config(FROM_BROWSER="firefox", SETUP_COMPLETE="true")
mock_extract.return_value = {"auth_token": "ff_tok", "ct0": "ff_ct0"}
config = _base_config(FROM_BROWSER="firefox")
result = extract_browser_credentials(config)
assert result["AUTH_TOKEN"] == "ff_tok"
# All calls should use 'firefox' as the browser arg
for call in mock_extract.call_args_list:
assert call[0][0] == "firefox"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("lib.cookie_extract.extract_cookies")
def test_extraction_returns_none_config_unchanged(self, mock_extract):
"""Cookie extraction returns None for X -> config unchanged for AUTH_TOKEN."""
mock_extract.return_value = None
config = _base_config(FROM_BROWSER="auto", SETUP_COMPLETE="true")
config = _base_config(FROM_BROWSER="auto")
result = extract_browser_credentials(config)
assert "AUTH_TOKEN" not in result
assert "CT0" not in result
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_extraction_raises_exception_config_unchanged(self, mock_extract):
"""Cookie extraction raises exception -> caught, config unchanged."""
@patch("lib.cookie_extract.extract_cookies")
def test_extraction_raises_exception_caught(self, mock_extract):
mock_extract.side_effect = RuntimeError("database locked")
config = _base_config(FROM_BROWSER="auto", SETUP_COMPLETE="true")
config = _base_config(FROM_BROWSER="auto")
result = extract_browser_credentials(config)
# Should not raise, and no credentials populated
assert "AUTH_TOKEN" not in result
assert "CT0" not in result
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("lib.cookie_extract.extract_cookies")
def test_partial_credentials_only_fills_missing(self, mock_extract):
"""If AUTH_TOKEN is set but CT0 is not, only CT0 gets filled."""
mock_extract.return_value = ({"auth_token": "cookie_tok", "ct0": "cookie_ct0"}, "chrome")
mock_extract.return_value = {"auth_token": "cookie_tok", "ct0": "cookie_ct0"}
config = _base_config(
AUTH_TOKEN="explicit",
CT0=None,
AUTH_TOKEN="explicit", CT0=None,
FROM_BROWSER="auto",
SETUP_COMPLETE="true",
)
result = extract_browser_credentials(config)
# AUTH_TOKEN already set, should not be overridden
assert "AUTH_TOKEN" not in result
# CT0 was missing, should be filled
assert result["CT0"] == "cookie_ct0"
class TestGetConfigCookieIntegration:
"""Integration test: get_config() calls extract_browser_credentials."""
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("scripts.lib.env._find_project_env", return_value=None)
@patch("scripts.lib.env.load_env_file", return_value={})
@patch("scripts.lib.env.get_openai_auth")
@patch("lib.cookie_extract.extract_cookies")
@patch("lib.env._find_project_env", return_value=None)
@patch("lib.env.load_env_file", return_value={})
@patch("lib.env.get_openai_auth")
def test_get_config_injects_cookies(
self, mock_openai, mock_load, mock_proj, mock_extract
):
"""get_config merges browser cookies into the returned config."""
from scripts.lib.env import get_config, OpenAIAuth
from lib.env import get_config, OpenAIAuth
mock_openai.return_value = OpenAIAuth(
token=None, source="none", status="missing",
account_id=None, codex_auth_file="/fake",
)
mock_extract.return_value = ({"auth_token": "browser_tok", "ct0": "browser_ct0"}, "firefox")
import os
mock_extract.return_value = {"auth_token": "browser_tok", "ct0": "browser_ct0"}
env_patch = {
"SETUP_COMPLETE": "true",
"FROM_BROWSER": "auto",
@@ -180,6 +131,5 @@ class TestGetConfigCookieIntegration:
}
with patch.dict(os.environ, env_patch, clear=False):
config = get_config()
assert config["AUTH_TOKEN"] == "browser_tok"
assert config["CT0"] == "browser_ct0"
-236
View File
@@ -1,236 +0,0 @@
"""Tests for per-project .env config discovery and precedence."""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import env
class TestFindProjectEnv(unittest.TestCase):
"""Tests for _find_project_env() directory walking."""
def test_finds_env_in_cwd(self, ):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
claude_dir = Path(tmpdir) / ".claude"
claude_dir.mkdir()
env_file = claude_dir / "last30days.env"
env_file.write_text("OPENAI_API_KEY=sk-test\n")
with patch.object(Path, 'cwd', return_value=Path(tmpdir)):
result = env._find_project_env()
self.assertIsNotNone(result)
self.assertEqual(result.name, "last30days.env")
def test_returns_none_when_no_config(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(Path, 'cwd', return_value=Path(tmpdir)):
result = env._find_project_env()
self.assertIsNone(result)
class TestConfigPrecedence(unittest.TestCase):
"""Tests for config source priority."""
def test_project_overrides_global(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
# Create global config
global_dir = Path(tmpdir) / "global"
global_dir.mkdir()
global_env = global_dir / ".env"
global_env.write_text("BRAVE_API_KEY=global-key\nPARALLEL_API_KEY=global-only\n")
# Create project config
project_dir = Path(tmpdir) / "project" / ".claude"
project_dir.mkdir(parents=True)
project_env = project_dir / "last30days.env"
project_env.write_text("BRAVE_API_KEY=project-key\n")
with patch.object(Path, 'cwd', return_value=Path(tmpdir) / "project"), \
patch.object(env, 'CONFIG_FILE', global_env), \
patch.dict(os.environ, {}, clear=False):
# Remove any env vars that would override
for k in ('BRAVE_API_KEY', 'PARALLEL_API_KEY', 'OPENAI_API_KEY'):
os.environ.pop(k, None)
config = env.get_config()
# Project value wins over global
self.assertEqual(config['BRAVE_API_KEY'], 'project-key')
# Global value still available for keys not in project
self.assertEqual(config['PARALLEL_API_KEY'], 'global-only')
def test_env_var_overrides_project(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
project_dir = Path(tmpdir) / ".claude"
project_dir.mkdir()
project_env = project_dir / "last30days.env"
project_env.write_text("BRAVE_API_KEY=project-key\n")
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
patch.object(env, 'CONFIG_FILE', None), \
patch.dict(os.environ, {'BRAVE_API_KEY': 'env-key'}):
config = env.get_config()
self.assertEqual(config['BRAVE_API_KEY'], 'env-key')
def test_gemini_keys_load_from_project_env(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
project_dir = Path(tmpdir) / ".claude"
project_dir.mkdir()
project_env = project_dir / "last30days.env"
project_env.write_text("GEMINI_API_KEY=gem-key\nGEMINI_MODEL=gemini-3-pro-preview\n")
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
patch.object(env, 'CONFIG_FILE', None), \
patch.dict(os.environ, {}, clear=False):
os.environ.pop('GEMINI_API_KEY', None)
os.environ.pop('GEMINI_MODEL', None)
config = env.get_config()
self.assertEqual(config['GEMINI_API_KEY'], 'gem-key')
self.assertEqual(config['GEMINI_MODEL'], 'gemini-3-pro-preview')
def test_google_api_key_loads_from_project_env(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
project_dir = Path(tmpdir) / ".claude"
project_dir.mkdir()
project_env = project_dir / "last30days.env"
project_env.write_text("GOOGLE_API_KEY=google-key\n")
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
patch.object(env, 'CONFIG_FILE', None), \
patch.dict(os.environ, {}, clear=False):
os.environ.pop('GOOGLE_API_KEY', None)
config = env.get_config()
self.assertEqual(config['GOOGLE_API_KEY'], 'google-key')
class TestConfigSource(unittest.TestCase):
"""Tests for _CONFIG_SOURCE tracking."""
def test_tracks_project_source(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
project_dir = Path(tmpdir) / ".claude"
project_dir.mkdir()
project_env = project_dir / "last30days.env"
project_env.write_text("BRAVE_API_KEY=test\n")
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
patch.object(env, 'CONFIG_FILE', None):
config = env.get_config()
self.assertIn('project:', config['_CONFIG_SOURCE'])
def test_tracks_global_source(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
global_env = Path(tmpdir) / ".env"
global_env.write_text("BRAVE_API_KEY=test\n")
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
patch.object(env, 'CONFIG_FILE', global_env):
config = env.get_config()
self.assertIn('global:', config['_CONFIG_SOURCE'])
def test_tracks_env_only(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
patch.object(env, 'CONFIG_FILE', None):
config = env.get_config()
self.assertEqual(config['_CONFIG_SOURCE'], 'env_only')
class TestConfigExists(unittest.TestCase):
"""Tests for config_exists() with project support."""
def test_true_with_project_env(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
project_dir = Path(tmpdir) / ".claude"
project_dir.mkdir()
(project_dir / "last30days.env").write_text("KEY=val\n")
with patch.object(Path, 'cwd', return_value=Path(tmpdir)):
self.assertTrue(env.config_exists())
def test_true_with_global_env(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
global_env = Path(tmpdir) / ".env"
global_env.write_text("KEY=val\n")
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
patch.object(env, 'CONFIG_FILE', global_env):
self.assertTrue(env.config_exists())
def test_false_with_nothing(self):
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
patch.object(env, 'CONFIG_FILE', None):
self.assertFalse(env.config_exists())
class TestFilePermissions(unittest.TestCase):
"""Tests for _check_file_permissions() warnings."""
def test_warns_on_world_readable(self):
import tempfile
import io
with tempfile.TemporaryDirectory() as tmpdir:
f = Path(tmpdir) / ".env"
f.write_text("KEY=val\n")
f.chmod(0o644)
stderr = io.StringIO()
with patch('sys.stderr', stderr):
env._check_file_permissions(f)
self.assertIn("WARNING", stderr.getvalue())
def test_no_warning_on_600(self):
import tempfile
import io
with tempfile.TemporaryDirectory() as tmpdir:
f = Path(tmpdir) / ".env"
f.write_text("KEY=val\n")
f.chmod(0o600)
stderr = io.StringIO()
with patch('sys.stderr', stderr):
env._check_file_permissions(f)
self.assertEqual(stderr.getvalue(), "")
class TestXSourceSelection(unittest.TestCase):
"""Tests for supported X backend selection."""
def test_get_x_source_ignores_scrapecreators_key(self):
config = {'SCRAPECREATORS_API_KEY': 'sc-key'}
with patch('lib.bird_x.is_bird_installed', return_value=False):
self.assertIsNone(env.get_x_source(config))
def test_get_x_source_status_ignores_scrapecreators_key(self):
config = {'SCRAPECREATORS_API_KEY': 'sc-key'}
bird_status = {
'installed': True,
'authenticated': False,
'username': None,
'can_install': False,
}
with patch('lib.bird_x.get_bird_status', return_value=bird_status), \
patch('lib.bird_x.is_bird_installed', return_value=True), \
patch('lib.bird_x.is_bird_authenticated', return_value=None):
status = env.get_x_source_status(config)
self.assertIsNone(status['source'])
self.assertFalse(status['xai_available'])
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
import os
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import bird_x, env
class EnvV3Tests(unittest.TestCase):
def setUp(self):
self._saved_credentials = dict(bird_x._credentials)
def tearDown(self):
bird_x._credentials.clear()
bird_x._credentials.update(self._saved_credentials)
def test_x_source_prefers_xai_without_bird_probe(self):
with mock.patch("lib.bird_x.is_bird_authenticated", side_effect=AssertionError("should not probe bird auth")):
source = env.get_x_source({"XAI_API_KEY": "test"})
self.assertEqual("xai", source)
def test_x_source_uses_bird_with_explicit_cookies(self):
with mock.patch("lib.bird_x.is_bird_installed", return_value=True):
source = env.get_x_source({"AUTH_TOKEN": "a", "CT0": "b"})
self.assertEqual("bird", source)
self.assertEqual("a", bird_x._credentials["AUTH_TOKEN"])
self.assertEqual("b", bird_x._credentials["CT0"])
def test_bird_auth_never_checks_browser_cookies(self):
with mock.patch("lib.bird_x.is_bird_installed", return_value=True), mock.patch(
"lib.bird_x.subprocess.run",
side_effect=AssertionError("browser-cookie whoami should not run"),
):
bird_x._credentials.clear()
with mock.patch.dict(os.environ, {}, clear=False):
self.assertIsNone(bird_x.is_bird_authenticated())
if __name__ == "__main__":
unittest.main()
-134
View File
@@ -1,134 +0,0 @@
"""Tests for the local search-quality evaluation harness."""
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
import evaluate_search_quality as evalsq
class TestMetrics(unittest.TestCase):
def test_jaccard(self):
self.assertAlmostEqual(evalsq.jaccard({"a", "b"}, {"b", "c"}), 1 / 3)
def test_retention(self):
self.assertAlmostEqual(evalsq.retention({"a", "b"}, {"b", "c"}), 0.5)
def test_precision_at_k(self):
ranking = [
{"key": "a", "source": "reddit"},
{"key": "b", "source": "x"},
{"key": "c", "source": "youtube"},
]
judgments = {"a": 3, "b": 1, "c": 2}
self.assertAlmostEqual(evalsq.precision_at_k(ranking, judgments, 2), 0.5)
def test_ndcg_at_k(self):
ranking = [
{"key": "a", "source": "reddit"},
{"key": "b", "source": "x"},
{"key": "c", "source": "youtube"},
]
judgments = {"a": 3, "b": 0, "c": 2}
self.assertGreater(evalsq.ndcg_at_k(ranking, judgments, 3), 0.8)
def test_ndcg_at_k_uses_best_items_from_judged_pool(self):
ranking = [
{"key": "a", "source": "reddit"},
{"key": "b", "source": "x"},
{"key": "c", "source": "youtube"},
]
judged_pool = ranking + [
{"key": "d", "source": "reddit"},
{"key": "e", "source": "x"},
]
judgments = {"a": 3, "b": 0, "c": 0, "d": 3, "e": 2}
self.assertLess(
evalsq.ndcg_at_k(ranking, judgments, 3, judged_pool),
1.0,
)
def test_source_coverage_recall_uses_union_pool(self):
judged_pool = [
{"key": "a", "source": "reddit"},
{"key": "b", "source": "x"},
{"key": "c", "source": "youtube"},
]
ranking = [
{"key": "a", "source": "reddit"},
{"key": "b", "source": "x"},
]
judgments = {"a": 3, "b": 0, "c": 2}
self.assertAlmostEqual(evalsq.source_coverage_recall(ranking, judged_pool, judgments), 0.5)
class TestRankedItems(unittest.TestCase):
def test_build_ranked_items_sorts_by_score(self):
report = {
"reddit": [{"id": "R1", "title": "Low", "url": "r1", "score": 20}],
"x": [{"id": "X1", "text": "High", "url": "x1", "score": 90}],
"youtube": [],
"tiktok": [],
"instagram": [],
"hackernews": [],
"bluesky": [],
"truthsocial": [],
"polymarket": [],
"websearch": [],
}
ranked = evalsq.build_ranked_items(report, per_source_limit=5)
self.assertEqual(ranked[0]["key"], "x1")
class TestPathWithoutNode(unittest.TestCase):
def test_removes_node_entries(self):
path = "/usr/bin:/tmp/node-bin:/opt/homebrew/bin"
def fake_exists(path_obj):
return str(path_obj).endswith("/tmp/node-bin/node")
with patch.object(evalsq.Path, "exists", fake_exists):
filtered = evalsq.path_without_node(path)
self.assertEqual(filtered, "/usr/bin:/opt/homebrew/bin")
class TestEvalToolPath(unittest.TestCase):
def test_wraps_ytdlp_with_ignore_config(self):
with tempfile.TemporaryDirectory() as tmpdir:
eval_home = Path(tmpdir)
with patch.object(evalsq.shutil, "which", return_value="/opt/homebrew/bin/yt-dlp"):
path_value = evalsq.create_eval_tool_path(eval_home, "/usr/bin")
wrapper = eval_home / "bin" / "yt-dlp"
self.assertTrue(wrapper.exists())
text = wrapper.read_text()
self.assertIn("--ignore-config", text)
self.assertIn("--no-cookies-from-browser", text)
self.assertEqual(path_value, f"{eval_home / 'bin'}:/usr/bin")
class TestJudgeKeyResolution(unittest.TestCase):
def test_prefers_google_api_key(self):
config = {
"GOOGLE_API_KEY": "google-key",
"GEMINI_API_KEY": "gem-key",
"GOOGLE_GENAI_API_KEY": "genai-key",
}
self.assertEqual(evalsq.resolve_google_judge_api_key(config), "google-key")
def test_falls_back_to_gemini_aliases(self):
self.assertEqual(
evalsq.resolve_google_judge_api_key({"GEMINI_API_KEY": "gem-key"}),
"gem-key",
)
self.assertEqual(
evalsq.resolve_google_judge_api_key({"GOOGLE_GENAI_API_KEY": "genai-key"}),
"genai-key",
)
if __name__ == "__main__":
unittest.main()
+203
View File
@@ -0,0 +1,203 @@
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import evaluate_search_quality as evaluator
class EvaluatorV3Tests(unittest.TestCase):
def test_build_ranked_items_uses_multi_source_provenance_and_best_date(self):
report = {
"ranked_candidates": [
{
"candidate_id": "c1",
"item_id": "i1",
"source": "grounding",
"sources": ["grounding", "reddit"],
"title": "Title",
"url": "https://example.com",
"snippet": "Snippet",
"subquery_labels": ["primary"],
"native_ranks": {"primary:grounding": 1},
"local_relevance": 0.8,
"freshness": 90,
"engagement": None,
"source_quality": 1.0,
"rrf_score": 0.02,
"final_score": 88.0,
"source_items": [
{"item_id": "i1", "source": "grounding", "title": "Title", "body": "Body", "url": "https://example.com", "published_at": "2026-03-10"},
{"item_id": "i2", "source": "reddit", "title": "Title", "body": "Body", "url": "https://example.com", "published_at": "2026-03-12"},
],
}
]
}
items = evaluator.build_ranked_items(report, 10)
self.assertEqual(["grounding", "reddit"], items[0]["sources"])
self.assertEqual("grounding, reddit", items[0]["source"])
self.assertEqual("2026-03-12", items[0]["date"])
grouped = evaluator.source_sets(report, 10)
self.assertEqual({"c1"}, grouped["grounding"])
self.assertEqual({"c1"}, grouped["reddit"])
def test_write_failure_summary_persists_failures(self):
with tempfile.TemporaryDirectory() as tmp:
output_dir = Path(tmp)
evaluator.write_failure_summary(
output_dir,
"HEAD~1",
"HEAD",
summaries=[{
"topic": "test topic",
"baseline": {"precision_at_5": 0.5, "ndcg_at_5": 0.6, "source_coverage_recall": 1.0},
"candidate": {"precision_at_5": 0.7, "ndcg_at_5": 0.8, "source_coverage_recall": 1.0},
"stability": {"overall_jaccard": 0.4, "overall_retention_vs_baseline": 0.9},
}],
failures=[{"topic": "broken topic", "error": "timeout"}],
)
metrics = json.loads((output_dir / "metrics.json").read_text())
summary = (output_dir / "summary.md").read_text()
self.assertEqual(1, len(metrics["failures"]))
self.assertIn("broken topic", summary)
self.assertIn("## Failures", summary)
def test_resolve_repo_dir_keeps_live_worktree(self):
repo_dir, is_temp = evaluator.resolve_repo_dir("WORKTREE")
self.assertEqual(evaluator.REPO_ROOT, repo_dir)
self.assertFalse(is_temp)
def test_resolve_repo_dir_materializes_git_ref_in_temp_worktree(self):
fake_dir = Path("/tmp/last30days-eval-fake")
with mock.patch.object(evaluator, "create_worktree", return_value=fake_dir) as create_worktree:
repo_dir, is_temp = evaluator.resolve_repo_dir("HEAD~2")
create_worktree.assert_called_once_with("HEAD~2")
self.assertEqual(fake_dir, repo_dir)
self.assertTrue(is_temp)
def test_metric_helpers_cover_empty_and_ranked_cases(self):
ranking = [
{"key": "a", "sources": ["grounding"]},
{"key": "b", "sources": ["reddit"]},
]
judged = [{"key": "a", "sources": ["grounding"]}, {"key": "b", "sources": ["reddit"]}]
judgments = {"a": 3, "b": 1}
self.assertEqual(1.0, evaluator.jaccard(set(), set()))
self.assertEqual(1.0, evaluator.retention(set(), {"a"}))
self.assertEqual(0.5, evaluator.precision_at_k(ranking, judgments, 2))
self.assertGreater(evaluator.ndcg_at_k(ranking, judgments, 2, judged), 0.0)
self.assertEqual(1.0, evaluator.source_coverage_recall(ranking, judged, judgments))
self.assertEqual(0.0, evaluator.precision_at_k([], judgments, 5))
self.assertEqual(0.0, evaluator.ndcg_at_k([], judgments, 5, judged))
def test_resolve_google_judge_api_key_prefers_google_key(self):
with mock.patch.dict("os.environ", {"GOOGLE_API_KEY": "google", "GEMINI_API_KEY": "gemini"}, clear=False):
self.assertEqual("google", evaluator.resolve_google_judge_api_key({}))
self.assertEqual("fallback", evaluator.resolve_google_judge_api_key({"GOOGLE_GENAI_API_KEY": "fallback"}))
def test_extract_gemini_text_raises_when_missing(self):
self.assertEqual(
"hello",
evaluator.extract_gemini_text({"candidates": [{"content": {"parts": [{"text": "hello"}]}}]}),
)
with self.assertRaises(ValueError):
evaluator.extract_gemini_text({"candidates": [{"content": {"parts": [{}]}}]})
def test_get_judgments_uses_cache_and_skips_when_not_configured(self):
with tempfile.TemporaryDirectory() as tmp:
output_dir = Path(tmp)
cache_dir = output_dir / "judgments"
cache_dir.mkdir()
(cache_dir / "topic.json").write_text(json.dumps({"judgments": [{"id": "a", "grade": 3}]}))
cached = evaluator.get_judgments(
output_dir=output_dir,
slug="topic",
topic="test topic",
query_type="general",
items=[{"key": "a"}],
judge_model="gemini-3.1-flash-lite-preview",
gemini_api_key="key",
)
self.assertEqual({"a": 3}, cached)
skipped = evaluator.get_judgments(
output_dir=output_dir,
slug="fresh",
topic="test topic",
query_type="general",
items=[],
judge_model="gemini-3.1-flash-lite-preview",
gemini_api_key=None,
)
self.assertEqual({}, skipped)
def test_create_eval_env_and_run_last30days(self):
with mock.patch.object(evaluator.envlib, "get_config", return_value={"OPENAI_API_KEY": "config-openai"}):
with mock.patch.dict("os.environ", {"PATH": "/bin", "GOOGLE_API_KEY": "env-google"}, clear=False):
created = evaluator.create_eval_env()
self.assertEqual("/bin", created["PATH"])
self.assertEqual("env-google", created["GOOGLE_API_KEY"])
self.assertEqual("config-openai", created["OPENAI_API_KEY"])
self.assertEqual("", created["LAST30DAYS_CONFIG_DIR"])
with mock.patch.object(evaluator.subprocess, "run", return_value=mock.Mock(returncode=0, stdout='{"topic":"x"}', stderr="")):
payload = evaluator.run_last30days(
Path("/tmp/repo"),
"topic",
search="reddit",
timeout_seconds=30,
quick=True,
mock=True,
env={"PATH": "/bin"},
)
self.assertEqual("x", payload["topic"])
with mock.patch.object(evaluator.subprocess, "run", return_value=mock.Mock(returncode=2, stdout="", stderr="bad run")):
with self.assertRaises(RuntimeError):
evaluator.run_last30days(
Path("/tmp/repo"),
"topic",
search="reddit",
timeout_seconds=30,
quick=False,
mock=False,
env={"PATH": "/bin"},
)
def test_parse_topics_file_and_summary_writer(self):
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
topics_path = tmp_path / "topics.json"
topics_path.write_text(json.dumps([{"topic": "topic a", "query_type": "comparison"}, {"topic": "topic b"}]))
self.assertEqual(
[("topic a", "comparison"), ("topic b", "general")],
evaluator.parse_topics_file(topics_path),
)
evaluator.write_summary(
tmp_path,
"HEAD~1",
"WORKTREE",
[
{
"topic": "topic a",
"baseline": {"precision_at_5": 0.1, "ndcg_at_5": 0.2, "source_coverage_recall": 0.5},
"candidate": {"precision_at_5": 0.3, "ndcg_at_5": 0.4, "source_coverage_recall": 0.8},
"stability": {"overall_jaccard": 0.6, "overall_retention_vs_baseline": 0.7},
}
],
)
summary = (tmp_path / "summary.md").read_text()
metrics = json.loads((tmp_path / "metrics.json").read_text())
self.assertIn("| topic a | 0.10 | 0.30 |", summary)
self.assertEqual("HEAD~1", metrics["baseline"])
if __name__ == "__main__":
unittest.main()
+20 -42
View File
@@ -12,7 +12,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
os.environ['LAST30DAYS_CONFIG_DIR'] = ''
from lib.exa_search import search_web, _normalize_results, _parse_exa_date, EXCLUDED_DOMAINS
from lib import env, http
from lib import http
class TestNormalizeResults(unittest.TestCase):
@@ -200,49 +200,27 @@ class TestSearchWebIntegration(unittest.TestCase):
self.assertEqual(results, [])
class TestEnvExaPriority(unittest.TestCase):
"""Test that Exa is prioritized correctly in env.py."""
class TestExaNormalizationV3Keys(unittest.TestCase):
"""Test that Exa results include v3 normalization keys."""
def test_no_exa_key_not_selected(self):
config = {}
self.assertIsNone(env.get_web_search_source(config))
def test_exa_key_selected(self):
config = {"EXA_API_KEY": "exa-test-key"}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_exa_takes_priority_over_brave(self):
config = {"EXA_API_KEY": "exa-key", "BRAVE_API_KEY": "brave-key"}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_exa_takes_priority_over_parallel(self):
config = {"EXA_API_KEY": "exa-key", "PARALLEL_API_KEY": "parallel-key"}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_exa_takes_priority_over_openrouter(self):
config = {"EXA_API_KEY": "exa-key", "OPENROUTER_API_KEY": "or-key"}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_exa_takes_priority_over_all(self):
config = {
"EXA_API_KEY": "exa-key",
"PARALLEL_API_KEY": "parallel-key",
"BRAVE_API_KEY": "brave-key",
"OPENROUTER_API_KEY": "or-key",
def test_results_include_engagement_and_metadata(self):
response = {
"results": [
{
"title": "Test Article",
"url": "https://example.com/test",
"text": "Content here",
"publishedDate": "2026-03-15T00:00:00.000Z",
"score": 0.8,
},
]
}
self.assertEqual(env.get_web_search_source(config), "exa")
def test_fallback_to_parallel_without_exa(self):
config = {"PARALLEL_API_KEY": "parallel-key", "BRAVE_API_KEY": "brave-key"}
self.assertEqual(env.get_web_search_source(config), "parallel")
def test_has_web_search_keys_with_exa(self):
config = {"EXA_API_KEY": "exa-key"}
self.assertTrue(env.has_web_search_keys(config))
def test_has_web_search_keys_without_any(self):
config = {}
self.assertFalse(env.has_web_search_keys(config))
items = _normalize_results(response)
self.assertEqual(len(items), 1)
self.assertIn("engagement", items[0])
self.assertIn("metadata", items[0])
self.assertEqual(items[0]["engagement"], {})
self.assertEqual(items[0]["metadata"], {})
if __name__ == "__main__":
+444
View File
@@ -0,0 +1,444 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import fusion, schema
def make_item(item_id: str, source: str, url: str, title: str, rank_score: float) -> schema.SourceItem:
return schema.SourceItem(
item_id=item_id,
source=source,
title=title,
body=title,
url=url,
relevance_hint=rank_score,
snippet=title,
metadata={
"local_relevance": rank_score,
"freshness": 80,
"engagement_score": 5,
"source_quality": 0.7,
},
)
class FusionV3Tests(unittest.TestCase):
def test_weighted_rrf_merges_duplicate_urls(self):
plan = schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="test",
subqueries=[
schema.SubQuery(label="primary", search_query="test", ranking_query="What happened in test?", sources=["reddit", "x"], weight=0.7),
schema.SubQuery(label="reaction", search_query="test reaction", ranking_query="What are the reactions to test?", sources=["x"], weight=0.3),
],
source_weights={"reddit": 0.4, "x": 0.6},
)
shared = "https://example.com/shared"
streams = {
("primary", "reddit"): [make_item("r1", "reddit", shared, "Shared item", 0.8)],
("primary", "x"): [make_item("x1", "x", shared, "Shared item", 0.9)],
("reaction", "x"): [make_item("x2", "x", "https://example.com/unique", "Unique item", 0.7)],
}
candidates = fusion.weighted_rrf(streams, plan, pool_limit=10)
self.assertEqual(2, len(candidates))
merged = next(candidate for candidate in candidates if candidate.url == shared)
self.assertEqual({"primary"}, set(merged.subquery_labels))
self.assertEqual(2, len(merged.native_ranks))
self.assertEqual({"reddit", "x"}, set(merged.sources))
self.assertEqual(2, len(merged.source_items))
def test_diversify_pool_guarantees_min_per_qualifying_source(self):
"""Every qualifying source (local_relevance >= 0.25) gets at least 2
items in the fused pool.
Dominant sources (x, tiktok) get high weights, so pure-RRF truncation
would squeeze out low-weight sources entirely. The diversity guarantee
must reserve at least 2 slots per qualifying active source. All sources
here have rank_score=0.8 (well above the 0.25 threshold), so every
source qualifies for reserved slots.
"""
sources = ["reddit", "hackernews", "x", "tiktok", "bluesky", "youtube"]
# Heavily skewed weights: x and tiktok dominate.
weights = {
"x": 3.0,
"tiktok": 2.5,
"reddit": 0.5,
"hackernews": 0.4,
"bluesky": 0.3,
"youtube": 0.3,
}
plan = schema.QueryPlan(
intent="concept",
freshness_mode="relaxed",
cluster_mode="concept",
raw_topic="RAG",
subqueries=[
schema.SubQuery(
label="primary",
search_query="RAG",
ranking_query="What is RAG?",
sources=sources,
weight=1.0,
),
],
source_weights=weights,
)
streams: dict[tuple[str, str], list[schema.SourceItem]] = {}
for src in sources:
items = []
for rank in range(4):
items.append(
make_item(
item_id=f"{src}_{rank}",
source=src,
url=f"https://{src}.example.com/{rank}",
title=f"{src} item {rank}",
rank_score=0.8,
)
)
streams[("primary", src)] = items
candidates = fusion.weighted_rrf(streams, plan, pool_limit=12)
self.assertEqual(12, len(candidates))
source_counts: dict[str, int] = {}
for c in candidates:
source_counts[c.source] = source_counts.get(c.source, 0) + 1
for src in sources:
self.assertGreaterEqual(
source_counts.get(src, 0),
2,
f"Source '{src}' has {source_counts.get(src, 0)} items, expected >= 2",
)
def test_diversify_pool_denies_slots_for_low_relevance_source(self):
"""Sources with best local_relevance < 0.25 do not get reserved slots.
Create two sources: 'x' with local_relevance=0.5 (qualifies) and
'reddit' with local_relevance=0.1 (below threshold). With a tight
pool_limit, the high-relevance source gets reserved slots while
the low-relevance source must compete on RRF merit alone.
"""
plan = schema.QueryPlan(
intent="concept",
freshness_mode="relaxed",
cluster_mode="concept",
raw_topic="test",
subqueries=[
schema.SubQuery(
label="primary",
search_query="test",
ranking_query="What is test?",
sources=["x", "reddit"],
weight=1.0,
),
],
source_weights={"x": 1.0, "reddit": 1.0},
)
# x items: high relevance (0.5) -- qualifies for diversity reservation
x_items = [
make_item(f"x_{i}", "x", f"https://x.example.com/{i}", f"x item {i}", 0.5)
for i in range(4)
]
# reddit items: low relevance (0.1) -- below threshold, no reserved slots
reddit_items = [
make_item(f"r_{i}", "reddit", f"https://reddit.example.com/{i}", f"reddit item {i}", 0.1)
for i in range(4)
]
streams = {
("primary", "x"): x_items,
("primary", "reddit"): reddit_items,
}
# pool_limit=3: x gets 2 reserved + 1 more by RRF. Reddit has no
# reserved slots, so it must out-score x items in the remainder.
candidates = fusion.weighted_rrf(streams, plan, pool_limit=3)
self.assertEqual(3, len(candidates))
# x must have at least 2 (reserved slots)
x_count = sum(1 for c in candidates if c.source == "x")
self.assertGreaterEqual(x_count, 2, "x should have at least 2 reserved slots")
def test_diversify_pool_no_reservation_when_all_below_threshold(self):
"""When all sources are below the relevance threshold, no reserved slots
are granted. The pool is filled purely by RRF score order."""
plan = schema.QueryPlan(
intent="concept",
freshness_mode="relaxed",
cluster_mode="concept",
raw_topic="test",
subqueries=[
schema.SubQuery(
label="primary",
search_query="test",
ranking_query="What is test?",
sources=["x", "reddit", "hackernews"],
weight=1.0,
),
],
# Give x a much higher weight so its items get higher RRF scores
source_weights={"x": 3.0, "reddit": 0.3, "hackernews": 0.3},
)
streams: dict[tuple[str, str], list[schema.SourceItem]] = {}
# All sources below threshold (local_relevance = 0.1)
for src in ["x", "reddit", "hackernews"]:
items = [
make_item(f"{src}_{i}", src, f"https://{src}.example.com/{i}", f"{src} item {i}", 0.1)
for i in range(4)
]
streams[("primary", src)] = items
candidates = fusion.weighted_rrf(streams, plan, pool_limit=4)
self.assertEqual(4, len(candidates))
# With no diversity reservation and x having 3x the weight,
# x should dominate the top slots purely on RRF score
source_counts: dict[str, int] = {}
for c in candidates:
source_counts[c.source] = source_counts.get(c.source, 0) + 1
# x has 3x weight so its RRF scores are ~3x higher than reddit/hn.
# All 4 x items should beat all reddit/hackernews items.
self.assertEqual(
source_counts.get("x", 0),
4,
f"Expected x to take all 4 slots on pure RRF merit, got {source_counts}",
)
def test_diversify_pool_threshold_boundary(self):
"""Source with best local_relevance exactly at the threshold (0.25)
qualifies for reserved slots."""
plan = schema.QueryPlan(
intent="concept",
freshness_mode="relaxed",
cluster_mode="concept",
raw_topic="boundary",
subqueries=[
schema.SubQuery(
label="primary",
search_query="boundary",
ranking_query="What is boundary?",
sources=["x", "reddit"],
weight=1.0,
),
],
# Give x much higher weight so it would dominate without reservation
source_weights={"x": 5.0, "reddit": 0.1},
)
x_items = [
make_item(f"x_{i}", "x", f"https://x.example.com/{i}", f"x item {i}", 0.8)
for i in range(6)
]
# reddit at exactly the threshold
reddit_items = [
make_item(f"r_{i}", "reddit", f"https://reddit.example.com/{i}", f"reddit item {i}", 0.25)
for i in range(3)
]
streams = {
("primary", "x"): x_items,
("primary", "reddit"): reddit_items,
}
candidates = fusion.weighted_rrf(streams, plan, pool_limit=6)
self.assertEqual(6, len(candidates))
reddit_count = sum(1 for c in candidates if c.source == "reddit")
self.assertGreaterEqual(
reddit_count,
2,
f"reddit (local_relevance=0.25, at threshold) should get 2 reserved slots, got {reddit_count}",
)
def make_item_with_author(
item_id: str, source: str, url: str, title: str, rank_score: float, author: str | None = None,
) -> schema.SourceItem:
return schema.SourceItem(
item_id=item_id,
source=source,
title=title,
body=title,
url=url,
author=author,
relevance_hint=rank_score,
snippet=title,
metadata={
"local_relevance": rank_score,
"freshness": 80,
"engagement_score": 5,
"source_quality": 0.7,
},
)
class TestPerAuthorCap(unittest.TestCase):
"""Per-author cap: no single author should have more than 3 items in fused pool."""
def _make_plan(self, sources: list[str]) -> schema.QueryPlan:
return schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="test",
subqueries=[
schema.SubQuery(
label="primary",
search_query="test",
ranking_query="test",
sources=sources,
weight=1.0,
),
],
source_weights={s: 1.0 for s in sources},
)
def test_author_with_8_items_capped_to_3(self):
"""@grok scenario: 8 items from the same author, only best 3 survive."""
plan = self._make_plan(["x"])
items = [
make_item_with_author(
f"x_{i}", "x", f"https://x.com/{i}", f"grok summary {i}", 0.7, author="@grok",
)
for i in range(8)
]
streams = {("primary", "x"): items}
candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
grok_count = sum(
1 for c in candidates
if any(si.author == "@grok" for si in c.source_items)
)
self.assertLessEqual(grok_count, 3, f"@grok should be capped at 3, got {grok_count}")
def test_author_with_3_items_all_kept(self):
"""Author with exactly 3 items should keep all of them."""
plan = self._make_plan(["x"])
items = [
make_item_with_author(
f"x_{i}", "x", f"https://x.com/{i}", f"author3 post {i}", 0.7, author="@author3",
)
for i in range(3)
]
streams = {("primary", "x"): items}
candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
count = sum(
1 for c in candidates
if any(si.author == "@author3" for si in c.source_items)
)
self.assertEqual(count, 3)
def test_items_without_author_not_capped(self):
"""Items with no author field should never be dropped by the cap."""
plan = self._make_plan(["reddit"])
items = [
make_item_with_author(
f"r_{i}", "reddit", f"https://reddit.com/{i}", f"post {i}", 0.7, author=None,
)
for i in range(6)
]
streams = {("primary", "reddit"): items}
candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
self.assertEqual(len(candidates), 6)
def test_multiple_authors_capped_independently(self):
"""Two prolific authors each get capped to 3 independently."""
plan = self._make_plan(["x"])
items = []
for i in range(5):
items.append(make_item_with_author(
f"grok_{i}", "x", f"https://x.com/grok/{i}", f"grok {i}", 0.7, author="@grok",
))
for i in range(5):
items.append(make_item_with_author(
f"spam_{i}", "x", f"https://x.com/spam/{i}", f"spam {i}", 0.6, author="@spammer",
))
streams = {("primary", "x"): items}
candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
grok_count = sum(1 for c in candidates if any(si.author == "@grok" for si in c.source_items))
spam_count = sum(1 for c in candidates if any(si.author == "@spammer" for si in c.source_items))
self.assertLessEqual(grok_count, 3)
self.assertLessEqual(spam_count, 3)
def test_cap_keeps_best_items_by_rrf_order(self):
"""The cap should keep the first (highest-ranked) items per author."""
plan = self._make_plan(["x"])
# Items with decreasing relevance scores so ranking is deterministic
items = [
make_item_with_author(
f"x_{i}", "x", f"https://x.com/{i}", f"post {i}", 0.9 - (i * 0.05), author="@prolific",
)
for i in range(5)
]
streams = {("primary", "x"): items}
candidates = fusion.weighted_rrf(streams, plan, pool_limit=20)
kept_ids = {c.item_id for c in candidates if any(si.author == "@prolific" for si in c.source_items)}
# The top 3 items (x_0, x_1, x_2) should be kept
self.assertLessEqual(len(kept_ids), 3)
class TestUrlNormalization(unittest.TestCase):
def test_strips_www(self):
from lib.fusion import _normalize_url
self.assertEqual(
_normalize_url("https://www.reddit.com/r/test"),
_normalize_url("https://reddit.com/r/test"),
)
def test_strips_old_prefix(self):
from lib.fusion import _normalize_url
self.assertEqual(
_normalize_url("https://old.reddit.com/r/test"),
_normalize_url("https://reddit.com/r/test"),
)
def test_strips_mobile_prefix(self):
from lib.fusion import _normalize_url
self.assertEqual(
_normalize_url("https://m.youtube.com/watch?v=abc"),
_normalize_url("https://youtube.com/watch?v=abc"),
)
def test_strips_utm_params(self):
from lib.fusion import _normalize_url
self.assertEqual(
_normalize_url("https://example.com/page?utm_source=twitter&id=5"),
_normalize_url("https://example.com/page?id=5"),
)
def test_strips_trailing_slash(self):
from lib.fusion import _normalize_url
self.assertEqual(
_normalize_url("https://example.com/page/"),
_normalize_url("https://example.com/page"),
)
def test_preserves_non_tracking_params(self):
from lib.fusion import _normalize_url
result = _normalize_url("https://example.com/page?id=5&sort=new")
self.assertIn("id=5", result)
self.assertIn("sort=new", result)
def test_case_insensitive(self):
from lib.fusion import _normalize_url
self.assertEqual(
_normalize_url("https://Reddit.com/r/Test"),
_normalize_url("https://reddit.com/r/test"),
)
if __name__ == "__main__":
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import schema
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "generate-synthesis-inputs.py"
def load_module():
spec = importlib.util.spec_from_file_location("generate_synthesis_inputs", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
class GenerateSynthesisInputsV3Tests(unittest.TestCase):
def test_main_uses_v3_report_deserializer(self):
module = load_module()
report = schema.Report(
topic="test topic",
range_from="2026-02-14",
range_to="2026-03-16",
generated_at="2026-03-16T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="gemini",
planner_model="gemini-3.1-flash-lite-preview",
rerank_model="gemini-3.1-flash-lite-preview",
),
query_plan=schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="test topic",
subqueries=[
schema.SubQuery(
label="primary",
search_query="test topic",
ranking_query="What happened with test topic?",
sources=["grounding"],
)
],
source_weights={"grounding": 1.0},
),
clusters=[
schema.Cluster(
cluster_id="cluster-1",
title="Title",
candidate_ids=["c1"],
representative_ids=["c1"],
sources=["grounding"],
score=90.0,
)
],
ranked_candidates=[
schema.Candidate(
candidate_id="c1",
item_id="i1",
source="grounding",
sources=["grounding"],
title="Title",
url="https://example.com",
snippet="Snippet",
subquery_labels=["primary"],
native_ranks={"primary:grounding": 1},
local_relevance=0.8,
freshness=90,
engagement=None,
source_quality=1.0,
rrf_score=0.02,
rerank_score=91.0,
final_score=90.0,
source_items=[
schema.SourceItem(
item_id="i1",
source="grounding",
title="Title",
body="Body",
url="https://example.com",
published_at="2026-03-16",
)
],
)
],
items_by_source={
"grounding": [
schema.SourceItem(
item_id="i1",
source="grounding",
title="Title",
body="Body",
url="https://example.com",
)
]
},
errors_by_source={},
)
with tempfile.TemporaryDirectory() as tmp:
json_dir = Path(tmp) / "json"
compact_dir = Path(tmp) / "compact"
json_dir.mkdir()
(json_dir / "sample.json").write_text(json.dumps(schema.to_dict(report)))
module.JSON_DIR = json_dir
module.COMPACT_DIR = compact_dir
result = module.main()
self.assertEqual(0, result)
output = (compact_dir / "sample.md").read_text()
self.assertIn("# last30days-3 v3.0.0-alpha: test topic", output)
if __name__ == "__main__":
unittest.main()
+147
View File
@@ -0,0 +1,147 @@
"""Tests for GitHub source module."""
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import github
class TestResolveToken(unittest.TestCase):
def test_explicit_token(self):
self.assertEqual(github._resolve_token("my-token"), "my-token")
@patch.dict("os.environ", {"GITHUB_TOKEN": "env-token"})
def test_env_token(self):
self.assertEqual(github._resolve_token(), "env-token")
@patch.dict("os.environ", {}, clear=True)
@patch("subprocess.run")
def test_gh_cli_fallback(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout="gh-token\n")
# Clear GITHUB_TOKEN from env for this test
result = github._resolve_token()
self.assertEqual(result, "gh-token")
@patch.dict("os.environ", {}, clear=True)
@patch("subprocess.run", side_effect=FileNotFoundError)
def test_no_token_available(self, mock_run):
result = github._resolve_token()
self.assertIsNone(result)
class TestParseRepoFromUrl(unittest.TestCase):
def test_issue_url(self):
url = "https://github.com/facebook/react/issues/123"
self.assertEqual(github._parse_repo_from_url(url), "facebook/react")
def test_pr_url(self):
url = "https://github.com/vercel/next.js/pull/456"
self.assertEqual(github._parse_repo_from_url(url), "vercel/next.js")
def test_empty(self):
self.assertEqual(github._parse_repo_from_url(""), "")
class TestParseDate(unittest.TestCase):
def test_iso_date(self):
self.assertEqual(github._parse_date("2026-03-15T12:00:00Z"), "2026-03-15")
def test_none(self):
self.assertIsNone(github._parse_date(None))
def test_empty(self):
self.assertIsNone(github._parse_date(""))
class TestSearchGithub(unittest.TestCase):
@patch.dict("os.environ", {}, clear=True)
@patch("subprocess.run", side_effect=FileNotFoundError)
def test_no_token_returns_empty(self, mock_run):
result = github.search_github("react", "2026-03-01", "2026-03-31", token=None)
self.assertEqual(result, [])
@patch.object(github, "_fetch_json")
@patch.object(github, "_resolve_token", return_value="test-token")
def test_search_returns_items(self, mock_token, mock_fetch):
mock_fetch.return_value = {
"total_count": 1,
"items": [
{
"html_url": "https://github.com/facebook/react/issues/42",
"title": "React Server Components bug",
"body": "There is a bug when using RSC with streaming...",
"created_at": "2026-03-15T10:00:00Z",
"state": "open",
"comments": 12,
"reactions": {"total_count": 8},
"labels": [{"name": "bug"}, {"name": "rsc"}],
"user": {"login": "testuser"},
},
],
}
result = github.search_github("react", "2026-03-01", "2026-03-31")
self.assertEqual(len(result), 1)
item = result[0]
self.assertEqual(item["source"], "github")
self.assertEqual(item["container"], "facebook/react")
self.assertEqual(item["title"], "React Server Components bug")
self.assertEqual(item["date"], "2026-03-15")
self.assertEqual(item["author"], "testuser")
self.assertIn("bug", item["metadata"]["labels"])
self.assertEqual(item["metadata"]["state"], "open")
self.assertEqual(item["metadata"]["comment_count"], 12)
self.assertEqual(item["metadata"]["reactions"], 8)
self.assertEqual(item["engagement"]["reactions"], 8)
self.assertEqual(item["engagement"]["comments"], 12)
self.assertFalse(item["metadata"]["is_pr"])
@patch.object(github, "_fetch_json", return_value=None)
@patch.object(github, "_resolve_token", return_value="test-token")
def test_rate_limit_returns_empty(self, mock_token, mock_fetch):
"""403 rate limit returns empty list gracefully."""
result = github.search_github("react", "2026-03-01", "2026-03-31")
self.assertEqual(result, [])
@patch.object(github, "_fetch_json")
@patch.object(github, "_resolve_token", return_value="test-token")
def test_pr_detected(self, mock_token, mock_fetch):
mock_fetch.return_value = {
"total_count": 1,
"items": [
{
"html_url": "https://github.com/vercel/next.js/pull/99",
"title": "Add streaming support",
"body": "This PR adds...",
"created_at": "2026-03-20T10:00:00Z",
"state": "open",
"comments": 5,
"reactions": {"total_count": 3},
"labels": [],
"user": {"login": "dev"},
"pull_request": {"url": "..."},
},
],
}
result = github.search_github("next.js", "2026-03-01", "2026-03-31")
self.assertEqual(len(result), 1)
self.assertTrue(result[0]["metadata"]["is_pr"])
class TestComputeRelevance(unittest.TestCase):
def test_basic_relevance(self):
score = github._compute_relevance("react hooks", "React Hooks Tutorial", 0, 10, 5)
self.assertGreater(score, 0.5)
self.assertLessEqual(score, 1.0)
def test_lower_rank_lower_score(self):
high = github._compute_relevance("react", "React", 0, 0, 0)
low = github._compute_relevance("react", "React", 20, 0, 0)
self.assertGreater(high, low)
if __name__ == "__main__":
unittest.main()
+194
View File
@@ -0,0 +1,194 @@
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import grounding
class BraveSearchTests(unittest.TestCase):
def test_brave_search_applies_freshness_and_filters_to_in_range_dated_items(self):
mock_response = {
"web": {
"results": [
{
"title": "Test Article",
"url": "https://example.com/article",
"description": "A test snippet",
"page_age": "2026-03-10T00:00:00",
},
{
"title": "Old Article",
"url": "https://example.com/old",
"description": "Should be filtered",
"page_age": "2025-12-10T00:00:00",
},
{
"title": "Undated Article",
"url": "https://example.com/undated",
"description": "Should also be filtered",
}
]
}
}
with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
items, artifact = grounding.brave_search("test", ("2026-02-25", "2026-03-27"), "fake-key")
self.assertEqual(1, len(items))
self.assertEqual("Test Article", items[0]["title"])
self.assertEqual("https://example.com/article", items[0]["url"])
self.assertEqual("2026-03-10", items[0]["date"])
self.assertEqual("brave", artifact["label"])
call_url = mock_req.call_args.args[1]
self.assertIn("freshness=2026-02-25to2026-03-27", call_url)
class SerperSearchTests(unittest.TestCase):
def test_serper_search_filters_to_in_range_dated_items(self):
mock_response = {
"organic": [
{
"title": "Serper Result",
"link": "https://example.com/serper",
"snippet": "A serper snippet",
"date": "Mar 15, 2026",
},
{
"title": "Old Result",
"link": "https://example.com/old",
"snippet": "Should be filtered",
"date": "Jan 15, 2026",
},
{
"title": "Undated Result",
"link": "https://example.com/undated",
"snippet": "Should also be filtered",
}
]
}
with patch("lib.grounding.http.request", return_value=mock_response):
items, artifact = grounding.serper_search("test", ("2026-02-25", "2026-03-27"), "fake-key")
self.assertEqual(1, len(items))
self.assertEqual("Serper Result", items[0]["title"])
self.assertEqual("2026-03-15", items[0]["date"])
self.assertEqual("serper", artifact["label"])
class ExaSearchTests(unittest.TestCase):
def test_exa_search_filters_to_in_range_dated_items(self):
mock_response = {
"results": [
{
"title": "Exa Result",
"url": "https://example.com/exa",
"text": "An exa snippet about AI trends",
"publishedDate": "2026-03-15T00:00:00.000Z",
"score": 0.85,
},
{
"title": "Old Exa Result",
"url": "https://example.com/old-exa",
"text": "Should be filtered out",
"publishedDate": "2025-12-01T00:00:00.000Z",
"score": 0.7,
},
{
"title": "Undated Exa Result",
"url": "https://example.com/undated-exa",
"text": "No date means filtered",
},
]
}
with patch("lib.grounding.http.request", return_value=mock_response) as mock_req:
items, artifact = grounding.exa_search("test", ("2026-02-25", "2026-03-27"), "fake-exa-key")
self.assertEqual(1, len(items))
self.assertEqual("Exa Result", items[0]["title"])
self.assertEqual("https://example.com/exa", items[0]["url"])
self.assertEqual("2026-03-15", items[0]["date"])
self.assertTrue(items[0]["id"].startswith("WE"))
self.assertEqual("exa", artifact["label"])
self.assertEqual(1, artifact["resultCount"])
# Verify API call
call_args = mock_req.call_args
self.assertEqual("POST", call_args.args[0])
self.assertEqual("https://api.exa.ai/search", call_args.args[1])
self.assertEqual("fake-exa-key", call_args.kwargs["headers"]["x-api-key"])
def test_exa_search_returns_empty_for_no_results(self):
with patch("lib.grounding.http.request", return_value={"results": []}):
items, artifact = grounding.exa_search("test", ("2026-02-25", "2026-03-27"), "key")
self.assertEqual([], items)
self.assertEqual(0, artifact["resultCount"])
class WebSearchDispatchTests(unittest.TestCase):
def test_auto_selects_brave_when_key_present(self):
config = {"BRAVE_API_KEY": "test-key"}
with patch("lib.grounding.brave_search", return_value=([], {})) as mock:
grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
mock.assert_called_once()
def test_auto_selects_exa_when_only_exa_key(self):
config = {"EXA_API_KEY": "test-key"}
with patch("lib.grounding.exa_search", return_value=([], {})) as mock:
grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
mock.assert_called_once()
def test_auto_selects_serper_when_only_serper_key(self):
config = {"SERPER_API_KEY": "test-key"}
with patch("lib.grounding.serper_search", return_value=([], {})) as mock:
grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
mock.assert_called_once()
def test_auto_returns_empty_when_no_keys(self):
items, artifact = grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="auto")
self.assertEqual([], items)
self.assertEqual({}, artifact)
def test_none_returns_empty(self):
config = {"BRAVE_API_KEY": "test-key"}
items, artifact = grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="none")
self.assertEqual([], items)
def test_auto_prefers_brave_over_exa(self):
config = {"BRAVE_API_KEY": "brave-key", "EXA_API_KEY": "exa-key"}
with patch("lib.grounding.brave_search", return_value=([], {})) as mock_brave, \
patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa:
grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
mock_brave.assert_called_once()
mock_exa.assert_not_called()
def test_auto_prefers_exa_over_serper(self):
config = {"EXA_API_KEY": "exa-key", "SERPER_API_KEY": "serper-key"}
with patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa, \
patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper:
grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
mock_exa.assert_called_once()
mock_serper.assert_not_called()
def test_auto_prefers_brave_when_all_keys_present(self):
config = {"BRAVE_API_KEY": "brave-key", "EXA_API_KEY": "exa-key", "SERPER_API_KEY": "serper-key"}
with patch("lib.grounding.brave_search", return_value=([], {})) as mock_brave, \
patch("lib.grounding.exa_search", return_value=([], {})) as mock_exa, \
patch("lib.grounding.serper_search", return_value=([], {})) as mock_serper:
grounding.web_search("test", ("2026-02-25", "2026-03-27"), config, backend="auto")
mock_brave.assert_called_once()
mock_exa.assert_not_called()
mock_serper.assert_not_called()
def test_explicit_exa_without_key_raises(self):
with self.assertRaises(RuntimeError):
grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="exa")
def test_explicit_brave_without_key_raises(self):
with self.assertRaises(RuntimeError):
grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="brave")
def test_unsupported_backend_raises(self):
with self.assertRaises(ValueError):
grounding.web_search("test", ("2026-02-25", "2026-03-27"), {}, backend="google")
if __name__ == "__main__":
unittest.main()
+379 -190
View File
@@ -1,219 +1,408 @@
"""Tests for Hacker News source module."""
"""Tests for hackernews.py - HN search via Algolia API."""
import json
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import hackernews, normalize, schema, score
from lib import hackernews
class TestDateToUnix(unittest.TestCase):
def test_known_date(self):
# 2026-01-01 00:00:00 UTC
result = hackernews._date_to_unix("2026-01-01")
self.assertIsInstance(result, int)
self.assertGreater(result, 0)
# === Helper Functions ===
def test_roundtrip(self):
ts = hackernews._date_to_unix("2026-02-15")
back = hackernews._unix_to_date(ts)
self.assertEqual(back, "2026-02-15")
class TestStripHtml(unittest.TestCase):
def test_basic_html(self):
result = hackernews._strip_html("<p>Hello <b>world</b></p>")
self.assertIn("Hello", result)
self.assertIn("world", result)
self.assertNotIn("<", result)
def test_html_entities(self):
result = hackernews._strip_html("&amp; test")
self.assertIn("&", result)
self.assertIn("test", result)
def test_empty(self):
result = hackernews._strip_html("")
self.assertEqual(result, "")
class TestParseHackernewsResponse(unittest.TestCase):
SAMPLE_RESPONSE = {
"hits": [
{
"objectID": "12345",
"title": "Show HN: A new AI coding assistant",
"url": "https://example.com/article",
"author": "pg",
"points": 350,
"num_comments": 127,
"created_at_i": 1739836800, # 2025-02-18
},
{
"objectID": "12346",
"title": "Ask HN: Best practices for LLM apps?",
"url": "",
"author": "dang",
"points": 80,
"num_comments": 45,
"created_at_i": 1739750400, # 2025-02-17
},
],
def create_mock_hit(
object_id="12345",
title="Test HN Story",
points=100,
num_comments=50,
created_at_i=None,
author="testuser",
url="https://example.com",
):
"""Create a mock Algolia hit object."""
if created_at_i is None:
# Default to 30 days ago
dt = datetime.now(timezone.utc)
created_at_i = int(dt.timestamp()) - (30 * 86400)
return {
"objectID": object_id,
"title": title,
"points": points,
"num_comments": num_comments,
"created_at_i": created_at_i,
"author": author,
"url": url,
}
def test_parses_hits(self):
items = hackernews.parse_hackernews_response(self.SAMPLE_RESPONSE)
self.assertEqual(len(items), 2)
def test_item_fields(self):
items = hackernews.parse_hackernews_response(self.SAMPLE_RESPONSE)
item = items[0]
self.assertEqual(item["object_id"], "12345")
self.assertEqual(item["title"], "Show HN: A new AI coding assistant")
self.assertEqual(item["url"], "https://example.com/article")
self.assertEqual(item["hn_url"], "https://news.ycombinator.com/item?id=12345")
self.assertEqual(item["author"], "pg")
self.assertEqual(item["engagement"]["points"], 350)
self.assertEqual(item["engagement"]["num_comments"], 127)
# === Tests for _date_to_unix() ===
def test_date_conversion(self):
items = hackernews.parse_hackernews_response(self.SAMPLE_RESPONSE)
self.assertIsNotNone(items[0]["date"])
# Should be a valid YYYY-MM-DD string
self.assertRegex(items[0]["date"], r"^\d{4}-\d{2}-\d{2}$")
def test_hn_url_for_askhn(self):
"""Ask HN posts have no article URL, but should have hn_url."""
items = hackernews.parse_hackernews_response(self.SAMPLE_RESPONSE)
ask_hn = items[1]
self.assertEqual(ask_hn["url"], "")
self.assertIn("news.ycombinator.com", ask_hn["hn_url"])
def test_relevance_range(self):
items = hackernews.parse_hackernews_response(self.SAMPLE_RESPONSE)
for item in items:
self.assertGreaterEqual(item["relevance"], 0.0)
self.assertLessEqual(item["relevance"], 1.0)
def test_empty_response(self):
items = hackernews.parse_hackernews_response({"hits": []})
self.assertEqual(items, [])
def test_missing_hits(self):
items = hackernews.parse_hackernews_response({})
self.assertEqual(items, [])
def test_date_to_unix_basic():
"""Test converting YYYY-MM-DD to Unix timestamp."""
result = hackernews._date_to_unix("2026-01-01")
# Should be midnight UTC on Jan 1, 2026
expected = datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp()
assert result == int(expected)
class TestNormalizeHackernewsItems(unittest.TestCase):
def test_normalize(self):
raw_items = [
{
"object_id": "99999",
"title": "Test Story",
"url": "https://example.com",
"hn_url": "https://news.ycombinator.com/item?id=99999",
"author": "testuser",
"date": "2026-02-15",
"engagement": {"points": 100, "num_comments": 50},
"relevance": 0.8,
"why_relevant": "Test",
}
def test_date_to_unix_leap_day():
"""Test date conversion with leap day."""
result = hackernews._date_to_unix("2024-02-29")
expected = datetime(2024, 2, 29, tzinfo=timezone.utc).timestamp()
assert result == int(expected)
# === Tests for _unix_to_date() ===
def test_unix_to_date_basic():
"""Test converting Unix timestamp to YYYY-MM-DD."""
ts = int(datetime(2026, 1, 15, tzinfo=timezone.utc).timestamp())
result = hackernews._unix_to_date(ts)
assert result == "2026-01-15"
def test_unix_to_date_with_time():
"""Test that time component is stripped."""
ts = int(datetime(2026, 1, 15, 14, 30, 45, tzinfo=timezone.utc).timestamp())
result = hackernews._unix_to_date(ts)
assert result == "2026-01-15"
# === Tests for _strip_html() ===
def test_strip_html_basic():
"""Test HTML stripping and entity decoding."""
html_text = "<p>Hello &amp; goodbye</p>"
result = hackernews._strip_html(html_text)
assert result == "Hello & goodbye"
def test_strip_html_paragraph_tags():
"""Test that <p> tags are converted to newlines."""
html_text = "First<p>Second<p>Third"
result = hackernews._strip_html(html_text)
assert "First\n" in result
assert "Second\n" in result
def test_strip_html_nested_tags():
"""Test stripping nested HTML tags."""
html_text = "<div><a href='test'>Link</a> text <b>bold</b></div>"
result = hackernews._strip_html(html_text)
assert result == "Link text bold"
def test_strip_html_entities():
"""Test HTML entity decoding and tag stripping."""
html_text = "Text &amp; &quot;test&quot;"
result = hackernews._strip_html(html_text)
# Entities are decoded
assert "&" in result or "test" in result
# === Tests for _title_matches_query() ===
def test_title_matches_query_basic():
"""Test basic query matching."""
title = "New AI framework for developers"
query = "AI framework"
assert hackernews._title_matches_query(title, query) is True
def test_title_matches_query_case_insensitive():
"""Test that matching is case-insensitive."""
title = "NEW AI FRAMEWORK"
query = "ai framework"
assert hackernews._title_matches_query(title, query) is True
def test_title_matches_query_with_prefix():
"""Test matching with HN prefix stripped."""
title = "Show HN: My new AI framework"
query = "AI framework"
# Should match "AI framework" in the content, not the "Show HN:" prefix
assert hackernews._title_matches_query(title, query) is True
def test_title_matches_query_prefix_only():
"""Test that matching prefix-only returns False."""
title = "Show HN: Something else entirely"
query = "Show HN"
# "Show HN" is a prefix, not real content
# After stripping, "Show HN" won't be in the stripped title
assert hackernews._title_matches_query(title, query) is False
def test_title_matches_query_empty_query():
"""Test that empty query always matches."""
title = "Any title"
query = ""
assert hackernews._title_matches_query(title, query) is True
def test_title_matches_query_partial_match():
"""Test that all query words must match."""
title = "New AI framework"
query = "AI blockchain"
# "blockchain" is not in title, so should fail
assert hackernews._title_matches_query(title, query) is False
# === Tests for search_hackernews() ===
@patch('lib.hackernews.http.request')
def test_search_hackernews_basic(mock_request):
"""Test basic HN search."""
mock_request.return_value = {
"hits": [create_mock_hit()],
"nbHits": 1,
}
result = hackernews.search_hackernews(
"AI framework",
"2026-01-01",
"2026-01-31",
depth="quick"
)
assert "hits" in result
assert len(result["hits"]) == 1
assert mock_request.called
@patch('lib.hackernews.http.request')
def test_search_hackernews_depth_config(mock_request):
"""Test that depth parameter controls hit count."""
mock_request.return_value = {"hits": [], "nbHits": 0}
# Quick mode should request 15 hits
hackernews.search_hackernews("test", "2026-01-01", "2026-01-31", depth="quick")
call_args = mock_request.call_args[0]
url = call_args[1]
assert "hitsPerPage=15" in url
@patch('lib.hackernews.http.request')
def test_search_hackernews_date_filtering(mock_request):
"""Test that date range is applied correctly."""
mock_request.return_value = {"hits": [], "nbHits": 0}
hackernews.search_hackernews("test", "2026-01-01", "2026-01-31", depth="quick")
call_args = mock_request.call_args[0]
url = call_args[1]
# Should have numeric filters for date range
assert "numericFilters" in url
assert "created_at_i" in url
@patch('lib.hackernews.http.request')
def test_search_hackernews_http_error_handling(mock_request):
"""Test graceful handling of HTTP errors."""
from lib.http import HTTPError
mock_request.side_effect = HTTPError("HTTP 429: Too Many Requests")
result = hackernews.search_hackernews("test", "2026-01-01", "2026-01-31")
# Should return empty hits with error
assert result["hits"] == []
assert "error" in result
@patch('lib.hackernews.http.request')
def test_search_hackernews_engagement_filter(mock_request):
"""Test that low-engagement stories are filtered."""
mock_request.return_value = {"hits": [], "nbHits": 0}
hackernews.search_hackernews("test", "2026-01-01", "2026-01-31")
call_args = mock_request.call_args[0]
url = call_args[1]
# Should filter for points > 2 (URL-encoded)
assert "points" in url and "%3E2" in url
# === Tests for parse_hackernews_response() ===
def test_parse_hackernews_response_basic():
"""Test parsing basic Algolia response."""
response = {
"hits": [create_mock_hit(
object_id="123",
title="Test Story",
points=100,
num_comments=50
)]
}
items = hackernews.parse_hackernews_response(response)
assert len(items) == 1
assert items[0]["id"] == "123"
assert items[0]["title"] == "Test Story"
assert items[0]["engagement"]["points"] == 100
assert items[0]["engagement"]["comments"] == 50
def test_parse_hackernews_response_hn_url():
"""Test that HN discussion URL is generated correctly."""
response = {
"hits": [create_mock_hit(object_id="12345")]
}
items = hackernews.parse_hackernews_response(response)
assert items[0]["hn_url"] == "https://news.ycombinator.com/item?id=12345"
def test_parse_hackernews_response_date_conversion():
"""Test that Unix timestamp is converted to YYYY-MM-DD."""
ts = int(datetime(2026, 1, 15, tzinfo=timezone.utc).timestamp())
response = {
"hits": [create_mock_hit(created_at_i=ts)]
}
items = hackernews.parse_hackernews_response(response)
assert items[0]["date"] == "2026-01-15"
def test_parse_hackernews_response_missing_fields():
"""Test handling of hits with missing optional fields."""
response = {
"hits": [{
"objectID": "123",
"title": "Test",
# Missing points, num_comments, created_at_i
}]
}
items = hackernews.parse_hackernews_response(response)
assert len(items) == 1
assert items[0]["engagement"]["points"] == 0
assert items[0]["engagement"]["comments"] == 0
assert items[0]["date"] is None
def test_parse_hackernews_response_relevance_scoring():
"""Test that relevance scores are calculated."""
response = {
"hits": [
create_mock_hit(object_id="1", points=100),
create_mock_hit(object_id="2", points=50),
create_mock_hit(object_id="3", points=10),
]
result = normalize.normalize_hackernews_items(raw_items, "2026-01-01", "2026-03-01")
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], schema.HackerNewsItem)
self.assertEqual(result[0].id, "HN1")
self.assertEqual(result[0].title, "Test Story")
self.assertEqual(result[0].date_confidence, "high")
self.assertEqual(result[0].engagement.score, 100)
self.assertEqual(result[0].engagement.num_comments, 50)
}
items = hackernews.parse_hackernews_response(response, query="test")
# Should have relevance scores
for item in items:
assert "relevance" in item
assert 0 <= item["relevance"] <= 1.0
# First item should generally have higher relevance (better rank)
assert items[0]["relevance"] >= items[2]["relevance"]
def test_normalize_with_comments(self):
raw_items = [
{
"object_id": "99999",
"title": "Test",
"url": "",
"hn_url": "",
"author": "user",
"date": "2026-02-15",
"engagement": {"points": 10, "num_comments": 5},
"relevance": 0.5,
"why_relevant": "Test",
"top_comments": [
{"author": "commenter", "text": "Great post!", "points": 5},
],
"comment_insights": ["Great post!"],
}
def test_parse_hackernews_response_engagement_boost():
"""Test that high-engagement items get relevance boost."""
response = {
"hits": [
create_mock_hit(object_id="1", points=500, num_comments=200), # High engagement
create_mock_hit(object_id="2", points=10, num_comments=5), # Low engagement
]
result = normalize.normalize_hackernews_items(raw_items, "2026-01-01", "2026-03-01")
self.assertEqual(len(result[0].top_comments), 1)
self.assertEqual(result[0].top_comments[0].author, "commenter")
self.assertEqual(len(result[0].comment_insights), 1)
}
items = hackernews.parse_hackernews_response(response, query="test")
# Verify engagement is captured
assert items[0]["engagement"]["points"] == 500
assert items[1]["engagement"]["points"] == 10
class TestScoreHackernewsItems(unittest.TestCase):
def test_score_items(self):
items = [
schema.HackerNewsItem(
id="HN1", title="High engagement", url="", hn_url="",
author="user1", date="2026-02-20",
engagement=schema.Engagement(score=500, num_comments=200),
relevance=0.9,
),
schema.HackerNewsItem(
id="HN2", title="Low engagement", url="", hn_url="",
author="user2", date="2026-02-18",
engagement=schema.Engagement(score=10, num_comments=3),
relevance=0.5,
),
def test_parse_hackernews_response_prefix_filtering():
"""Test that items matching only HN prefixes are filtered."""
response = {
"hits": [
create_mock_hit(title="Show HN: My AI Project", object_id="1"),
create_mock_hit(title="Show HN: Unrelated Project", object_id="2"),
]
scored = score.score_hackernews_items(items)
self.assertEqual(len(scored), 2)
# High engagement + high relevance should score higher
self.assertGreater(scored[0].score, scored[1].score)
def test_score_empty(self):
result = score.score_hackernews_items([])
self.assertEqual(result, [])
def test_engagement_formula(self):
eng = schema.Engagement(score=100, num_comments=50)
result = score.compute_hackernews_engagement_raw(eng)
self.assertIsNotNone(result)
self.assertGreater(result, 0)
def test_engagement_none(self):
result = score.compute_hackernews_engagement_raw(None)
self.assertIsNone(result)
def test_engagement_empty(self):
eng = schema.Engagement()
result = score.compute_hackernews_engagement_raw(eng)
self.assertIsNone(result)
}
# Query for "AI" should keep first, filter second
items = hackernews.parse_hackernews_response(response, query="AI")
assert len(items) == 1
assert items[0]["id"] == "1"
class TestSortItemsWithHN(unittest.TestCase):
def test_hn_priority_after_youtube(self):
"""HN should sort after YouTube at same score."""
x_item = schema.XItem(id="X1", text="test", url="", author_handle="user")
x_item.score = 50
def test_parse_hackernews_response_empty_response():
"""Test handling of empty response."""
response = {"hits": []}
items = hackernews.parse_hackernews_response(response)
assert items == []
hn_item = schema.HackerNewsItem(id="HN1", title="test", url="", hn_url="", author="user")
hn_item.score = 50
yt_item = schema.YouTubeItem(id="YT1", title="test", url="", channel_name="ch")
yt_item.score = 50
# === Tests for engagement scoring ===
sorted_items = score.sort_items([yt_item, hn_item, x_item])
# Same score, so sorted by source priority: X > YouTube > HN
self.assertIsInstance(sorted_items[0], schema.XItem)
self.assertIsInstance(sorted_items[1], schema.YouTubeItem)
self.assertIsInstance(sorted_items[2], schema.HackerNewsItem)
def test_engagement_score_calculation():
"""Test that engagement dict contains points and comments."""
response = {
"hits": [create_mock_hit(points=150, num_comments=75)]
}
items = hackernews.parse_hackernews_response(response)
engagement = items[0]["engagement"]
assert engagement["points"] == 150
assert engagement["comments"] == 75
def test_engagement_score_zero_values():
"""Test handling of zero engagement values."""
response = {
"hits": [{
"objectID": "123",
"title": "Test",
"points": None,
"num_comments": None,
}]
}
items = hackernews.parse_hackernews_response(response)
engagement = items[0]["engagement"]
assert engagement["points"] == 0
assert engagement["comments"] == 0
if __name__ == "__main__":
unittest.main()
pytest.main([__file__, "-v"])
+43
View File
@@ -0,0 +1,43 @@
import sys
import urllib.error
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import http
class Test429RetryLimit(unittest.TestCase):
"""429 retries must be capped at max_429_retries to avoid wasting latency."""
@patch("lib.http.urllib.request.urlopen")
@patch("lib.http.time.sleep") # Don't actually sleep in tests
def test_429_retries_limited_to_2_by_default(self, mock_sleep, mock_urlopen):
"""With default max_429_retries=2, should attempt 2 times then raise."""
error = urllib.error.HTTPError(
"http://example.com", 429, "Too Many Requests", {}, None
)
mock_urlopen.side_effect = error
with self.assertRaises(http.HTTPError) as ctx:
http.request("GET", "http://example.com", retries=5)
self.assertEqual(ctx.exception.status_code, 429)
# Should be called exactly 2 times (initial + 1 retry), not 5
self.assertEqual(mock_urlopen.call_count, 2)
@patch("lib.http.urllib.request.urlopen")
@patch("lib.http.time.sleep")
def test_non_429_errors_still_use_full_retries(self, mock_sleep, mock_urlopen):
"""500 errors should still retry up to the full retries count."""
error = urllib.error.HTTPError(
"http://example.com", 500, "Internal Server Error", {}, None
)
mock_urlopen.side_effect = error
with self.assertRaises(http.HTTPError):
http.request("GET", "http://example.com", retries=3)
self.assertEqual(mock_urlopen.call_count, 3)
+68
View File
@@ -0,0 +1,68 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib.instagram import _parse_items
class TestInstagramOwnerTypeSafety(unittest.TestCase):
def _make_raw(self, **overrides):
base = {
"id": "1",
"code": "ABC123",
"caption": "test caption",
"owner": {"username": "testuser"},
}
base.update(overrides)
return base
def test_owner_as_dict(self):
items = _parse_items([self._make_raw()], "test")
self.assertEqual("testuser", items[0]["author_name"])
def test_owner_as_string(self):
items = _parse_items([self._make_raw(owner="stringuser")], "test")
self.assertEqual("stringuser", items[0]["author_name"])
def test_owner_missing(self):
raw = self._make_raw()
del raw["owner"]
items = _parse_items([raw], "test")
self.assertEqual("", items[0]["author_name"])
def test_owner_none(self):
items = _parse_items([self._make_raw(owner=None)], "test")
self.assertEqual("", items[0]["author_name"])
def test_user_field_fallback(self):
raw = self._make_raw()
del raw["owner"]
raw["user"] = {"username": "fallbackuser"}
items = _parse_items([raw], "test")
self.assertEqual("fallbackuser", items[0]["author_name"])
class TestExpandInstagramQueries(unittest.TestCase):
"""Tests for expand_instagram_queries() multi-query generation."""
def test_default_depth_returns_two_plus_queries(self):
from lib.instagram import expand_instagram_queries
queries = expand_instagram_queries("Kanye West", "default")
self.assertGreaterEqual(len(queries), 2)
# Breaking_news intent should include reaction/edit variant
variant_found = any(
"reaction" in q.lower() or "edit" in q.lower()
for q in queries
)
self.assertTrue(variant_found, f"Expected reaction/edit variant: {queries}")
def test_quick_depth_returns_one_query(self):
from lib.instagram import expand_instagram_queries
queries = expand_instagram_queries("Kanye West", "quick")
self.assertEqual(len(queries), 1)
if __name__ == "__main__":
unittest.main()
+559
View File
@@ -0,0 +1,559 @@
"""Unit tests for untested internal functions across rerank, render, planner, and signals.
These pin the correct behavior of core building blocks that higher-level
tests exercise transitively but don't assert on directly. A regression in
any of these functions would silently degrade output quality.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import planner, rerank, render, signals, schema
def _item(source: str = "reddit", **kwargs) -> schema.SourceItem:
defaults = dict(
item_id="t1", source=source, title="Test Title", body="Test body",
url="https://example.com", engagement={}, metadata={},
)
defaults.update(kwargs)
return schema.SourceItem(**defaults)
def _candidate(source: str = "reddit", **kwargs) -> schema.Candidate:
defaults = dict(
candidate_id="c1", item_id="t1", source=source, title="Test",
url="https://example.com", snippet="snippet", subquery_labels=["primary"],
native_ranks={"primary": 1}, local_relevance=0.5, freshness=50,
engagement=50, source_quality=0.7, rrf_score=0.01, sources=[source],
source_items=[],
)
defaults.update(kwargs)
return schema.Candidate(**defaults)
# ---------------------------------------------------------------------------
# rerank._fallback_tuple
# ---------------------------------------------------------------------------
class TestFallbackTuple(unittest.TestCase):
def test_returns_score_and_explanation(self):
c = _candidate(local_relevance=0.8, freshness=80, source_quality=0.7)
score, explanation = rerank._fallback_tuple(c)
self.assertIsInstance(score, float)
self.assertEqual(explanation, "fallback-local-score")
def test_score_clamped_to_0_100(self):
c = _candidate(local_relevance=2.0, freshness=200, source_quality=2.0)
score, _ = rerank._fallback_tuple(c)
self.assertLessEqual(score, 100.0)
self.assertGreaterEqual(score, 0.0)
def test_higher_relevance_gives_higher_score(self):
high = _candidate(local_relevance=0.9, freshness=50, source_quality=0.7)
low = _candidate(local_relevance=0.1, freshness=50, source_quality=0.7)
self.assertGreater(rerank._fallback_tuple(high)[0], rerank._fallback_tuple(low)[0])
# ---------------------------------------------------------------------------
# rerank._normalized_rrf
# ---------------------------------------------------------------------------
class TestNormalizedRrf(unittest.TestCase):
def test_zero_input(self):
self.assertAlmostEqual(rerank._normalized_rrf(0.0), 0.0)
def test_positive_input(self):
result = rerank._normalized_rrf(0.04)
self.assertGreater(result, 0.0)
self.assertLessEqual(result, 100.0)
def test_clamped_at_100(self):
result = rerank._normalized_rrf(1.0)
self.assertLessEqual(result, 100.0)
# ---------------------------------------------------------------------------
# render._assess_data_freshness
# ---------------------------------------------------------------------------
class TestAssessDataFreshness(unittest.TestCase):
def _report(self, items_by_source: dict) -> schema.Report:
return schema.Report(
topic="test", range_from="2026-02-15", range_to="2026-03-17",
generated_at="2026-03-17T00:00:00Z",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="test", planner_model="test", rerank_model="test",
),
query_plan=schema.QueryPlan(
intent="comparison", freshness_mode="balanced_recent",
cluster_mode="debate", raw_topic="test", subqueries=[],
source_weights={},
),
clusters=[], ranked_candidates=[],
items_by_source=items_by_source, errors_by_source={},
)
def test_no_items_returns_warning(self):
report = self._report({})
result = render._assess_data_freshness(report)
self.assertIsNotNone(result)
self.assertIn("Limited", result)
def test_all_old_items_returns_warning(self):
items = [_item(published_at="2026-01-01") for _ in range(10)]
report = self._report({"reddit": items})
result = render._assess_data_freshness(report)
self.assertIsNotNone(result)
def test_many_recent_items_returns_none(self):
from datetime import date
today = date.today().isoformat()
items = [_item(published_at=today) for _ in range(10)]
report = self._report({"reddit": items})
result = render._assess_data_freshness(report)
self.assertIsNone(result)
# ---------------------------------------------------------------------------
# render._format_date
# ---------------------------------------------------------------------------
class TestFormatDate(unittest.TestCase):
def test_high_confidence_clean(self):
item = _item(published_at="2026-03-10", date_confidence="high")
self.assertEqual(render._format_date(item), "2026-03-10")
def test_low_confidence_tagged(self):
item = _item(published_at="2026-03-10", date_confidence="low")
self.assertIn("date:low", render._format_date(item))
def test_none_item(self):
self.assertIn("unknown", render._format_date(None).lower())
# ---------------------------------------------------------------------------
# render._format_actor
# ---------------------------------------------------------------------------
class TestFormatActor(unittest.TestCase):
def test_reddit_subreddit(self):
item = _item(source="reddit", container="python")
self.assertEqual(render._format_actor(item), "r/python")
def test_x_handle(self):
item = _item(source="x", author="karpathy")
self.assertEqual(render._format_actor(item), "@karpathy")
def test_youtube_channel(self):
item = _item(source="youtube", author="Fireship")
self.assertEqual(render._format_actor(item), "Fireship")
# ---------------------------------------------------------------------------
# render._format_engagement
# ---------------------------------------------------------------------------
class TestFormatEngagement(unittest.TestCase):
def test_reddit_format(self):
item = _item(engagement={"score": 344, "num_comments": 119})
result = render._format_engagement(item)
self.assertIn("344", result)
self.assertIn("pts", result)
def test_empty_engagement(self):
item = _item(engagement={})
self.assertIsNone(render._format_engagement(item))
# ---------------------------------------------------------------------------
# render._format_corroboration
# ---------------------------------------------------------------------------
class TestFormatCorroboration(unittest.TestCase):
def test_multi_source(self):
c = _candidate(sources=["reddit", "x", "hackernews"])
result = render._format_corroboration(c)
self.assertIn("Also on", result)
self.assertIn("X", result)
def test_single_source_none(self):
c = _candidate(sources=["reddit"])
self.assertIsNone(render._format_corroboration(c))
# ---------------------------------------------------------------------------
# render._format_explanation
# ---------------------------------------------------------------------------
class TestFormatExplanation(unittest.TestCase):
def test_hides_fallback_sentinel(self):
c = _candidate(explanation="fallback-local-score")
self.assertIsNone(render._format_explanation(c))
def test_shows_real_explanation(self):
c = _candidate(explanation="Directly compares frameworks")
self.assertEqual(render._format_explanation(c), "Directly compares frameworks")
# ---------------------------------------------------------------------------
# render._fmt_pairs and _format_number
# ---------------------------------------------------------------------------
class TestFmtPairs(unittest.TestCase):
def test_basic(self):
self.assertEqual(render._fmt_pairs([(120, "pts"), (48, "cmt")]), "120pts, 48cmt")
def test_skips_none_and_zero(self):
self.assertEqual(render._fmt_pairs([(None, "pts"), (0, "cmt"), (5, "re")]), "5re")
def test_large_numbers(self):
self.assertIn("94,200", render._fmt_pairs([(94200, "views")]))
class TestFormatNumber(unittest.TestCase):
def test_comma_thousands(self):
self.assertEqual(render._format_number(94200), "94,200")
def test_small_integer(self):
self.assertEqual(render._format_number(42), "42")
# ---------------------------------------------------------------------------
# render._truncate
# ---------------------------------------------------------------------------
class TestTruncate(unittest.TestCase):
def test_short_text(self):
self.assertEqual(render._truncate("hello", 100), "hello")
def test_long_text_has_ellipsis(self):
result = render._truncate("a" * 200, 50)
self.assertTrue(result.endswith("..."))
self.assertEqual(len(result), 50)
# ---------------------------------------------------------------------------
# planner._normalize_subquery_weights
# ---------------------------------------------------------------------------
class TestNormalizeSubqueryWeights(unittest.TestCase):
def test_sums_to_one(self):
sqs = [
schema.SubQuery(label="a", search_query="a", ranking_query="a?", sources=["r"], weight=3.0),
schema.SubQuery(label="b", search_query="b", ranking_query="b?", sources=["r"], weight=1.0),
]
normed = planner._normalize_subquery_weights(sqs)
total = sum(sq.weight for sq in normed)
self.assertAlmostEqual(total, 1.0)
def test_preserves_ratio(self):
sqs = [
schema.SubQuery(label="a", search_query="a", ranking_query="a?", sources=["r"], weight=4.0),
schema.SubQuery(label="b", search_query="b", ranking_query="b?", sources=["r"], weight=1.0),
]
normed = planner._normalize_subquery_weights(sqs)
self.assertAlmostEqual(normed[0].weight / normed[1].weight, 4.0)
# ---------------------------------------------------------------------------
# planner._normalize_weights
# ---------------------------------------------------------------------------
class TestNormalizeWeights(unittest.TestCase):
def test_sums_to_one(self):
result = planner._normalize_weights({"a": 3.0, "b": 1.0})
self.assertAlmostEqual(sum(result.values()), 1.0)
def test_negative_clamped_to_zero(self):
result = planner._normalize_weights({"a": 2.0, "b": -1.0})
self.assertAlmostEqual(result["b"], 0.0)
# ---------------------------------------------------------------------------
# planner._trim_subqueries_for_depth
# ---------------------------------------------------------------------------
class TestTrimSubqueriesForDepth(unittest.TestCase):
def _sq(self, label: str = "primary", sources: list[str] = None) -> schema.SubQuery:
return schema.SubQuery(
label=label, search_query="test", ranking_query="test?",
sources=sources or ["reddit", "x", "grounding", "youtube", "hackernews", "polymarket"],
weight=1.0,
)
def test_quick_limits_sources(self):
sqs = [self._sq()]
result = planner._trim_subqueries_for_depth(sqs, "comparison", "quick", ["reddit", "x", "grounding"])
self.assertLessEqual(len(result[0].sources), 2)
def test_default_comparison_expands_via_capabilities(self):
available = ["reddit", "x", "grounding", "youtube", "hackernews", "tiktok", "instagram"]
sqs = [self._sq(sources=available)]
result = planner._trim_subqueries_for_depth(sqs, "comparison", "default", available)
# Comparison should use all capability-matched sources, not top-3
self.assertGreater(len(result[0].sources), 3)
def test_deep_expands_via_capabilities(self):
available = ["reddit", "x", "youtube", "hackernews", "polymarket"]
sqs = [self._sq(sources=available)]
result = planner._trim_subqueries_for_depth(sqs, "comparison", "deep", available)
# Deep comparison should also use capability expansion, not trim
self.assertGreaterEqual(len(result[0].sources), 4)
# ---------------------------------------------------------------------------
# signals.annotate_stream
# ---------------------------------------------------------------------------
class TestAnnotateStream(unittest.TestCase):
def test_attaches_metadata(self):
items = [
_item(engagement={"score": 100, "num_comments": 50, "upvote_ratio": 0.9}),
]
annotated = signals.annotate_stream(items, "test query", "balanced_recent")
item = annotated[0]
self.assertIsNotNone(item.local_relevance)
self.assertIsNotNone(item.freshness)
self.assertIsNotNone(item.engagement_score)
self.assertIsNotNone(item.source_quality)
self.assertIsNotNone(item.local_rank_score)
def test_sorted_by_local_rank_score(self):
items = [
_item(item_id="low", title="irrelevant stuff", engagement={}),
_item(item_id="high", title="test query exact match test query", engagement={"score": 500, "num_comments": 200}),
]
annotated = signals.annotate_stream(items, "test query", "balanced_recent")
self.assertEqual(annotated[0].item_id, "high")
# ---------------------------------------------------------------------------
# signals.prune_low_relevance
# ---------------------------------------------------------------------------
class TestPruneLowRelevance(unittest.TestCase):
def test_removes_low_relevance_items(self):
items = [
_item(item_id="good"),
_item(item_id="bad"),
]
items[0].local_relevance = 0.8
items[1].local_relevance = 0.01
result = signals.prune_low_relevance(items, minimum=0.1)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].item_id, "good")
def test_keeps_all_if_all_below_minimum(self):
items = [_item(item_id="only")]
items[0].local_relevance = 0.05
result = signals.prune_low_relevance(items, minimum=0.1)
self.assertEqual(len(result), 1) # fallback keeps all
# ---------------------------------------------------------------------------
# Bug fixes found by PR review agents
# ---------------------------------------------------------------------------
class TestDaysAgoZeroFalsy(unittest.TestCase):
"""render._assess_data_freshness must not treat days_ago=0 as falsy."""
def _report_with_items(self, dates_list: list[str]) -> schema.Report:
items = [_item(published_at=d) for d in dates_list]
return schema.Report(
topic="test", range_from="2026-02-15", range_to="2026-03-17",
generated_at="2026-03-17T00:00:00Z",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="test", planner_model="test", rerank_model="test",
),
query_plan=schema.QueryPlan(
intent="comparison", freshness_mode="balanced_recent",
cluster_mode="debate", raw_topic="test", subqueries=[],
source_weights={},
),
clusters=[], ranked_candidates=[],
items_by_source={"reddit": items}, errors_by_source={},
)
def test_items_from_today_count_as_recent(self):
from datetime import date
today = date.today().isoformat()
report = self._report_with_items([today] * 5)
warning = render._assess_data_freshness(report)
self.assertIsNone(warning, f"Items from today should be recent, got warning: {warning}")
class TestRerankBoundary(unittest.TestCase):
"""Rerank demotion must have a clean boundary at exactly 20.0."""
def test_score_at_exactly_20_is_not_demoted(self):
c = _candidate()
c.rerank_score = 20.0
score_at_20 = rerank._final_score(c)
c.rerank_score = 50.0
score_at_50 = rerank._final_score(c)
self.assertGreater(score_at_20 / score_at_50, 0.3,
"Score at 20.0 should not be demoted")
def test_score_at_19_99_is_demoted(self):
c = _candidate()
c.rerank_score = 19.99
score_demoted = rerank._final_score(c)
c.rerank_score = 20.0
score_not_demoted = rerank._final_score(c)
self.assertLess(score_demoted, score_not_demoted * 0.5,
"Score at 19.99 should be heavily demoted vs 20.0")
class TestSlashFalsePositives(unittest.TestCase):
"""Slash regex must not misclassify compound terms as comparisons."""
def test_ci_cd_is_not_comparison(self):
self.assertNotEqual(planner._infer_intent("CI/CD pipeline setup"), "comparison")
def test_tcp_ip_is_not_comparison(self):
self.assertNotEqual(planner._infer_intent("TCP/IP networking guide"), "comparison")
def test_io_is_not_comparison(self):
self.assertNotEqual(planner._infer_intent("I/O performance tuning"), "comparison")
def test_os_kernel_is_not_comparison(self):
self.assertNotEqual(planner._infer_intent("input/output buffering"), "comparison")
def test_proper_noun_slash_still_works(self):
self.assertEqual(planner._infer_intent("React/Vue/Svelte"), "comparison")
class TestGenericEngagementFormatter(unittest.TestCase):
"""Generic formatter must not garble output for unknown sources."""
def test_xiaohongshu_engagement_not_garbled(self):
item = _item(source="xiaohongshu", engagement={"likes": 500, "views": 10000})
result = render._format_engagement(item)
if result is not None:
self.assertNotIn("likes500", result, "Key used as value prefix")
self.assertNotIn("views10000", result, "Key used as value prefix")
# Should contain numeric values, not dict keys as numbers
self.assertIn("500", result)
if __name__ == "__main__":
unittest.main()
class TestDefaultDepthDoesNotCapSources(unittest.TestCase):
"""Default depth must not aggressively limit sources for any intent.
E2E testing showed factual/opinion/prediction/concept queries getting
0-1 sources because SOURCE_LIMITS["default"] capped them at 2-3,
and those 2-3 sources returned empty. v2.9.5 searched all available
sources and let scoring handle quality.
"""
ALL_SOURCES = ["reddit", "x", "grounding", "youtube", "hackernews",
"tiktok", "instagram", "polymarket"]
def _plan_sources(self, topic: str) -> list[str]:
plan = planner.plan_query(
topic=topic,
available_sources=self.ALL_SOURCES,
requested_sources=None,
depth="default",
provider=None,
model=None,
)
return plan.subqueries[0].sources
def test_factual_gets_more_than_2_sources(self):
sources = self._plan_sources("what is quantum computing")
self.assertGreater(len(sources), 2,
f"Factual query capped at {len(sources)} sources: {sources}")
def test_opinion_gets_more_than_3_sources(self):
sources = self._plan_sources("thoughts on Rust")
self.assertGreater(len(sources), 3,
f"Opinion query capped at {len(sources)} sources: {sources}")
def test_prediction_gets_more_than_3_sources(self):
sources = self._plan_sources("odds of recession")
self.assertGreater(len(sources), 3,
f"Prediction query capped at {len(sources)} sources: {sources}")
def test_breaking_news_gets_more_than_4_sources(self):
sources = self._plan_sources("kanye west")
self.assertGreater(len(sources), 4,
f"Breaking news capped at {len(sources)} sources: {sources}")
def test_concept_gets_more_than_3_sources(self):
sources = self._plan_sources("explain transformer architecture")
self.assertGreater(len(sources), 3,
f"Concept query capped at {len(sources)} sources: {sources}")
def test_quick_mode_still_limited(self):
"""Quick mode should remain tight for latency."""
plan = planner.plan_query(
topic="what is quantum computing",
available_sources=self.ALL_SOURCES,
requested_sources=None,
depth="quick",
provider=None,
model=None,
)
self.assertLessEqual(len(plan.subqueries[0].sources), 3)
class TestRerankWeightBalance(unittest.TestCase):
"""Reranker weight must dominate over RRF when candidates have divergent quality."""
def test_rerank_gap_dominates_with_identical_rrf(self):
"""Two candidates with identical RRF but rerank_scores of 80 and 40 should have a meaningful final_score gap (rerank still dominates)."""
high = _candidate(rrf_score=0.03, freshness=50, source_quality=0.7)
high.rerank_score = 80.0
high.final_score = rerank._final_score(high)
low = _candidate(rrf_score=0.03, freshness=50, source_quality=0.7)
low.rerank_score = 40.0
low.final_score = rerank._final_score(low)
gap = high.final_score - low.final_score
# Rerank weight is 0.60, so gap = 0.60 * 40 = 24 points.
# Engagement boost may add a small delta but rerank remains dominant.
self.assertGreaterEqual(gap, 23.0,
f"Rerank gap should be >= 23 points, got {gap:.1f}")
class TestXaiModelDefault(unittest.TestCase):
"""XAI_DEFAULT must be a model that xAI's API actually accepts."""
def test_default_is_not_grok_3(self):
from lib import providers
self.assertNotEqual(providers.XAI_DEFAULT, "grok-3-fast",
"grok-3-fast returns HTTP 400 from xAI API")
self.assertNotEqual(providers.XAI_DEFAULT, "grok-3-mini-fast",
"grok-3-mini-fast returns HTTP 400 from xAI API")
def test_default_is_grok_4_generation(self):
from lib import providers
self.assertIn("grok-4", providers.XAI_DEFAULT,
f"XAI_DEFAULT should be a grok-4 model, got: {providers.XAI_DEFAULT}")
-228
View File
@@ -1,228 +0,0 @@
"""Tests for models module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import models
class TestParseVersion(unittest.TestCase):
def test_simple_version(self):
result = models.parse_version("gpt-5")
self.assertEqual(result, (5,))
def test_minor_version(self):
result = models.parse_version("gpt-5.2")
self.assertEqual(result, (5, 2))
def test_patch_version(self):
result = models.parse_version("gpt-5.2.1")
self.assertEqual(result, (5, 2, 1))
def test_no_version(self):
result = models.parse_version("custom-model")
self.assertIsNone(result)
class TestIsSearchCapableModel(unittest.TestCase):
def test_gpt5_is_capable(self):
"""gpt-5 supports web_search when reasoning is not set to 'minimal'.
Per OpenAI docs, gpt-5 with reasoning effort="minimal" does NOT
support web_search. We never set reasoning params (our usage is
tool invocation + JSON extraction only), so gpt-5 is safe here.
"""
self.assertTrue(models.is_search_capable_model("gpt-5"))
def test_gpt52_is_capable(self):
self.assertTrue(models.is_search_capable_model("gpt-5.2"))
def test_gpt5_mini_is_capable(self):
self.assertTrue(models.is_search_capable_model("gpt-5-mini"))
def test_gpt41_mini_is_capable(self):
self.assertTrue(models.is_search_capable_model("gpt-4.1-mini"))
def test_gpt4o_is_capable(self):
self.assertTrue(models.is_search_capable_model("gpt-4o"))
def test_gpt4o_mini_not_capable(self):
"""gpt-4o-mini does not support web_search with domain filtering."""
self.assertFalse(models.is_search_capable_model("gpt-4o-mini"))
def test_nano_not_capable(self):
"""nano models don't support web_search."""
self.assertFalse(models.is_search_capable_model("gpt-4.1-nano"))
self.assertFalse(models.is_search_capable_model("gpt-5-nano"))
def test_gpt4_not_capable(self):
self.assertFalse(models.is_search_capable_model("gpt-4"))
def test_codex_not_capable(self):
self.assertFalse(models.is_search_capable_model("gpt-5.1-codex"))
def test_backward_compat_alias(self):
"""is_mainline_openai_model still works as alias."""
self.assertTrue(models.is_mainline_openai_model("gpt-5"))
class TestSelectOpenAIModel(unittest.TestCase):
def setUp(self):
from lib import cache
cache.MODEL_CACHE_FILE.unlink(missing_ok=True)
def test_pinned_policy(self):
result = models.select_openai_model(
"fake-key",
policy="pinned",
pin="gpt-5.1"
)
self.assertEqual(result, "gpt-5.1")
def test_prefers_mini_over_mainline(self):
"""Mini models should be preferred for cost-efficiency."""
mock_models = [
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-5-mini", "created": 1704067200},
{"id": "gpt-5.1", "created": 1701388800},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-5-mini")
def test_prefers_newer_generation_mini(self):
"""gpt-5-mini should beat gpt-4.1-mini (newer generation)."""
mock_models = [
{"id": "gpt-4.1-mini", "created": 1701388800},
{"id": "gpt-5-mini", "created": 1704067200},
{"id": "gpt-4.1", "created": 1698710400},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-5-mini")
def test_falls_back_to_mainline_when_no_mini(self):
"""Without mini models, mainline models are selected."""
mock_models = [
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-4.1", "created": 1698710400},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-5.2")
def test_filters_unsupported_variants(self):
"""Nano, codex, preview models should be excluded."""
mock_models = [
{"id": "gpt-5-nano", "created": 1704067200},
{"id": "gpt-5.1-codex", "created": 1704067200},
{"id": "gpt-4o-mini", "created": 1704067200},
{"id": "gpt-4.1-mini", "created": 1698710400},
]
result = models.select_openai_model(
"fake-key",
policy="auto",
mock_models=mock_models
)
self.assertEqual(result, "gpt-4.1-mini")
class TestSelectOpenAIModelErrorPaths(unittest.TestCase):
def setUp(self):
from lib import cache
cache.MODEL_CACHE_FILE.unlink(missing_ok=True)
def test_http_error_returns_fallback(self):
"""HTTPError during model fetch should return fallback, not crash."""
from unittest.mock import patch
from lib import http
with patch('lib.http.get', side_effect=http.HTTPError("Unauthorized", status_code=401)):
result = models.select_openai_model("bad-key", policy="auto")
self.assertEqual(result, models.OPENAI_FALLBACK_MODELS[0])
def test_http_403_returns_fallback(self):
from unittest.mock import patch
from lib import http
with patch('lib.http.get', side_effect=http.HTTPError("Forbidden", status_code=403)):
result = models.select_openai_model("bad-key", policy="auto")
self.assertEqual(result, models.OPENAI_FALLBACK_MODELS[0])
class TestSelectXAIModel(unittest.TestCase):
def test_latest_policy(self):
result = models.select_xai_model(
"fake-key",
policy="latest"
)
self.assertEqual(result, "grok-4-1-fast-non-reasoning")
def test_stable_policy(self):
# Clear cache first to avoid interference
from lib import cache
cache.MODEL_CACHE_FILE.unlink(missing_ok=True)
result = models.select_xai_model(
"fake-key",
policy="stable"
)
self.assertEqual(result, "grok-4-1-fast-non-reasoning")
def test_pinned_policy(self):
result = models.select_xai_model(
"fake-key",
policy="pinned",
pin="grok-3"
)
self.assertEqual(result, "grok-3")
class TestGetModels(unittest.TestCase):
def setUp(self):
from lib import cache
cache.MODEL_CACHE_FILE.unlink(missing_ok=True)
def test_no_keys_returns_none(self):
config = {}
result = models.get_models(config)
self.assertIsNone(result["openai"])
self.assertIsNone(result["xai"])
def test_openai_key_only(self):
config = {"OPENAI_API_KEY": "sk-test"}
mock_models = [
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-5-mini", "created": 1704067200},
]
result = models.get_models(config, mock_openai_models=mock_models)
self.assertEqual(result["openai"], "gpt-5-mini")
self.assertIsNone(result["xai"])
def test_both_keys(self):
config = {
"OPENAI_API_KEY": "sk-test",
"XAI_API_KEY": "xai-test",
}
mock_openai = [
{"id": "gpt-5.2", "created": 1704067200},
{"id": "gpt-5-mini", "created": 1704067200},
]
mock_xai = [{"id": "grok-4-1-fast-non-reasoning", "created": 1704067200}]
result = models.get_models(config, mock_openai, mock_xai)
self.assertEqual(result["openai"], "gpt-5-mini")
self.assertEqual(result["xai"], "grok-4-1-fast-non-reasoning")
if __name__ == "__main__":
unittest.main()
-138
View File
@@ -1,138 +0,0 @@
"""Tests for normalize module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import normalize, schema
class TestNormalizeRedditItems(unittest.TestCase):
def test_normalizes_basic_item(self):
items = [
{
"id": "R1",
"title": "Test Thread",
"url": "https://reddit.com/r/test/1",
"subreddit": "test",
"date": "2026-01-15",
"why_relevant": "Relevant because...",
"relevance": 0.85,
}
]
result = normalize.normalize_reddit_items(items, "2026-01-01", "2026-01-31")
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], schema.RedditItem)
self.assertEqual(result[0].id, "R1")
self.assertEqual(result[0].title, "Test Thread")
self.assertEqual(result[0].date_confidence, "high")
def test_sets_low_confidence_for_old_date(self):
items = [
{
"id": "R1",
"title": "Old Thread",
"url": "https://reddit.com/r/test/1",
"subreddit": "test",
"date": "2025-12-01", # Before range
"relevance": 0.5,
}
]
result = normalize.normalize_reddit_items(items, "2026-01-01", "2026-01-31")
self.assertEqual(result[0].date_confidence, "low")
def test_handles_engagement(self):
items = [
{
"id": "R1",
"title": "Thread with engagement",
"url": "https://reddit.com/r/test/1",
"subreddit": "test",
"engagement": {
"score": 100,
"num_comments": 50,
"upvote_ratio": 0.9,
},
"relevance": 0.5,
}
]
result = normalize.normalize_reddit_items(items, "2026-01-01", "2026-01-31")
self.assertIsNotNone(result[0].engagement)
self.assertEqual(result[0].engagement.score, 100)
self.assertEqual(result[0].engagement.num_comments, 50)
class TestNormalizeXItems(unittest.TestCase):
def test_normalizes_basic_item(self):
items = [
{
"id": "X1",
"text": "Test post content",
"url": "https://x.com/user/status/123",
"author_handle": "testuser",
"date": "2026-01-15",
"why_relevant": "Relevant because...",
"relevance": 0.9,
}
]
result = normalize.normalize_x_items(items, "2026-01-01", "2026-01-31")
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], schema.XItem)
self.assertEqual(result[0].id, "X1")
self.assertEqual(result[0].author_handle, "testuser")
def test_handles_x_engagement(self):
items = [
{
"id": "X1",
"text": "Post with engagement",
"url": "https://x.com/user/status/123",
"author_handle": "user",
"engagement": {
"likes": 100,
"reposts": 25,
"replies": 15,
"quotes": 5,
},
"relevance": 0.5,
}
]
result = normalize.normalize_x_items(items, "2026-01-01", "2026-01-31")
self.assertIsNotNone(result[0].engagement)
self.assertEqual(result[0].engagement.likes, 100)
self.assertEqual(result[0].engagement.reposts, 25)
class TestItemsToDicts(unittest.TestCase):
def test_converts_items(self):
items = [
schema.RedditItem(
id="R1",
title="Test",
url="https://reddit.com/r/test/1",
subreddit="test",
)
]
result = normalize.items_to_dicts(items)
self.assertEqual(len(result), 1)
self.assertIsInstance(result[0], dict)
self.assertEqual(result[0]["id"], "R1")
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import normalize
class NormalizeV3Tests(unittest.TestCase):
def test_youtube_evergreen_fallback_keeps_older_items_when_recent_pool_is_empty(self):
items = [
{
"video_id": "vid-1",
"title": "Deploy to Fly.io tutorial",
"url": "https://youtube.com/watch?v=vid-1",
"channel_name": "Example",
"date": "2026-01-10",
"engagement": {"views": 1000, "likes": 50, "comments": 10},
}
]
normalized = normalize.normalize_source_items(
"youtube",
items,
"2026-02-15",
"2026-03-17",
freshness_mode="evergreen_ok",
)
self.assertEqual(1, len(normalized))
self.assertEqual("2026-01-10", normalized[0].published_at)
def test_grounding_still_drops_older_items_in_evergreen_mode(self):
items = [
{
"id": "g-1",
"title": "Fly.io guide",
"url": "https://example.com/fly-guide",
"date": "2026-01-08",
"date_confidence": "high",
"snippet": "Step-by-step guide.",
}
]
normalized = normalize.normalize_source_items(
"grounding",
items,
"2026-02-15",
"2026-03-17",
freshness_mode="evergreen_ok",
)
self.assertEqual([], normalized)
def test_grounding_requires_a_usable_date(self):
items = [
{
"id": "g-1",
"title": "Undated result",
"url": "https://example.com/undated",
"snippet": "No date attached.",
}
]
normalized = normalize.normalize_source_items(
"grounding",
items,
"2026-02-15",
"2026-03-17",
)
self.assertEqual([], normalized)
if __name__ == "__main__":
unittest.main()
-411
View File
@@ -1,411 +0,0 @@
"""End-to-end NUX integration tests.
Verifies that setup wizard, status banner, quality nudge, and SKILL.md
first-run flow work together coherently:
- first_run flag emitted / not emitted based on SETUP_COMPLETE
- setup subcommand runs auto_setup and writes config
- quality score is consistent with source configuration
- quality nudge disappears at 100%
- banner and quality nudge don't contradict each other
"""
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
# Add scripts dir to path
SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
from lib import setup_wizard, quality_nudge, ui
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_config(**overrides):
"""Return a minimal config dict."""
config = {
"AUTH_TOKEN": None,
"CT0": None,
"XAI_API_KEY": None,
"SCRAPECREATORS_API_KEY": None,
}
config.update(overrides)
return config
def _base_results(**overrides):
"""Return a minimal research_results dict with no errors."""
results = {
"x_error": None,
"youtube_error": None,
"reddit_error": None,
}
results.update(overrides)
return results
def _base_diag(**overrides):
"""Return a minimal diag dict for banner testing."""
diag = {
"setup_complete": False,
"reddit_source": None,
"x_source": None,
"x_method": None,
"youtube": False,
"tiktok": False,
"instagram": False,
"hackernews": True,
"polymarket": True,
"bluesky": False,
"truthsocial": False,
"xiaohongshu": False,
"scrapecreators": False,
"web_search_backend": None,
}
diag.update(overrides)
return diag
def _compute(config_overrides=None, result_overrides=None, ytdlp_installed=False):
"""Helper to call compute_quality_score with mocked yt-dlp check."""
from lib import youtube_yt
config = _base_config(**(config_overrides or {}))
results = _base_results(**(result_overrides or {}))
with patch.object(youtube_yt, "is_ytdlp_installed", return_value=ytdlp_installed):
return quality_nudge.compute_quality_score(config, results)
# ---------------------------------------------------------------------------
# First-run flag detection
# ---------------------------------------------------------------------------
class TestFirstRunDetection:
"""first_run flag is emitted based on SETUP_COMPLETE in config."""
def test_first_run_when_setup_not_complete(self):
"""SETUP_COMPLETE missing -> is_first_run returns True."""
config = _base_config()
assert setup_wizard.is_first_run(config) is True
def test_first_run_when_setup_complete_empty(self):
"""SETUP_COMPLETE='' -> is_first_run returns True."""
config = _base_config(SETUP_COMPLETE="")
assert setup_wizard.is_first_run(config) is True
def test_not_first_run_when_setup_complete(self):
"""SETUP_COMPLETE=true -> is_first_run returns False."""
config = _base_config(SETUP_COMPLETE="true")
assert setup_wizard.is_first_run(config) is False
def test_not_first_run_any_truthy_value(self):
"""SETUP_COMPLETE=1 -> is_first_run returns False."""
config = _base_config(SETUP_COMPLETE="1")
assert setup_wizard.is_first_run(config) is False
# ---------------------------------------------------------------------------
# Setup subcommand writes config
# ---------------------------------------------------------------------------
class TestSetupSubcommandWritesConfig:
"""setup subcommand runs auto_setup and writes SETUP_COMPLETE."""
@patch("lib.cookie_extract.extract_cookies_with_source")
@patch("shutil.which")
def test_auto_setup_and_write(self, mock_which, mock_extract):
"""run_auto_setup + write_setup_config creates valid config."""
mock_extract.return_value = ({"auth_token": "abc", "ct0": "xyz"}, "chrome")
mock_which.return_value = "/usr/local/bin/yt-dlp"
config = _base_config()
results = setup_wizard.run_auto_setup(config)
assert results["cookies_found"]["x"] == "chrome"
assert results["ytdlp_installed"] is True
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / ".env"
written = setup_wizard.write_setup_config(env_path)
assert written is True
content = env_path.read_text()
assert "SETUP_COMPLETE=true" in content
assert "FROM_BROWSER=auto" in content
@patch("lib.cookie_extract.extract_cookies_with_source")
@patch("shutil.which")
def test_after_setup_not_first_run(self, mock_which, mock_extract):
"""After write_setup_config, is_first_run should return False."""
mock_extract.return_value = None
mock_which.return_value = None
with tempfile.TemporaryDirectory() as tmpdir:
env_path = Path(tmpdir) / ".env"
setup_wizard.write_setup_config(env_path)
# Simulate reading the config back
config = {"SETUP_COMPLETE": "true"}
assert setup_wizard.is_first_run(config) is False
# ---------------------------------------------------------------------------
# Quality score consistency with source config
# ---------------------------------------------------------------------------
class TestQualityScoreConsistency:
"""Quality score matches the source configuration state."""
def test_zero_config_is_40_pct(self):
"""No X, no yt-dlp, no SC -> 40% (HN + Polymarket only)."""
q = _compute()
assert q["score_pct"] == 40
assert set(q["core_active"]) == {"hn", "polymarket"}
def test_x_cookies_is_60_pct(self):
"""X cookies active -> 60%."""
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert q["score_pct"] == 60
assert "x" in q["core_active"]
def test_x_plus_ytdlp_is_80_pct(self):
"""X cookies + yt-dlp -> 80%."""
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert q["score_pct"] == 80
assert "x" in q["core_active"]
assert "youtube" in q["core_active"]
def test_full_config_is_100_pct(self):
"""X + yt-dlp + ScrapeCreators -> 100%."""
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["score_pct"] == 100
assert len(q["core_active"]) == 5
def test_xai_key_also_enables_x(self):
"""XAI_API_KEY activates X source same as cookies."""
q = _compute(config_overrides={"XAI_API_KEY": "xai_key"})
assert q["score_pct"] == 60
assert "x" in q["core_active"]
# ---------------------------------------------------------------------------
# Quality nudge disappears at 100%
# ---------------------------------------------------------------------------
class TestQualityNudgeDisappears:
"""Quality nudge is None at 100% and present below 100%."""
def test_nudge_present_at_40(self):
q = _compute()
assert q["nudge_text"] is not None
assert len(q["nudge_text"]) > 0
def test_nudge_present_at_60(self):
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert q["nudge_text"] is not None
def test_nudge_present_at_80(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert q["nudge_text"] is not None
def test_nudge_none_at_100(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["nudge_text"] is None
assert q["core_missing"] == []
# ---------------------------------------------------------------------------
# Banner and quality nudge consistency
# ---------------------------------------------------------------------------
class TestBannerQualityNudgeConsistency:
"""Banner source count and quality nudge percentage don't contradict."""
def test_zero_config_banner_3_sources_nudge_40(self):
"""Banner shows 3 sources (Reddit, HN, PM), nudge says 40%."""
diag = _base_diag()
banner = "\n".join(ui._build_status_banner(diag))
# Banner shows 3 active sources
assert "Reddit (threads only)" in banner
assert "HN" in banner
assert "Polymarket" in banner
# X and YouTube should NOT be in the banner
assert "X (" not in banner
assert "YouTube" not in banner
# Quality nudge is 40%
q = _compute()
assert q["score_pct"] == 40
def test_after_wizard_banner_5_sources_nudge_80(self):
"""After wizard (X cookies + yt-dlp): banner shows 5+ sources, nudge ~80%."""
diag = _base_diag(
setup_complete=True,
x_source="bird",
x_method="browser-chrome",
youtube=True,
)
banner = "\n".join(ui._build_status_banner(diag))
# Banner shows X and YouTube
assert "X (Chrome)" in banner
assert "YouTube" in banner
assert "Reddit (threads only)" in banner
assert "HN" in banner
assert "Polymarket" in banner
# Quality nudge is 80%
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert q["score_pct"] == 80
def test_full_config_banner_all_nudge_gone(self):
"""Full config: banner shows all sources, nudge disappears (100%)."""
diag = _base_diag(
setup_complete=True,
reddit_source="scrapecreators",
x_source="bird",
x_method="browser-chrome",
youtube=True,
tiktok=True,
instagram=True,
scrapecreators=True,
)
banner = "\n".join(ui._build_status_banner(diag))
assert "Reddit (with comments)" in banner
assert "X (Chrome)" in banner
assert "YouTube" in banner
assert "TikTok" in banner
assert "Instagram" in banner
# Quality nudge is 100% / gone
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["score_pct"] == 100
assert q["nudge_text"] is None
def test_banner_first_run_suggests_setup(self):
"""First-run banner suggests running setup wizard."""
diag = _base_diag(setup_complete=False)
banner = "\n".join(ui._build_status_banner(diag))
assert "First Run" in banner
assert "/last30days setup" in banner
def test_banner_partial_suggests_scrapecreators(self):
"""After setup, missing SC is the only recommendation."""
diag = _base_diag(
setup_complete=True,
x_source="bird",
x_method="browser-chrome",
youtube=True,
scrapecreators=False,
)
banner = "\n".join(ui._build_status_banner(diag))
assert "SCRAPECREATORS_API_KEY" in banner
assert "100 free calls, no CC" in banner
# ---------------------------------------------------------------------------
# Progressive narrowing of nudge suggestions
# ---------------------------------------------------------------------------
class TestProgressiveNarrowing:
"""As users configure more, nudge suggestions narrow."""
def test_baseline_mentions_all_three_missing(self):
q = _compute()
assert "X/Twitter" in q["nudge_text"]
assert "YouTube" in q["nudge_text"]
assert "Reddit with comments" in q["nudge_text"]
def test_with_x_mentions_yt_and_sc(self):
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert "X/Twitter" not in q["nudge_text"]
assert "YouTube" in q["nudge_text"]
assert "Reddit with comments" in q["nudge_text"]
def test_with_x_and_yt_mentions_sc_only(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert "X/Twitter" not in q["nudge_text"]
assert "YouTube" not in q["nudge_text"]
# SC nudge is present
assert "Reddit" in q["nudge_text"] or "ScrapeCreators" in q["nudge_text"].lower() or "scrapecreators" in q["nudge_text"]
def test_full_coverage_no_nudge(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
assert q["nudge_text"] is None
# ---------------------------------------------------------------------------
# Render quality nudge integration
# ---------------------------------------------------------------------------
class TestRenderQualityNudge:
"""render_quality_nudge produces correct output."""
def test_render_at_80_pct(self):
from lib.render import render_quality_nudge
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
rendered = render_quality_nudge(q)
assert "80%" in rendered
assert "Research Coverage" in rendered
def test_render_at_100_pct_is_empty(self):
from lib.render import render_quality_nudge
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
ytdlp_installed=True,
)
rendered = render_quality_nudge(q)
assert rendered == ""
-82
View File
@@ -1,82 +0,0 @@
"""Tests for openai_reddit module."""
import sys
import unittest
from pathlib import Path
# Add scripts directory to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import http
from lib.openai_reddit import _is_model_access_error, MODEL_FALLBACK_ORDER
class TestIsModelAccessError(unittest.TestCase):
"""Tests for _is_model_access_error function."""
def test_returns_false_for_non_400_error(self):
"""Non-400 errors should not trigger fallback."""
error = http.HTTPError("Server error", status_code=500, body="Internal error")
self.assertFalse(_is_model_access_error(error))
def test_returns_false_for_400_without_body(self):
"""400 without body should not trigger fallback."""
error = http.HTTPError("Bad request", status_code=400, body=None)
self.assertFalse(_is_model_access_error(error))
def test_returns_true_for_verification_error(self):
"""Verification error should trigger fallback."""
error = http.HTTPError(
"Bad request",
status_code=400,
body='{"error": {"message": "Your organization must be verified to use the model \'gpt-5.2\'"}}'
)
self.assertTrue(_is_model_access_error(error))
def test_returns_true_for_access_error(self):
"""Access denied error should trigger fallback."""
error = http.HTTPError(
"Bad request",
status_code=400,
body='{"error": {"message": "Your account does not have access to this model"}}'
)
self.assertTrue(_is_model_access_error(error))
def test_returns_true_for_model_not_found(self):
"""Model not found error should trigger fallback."""
error = http.HTTPError(
"Bad request",
status_code=400,
body='{"error": {"message": "The model gpt-5.2 was not found"}}'
)
self.assertTrue(_is_model_access_error(error))
def test_returns_false_for_unrelated_400(self):
"""Unrelated 400 errors should not trigger fallback."""
error = http.HTTPError(
"Bad request",
status_code=400,
body='{"error": {"message": "Invalid JSON in request body"}}'
)
self.assertFalse(_is_model_access_error(error))
class TestModelFallbackOrder(unittest.TestCase):
"""Tests for MODEL_FALLBACK_ORDER constant."""
def test_mini_first(self):
"""Mini models should come first (cost-efficient for structured extraction)."""
self.assertEqual(MODEL_FALLBACK_ORDER[0], "gpt-5-mini")
def test_contains_mainline_fallbacks(self):
"""Fallback list should include mainline models as last resort."""
self.assertIn("gpt-4.1", MODEL_FALLBACK_ORDER)
self.assertIn("gpt-4o", MODEL_FALLBACK_ORDER)
def test_no_gpt4o_mini(self):
"""gpt-4o-mini should NOT be in fallback (no domain filtering support)."""
self.assertNotIn("gpt-4o-mini", MODEL_FALLBACK_ORDER)
if __name__ == "__main__":
unittest.main()
+884
View File
@@ -0,0 +1,884 @@
import sys
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import pipeline
from lib import http
from lib import schema
class PipelineV3Tests(unittest.TestCase):
def test_mock_pipeline_report_without_live_credentials(self):
report = pipeline.run(
topic="test topic",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="quick",
requested_sources=["reddit", "x", "grounding"],
mock=True,
)
self.assertEqual("test topic", report.topic)
self.assertTrue(report.ranked_candidates)
self.assertTrue(report.clusters)
self.assertIn("x", report.items_by_source)
# Grounding items now enter the ranked pool (web search backends produce real items)
self.assertIn("grounding", report.items_by_source)
self.assertEqual("gemini", report.provider_runtime.reasoning_provider)
class TestSourceFetchCap(unittest.TestCase):
"""X source fetch count must be capped by MAX_SOURCE_FETCHES."""
def test_x_capped_in_max_source_fetches(self):
"""MAX_SOURCE_FETCHES must cap X at 2 to prevent 429 cascades."""
self.assertIn("x", pipeline.MAX_SOURCE_FETCHES)
self.assertEqual(pipeline.MAX_SOURCE_FETCHES["x"], 2)
def test_cap_logic_limits_source_submissions(self):
"""Verify the cap logic skips submissions beyond the limit."""
cap = pipeline.MAX_SOURCE_FETCHES.get("x", float("inf"))
subquery_sources = [
["x", "reddit", "youtube"],
["x", "reddit", "youtube"],
["x", "reddit", "youtube"],
["x", "reddit", "youtube"],
]
source_fetch_count: dict[str, int] = {}
submitted: list[str] = []
for sources in subquery_sources:
for source in sources:
source_cap = pipeline.MAX_SOURCE_FETCHES.get(source)
if source_cap is not None:
current = source_fetch_count.get(source, 0)
if current >= source_cap:
continue
source_fetch_count[source] = current + 1
submitted.append(source)
x_count = submitted.count("x")
reddit_count = submitted.count("reddit")
self.assertEqual(x_count, 2, f"X should be capped at 2, got {x_count}")
self.assertEqual(reddit_count, 4, f"Reddit should be uncapped, got {reddit_count}")
@patch("lib.pipeline._retrieve_stream")
def test_mock_run_caps_x_fetches(self, mock_retrieve):
"""Pipeline.run in mock mode should call _retrieve_stream for X at most 2 times."""
mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
kwargs["source"], kwargs["subquery"]
)
report = pipeline.run(
topic="compare iPhone vs Android vs Pixel vs Samsung",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="quick",
requested_sources=["reddit", "x"],
mock=True,
)
x_calls = [
call for call in mock_retrieve.call_args_list
if call.kwargs.get("source") == "x"
]
self.assertLessEqual(
len(x_calls), 2,
f"X should be fetched at most 2 times, got {len(x_calls)}",
)
class TestRateLimitSharing(unittest.TestCase):
"""429 signals should be shared across subqueries."""
def test_is_rate_limit_error_detects_429_status(self):
exc = http.HTTPError("HTTP 429: Too Many Requests", status_code=429)
self.assertTrue(pipeline._is_rate_limit_error(exc))
def test_is_rate_limit_error_ignores_non_429(self):
exc = http.HTTPError("HTTP 400: Bad Request", status_code=400)
self.assertFalse(pipeline._is_rate_limit_error(exc))
def test_is_rate_limit_error_detects_429_in_string(self):
exc = RuntimeError("xAI returned 429 rate limit")
self.assertTrue(pipeline._is_rate_limit_error(exc))
def test_is_rate_limit_error_rejects_unrelated_error(self):
exc = RuntimeError("Connection refused")
self.assertFalse(pipeline._is_rate_limit_error(exc))
def test_retrieve_stream_skips_rate_limited_source(self):
"""_retrieve_stream should return empty when source is rate-limited."""
from lib import schema
rate_limited = {"x"}
lock = threading.Lock()
subquery = schema.SubQuery(
label="test",
search_query="test query",
ranking_query="test query",
sources=["x"],
)
items, artifact = pipeline._retrieve_stream(
topic="test",
subquery=subquery,
source="x",
config={},
depth="quick",
date_range=("2026-02-15", "2026-03-17"),
runtime=schema.ProviderRuntime(
reasoning_provider="mock",
planner_model="mock",
rerank_model="mock",
),
mock=True,
rate_limited_sources=rate_limited,
rate_limit_lock=lock,
)
self.assertEqual(items, [])
self.assertEqual(artifact, {})
class TestThinSourceRetry(unittest.TestCase):
@patch("lib.pipeline._retrieve_stream")
def test_retry_includes_planned_source_with_zero_initial_items(self, mock_retrieve):
mock_retrieve.return_value = (
[
{
"id": "X100",
"text": "OpenClaw funding update from an investor",
"url": "https://x.com/example/status/100",
"author_handle": "example",
"date": "2026-03-15",
"engagement": {"likes": 25, "reposts": 4, "replies": 2},
"relevance": 0.8,
"why_relevant": "retry result",
}
],
{},
)
plan = schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="latest OpenClaw funding updates",
subqueries=[
schema.SubQuery(
label="primary",
search_query="latest OpenClaw funding updates",
ranking_query="What recent evidence matters for OpenClaw funding?",
sources=["x", "reddit"],
)
],
source_weights={"x": 1.0, "reddit": 1.0},
)
bundle = schema.RetrievalBundle(
items_by_source={
"reddit": [
_make_source_item("reddit", "r1", "https://reddit.com/1"),
_make_source_item("reddit", "r2", "https://reddit.com/2"),
_make_source_item("reddit", "r3", "https://reddit.com/3"),
]
}
)
pipeline._retry_thin_sources(
topic="latest OpenClaw funding updates",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=pipeline.DEPTH_SETTINGS["default"],
)
self.assertEqual(["x"], [call.kwargs["source"] for call in mock_retrieve.call_args_list])
self.assertIn("x", bundle.items_by_source)
self.assertEqual("https://x.com/example/status/100", bundle.items_by_source["x"][0].url)
def _make_runtime(x_backend="bird"):
return schema.ProviderRuntime(
reasoning_provider="mock",
planner_model="mock",
rerank_model="mock",
x_search_backend=x_backend,
)
def _make_plan(topic="test topic"):
return schema.QueryPlan(
intent="exploration",
freshness_mode="balanced_recent",
cluster_mode="topic",
raw_topic=topic,
subqueries=[
schema.SubQuery(
label="primary",
search_query=topic,
ranking_query=f"What recent evidence matters for {topic}?",
sources=["x", "reddit"],
)
],
source_weights={"x": 1.0, "reddit": 1.0},
)
def _make_source_item(source, item_id, url, author=None, body="", container=None, metadata=None):
return schema.SourceItem(
item_id=item_id,
source=source,
title=f"Item {item_id}",
body=body,
url=url,
author=author,
container=container,
metadata=metadata or {},
)
class TestSupplementalSearches(unittest.TestCase):
"""R1: Phase 2 entity drilling should be wired into the pipeline."""
def test_run_supplemental_searches_exists(self):
"""_run_supplemental_searches must be a callable in pipeline module."""
self.assertTrue(
hasattr(pipeline, "_run_supplemental_searches"),
"_run_supplemental_searches function not found in pipeline module",
)
self.assertTrue(callable(pipeline._run_supplemental_searches))
@patch("lib.bird_x.search_handles")
@patch("lib.entity_extract.extract_entities")
def test_entity_extract_called_after_phase1(self, mock_extract, mock_handles):
"""Phase 2 should call entity_extract on Phase 1 X results, then search_handles."""
mock_extract.return_value = {"x_handles": ["analyst1", "reporter2"], "x_hashtags": [], "reddit_subreddits": []}
mock_handles.return_value = [
{
"id": "supp1",
"text": "Supplemental tweet from analyst1",
"url": "https://x.com/analyst1/status/999",
"author_handle": "analyst1",
"date": "2026-03-15",
"engagement": {"likes": 50},
"relevance": 0.8,
"why_relevant": "direct handle search",
}
]
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1", body="Some tweet about AI"),
_make_source_item("x", "X2", "https://x.com/reporter2/status/2", author="reporter2", body="AI analysis @expert3"),
]
plan = _make_plan("AI safety")
config = {}
pipeline._run_supplemental_searches(
topic="AI safety",
bundle=bundle,
plan=plan,
config=config,
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
mock_extract.assert_called_once()
mock_handles.assert_called_once()
# Supplemental items should be merged into bundle
x_urls = {item.url for item in bundle.items_by_source.get("x", [])}
self.assertIn("https://x.com/analyst1/status/999", x_urls)
@patch("lib.bird_x.search_handles")
@patch("lib.entity_extract.extract_entities")
def test_supplemental_items_deduplicated_by_url(self, mock_extract, mock_handles):
"""Supplemental items with same URL as Phase 1 should not be duplicated."""
mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
# Return item with same URL as Phase 1
mock_handles.return_value = [
{
"id": "dup1",
"text": "Same tweet",
"url": "https://x.com/analyst1/status/1",
"author_handle": "analyst1",
"date": "2026-03-15",
"engagement": {"likes": 50},
"relevance": 0.8,
"why_relevant": "duplicate",
}
]
bundle = schema.RetrievalBundle()
original = _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1")
bundle.items_by_source["x"] = [original]
plan = _make_plan("AI safety")
pipeline._run_supplemental_searches(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
# Should still have only 1 item (no duplicates)
x_items = bundle.items_by_source.get("x", [])
urls = [item.url for item in x_items]
self.assertEqual(
urls.count("https://x.com/analyst1/status/1"), 1,
f"Duplicate URL found: {urls}",
)
def test_phase2_skipped_in_quick_mode(self):
"""_run_supplemental_searches should return immediately when depth='quick'."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
]
# If it tries to import entity_extract, that's fine -- it should return before calling it
pipeline._run_supplemental_searches(
topic="test",
bundle=bundle,
plan=_make_plan(),
config={},
depth="quick",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
# Bundle should be unchanged (only original item)
self.assertEqual(len(bundle.items_by_source["x"]), 1)
def test_phase2_skipped_in_mock_mode(self):
"""_run_supplemental_searches should return immediately when mock=True."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
]
pipeline._run_supplemental_searches(
topic="test",
bundle=bundle,
plan=_make_plan(),
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=True,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
self.assertEqual(len(bundle.items_by_source["x"]), 1)
def test_phase2_skipped_when_x_rate_limited(self):
"""_run_supplemental_searches should skip when X is rate-limited."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
]
pipeline._run_supplemental_searches(
topic="test",
bundle=bundle,
plan=_make_plan(),
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources={"x"},
rate_limit_lock=threading.Lock(),
)
self.assertEqual(len(bundle.items_by_source["x"]), 1)
def test_phase2_skipped_when_backend_not_bird(self):
"""_run_supplemental_searches should skip when X backend is not bird."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
]
pipeline._run_supplemental_searches(
topic="test",
bundle=bundle,
plan=_make_plan(),
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("xai"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
self.assertEqual(len(bundle.items_by_source["x"]), 1)
class TestThinSourceRetry(unittest.TestCase):
"""R2: Dynamic query refinement on thin results."""
def test_retry_thin_sources_exists(self):
"""_retry_thin_sources must be a callable in pipeline module."""
self.assertTrue(
hasattr(pipeline, "_retry_thin_sources"),
"_retry_thin_sources function not found in pipeline module",
)
self.assertTrue(callable(pipeline._retry_thin_sources))
@patch("lib.pipeline._retrieve_stream")
def test_thin_source_retried_with_core_subject(self, mock_retrieve):
"""Sources with < 3 items and no errors should be retried."""
mock_retrieve.return_value = (
[
{
"id": "retry1",
"title": "Retry result",
"url": "https://reddit.com/r/test/2",
"subreddit": "test",
"date": "2026-03-15",
"engagement": {"score": 10},
"selftext": "Retry content",
"relevance": 0.7,
"why_relevant": "retry",
}
],
{},
)
bundle = schema.RetrievalBundle()
# Only 1 reddit item (thin)
bundle.items_by_source["reddit"] = [
_make_source_item("reddit", "R1", "https://reddit.com/r/test/1", container="test"),
]
# 5 X items (not thin)
bundle.items_by_source["x"] = [
_make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
]
plan = _make_plan("advanced AI safety techniques")
settings = pipeline.DEPTH_SETTINGS["default"]
pipeline._retry_thin_sources(
topic="advanced AI safety techniques",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=settings,
)
# _retrieve_stream should have been called for reddit (thin source)
mock_retrieve.assert_called()
call_sources = [c.kwargs.get("source") for c in mock_retrieve.call_args_list]
self.assertIn("reddit", call_sources)
# X should NOT have been retried
self.assertNotIn("x", call_sources)
def test_sources_with_enough_items_not_retried(self):
"""Sources with >= 3 items should not be retried."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["reddit"] = [
_make_source_item("reddit", f"R{i}", f"https://reddit.com/r/test/{i}") for i in range(5)
]
bundle.items_by_source["x"] = [
_make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
]
plan = _make_plan("AI safety")
settings = pipeline.DEPTH_SETTINGS["default"]
with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
pipeline._retry_thin_sources(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=settings,
)
mock_retrieve.assert_not_called()
def test_errored_sources_not_retried(self):
"""Sources in errors_by_source should not be retried even if thin.
Non-errored thin sources SHOULD still be retried."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["reddit"] = [
_make_source_item("reddit", "R1", "https://reddit.com/r/test/1"),
]
bundle.errors_by_source["reddit"] = "API error"
plan = _make_plan("AI safety")
settings = pipeline.DEPTH_SETTINGS["default"]
mock_items = [{"id": "X1", "title": "test", "url": "https://x.com/1", "text": "test"}]
with patch("lib.pipeline._retrieve_stream", return_value=(mock_items, {})) as mock_retrieve:
pipeline._retry_thin_sources(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=settings,
)
# x (non-errored, thin) should be retried; reddit (errored) should not
if mock_retrieve.call_count > 0:
retried_sources = [call.kwargs.get("source") or call.args[2] for call in mock_retrieve.call_args_list if hasattr(call, 'kwargs')]
self.assertNotIn("reddit", [c.kwargs.get("source") for c in mock_retrieve.call_args_list])
def test_retry_skipped_in_quick_mode(self):
"""_retry_thin_sources should return immediately in quick mode."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["reddit"] = [
_make_source_item("reddit", "R1", "https://reddit.com/r/test/1"),
]
plan = _make_plan("AI safety")
settings = pipeline.DEPTH_SETTINGS["quick"]
with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
pipeline._retry_thin_sources(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="quick",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=settings,
)
mock_retrieve.assert_not_called()
class TestErrorCleanup(unittest.TestCase):
"""Source errors should be cleared when the source has items from other subqueries."""
def test_error_cleared_when_source_has_items(self):
"""A source that 429'd on one subquery but succeeded on another is not errored."""
bundle = schema.RetrievalBundle(artifacts={})
item = schema.SourceItem(
item_id="x1", source="x", title="A tweet", body="content",
url="https://x.com/user/status/1",
)
bundle.items_by_source["x"] = [item]
bundle.errors_by_source["x"] = "HTTP 429: Too Many Requests"
# Simulate the cleanup logic from pipeline.run()
for source in list(bundle.errors_by_source):
if bundle.items_by_source.get(source):
del bundle.errors_by_source[source]
self.assertNotIn("x", bundle.errors_by_source,
"X should not be errored when it has items")
def test_error_kept_when_source_has_no_items(self):
"""A source with zero items should remain in errors_by_source."""
bundle = schema.RetrievalBundle(artifacts={})
bundle.errors_by_source["x"] = "HTTP 429: Too Many Requests"
for source in list(bundle.errors_by_source):
if bundle.items_by_source.get(source):
del bundle.errors_by_source[source]
self.assertIn("x", bundle.errors_by_source,
"X should remain errored when it has no items")
class TestXHandleFlag(unittest.TestCase):
"""R3: --x-handle CLI flag and pipeline parameter."""
def test_cli_accepts_x_handle_flag(self):
"""build_parser() should accept --x-handle."""
import last30days as cli
parser = cli.build_parser()
args = parser.parse_args(["test topic", "--x-handle", "elonmusk"])
self.assertEqual(args.x_handle, "elonmusk")
def test_cli_x_handle_default_is_none(self):
"""--x-handle should default to None."""
import last30days as cli
parser = cli.build_parser()
args = parser.parse_args(["test topic"])
self.assertIsNone(args.x_handle)
def test_pipeline_run_accepts_x_handle(self):
"""pipeline.run() should accept x_handle keyword argument."""
import inspect
sig = inspect.signature(pipeline.run)
self.assertIn("x_handle", sig.parameters, "pipeline.run() must accept x_handle parameter")
def test_x_handle_passed_to_supplemental_searches(self):
"""When x_handle is provided, it should trigger targeted handle search."""
# Run pipeline in mock mode with x_handle -- should not raise
report = pipeline.run(
topic="test topic",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="quick",
requested_sources=["reddit", "x", "grounding"],
mock=True,
x_handle="testuser",
)
self.assertEqual("test topic", report.topic)
class TestWarnings(unittest.TestCase):
def _item(self, source="reddit"):
return schema.SourceItem(item_id="1", source=source, title="t", body="b", url="u")
def _candidate(self, source="reddit", score=50.0):
c = schema.Candidate(
candidate_id="c1", item_id="1", source=source, title="t", url="u",
snippet="s", subquery_labels=["main"], native_ranks={"main:reddit": 1},
local_relevance=0.5, freshness=50, engagement=10, source_quality=0.7,
rrf_score=0.01, sources=[source],
)
c.final_score = score
return c
def test_no_candidates_warning(self):
w = pipeline._warnings({"reddit": [self._item()]}, [], {})
self.assertTrue(any("No candidates" in msg for msg in w))
def test_thin_evidence_warning(self):
candidates = [self._candidate() for _ in range(3)]
w = pipeline._warnings({"reddit": [self._item()]}, candidates, {})
self.assertTrue(any("thin" in msg.lower() for msg in w))
def test_single_source_concentration(self):
candidates = [self._candidate() for _ in range(5)]
w = pipeline._warnings({"reddit": [self._item()]}, candidates, {})
self.assertTrue(any("concentrated" in msg.lower() for msg in w))
def test_source_errors_listed(self):
w = pipeline._warnings({}, [self._candidate()], {"x": "timeout"})
self.assertTrue(any("x" in msg for msg in w))
def test_no_items_warning(self):
w = pipeline._warnings({}, [], {})
self.assertTrue(any("No source returned" in msg for msg in w))
class TestXRelatedSupplementalSearch(unittest.TestCase):
"""Tests for --x-related weighted supplemental search."""
@patch("lib.bird_x.search_handles")
@patch("lib.entity_extract.extract_entities")
def test_x_related_triggers_supplemental_related_label(self, mock_extract, mock_handles):
"""x_related handles should be searched and added with supplemental-related label."""
mock_extract.return_value = {"x_handles": [], "x_hashtags": [], "reddit_subreddits": []}
mock_handles.return_value = [
{
"id": "rel1",
"text": "Related tweet from biancacensori",
"url": "https://x.com/biancacensori/status/555",
"author_handle": "biancacensori",
"date": "2026-03-15",
"engagement": {"likes": 30},
"relevance": 0.7,
"why_relevant": "related handle search",
}
]
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/kanyewest/status/1", author="kanyewest"),
]
plan = _make_plan("Kanye West")
pipeline._run_supplemental_searches(
topic="Kanye West",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
x_related=["biancacensori"],
)
# search_handles should have been called for the related handle
mock_handles.assert_called()
# The supplemental-related subquery label should exist in the plan
labels = [sq.label for sq in plan.subqueries]
self.assertIn("supplemental-related", labels)
# The supplemental-related subquery should have weight 0.3
related_sq = [sq for sq in plan.subqueries if sq.label == "supplemental-related"][0]
self.assertAlmostEqual(related_sq.weight, 0.3)
@patch("lib.bird_x.search_handles")
@patch("lib.entity_extract.extract_entities")
def test_no_x_related_no_supplemental_related_label(self, mock_extract, mock_handles):
"""Without x_related, supplemental-related label should not appear."""
mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
mock_handles.return_value = [
{
"id": "supp1",
"text": "Supplemental tweet",
"url": "https://x.com/analyst1/status/999",
"author_handle": "analyst1",
"date": "2026-03-15",
"engagement": {"likes": 50},
"relevance": 0.8,
"why_relevant": "direct handle search",
}
]
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1"),
]
plan = _make_plan("AI safety")
pipeline._run_supplemental_searches(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
# supplemental-related label should NOT exist (no x_related provided)
labels = [sq.label for sq in plan.subqueries]
self.assertNotIn("supplemental-related", labels)
class TestRetryThinSourcesCoreEqualsTopic(unittest.TestCase):
"""Test that _retry_thin_sources fires even when core == topic (the fix)."""
@patch("lib.pipeline._retrieve_stream")
def test_retry_fires_when_core_equals_topic(self, mock_retrieve):
"""Topic 'Kanye West' with 0 YouTube items should trigger retry.
Previously this was skipped because core 'kanye west' == topic.
The fix ensures retry still fires for short topics.
"""
mock_retrieve.return_value = (
[
{
"id": "YT1",
"title": "Kanye West new album leak",
"url": "https://www.youtube.com/watch?v=abc123",
"date": "2026-03-15",
"engagement": {"views": 1000},
"relevance": 0.8,
"why_relevant": "retry result",
}
],
{},
)
plan = schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="Kanye West",
subqueries=[
schema.SubQuery(
label="primary",
search_query="Kanye West",
ranking_query="What recent evidence matters for Kanye West?",
sources=["youtube", "x"],
)
],
source_weights={"youtube": 1.0, "x": 1.0},
)
bundle = schema.RetrievalBundle()
# YouTube has 0 items (thin), X has enough
bundle.items_by_source["x"] = [
_make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
]
pipeline._retry_thin_sources(
topic="Kanye West",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=pipeline.DEPTH_SETTINGS["default"],
)
# _retrieve_stream should have been called for youtube
mock_retrieve.assert_called()
retried_sources = [c.kwargs["source"] for c in mock_retrieve.call_args_list]
self.assertIn("youtube", retried_sources)
# YouTube should now have items in the bundle
self.assertIn("youtube", bundle.items_by_source)
class TestZeroKeyPipelineRun(unittest.TestCase):
"""Pipeline should complete with local fallbacks when no reasoning keys are configured."""
@patch("lib.pipeline._retrieve_stream")
def test_zero_key_run_produces_report(self, mock_retrieve):
mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
kwargs["source"], kwargs["subquery"]
)
config = {"LAST30DAYS_REASONING_PROVIDER": "auto"}
report = pipeline.run(
topic="test zero key topic",
config=config,
depth="quick",
requested_sources=["hackernews"],
)
self.assertEqual("test zero key topic", report.topic)
self.assertEqual("local", report.provider_runtime.reasoning_provider)
self.assertEqual("deterministic", report.provider_runtime.planner_model)
self.assertTrue(
any("fallback" in note for note in report.query_plan.notes),
f"Expected fallback plan, got notes: {report.query_plan.notes}",
)
for candidate in report.ranked_candidates:
self.assertEqual("fallback-local-score", candidate.explanation)
if __name__ == "__main__":
unittest.main()
+285
View File
@@ -0,0 +1,285 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import planner
class PlannerV3Tests(unittest.TestCase):
def test_default_how_to_expands_past_llm_narrow_source_weights(self):
raw = {
"intent": "how_to",
"freshness_mode": "balanced_recent",
"cluster_mode": "workflow",
"source_weights": {"hackernews": 0.7, "reddit": 0.3},
"subqueries": [
{
"label": "primary",
"search_query": "deploy app to Fly.io guide",
"ranking_query": "How do I deploy an app to Fly.io?",
"sources": ["hackernews"],
"weight": 1.0,
}
],
}
plan = planner._sanitize_plan(
raw,
"how to deploy on Fly.io",
["reddit", "x", "youtube", "hackernews"],
None,
"default",
)
sources = plan.subqueries[0].sources
# how_to capability routing selects video + discussion
self.assertIn("reddit", sources)
self.assertIn("youtube", sources)
self.assertIn("reddit", plan.source_weights)
self.assertIn("youtube", plan.source_weights)
self.assertEqual("evergreen_ok", plan.freshness_mode)
def test_comparison_uses_deterministic_plan_and_preserves_entities(self):
plan = planner.plan_query(
topic="openclaw vs nanoclaw vs ironclaw",
available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
requested_sources=None,
depth="default",
provider=object(),
model="ignored",
)
self.assertEqual("comparison", plan.intent)
self.assertEqual(["deterministic-comparison-plan"], plan.notes)
self.assertEqual(4, len(plan.subqueries))
joined_queries = "\n".join(subquery.search_query for subquery in plan.subqueries).lower()
self.assertIn("openclaw", joined_queries)
self.assertIn("nanoclaw", joined_queries)
self.assertIn("ironclaw", joined_queries)
def test_fallback_plan_emits_dual_query_fields(self):
plan = planner.plan_query(
topic="codex vs claude code",
available_sources=["reddit", "x"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("comparison", plan.intent)
self.assertGreaterEqual(len(plan.subqueries), 2)
for subquery in plan.subqueries:
self.assertTrue(subquery.search_query)
self.assertTrue(subquery.ranking_query)
def test_factual_topic_uses_no_cluster_mode(self):
plan = planner.plan_query(
topic="what is the parameter count of claude code",
available_sources=["reddit", "hackernews"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("factual", plan.intent)
self.assertEqual("none", plan.cluster_mode)
def test_quick_mode_collapses_fallback_to_single_subquery(self):
plan = planner.plan_query(
topic="codex vs claude code",
available_sources=["reddit", "x"],
requested_sources=None,
depth="quick",
provider=None,
model=None,
)
self.assertEqual("comparison", plan.intent)
self.assertEqual(1, len(plan.subqueries))
self.assertEqual(["reddit", "x"], plan.subqueries[0].sources)
def test_default_comparison_uses_all_capable_sources(self):
plan = planner.plan_query(
topic="codex vs claude code",
available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("comparison", plan.intent)
for subquery in plan.subqueries:
# Default depth should not artificially cap sources
self.assertGreaterEqual(len(subquery.sources), 4)
def test_default_how_to_keeps_youtube_in_source_mix(self):
plan = planner.plan_query(
topic="how to deploy remotion animations for claude code",
available_sources=["reddit", "x", "youtube", "hackernews"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("how_to", plan.intent)
sources = plan.subqueries[0].sources
self.assertIn("youtube", sources)
self.assertIn("reddit", sources)
def test_how_to_sources_includes_capability_matched_extras(self):
"""how_to routing should include additional sources beyond the core ones."""
plan = planner.plan_query(
topic="how to deploy on Fly.io",
available_sources=["reddit", "tiktok", "instagram", "youtube", "hackernews"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("how_to", plan.intent)
sources = plan.subqueries[0].sources
self.assertIn("youtube", sources)
self.assertIn("reddit", sources)
# Additional capability-matched sources should also be included
self.assertGreater(len(sources), 2,
f"how_to should include >2 sources, got {len(sources)}: {sources}")
def test_ncaa_tournament_is_breaking_news(self):
intent = planner._infer_intent("NCAA tournament brackets")
self.assertEqual("breaking_news", intent)
def test_march_madness_is_breaking_news(self):
intent = planner._infer_intent("2026 March Madness")
self.assertEqual("breaking_news", intent)
def test_factual_plan_has_at_most_2_subqueries(self):
plan = planner.plan_query(
topic="who acquired Wiz",
available_sources=["reddit", "x", "hackernews"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("factual", plan.intent)
self.assertLessEqual(len(plan.subqueries), 2)
def test_default_how_to_prefers_longform_video_over_shortform(self):
plan = planner.plan_query(
topic="how to deploy on Fly.io",
available_sources=["reddit", "tiktok", "instagram", "youtube", "hackernews"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("how_to", plan.intent)
sources = plan.subqueries[0].sources
# how_to routing should include youtube (longform) over tiktok/instagram
self.assertIn("youtube", sources)
self.assertIn("reddit", sources)
def test_prediction_includes_tiktok_and_instagram(self):
"""TikTok and Instagram are no longer excluded from prediction intent."""
plan = planner.plan_query(
topic="odds of US recession 2026",
available_sources=["reddit", "x", "tiktok", "instagram", "youtube", "hackernews", "polymarket"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("prediction", plan.intent)
all_sources = set()
for subquery in plan.subqueries:
all_sources.update(subquery.sources)
self.assertIn("tiktok", all_sources)
self.assertIn("instagram", all_sources)
def test_opinion_includes_tiktok_and_instagram(self):
"""TikTok and Instagram are no longer excluded from opinion intent."""
plan = planner.plan_query(
topic="thoughts on OpenAI Codex pricing",
available_sources=["reddit", "x", "tiktok", "instagram", "youtube", "hackernews"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("opinion", plan.intent)
all_sources = set()
for subquery in plan.subqueries:
all_sources.update(subquery.sources)
self.assertIn("tiktok", all_sources)
self.assertIn("instagram", all_sources)
def test_comparison_includes_polymarket(self):
"""Polymarket should not be excluded from comparison intent plans."""
plan = planner.plan_query(
topic="Sam Altman vs Dario Amodei",
available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("comparison", plan.intent)
all_sources = set()
for subquery in plan.subqueries:
all_sources.update(subquery.sources)
self.assertIn("polymarket", all_sources)
def test_polymarket_excluded_from_how_to_and_concept(self):
"""Polymarket should remain excluded from how_to and concept intents."""
for topic, expected_intent in [
("how to deploy on Fly.io", "how_to"),
("explain transformer architecture", "concept"),
]:
plan = planner.plan_query(
topic=topic,
available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual(expected_intent, plan.intent)
all_sources = set()
for subquery in plan.subqueries:
all_sources.update(subquery.sources)
self.assertNotIn("polymarket", all_sources,
f"polymarket should be excluded from {expected_intent}")
def test_opinion_includes_polymarket(self):
"""Polymarket should not be excluded from opinion intent plans."""
plan = planner.plan_query(
topic="thoughts on OpenAI future",
available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("opinion", plan.intent)
all_sources = set()
for subquery in plan.subqueries:
all_sources.update(subquery.sources)
self.assertIn("polymarket", all_sources)
def test_breaking_news_includes_tiktok_and_instagram(self):
plan = planner.plan_query(
topic="2026 March Madness",
available_sources=["reddit", "x", "tiktok", "instagram", "youtube", "hackernews"],
requested_sources=None,
depth="default",
provider=None,
model=None,
)
self.assertEqual("breaking_news", plan.intent)
all_sources = set()
for subquery in plan.subqueries:
all_sources.update(subquery.sources)
self.assertIn("tiktok", all_sources)
self.assertIn("instagram", all_sources)
if __name__ == "__main__":
unittest.main()
+545 -814
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
import json
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import providers
class ProvidersV3Tests(unittest.TestCase):
def test_auto_prefers_gemini_with_google_key(self):
runtime, client = providers.resolve_runtime(
{"GOOGLE_API_KEY": "test", "LAST30DAYS_REASONING_PROVIDER": "auto"},
depth="default",
)
self.assertEqual("gemini", runtime.reasoning_provider)
self.assertEqual("gemini", client.name)
self.assertTrue(runtime.planner_model.startswith("gemini-3.1-"))
def test_auto_falls_back_to_openai(self):
runtime, client = providers.resolve_runtime(
{
"OPENAI_API_KEY": "test-key",
"OPENAI_AUTH_STATUS": "ok",
"LAST30DAYS_REASONING_PROVIDER": "auto",
},
depth="default",
)
self.assertEqual("openai", runtime.reasoning_provider)
def test_auto_falls_back_to_xai(self):
runtime, client = providers.resolve_runtime(
{"XAI_API_KEY": "test-key", "LAST30DAYS_REASONING_PROVIDER": "auto"},
depth="default",
)
self.assertEqual("xai", runtime.reasoning_provider)
def test_auto_returns_local_runtime_when_no_keys(self):
runtime, client = providers.resolve_runtime(
{"LAST30DAYS_REASONING_PROVIDER": "auto"},
depth="default",
)
self.assertEqual("local", runtime.reasoning_provider)
self.assertEqual("deterministic", runtime.planner_model)
self.assertEqual("local-score", runtime.rerank_model)
self.assertIsNone(client)
def test_explicit_gemini_without_key_still_raises(self):
with self.assertRaises(RuntimeError):
providers.resolve_runtime(
{"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="default",
)
def test_explicit_openai_without_key_still_raises(self):
with self.assertRaises(RuntimeError):
providers.resolve_runtime(
{"LAST30DAYS_REASONING_PROVIDER": "openai"},
depth="default",
)
def test_explicit_xai_without_key_still_raises(self):
with self.assertRaises(RuntimeError):
providers.resolve_runtime(
{"LAST30DAYS_REASONING_PROVIDER": "xai"},
depth="default",
)
class TestExtractJson(unittest.TestCase):
def test_direct_json(self):
result = providers.extract_json('{"scores": [1, 2]}')
self.assertEqual(result, {"scores": [1, 2]})
def test_json_in_markdown_fences(self):
text = '```json\n{"scores": [1, 2]}\n```'
result = providers.extract_json(text)
self.assertEqual(result, {"scores": [1, 2]})
def test_json_with_surrounding_text(self):
text = 'Here is the result:\n{"scores": [1]}\nDone.'
result = providers.extract_json(text)
self.assertEqual(result, {"scores": [1]})
def test_empty_text_raises(self):
with self.assertRaises(ValueError):
providers.extract_json("")
def test_no_json_raises(self):
with self.assertRaises(json.JSONDecodeError):
providers.extract_json("no json here at all")
class TestExtractOpenAIText(unittest.TestCase):
def test_output_text_field(self):
self.assertEqual("hello", providers.extract_openai_text({"output_text": "hello"}))
def test_choices_message_content(self):
payload = {"choices": [{"message": {"content": "world"}}]}
self.assertEqual("world", providers.extract_openai_text(payload))
def test_output_list_text(self):
payload = {"output": [{"text": "foo"}]}
self.assertEqual("foo", providers.extract_openai_text(payload))
def test_output_content_output_text_type(self):
payload = {"output": [{"content": [{"type": "output_text", "text": "bar"}]}]}
self.assertEqual("bar", providers.extract_openai_text(payload))
def test_output_string_item(self):
payload = {"output": ["direct string"]}
self.assertEqual("direct string", providers.extract_openai_text(payload))
def test_empty_payload_returns_empty(self):
self.assertEqual("", providers.extract_openai_text({}))
class TestExtractGeminiText(unittest.TestCase):
def test_standard_response(self):
payload = {"candidates": [{"content": {"parts": [{"text": "gemini says"}]}}]}
self.assertEqual("gemini says", providers.extract_gemini_text(payload))
def test_empty_candidates(self):
self.assertEqual("", providers.extract_gemini_text({"candidates": []}))
def test_empty_payload(self):
self.assertEqual("", providers.extract_gemini_text({}))
class TestParseSSEChunk(unittest.TestCase):
def test_valid_chunk(self):
chunk = 'data: {"type": "delta", "text": "hi"}'
result = providers._parse_sse_chunk(chunk)
self.assertEqual(result, {"type": "delta", "text": "hi"})
def test_done_sentinel(self):
self.assertIsNone(providers._parse_sse_chunk("data: [DONE]"))
def test_no_data_lines(self):
self.assertIsNone(providers._parse_sse_chunk("event: ping"))
def test_invalid_json(self):
self.assertIsNone(providers._parse_sse_chunk("data: {bad json"))
class TestParseCodexStream(unittest.TestCase):
def test_response_completed_event(self):
stream = 'data: {"type": "response.completed", "response": {"output_text": "done"}}\n\n'
result = providers._parse_codex_stream(stream)
self.assertEqual(result["output_text"], "done")
def test_delta_text_accumulation(self):
stream = 'data: {"delta": "hel"}\n\ndata: {"delta": "lo"}\n\n'
result = providers._parse_codex_stream(stream)
text = providers.extract_openai_text(result)
self.assertEqual(text, "hello")
def test_empty_stream(self):
self.assertEqual({}, providers._parse_codex_stream(""))
def test_done_only_stream(self):
self.assertEqual({}, providers._parse_codex_stream("data: [DONE]\n\n"))
if __name__ == "__main__":
unittest.main()
+53 -55
View File
@@ -1,4 +1,9 @@
"""Tests for post-research quality score and upgrade nudge."""
"""Tests for post-research quality score and upgrade nudge.
Reddit is always a core source (free public JSON). The 5 core sources are:
HN, Polymarket, Reddit (always active), X, YouTube.
ScrapeCreators adds TikTok + Instagram as bonus sources, not core.
"""
import pytest
from unittest.mock import patch
@@ -48,66 +53,75 @@ def _compute(config_overrides=None, result_overrides=None, ytdlp_installed=False
# ---------------------------------------------------------------------------
class TestBaseline:
"""HN + Polymarket only (no X, no YT, no SC) -> 40%."""
"""HN + Polymarket + Reddit always active (no X, no YT) -> 60%."""
def test_score_40(self):
def test_score_60(self):
q = _compute()
assert q["score_pct"] == 40
assert q["score_pct"] == 60
def test_active_sources(self):
q = _compute()
assert "hn" in q["core_active"]
assert "polymarket" in q["core_active"]
assert len(q["core_active"]) == 2
assert "reddit" in q["core_active"]
assert len(q["core_active"]) == 3
def test_missing_all_three(self):
def test_missing_x_and_youtube(self):
q = _compute()
assert set(q["core_missing"]) == {"x", "youtube", "reddit_comments"}
assert set(q["core_missing"]) == {"x", "youtube"}
def test_nudge_mentions_all_missing(self):
def test_reddit_not_in_missing(self):
"""Reddit is always active - never appears in missing."""
q = _compute()
assert "reddit" not in q["core_missing"]
assert "reddit_comments" not in q["core_missing"]
def test_nudge_mentions_x_and_youtube(self):
q = _compute()
assert q["nudge_text"] is not None
assert "X/Twitter" in q["nudge_text"]
assert "YouTube" in q["nudge_text"]
assert "Reddit with comments" in q["nudge_text"]
def test_nudge_does_not_mention_reddit(self):
"""Reddit is free - nudge should not tell user to get SC for it."""
q = _compute()
assert "Reddit with comments" not in q["nudge_text"]
class TestXCookies:
"""+X cookies -> 60%."""
"""+X cookies -> 80%."""
def test_score_60(self):
def test_score_80(self):
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert q["score_pct"] == 60
assert q["score_pct"] == 80
def test_nudge_mentions_yt_and_sc(self):
def test_nudge_mentions_yt_only(self):
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
assert "YouTube" in q["nudge_text"]
assert "Reddit with comments" in q["nudge_text"]
assert "X/Twitter" not in q["nudge_text"]
class TestXPlusYtdlp:
"""+X + yt-dlp -> 80%."""
"""+X + yt-dlp -> 100%. No SC needed for full core coverage."""
def test_score_80(self):
def test_score_100(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert q["score_pct"] == 80
assert q["score_pct"] == 100
def test_nudge_mentions_sc_only(self):
def test_nudge_is_none(self):
"""Full core coverage with zero paid keys."""
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert "ScrapeCreators" in q["nudge_text"] or "scrapecreators" in q["nudge_text"]
assert "YouTube" not in q["nudge_text"]
assert "X/Twitter" not in q["nudge_text"]
assert q["nudge_text"] is None
class TestFullCoverage:
"""+X + yt-dlp + SC -> 100%, no nudge."""
class TestFullCoverageWithSC:
"""+X + yt-dlp + SC -> still 100%, SC adds bonus sources."""
def test_score_100(self):
q = _compute(
@@ -130,10 +144,15 @@ class TestFullCoverage:
assert q["nudge_text"] is None
class TestSCActiveNoX:
"""SC active but no X -> 80%, nudge suggests browser cookies (free)."""
class TestSCDoesNotAffectCoreScore:
"""SC key should not change core score - it only adds bonus sources."""
def test_score_80(self):
def test_sc_alone_still_60(self):
"""SC key without X or yt-dlp is still 60% (3/5 core)."""
q = _compute(config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"})
assert q["score_pct"] == 60
def test_sc_plus_ytdlp_is_80(self):
q = _compute(
config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"},
ytdlp_installed=True,
@@ -146,26 +165,9 @@ class TestSCActiveNoX:
ytdlp_installed=True,
)
assert q["nudge_text"] is not None
assert "browser" in q["nudge_text"].lower()
assert "x.com" in q["nudge_text"].lower()
class TestRedditErrored:
"""SC is configured but Reddit errored this run."""
def test_nudge_mentions_error(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
result_overrides={"reddit_error": "ScrapeCreators: 500 Internal Server Error"},
ytdlp_installed=True,
)
assert "reddit_comments" in q["core_errored"]
assert "errored" in q["nudge_text"].lower()
class TestDisclaimerAlwaysPresent:
"""Nudge always includes no-affiliate disclaimer when present."""
@@ -179,25 +181,21 @@ class TestDisclaimerAlwaysPresent:
def test_disclaimer_not_present_at_100(self):
q = _compute(
config_overrides={
"AUTH_TOKEN": "tok123",
"SCRAPECREATORS_API_KEY": "sc_key",
},
config_overrides={"AUTH_TOKEN": "tok123"},
ytdlp_installed=True,
)
assert q["nudge_text"] is None
class TestSCNudgeContent:
"""SC nudge always includes '100 free API calls, no credit card'."""
class TestRedditNeverInCoreErrored:
"""Reddit errors don't affect core score since it's always-active via public path."""
def test_sc_nudge_content(self):
q = _compute()
assert "100 free API calls, no credit card" in q["nudge_text"]
def test_sc_nudge_content_when_only_missing_sc(self):
def test_reddit_error_does_not_affect_score(self):
q = _compute(
config_overrides={"AUTH_TOKEN": "tok123"},
result_overrides={"reddit_error": "429 Too Many Requests"},
ytdlp_installed=True,
)
assert "100 free API calls, no credit card" in q["nudge_text"]
# Reddit is always-active in core (public path), error doesn't demote it
assert "reddit" in q["core_active"]
assert q["score_pct"] == 100
-157
View File
@@ -1,157 +0,0 @@
"""Tests for query type detection and source tiering."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib.query_type import (
detect_query_type,
is_source_enabled,
WEBSEARCH_PENALTY_BY_TYPE,
TIEBREAKER_BY_TYPE,
SOURCE_TIERS,
)
class TestDetectQueryType(unittest.TestCase):
def test_product_queries(self):
self.assertEqual(detect_query_type("cursor IDE pricing"), "product")
self.assertEqual(detect_query_type("is Claude Pro worth the cost"), "product")
self.assertEqual(detect_query_type("best free tier LLM API"), "product")
self.assertEqual(detect_query_type("nano banana pro prompting"), "product")
def test_concept_queries(self):
self.assertEqual(detect_query_type("what is WebTransport"), "concept")
self.assertEqual(detect_query_type("explain React Server Components"), "concept")
self.assertEqual(detect_query_type("how does MCP protocol work"), "concept")
def test_opinion_queries(self):
self.assertEqual(detect_query_type("is cursor worth it"), "opinion")
self.assertEqual(detect_query_type("thoughts on Claude Code"), "opinion")
self.assertEqual(detect_query_type("should i switch to Neovim"), "opinion")
def test_howto_queries(self):
self.assertEqual(detect_query_type("how to deploy on Vercel"), "how_to")
self.assertEqual(detect_query_type("tutorial for building MCP servers"), "how_to")
self.assertEqual(detect_query_type("step by step Kubernetes setup"), "how_to")
self.assertEqual(detect_query_type("nano banana pro prompting"), "how_to")
self.assertEqual(detect_query_type("remotion animations for Claude Code"), "how_to")
def test_comparison_queries(self):
self.assertEqual(detect_query_type("cursor vs windsurf"), "comparison")
self.assertEqual(detect_query_type("Claude compared to GPT-5"), "comparison")
self.assertEqual(detect_query_type("difference between React and Vue"), "comparison")
def test_breaking_news_queries(self):
self.assertEqual(detect_query_type("latest AI funding rounds"), "breaking_news")
self.assertEqual(detect_query_type("OpenAI just announced GPT-6"), "breaking_news")
def test_prediction_queries(self):
self.assertEqual(detect_query_type("odds of Fed rate cut"), "prediction")
self.assertEqual(detect_query_type("predict the next recession"), "prediction")
self.assertEqual(detect_query_type("election outcome 2028"), "prediction")
def test_default_is_breaking_news(self):
self.assertEqual(detect_query_type("tariffs"), "breaking_news")
self.assertEqual(detect_query_type("AI agents"), "breaking_news")
def test_comparison_beats_product(self):
"""Comparison is more specific than product."""
self.assertEqual(detect_query_type("cursor vs windsurf pricing"), "comparison")
def test_howto_beats_concept(self):
"""How-to is more specific than concept."""
self.assertEqual(detect_query_type("how to explain transformers"), "how_to")
def test_will_alone_not_prediction(self):
"""Bare 'will' should not trigger prediction classification."""
self.assertNotEqual(detect_query_type("Will React 19 support concurrent mode"), "prediction")
def test_or_for_not_comparison(self):
"""'or X for Y' should not trigger comparison classification."""
self.assertNotEqual(detect_query_type("best tools or libraries for Python"), "comparison")
class TestIsSourceEnabled(unittest.TestCase):
def test_truthsocial_always_opt_in(self):
for qt in ["product", "concept", "opinion", "breaking_news", "prediction"]:
self.assertFalse(is_source_enabled("truthsocial", qt))
self.assertTrue(is_source_enabled("truthsocial", "breaking_news", explicitly_requested=True))
def test_tier1_sources_enabled(self):
self.assertTrue(is_source_enabled("reddit", "product"))
self.assertTrue(is_source_enabled("youtube", "how_to"))
self.assertTrue(is_source_enabled("polymarket", "prediction"))
self.assertTrue(is_source_enabled("x", "breaking_news"))
def test_tier2_sources_enabled(self):
self.assertTrue(is_source_enabled("web", "product"))
self.assertTrue(is_source_enabled("bluesky", "opinion"))
self.assertTrue(is_source_enabled("x", "how_to"))
self.assertTrue(is_source_enabled("youtube", "breaking_news"))
self.assertTrue(is_source_enabled("hn", "prediction"))
def test_tier3_sources_disabled_by_default(self):
self.assertFalse(is_source_enabled("instagram", "concept"))
self.assertFalse(is_source_enabled("tiktok", "comparison"))
self.assertFalse(is_source_enabled("bluesky", "product"))
def test_explicit_request_overrides_tier(self):
self.assertTrue(is_source_enabled("instagram", "concept", explicitly_requested=True))
self.assertTrue(is_source_enabled("tiktok", "comparison", explicitly_requested=True))
class TestWebSearchPenalty(unittest.TestCase):
def test_concept_has_zero_penalty(self):
self.assertEqual(WEBSEARCH_PENALTY_BY_TYPE["concept"], 0)
def test_product_has_full_penalty(self):
self.assertEqual(WEBSEARCH_PENALTY_BY_TYPE["product"], 15)
def test_howto_has_reduced_penalty(self):
self.assertLess(WEBSEARCH_PENALTY_BY_TYPE["how_to"], WEBSEARCH_PENALTY_BY_TYPE["product"])
def test_all_query_types_have_penalty(self):
for qt in ["product", "concept", "opinion", "how_to", "comparison", "breaking_news", "prediction"]:
self.assertIn(qt, WEBSEARCH_PENALTY_BY_TYPE)
class TestTiebreakerPriority(unittest.TestCase):
def test_youtube_highest_for_howto(self):
self.assertEqual(TIEBREAKER_BY_TYPE["how_to"]["youtube"], 0)
def test_x_highest_for_breaking_news(self):
self.assertEqual(TIEBREAKER_BY_TYPE["breaking_news"]["x"], 0)
def test_polymarket_highest_for_prediction(self):
self.assertEqual(TIEBREAKER_BY_TYPE["prediction"]["polymarket"], 0)
def test_hn_highest_for_concept(self):
self.assertEqual(TIEBREAKER_BY_TYPE["concept"]["hn"], 0)
def test_all_query_types_have_tiebreakers(self):
for qt in ["product", "concept", "opinion", "how_to", "comparison", "breaking_news", "prediction"]:
self.assertIn(qt, TIEBREAKER_BY_TYPE)
class TestSourceTiers(unittest.TestCase):
def test_all_query_types_have_tiers(self):
for qt in ["product", "concept", "opinion", "how_to", "comparison", "breaking_news", "prediction"]:
self.assertIn(qt, SOURCE_TIERS)
self.assertIn("tier1", SOURCE_TIERS[qt])
self.assertIn("tier2", SOURCE_TIERS[qt])
def test_truthsocial_not_in_any_tier(self):
for qt, tiers in SOURCE_TIERS.items():
self.assertNotIn("truthsocial", tiers["tier1"], f"truthsocial in tier1 for {qt}")
self.assertNotIn("truthsocial", tiers["tier2"], f"truthsocial in tier2 for {qt}")
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import query
class QueryV3Tests(unittest.TestCase):
def test_extract_core_subject_strips_prefix_and_noise(self):
result = query.extract_core_subject("What are the best Claude Code skills for startups?")
self.assertEqual("claude code startups", result)
def test_extract_core_subject_supports_suffix_stripping_and_max_words(self):
result = query.extract_core_subject(
"How to use Claude Code prompting techniques",
strip_suffixes=True,
max_words=2,
)
self.assertEqual("claude code", result)
def test_extract_core_subject_preserves_original_when_noise_removes_everything(self):
result = query.extract_core_subject("all tokens", noise=frozenset({"all", "tokens"}))
self.assertEqual("all tokens", result)
def test_extract_core_subject_handles_empty_query_and_custom_noise(self):
self.assertEqual("", query.extract_core_subject(" "))
result = query.extract_core_subject(
"OpenClaw release notes",
noise=frozenset({"release"}),
max_words=2,
)
self.assertEqual("openclaw notes", result)
def test_extract_compound_terms_finds_hyphenated_and_title_cased_phrases(self):
terms = query.extract_compound_terms("Best multi-agent patterns in Claude Code and React Native")
self.assertIn("multi-agent", terms)
self.assertIn("Claude Code", terms)
self.assertIn("React Native", terms)
if __name__ == "__main__":
unittest.main()
+269
View File
@@ -0,0 +1,269 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib.reddit import (
_extract_date,
_extract_score,
_extract_subreddit_name,
_normalize_reddit_id,
_total_engagement,
enrich_with_comments,
)
class TestExtractSubredditName(unittest.TestCase):
def test_from_string(self):
self.assertEqual("openclaw", _extract_subreddit_name("openclaw"))
def test_from_dict_with_name(self):
self.assertEqual(
"openclaw",
_extract_subreddit_name({"id": "t5_ghydwa", "name": "openclaw"}),
)
def test_from_dict_with_display_name(self):
self.assertEqual(
"LocalLLM",
_extract_subreddit_name({"display_name": "LocalLLM"}),
)
def test_from_dict_name_preferred_over_display_name(self):
self.assertEqual(
"name_wins",
_extract_subreddit_name({"name": "name_wins", "display_name": "display"}),
)
def test_empty_string(self):
self.assertEqual("", _extract_subreddit_name(""))
def test_empty_dict(self):
self.assertEqual("", _extract_subreddit_name({}))
def test_strips_whitespace(self):
self.assertEqual("test", _extract_subreddit_name(" test "))
class TestExtractScore(unittest.TestCase):
def test_ups(self):
self.assertEqual(42, _extract_score({"ups": 42}))
def test_score_field(self):
self.assertEqual(77, _extract_score({"score": 77}))
def test_votes(self):
self.assertEqual(99, _extract_score({"votes": 99}))
def test_ups_preferred_over_votes(self):
self.assertEqual(10, _extract_score({"ups": 10, "votes": 99}))
def test_missing(self):
self.assertEqual(0, _extract_score({}))
def test_zero_preserved(self):
self.assertEqual(0, _extract_score({"ups": 0}))
def test_zero_ups_does_not_fall_through(self):
# ups=0 should be returned, not fall through to score
self.assertEqual(0, _extract_score({"ups": 0, "score": 5}))
class TestExtractDate(unittest.TestCase):
def test_unix_timestamp(self):
self.assertEqual("2024-05-03", _extract_date({"created_utc": 1714694957}))
def test_iso_string(self):
result = _extract_date({"created_at": "2024-05-03T01:09:17.620000+0000"})
self.assertEqual("2024-05-03", result)
def test_iso_with_z_suffix(self):
result = _extract_date({"created_at": "2024-05-03T01:09:17Z"})
self.assertEqual("2024-05-03", result)
def test_created_utc_preferred(self):
result = _extract_date({"created_utc": 1714694957, "created_at": "2025-01-01T00:00:00Z"})
self.assertEqual("2024-05-03", result)
def test_missing(self):
self.assertIsNone(_extract_date({}))
class TestNormalizeRedditId(unittest.TestCase):
def test_strips_t3_prefix(self):
self.assertEqual("abc123", _normalize_reddit_id("t3_abc123"))
def test_no_prefix(self):
self.assertEqual("abc123", _normalize_reddit_id("abc123"))
def test_empty(self):
self.assertEqual("", _normalize_reddit_id(""))
def test_none(self):
self.assertEqual("", _normalize_reddit_id(None))
class TestTotalEngagement(unittest.TestCase):
def test_score_plus_comments(self):
item = {"engagement": {"score": 100, "num_comments": 50}}
self.assertEqual(150, _total_engagement(item))
def test_high_comments_low_score(self):
item = {"engagement": {"score": 1, "num_comments": 1387}}
self.assertEqual(1388, _total_engagement(item))
def test_missing_engagement(self):
self.assertEqual(0, _total_engagement({}))
def test_none_values(self):
item = {"engagement": {"score": None, "num_comments": None}}
self.assertEqual(0, _total_engagement(item))
def test_score_only(self):
item = {"engagement": {"score": 42}}
self.assertEqual(42, _total_engagement(item))
class TestEnrichSelectsTopEngagement(unittest.TestCase):
"""Verify enrich_with_comments picks threads by total engagement, not list order."""
def test_high_comment_thread_enriched_over_low_engagement(self):
"""A thread with 1387 comments but score:1 should be enriched before
a thread with score:5 and 0 comments."""
from unittest.mock import patch
items = [
# Low engagement thread (first in list)
{
"id": "R1",
"url": "https://www.reddit.com/r/test/comments/low",
"engagement": {"score": 5, "num_comments": 0},
},
# High engagement thread (second in list)
{
"id": "R2",
"url": "https://www.reddit.com/r/test/comments/high",
"engagement": {"score": 1, "num_comments": 1387},
},
# Medium engagement
{
"id": "R3",
"url": "https://www.reddit.com/r/test/comments/med",
"engagement": {"score": 50, "num_comments": 10},
},
]
enriched_urls = []
def mock_fetch_comments(url, token):
enriched_urls.append(url)
return [{"body": "Great thread!", "ups": 10, "author": "testuser"}]
# Only allow 1 enrichment to prove selection order matters
with patch("lib.reddit.fetch_post_comments", side_effect=mock_fetch_comments):
result = enrich_with_comments(items, token="fake", depth="quick")
# With quick depth (3 enrichments), all 3 should be enriched.
# But the key assertion: the high-comment thread (R2) must be included.
self.assertIn(
"https://www.reddit.com/r/test/comments/high",
enriched_urls,
"High-comment thread should always be selected for enrichment",
)
def test_enrichment_order_by_engagement(self):
"""With a budget of 1, only the highest-engagement thread gets enriched."""
from unittest.mock import patch
items = [
{
"id": "R1",
"url": "https://www.reddit.com/r/test/comments/a",
"engagement": {"score": 200, "num_comments": 5},
},
{
"id": "R2",
"url": "https://www.reddit.com/r/test/comments/b",
"engagement": {"score": 1, "num_comments": 1500},
},
]
enriched_urls = []
def mock_fetch_comments(url, token):
enriched_urls.append(url)
return [{"body": "Comment", "ups": 5, "author": "user"}]
# Override DEPTH_CONFIG to allow only 1 enrichment
custom_config = {"comment_enrichments": 1}
with patch("lib.reddit.DEPTH_CONFIG", {"test": custom_config, "default": custom_config}), \
patch("lib.reddit.fetch_post_comments", side_effect=mock_fetch_comments):
enrich_with_comments(items, token="fake", depth="test")
# R2 has 1501 total engagement vs R1's 205 -- R2 should be picked
self.assertEqual(len(enriched_urls), 1)
self.assertEqual(
enriched_urls[0],
"https://www.reddit.com/r/test/comments/b",
)
class TestEnrichmentBudget(unittest.TestCase):
"""Tests for the enrichment time budget in enrich_with_comments()."""
def _make_items(self, n):
return [
{"url": f"https://reddit.com/r/test/comments/{i}/post", "score": 100 - i, "num_comments": 50,
"engagement": {"score": 100 - i, "num_comments": 50}}
for i in range(n)
]
def test_all_complete_within_budget(self):
"""When enrichment is fast, all items get comments."""
from unittest.mock import patch
items = self._make_items(3)
fast_comments = [{"body": "Great post!", "score": 42, "author": "user1"}]
with patch("lib.reddit.fetch_post_comments", return_value=fast_comments):
result = enrich_with_comments(items, "fake-token", depth="quick", budget_seconds=60)
enriched = [i for i in result if i.get("top_comments")]
self.assertEqual(len(enriched), 3)
def test_budget_zero_returns_items_unenriched(self):
"""With budget=0, items are returned without enrichment (not discarded)."""
import time as _time
from unittest.mock import patch
items = self._make_items(3)
def slow_fetch(url, token):
_time.sleep(2)
return [{"body": "comment", "score": 10, "author": "u"}]
with patch("lib.reddit.fetch_post_comments", side_effect=slow_fetch):
result = enrich_with_comments(items, "fake-token", depth="quick", budget_seconds=0)
# All 3 items returned (not discarded)
self.assertEqual(len(result), 3)
def test_empty_items_returns_immediately(self):
result = enrich_with_comments([], "fake-token", depth="default", budget_seconds=60)
self.assertEqual(result, [])
def test_exceptions_dont_crash(self):
"""If enrichment raises, items are returned without comments."""
from unittest.mock import patch
items = self._make_items(3)
with patch("lib.reddit.fetch_post_comments", side_effect=ConnectionError("boom")):
result = enrich_with_comments(items, "fake-token", depth="quick", budget_seconds=60)
self.assertEqual(len(result), 3)
enriched = [i for i in result if i.get("top_comments")]
self.assertEqual(len(enriched), 0)
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -328,3 +328,85 @@ class TestMissingSubreddit:
results = reddit_public.search("test", subreddit="nonexistent")
assert results == []
# ---------------------------------------------------------------------------
# Tests for comment enrichment (Unit 2)
# ---------------------------------------------------------------------------
class TestEnrichmentIntegration:
"""search_reddit_public enriches top posts with comments."""
@mock.patch("lib.reddit_public._enrich_post")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_search_enriches_top_5_by_default(self, mock_urlopen, mock_enrich):
listing = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/",
"score": 100 - i, "created_utc": 1711670400}
for i in range(10)
])
mock_urlopen.return_value = _mock_urlopen_ok(listing)
mock_enrich.side_effect = lambda item, timeout=10: item # pass-through
results = reddit_public.search_reddit_public("test", "2024-03-01", "2024-03-31")
assert len(results) == 10
# Default depth enriches top 5
assert mock_enrich.call_count == 5
@mock.patch("lib.reddit_public._enrich_post")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_enrichment_timeout_keeps_posts(self, mock_urlopen, mock_enrich):
listing = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/",
"score": 100 - i, "created_utc": 1711670400}
for i in range(10)
])
mock_urlopen.return_value = _mock_urlopen_ok(listing)
# Some enrichments raise, some succeed
call_count = {"n": 0}
def _side_effect(item, timeout=10):
call_count["n"] += 1
if call_count["n"] % 2 == 0:
raise TimeoutError("enrichment timed out")
return item
mock_enrich.side_effect = _side_effect
results = reddit_public.search_reddit_public("test", "2024-03-01", "2024-03-31")
# All 10 posts should still be returned
assert len(results) == 10
@mock.patch("lib.reddit_public._enrich_post")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_all_enrichment_fails_all_posts_returned(self, mock_urlopen, mock_enrich):
listing = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/",
"score": 100 - i, "created_utc": 1711670400}
for i in range(10)
])
mock_urlopen.return_value = _mock_urlopen_ok(listing)
mock_enrich.side_effect = Exception("total failure")
results = reddit_public.search_reddit_public("test", "2024-03-01", "2024-03-31")
# All posts returned despite enrichment failure
assert len(results) == 10
@mock.patch("lib.reddit_public._enrich_post")
@mock.patch("lib.reddit_public.urllib.request.urlopen")
def test_quick_depth_enriches_top_3(self, mock_urlopen, mock_enrich):
listing = _make_reddit_listing([
{"title": f"Post {i}", "permalink": f"/r/test/comments/{i:06d}/post_{i}/",
"score": 100 - i, "created_utc": 1711670400}
for i in range(10)
])
mock_urlopen.return_value = _mock_urlopen_ok(listing)
mock_enrich.side_effect = lambda item, timeout=10: item
results = reddit_public.search_reddit_public("test", "2024-03-01", "2024-03-31", depth="quick")
assert len(results) == 10
# Quick depth enriches only top 3
assert mock_enrich.call_count == 3
+77
View File
@@ -0,0 +1,77 @@
import json
import subprocess
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
def run_mock_json(topic: str) -> dict:
result = subprocess.run(
[sys.executable, "scripts/last30days.py", topic, "--mock", "--emit=json"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise AssertionError(f"mock CLI failed for {topic!r}: {result.stderr}")
return json.loads(result.stdout)
class RegressionTests(unittest.TestCase):
def assert_common_shape(self, payload: dict) -> None:
self.assertIn("topic", payload)
self.assertIn("query_plan", payload)
self.assertIn("ranked_candidates", payload)
self.assertIn("clusters", payload)
self.assertIn("items_by_source", payload)
def test_openclaw_three_way_comparison_preserves_entities(self):
payload = run_mock_json("openclaw vs. nanoclaw vs. ironclaw")
self.assert_common_shape(payload)
plan = payload["query_plan"]
self.assertEqual("comparison", plan["intent"])
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
self.assertIn("openclaw", joined_queries)
self.assertIn("nanoclaw", joined_queries)
self.assertIn("ironclaw", joined_queries)
self.assertNotIn("corsair", joined_queries)
self.assertNotIn("mouse", joined_queries)
for subquery in plan["subqueries"]:
self.assertGreaterEqual(len(subquery["sources"]), 4)
def test_how_to_keeps_web_video_and_discussion_sources(self):
payload = run_mock_json("how to deploy on Fly.io")
self.assert_common_shape(payload)
plan = payload["query_plan"]
self.assertEqual("how_to", plan["intent"])
sources = set(plan["subqueries"][0]["sources"])
self.assertIn("youtube", sources)
self.assertIn("reddit", sources)
self.assertGreaterEqual(len(sources), 2)
def test_breaking_news_query_keeps_expected_shape(self):
payload = run_mock_json("latest news about React 20")
self.assert_common_shape(payload)
plan = payload["query_plan"]
self.assertEqual("breaking_news", plan["intent"])
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
self.assertIn("react 20", joined_queries)
self.assertGreaterEqual(len(plan["subqueries"][0]["sources"]), 2)
def test_two_way_comparison_preserves_exact_strings(self):
payload = run_mock_json("DeepSeek R1 vs GPT-5")
self.assert_common_shape(payload)
plan = payload["query_plan"]
self.assertEqual("comparison", plan["intent"])
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
self.assertIn("deepseek r1", joined_queries)
self.assertIn("gpt-5", joined_queries)
self.assertNotIn("corsair", joined_queries)
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import relevance
class RelevanceCoreV3Tests(unittest.TestCase):
def test_tokenize_removes_stopwords_and_expands_synonyms(self):
tokens = relevance.tokenize("How to use JS for hip hop apps")
self.assertIn("js", tokens)
self.assertIn("javascript", tokens)
self.assertIn("hiphop", tokens)
self.assertNotIn("how", tokens)
def test_token_overlap_relevance_returns_neutral_for_stopword_only_query(self):
self.assertEqual(0.5, relevance.token_overlap_relevance("how to", "anything at all"))
def test_token_overlap_relevance_returns_zero_for_no_overlap(self):
self.assertEqual(0.0, relevance.token_overlap_relevance("openclaw", "corsair gaming mouse"))
def test_token_overlap_relevance_rewards_exact_phrase_matches(self):
phrase_score = relevance.token_overlap_relevance(
"openclaw nanoclaw",
"A detailed openclaw nanoclaw comparison for agents.",
)
partial_score = relevance.token_overlap_relevance(
"openclaw nanoclaw",
"A detailed openclaw comparison for agents.",
)
self.assertGreater(phrase_score, partial_score)
def test_token_overlap_relevance_caps_generic_only_matches(self):
score = relevance.token_overlap_relevance(
"anthropic odds",
"Latest odds and prediction updates for markets",
)
self.assertLessEqual(score, 0.24)
def test_token_overlap_relevance_splits_concatenated_hashtags(self):
score = relevance.token_overlap_relevance(
"claude code",
"Agent workflow discussion",
hashtags=["ClaudeCode", "BuildInPublic"],
)
self.assertGreater(score, 0.0)
if __name__ == "__main__":
unittest.main()
-164
View File
@@ -1,164 +0,0 @@
"""Tests for render module."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import render, schema
class TestRenderCompact(unittest.TestCase):
def test_renders_basic_report(self):
report = schema.Report(
topic="test topic",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="both",
openai_model_used="gpt-5.2",
xai_model_used="grok-4-latest",
)
result = render.render_compact(report)
self.assertIn("test topic", result)
self.assertIn("2026-01-01", result)
self.assertIn("both", result)
self.assertIn("gpt-5.2", result)
def test_renders_reddit_items(self):
report = schema.Report(
topic="test",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="reddit-only",
reddit=[
schema.RedditItem(
id="R1",
title="Test Thread",
url="https://reddit.com/r/test/1",
subreddit="test",
date="2026-01-15",
date_confidence="high",
score=85,
why_relevant="Very relevant",
)
],
)
result = render.render_compact(report)
self.assertIn("R1", result)
self.assertIn("Test Thread", result)
self.assertIn("r/test", result)
def test_shows_coverage_tip_for_reddit_only(self):
report = schema.Report(
topic="test",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="reddit-only",
)
result = render.render_compact(report)
self.assertIn("xAI key", result)
class TestRenderContextSnippet(unittest.TestCase):
def test_renders_snippet(self):
report = schema.Report(
topic="Claude Code Skills",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="both",
)
result = render.render_context_snippet(report)
self.assertIn("Claude Code Skills", result)
self.assertIn("Last 30 Days", result)
class TestRenderFullReport(unittest.TestCase):
def test_renders_full_report(self):
report = schema.Report(
topic="test topic",
range_from="2026-01-01",
range_to="2026-01-31",
generated_at="2026-01-31T12:00:00Z",
mode="both",
openai_model_used="gpt-5.2",
xai_model_used="grok-4-latest",
)
result = render.render_full_report(report)
self.assertIn("# test topic", result)
self.assertIn("## Models Used", result)
self.assertIn("gpt-5.2", result)
class TestGetContextPath(unittest.TestCase):
def test_returns_path_string(self):
result = render.get_context_path()
self.assertIsInstance(result, str)
self.assertIn("last30days.context.md", result)
class TestEnsureOutputDir(unittest.TestCase):
"""Tests for ensure_output_dir()."""
def test_creates_directory(self):
import os
import tempfile
test_dir = os.path.join(tempfile.mkdtemp(), "output", "nested")
os.environ["LAST30DAYS_OUTPUT_DIR"] = test_dir
try:
render.ensure_output_dir()
self.assertTrue(os.path.exists(test_dir))
finally:
os.environ.pop("LAST30DAYS_OUTPUT_DIR", None)
class TestXrefTag(unittest.TestCase):
"""Tests for _xref_tag()."""
def test_no_refs(self):
item = schema.RedditItem(id="R1", title="T", url="u", subreddit="s")
self.assertEqual(render._xref_tag(item), "")
def test_with_refs(self):
item = schema.RedditItem(
id="R1", title="T", url="u", subreddit="s",
cross_refs=["X1", "HN2"],
)
tag = render._xref_tag(item)
self.assertIn("X", tag)
self.assertIn("HN", tag)
class TestRenderEmptyReport(unittest.TestCase):
"""Test render_compact handles empty reports gracefully."""
def test_empty_items_graceful(self):
report = schema.Report(
topic="test",
range_from="2026-02-04",
range_to="2026-03-06",
generated_at="2026-03-06T00:00:00+00:00",
mode="both",
)
output = render.render_compact(report)
self.assertIn("test", output)
self.assertIsInstance(output, str)
if __name__ == "__main__":
unittest.main()
+372
View File
@@ -0,0 +1,372 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import render, schema
def sample_report() -> schema.Report:
primary_item = schema.SourceItem(
item_id="i1",
source="grounding",
title="Grounded result",
body="A grounded body with useful detail.",
url="https://example.com",
container="example.com",
published_at="2026-03-15",
date_confidence="high",
snippet="A grounded snippet about the topic.",
metadata={},
)
reddit_item = schema.SourceItem(
item_id="i2",
source="reddit",
title="Grounded result",
body="Reddit discussion body.",
url="https://example.com",
container="LocalLLaMA",
published_at="2026-03-14",
date_confidence="high",
engagement={"score": 344, "num_comments": 119, "upvote_ratio": 0.92},
metadata={
"top_comments": [{"excerpt": "This is the strongest user reaction.", "score": 22}],
"comment_insights": ["Users corroborate the main claim."],
},
)
candidate = schema.Candidate(
candidate_id="c1",
item_id="i2",
source="reddit",
title="Grounded result",
url="https://example.com",
snippet="A grounded snippet about the topic.",
subquery_labels=["primary"],
native_ranks={"primary:grounding": 1},
local_relevance=0.9,
freshness=90,
engagement=88,
source_quality=1.0,
rrf_score=0.02,
rerank_score=92,
final_score=90,
explanation="high-signal result",
sources=["reddit", "grounding"],
source_items=[reddit_item, primary_item],
)
cluster = schema.Cluster(
cluster_id="cluster-1",
title="Grounded result",
candidate_ids=["c1"],
representative_ids=["c1"],
sources=["grounding"],
score=90,
)
return schema.Report(
topic="test topic",
range_from="2026-02-14",
range_to="2026-03-16",
generated_at="2026-03-16T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="gemini",
planner_model="gemini-3.1-flash-lite-preview",
rerank_model="gemini-3.1-flash-lite-preview",
),
query_plan=schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="test topic",
subqueries=[schema.SubQuery(label="primary", search_query="test topic", ranking_query="What happened with test topic?", sources=["grounding"])],
source_weights={"grounding": 1.0},
),
clusters=[cluster],
ranked_candidates=[candidate],
items_by_source={"grounding": [primary_item], "reddit": [reddit_item]},
errors_by_source={},
)
class RenderV3Tests(unittest.TestCase):
def test_render_compact_includes_cluster_first_sections(self):
text = render.render_compact(sample_report())
self.assertIn("# last30days-3 v3.0.0-alpha: test topic", text)
self.assertIn("## Ranked Evidence Clusters", text)
self.assertIn("## Stats", text)
self.assertIn("Total evidence: 2 items across 2 sources", text)
self.assertIn("Top voices: example.com, r/LocalLLaMA", text)
self.assertIn("Web: 1 item | domains: example.com", text)
self.assertIn("Reddit: 1 item | 344pts, 119cmt | communities: r/LocalLLaMA", text)
self.assertIn("[reddit, grounding] Grounded result", text)
self.assertIn("[344pts, 119cmt]", text)
self.assertIn("Also on: Web", text)
self.assertIn("Comment (22 upvotes): This is the strongest user reaction.", text)
self.assertIn("Insight: Users corroborate the main claim.", text)
self.assertIn("## Source Coverage", text)
def test_render_context_includes_top_clusters(self):
text = render.render_context(sample_report())
self.assertIn("Top clusters:", text)
self.assertIn("Grounded result", text)
def test_render_compact_includes_source_errors_section(self):
report = sample_report()
report.errors_by_source = {"x": "HTTP 400: Bad Request"}
text = render.render_compact(report)
self.assertIn("## Source Errors", text)
self.assertIn("HTTP 400: Bad Request", text)
self.assertIn("X:", text)
class RenderTopCommentsTests(unittest.TestCase):
"""Tests for the top-3 comments rendering in compact cluster view."""
def _make_report_with_comments(self, source="reddit", top_comments=None, comment_insights=None):
"""Helper: build a report with a single candidate carrying given comments."""
item = schema.SourceItem(
item_id="i1",
source=source,
title="Test post",
body="Body text.",
url="https://reddit.com/r/test/comments/abc/test/",
container="test",
published_at="2026-03-15",
date_confidence="high",
engagement={"score": 100, "num_comments": 50},
metadata={
"top_comments": top_comments or [],
"comment_insights": comment_insights or [],
},
)
candidate = schema.Candidate(
candidate_id="c1",
item_id="i1",
source=source,
title="Test post",
url="https://reddit.com/r/test/comments/abc/test/",
snippet="A test snippet.",
subquery_labels=["primary"],
native_ranks={"primary:reddit": 1},
local_relevance=0.9,
freshness=90,
engagement=88,
source_quality=1.0,
rrf_score=0.02,
rerank_score=92,
final_score=90,
sources=[source],
source_items=[item],
)
cluster = schema.Cluster(
cluster_id="cluster-1",
title="Test cluster",
candidate_ids=["c1"],
representative_ids=["c1"],
sources=[source],
score=90,
)
return schema.Report(
topic="test topic",
range_from="2026-02-14",
range_to="2026-03-16",
generated_at="2026-03-16T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="gemini",
planner_model="gemini-3.1-flash-lite-preview",
rerank_model="gemini-3.1-flash-lite-preview",
),
query_plan=schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="test topic",
subqueries=[schema.SubQuery(label="primary", search_query="test", ranking_query="test?", sources=[source])],
source_weights={source: 1.0},
),
clusters=[cluster],
ranked_candidates=[candidate],
items_by_source={source: [item]},
errors_by_source={},
)
def test_reddit_5_comments_renders_top_3(self):
"""Reddit candidate with 5 comments (scores 500, 200, 50, 8, 3) renders 3."""
comments = [
{"score": 500, "excerpt": "Comment with 500 upvotes", "author": "user1"},
{"score": 200, "excerpt": "Comment with 200 upvotes", "author": "user2"},
{"score": 50, "excerpt": "Comment with 50 upvotes", "author": "user3"},
{"score": 8, "excerpt": "Comment with 8 upvotes", "author": "user4"},
{"score": 3, "excerpt": "Comment with 3 upvotes", "author": "user5"},
]
report = self._make_report_with_comments(top_comments=comments)
text = render.render_compact(report)
self.assertIn("Comment (500 upvotes):", text)
self.assertIn("Comment (200 upvotes):", text)
self.assertIn("Comment (50 upvotes):", text)
self.assertNotIn("Comment (8 upvotes):", text)
self.assertNotIn("Comment (3 upvotes):", text)
def test_reddit_1_comment_renders_1(self):
"""Reddit candidate with 1 comment renders 1."""
comments = [{"score": 100, "excerpt": "Single comment", "author": "user1"}]
report = self._make_report_with_comments(top_comments=comments)
text = render.render_compact(report)
self.assertIn("Comment (100 upvotes): Single comment", text)
def test_reddit_0_comments_no_section(self):
"""Reddit candidate with 0 comments renders no comment section."""
report = self._make_report_with_comments(top_comments=[])
text = render.render_compact(report)
self.assertNotIn("Comment (", text)
self.assertNotIn("upvotes)", text)
def test_non_reddit_no_comments(self):
"""Non-Reddit candidate doesn't render comments when metadata has none."""
report = self._make_report_with_comments(source="grounding", top_comments=[])
text = render.render_compact(report)
self.assertNotIn("Comment (", text)
self.assertIn("Test cluster", text)
def test_all_comments_below_score_10_no_section(self):
"""All comments below score 10 renders no comment section."""
comments = [
{"score": 9, "excerpt": "Low score 1", "author": "user1"},
{"score": 5, "excerpt": "Low score 2", "author": "user2"},
{"score": 1, "excerpt": "Low score 3", "author": "user3"},
]
report = self._make_report_with_comments(top_comments=comments)
text = render.render_compact(report)
self.assertNotIn("Comment (", text)
self.assertNotIn("upvotes)", text)
class RenderBestTakesCompactTests(unittest.TestCase):
"""Tests for Best Takes section in compact output and fun tags on candidates."""
def _make_candidate(self, cid, fun_score=None, fun_explanation=None, final_score=80):
"""Helper: build a candidate with a given fun_score."""
item = schema.SourceItem(
item_id=f"item-{cid}",
source="reddit",
title=f"Post {cid}",
body="Body text.",
url=f"https://reddit.com/r/test/comments/{cid}/",
container="test",
published_at="2026-03-15",
date_confidence="high",
engagement={"score": 200, "num_comments": 30},
metadata={
"top_comments": [{"excerpt": "Funny comment", "score": 50, "body": "lmao this is gold"}],
},
)
return schema.Candidate(
candidate_id=cid,
item_id=f"item-{cid}",
source="reddit",
title=f"Post {cid}",
url=f"https://reddit.com/r/test/comments/{cid}/",
snippet="A test snippet.",
subquery_labels=["primary"],
native_ranks={"primary:reddit": 1},
local_relevance=0.9,
freshness=90,
engagement=88,
source_quality=1.0,
rrf_score=0.02,
rerank_score=92,
final_score=final_score,
sources=["reddit"],
source_items=[item],
fun_score=fun_score,
fun_explanation=fun_explanation,
)
def _make_report_with_candidates(self, candidates):
"""Helper: build a report with given candidates."""
items = []
for c in candidates:
items.extend(c.source_items)
cluster = schema.Cluster(
cluster_id="cluster-1",
title="Test cluster",
candidate_ids=[c.candidate_id for c in candidates],
representative_ids=[c.candidate_id for c in candidates],
sources=["reddit"],
score=90,
)
return schema.Report(
topic="test topic",
range_from="2026-02-14",
range_to="2026-03-16",
generated_at="2026-03-16T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="gemini",
planner_model="gemini-3.1-flash-lite-preview",
rerank_model="gemini-3.1-flash-lite-preview",
),
query_plan=schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="test topic",
subqueries=[schema.SubQuery(label="primary", search_query="test", ranking_query="test?", sources=["reddit"])],
source_weights={"reddit": 1.0},
),
clusters=[cluster],
ranked_candidates=candidates,
items_by_source={"reddit": items},
errors_by_source={},
)
def test_compact_includes_best_takes_with_2_high_fun_candidates(self):
"""Compact output includes Best Takes section when 2+ candidates score >= 70."""
candidates = [
self._make_candidate("c1", fun_score=85, fun_explanation="hilarious comment"),
self._make_candidate("c2", fun_score=75, fun_explanation="witty remark"),
self._make_candidate("c3", fun_score=40),
]
report = self._make_report_with_candidates(candidates)
text = render.render_compact(report)
self.assertIn("## Best Takes", text)
self.assertIn("(fun:85)", text)
self.assertIn("(fun:75)", text)
def test_candidate_with_fun_score_85_shows_fun_tag(self):
"""Candidate with fun_score=85 shows 'fun:85' in its detail line."""
candidates = [self._make_candidate("c1", fun_score=85)]
report = self._make_report_with_candidates(candidates)
text = render.render_compact(report)
self.assertIn("fun:85", text)
def test_candidate_with_fun_score_40_no_fun_tag(self):
"""Candidate with fun_score=40 does NOT show fun tag (below 50 threshold)."""
candidates = [self._make_candidate("c1", fun_score=40)]
report = self._make_report_with_candidates(candidates)
text = render.render_compact(report)
self.assertNotIn("fun:40", text)
self.assertNotIn("fun:", text)
def test_no_best_takes_with_0_high_fun_candidates(self):
"""No Best Takes section when 0 candidates above threshold."""
candidates = [
self._make_candidate("c1", fun_score=50),
self._make_candidate("c2", fun_score=40),
]
report = self._make_report_with_candidates(candidates)
text = render.render_compact(report)
self.assertNotIn("## Best Takes", text)
def test_no_best_takes_with_1_high_fun_candidate(self):
"""No Best Takes section when only 1 candidate above threshold."""
candidates = [
self._make_candidate("c1", fun_score=80),
self._make_candidate("c2", fun_score=50),
]
report = self._make_report_with_candidates(candidates)
text = render.render_compact(report)
self.assertNotIn("## Best Takes", text)
if __name__ == "__main__":
unittest.main()
+129
View File
@@ -0,0 +1,129 @@
"""Tests for the fun judge heuristic fallback in rerank.py."""
import sys
from pathlib import Path
import pytest
SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
from lib import schema
from lib.rerank import _apply_single_fun_fallback, _extract_comment_text
def _make_candidate(
title: str = "Some Title",
snippet: str = "",
engagement: float | None = 0.0,
top_comments: list[dict] | None = None,
) -> schema.Candidate:
"""Build a minimal Candidate with optional source_items carrying top_comments."""
source_items = []
if top_comments is not None:
source_items.append(
schema.SourceItem(
item_id="si-1",
source="reddit",
title=title,
body="",
url="https://reddit.com/r/test/1",
metadata={"top_comments": top_comments},
)
)
return schema.Candidate(
candidate_id="c-1",
item_id="i-1",
source="reddit",
title=title,
url="https://reddit.com/r/test/1",
snippet=snippet,
subquery_labels=["q1"],
native_ranks={"reddit": 1},
local_relevance=0.5,
freshness=50,
engagement=engagement,
source_quality=0.5,
rrf_score=0.01,
source_items=source_items,
)
class TestFunFallbackCommentText:
"""Heuristic fallback reads comment text, not just title+snippet."""
def test_comment_with_lmao_gets_marker_bonus(self):
"""A candidate with 'lmao' in a top_comment should get the marker bonus."""
candidate = _make_candidate(
title="Boring press conference recap",
snippet="Coach talked about the game plan.",
top_comments=[{"body": "lmao this is gold"}],
)
_apply_single_fun_fallback(candidate)
# marker_bonus = 10, should be reflected in fun_score
assert candidate.fun_score is not None
assert candidate.fun_score >= 10.0
assert candidate.fun_explanation == "heuristic-fallback"
def test_short_punchy_comment_higher_shortness(self):
"""A candidate with a short punchy comment should score higher shortness
bonus compared to one with a very long title and snippet."""
short_candidate = _make_candidate(
title="Hot dogs",
snippet="",
top_comments=[{"body": "bro what"}],
)
long_candidate = _make_candidate(
title="A very long and detailed analysis of the upcoming season with comprehensive breakdown of every roster move and coaching decision that happened over the past thirty days",
snippet="This extensive report covers all aspects of the team performance including advanced metrics and historical comparisons going back several decades.",
top_comments=[{"body": "bro what"}],
)
_apply_single_fun_fallback(short_candidate)
_apply_single_fun_fallback(long_candidate)
# Both get marker bonus from "bro", but short one gets higher shortness bonus
assert short_candidate.fun_score > long_candidate.fun_score
def test_no_comments_falls_back_to_title_snippet(self):
"""A candidate with no source_items/comments still scores based on title+snippet."""
candidate = _make_candidate(
title="This is hilarious content",
snippet="Very funny stuff",
top_comments=None, # no source_items at all
)
_apply_single_fun_fallback(candidate)
assert candidate.fun_score is not None
assert candidate.fun_score >= 10.0 # marker bonus from "hilarious"
assert candidate.fun_explanation == "heuristic-fallback"
def test_empty_comment_bodies_no_crash(self):
"""Candidates with empty comment bodies should not crash."""
candidate = _make_candidate(
title="Normal title",
snippet="Normal snippet",
top_comments=[
{"body": ""},
{"body": None},
{},
{"body": "actual comment"},
],
)
_apply_single_fun_fallback(candidate)
assert candidate.fun_score is not None
assert candidate.fun_explanation == "heuristic-fallback"
class TestExtractCommentText:
"""Verify _extract_comment_text handles edge cases."""
def test_extracts_from_top_comments(self):
candidate = _make_candidate(
top_comments=[{"body": "first comment"}, {"body": "second comment"}],
)
text = _extract_comment_text(candidate)
assert "first comment" in text
assert "second comment" in text
def test_empty_source_items(self):
candidate = _make_candidate(top_comments=None)
text = _extract_comment_text(candidate)
assert text == ""
+159
View File
@@ -0,0 +1,159 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import rerank, schema
def make_candidate(relevance: float) -> schema.Candidate:
candidate = schema.Candidate(
candidate_id=f"c-{relevance}",
item_id="i1",
source="reddit",
title="Title",
url="https://example.com",
snippet="Snippet",
subquery_labels=["primary"],
native_ranks={"primary:reddit": 1},
local_relevance=0.8,
freshness=80,
engagement=50,
source_quality=0.7,
rrf_score=0.02,
)
candidate.rerank_score = relevance
return candidate
def make_plan() -> schema.QueryPlan:
return schema.QueryPlan(
intent="comparison",
freshness_mode="balanced_recent",
cluster_mode="debate",
raw_topic="openclaw vs nanoclaw",
subqueries=[
schema.SubQuery(
label="primary",
search_query="openclaw vs nanoclaw",
ranking_query="How does openclaw compare to nanoclaw?",
sources=["grounding", "reddit"],
)
],
source_weights={"grounding": 1.0, "reddit": 0.8},
)
class FakeProvider:
def __init__(self, payload):
self.payload = payload
def generate_json(self, model, prompt):
self.model = model
self.prompt = prompt
return self.payload
class RerankV3Tests(unittest.TestCase):
def test_low_rerank_score_is_demoted(self):
low = make_candidate(4.0)
high = make_candidate(40.0)
low_score = rerank._final_score(low)
high_score = rerank._final_score(high)
self.assertLess(low_score, high_score)
self.assertLess(low_score, 20.0)
def test_engagement_boosts_score(self):
"""Items with engagement score higher than those without."""
candidate = make_candidate(80.0)
candidate.engagement = None
score_without = rerank._final_score(candidate)
candidate.engagement = 50
score_with = rerank._final_score(candidate)
self.assertGreater(score_with, score_without)
# Boost is modest, not dominant
self.assertLess(score_with - score_without, 10.0)
def test_build_prompt_includes_source_labels_and_dates(self):
candidate = make_candidate(80.0)
candidate.sources = ["grounding", "reddit"]
candidate.source_items = [
schema.SourceItem(
item_id="i1",
source="grounding",
title="Title",
body="Body",
url="https://example.com",
published_at="2026-03-16",
)
]
prompt = rerank._build_prompt("topic", make_plan(), [candidate])
self.assertIn("sources: grounding, reddit", prompt)
self.assertIn("date: 2026-03-16", prompt)
self.assertIn("How does openclaw compare to nanoclaw?", prompt)
def test_apply_llm_scores_ignores_invalid_rows_and_clamps_scores(self):
candidate = make_candidate(0.0)
rerank._apply_llm_scores(
[candidate],
{
"scores": [
"bad-row",
{"candidate_id": "", "relevance": 99},
{"candidate_id": candidate.candidate_id, "relevance": 101, "reason": " best hit "},
]
},
)
self.assertEqual(100.0, candidate.rerank_score)
self.assertEqual("best hit", candidate.explanation)
self.assertGreater(candidate.final_score, 0.0)
def test_build_prompt_includes_comparison_intent_hint(self):
plan = make_plan() # intent="comparison"
candidate = make_candidate(80.0)
prompt = rerank._build_prompt("openclaw vs nanoclaw", plan, [candidate])
self.assertIn("Intent-specific guidance (comparison)", prompt)
self.assertIn("head-to-head", prompt.lower())
def test_build_prompt_includes_factual_intent_hint(self):
plan = make_plan()
plan.intent = "factual"
candidate = make_candidate(80.0)
prompt = rerank._build_prompt("latest GDP numbers", plan, [candidate])
self.assertTrue(
"facts" in prompt.lower() or "primary sources" in prompt.lower(),
"factual intent hint should mention facts or primary sources",
)
def test_build_prompt_no_hint_for_unknown_intent(self):
plan = make_plan()
plan.intent = "unknown_intent_xyz"
candidate = make_candidate(80.0)
prompt = rerank._build_prompt("some topic", plan, [candidate])
self.assertNotIn("Intent-specific guidance", prompt)
def test_rerank_candidates_uses_provider_for_shortlist_and_fallback_for_tail(self):
first = make_candidate(0.0)
second = make_candidate(0.0)
second.candidate_id = "tail"
provider = FakeProvider(
{"scores": [{"candidate_id": first.candidate_id, "relevance": 95, "reason": "high fit"}]}
)
ranked = rerank.rerank_candidates(
topic="openclaw vs nanoclaw",
plan=make_plan(),
candidates=[first, second],
provider=provider,
model="gemini-3.1-flash-lite-preview",
shortlist_size=1,
)
self.assertEqual("gemini-3.1-flash-lite-preview", provider.model)
self.assertEqual(95.0, first.rerank_score)
self.assertEqual("high fit", first.explanation)
self.assertEqual("fallback-local-score", second.explanation)
self.assertEqual(first.candidate_id, ranked[0].candidate_id)
if __name__ == "__main__":
unittest.main()
+175
View File
@@ -0,0 +1,175 @@
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import resolve
class TestHasBackend(unittest.TestCase):
def test_no_keys_returns_false(self):
self.assertFalse(resolve._has_backend({}))
def test_brave_key_returns_true(self):
self.assertTrue(resolve._has_backend({"BRAVE_API_KEY": "key"}))
def test_exa_key_returns_true(self):
self.assertTrue(resolve._has_backend({"EXA_API_KEY": "key"}))
def test_serper_key_returns_true(self):
self.assertTrue(resolve._has_backend({"SERPER_API_KEY": "key"}))
class TestExtractSubreddits(unittest.TestCase):
def test_extracts_from_title_and_snippet(self):
items = [
{"title": "Check out r/MachineLearning", "snippet": "Also r/artificial", "url": ""},
{"title": "More at r/datascience", "snippet": "", "url": ""},
]
result = resolve._extract_subreddits(items)
self.assertEqual(result, ["MachineLearning", "artificial", "datascience"])
def test_extracts_from_url(self):
items = [
{"title": "Discussion", "snippet": "", "url": "https://reddit.com/r/python/comments/123"},
]
result = resolve._extract_subreddits(items)
self.assertEqual(result, ["python"])
def test_deduplicates_case_insensitive(self):
items = [
{"title": "r/Python", "snippet": "r/python is great", "url": ""},
]
result = resolve._extract_subreddits(items)
self.assertEqual(len(result), 1)
def test_empty_items_returns_empty(self):
self.assertEqual(resolve._extract_subreddits([]), [])
def test_no_subreddits_in_text(self):
items = [{"title": "No subreddits here", "snippet": "Just text", "url": ""}]
self.assertEqual(resolve._extract_subreddits(items), [])
class TestExtractXHandle(unittest.TestCase):
def test_extracts_from_url(self):
items = [
{"title": "OpenAI on X", "snippet": "Updates from @OpenAI", "url": "https://x.com/OpenAI"},
]
result = resolve._extract_x_handle(items)
self.assertEqual(result, "openai")
def test_extracts_from_text(self):
items = [
{"title": "Follow @elonmusk", "snippet": "Also @elonmusk tweeted", "url": ""},
]
result = resolve._extract_x_handle(items)
self.assertEqual(result, "elonmusk")
def test_filters_generic_handles(self):
items = [
{"title": "Go to @twitter", "snippet": "Visit @x", "url": ""},
]
result = resolve._extract_x_handle(items)
self.assertEqual(result, "")
def test_empty_items_returns_empty(self):
self.assertEqual(resolve._extract_x_handle([]), "")
class TestBuildContextSummary(unittest.TestCase):
def test_builds_from_snippets(self):
items = [
{"snippet": "First news item about topic."},
{"snippet": "Second news item with details."},
{"snippet": "Third item ignored."},
]
result = resolve._build_context_summary(items)
self.assertIn("First news item", result)
self.assertIn("Second news item", result)
# Only first 2 snippets used
self.assertNotIn("Third item", result)
def test_truncates_long_text(self):
items = [{"snippet": "A" * 200}, {"snippet": "B" * 200}]
result = resolve._build_context_summary(items)
self.assertLessEqual(len(result), 300)
self.assertTrue(result.endswith("..."))
def test_empty_items_returns_empty(self):
self.assertEqual(resolve._build_context_summary([]), "")
def test_items_with_empty_snippets(self):
items = [{"snippet": ""}, {"snippet": ""}]
self.assertEqual(resolve._build_context_summary(items), "")
class TestAutoResolve(unittest.TestCase):
def test_no_backend_returns_empty(self):
result = resolve.auto_resolve("test topic", {})
self.assertEqual(result["subreddits"], [])
self.assertEqual(result["x_handle"], "")
self.assertEqual(result["context"], "")
self.assertEqual(result["searches_run"], 0)
@patch("lib.resolve.grounding.web_search")
def test_full_resolve(self, mock_search):
def side_effect(query, date_range, config):
if "subreddit" in query:
return [
{"title": "r/technology discussion", "snippet": "Also r/gadgets", "url": ""},
], {"label": "brave"}
if "news" in query:
return [
{"snippet": "Major tech breakthrough announced this week."},
], {"label": "brave"}
if "handle" in query:
return [
{"title": "TechCo on X", "snippet": "@TechCo", "url": "https://x.com/TechCo"},
], {"label": "brave"}
return [], {}
mock_search.side_effect = side_effect
result = resolve.auto_resolve("tech", {"BRAVE_API_KEY": "fake"})
self.assertEqual(result["subreddits"], ["technology", "gadgets"])
self.assertEqual(result["x_handle"], "techco")
self.assertIn("breakthrough", result["context"])
self.assertEqual(result["searches_run"], 3)
self.assertEqual(mock_search.call_count, 3)
@patch("lib.resolve.grounding.web_search")
def test_search_failure_graceful(self, mock_search):
mock_search.side_effect = RuntimeError("API error")
result = resolve.auto_resolve("test", {"BRAVE_API_KEY": "fake"})
self.assertEqual(result["subreddits"], [])
self.assertEqual(result["x_handle"], "")
self.assertEqual(result["context"], "")
self.assertEqual(result["searches_run"], 0)
@patch("lib.resolve.grounding.web_search")
def test_partial_failure(self, mock_search):
call_count = 0
def side_effect(query, date_range, config):
nonlocal call_count
call_count += 1
if "subreddit" in query:
return [{"title": "r/cooking tips", "snippet": "", "url": ""}], {}
if "news" in query:
raise RuntimeError("Timeout")
return [], {}
mock_search.side_effect = side_effect
result = resolve.auto_resolve("cooking", {"EXA_API_KEY": "fake"})
self.assertEqual(result["subreddits"], ["cooking"])
# News search failed, so context is empty
self.assertEqual(result["context"], "")
# 2 out of 3 succeeded
self.assertEqual(result["searches_run"], 2)
if __name__ == "__main__":
unittest.main()
-142
View File
@@ -1,142 +0,0 @@
"""Tests for schema.py — data class serialization roundtrips."""
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import schema
class TestEngagement(unittest.TestCase):
"""Tests for Engagement.to_dict()."""
def test_sparse_fields(self):
eng = schema.Engagement(score=100, num_comments=50)
d = eng.to_dict()
self.assertEqual(d, {"score": 100, "num_comments": 50})
self.assertNotIn("likes", d)
def test_all_none_returns_none(self):
eng = schema.Engagement()
self.assertIsNone(eng.to_dict())
def test_all_fields(self):
eng = schema.Engagement(
score=1, num_comments=2, upvote_ratio=0.9,
likes=3, reposts=4, replies=5, quotes=6,
views=7, shares=8, volume=9.0, liquidity=10.0,
)
d = eng.to_dict()
self.assertEqual(len(d), 11)
class TestComment(unittest.TestCase):
def test_basic(self):
c = schema.Comment(score=50, date="2026-03-01", author="user", excerpt="text", url="http://x")
d = c.to_dict()
self.assertEqual(d["score"], 50)
self.assertEqual(d["author"], "user")
self.assertEqual(len(d), 5)
class TestRedditItem(unittest.TestCase):
def test_roundtrip(self):
item = schema.RedditItem(
id="R1", title="Test", url="http://reddit.com/r/test",
subreddit="test", date="2026-03-01",
engagement=schema.Engagement(score=100),
)
d = item.to_dict()
self.assertEqual(d["id"], "R1")
self.assertEqual(d["subreddit"], "test")
self.assertEqual(d["engagement"], {"score": 100})
self.assertNotIn("cross_refs", d)
def test_cross_refs_included_when_present(self):
item = schema.RedditItem(
id="R1", title="T", url="u", subreddit="s",
cross_refs=["X1", "HN2"],
)
d = item.to_dict()
self.assertEqual(d["cross_refs"], ["X1", "HN2"])
class TestXItem(unittest.TestCase):
def test_roundtrip(self):
item = schema.XItem(
id="X1", text="tweet", url="http://x.com/1",
author_handle="user", date="2026-03-01",
)
d = item.to_dict()
self.assertEqual(d["id"], "X1")
self.assertEqual(d["author_handle"], "user")
self.assertNotIn("cross_refs", d)
class TestYouTubeItem(unittest.TestCase):
def test_roundtrip(self):
item = schema.YouTubeItem(
id="YT1", title="Video", url="http://youtube.com/1",
channel_name="chan",
)
d = item.to_dict()
self.assertEqual(d["channel_name"], "chan")
self.assertEqual(d["date_confidence"], "high")
class TestTikTokItem(unittest.TestCase):
def test_roundtrip(self):
item = schema.TikTokItem(
id="TK1", text="caption", url="http://tiktok.com/1",
author_name="creator", hashtags=["ai", "code"],
)
d = item.to_dict()
self.assertEqual(d["hashtags"], ["ai", "code"])
self.assertEqual(d["author_name"], "creator")
class TestInstagramItem(unittest.TestCase):
def test_roundtrip(self):
item = schema.InstagramItem(
id="IG1", text="caption", url="http://instagram.com/reel/1",
author_name="creator",
)
d = item.to_dict()
self.assertEqual(d["id"], "IG1")
class TestWebSearchItem(unittest.TestCase):
def test_roundtrip(self):
item = schema.WebSearchItem(
id="W1", title="Article", url="http://example.com",
source_domain="example.com", snippet="text",
)
d = item.to_dict()
self.assertEqual(d["source_domain"], "example.com")
class TestHackerNewsItem(unittest.TestCase):
def test_roundtrip(self):
item = schema.HackerNewsItem(
id="HN1", title="Show HN", url="http://example.com",
hn_url="http://news.ycombinator.com/item?id=1", author="pg",
)
d = item.to_dict()
self.assertTrue(d["hn_url"].startswith("http://news.ycombinator.com"))
class TestPolymarketItem(unittest.TestCase):
def test_roundtrip(self):
item = schema.PolymarketItem(
id="PM1", title="Election", question="Who wins?",
url="http://polymarket.com/1",
)
d = item.to_dict()
self.assertEqual(d["question"], "Who wins?")
if __name__ == "__main__":
unittest.main()
+89
View File
@@ -0,0 +1,89 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import schema
class SchemaV3Tests(unittest.TestCase):
def test_report_roundtrip(self):
report = schema.Report(
topic="test topic",
range_from="2026-02-14",
range_to="2026-03-16",
generated_at="2026-03-16T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="gemini",
planner_model="gemini-3.1-flash-lite-preview",
rerank_model="gemini-3.1-flash-lite-preview",
),
query_plan=schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="test topic",
subqueries=[schema.SubQuery(label="primary", search_query="test topic", ranking_query="What happened with test topic?", sources=["grounding"])],
source_weights={"grounding": 1.0},
),
clusters=[schema.Cluster(cluster_id="cluster-1", title="Title", candidate_ids=["c1"], representative_ids=["c1"], sources=["grounding"], score=90)],
ranked_candidates=[schema.Candidate(
candidate_id="c1",
item_id="i1",
source="grounding",
sources=["grounding", "reddit"],
title="Title",
url="https://example.com",
snippet="Snippet",
subquery_labels=["primary"],
native_ranks={"primary:grounding": 1},
local_relevance=0.8,
freshness=90,
engagement=None,
source_quality=1.0,
rrf_score=0.02,
rerank_score=91,
final_score=90,
source_items=[
schema.SourceItem(item_id="i1", source="grounding", title="Title", body="Body", url="https://example.com", published_at="2026-03-16")
],
)],
items_by_source={"grounding": [schema.SourceItem(item_id="i1", source="grounding", title="Title", body="Body", url="https://example.com")]},
errors_by_source={},
warnings=["warning"],
artifacts={"grounding": []},
)
restored = schema.report_from_dict(schema.to_dict(report))
self.assertEqual(report.topic, restored.topic)
self.assertEqual(report.provider_runtime.planner_model, restored.provider_runtime.planner_model)
self.assertEqual(report.ranked_candidates[0].candidate_id, restored.ranked_candidates[0].candidate_id)
self.assertEqual(report.ranked_candidates[0].sources, restored.ranked_candidates[0].sources)
self.assertEqual(report.items_by_source["grounding"][0].title, restored.items_by_source["grounding"][0].title)
def test_source_item_from_dict_preserves_zero_valued_signals(self):
item = schema.source_item_from_dict(
{
"item_id": "x1",
"source": "x",
"title": "Title",
"body": "Body",
"url": "https://example.com",
"relevance_hint": 0.0,
"local_relevance": 0.0,
"freshness": 0,
"engagement_score": 0,
"source_quality": 0.0,
"local_rank_score": 0.0,
}
)
self.assertEqual(0.0, item.relevance_hint)
self.assertEqual(0.0, item.local_relevance)
self.assertEqual(0, item.freshness)
self.assertEqual(0, item.engagement_score)
self.assertEqual(0.0, item.source_quality)
self.assertEqual(0.0, item.local_rank_score)
if __name__ == "__main__":
unittest.main()
-351
View File
@@ -1,351 +0,0 @@
"""Tests for score module."""
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import schema, score
class TestLog1pSafe(unittest.TestCase):
def test_positive_value(self):
result = score.log1p_safe(100)
self.assertGreater(result, 0)
def test_zero(self):
result = score.log1p_safe(0)
self.assertEqual(result, 0)
def test_none(self):
result = score.log1p_safe(None)
self.assertEqual(result, 0)
def test_negative(self):
result = score.log1p_safe(-5)
self.assertEqual(result, 0)
class TestComputeRedditEngagementRaw(unittest.TestCase):
def test_with_engagement(self):
eng = schema.Engagement(score=100, num_comments=50, upvote_ratio=0.9)
result = score.compute_reddit_engagement_raw(eng)
self.assertIsNotNone(result)
self.assertGreater(result, 0)
def test_without_engagement(self):
result = score.compute_reddit_engagement_raw(None)
self.assertIsNone(result)
def test_empty_engagement(self):
eng = schema.Engagement()
result = score.compute_reddit_engagement_raw(eng)
self.assertIsNone(result)
class TestComputeXEngagementRaw(unittest.TestCase):
def test_with_engagement(self):
eng = schema.Engagement(likes=100, reposts=25, replies=15, quotes=5)
result = score.compute_x_engagement_raw(eng)
self.assertIsNotNone(result)
self.assertGreater(result, 0)
def test_without_engagement(self):
result = score.compute_x_engagement_raw(None)
self.assertIsNone(result)
class TestNormalizeTo100(unittest.TestCase):
def test_normalizes_values(self):
values = [0, 50, 100]
result = score.normalize_to_100(values)
self.assertEqual(result[0], 0)
self.assertEqual(result[1], 50)
self.assertEqual(result[2], 100)
def test_handles_none(self):
values = [0, None, 100]
result = score.normalize_to_100(values)
self.assertIsNone(result[1])
def test_single_value(self):
values = [50]
result = score.normalize_to_100(values)
self.assertEqual(result[0], 50)
class TestScoreRedditItems(unittest.TestCase):
def test_scores_items(self):
today = datetime.now(timezone.utc).date().isoformat()
items = [
schema.RedditItem(
id="R1",
title="Test",
url="https://reddit.com/r/test/1",
subreddit="test",
date=today,
date_confidence="high",
engagement=schema.Engagement(score=100, num_comments=50, upvote_ratio=0.9),
relevance=0.9,
),
schema.RedditItem(
id="R2",
title="Test 2",
url="https://reddit.com/r/test/2",
subreddit="test",
date=today,
date_confidence="high",
engagement=schema.Engagement(score=10, num_comments=5, upvote_ratio=0.8),
relevance=0.5,
),
]
result = score.score_reddit_items(items)
self.assertEqual(len(result), 2)
self.assertGreater(result[0].score, 0)
self.assertGreater(result[1].score, 0)
# Higher relevance and engagement should score higher
self.assertGreater(result[0].score, result[1].score)
def test_empty_list(self):
result = score.score_reddit_items([])
self.assertEqual(result, [])
class TestScoreXItems(unittest.TestCase):
def test_scores_items(self):
today = datetime.now(timezone.utc).date().isoformat()
items = [
schema.XItem(
id="X1",
text="Test post",
url="https://x.com/user/1",
author_handle="user1",
date=today,
date_confidence="high",
engagement=schema.Engagement(likes=100, reposts=25, replies=15, quotes=5),
relevance=0.9,
),
]
result = score.score_x_items(items)
self.assertEqual(len(result), 1)
self.assertGreater(result[0].score, 0)
class TestSortItems(unittest.TestCase):
def test_sorts_by_score_descending(self):
items = [
schema.RedditItem(id="R1", title="Low", url="", subreddit="", score=30),
schema.RedditItem(id="R2", title="High", url="", subreddit="", score=90),
schema.RedditItem(id="R3", title="Mid", url="", subreddit="", score=60),
]
result = score.sort_items(items)
self.assertEqual(result[0].id, "R2")
self.assertEqual(result[1].id, "R3")
self.assertEqual(result[2].id, "R1")
def test_stable_sort(self):
items = [
schema.RedditItem(id="R1", title="A", url="", subreddit="", score=50),
schema.RedditItem(id="R2", title="B", url="", subreddit="", score=50),
]
result = score.sort_items(items)
# Both have same score, should maintain order by title
self.assertEqual(len(result), 2)
class TestCommentQualityWeight(unittest.TestCase):
"""Test that top comment score boosts Reddit engagement."""
def test_comment_boosts_score(self):
eng = schema.Engagement(score=100, num_comments=50, upvote_ratio=0.9)
without_comment = score.compute_reddit_engagement_raw(eng, top_comment_score=None)
with_comment = score.compute_reddit_engagement_raw(eng, top_comment_score=500)
self.assertGreater(with_comment, without_comment)
class TestInstagramEngagement(unittest.TestCase):
"""Tests for compute_instagram_engagement_raw()."""
def test_basic(self):
eng = schema.Engagement(views=10000, likes=500, num_comments=50)
raw = score.compute_instagram_engagement_raw(eng)
self.assertIsNotNone(raw)
self.assertGreater(raw, 0)
def test_views_dominate(self):
views_only = schema.Engagement(views=10000)
likes_only = schema.Engagement(likes=10000)
self.assertGreater(
score.compute_instagram_engagement_raw(views_only),
score.compute_instagram_engagement_raw(likes_only),
)
class TestBlueskyEngagement(unittest.TestCase):
"""Tests for compute_bluesky_engagement_raw()."""
def test_basic(self):
eng = schema.Engagement(likes=100, reposts=25, replies=15, quotes=5)
raw = score.compute_bluesky_engagement_raw(eng)
self.assertIsNotNone(raw)
self.assertGreater(raw, 0)
def test_likes_dominate(self):
likes_heavy = schema.Engagement(likes=1000, reposts=0, replies=0, quotes=0)
reposts_heavy = schema.Engagement(likes=0, reposts=1000, replies=0, quotes=0)
self.assertGreater(
score.compute_bluesky_engagement_raw(likes_heavy),
score.compute_bluesky_engagement_raw(reposts_heavy),
)
def test_none_engagement(self):
self.assertIsNone(score.compute_bluesky_engagement_raw(None))
def test_no_likes_no_reposts(self):
eng = schema.Engagement(replies=10)
self.assertIsNone(score.compute_bluesky_engagement_raw(eng))
class TestTruthSocialEngagement(unittest.TestCase):
"""Tests for compute_truthsocial_engagement_raw()."""
def test_basic(self):
eng = schema.Engagement(likes=100, reposts=25, replies=15)
raw = score.compute_truthsocial_engagement_raw(eng)
self.assertIsNotNone(raw)
self.assertGreater(raw, 0)
def test_likes_dominate(self):
likes_heavy = schema.Engagement(likes=1000, reposts=0, replies=0)
reposts_heavy = schema.Engagement(likes=0, reposts=1000, replies=0)
self.assertGreater(
score.compute_truthsocial_engagement_raw(likes_heavy),
score.compute_truthsocial_engagement_raw(reposts_heavy),
)
def test_none_engagement(self):
self.assertIsNone(score.compute_truthsocial_engagement_raw(None))
class TestScoreBlueskyItems(unittest.TestCase):
"""Tests for score_bluesky_items()."""
def test_scores_items(self):
items = [
schema.BlueskyItem(
id="bsky1", text="Test", url="https://bsky.app/1",
author_handle="user.bsky.social", display_name="User",
engagement=schema.Engagement(likes=50, reposts=10, replies=5, quotes=2),
relevance=0.8,
),
]
result = score.score_bluesky_items(items)
self.assertEqual(len(result), 1)
self.assertGreater(result[0].score, 0)
def test_empty_list(self):
self.assertEqual(score.score_bluesky_items([]), [])
class TestScoreTruthSocialItems(unittest.TestCase):
"""Tests for score_truthsocial_items()."""
def test_scores_items(self):
items = [
schema.TruthSocialItem(
id="ts1", text="Test", url="https://truthsocial.com/1",
author_handle="@user", display_name="User",
engagement=schema.Engagement(likes=50, reposts=10, replies=5),
relevance=0.8,
),
]
result = score.score_truthsocial_items(items)
self.assertEqual(len(result), 1)
self.assertGreater(result[0].score, 0)
def test_empty_list(self):
self.assertEqual(score.score_truthsocial_items([]), [])
class TestSortItemsMixedSources(unittest.TestCase):
"""Test sort_items with Bluesky and TruthSocial items."""
def test_bluesky_item_sorts(self):
items = [
schema.RedditItem(id="R1", title="Reddit", url="", subreddit="", score=30),
schema.BlueskyItem(id="B1", text="Bluesky", url="", author_handle="u.bsky.social", display_name="U", score=90),
]
result = score.sort_items(items)
self.assertEqual(result[0].id, "B1")
def test_truthsocial_item_sorts(self):
items = [
schema.RedditItem(id="R1", title="Reddit", url="", subreddit="", score=30),
schema.TruthSocialItem(id="T1", text="TS", url="", author_handle="@u", display_name="U", score=90),
]
result = score.sort_items(items)
self.assertEqual(result[0].id, "T1")
class TestRelevanceFilter(unittest.TestCase):
"""Tests for relevance_filter()."""
def _make_items(self, relevances):
"""Helper: create RedditItems with given relevance values."""
return [
schema.RedditItem(id=f"R{i}", title=f"Item {i}", url="", subreddit="", relevance=r)
for i, r in enumerate(relevances)
]
def test_filters_below_threshold(self):
items = self._make_items([0.8, 0.1, 0.5, 0.2])
result = score.relevance_filter(items, "TEST", threshold=0.3)
self.assertEqual(len(result), 2)
self.assertTrue(all(i.relevance >= 0.3 for i in result))
def test_small_list_unchanged(self):
items = self._make_items([0.1, 0.05, 0.02])
result = score.relevance_filter(items, "TEST")
self.assertEqual(len(result), 3)
def test_all_below_threshold_keeps_top_3(self):
items = self._make_items([0.1, 0.25, 0.05, 0.2, 0.15])
result = score.relevance_filter(items, "TEST", threshold=0.3)
self.assertEqual(len(result), 3)
# Should be sorted by relevance: 0.25, 0.2, 0.15
self.assertEqual(result[0].relevance, 0.25)
self.assertEqual(result[1].relevance, 0.2)
def test_empty_list(self):
result = score.relevance_filter([], "TEST")
self.assertEqual(result, [])
def test_items_without_relevance_attr_treated_as_zero(self):
"""Objects lacking a relevance attribute get 0.0, failing the filter."""
class BareItem:
def __init__(self, id):
self.id = id
items = [
schema.RedditItem(id="R0", title="Has relevance", url="", subreddit="", relevance=0.8),
BareItem("B1"),
BareItem("B2"),
BareItem("B3"),
]
result = score.relevance_filter(items, "TEST", threshold=0.3)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].id, "R0")
if __name__ == "__main__":
unittest.main()
-114
View File
@@ -1,114 +0,0 @@
"""Tests for scrapecreators_x module."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import scrapecreators_x
from lib.relevance import tokenize as _tokenize
class TestTokenize(unittest.TestCase):
def test_lowercases(self):
tokens = _tokenize("Claude AI")
self.assertIn("claude", tokens)
def test_strips_stopwords(self):
tokens = _tokenize("the best AI tool")
self.assertNotIn("the", tokens)
self.assertIn("best", tokens) # 'best' is not a stopword in tokenizer
def test_removes_single_char(self):
tokens = _tokenize("a b cd ef")
self.assertNotIn("a", tokens)
self.assertNotIn("b", tokens)
self.assertIn("cd", tokens)
def test_expands_synonyms(self):
tokens = _tokenize("ai research")
self.assertIn("artificial", tokens)
self.assertIn("intelligence", tokens)
class TestComputeRelevance(unittest.TestCase):
def test_exact_match_high(self):
score = scrapecreators_x._compute_relevance("claude code", "claude code is amazing")
self.assertGreaterEqual(score, 0.8)
def test_no_match_low(self):
score = scrapecreators_x._compute_relevance("claude code", "pizza recipes today")
self.assertLessEqual(score, 0.2)
def test_empty_query_returns_neutral(self):
score = scrapecreators_x._compute_relevance("", "some text")
self.assertEqual(score, 0.5)
def test_no_match_returns_zero(self):
score = scrapecreators_x._compute_relevance("abcdef ghijkl", "xyz")
self.assertEqual(score, 0.0)
class TestExtractCoreSubject(unittest.TestCase):
def test_strips_prefix(self):
result = scrapecreators_x._extract_core_subject("what are people saying about claude")
self.assertEqual(result, "claude")
def test_strips_noise(self):
result = scrapecreators_x._extract_core_subject("latest trending news claude code")
self.assertNotIn("latest", result)
self.assertNotIn("trending", result)
self.assertIn("claude", result)
def test_preserves_core(self):
result = scrapecreators_x._extract_core_subject("react native")
self.assertEqual(result, "react native")
class TestParseDate(unittest.TestCase):
def test_twitter_format(self):
item = {"created_at": "Wed Oct 10 20:19:24 +0000 2018"}
self.assertEqual(scrapecreators_x._parse_date(item), "2018-10-10")
def test_unix_timestamp(self):
item = {"timestamp": 1705363200}
self.assertEqual(scrapecreators_x._parse_date(item), "2024-01-16")
def test_iso_format(self):
item = {"created_at": "2024-06-15T12:00:00Z"}
self.assertEqual(scrapecreators_x._parse_date(item), "2024-06-15")
def test_none_returns_none(self):
self.assertIsNone(scrapecreators_x._parse_date({}))
class TestSearchX(unittest.TestCase):
def test_no_token_returns_error(self):
result = scrapecreators_x.search_x("test", "2024-01-01", "2024-12-31")
self.assertEqual(result["items"], [])
self.assertIn("No SCRAPECREATORS_API_KEY", result["error"])
def test_parse_x_response(self):
response = {"items": [{"id": "1", "text": "hello"}]}
items = scrapecreators_x.parse_x_response(response)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["text"], "hello")
def test_parse_empty_response(self):
items = scrapecreators_x.parse_x_response({})
self.assertEqual(items, [])
class TestDepthConfig(unittest.TestCase):
def test_all_depths_exist(self):
for depth in ("quick", "default", "deep"):
self.assertIn(depth, scrapecreators_x.DEPTH_CONFIG)
def test_deep_has_more_results(self):
quick = scrapecreators_x.DEPTH_CONFIG["quick"]["results_per_page"]
deep = scrapecreators_x.DEPTH_CONFIG["deep"]["results_per_page"]
self.assertGreater(deep, quick)
if __name__ == "__main__":
unittest.main()
+562
View File
@@ -0,0 +1,562 @@
"""Tests for OpenClaw setup and device auth functions."""
import json
import sys
import time
from pathlib import Path
from unittest.mock import patch, MagicMock, call
import pytest
# Add scripts dir to path
SCRIPTS_DIR = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
from lib import setup_wizard
class TestRunOpenclawSetup:
"""Tests for run_openclaw_setup()."""
@patch("shutil.which")
def test_all_tools_present_no_keys(self, mock_which):
"""All CLI tools found, no API keys configured."""
mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}"
config = {}
result = setup_wizard.run_openclaw_setup(config)
assert result["yt_dlp"] is True
assert result["node"] is True
assert result["python3"] is True
assert all(v is False for v in result["keys"].values())
assert result["x_method"] is None
@patch("shutil.which")
def test_missing_tools(self, mock_which):
"""Some CLI tools missing."""
def which_side(cmd):
if cmd == "node":
return None
return f"/usr/bin/{cmd}"
mock_which.side_effect = which_side
config = {}
result = setup_wizard.run_openclaw_setup(config)
assert result["yt_dlp"] is True
assert result["node"] is False
assert result["python3"] is True
@patch("shutil.which")
def test_keys_detected(self, mock_which):
"""API keys in config are reported as present."""
mock_which.return_value = None
config = {
"XAI_API_KEY": "xai-abc123",
"BRAVE_API_KEY": "brav-xyz",
"SCRAPECREATORS_API_KEY": "", # empty = falsy
}
result = setup_wizard.run_openclaw_setup(config)
assert result["keys"]["xai"] is True
assert result["keys"]["brave"] is True
assert result["keys"]["scrapecreators"] is False
@patch("shutil.which")
def test_x_method_xai(self, mock_which):
"""x_method is 'xai' when XAI_API_KEY is set."""
mock_which.return_value = None
config = {"XAI_API_KEY": "xai-key"}
result = setup_wizard.run_openclaw_setup(config)
assert result["x_method"] == "xai"
@patch("shutil.which")
def test_x_method_cookies(self, mock_which):
"""x_method is 'cookies' when AUTH_TOKEN + CT0 are set."""
mock_which.return_value = None
config = {"AUTH_TOKEN": "tok", "CT0": "ct0val"}
result = setup_wizard.run_openclaw_setup(config)
assert result["x_method"] == "cookies"
@patch("shutil.which")
def test_x_method_xai_over_cookies(self, mock_which):
"""XAI takes priority over cookies for x_method."""
mock_which.return_value = None
config = {"XAI_API_KEY": "xai-key", "AUTH_TOKEN": "tok", "CT0": "ct0val"}
result = setup_wizard.run_openclaw_setup(config)
assert result["x_method"] == "xai"
@patch("shutil.which")
def test_x_method_null_when_nothing(self, mock_which):
"""x_method is None when no X access configured."""
mock_which.return_value = None
config = {}
result = setup_wizard.run_openclaw_setup(config)
assert result["x_method"] is None
@patch("shutil.which")
def test_output_is_json_serializable(self, mock_which):
"""Result can be serialized to JSON without errors."""
mock_which.return_value = "/usr/bin/something"
config = {"XAI_API_KEY": "k", "OPENAI_API_KEY": "ok"}
result = setup_wizard.run_openclaw_setup(config)
serialized = json.dumps(result)
parsed = json.loads(serialized)
assert parsed["yt_dlp"] is True
assert parsed["keys"]["xai"] is True
class TestRunDeviceAuth:
"""Tests for run_device_auth()."""
@patch("lib.setup_wizard.urlopen")
def test_success(self, mock_urlopen):
"""Successful device code request returns tuple."""
resp_data = {
"device_code": "dc-123",
"user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"interval": 5,
}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(resp_data).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
result = setup_wizard.run_device_auth()
assert result is not None
device_code, user_code, verification_uri, interval = result
assert device_code == "dc-123"
assert user_code == "ABCD-1234"
assert verification_uri == "https://github.com/login/device"
assert interval == 5
@patch("lib.setup_wizard.urlopen")
def test_http_error_returns_none(self, mock_urlopen):
"""HTTP error during code request returns None."""
from urllib.error import HTTPError
mock_urlopen.side_effect = HTTPError(
"https://example.com", 500, "Server Error", {}, None
)
result = setup_wizard.run_device_auth()
assert result is None
@patch("lib.setup_wizard.urlopen")
def test_missing_device_code_returns_none(self, mock_urlopen):
"""Incomplete response (no device_code) returns None."""
resp_data = {"user_code": "ABCD-1234"}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(resp_data).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
result = setup_wizard.run_device_auth()
assert result is None
class TestPollDeviceAuth:
"""Tests for poll_device_auth()."""
@patch("lib.setup_wizard.time")
@patch("lib.setup_wizard.urlopen")
def test_success_on_second_poll(self, mock_urlopen, mock_time):
"""Returns access_token after initial pending then success."""
# First call: time check (within deadline), second: after sleep, etc.
mock_time.time = MagicMock(side_effect=[0, 0, 0, 0])
mock_time.sleep = MagicMock()
pending_resp = MagicMock()
pending_resp.read.return_value = json.dumps({"error": "authorization_pending"}).encode()
pending_resp.__enter__ = lambda s: s
pending_resp.__exit__ = MagicMock(return_value=False)
success_resp = MagicMock()
success_resp.read.return_value = json.dumps({"access_token": "gho_abc123"}).encode()
success_resp.__enter__ = lambda s: s
success_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.side_effect = [pending_resp, success_resp]
result = setup_wizard.poll_device_auth("dc-123", interval=1, timeout=300)
assert result == "gho_abc123"
@patch("lib.setup_wizard.time")
@patch("lib.setup_wizard.urlopen")
def test_timeout_returns_none(self, mock_urlopen, mock_time):
"""Returns None when timeout is exceeded."""
# Simulate time passing beyond deadline
mock_time.time = MagicMock(side_effect=[0, 301])
mock_time.sleep = MagicMock()
result = setup_wizard.poll_device_auth("dc-123", interval=5, timeout=300)
assert result is None
@patch("lib.setup_wizard.time")
@patch("lib.setup_wizard.urlopen")
def test_expired_token_returns_none(self, mock_urlopen, mock_time):
"""Returns None on expired_token error."""
mock_time.time = MagicMock(side_effect=[0, 0])
mock_time.sleep = MagicMock()
expired_resp = MagicMock()
expired_resp.read.return_value = json.dumps({"error": "expired_token"}).encode()
expired_resp.__enter__ = lambda s: s
expired_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = expired_resp
result = setup_wizard.poll_device_auth("dc-123", interval=1, timeout=300)
assert result is None
@patch("lib.setup_wizard.time")
@patch("lib.setup_wizard.urlopen")
def test_http_400_continues_polling(self, mock_urlopen, mock_time):
"""HTTP 400 during polling continues (authorization pending)."""
from urllib.error import HTTPError
mock_time.time = MagicMock(side_effect=[0, 0, 0])
mock_time.sleep = MagicMock()
success_resp = MagicMock()
success_resp.read.return_value = json.dumps({"access_token": "gho_ok"}).encode()
success_resp.__enter__ = lambda s: s
success_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.side_effect = [
HTTPError("url", 400, "Bad Request", {}, None),
success_resp,
]
result = setup_wizard.poll_device_auth("dc-123", interval=1, timeout=300)
assert result == "gho_ok"
class TestFetchApiKey:
"""Tests for fetch_api_key()."""
@patch("lib.setup_wizard.urlopen")
def test_success(self, mock_urlopen):
"""Returns api_key from profile response."""
resp_data = {"api_key": "sc-key-abc123", "username": "testuser"}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(resp_data).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
result = setup_wizard.fetch_api_key("gho_token")
assert result == "sc-key-abc123"
@patch("lib.setup_wizard.urlopen")
def test_no_api_key_in_response(self, mock_urlopen):
"""Returns None when api_key is not in the response."""
resp_data = {"username": "testuser"}
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(resp_data).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
result = setup_wizard.fetch_api_key("gho_token")
assert result is None
@patch("lib.setup_wizard.urlopen")
def test_http_error_returns_none(self, mock_urlopen):
"""HTTP error returns None."""
from urllib.error import HTTPError
mock_urlopen.side_effect = HTTPError(
"https://example.com", 401, "Unauthorized", {}, None
)
result = setup_wizard.fetch_api_key("bad_token")
assert result is None
class TestRunFullDeviceAuth:
"""Tests for run_full_device_auth()."""
@patch("lib.setup_wizard.fetch_api_key")
@patch("lib.setup_wizard.poll_device_auth")
@patch("lib.setup_wizard.run_device_auth")
@patch("webbrowser.open")
def test_happy_path(self, mock_browser, mock_start, mock_poll, mock_fetch):
"""Full flow succeeds: start -> poll -> fetch -> return api_key."""
mock_start.return_value = ("dev123", "ABCD-1234", "https://example.com/device", 5)
mock_poll.return_value = "access_tok"
mock_fetch.return_value = "sc_live_abc123"
result = setup_wizard.run_full_device_auth(timeout=10)
assert result["status"] == "success"
assert result["api_key"] == "sc_live_abc123"
assert result["user_code"] == "ABCD-1234"
mock_browser.assert_called_once_with("https://example.com/device")
@patch("lib.setup_wizard.run_device_auth")
def test_start_fails(self, mock_start):
"""Device code request fails -> error status."""
mock_start.return_value = None
result = setup_wizard.run_full_device_auth()
assert result["status"] == "error"
assert "Failed to start" in result["message"]
@patch("lib.setup_wizard.poll_device_auth")
@patch("lib.setup_wizard.run_device_auth")
@patch("webbrowser.open")
def test_poll_timeout(self, mock_browser, mock_start, mock_poll):
"""Poll times out -> timeout status with user_code."""
mock_start.return_value = ("dev123", "WXYZ-5678", "https://example.com/device", 5)
mock_poll.return_value = None
result = setup_wizard.run_full_device_auth(timeout=10)
assert result["status"] == "timeout"
assert result["user_code"] == "WXYZ-5678"
@patch("lib.setup_wizard.fetch_api_key")
@patch("lib.setup_wizard.poll_device_auth")
@patch("lib.setup_wizard.run_device_auth")
@patch("webbrowser.open")
def test_fetch_fails_after_auth(self, mock_browser, mock_start, mock_poll, mock_fetch):
"""Auth succeeds but profile fetch fails -> error status."""
mock_start.return_value = ("dev123", "CODE-1111", "https://example.com/device", 5)
mock_poll.return_value = "access_tok"
mock_fetch.return_value = None
result = setup_wizard.run_full_device_auth(timeout=10)
assert result["status"] == "error"
assert "failed to fetch" in result["message"].lower()
@patch("lib.setup_wizard.run_device_auth")
@patch("webbrowser.open")
def test_browser_open_fails_gracefully(self, mock_browser, mock_start):
"""webbrowser.open raises -> flow continues without crashing."""
mock_start.return_value = ("dev123", "CODE-2222", "https://example.com/device", 5)
mock_browser.side_effect = Exception("no display")
with patch("lib.setup_wizard.poll_device_auth", return_value=None):
result = setup_wizard.run_full_device_auth(timeout=1)
# Should not crash, just timeout
assert result["status"] == "timeout"
@patch("lib.setup_wizard.run_device_auth")
@patch("webbrowser.open")
def test_no_verification_uri_skips_browser(self, mock_browser, mock_start):
"""Empty verification_uri -> browser not opened."""
mock_start.return_value = ("dev123", "CODE-3333", "", 5)
with patch("lib.setup_wizard.poll_device_auth", return_value=None):
setup_wizard.run_full_device_auth(timeout=1)
mock_browser.assert_not_called()
class TestAuthWithPat:
"""Tests for auth_with_pat()."""
@patch("lib.setup_wizard.urlopen")
def test_success(self, mock_urlopen):
"""Valid PAT -> returns dict with api_key."""
resp = MagicMock()
resp.read.return_value = json.dumps({
"api_key": "sc_live_test123",
"github_username": "testuser",
"credits_remaining": 100,
}).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = resp
result = setup_wizard.auth_with_pat("gho_validtoken")
assert result is not None
assert result["api_key"] == "sc_live_test123"
assert result["github_username"] == "testuser"
@patch("lib.setup_wizard.urlopen")
def test_invalid_token_returns_none(self, mock_urlopen):
"""HTTP 401 (invalid token) -> returns None."""
from urllib.error import HTTPError
mock_urlopen.side_effect = HTTPError(
url="https://api.scrapecreators.com/v1/github/pat/auth",
code=401, msg="Unauthorized", hdrs={}, fp=None,
)
result = setup_wizard.auth_with_pat("gho_badtoken")
assert result is None
@patch("lib.setup_wizard.urlopen")
def test_insufficient_scope_returns_none(self, mock_urlopen):
"""HTTP 422 (insufficient scope) -> returns None."""
from urllib.error import HTTPError
mock_urlopen.side_effect = HTTPError(
url="https://api.scrapecreators.com/v1/github/pat/auth",
code=422, msg="Unprocessable", hdrs={}, fp=None,
)
result = setup_wizard.auth_with_pat("gho_noscope")
assert result is None
@patch("lib.setup_wizard.urlopen")
def test_network_error_returns_none(self, mock_urlopen):
"""URLError -> returns None."""
from urllib.error import URLError
mock_urlopen.side_effect = URLError("Connection refused")
result = setup_wizard.auth_with_pat("gho_anytoken")
assert result is None
@patch("lib.setup_wizard.urlopen")
def test_no_api_key_in_response(self, mock_urlopen):
"""Response without api_key -> returns None."""
resp = MagicMock()
resp.read.return_value = json.dumps({"error": "something"}).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = resp
result = setup_wizard.auth_with_pat("gho_validtoken")
assert result is None
class TestClipboardDeviceAuth:
"""Tests for clipboard-first behavior in run_full_device_auth()."""
@patch("lib.setup_wizard.run_device_auth")
@patch("lib.setup_wizard.poll_device_auth", return_value=None)
@patch("webbrowser.open")
@patch("subprocess.run")
def test_pbcopy_called_on_macos(self, mock_subproc, mock_browser, mock_poll, mock_start):
"""On macOS, pbcopy is called with the user code before browser opens."""
mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)
with patch("sys.platform", "darwin"):
setup_wizard.run_full_device_auth(timeout=1)
mock_subproc.assert_called_once()
call_args = mock_subproc.call_args
assert call_args[0][0] == ["pbcopy"]
assert call_args[1]["input"] == b"CLIP-CODE"
@patch("lib.setup_wizard.run_device_auth")
@patch("lib.setup_wizard.poll_device_auth", return_value=None)
@patch("webbrowser.open")
@patch("subprocess.run")
def test_no_pbcopy_on_linux(self, mock_subproc, mock_browser, mock_poll, mock_start):
"""On Linux, subprocess.run (pbcopy) is not called."""
mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)
with patch("sys.platform", "linux"):
setup_wizard.run_full_device_auth(timeout=1)
mock_subproc.assert_not_called()
@patch("lib.setup_wizard.run_device_auth")
@patch("lib.setup_wizard.poll_device_auth", return_value=None)
@patch("webbrowser.open")
@patch("subprocess.run", side_effect=Exception("pbcopy not found"))
def test_pbcopy_failure_continues(self, mock_subproc, mock_browser, mock_poll, mock_start):
"""pbcopy failing -> flow continues, browser still opens."""
mock_start.return_value = ("dev123", "CLIP-CODE", "https://github.com/login/device", 5)
with patch("sys.platform", "darwin"):
result = setup_wizard.run_full_device_auth(timeout=1)
# Should not crash, browser still called
mock_browser.assert_called_once()
assert result["status"] == "timeout"
class TestRunGithubAuth:
"""Tests for run_github_auth() — PAT-first with device fallback."""
@patch("lib.setup_wizard.auth_with_pat")
@patch("subprocess.run")
@patch("shutil.which", return_value="/usr/local/bin/gh")
def test_pat_success(self, mock_which, mock_subproc, mock_pat):
"""gh found + valid token + PAT endpoint success -> pat method."""
mock_subproc.return_value = MagicMock(
returncode=0, stdout="gho_testtoken123\n",
)
mock_pat.return_value = {
"api_key": "sc_live_fromPAT",
"github_username": "testuser",
}
result = setup_wizard.run_github_auth(timeout=10)
assert result["status"] == "success"
assert result["method"] == "pat"
assert result["api_key"] == "sc_live_fromPAT"
@patch("lib.setup_wizard.run_full_device_auth")
@patch("lib.setup_wizard.auth_with_pat", return_value=None)
@patch("subprocess.run")
@patch("shutil.which", return_value="/usr/local/bin/gh")
def test_pat_fails_falls_to_device(self, mock_which, mock_subproc, mock_pat, mock_device):
"""gh found + PAT endpoint fails -> falls through to device flow."""
mock_subproc.return_value = MagicMock(
returncode=0, stdout="gho_badtoken\n",
)
mock_device.return_value = {
"status": "success", "method": "device",
"api_key": "sc_live_fromDevice",
}
result = setup_wizard.run_github_auth(timeout=10)
assert result["status"] == "success"
assert result["method"] == "device"
mock_device.assert_called_once()
@patch("lib.setup_wizard.run_full_device_auth")
@patch("shutil.which", return_value=None)
def test_no_gh_goes_to_device(self, mock_which, mock_device):
"""gh not installed -> straight to device flow."""
mock_device.return_value = {
"status": "success", "method": "device",
"api_key": "sc_live_deviceOnly",
}
result = setup_wizard.run_github_auth(timeout=10)
assert result["status"] == "success"
assert result["method"] == "device"
@patch("lib.setup_wizard.run_full_device_auth")
@patch("subprocess.run")
@patch("shutil.which", return_value="/usr/local/bin/gh")
def test_gh_not_logged_in_falls_to_device(self, mock_which, mock_subproc, mock_device):
"""gh exists but not logged in (exit code 1) -> device flow."""
mock_subproc.return_value = MagicMock(
returncode=1, stdout="", stderr="not logged in",
)
mock_device.return_value = {
"status": "success", "method": "device",
"api_key": "sc_live_fallback",
}
result = setup_wizard.run_github_auth(timeout=10)
assert result["status"] == "success"
assert result["method"] == "device"
+621
View File
@@ -0,0 +1,621 @@
import math
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import schema, signals
from lib.hackernews import parse_hackernews_response
class SignalsV3Tests(unittest.TestCase):
def test_reddit_engagement_uses_source_specific_formula(self):
item = schema.SourceItem(
item_id="r1",
source="reddit",
title="Title",
body="Body",
url="https://example.com",
engagement={"score": 99, "num_comments": 20, "upvote_ratio": 0.8},
metadata={"top_comments": [{"score": 10}]},
)
expected = (
0.50 * math.log1p(99)
+ 0.35 * math.log1p(20)
+ 0.05 * (0.8 * 10.0)
+ 0.10 * math.log1p(10)
)
self.assertAlmostEqual(expected, signals.engagement_raw(item))
def test_polymarket_engagement_uses_market_fields(self):
item = schema.SourceItem(
item_id="pm1",
source="polymarket",
title="Title",
body="Body",
url="https://example.com",
engagement={"volume": 1000, "liquidity": 250},
)
expected = (0.60 * math.log1p(1000)) + (0.40 * math.log1p(250))
self.assertAlmostEqual(expected, signals.engagement_raw(item))
def test_grounding_uses_generic_fallback(self):
item = schema.SourceItem(
item_id="g1",
source="grounding",
title="Title",
body="Body",
url="https://example.com",
engagement={"shares": 10, "reads": 100},
)
expected = (math.log1p(10) + math.log1p(100)) / 2
self.assertAlmostEqual(expected, signals.engagement_raw(item))
def test_annotate_stream_sorts_by_source_specific_reddit_engagement(self):
higher = schema.SourceItem(
item_id="r-high",
source="reddit",
title="High signal",
body="claude code skill",
url="https://example.com/high",
published_at="2026-03-15",
engagement={"score": 120, "num_comments": 40, "upvote_ratio": 0.9},
metadata={"top_comments": [{"score": 15}]},
)
lower = schema.SourceItem(
item_id="r-low",
source="reddit",
title="Lower signal",
body="claude code skill",
url="https://example.com/low",
published_at="2026-03-15",
engagement={"score": 4, "num_comments": 1, "upvote_ratio": 0.5},
metadata={"top_comments": [{"score": 1}]},
)
ranked = signals.annotate_stream(
[lower, higher],
ranking_query="What recent evidence matters for claude code skill?",
freshness_mode="balanced_recent",
)
self.assertEqual(["r-high", "r-low"], [item.item_id for item in ranked])
def test_local_relevance_dominates_over_high_engagement_noise(self):
relevant = schema.SourceItem(
item_id="relevant",
source="reddit",
title="Deploy to Fly.io with MCP in 60 seconds",
body="Deploy to Fly.io guide with concrete steps.",
url="https://example.com/relevant",
published_at="2026-03-15",
engagement={"score": 2, "num_comments": 0, "upvote_ratio": 0.8},
metadata={"top_comments": []},
)
noisy = schema.SourceItem(
item_id="noisy",
source="reddit",
title="BATTLEFIELD 6 GAME UPDATE 1.2.2.0",
body="Patch notes and gameplay discussion.",
url="https://example.com/noisy",
published_at="2026-03-15",
engagement={"score": 5000, "num_comments": 1200, "upvote_ratio": 0.95},
metadata={"top_comments": [{"score": 400}]},
)
ranked = signals.annotate_stream(
[noisy, relevant],
ranking_query="How do I deploy on Fly.io?",
freshness_mode="evergreen_ok",
)
self.assertEqual("relevant", ranked[0].item_id)
def test_prune_low_relevance_keeps_stronger_matches(self):
strong = schema.SourceItem(
item_id="strong",
source="reddit",
title="Deploy to Fly.io",
body="Step-by-step Fly.io deploy guide.",
url="https://example.com/strong",
local_relevance=0.3,
)
weak = schema.SourceItem(
item_id="weak",
source="reddit",
title="Battlefield update",
body="Patch notes.",
url="https://example.com/weak",
local_relevance=0.0,
)
pruned = signals.prune_low_relevance([strong, weak], minimum=0.1)
self.assertEqual(["strong"], [item.item_id for item in pruned])
def test_prune_low_relevance_falls_back_when_all_are_weak(self):
weak = schema.SourceItem(
item_id="weak",
source="reddit",
title="Generic post",
body="Generic body.",
url="https://example.com/weak",
metadata={"local_relevance": 0.02},
)
pruned = signals.prune_low_relevance([weak], minimum=0.1)
self.assertEqual(["weak"], [item.item_id for item in pruned])
# -- Iteration 1: HN engagement bug --
def test_hackernews_parse_emits_comments_key(self):
"""parse_hackernews_response must emit 'comments' (not 'num_comments')."""
response = {
"hits": [
{
"objectID": "123",
"title": "Show HN: Something Cool",
"url": "https://example.com",
"author": "pg",
"points": 150,
"num_comments": 45,
"created_at_i": 1710720000,
},
],
}
items = parse_hackernews_response(response, query="something cool")
self.assertIn("comments", items[0]["engagement"])
self.assertNotIn("num_comments", items[0]["engagement"])
self.assertEqual(items[0]["engagement"]["comments"], 45)
def test_hackernews_engagement_raw_uses_both_fields(self):
"""engagement_raw for HN must weight both points and comments."""
item = schema.SourceItem(
item_id="hn1",
source="hackernews",
title="Show HN: Something",
body="Description",
url="https://example.com",
engagement={"points": 150, "comments": 45},
)
expected = 0.55 * math.log1p(150) + 0.45 * math.log1p(45)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
self.assertAlmostEqual(expected, result)
# Verify comments actually contributed (not just points)
points_only = 0.55 * math.log1p(150)
self.assertGreater(result, points_only)
# -- Iteration 4: Missing engagement formula tests --
def test_x_engagement_dominant_weight(self):
"""X: likes at 0.55 should dominate over quotes at 0.05."""
item = schema.SourceItem(
item_id="x1", source="x", title="T", body="B",
url="https://example.com",
engagement={"likes": 100, "reposts": 100, "replies": 100, "quotes": 100},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = (
0.55 * math.log1p(100)
+ 0.25 * math.log1p(100)
+ 0.15 * math.log1p(100)
+ 0.05 * math.log1p(100)
)
self.assertAlmostEqual(expected, result)
def test_x_engagement_all_zero_returns_none(self):
item = schema.SourceItem(
item_id="x2", source="x", title="T", body="B",
url="https://example.com",
engagement={"likes": 0, "reposts": 0, "replies": 0, "quotes": 0},
)
self.assertIsNone(signals.engagement_raw(item))
def test_x_engagement_missing_fields(self):
"""Missing fields default to 0, no crash."""
item = schema.SourceItem(
item_id="x3", source="x", title="T", body="B",
url="https://example.com",
engagement={"likes": 50},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = 0.55 * math.log1p(50)
self.assertAlmostEqual(expected, result)
def test_youtube_engagement_dominant_weight(self):
"""YouTube: views at 0.50 should dominate over comments at 0.15."""
item = schema.SourceItem(
item_id="yt1", source="youtube", title="T", body="B",
url="https://example.com",
engagement={"views": 10000, "likes": 500, "comments": 80},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = (
0.50 * math.log1p(10000)
+ 0.35 * math.log1p(500)
+ 0.15 * math.log1p(80)
)
self.assertAlmostEqual(expected, result)
def test_youtube_engagement_all_zero_returns_none(self):
item = schema.SourceItem(
item_id="yt2", source="youtube", title="T", body="B",
url="https://example.com",
engagement={"views": 0, "likes": 0, "comments": 0},
)
self.assertIsNone(signals.engagement_raw(item))
def test_youtube_engagement_missing_fields(self):
item = schema.SourceItem(
item_id="yt3", source="youtube", title="T", body="B",
url="https://example.com",
engagement={"views": 5000},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = 0.50 * math.log1p(5000)
self.assertAlmostEqual(expected, result)
def test_tiktok_engagement_dominant_weight(self):
item = schema.SourceItem(
item_id="tt1", source="tiktok", title="T", body="B",
url="https://example.com",
engagement={"views": 50000, "likes": 3000, "comments": 200},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = (
0.50 * math.log1p(50000)
+ 0.30 * math.log1p(3000)
+ 0.20 * math.log1p(200)
)
self.assertAlmostEqual(expected, result)
def test_tiktok_engagement_all_zero_returns_none(self):
item = schema.SourceItem(
item_id="tt2", source="tiktok", title="T", body="B",
url="https://example.com",
engagement={"views": 0, "likes": 0, "comments": 0},
)
self.assertIsNone(signals.engagement_raw(item))
def test_tiktok_engagement_missing_fields(self):
item = schema.SourceItem(
item_id="tt3", source="tiktok", title="T", body="B",
url="https://example.com",
engagement={"likes": 1000},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = 0.30 * math.log1p(1000)
self.assertAlmostEqual(expected, result)
def test_instagram_engagement_dominant_weight(self):
item = schema.SourceItem(
item_id="ig1", source="instagram", title="T", body="B",
url="https://example.com",
engagement={"views": 8000, "likes": 1500, "comments": 100},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = (
0.50 * math.log1p(8000)
+ 0.30 * math.log1p(1500)
+ 0.20 * math.log1p(100)
)
self.assertAlmostEqual(expected, result)
def test_instagram_engagement_all_zero_returns_none(self):
item = schema.SourceItem(
item_id="ig2", source="instagram", title="T", body="B",
url="https://example.com",
engagement={"views": 0, "likes": 0, "comments": 0},
)
self.assertIsNone(signals.engagement_raw(item))
def test_instagram_engagement_missing_fields(self):
item = schema.SourceItem(
item_id="ig3", source="instagram", title="T", body="B",
url="https://example.com",
engagement={"comments": 50},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = 0.20 * math.log1p(50)
self.assertAlmostEqual(expected, result)
def test_hackernews_engagement_all_zero_returns_none(self):
item = schema.SourceItem(
item_id="hn2", source="hackernews", title="T", body="B",
url="https://example.com",
engagement={"points": 0, "comments": 0},
)
self.assertIsNone(signals.engagement_raw(item))
def test_hackernews_engagement_missing_fields(self):
item = schema.SourceItem(
item_id="hn3", source="hackernews", title="T", body="B",
url="https://example.com",
engagement={"points": 75},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = 0.55 * math.log1p(75)
self.assertAlmostEqual(expected, result)
def test_bluesky_engagement_dominant_weight(self):
"""Bluesky: likes at 0.40 should dominate over quotes at 0.10."""
item = schema.SourceItem(
item_id="bs1", source="bluesky", title="T", body="B",
url="https://example.com",
engagement={"likes": 200, "reposts": 50, "replies": 30, "quotes": 10},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = (
0.40 * math.log1p(200)
+ 0.30 * math.log1p(50)
+ 0.20 * math.log1p(30)
+ 0.10 * math.log1p(10)
)
self.assertAlmostEqual(expected, result)
def test_bluesky_engagement_all_zero_returns_none(self):
item = schema.SourceItem(
item_id="bs2", source="bluesky", title="T", body="B",
url="https://example.com",
engagement={"likes": 0, "reposts": 0, "replies": 0, "quotes": 0},
)
self.assertIsNone(signals.engagement_raw(item))
def test_bluesky_engagement_missing_fields(self):
item = schema.SourceItem(
item_id="bs3", source="bluesky", title="T", body="B",
url="https://example.com",
engagement={"likes": 100, "replies": 20},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = 0.40 * math.log1p(100) + 0.20 * math.log1p(20)
self.assertAlmostEqual(expected, result)
def test_truthsocial_engagement_dominant_weight(self):
"""Truth Social: likes at 0.45 should dominate over replies at 0.25."""
item = schema.SourceItem(
item_id="ts1", source="truthsocial", title="T", body="B",
url="https://example.com",
engagement={"likes": 500, "reposts": 100, "replies": 50},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = (
0.45 * math.log1p(500)
+ 0.30 * math.log1p(100)
+ 0.25 * math.log1p(50)
)
self.assertAlmostEqual(expected, result)
def test_truthsocial_engagement_all_zero_returns_none(self):
item = schema.SourceItem(
item_id="ts2", source="truthsocial", title="T", body="B",
url="https://example.com",
engagement={"likes": 0, "reposts": 0, "replies": 0},
)
self.assertIsNone(signals.engagement_raw(item))
def test_truthsocial_engagement_missing_fields(self):
item = schema.SourceItem(
item_id="ts3", source="truthsocial", title="T", body="B",
url="https://example.com",
engagement={"reposts": 80},
)
result = signals.engagement_raw(item)
self.assertIsNotNone(result)
expected = 0.30 * math.log1p(80)
self.assertAlmostEqual(expected, result)
# -- Fix 5: Rebalance engagement weight --
def test_engagement_weight_meaningful_for_social_ranking(self):
"""Engagement must have enough weight to differentiate otherwise-equal items."""
high_engagement = schema.SourceItem(
item_id="viral",
source="x",
title="Trending topic discussion",
body="Popular social post",
url="https://example.com/viral",
published_at="2026-03-15",
engagement={"likes": 50000, "reposts": 5000, "replies": 2000, "quotes": 500},
)
low_engagement = schema.SourceItem(
item_id="quiet",
source="x",
title="Trending topic discussion",
body="Popular social post",
url="https://example.com/quiet",
published_at="2026-03-15",
engagement={"likes": 10, "reposts": 1, "replies": 0, "quotes": 0},
)
ranked = signals.annotate_stream(
[low_engagement, high_engagement],
ranking_query="trending topic discussion",
freshness_mode="balanced_recent",
)
high_score = ranked[0].local_rank_score
low_score = ranked[1].local_rank_score
gap = high_score - low_score
# With 10% engagement weight, the gap should be >= 0.06
# With 5% weight, gap would be ~0.04
self.assertGreaterEqual(gap, 0.06,
f"Engagement gap should be >= 0.06 with 10% weight, got {gap:.4f}")
# -- Fix 4: Lower prune threshold for social media --
def test_prune_keeps_social_items_above_003(self):
"""Social media items with low but non-trivial relevance should survive pruning."""
social = schema.SourceItem(
item_id="social",
source="x",
title="Viral tweet about topic",
body="Short social post",
url="https://example.com/social",
metadata={"local_relevance": 0.05},
)
strong = schema.SourceItem(
item_id="strong",
source="grounding",
title="Detailed article about topic",
body="In-depth analysis",
url="https://example.com/strong",
metadata={"local_relevance": 0.4},
)
pruned = signals.prune_low_relevance([strong, social])
ids = [item.item_id for item in pruned]
self.assertIn("social", ids, "Item with relevance 0.05 should survive pruning")
self.assertIn("strong", ids)
# -- Unit 3: YouTube high-engagement relevance floor --
def test_youtube_high_engagement_gets_relevance_floor(self):
"""YouTube video with >100K views gets at least 0.3 relevance even with low text overlap."""
item = schema.SourceItem(
item_id="yt-official",
source="youtube",
title="YE - FATHER (feat. TRAVIS SCOTT)",
body="Official music video",
url="https://youtube.com/watch?v=abc",
engagement={"views": 8_000_000, "likes": 422_000, "comments": 5000},
)
rel = signals.local_relevance(item, "kanye west")
self.assertGreaterEqual(rel, 0.3, f"High-engagement YouTube should get >= 0.3 relevance, got {rel}")
def test_youtube_low_engagement_no_floor(self):
"""YouTube video with <100K views does NOT get the relevance floor."""
item = schema.SourceItem(
item_id="yt-small",
source="youtube",
title="Random unrelated video title",
body="Nothing relevant here",
url="https://youtube.com/watch?v=xyz",
engagement={"views": 500, "likes": 10, "comments": 1},
)
rel = signals.local_relevance(item, "kanye west")
self.assertLess(rel, 0.3, f"Low-engagement YouTube should not get floor, got {rel}")
def test_non_youtube_high_engagement_no_floor(self):
"""Non-YouTube items with high engagement do NOT get the YouTube floor."""
item = schema.SourceItem(
item_id="reddit-viral",
source="reddit",
title="Completely unrelated post",
body="Nothing about the topic",
url="https://reddit.com/r/test",
engagement={"score": 50000, "num_comments": 3000},
)
rel = signals.local_relevance(item, "kanye west")
self.assertLess(rel, 0.3, f"Non-YouTube item should not get YouTube floor, got {rel}")
# -- Unit 8: Engagement floor for TikTok/Instagram --
def test_tiktok_below_1000_views_pruned(self):
"""TikTok items with <1000 views should be pruned when other sources exist."""
spam = schema.SourceItem(
item_id="tt-spam", source="tiktok", title="AI news clip", body="Generic",
url="https://tiktok.com/spam",
local_relevance=0.4, engagement={"views": 500, "likes": 10, "comments": 1},
)
good = schema.SourceItem(
item_id="r-good", source="reddit", title="Good discussion", body="Quality",
url="https://reddit.com/good",
local_relevance=0.5, engagement_score=50,
)
pruned = signals.prune_low_relevance([good, spam])
ids = [item.item_id for item in pruned]
self.assertNotIn("tt-spam", ids, "TikTok with 500 views should be pruned")
self.assertIn("r-good", ids)
def test_instagram_below_1000_views_pruned(self):
"""Instagram items with <1000 views should be pruned when other sources exist."""
spam = schema.SourceItem(
item_id="ig-spam", source="instagram", title="Repost clip", body="Generic",
url="https://instagram.com/spam",
local_relevance=0.4, engagement={"views": 200, "likes": 5, "comments": 0},
)
good = schema.SourceItem(
item_id="x-good", source="x", title="Good tweet", body="Quality",
url="https://x.com/good",
local_relevance=0.5, engagement_score=50,
)
pruned = signals.prune_low_relevance([good, spam])
ids = [item.item_id for item in pruned]
self.assertNotIn("ig-spam", ids, "Instagram with 200 views should be pruned")
def test_tiktok_above_1000_views_kept(self):
"""TikTok items with >=1000 views should survive pruning."""
good_tt = schema.SourceItem(
item_id="tt-good", source="tiktok", title="Popular clip", body="Relevant",
url="https://tiktok.com/good",
local_relevance=0.4, engagement={"views": 5000, "likes": 200, "comments": 30},
)
other = schema.SourceItem(
item_id="r-other", source="reddit", title="Reddit post", body="Relevant",
url="https://reddit.com/other",
local_relevance=0.5, engagement_score=50,
)
pruned = signals.prune_low_relevance([other, good_tt])
ids = [item.item_id for item in pruned]
self.assertIn("tt-good", ids, "TikTok with 5000 views should be kept")
def test_tiktok_sole_source_not_pruned(self):
"""When TikTok is the only source, low-view items should NOT be pruned."""
items = [
schema.SourceItem(
item_id=f"tt-{i}", source="tiktok", title=f"Clip {i}", body="Content",
url=f"https://tiktok.com/{i}",
local_relevance=0.4, engagement={"views": 300, "likes": 5, "comments": 0},
)
for i in range(3)
]
pruned = signals.prune_low_relevance(items)
self.assertEqual(len(pruned), 3, "Sole-source TikTok items should all survive")
def test_non_video_sources_unaffected_by_floor(self):
"""Reddit/X items should not be affected by the video engagement floor."""
low_eng_x = schema.SourceItem(
item_id="x-low", source="x", title="Tweet", body="Topic discussion",
url="https://x.com/low",
local_relevance=0.5, engagement={"likes": 2, "reposts": 0},
engagement_score=5,
)
other = schema.SourceItem(
item_id="r-other", source="reddit", title="Post", body="Topic",
url="https://reddit.com/other",
local_relevance=0.5, engagement_score=50,
)
pruned = signals.prune_low_relevance([other, low_eng_x])
ids = [item.item_id for item in pruned]
self.assertIn("x-low", ids, "X items should not be affected by video floor")
def test_aspiresnippets_scenario(self):
"""@aspiresnippets scenario: 5 TikTok items with 200-700 views all pruned."""
spam_items = [
schema.SourceItem(
item_id=f"aspire-{i}", source="tiktok", title=f"AI news {i}", body="Generic clip",
url=f"https://tiktok.com/aspire/{i}",
local_relevance=0.3, engagement={"views": 200 + i * 100, "likes": 5, "comments": 0},
)
for i in range(5)
]
good = schema.SourceItem(
item_id="good-yt", source="youtube", title="In-depth analysis", body="Quality content",
url="https://youtube.com/good",
local_relevance=0.6, engagement_score=70,
)
pruned = signals.prune_low_relevance([good] + spam_items)
aspire_ids = [item.item_id for item in pruned if item.item_id.startswith("aspire")]
self.assertEqual(len(aspire_ids), 0, f"All @aspiresnippets items should be pruned, got {aspire_ids}")
if __name__ == "__main__":
unittest.main()
-99
View File
@@ -1,99 +0,0 @@
"""End-to-end smoke tests — run the actual script as subprocess."""
import json
import subprocess
import sys
import unittest
from pathlib import Path
SCRIPT = str(Path(__file__).parent.parent / "scripts" / "last30days.py")
def _run(args, timeout=30):
"""Run last30days.py with args, return (returncode, stdout, stderr)."""
result = subprocess.run(
[sys.executable, SCRIPT] + args,
capture_output=True, text=True, timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
class TestDiagnose(unittest.TestCase):
"""Tests for --diagnose flag."""
def test_exits_zero(self):
rc, stdout, stderr = _run(["--diagnose"])
self.assertEqual(rc, 0, f"--diagnose failed: {stderr}")
def test_returns_valid_json(self):
rc, stdout, stderr = _run(["--diagnose"])
data = json.loads(stdout)
self.assertIsInstance(data, dict)
def test_has_expected_keys(self):
rc, stdout, stderr = _run(["--diagnose"])
data = json.loads(stdout)
for key in ("openai", "xai", "youtube", "tiktok", "instagram", "hackernews", "polymarket"):
self.assertIn(key, data, f"Missing key: {key}")
def test_boolean_values(self):
rc, stdout, stderr = _run(["--diagnose"])
data = json.loads(stdout)
for key in ("openai", "xai", "youtube", "tiktok", "instagram", "hackernews", "polymarket"):
self.assertIsInstance(data[key], bool, f"{key} should be boolean")
def test_hackernews_always_true(self):
data = json.loads(_run(["--diagnose"])[1])
self.assertTrue(data["hackernews"])
def test_polymarket_always_true(self):
data = json.loads(_run(["--diagnose"])[1])
self.assertTrue(data["polymarket"])
class TestHelp(unittest.TestCase):
"""Tests for --help flag."""
def test_exits_zero(self):
rc, stdout, stderr = _run(["--help"])
self.assertEqual(rc, 0)
def test_shows_usage(self):
rc, stdout, stderr = _run(["--help"])
self.assertTrue(
"topic" in stdout.lower() or "usage" in stdout.lower(),
"Expected usage info in help output"
)
class TestNoTopic(unittest.TestCase):
"""Tests for missing topic."""
def test_exits_nonzero_without_topic(self):
rc, stdout, stderr = _run([])
self.assertNotEqual(rc, 0)
def test_error_message(self):
rc, stdout, stderr = _run([])
self.assertTrue(
"topic" in stderr.lower() or "error" in stderr.lower(),
"Expected error about missing topic"
)
class TestMockMode(unittest.TestCase):
"""Tests for --mock mode (fixture-based, no API calls)."""
def test_mock_json_exits_zero(self):
rc, stdout, stderr = _run(["--mock", "--emit", "json", "test topic"], timeout=120)
self.assertEqual(rc, 0, f"--mock failed: {stderr}")
def test_mock_json_valid(self):
rc, stdout, stderr = _run(["--mock", "--emit", "json", "test topic"], timeout=120)
data = json.loads(stdout)
self.assertIn("topic", data)
self.assertEqual(data["topic"], "test topic")
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import schema, snippet
def make_item(**overrides):
payload = {
"item_id": "i1",
"source": "grounding",
"title": "OpenClaw comparison guide",
"body": "",
"url": "https://example.com",
"snippet": "",
}
payload.update(overrides)
return schema.SourceItem(**payload)
class SnippetV3Tests(unittest.TestCase):
def test_truncate_words_preserves_short_text_and_truncates_long_text(self):
self.assertEqual("short text", snippet._truncate_words("short text", 5))
self.assertEqual("one two three...", snippet._truncate_words("one two three four", 3))
def test_windows_handles_empty_short_and_overlapping_inputs(self):
self.assertEqual([], snippet._windows([], size=5, overlap=2))
self.assertEqual(["one two"], snippet._windows(["one", "two"], size=5, overlap=2))
self.assertEqual(
["one two three", "two three four", "three four five", "four five", "five"],
snippet._windows(["one", "two", "three", "four", "five"], size=3, overlap=2),
)
def test_extract_best_snippet_prefers_existing_snippet(self):
item = make_item(snippet="existing evidence window " * 20)
result = snippet.extract_best_snippet(item, "ignored", max_words=5)
self.assertEqual("existing evidence window existing evidence...", result)
def test_extract_best_snippet_falls_back_to_title_when_body_missing(self):
item = make_item(title="OpenClaw vs NanoClaw", body="")
self.assertEqual("OpenClaw vs NanoClaw", snippet.extract_best_snippet(item, "openclaw"))
def test_extract_best_snippet_selects_best_matching_body_window(self):
body = " ".join(
[
"generic filler words" for _ in range(40)
]
+ [
"openclaw nanoclaw ironclaw comparison details" for _ in range(15)
]
+ [
"more generic filler words" for _ in range(40)
]
)
item = make_item(body=body)
result = snippet.extract_best_snippet(item, "openclaw nanoclaw ironclaw", max_words=20)
self.assertIn("openclaw nanoclaw ironclaw", result)
if __name__ == "__main__":
unittest.main()
-737
View File
@@ -1,737 +0,0 @@
"""Tests for source resolution priority hierarchy (Unit 4).
Validates the free-first priority chain:
env AUTH_TOKEN/CT0 -> browser cookies -> XAI_API_KEY -> None
"""
import os
from unittest.mock import patch, MagicMock
import pytest
from scripts.lib import env
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_config(**overrides):
"""Return a minimal config dict with typical defaults."""
cfg = {
"AUTH_TOKEN": None,
"CT0": None,
"XAI_API_KEY": None,
"SCRAPECREATORS_API_KEY": None,
"OPENAI_API_KEY": None,
"OPENAI_AUTH_STATUS": "missing",
"OPENROUTER_API_KEY": None,
"PARALLEL_API_KEY": None,
"BRAVE_API_KEY": None,
"BSKY_HANDLE": None,
"BSKY_APP_PASSWORD": None,
"TRUTHSOCIAL_TOKEN": None,
"FROM_BROWSER": None,
"SETUP_COMPLETE": None,
"_AUTH_TOKEN_SOURCE": None,
}
cfg.update(overrides)
return cfg
def _mock_bird_installed(installed=True):
"""Patch bird_x.is_bird_installed to return the given value."""
return patch("scripts.lib.bird_x.is_bird_installed", return_value=installed)
def _mock_bird_authenticated(username=None):
"""Patch bird_x.is_bird_authenticated to return the given value."""
return patch("scripts.lib.bird_x.is_bird_authenticated", return_value=username)
def _mock_bird_status(installed=True, authenticated=True, username="env AUTH_TOKEN"):
"""Patch bird_x.get_bird_status to return a status dict."""
return patch("scripts.lib.bird_x.get_bird_status", return_value={
"installed": installed,
"authenticated": authenticated,
"username": username,
"can_install": True,
})
# ---------------------------------------------------------------------------
# Tests: X source resolution priority
# ---------------------------------------------------------------------------
class TestXSourcePriority:
"""Test the X source priority chain: env -> browser cookies -> xAI -> None."""
def test_no_env_cookies_found_resolves_bird_browser(self):
"""No env vars, SETUP_COMPLETE=true, cookies found -> Bird with browser method."""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "browser-firefox"
def test_env_auth_token_plus_cookies_env_wins(self):
"""AUTH_TOKEN in .env + cookies available -> env wins (method='env')."""
config = _base_config(
AUTH_TOKEN="explicit_token",
CT0="explicit_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="env",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "env"
def test_no_env_no_cookies_xai_key_resolves_xai(self):
"""No env vars, no cookies, XAI_API_KEY set -> xAI with method 'api'."""
config = _base_config(XAI_API_KEY="xai-key-123")
with _mock_bird_installed(True), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source == "xai"
assert method == "api"
def test_no_env_no_cookies_no_api_keys_none(self):
"""No env vars, no cookies, no API keys -> X not available."""
config = _base_config()
with _mock_bird_installed(True), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source is None
assert method is None
def test_bird_not_installed_falls_to_xai(self):
"""Bird not installed, XAI_API_KEY set -> xAI."""
config = _base_config(XAI_API_KEY="xai-key")
with _mock_bird_installed(False), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source == "xai"
assert method == "api"
def test_bird_not_installed_no_xai_none(self):
"""Bird not installed, no XAI_API_KEY -> None."""
config = _base_config()
with _mock_bird_installed(False), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source is None
assert method is None
def test_browser_chrome_method_tracked(self):
"""Cookies from Chrome -> method is 'browser-chrome'."""
config = _base_config(
AUTH_TOKEN="chrome_tok",
CT0="chrome_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-chrome",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "browser-chrome"
def test_browser_safari_method_tracked(self):
"""Cookies from Safari -> method is 'browser-safari'."""
config = _base_config(
AUTH_TOKEN="safari_tok",
CT0="safari_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-safari",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "browser-safari"
# ---------------------------------------------------------------------------
# Tests: get_x_source() backward compat
# ---------------------------------------------------------------------------
class TestGetXSourceBackwardCompat:
"""Ensure get_x_source() returns the same string as before."""
def test_bird_returns_bird(self):
config = _base_config(AUTH_TOKEN="tok", CT0="ct0", _AUTH_TOKEN_SOURCE="env")
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
assert env.get_x_source(config) == "bird"
def test_xai_returns_xai(self):
config = _base_config(XAI_API_KEY="key")
with _mock_bird_installed(True), _mock_bird_authenticated(None):
assert env.get_x_source(config) == "xai"
def test_none_returns_none(self):
config = _base_config()
with _mock_bird_installed(False):
assert env.get_x_source(config) is None
# ---------------------------------------------------------------------------
# Tests: get_x_source_status() method field
# ---------------------------------------------------------------------------
class TestGetXSourceStatusMethod:
"""Test that get_x_source_status() includes the method field."""
def test_bird_env_method(self):
config = _base_config(AUTH_TOKEN="tok", CT0="ct0", _AUTH_TOKEN_SOURCE="env")
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"), \
_mock_bird_status(installed=True, authenticated=True, username="env AUTH_TOKEN"):
status = env.get_x_source_status(config)
assert status["source"] == "bird"
assert status["method"] == "env"
assert "bird_installed" in status
assert "xai_available" in status
def test_bird_browser_method(self):
config = _base_config(
AUTH_TOKEN="tok", CT0="ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"), \
_mock_bird_status(installed=True, authenticated=True):
status = env.get_x_source_status(config)
assert status["source"] == "bird"
assert status["method"] == "browser-firefox"
def test_xai_api_method(self):
config = _base_config(XAI_API_KEY="key")
with _mock_bird_installed(True), _mock_bird_authenticated(None), \
_mock_bird_status(installed=True, authenticated=False, username=None):
status = env.get_x_source_status(config)
assert status["source"] == "xai"
assert status["method"] == "api"
def test_no_source_method_none(self):
config = _base_config()
with _mock_bird_installed(False), \
_mock_bird_status(installed=False, authenticated=False, username=None):
status = env.get_x_source_status(config)
assert status["source"] is None
assert status["method"] is None
# ---------------------------------------------------------------------------
# Tests: get_available_sources() with various configs
# ---------------------------------------------------------------------------
class TestGetAvailableSources:
"""Test get_available_sources() returns correct strings."""
def test_no_x_no_web_reddit_only(self):
"""No X source, no web keys -> 'reddit' (Reddit always available)."""
config = _base_config()
with _mock_bird_installed(False):
result = env.get_available_sources(config)
assert result == "reddit"
def test_xai_key_no_web(self):
"""XAI_API_KEY set, no web keys -> 'both'."""
config = _base_config(XAI_API_KEY="key")
with _mock_bird_installed(True), _mock_bird_authenticated(None):
result = env.get_available_sources(config)
assert result == "both"
def test_bird_auth_no_web(self):
"""Bird authenticated (cookies), SETUP_COMPLETE=true, no web keys -> 'both'."""
config = _base_config(
AUTH_TOKEN="tok", CT0="ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
result = env.get_available_sources(config)
assert result == "both"
def test_bird_auth_with_web(self):
"""Bird authenticated + web keys -> 'all'."""
config = _base_config(
AUTH_TOKEN="tok", CT0="ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
BRAVE_API_KEY="brave-key",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
result = env.get_available_sources(config)
assert result == "all"
def test_no_x_with_web(self):
"""No X source, web keys -> 'reddit-web'."""
config = _base_config(BRAVE_API_KEY="brave-key")
with _mock_bird_installed(False):
result = env.get_available_sources(config)
assert result == "reddit-web"
def test_reddit_hn_polymarket_always_available(self):
"""Reddit, HN, and Polymarket are always available regardless of config."""
config = _base_config()
# These functions don't depend on config
assert env.is_hackernews_available() is True
assert env.is_polymarket_available() is True
# Reddit: get_available_sources always includes it
with _mock_bird_installed(False):
result = env.get_available_sources(config)
assert result in ("reddit", "reddit-web") # never 'none' when Reddit is always True
# ---------------------------------------------------------------------------
# Tests: Full resolution with mixed config
# ---------------------------------------------------------------------------
class TestFullResolution:
"""Test that each source resolves independently with mixed config."""
def test_mixed_config_all_sources(self):
"""Bird for X, public Reddit, web keys, YouTube/TikTok available."""
config = _base_config(
AUTH_TOKEN="tok", CT0="ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-chrome",
BRAVE_API_KEY="brave-key",
SCRAPECREATORS_API_KEY="sc-key",
BSKY_HANDLE="user.bsky.social",
BSKY_APP_PASSWORD="app-pw",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
x_source, x_method = env.get_x_source_with_method(config)
available = env.get_available_sources(config)
assert x_source == "bird"
assert x_method == "browser-chrome"
assert available == "all" # reddit + x + web
assert env.is_bluesky_available(config) is True
assert env.is_tiktok_available(config) is True
assert env.is_hackernews_available() is True
assert env.is_polymarket_available() is True
def test_no_config_minimal_sources(self):
"""No API keys, no cookies -> Reddit + HN + Polymarket only."""
config = _base_config()
with _mock_bird_installed(False):
x_source = env.get_x_source(config)
available = env.get_available_sources(config)
assert x_source is None
assert available == "reddit"
assert env.is_hackernews_available() is True
assert env.is_polymarket_available() is True
assert env.is_bluesky_available(config) is False
assert env.is_tiktok_available(config) is False
# ---------------------------------------------------------------------------
# Tests: extract_browser_credentials tracks browser source
# ---------------------------------------------------------------------------
class TestExtractBrowserCredentialsSource:
"""Test that extract_browser_credentials tracks __X_BROWSER."""
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_tracks_firefox_source(self, mock_extract):
"""Cookies from Firefox -> __X_BROWSER set to 'firefox'."""
mock_extract.return_value = (
{"auth_token": "tok", "ct0": "ct0val"},
"firefox",
)
config = {
"AUTH_TOKEN": None,
"CT0": None,
"TRUTHSOCIAL_TOKEN": None,
"FROM_BROWSER": "auto",
"SETUP_COMPLETE": "true",
}
result = env.extract_browser_credentials(config)
assert result["AUTH_TOKEN"] == "tok"
assert result["CT0"] == "ct0val"
assert result["__X_BROWSER"] == "firefox"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_tracks_chrome_source(self, mock_extract):
"""Cookies from Chrome -> __X_BROWSER set to 'chrome'."""
mock_extract.return_value = (
{"auth_token": "tok", "ct0": "ct0val"},
"chrome",
)
config = {
"AUTH_TOKEN": None,
"CT0": None,
"TRUTHSOCIAL_TOKEN": None,
"FROM_BROWSER": "chrome",
"SETUP_COMPLETE": "true",
}
result = env.extract_browser_credentials(config)
assert result["__X_BROWSER"] == "chrome"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
def test_no_cookies_no_browser_key(self, mock_extract):
"""No cookies found -> no __X_BROWSER key."""
mock_extract.return_value = None
config = {
"AUTH_TOKEN": None,
"CT0": None,
"TRUTHSOCIAL_TOKEN": None,
"FROM_BROWSER": "auto",
"SETUP_COMPLETE": "true",
}
result = env.extract_browser_credentials(config)
assert "__X_BROWSER" not in result
# ---------------------------------------------------------------------------
# Tests: get_config() _AUTH_TOKEN_SOURCE tracking
# ---------------------------------------------------------------------------
class TestGetConfigAuthTokenSource:
"""Test that get_config() sets _AUTH_TOKEN_SOURCE correctly."""
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("scripts.lib.env._find_project_env", return_value=None)
@patch("scripts.lib.env.load_env_file", return_value={})
@patch("scripts.lib.env.get_openai_auth")
def test_env_var_auth_token_source_env(
self, mock_openai, mock_load, mock_proj, mock_extract
):
"""AUTH_TOKEN from env var -> _AUTH_TOKEN_SOURCE='env'."""
from scripts.lib.env import get_config, OpenAIAuth
mock_openai.return_value = OpenAIAuth(
token=None, source="none", status="missing",
account_id=None, codex_auth_file="/fake",
)
mock_extract.return_value = None
env_patch = {
"AUTH_TOKEN": "env_token",
"CT0": "env_ct0",
"LAST30DAYS_CONFIG_DIR": "",
}
with patch.dict(os.environ, env_patch, clear=False):
config = get_config()
assert config["_AUTH_TOKEN_SOURCE"] == "env"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("scripts.lib.env._find_project_env", return_value=None)
@patch("scripts.lib.env.load_env_file", return_value={})
@patch("scripts.lib.env.get_openai_auth")
def test_browser_cookies_auth_token_source_browser(
self, mock_openai, mock_load, mock_proj, mock_extract
):
"""AUTH_TOKEN from cookies -> _AUTH_TOKEN_SOURCE='browser-firefox'."""
from scripts.lib.env import get_config, OpenAIAuth
mock_openai.return_value = OpenAIAuth(
token=None, source="none", status="missing",
account_id=None, codex_auth_file="/fake",
)
mock_extract.return_value = (
{"auth_token": "cookie_tok", "ct0": "cookie_ct0"},
"firefox",
)
env_patch = {
"SETUP_COMPLETE": "true",
"FROM_BROWSER": "auto",
"LAST30DAYS_CONFIG_DIR": "",
}
# Ensure AUTH_TOKEN is NOT in env
clean_env = {k: v for k, v in os.environ.items()
if k not in ("AUTH_TOKEN", "CT0")}
clean_env.update(env_patch)
with patch.dict(os.environ, clean_env, clear=True):
config = get_config()
assert config["AUTH_TOKEN"] == "cookie_tok"
assert config["_AUTH_TOKEN_SOURCE"] == "browser-firefox"
@patch("scripts.lib.cookie_extract.extract_cookies_with_source")
@patch("scripts.lib.env._find_project_env", return_value=None)
@patch("scripts.lib.env.load_env_file", return_value={})
@patch("scripts.lib.env.get_openai_auth")
def test_no_auth_token_source_none(
self, mock_openai, mock_load, mock_proj, mock_extract
):
"""No AUTH_TOKEN at all -> _AUTH_TOKEN_SOURCE=None."""
from scripts.lib.env import get_config, OpenAIAuth
mock_openai.return_value = OpenAIAuth(
token=None, source="none", status="missing",
account_id=None, codex_auth_file="/fake",
)
mock_extract.return_value = None
clean_env = {k: v for k, v in os.environ.items()
if k not in ("AUTH_TOKEN", "CT0")}
clean_env["LAST30DAYS_CONFIG_DIR"] = ""
with patch.dict(os.environ, clean_env, clear=True):
config = get_config()
assert config["_AUTH_TOKEN_SOURCE"] is None
# ---------------------------------------------------------------------------
# Tests: SETUP_COMPLETE gate — Bird cookie probing blocked before consent
# ---------------------------------------------------------------------------
class TestSetupCompleteGate:
"""Test that Bird cookie probing is gated behind SETUP_COMPLETE consent."""
def test_no_setup_complete_bird_has_cookies_returns_none(self):
"""SETUP_COMPLETE not set, Bird has browser cookies -> returns None (not 'bird').
Bird's is_bird_authenticated() should NOT be called at all because
there is no consent yet. Cookie-sourced AUTH_TOKEN must be ignored.
"""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE=None, # not set — first run
_AUTH_TOKEN_SOURCE="browser-chrome",
)
with _mock_bird_installed(True), \
_mock_bird_authenticated("Chrome") as mock_auth:
source = env.get_x_source(config)
assert source is None
# is_bird_authenticated should NOT have been called (no cookie probing)
mock_auth.assert_not_called()
def test_setup_complete_bird_has_cookies_returns_bird(self):
"""SETUP_COMPLETE=true, Bird has browser cookies -> returns 'bird'.
After user consent, cookie probing should work normally.
"""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-chrome",
)
with _mock_bird_installed(True), _mock_bird_authenticated("Chrome"):
source = env.get_x_source(config)
assert source == "bird"
def test_no_setup_complete_explicit_auth_token_returns_bird(self):
"""SETUP_COMPLETE not set, AUTH_TOKEN from env var -> returns 'bird' with method 'env'.
Explicit env var credentials must ALWAYS work regardless of SETUP_COMPLETE.
"""
config = _base_config(
AUTH_TOKEN="explicit_token",
CT0="explicit_ct0",
SETUP_COMPLETE=None, # not set
_AUTH_TOKEN_SOURCE="env",
)
with _mock_bird_installed(True), _mock_bird_authenticated("env AUTH_TOKEN"):
source, method = env.get_x_source_with_method(config)
assert source == "bird"
assert method == "env"
def test_no_setup_complete_xai_key_returns_xai(self):
"""SETUP_COMPLETE not set, XAI_API_KEY configured -> returns 'xai'.
API keys always work without setup consent.
"""
config = _base_config(
XAI_API_KEY="xai-key-123",
SETUP_COMPLETE=None, # not set
)
with _mock_bird_installed(True), _mock_bird_authenticated(None):
source, method = env.get_x_source_with_method(config)
assert source == "xai"
assert method == "api"
def test_no_setup_complete_status_reports_not_configured(self):
"""First-run status banner should show X as not configured when only cookies exist."""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE=None,
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True):
status = env.get_x_source_status(config)
assert status["source"] is None
assert status["method"] is None
assert status["bird_authenticated"] is False
def test_setup_complete_status_reports_bird(self):
"""After consent, status banner should show Bird as configured."""
config = _base_config(
AUTH_TOKEN="cookie_tok",
CT0="cookie_ct0",
SETUP_COMPLETE="true",
_AUTH_TOKEN_SOURCE="browser-firefox",
)
with _mock_bird_installed(True), _mock_bird_authenticated("Firefox"), \
_mock_bird_status(installed=True, authenticated=True, username="Firefox"):
status = env.get_x_source_status(config)
assert status["source"] == "bird"
assert status["method"] == "browser-firefox"
assert status["bird_authenticated"] is True
# ---------------------------------------------------------------------------
# Tests: BIRD_DISABLE_BROWSER_COOKIES env var on first run
# ---------------------------------------------------------------------------
class TestBirdDisableBrowserCookiesEnvVar:
"""Test that BIRD_DISABLE_BROWSER_COOKIES is set to block Bird's Node.js
sweet-cookie scanner on first run before user consent."""
def _run_main_flow_env_setup(self, config, first_run):
"""Simulate the env-var-setting logic from last30days.py main flow.
Mirrors the block right after first_run detection in main().
"""
if first_run and config.get('_AUTH_TOKEN_SOURCE') != 'env':
os.environ['BIRD_DISABLE_BROWSER_COOKIES'] = '1'
else:
os.environ.pop('BIRD_DISABLE_BROWSER_COOKIES', None)
def test_first_run_no_auth_token_sets_env_var(self):
"""first_run=True, no AUTH_TOKEN -> BIRD_DISABLE_BROWSER_COOKIES is set."""
config = _base_config(SETUP_COMPLETE=None, _AUTH_TOKEN_SOURCE=None)
try:
self._run_main_flow_env_setup(config, first_run=True)
assert os.environ.get('BIRD_DISABLE_BROWSER_COOKIES') == '1'
finally:
os.environ.pop('BIRD_DISABLE_BROWSER_COOKIES', None)
def test_not_first_run_no_env_var(self):
"""first_run=False -> BIRD_DISABLE_BROWSER_COOKIES is NOT set."""
config = _base_config(SETUP_COMPLETE="true", _AUTH_TOKEN_SOURCE="browser-firefox")
try:
self._run_main_flow_env_setup(config, first_run=False)
assert 'BIRD_DISABLE_BROWSER_COOKIES' not in os.environ
finally:
os.environ.pop('BIRD_DISABLE_BROWSER_COOKIES', None)
def test_first_run_explicit_auth_token_no_env_var(self):
"""first_run=True, AUTH_TOKEN explicitly set -> BIRD_DISABLE_BROWSER_COOKIES is NOT set."""
config = _base_config(
AUTH_TOKEN="explicit_token",
CT0="explicit_ct0",
SETUP_COMPLETE=None,
_AUTH_TOKEN_SOURCE="env",
)
try:
self._run_main_flow_env_setup(config, first_run=True)
assert 'BIRD_DISABLE_BROWSER_COOKIES' not in os.environ
finally:
os.environ.pop('BIRD_DISABLE_BROWSER_COOKIES', None)
# ---------------------------------------------------------------------------
# Tests: INCLUDE_SOURCES config override
# ---------------------------------------------------------------------------
class TestIncludeSourcesOverride:
"""Test that INCLUDE_SOURCES forces sources on regardless of tier."""
def _simulate_source_decisions(self, config, query_type="breaking_news"):
"""Simulate the source decision logic from last30days.py main flow.
Returns (search_run_tiktok, search_run_instagram) after tier + override.
"""
from scripts.lib import query_type as qt
has_tiktok = env.is_tiktok_available(config)
has_instagram = env.is_instagram_available(config)
# Tier system decision
search_run_tiktok = has_tiktok and qt.is_source_enabled("tiktok", query_type)
search_run_instagram = has_instagram and qt.is_source_enabled("instagram", query_type)
# INCLUDE_SOURCES override (mirrors last30days.py logic)
_include_sources = {
s.strip().lower()
for s in config.get('INCLUDE_SOURCES', '').split(',')
if s.strip()
}
if _include_sources:
if 'tiktok' in _include_sources and has_tiktok:
if not search_run_tiktok:
search_run_tiktok = True
if 'instagram' in _include_sources and has_instagram:
if not search_run_instagram:
search_run_instagram = True
return search_run_tiktok, search_run_instagram
def test_include_sources_forces_tiktok_and_instagram_on(self):
"""INCLUDE_SOURCES=tiktok,instagram + SC key + GENERAL query -> both forced on."""
config = _base_config(
SCRAPECREATORS_API_KEY="sc-key",
INCLUDE_SOURCES="tiktok,instagram",
)
run_tiktok, run_instagram = self._simulate_source_decisions(config, "breaking_news")
assert run_tiktok is True
assert run_instagram is True
def test_no_include_sources_tier_controls(self):
"""No INCLUDE_SOURCES + SC key + GENERAL query -> tier system controls (both off)."""
config = _base_config(
SCRAPECREATORS_API_KEY="sc-key",
)
run_tiktok, run_instagram = self._simulate_source_decisions(config, "breaking_news")
# breaking_news tier doesn't include tiktok or instagram
assert run_tiktok is False
assert run_instagram is False
def test_include_sources_no_sc_key_still_off(self):
"""INCLUDE_SOURCES=tiktok but no SC key -> TikTok still off (no backend)."""
config = _base_config(
INCLUDE_SOURCES="tiktok",
)
run_tiktok, run_instagram = self._simulate_source_decisions(config, "breaking_news")
assert run_tiktok is False
assert run_instagram is False
-283
View File
@@ -1,283 +0,0 @@
"""Tests for the redesigned status banner (free-first design)."""
import pytest
from scripts.lib.ui import _build_status_banner
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _base_diag(**overrides):
"""Return a minimal diag dict with common defaults."""
diag = {
"setup_complete": False,
"reddit_source": None, # None = public fallback
"x_source": None,
"x_method": None,
"youtube": False,
"tiktok": False,
"instagram": False,
"hackernews": True,
"polymarket": True,
"bluesky": False,
"truthsocial": False,
"xiaohongshu": False,
"scrapecreators": False,
"web_search_backend": None,
}
diag.update(overrides)
return diag
def _banner_text(diag):
"""Return full banner as a single string."""
return "\n".join(_build_status_banner(diag))
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestZeroConfig:
"""Zero-config state: SETUP_COMPLETE not set, wizard hasn't run."""
def test_shows_first_run_title(self):
banner = _banner_text(_base_diag())
assert "First Run" in banner
def test_shows_three_free_sources(self):
banner = _banner_text(_base_diag())
assert "Reddit (threads only)" in banner
assert "HN" in banner
assert "Polymarket" in banner
def test_shows_setup_prompt(self):
banner = _banner_text(_base_diag())
assert "/last30days setup" in banner
def test_does_not_show_source_status_title(self):
banner = _banner_text(_base_diag())
assert "Source Status" not in banner
class TestFullConfig:
"""Fully configured: X + yt-dlp + ScrapeCreators = everything active."""
def _full_diag(self):
return _base_diag(
setup_complete=True,
reddit_source="scrapecreators",
x_source="bird",
x_method="browser-chrome",
youtube=True,
tiktok=True,
instagram=True,
hackernews=True,
polymarket=True,
bluesky=True,
truthsocial=True,
xiaohongshu=True,
scrapecreators=True,
web_search_backend="parallel",
)
def test_shows_source_status_title(self):
banner = _banner_text(self._full_diag())
assert "Source Status" in banner
def test_shows_all_sources(self):
banner = _banner_text(self._full_diag())
assert "Reddit (with comments)" in banner
assert "X (Chrome)" in banner
assert "YouTube" in banner
assert "HN" in banner
assert "Polymarket" in banner
assert "TikTok" in banner
assert "Instagram" in banner
assert "Bluesky" in banner
assert "Truth Social" in banner
assert "Xiaohongshu" in banner
def test_no_recommendations(self):
banner = _banner_text(self._full_diag())
assert "" not in banner
assert "scrapecreators.com" not in banner
assert "/last30days setup" not in banner
class TestPartialConfig:
"""Partially configured: X + yt-dlp but no ScrapeCreators."""
def _partial_diag(self):
return _base_diag(
setup_complete=True,
reddit_source=None, # public fallback
x_source="bird",
x_method="browser-chrome",
youtube=True,
scrapecreators=False,
)
def test_shows_active_sources(self):
banner = _banner_text(self._partial_diag())
assert "Reddit (threads only)" in banner
assert "X (Chrome)" in banner
assert "YouTube" in banner
assert "HN" in banner
assert "Polymarket" in banner
def test_recommends_scrapecreators(self):
banner = _banner_text(self._partial_diag())
assert "SCRAPECREATORS_API_KEY" in banner
def test_scrapecreators_free_calls_copy(self):
banner = _banner_text(self._partial_diag())
assert "100 free calls, no CC" in banner
assert "scrapecreators.com" in banner
def test_shows_tiktok_instagram_unlock(self):
banner = _banner_text(self._partial_diag())
assert "TikTok" in banner
assert "Instagram" in banner
class TestScrapeCreatorsRecommendation:
"""ScrapeCreators recommendation always includes key copy."""
def test_always_includes_free_calls_no_cc(self):
"""Any config missing SC should show the free-calls copy."""
# Zero config
banner_zero = _banner_text(_base_diag())
# Zero config doesn't recommend SC directly (recommends setup wizard)
# But after setup, missing SC should always include the copy
banner_partial = _banner_text(_base_diag(
setup_complete=True,
scrapecreators=False,
))
assert "100 free calls, no CC" in banner_partial
def test_present_when_x_available_but_no_sc(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="xai",
x_method="api",
scrapecreators=False,
))
assert "100 free calls, no CC" in banner
assert "scrapecreators.com" in banner
class TestRedditLabelDisplay:
"""Reddit label reflects comment availability, not implementation details."""
def test_no_scrapecreators_shows_threads_only(self):
"""Without SC, Reddit label should say 'threads only' regardless of OpenAI auth."""
banner = _banner_text(_base_diag(
setup_complete=True,
reddit_source="openai",
scrapecreators=False,
))
assert "Reddit (threads only)" in banner
assert "OpenAI" not in banner
assert "Codex" not in banner
def test_no_scrapecreators_public_shows_threads_only(self):
"""Public fallback also shows 'threads only'."""
banner = _banner_text(_base_diag(
setup_complete=True,
reddit_source=None,
scrapecreators=False,
))
assert "Reddit (threads only)" in banner
def test_scrapecreators_shows_with_comments(self):
"""With SC configured, Reddit label should say 'with comments'."""
banner = _banner_text(_base_diag(
setup_complete=True,
reddit_source="scrapecreators",
scrapecreators=True,
))
assert "Reddit (with comments)" in banner
def test_no_openai_or_codex_in_banner(self):
"""Banner should never mention OpenAI or Codex — those are implementation details."""
for source in [None, "openai", "scrapecreators"]:
banner = _banner_text(_base_diag(
setup_complete=True,
reddit_source=source,
scrapecreators=(source == "scrapecreators"),
))
assert "OpenAI" not in banner, f"Found 'OpenAI' with reddit_source={source}"
assert "Codex" not in banner, f"Found 'Codex' with reddit_source={source}"
class TestXMethodDisplay:
"""X source shows auth method in parens."""
def test_browser_chrome(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="bird",
x_method="browser-chrome",
))
assert "X (Chrome)" in banner
def test_browser_firefox(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="bird",
x_method="browser-firefox",
))
assert "X (Firefox)" in banner
def test_env_method(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="bird",
x_method="env",
))
assert "X (env)" in banner
def test_xai_api(self):
banner = _banner_text(_base_diag(
setup_complete=True,
x_source="xai",
x_method="api",
))
assert "X (xAI)" in banner
class TestBannerStructure:
"""Banner formatting and structure tests."""
def test_has_box_drawing(self):
lines = _build_status_banner(_base_diag())
assert lines[0].startswith("")
assert lines[-1].startswith("")
def test_shows_config_path(self):
banner = _banner_text(_base_diag())
assert "~/.config/last30days/.env" in banner
def test_max_10_inner_lines(self):
"""Banner should be compact — max 10 lines inside the box."""
# Full config (most lines)
diag = _base_diag(
setup_complete=True,
reddit_source="scrapecreators",
x_source="bird",
x_method="browser-chrome",
youtube=True,
tiktok=True,
instagram=True,
bluesky=True,
truthsocial=True,
xiaohongshu=True,
scrapecreators=True,
)
lines = _build_status_banner(diag)
# Subtract top and bottom border
inner_lines = [l for l in lines if l.startswith("")]
assert len(inner_lines) <= 10, f"Banner has {len(inner_lines)} inner lines, max is 10"
+495
View File
@@ -0,0 +1,495 @@
"""Tests for store.py - SQLite research accumulator and watchlist storage."""
import json
import sqlite3
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
import pytest
# Import the module under test
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
import store
from lib import schema
@pytest.fixture
def temp_db():
"""Create a temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = Path(f.name)
# Override the database path
original_override = store._db_override
store._db_override = db_path
# Initialize fresh database
store.init_db()
yield db_path
# Cleanup
store._db_override = original_override
if db_path.exists():
db_path.unlink()
@pytest.fixture
def sample_report():
"""Create a sample Report with multiple sources including HN and Polymarket."""
return schema.report_from_dict({
"topic": "Test Topic",
"range_from": "2026-01-01",
"range_to": "2026-04-03",
"generated_at": "2026-04-03T00:00:00Z",
"provider_runtime": {
"reasoning_provider": "gemini",
"planner_model": "gemini-2.0-flash-exp",
"rerank_model": "gemini-2.0-flash-exp",
},
"query_plan": {
"intent": "test",
"freshness_mode": "recent",
"cluster_mode": "standard",
"raw_topic": "test",
"subqueries": [],
"source_weights": {},
},
"clusters": [],
"ranked_candidates": [],
"items_by_source": {
"reddit": [
{
"item_id": "R1",
"source": "reddit",
"title": "Test Reddit Post",
"body": "Reddit discussion content",
"url": "https://reddit.com/r/test/1",
"author": "testuser",
"engagement_score": 50.0,
"local_relevance": 0.8,
"snippet": "Reddit snippet",
}
],
"x": [
{
"item_id": "X1",
"source": "x",
"title": "Test X Post",
"body": "X post content",
"url": "https://x.com/test/status/1",
"author": "xuser",
"engagement_score": 75.0,
"local_relevance": 0.85,
"snippet": "X snippet",
}
],
"hackernews": [
{
"item_id": "HN1",
"source": "hackernews",
"title": "Test HN Story",
"body": "HN story content with comments",
"url": "https://news.ycombinator.com/item?id=12345",
"author": "hnuser",
"engagement_score": 120.0,
"local_relevance": 0.9,
"snippet": "HN snippet",
}
],
"polymarket": [
{
"item_id": "PM1",
"source": "polymarket",
"title": "Will event happen?",
"body": "Yes: 64% / No: 36%",
"url": "https://polymarket.com/event/test-event",
"author": None,
"engagement_score": 342000.0,
"local_relevance": 0.7,
"snippet": "Prediction market",
}
],
},
"errors_by_source": {},
"warnings": [],
})
# === Tests for findings_from_report() ===
def test_findings_from_report_processes_all_sources(sample_report):
"""Test that findings_from_report extracts items from all sources in items_by_source."""
findings = store.findings_from_report(sample_report)
# Should have 4 findings (reddit + x + hackernews + polymarket)
assert len(findings) == 4
# Check all sources are present
sources = {f["source"] for f in findings}
assert sources == {"reddit", "x", "hackernews", "polymarket"}
def test_findings_from_report_includes_hackernews(sample_report):
"""Test that HN items are extracted correctly (PR #85 feature)."""
findings = store.findings_from_report(sample_report)
hn_findings = [f for f in findings if f["source"] == "hackernews"]
assert len(hn_findings) == 1
hn = hn_findings[0]
assert hn["source_url"] == "https://news.ycombinator.com/item?id=12345"
assert hn["source_title"] == "Test HN Story"
assert hn["engagement_score"] == 120.0
assert hn["relevance_score"] == 0.9
assert "HN story content" in hn["content"]
def test_findings_from_report_includes_polymarket(sample_report):
"""Test that Polymarket items are extracted correctly (PR #85 feature)."""
findings = store.findings_from_report(sample_report)
pm_findings = [f for f in findings if f["source"] == "polymarket"]
assert len(pm_findings) == 1
pm = pm_findings[0]
assert pm["source_url"] == "https://polymarket.com/event/test-event"
assert pm["source_title"] == "Will event happen?"
assert pm["engagement_score"] == 342000.0
assert pm["relevance_score"] == 0.7
assert "Yes: 64%" in pm["content"]
def test_findings_from_report_respects_limit(sample_report):
"""Test that limit parameter works correctly."""
findings = store.findings_from_report(sample_report, limit=2)
# Should have at most 2 items per source
source_counts = {}
for f in findings:
source = f["source"]
source_counts[source] = source_counts.get(source, 0) + 1
for count in source_counts.values():
assert count <= 2
def test_findings_from_report_handles_empty_sources():
"""Test that empty sources in items_by_source don't cause issues."""
report = schema.report_from_dict({
"topic": "Test",
"range_from": "2026-01-01",
"range_to": "2026-04-03",
"generated_at": "2026-04-03T00:00:00Z",
"provider_runtime": {
"reasoning_provider": "gemini",
"planner_model": "gemini-2.0-flash-exp",
"rerank_model": "gemini-2.0-flash-exp",
},
"query_plan": {
"intent": "test",
"freshness_mode": "recent",
"cluster_mode": "standard",
"raw_topic": "test",
"subqueries": [],
"source_weights": {},
},
"clusters": [],
"ranked_candidates": [],
"items_by_source": {
"reddit": [],
"x": [],
"hackernews": [],
"polymarket": [],
},
"errors_by_source": {},
"warnings": [],
})
findings = store.findings_from_report(report)
assert len(findings) == 0
def test_findings_from_report_handles_missing_fields():
"""Test that missing optional fields (author, snippet) are handled gracefully."""
report = schema.report_from_dict({
"topic": "Test",
"range_from": "2026-01-01",
"range_to": "2026-04-03",
"generated_at": "2026-04-03T00:00:00Z",
"provider_runtime": {
"reasoning_provider": "gemini",
"planner_model": "gemini-2.0-flash-exp",
"rerank_model": "gemini-2.0-flash-exp",
},
"query_plan": {
"intent": "test",
"freshness_mode": "recent",
"cluster_mode": "standard",
"raw_topic": "test",
"subqueries": [],
"source_weights": {},
},
"clusters": [],
"ranked_candidates": [],
"items_by_source": {
"reddit": [
{
"item_id": "R1",
"source": "reddit",
"title": "Test",
"body": "Content",
"url": "https://reddit.com/1",
"author": None, # Missing author
"engagement_score": None, # Missing engagement
"local_relevance": None, # Missing relevance
"snippet": None, # Missing snippet
}
],
},
"errors_by_source": {},
"warnings": [],
})
findings = store.findings_from_report(report)
assert len(findings) == 1
f = findings[0]
assert f["author"] == ""
assert f["engagement_score"] == 0.0
assert f["relevance_score"] == 0.5
assert f["summary"] == "Content" # Falls back to body
# === Tests for store_findings() ===
def test_store_findings_basic(temp_db, sample_report):
"""Test basic storage of findings."""
topic = store.add_topic("Test Topic")
run_id = store.record_run(topic["id"], source_mode="v3")
findings = store.findings_from_report(sample_report)
counts = store.store_findings(run_id, topic["id"], findings)
assert counts["new"] == 4
assert counts["updated"] == 0
# Verify in database
conn = sqlite3.connect(str(temp_db))
total = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0]
assert total == 4
conn.close()
def test_store_findings_deduplicates_by_url(temp_db, sample_report):
"""Test that duplicate URLs are detected and updated, not duplicated."""
topic = store.add_topic("Test Topic")
run_id = store.record_run(topic["id"], source_mode="v3")
findings = store.findings_from_report(sample_report)
# Store once
counts1 = store.store_findings(run_id, topic["id"], findings)
assert counts1["new"] == 4
assert counts1["updated"] == 0
# Store again (same URLs)
counts2 = store.store_findings(run_id, topic["id"], findings)
assert counts2["new"] == 0
assert counts2["updated"] == 4
# Verify total count didn't double
conn = sqlite3.connect(str(temp_db))
total = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0]
assert total == 4
conn.close()
def test_store_findings_updates_engagement_on_resighting(temp_db, sample_report):
"""Test that re-sighting a finding updates engagement score if higher."""
topic = store.add_topic("Test Topic")
run_id = store.record_run(topic["id"], source_mode="v3")
findings = store.findings_from_report(sample_report)
# Store with initial engagement
store.store_findings(run_id, topic["id"], findings)
# Modify engagement score for HN finding
hn_finding = next(f for f in findings if f["source"] == "hackernews")
hn_finding["engagement_score"] = 200.0 # Higher than original 120.0
# Store again
store.store_findings(run_id, topic["id"], [hn_finding])
# Verify engagement was updated
conn = sqlite3.connect(str(temp_db))
score = conn.execute(
"SELECT engagement_score FROM findings WHERE source='hackernews'"
).fetchone()[0]
assert score == 200.0
conn.close()
def test_store_findings_increments_sighting_count(temp_db, sample_report):
"""Test that re-sighting increments sighting_count."""
topic = store.add_topic("Test Topic")
run_id = store.record_run(topic["id"], source_mode="v3")
findings = store.findings_from_report(sample_report)
# Store once
store.store_findings(run_id, topic["id"], findings)
# Store again
store.store_findings(run_id, topic["id"], findings)
# Verify sighting_count
conn = sqlite3.connect(str(temp_db))
counts = conn.execute(
"SELECT sighting_count FROM findings"
).fetchall()
for (count,) in counts:
assert count == 2 # Seen twice
conn.close()
def test_store_findings_skips_items_without_url(temp_db):
"""Test that findings without URLs are skipped."""
topic = store.add_topic("Test Topic")
run_id = store.record_run(topic["id"], source_mode="v3")
findings = [
{
"source": "reddit",
"source_url": None, # Missing URL
"source_title": "Test",
"content": "Content",
},
{
"source": "reddit",
"source_url": "https://reddit.com/1", # Has URL
"source_title": "Test 2",
"content": "Content 2",
},
]
counts = store.store_findings(run_id, topic["id"], findings)
# Only the one with URL should be stored
assert counts["new"] == 1
# === Tests for topic management ===
def test_add_topic(temp_db):
"""Test adding a topic."""
topic = store.add_topic("Test Topic", schedule="0 8 * * *")
assert topic["name"] == "Test Topic"
assert topic["schedule"] == "0 8 * * *"
assert topic["enabled"] == 1
def test_add_topic_with_search_queries(temp_db):
"""Test adding a topic with custom search queries."""
topic = store.add_topic(
"Test Topic",
search_queries=["query1", "query2"],
schedule="0 8 * * *",
)
assert topic["name"] == "Test Topic"
assert json.loads(topic["search_queries"]) == ["query1", "query2"]
def test_remove_topic_cascades_findings(temp_db, sample_report):
"""Test that removing a topic deletes its findings and runs."""
topic = store.add_topic("Test Topic")
run_id = store.record_run(topic["id"], source_mode="v3")
findings = store.findings_from_report(sample_report)
store.store_findings(run_id, topic["id"], findings)
# Verify data exists
conn = sqlite3.connect(str(temp_db))
finding_count = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0]
run_count = conn.execute("SELECT COUNT(*) FROM research_runs").fetchone()[0]
assert finding_count == 4
assert run_count == 1
conn.close()
# Remove topic
removed = store.remove_topic("Test Topic")
assert removed is True
# Verify cascade delete
conn = sqlite3.connect(str(temp_db))
finding_count = conn.execute("SELECT COUNT(*) FROM findings").fetchone()[0]
run_count = conn.execute("SELECT COUNT(*) FROM research_runs").fetchone()[0]
assert finding_count == 0
assert run_count == 0
conn.close()
def test_list_topics(temp_db):
"""Test listing topics with stats."""
# Add multiple topics
store.add_topic("Topic 1")
store.add_topic("Topic 2")
store.add_topic("Topic 3")
topics = store.list_topics()
assert len(topics) == 3
assert {t["name"] for t in topics} == {"Topic 1", "Topic 2", "Topic 3"}
# Check that stats fields are present
for topic in topics:
assert "finding_count" in topic
assert "last_run" in topic
assert "last_status" in topic
# === Tests for get_new_findings() ===
def test_get_new_findings(temp_db, sample_report):
"""Test retrieving new findings for a topic."""
topic = store.add_topic("Test Topic")
run_id = store.record_run(topic["id"], source_mode="v3")
findings = store.findings_from_report(sample_report)
store.store_findings(run_id, topic["id"], findings)
new_findings = store.get_new_findings(topic["id"])
assert len(new_findings) == 4
sources = {f["source"] for f in new_findings}
assert "hackernews" in sources
assert "polymarket" in sources
def test_get_new_findings_filters_by_date(temp_db, sample_report):
"""Test that since parameter filters findings correctly."""
topic = store.add_topic("Test Topic")
run_id = store.record_run(topic["id"], source_mode="v3")
findings = store.findings_from_report(sample_report)
store.store_findings(run_id, topic["id"], findings)
# Get findings since tomorrow (should be empty)
tomorrow = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d")
new_findings = store.get_new_findings(topic["id"], since=tomorrow)
assert len(new_findings) == 0
# Get findings since yesterday (should have all)
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
new_findings = store.get_new_findings(topic["id"], since=yesterday)
assert len(new_findings) == 4
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+83 -242
View File
@@ -1,267 +1,108 @@
"""Tests for TikTok module (search, normalize, score, dedupe, render)."""
import json
import sys
import unittest
from pathlib import Path
# Add lib to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import schema, score, normalize, dedupe, render
from lib import tiktok
from lib.tiktok import _parse_items
class TestTikTokRelevance(unittest.TestCase):
"""Test relevance scoring for TikTok items."""
class TestTikTokAuthorTypeSafety(unittest.TestCase):
def _make_raw(self, **overrides):
base = {
"aweme_id": "1",
"desc": "test video",
"share_url": "https://www.tiktok.com/@u/video/1",
"author": {"unique_id": "testuser"},
"statistics": {"play_count": 100, "digg_count": 50, "comment_count": 10, "share_count": 5},
}
base.update(overrides)
return base
def test_exact_match(self):
rel = tiktok._compute_relevance("claude code", "Claude Code tricks and tips")
self.assertGreaterEqual(rel, 0.8)
def test_author_as_dict(self):
items = _parse_items([self._make_raw()], "test")
self.assertEqual("testuser", items[0]["author_name"])
def test_partial_match(self):
rel = tiktok._compute_relevance("claude code tips", "Best AI tools for coding")
self.assertLess(rel, 0.5)
def test_author_as_string(self):
items = _parse_items([self._make_raw(author="stringuser")], "test")
self.assertEqual("stringuser", items[0]["author_name"])
def test_hashtag_boost(self):
"""Hashtags should boost relevance."""
rel_no_hash = tiktok._compute_relevance("claude code", "random video about stuff")
rel_with_hash = tiktok._compute_relevance("claude code", "random video about stuff", ["claudecode", "ai"])
self.assertGreater(rel_with_hash, rel_no_hash)
def test_author_missing(self):
raw = self._make_raw()
del raw["author"]
items = _parse_items([raw], "test")
self.assertEqual("", items[0]["author_name"])
def test_empty_query(self):
rel = tiktok._compute_relevance("", "Some video title")
self.assertEqual(rel, 0.5)
def test_no_match_returns_zero(self):
rel = tiktok._compute_relevance("quantum physics", "cat dancing video")
self.assertEqual(rel, 0.0)
def test_author_none(self):
items = _parse_items([self._make_raw(author=None)], "test")
self.assertEqual("", items[0]["author_name"])
class TestExtractCoreSubject(unittest.TestCase):
"""Test core subject extraction for TikTok search."""
class TestTikTokStatsZeroPreserved(unittest.TestCase):
def test_zero_play_count(self):
raw = {
"aweme_id": "1",
"desc": "test",
"share_url": "https://www.tiktok.com/@u/video/1",
"author": {"unique_id": "u"},
"statistics": {"play_count": 0, "digg_count": 0, "comment_count": 0, "share_count": 0},
}
items = _parse_items([raw], "test")
self.assertEqual(0, items[0]["engagement"]["views"])
self.assertEqual(0, items[0]["engagement"]["likes"])
self.assertEqual(0, items[0]["engagement"]["comments"])
self.assertEqual(0, items[0]["engagement"]["shares"])
def test_strips_prefix(self):
result = tiktok._extract_core_subject("what are the best claude code tips")
self.assertNotIn("what are the best", result)
self.assertIn("claude", result)
def test_stats_missing(self):
raw = {
"aweme_id": "1",
"desc": "test",
"share_url": "https://www.tiktok.com/@u/video/1",
"author": {"unique_id": "u"},
}
items = _parse_items([raw], "test")
self.assertEqual(0, items[0]["engagement"]["views"])
def test_strips_noise(self):
result = tiktok._extract_core_subject("latest trending updates on React")
self.assertNotIn("latest", result)
self.assertNotIn("trending", result)
self.assertIn("react", result.lower())
def test_preserves_core(self):
result = tiktok._extract_core_subject("Claude Code")
self.assertEqual(result, "claude code")
def test_stats_as_non_dict(self):
raw = {
"aweme_id": "1",
"desc": "test",
"share_url": "https://www.tiktok.com/@u/video/1",
"author": {"unique_id": "u"},
"statistics": "invalid",
}
items = _parse_items([raw], "test")
self.assertEqual(0, items[0]["engagement"]["views"])
class TestParseDate(unittest.TestCase):
"""Test date parsing from ScrapeCreators items."""
class TestExpandTikTokQueries(unittest.TestCase):
"""Tests for expand_tiktok_queries() multi-query generation."""
def test_unix_timestamp(self):
item = {"create_time": 1756403075}
result = tiktok._parse_date(item)
self.assertIsNotNone(result)
self.assertRegex(result, r"\d{4}-\d{2}-\d{2}")
def test_no_date(self):
item = {}
self.assertIsNone(tiktok._parse_date(item))
class TestCleanWebVTT(unittest.TestCase):
"""Test WebVTT transcript cleaning."""
def test_strips_timestamps(self):
raw = "WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nHello world\n\n00:00:02.000 --> 00:00:04.000\nGoodbye"
result = tiktok._clean_webvtt(raw)
self.assertEqual(result, "Hello world Goodbye")
def test_empty_input(self):
self.assertEqual(tiktok._clean_webvtt(""), "")
self.assertEqual(tiktok._clean_webvtt(None), "")
class TestNormalizeTikTokItems(unittest.TestCase):
"""Test TikTok normalization."""
def setUp(self):
self.fixtures_dir = Path(__file__).parent.parent / "fixtures"
with open(self.fixtures_dir / "tiktok_search.json") as f:
data = json.load(f)
self.raw_items = data["items"]
def test_normalizes_items(self):
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
self.assertEqual(len(items), 3)
self.assertIsInstance(items[0], schema.TikTokItem)
def test_ids_are_sequential(self):
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
self.assertEqual(items[0].id, "TK1")
self.assertEqual(items[1].id, "TK2")
self.assertEqual(items[2].id, "TK3")
def test_engagement_parsed(self):
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
eng = items[0].engagement
self.assertIsNotNone(eng)
self.assertEqual(eng.views, 2100000)
self.assertEqual(eng.likes, 45000)
self.assertEqual(eng.shares, 8400)
def test_hashtags_preserved(self):
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
self.assertEqual(items[0].hashtags, ["claudecode", "ai", "coding"])
def test_caption_snippet_preserved(self):
items = normalize.normalize_tiktok_items(self.raw_items, "2026-02-01", "2026-03-03")
self.assertIn("slash commands", items[0].caption_snippet)
class TestScoreTikTokItems(unittest.TestCase):
"""Test TikTok scoring."""
def test_engagement_scoring(self):
eng = schema.Engagement(views=1000000, likes=50000, num_comments=2000)
raw = score.compute_tiktok_engagement_raw(eng)
self.assertIsNotNone(raw)
self.assertGreater(raw, 0)
def test_none_engagement(self):
raw = score.compute_tiktok_engagement_raw(None)
self.assertIsNone(raw)
def test_empty_engagement(self):
eng = schema.Engagement()
raw = score.compute_tiktok_engagement_raw(eng)
self.assertIsNone(raw)
def test_scoring_pipeline(self):
items = [
schema.TikTokItem(
id="TK1", text="High views video", url="https://tiktok.com/1",
author_name="creator1", date="2026-03-01",
engagement=schema.Engagement(views=2000000, likes=50000, num_comments=1000),
relevance=0.9,
),
schema.TikTokItem(
id="TK2", text="Low views video", url="https://tiktok.com/2",
author_name="creator2", date="2026-02-20",
engagement=schema.Engagement(views=1000, likes=50, num_comments=5),
relevance=0.5,
),
]
scored = score.score_tiktok_items(items)
self.assertEqual(len(scored), 2)
self.assertGreater(scored[0].score, 0)
self.assertGreater(scored[0].score, scored[1].score)
class TestDedupeTikTok(unittest.TestCase):
"""Test TikTok deduplication."""
def test_no_dupes(self):
items = [
schema.TikTokItem(id="TK1", text="Totally different video A",
url="https://tiktok.com/1", author_name="a", score=80),
schema.TikTokItem(id="TK2", text="Completely unique video B",
url="https://tiktok.com/2", author_name="b", score=70),
]
result = dedupe.dedupe_tiktok(items)
self.assertEqual(len(result), 2)
def test_removes_dupes(self):
items = [
schema.TikTokItem(id="TK1", text="Claude Code is amazing for AI coding",
url="https://tiktok.com/1", author_name="a", score=80),
schema.TikTokItem(id="TK2", text="Claude Code is amazing for AI coding wow",
url="https://tiktok.com/2", author_name="a", score=60),
]
result = dedupe.dedupe_tiktok(items)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].id, "TK1") # Higher score kept
class TestRenderTikTok(unittest.TestCase):
"""Test TikTok rendering in reports."""
def test_renders_tiktok_section(self):
report = schema.Report(
topic="test", range_from="2026-02-01", range_to="2026-03-03",
generated_at="2026-03-03T00:00:00Z", mode="all",
tiktok=[
schema.TikTokItem(
id="TK1", text="Video caption here", url="https://tiktok.com/1",
author_name="creator", date="2026-03-01", score=85,
engagement=schema.Engagement(views=1000000, likes=50000),
hashtags=["ai", "coding"],
why_relevant="TikTok: Video caption here",
),
],
def test_default_depth_returns_two_plus_queries(self):
from lib.tiktok import expand_tiktok_queries
queries = expand_tiktok_queries("Kanye West", "default")
self.assertGreaterEqual(len(queries), 2)
# Breaking_news intent should include reaction/edit variant
variant_found = any(
"reaction" in q.lower() or "edit" in q.lower() or "trend" in q.lower()
for q in queries
)
output = render.render_compact(report)
self.assertIn("### TikTok Videos", output)
self.assertIn("TK1", output)
self.assertIn("@creator", output)
self.assertIn("1,000,000 views", output)
self.assertTrue(variant_found, f"Expected reaction/edit/trend variant: {queries}")
def test_renders_source_status(self):
report = schema.Report(
topic="test", range_from="2026-02-01", range_to="2026-03-03",
generated_at="2026-03-03T00:00:00Z", mode="all",
tiktok=[
schema.TikTokItem(
id="TK1", text="test", url="https://tiktok.com/1",
author_name="creator", caption_snippet="some caption",
),
],
def test_product_intent_includes_review_variant(self):
from lib.tiktok import expand_tiktok_queries
# "best laptop for coding" triggers the product intent (best .* for pattern)
queries = expand_tiktok_queries("best laptop for coding", "deep")
variant_found = any(
"review" in q.lower() or "haul" in q.lower() or "unboxing" in q.lower()
for q in queries
)
status = render.render_source_status(report)
self.assertIn("TikTok", status)
self.assertIn("1 videos", status)
self.assertTrue(variant_found, f"Expected review/haul/unboxing variant: {queries}")
def test_xref_tag_tiktok(self):
"""Test that TK prefix is recognized in cross-ref tags."""
item = schema.RedditItem(id="R1", title="test", url="test", subreddit="test",
cross_refs=["TK1"])
tag = render._xref_tag(item)
self.assertIn("TikTok", tag)
class TestSchemaRoundtrip(unittest.TestCase):
"""Test TikTokItem serialization round-trip via Report."""
def test_to_dict_and_back(self):
original = schema.TikTokItem(
id="TK1", text="Test caption", url="https://tiktok.com/1",
author_name="creator", date="2026-03-01",
date_confidence="high",
engagement=schema.Engagement(views=100, likes=10, num_comments=5, shares=3),
caption_snippet="spoken words",
hashtags=["test", "ai"],
relevance=0.8, why_relevant="TikTok: Test",
subs=schema.SubScores(relevance=80, recency=90, engagement=70),
score=80, cross_refs=["R1"],
)
report = schema.Report(
topic="test", range_from="2026-02-01", range_to="2026-03-03",
generated_at="2026-03-03T00:00:00Z", mode="all",
tiktok=[original],
)
d = report.to_dict()
restored = schema.Report.from_dict(d)
self.assertEqual(len(restored.tiktok), 1)
tk = restored.tiktok[0]
self.assertEqual(tk.id, "TK1")
self.assertEqual(tk.author_name, "creator")
self.assertEqual(tk.hashtags, ["test", "ai"])
self.assertEqual(tk.engagement.views, 100)
self.assertEqual(tk.engagement.shares, 3)
self.assertEqual(tk.caption_snippet, "spoken words")
self.assertEqual(tk.cross_refs, ["R1"])
def test_quick_depth_returns_one_query(self):
from lib.tiktok import expand_tiktok_queries
queries = expand_tiktok_queries("Kanye West", "quick")
self.assertEqual(len(queries), 1)
if __name__ == "__main__":
+77
View File
@@ -0,0 +1,77 @@
# ruff: noqa: E402
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib import ui
class UiV3Tests(unittest.TestCase):
def test_show_diagnostic_banner_uses_v3_source_model(self):
diag = {
"available_sources": ["grounding", "youtube"],
"providers": {"google": True, "openai": False, "xai": False},
"x_backend": None,
"bird_installed": True,
"bird_authenticated": False,
"bird_username": None,
"native_web_backend": "brave",
}
with mock.patch.object(ui, "IS_TTY", False):
stderr = io.StringIO()
with redirect_stderr(stderr):
ui.show_diagnostic_banner(diag)
output = stderr.getvalue()
self.assertIn("Reddit", output)
self.assertIn("unavailable", output)
self.assertIn("Add AUTH_TOKEN/CT0 or XAI_API_KEY", output)
self.assertIn("brave API available", output)
def test_build_nux_message_mentions_v3_unlock_paths(self):
text = ui._build_nux_message(
{"available_sources": ["reddit", "youtube", "grounding"]}
)
self.assertIn("Reddit ✓, X ✗, YouTube ✓, Web ✓", text)
self.assertIn("works fine as-is", text)
self.assertIn("all free", text)
def test_show_complete_uses_actual_sources_for_source_restricted_runs(self):
with mock.patch.object(ui, "IS_TTY", False):
stderr = io.StringIO()
with redirect_stderr(stderr):
progress = ui.ProgressDisplay("test topic", show_banner=False)
progress.show_complete(
source_counts={"grounding": 2},
display_sources=["grounding"],
)
output = stderr.getvalue()
self.assertIn("Web: 2 results", output)
self.assertNotIn("Reddit:", output)
self.assertNotIn("X:", output)
def test_show_complete_supports_newer_sources(self):
with mock.patch.object(ui, "IS_TTY", False):
stderr = io.StringIO()
with redirect_stderr(stderr):
progress = ui.ProgressDisplay("test topic", show_banner=False)
progress.show_complete(
source_counts={
"bluesky": 3,
"truthsocial": 1,
"xiaohongshu": 4,
},
display_sources=["bluesky", "truthsocial", "xiaohongshu"],
)
output = stderr.getvalue()
self.assertIn("Bluesky: 3 posts", output)
self.assertIn("Truth Social: 1 post", output)
self.assertIn("Xiaohongshu: 4 posts", output)
if __name__ == "__main__":
unittest.main()
+27
View File
@@ -0,0 +1,27 @@
import importlib.util
import unittest
from pathlib import Path
def load_verify_module():
path = Path(__file__).resolve().parents[1] / "scripts" / "verify_v3.py"
spec = importlib.util.spec_from_file_location("verify_v3_module", path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
class VerifyV3Tests(unittest.TestCase):
def test_parser_defaults(self):
module = load_verify_module()
parser = module.build_parser()
args = parser.parse_args([])
self.assertEqual(args.baseline, "HEAD~1")
self.assertEqual(args.candidate, "WORKTREE")
self.assertFalse(args.skip_eval)
self.assertFalse(args.skip_latency)
if __name__ == "__main__":
unittest.main()
+487
View File
@@ -0,0 +1,487 @@
"""Tests for watchlist.py command functions."""
import json
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
import store
import watchlist
from lib import schema
@pytest.fixture
def temp_db():
"""Create a temporary database for testing."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = Path(f.name)
# Override the database path
original_override = store._db_override
store._db_override = db_path
# Initialize fresh database
store.init_db()
yield db_path
# Cleanup
store._db_override = original_override
if db_path.exists():
db_path.unlink()
# === Tests for cmd_add() ===
def test_cmd_add_basic(temp_db, capsys):
"""Test adding a topic with default schedule."""
args = Mock()
args.topic = "Test Topic"
args.weekly = False
args.schedule = None
args.queries = None
watchlist.cmd_add(args)
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["action"] == "added"
assert output["topic"] == "Test Topic"
assert "daily" in output["schedule"]
def test_cmd_add_with_custom_schedule(temp_db, capsys):
"""Test adding a topic with custom schedule."""
args = Mock()
args.topic = "Test Topic"
args.weekly = False
args.schedule = "0 12 * * *"
args.queries = None
watchlist.cmd_add(args)
# Verify in database
topic = store.get_topic("Test Topic")
assert topic["schedule"] == "0 12 * * *"
def test_cmd_add_weekly(temp_db, capsys):
"""Test adding a topic with weekly schedule."""
args = Mock()
args.topic = "Test Topic"
args.weekly = True
args.schedule = None
args.queries = None
watchlist.cmd_add(args)
# Verify weekly schedule
topic = store.get_topic("Test Topic")
assert topic["schedule"] == "0 8 * * 1" # Monday 8am
def test_cmd_add_with_search_queries(temp_db, capsys):
"""Test adding a topic with custom search queries."""
args = Mock()
args.topic = "Test Topic"
args.weekly = False
args.schedule = None
args.queries = "query1, query2, query3"
watchlist.cmd_add(args)
# Verify queries stored
topic = store.get_topic("Test Topic")
queries = json.loads(topic["search_queries"])
assert queries == ["query1", "query2", "query3"]
# === Tests for cmd_remove() ===
def test_cmd_remove_existing_topic(temp_db, capsys):
"""Test removing an existing topic."""
# Add a topic first
store.add_topic("Test Topic")
args = Mock()
args.topic = "Test Topic"
watchlist.cmd_remove(args)
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["action"] == "removed"
assert output["topic"] == "Test Topic"
def test_cmd_remove_nonexistent_topic(temp_db, capsys):
"""Test removing a topic that doesn't exist."""
args = Mock()
args.topic = "Nonexistent Topic"
watchlist.cmd_remove(args)
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["action"] == "not_found"
assert output["topic"] == "Nonexistent Topic"
# === Tests for cmd_list() ===
def test_cmd_list_empty(temp_db, capsys):
"""Test listing when no topics exist."""
args = Mock()
watchlist.cmd_list(args)
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["topics"] == []
assert output["budget_used"] == 0.0
assert output["budget_limit"] == 5.0
def test_cmd_list_with_topics(temp_db, capsys):
"""Test listing with multiple topics."""
# Add topics
store.add_topic("Topic 1")
store.add_topic("Topic 2")
store.add_topic("Topic 3")
args = Mock()
watchlist.cmd_list(args)
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert len(output["topics"]) == 3
topic_names = {t["name"] for t in output["topics"]}
assert topic_names == {"Topic 1", "Topic 2", "Topic 3"}
# === Tests for cmd_config() ===
def test_cmd_config_delivery(temp_db, capsys):
"""Test configuring delivery channel."""
args = Mock()
args.key = "delivery"
args.value = "https://hooks.slack.com/services/TEST"
watchlist.cmd_config(args)
# Verify setting stored
channel = store.get_setting("delivery_channel")
assert channel == "https://hooks.slack.com/services/TEST"
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["action"] == "config"
assert output["key"] == "delivery_channel"
def test_cmd_config_budget(temp_db, capsys):
"""Test configuring daily budget."""
args = Mock()
args.key = "budget"
args.value = 10.0
watchlist.cmd_config(args)
# Verify setting stored
budget = store.get_setting("daily_budget")
assert budget == "10.0"
def test_cmd_config_unknown_key(temp_db):
"""Test that unknown config key raises error."""
args = Mock()
args.key = "unknown_key"
args.value = "value"
with pytest.raises(SystemExit):
watchlist.cmd_config(args)
# === Tests for _run_topic() ===
@patch('watchlist.subprocess.run')
def test_run_topic_success(mock_subprocess, temp_db):
"""Test successful topic run."""
topic = store.add_topic("Test Topic")
# Mock successful subprocess call
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = json.dumps({
"topic": "Test Topic",
"range_from": "2026-01-01",
"range_to": "2026-04-03",
"generated_at": "2026-04-03T00:00:00Z",
"provider_runtime": {
"reasoning_provider": "gemini",
"planner_model": "gemini-2.0-flash-exp",
"rerank_model": "gemini-2.0-flash-exp",
},
"query_plan": {
"intent": "test",
"freshness_mode": "recent",
"cluster_mode": "standard",
"raw_topic": "test",
"subqueries": [],
"source_weights": {},
},
"clusters": [],
"ranked_candidates": [],
"items_by_source": {
"reddit": [
{
"item_id": "R1",
"source": "reddit",
"title": "Test",
"body": "Content",
"url": "https://reddit.com/1",
"author": "user",
"engagement_score": 50.0,
"local_relevance": 0.8,
"snippet": "Snippet",
}
],
},
"errors_by_source": {},
"warnings": [],
})
mock_subprocess.return_value = mock_result
result = watchlist._run_topic(topic)
assert result["status"] == "completed"
assert result["new"] == 1
assert result["topic"] == "Test Topic"
@patch('watchlist.subprocess.run')
def test_run_topic_failure(mock_subprocess, temp_db):
"""Test topic run failure."""
topic = store.add_topic("Test Topic")
# Mock failed subprocess call
mock_result = Mock()
mock_result.returncode = 1
mock_result.stderr = "Error message"
mock_subprocess.return_value = mock_result
result = watchlist._run_topic(topic)
assert result["status"] == "failed"
assert "Error message" in result["error"]
@patch('watchlist.subprocess.run')
def test_run_topic_timeout(mock_subprocess, temp_db):
"""Test topic run timeout."""
topic = store.add_topic("Test Topic")
# Mock timeout
mock_subprocess.side_effect = subprocess.TimeoutExpired("cmd", 300)
result = watchlist._run_topic(topic)
assert result["status"] == "failed"
assert result["error"] == "timeout"
@patch('watchlist.subprocess.run')
@patch('watchlist._deliver_findings')
def test_run_topic_calls_delivery(mock_deliver, mock_subprocess, temp_db):
"""Test that successful run calls delivery."""
topic = store.add_topic("Test Topic")
# Mock successful subprocess call with findings
mock_result = Mock()
mock_result.returncode = 0
mock_result.stdout = json.dumps({
"topic": "Test Topic",
"range_from": "2026-01-01",
"range_to": "2026-04-03",
"generated_at": "2026-04-03T00:00:00Z",
"provider_runtime": {
"reasoning_provider": "gemini",
"planner_model": "gemini-2.0-flash-exp",
"rerank_model": "gemini-2.0-flash-exp",
},
"query_plan": {
"intent": "test",
"freshness_mode": "recent",
"cluster_mode": "standard",
"raw_topic": "test",
"subqueries": [],
"source_weights": {},
},
"clusters": [],
"ranked_candidates": [],
"items_by_source": {
"reddit": [
{
"item_id": "R1",
"source": "reddit",
"title": "Test",
"body": "Content",
"url": "https://reddit.com/1",
"author": "user",
"engagement_score": 50.0,
"local_relevance": 0.8,
"snippet": "Snippet",
}
],
},
"errors_by_source": {},
"warnings": [],
})
mock_subprocess.return_value = mock_result
watchlist._run_topic(topic)
# Verify delivery was called
assert mock_deliver.called
call_args = mock_deliver.call_args[0]
assert call_args[0] == "Test Topic"
assert call_args[1]["new"] == 1
# === Tests for cmd_run_one() ===
@patch('watchlist._run_topic')
def test_cmd_run_one(mock_run, temp_db, capsys):
"""Test running a single topic."""
topic = store.add_topic("Test Topic")
mock_run.return_value = {
"topic": "Test Topic",
"status": "completed",
"new": 5,
"updated": 2,
"duration": 60.0,
}
args = Mock()
args.topic = "Test Topic"
watchlist.cmd_run_one(args)
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["status"] == "completed"
assert output["new"] == 5
def test_cmd_run_one_nonexistent_topic(temp_db, capsys):
"""Test running a nonexistent topic."""
args = Mock()
args.topic = "Nonexistent Topic"
with pytest.raises(SystemExit):
watchlist.cmd_run_one(args)
# === Tests for cmd_run_all() ===
@patch('watchlist._run_topic')
def test_cmd_run_all_no_topics(mock_run, temp_db, capsys):
"""Test running all topics when none exist."""
args = Mock()
watchlist.cmd_run_all(args)
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert "No enabled topics" in output["message"]
@patch('watchlist._run_topic')
def test_cmd_run_all_multiple_topics(mock_run, temp_db, capsys):
"""Test running multiple topics."""
# Add topics
store.add_topic("Topic 1")
store.add_topic("Topic 2")
mock_run.return_value = {
"topic": "Test",
"status": "completed",
"new": 5,
"updated": 2,
"duration": 60.0,
}
args = Mock()
watchlist.cmd_run_all(args)
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["action"] == "run_all"
assert len(output["results"]) == 2
@patch('watchlist._run_topic')
@patch('watchlist.store.get_daily_cost')
def test_cmd_run_all_respects_budget(mock_cost, mock_run, temp_db, capsys):
"""Test that run-all respects daily budget."""
# Add topics
store.add_topic("Topic 1")
store.add_topic("Topic 2")
store.add_topic("Topic 3")
# Mock budget exceeded (budget limit is 5.0)
mock_cost.return_value = 6.0 # Over budget
mock_run.return_value = {
"topic": "Test",
"status": "completed",
"new": 5,
"updated": 2,
"duration": 60.0,
}
args = Mock()
watchlist.cmd_run_all(args)
# Verify output
captured = capsys.readouterr()
output = json.loads(captured.out)
# All topics should be skipped due to budget
results = output["results"]
skipped = [r for r in results if r["status"] == "skipped"]
assert len(skipped) == 3 # All 3 topics skipped
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+291
View File
@@ -0,0 +1,291 @@
"""Tests for watchlist.py delivery functions (PR #86 feature)."""
import sys
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
import watchlist
# === Tests for _format_delivery_message() ===
def test_format_message_announce_mode():
"""Test announce mode formatting (default mode with emoji)."""
message = watchlist._format_delivery_message(
"Test Topic", {"new": 5, "updated": 2}, "announce"
)
assert "📰" in message
assert "Test Topic" in message
assert "5 new" in message
assert "2 updated" in message
def test_format_message_silent_mode():
"""Test silent mode formatting (no emoji)."""
message = watchlist._format_delivery_message(
"Test Topic", {"new": 5, "updated": 2}, "silent"
)
assert "📰" not in message
assert "Test Topic" in message
assert "5 new" in message
def test_format_message_default_mode():
"""Test default mode formatting."""
message = watchlist._format_delivery_message(
"Test Topic", {"new": 5, "updated": 2}, "default"
)
assert "complete" in message.lower()
assert "Test Topic" in message
def test_format_message_handles_zero_counts():
"""Test formatting with zero counts."""
message = watchlist._format_delivery_message(
"Test Topic", {"new": 0, "updated": 0}, "announce"
)
assert "0 new" in message
assert "0 updated" in message
# === Tests for _send_slack_webhook() ===
@patch('watchlist.requests')
def test_send_slack_webhook_format(mock_requests):
"""Test that Slack webhook uses correct format."""
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._send_slack_webhook(
"https://hooks.slack.com/services/TEST",
"Test message"
)
# Verify POST was called with correct format
assert mock_requests.post.called
call_args = mock_requests.post.call_args
assert call_args[0][0] == "https://hooks.slack.com/services/TEST"
assert call_args[1]["json"] == {"text": "Test message"}
assert call_args[1]["headers"]["Content-Type"] == "application/json"
assert call_args[1]["timeout"] == 10
@patch('watchlist.requests')
def test_send_slack_webhook_raises_on_error(mock_requests):
"""Test that Slack webhook raises on HTTP error."""
mock_response = Mock()
mock_response.status_code = 400
mock_response.raise_for_status.side_effect = Exception("HTTP 400")
mock_requests.post.return_value = mock_response
with pytest.raises(Exception, match="HTTP 400"):
watchlist._send_slack_webhook(
"https://hooks.slack.com/services/TEST",
"Test message"
)
# === Tests for _send_generic_webhook() ===
@patch('watchlist.requests')
def test_send_generic_webhook_format(mock_requests):
"""Test that generic webhook uses correct format."""
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._send_generic_webhook(
"https://webhook.example.com/hook",
"Test message"
)
# Verify POST was called with correct format
assert mock_requests.post.called
call_args = mock_requests.post.call_args
assert call_args[0][0] == "https://webhook.example.com/hook"
json_data = call_args[1]["json"]
assert json_data["message"] == "Test message"
assert json_data["source"] == "last30days"
assert "timestamp" in json_data
assert isinstance(json_data["timestamp"], float)
@patch('watchlist.requests')
def test_send_generic_webhook_raises_on_error(mock_requests):
"""Test that generic webhook raises on HTTP error."""
mock_response = Mock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_requests.post.return_value = mock_response
with pytest.raises(Exception, match="HTTP 500"):
watchlist._send_generic_webhook(
"https://webhook.example.com/hook",
"Test message"
)
# === Tests for _deliver_findings() ===
@patch('watchlist.store.get_setting')
@patch('watchlist.requests')
def test_deliver_findings_sends_when_new_greater_than_zero(mock_requests, mock_get_setting):
"""Test that delivery fires when new > 0."""
mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce",
}.get(key, default)
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify webhook was called
assert mock_requests.post.called
@patch('watchlist.store.get_setting')
@patch('watchlist.requests')
def test_deliver_findings_skips_when_new_is_zero(mock_requests, mock_get_setting):
"""Test that delivery is skipped when new=0."""
mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce",
}.get(key, default)
watchlist._deliver_findings("Test Topic", {"new": 0, "updated": 5})
# Verify webhook was NOT called
assert not mock_requests.post.called
@patch('watchlist.store.get_setting')
@patch('watchlist.requests')
def test_deliver_findings_skips_when_channel_empty(mock_requests, mock_get_setting):
"""Test that delivery is skipped when delivery_channel is empty."""
mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "",
"delivery_mode": "announce",
}.get(key, default)
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify webhook was NOT called
assert not mock_requests.post.called
@patch('watchlist.store.get_setting')
@patch('watchlist.requests')
def test_deliver_findings_uses_slack_format_for_slack_urls(mock_requests, mock_get_setting):
"""Test that Slack URLs trigger Slack-specific format."""
mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://hooks.slack.com/services/TEST",
"delivery_mode": "announce",
}.get(key, default)
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify Slack format was used
call_args = mock_requests.post.call_args
json_data = call_args[1]["json"]
assert "text" in json_data
assert "Test Topic" in json_data["text"]
@patch('watchlist.store.get_setting')
@patch('watchlist.requests')
def test_deliver_findings_uses_generic_format_for_other_urls(mock_requests, mock_get_setting):
"""Test that non-Slack URLs trigger generic format."""
mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce",
}.get(key, default)
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify generic format was used
call_args = mock_requests.post.call_args
json_data = call_args[1]["json"]
assert "message" in json_data
assert "source" in json_data
assert "timestamp" in json_data
@patch('watchlist.store.get_setting')
@patch('watchlist.requests')
def test_deliver_findings_handles_failure_gracefully(mock_requests, mock_get_setting, capsys):
"""Test that delivery failures don't crash the process."""
mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce",
}.get(key, default)
# Simulate HTTP error
mock_response = Mock()
mock_response.status_code = 500
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_requests.post.return_value = mock_response
# Should not raise, just log to stderr
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
# Verify error was logged
captured = capsys.readouterr()
assert "Delivery failed" in captured.err
@patch('watchlist.store.get_setting')
@patch('watchlist.requests')
def test_deliver_findings_respects_delivery_mode(mock_requests, mock_get_setting):
"""Test that different delivery modes produce different messages."""
mock_response = Mock()
mock_response.status_code = 200
mock_requests.post.return_value = mock_response
# Test announce mode
mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "announce",
}.get(key, default)
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
announce_message = mock_requests.post.call_args[1]["json"]["message"]
assert "📰" in announce_message
# Test silent mode
mock_get_setting.side_effect = lambda key, default="": {
"delivery_channel": "https://webhook.example.com/test",
"delivery_mode": "silent",
}.get(key, default)
watchlist._deliver_findings("Test Topic", {"new": 5, "updated": 2})
silent_message = mock_requests.post.call_args[1]["json"]["message"]
assert "📰" not in silent_message
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+68
View File
@@ -0,0 +1,68 @@
import json
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from lib.xai_x import parse_x_response
def _wrap_items(items):
"""Wrap items list in the xAI response envelope."""
payload = json.dumps({"items": items})
return {
"output": [
{
"type": "message",
"content": [{"type": "output_text", "text": payload}],
}
]
}
class TestXaiXEngagementZero(unittest.TestCase):
def test_zero_likes_preserved(self):
raw_items = [
{
"text": "test",
"url": "https://x.com/u/status/1",
"engagement": {"likes": 0, "reposts": 5},
"relevance": 0.8,
}
]
items = parse_x_response(_wrap_items(raw_items))
self.assertEqual(0, items[0]["engagement"]["likes"])
self.assertEqual(5, items[0]["engagement"]["reposts"])
def test_none_engagement_when_missing(self):
raw_items = [
{
"text": "test",
"url": "https://x.com/u/status/1",
"engagement": {},
"relevance": 0.8,
}
]
items = parse_x_response(_wrap_items(raw_items))
self.assertIsNone(items[0]["engagement"]["likes"])
def test_nonzero_engagement(self):
raw_items = [
{
"text": "test",
"url": "https://x.com/u/status/1",
"engagement": {"likes": 42, "reposts": 3, "replies": 1, "quotes": 0},
"relevance": 0.9,
}
]
items = parse_x_response(_wrap_items(raw_items))
eng = items[0]["engagement"]
self.assertEqual(42, eng["likes"])
self.assertEqual(3, eng["reposts"])
self.assertEqual(1, eng["replies"])
self.assertEqual(0, eng["quotes"])
if __name__ == "__main__":
unittest.main()
+193 -2
View File
@@ -1,4 +1,4 @@
"""Tests for yt-dlp invocation safety flags."""
"""Tests for YouTube transcript highlights and yt-dlp safety flags."""
import json
import sys
@@ -25,6 +25,43 @@ class _DummyProc:
return 0
class TestYouTubeEngagementZero(unittest.TestCase):
"""Verify that 0 engagement counts are preserved (not coerced to fallback)."""
def test_zero_view_count_preserved(self):
"""video.get('view_count') == 0 must stay 0, not become the fallback."""
import json
import tempfile
import os
video = {
"id": "abc123",
"title": "Test",
"view_count": 0,
"like_count": 0,
"comment_count": 0,
"upload_date": "20260301",
"description": "desc",
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
f.write(json.dumps(video) + "\n")
f.flush()
with open(f.name) as rf:
lines = rf.readlines()
# Re-parse as the search function would
parsed = json.loads(lines[0])
view_count = parsed.get("view_count") if parsed.get("view_count") is not None else 0
like_count = parsed.get("like_count") if parsed.get("like_count") is not None else 0
comment_count = parsed.get("comment_count") if parsed.get("comment_count") is not None else 0
os.unlink(f.name)
self.assertEqual(0, view_count)
self.assertEqual(0, like_count)
self.assertEqual(0, comment_count)
class TestYtDlpFlags(unittest.TestCase):
def test_search_ignores_global_config_and_browser_cookies(self):
proc = _DummyProc()
@@ -61,7 +98,6 @@ class TestExtractTranscriptHighlights(unittest.TestCase):
)
highlights = youtube_yt.extract_transcript_highlights(transcript, "Lego")
self.assertTrue(len(highlights) > 0)
# Should pick the sentences with numbers and topic relevance, not filler
joined = " ".join(highlights)
self.assertIn("13,438", joined)
self.assertNotIn("subscribe", joined)
@@ -78,6 +114,17 @@ class TestExtractTranscriptHighlights(unittest.TestCase):
highlights = youtube_yt.extract_transcript_highlights(sentences, "model", limit=3)
self.assertEqual(len(highlights), 3)
def test_punctuation_free_transcript_produces_highlights(self):
# Auto-generated YouTube captions often lack sentence-ending punctuation
words = (
"the new Tesla Model Y has 350 miles of range and costs about 45000 dollars "
"which makes it one of the most affordable electric vehicles on the market today "
"compared to the BMW iX which starts at 87000 the value proposition is pretty clear "
"and with the 7500 dollar tax credit you can get it for under 40000"
)
highlights = youtube_yt.extract_transcript_highlights(words, "Tesla Model Y")
self.assertTrue(len(highlights) > 0, "Should produce highlights from punctuation-free text")
class TestFetchTranscriptDirect(unittest.TestCase):
"""Tests for _fetch_transcript_direct() — direct HTTP transcript fetching."""
@@ -214,5 +261,149 @@ class TestFetchTranscriptFallback(unittest.TestCase):
self.assertIsNone(result)
class TestExpandYouTubeQueries(unittest.TestCase):
"""Tests for expand_youtube_queries() multi-query generation."""
def test_default_depth_returns_two_plus_queries(self):
queries = youtube_yt.expand_youtube_queries("Kanye West", "default")
self.assertGreaterEqual(len(queries), 2)
# First query is the core subject
self.assertEqual(queries[0].lower(), "kanye west")
def test_how_to_intent_includes_tutorial_variant(self):
# Use deep depth so the intent variant isn't capped out by core + original
queries = youtube_yt.expand_youtube_queries("how to use Docker", "deep")
variant_found = any(
"tutorial" in q.lower() or "guide" in q.lower() or "explained" in q.lower()
for q in queries
)
self.assertTrue(
variant_found,
f"Expected tutorial/guide/explained in queries: {queries}",
)
def test_product_intent_includes_review_variant(self):
# Use deep depth so the intent variant isn't capped out
queries = youtube_yt.expand_youtube_queries("best running shoes", "deep")
variant_found = any("review" in q.lower() for q in queries)
self.assertTrue(variant_found, f"Expected 'review' in queries: {queries}")
def test_comparison_intent_includes_vs_variant(self):
queries = youtube_yt.expand_youtube_queries("Claude vs Gemini", "default")
variant_found = any("vs" in q.lower() or "compared" in q.lower() for q in queries)
self.assertTrue(variant_found, f"Expected 'vs' or 'compared' in queries: {queries}")
def test_quick_depth_returns_one_query(self):
queries = youtube_yt.expand_youtube_queries("Kanye West", "quick")
self.assertEqual(len(queries), 1)
def test_deep_depth_returns_three_queries(self):
queries = youtube_yt.expand_youtube_queries("Kanye West", "deep")
self.assertEqual(len(queries), 3)
def test_single_word_returns_at_least_one(self):
queries = youtube_yt.expand_youtube_queries("React", "default")
self.assertGreaterEqual(len(queries), 1)
def test_temporal_words_stripped_from_core(self):
queries = youtube_yt.expand_youtube_queries("kanye west last 30 days", "default")
core = queries[0].lower()
self.assertNotIn("last", core)
self.assertNotIn("days", core)
self.assertIn("kanye", core)
self.assertIn("west", core)
class TestInferQueryIntent(unittest.TestCase):
"""Tests for _infer_query_intent() classification."""
def test_comparison_intent(self):
self.assertEqual(youtube_yt._infer_query_intent("Claude vs Gemini"), "comparison")
def test_how_to_intent(self):
self.assertEqual(youtube_yt._infer_query_intent("how to deploy Kubernetes"), "how_to")
def test_opinion_intent(self):
self.assertEqual(youtube_yt._infer_query_intent("thoughts on Claude Code"), "opinion")
def test_product_intent(self):
self.assertEqual(youtube_yt._infer_query_intent("best laptop for programming"), "product")
def test_breaking_news_default(self):
self.assertEqual(youtube_yt._infer_query_intent("Kanye West"), "breaking_news")
class TestSearchAndTranscribe(unittest.TestCase):
"""Tests for search_and_transcribe() end-to-end flow."""
def _make_item(self, video_id, views):
return {
"video_id": video_id,
"title": f"Video {video_id}",
"url": f"https://www.youtube.com/watch?v={video_id}",
"channel_name": "TestChannel",
"date": "2026-03-15",
"engagement": {"views": views, "likes": 10, "comments": 5},
"relevance": 0.8,
"why_relevant": "test",
"description": "test desc",
"duration": 600,
}
def test_transcripts_attached_when_top_videos_lack_captions(self):
"""When top-viewed videos have no captions, lower-ranked ones still get transcripts."""
items = [
self._make_item("music1", 1_000_000), # no captions (music video)
self._make_item("music2", 500_000), # no captions (music video)
self._make_item("talk1", 50_000), # has captions
self._make_item("talk2", 25_000), # has captions
]
# fetch_transcripts_parallel returns None for music videos, text for talks
def fake_parallel(video_ids, max_workers=5):
result = {}
for vid in video_ids:
if vid.startswith("talk"):
result[vid] = "This is a detailed discussion about the topic with 100 data points."
else:
result[vid] = None
return result
with mock.patch.object(youtube_yt, "search_youtube", return_value={"items": items}), \
mock.patch.object(youtube_yt, "fetch_transcripts_parallel", side_effect=fake_parallel) as ft_mock:
result = youtube_yt.search_and_transcribe("test topic", "2026-03-01", "2026-03-31", depth="default")
# Should have attempted more than just the top 2 (transcript_limit=2)
called_ids = ft_mock.call_args[0][0]
self.assertGreater(len(called_ids), 2, "Should attempt more than transcript_limit candidates")
self.assertIn("talk1", called_ids)
self.assertIn("talk2", called_ids)
# talk1 and talk2 should have transcripts
items_by_id = {i["video_id"]: i for i in result["items"]}
self.assertTrue(items_by_id["talk1"]["transcript_snippet"])
self.assertTrue(items_by_id["talk1"]["transcript_highlights"])
# music videos should have empty transcripts
self.assertFalse(items_by_id["music1"]["transcript_snippet"])
def test_transcript_limit_zero_skips_fetch(self):
"""When transcript_limit is 0 (quick depth), no transcripts are fetched."""
items = [self._make_item("vid1", 1000)]
with mock.patch.object(youtube_yt, "search_youtube", return_value={"items": items}), \
mock.patch.object(youtube_yt, "fetch_transcripts_parallel") as ft_mock:
result = youtube_yt.search_and_transcribe("test", "2026-03-01", "2026-03-31", depth="quick")
ft_mock.assert_not_called()
self.assertEqual(result["items"][0]["transcript_snippet"], "")
def test_no_items_returns_early(self):
"""When search returns no items, returns without fetching transcripts."""
with mock.patch.object(youtube_yt, "search_youtube", return_value={"items": []}), \
mock.patch.object(youtube_yt, "fetch_transcripts_parallel") as ft_mock:
result = youtube_yt.search_and_transcribe("nothing", "2026-03-01", "2026-03-31")
ft_mock.assert_not_called()
if __name__ == "__main__":
unittest.main()