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:
@@ -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()
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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("& 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 & 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 & "test""
|
||||
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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 == ""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 == ""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user