Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions tools/heartbeat/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ python_version = "3.12"
[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"

[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
]
Empty file.
46 changes: 46 additions & 0 deletions tools/heartbeat/tests/test_analyzer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from __future__ import annotations

from unittest.mock import MagicMock, patch

from beartype import beartype

from analyzer import analyze_with_claude, read_project_context


@beartype
@patch("analyzer.PROJECT_ROOT")
def test_read_project_context_exists(mock_root: MagicMock) -> None:
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_path.read_text.return_value = "Mock Context"
mock_root.__truediv__.return_value = mock_path

assert read_project_context() == "Mock Context"


@beartype
@patch("analyzer.PROJECT_ROOT")
def test_read_project_context_not_found(mock_root: MagicMock) -> None:
mock_path = MagicMock()
mock_path.exists.return_value = False
mock_root.__truediv__.return_value = mock_path

assert "not found" in read_project_context()


@beartype
@patch("analyzer.read_project_context")
@patch("anthropic.Anthropic")
def test_analyze_with_claude(mock_anthropic: MagicMock, mock_read_context: MagicMock) -> None:
mock_read_context.return_value = "Mock Project Context"

mock_client = MagicMock()
mock_anthropic.return_value = mock_client

mock_message = MagicMock()
mock_message.content = [MagicMock(text="Mock Analysis Results")]
mock_client.messages.create.return_value = mock_message

result = analyze_with_claude("Raw Data")
assert result == "Mock Analysis Results"
mock_client.messages.create.assert_called_once()
26 changes: 26 additions & 0 deletions tools/heartbeat/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from __future__ import annotations

from beartype import beartype

from config import ALL_KEYWORDS, TOPICS


@beartype
def test_topics_structure() -> None:
assert isinstance(TOPICS, dict)
assert len(TOPICS) > 0
for category, keywords in TOPICS.items():
assert isinstance(category, str)
assert isinstance(keywords, list)
assert len(keywords) > 0
for kw in keywords:
assert isinstance(kw, str)


@beartype
def test_all_keywords() -> None:
assert isinstance(ALL_KEYWORDS, list)
expected_count = sum(len(keywords) for keywords in TOPICS.values())
assert len(ALL_KEYWORDS) == expected_count
for kw in ALL_KEYWORDS:
assert any(kw in keywords for keywords in TOPICS.values())
61 changes: 61 additions & 0 deletions tools/heartbeat/tests/test_formatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from __future__ import annotations

from datetime import datetime, timezone

from beartype import beartype

from formatter import format_raw_digest
from sources import GitHubRepo, HNStory, RedditPost, XTrend


@beartype
def test_format_raw_digest() -> None:
hn_stories = [
HNStory(
id=1,
title="HN Story",
url="http://hn.com",
score=100,
comments=10,
author="auth",
time=datetime.now(timezone.utc),
)
]
github_repos = [
GitHubRepo(
name="repo",
full_name="org/repo",
description="desc",
url="http://github.com/repo",
stars=500,
language="Python",
topics=["topic"],
created_at="2023-01-01",
)
]
reddit_posts = [
RedditPost(
title="Reddit Post",
url="http://reddit.com/post",
score=200,
comments=20,
author="user",
subreddit="sub",
created_at=datetime.now(timezone.utc),
)
]
x_trends = [
XTrend(name="Trend", url="http://x.com/trend", volume=1000)
]

digest = format_raw_digest(hn_stories, github_repos, reddit_posts, x_trends)

assert "Heartbeat Raw Data" in digest
assert "Hacker News Top Stories (1 relevant)" in digest
assert "HN Story" in digest
assert "GitHub Trending Repos (1 found)" in digest
assert "org/repo" in digest
assert "Reddit Top Posts (1 found)" in digest
assert "Reddit Post" in digest
assert "X Trending Topics (1 found)" in digest
assert "Trend" in digest
61 changes: 61 additions & 0 deletions tools/heartbeat/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from __future__ import annotations

from unittest.mock import MagicMock, patch

from beartype import beartype

from main import cmd_digest, cmd_fetch, main


@beartype
@patch("main.cmd_fetch")
@patch("sys.argv", ["main.py", "--mode", "fetch"])
def test_main_fetch(mock_fetch: MagicMock) -> None:
main()
mock_fetch.assert_called_once()


@beartype
@patch("main.cmd_digest")
@patch("sys.argv", ["main.py", "--mode", "digest"])
def test_main_digest(mock_digest: MagicMock) -> None:
main()
mock_digest.assert_called_once()


@beartype
@patch("main.cmd_fetch")
@patch("sys.argv", ["main.py"])
def test_main_default(mock_fetch: MagicMock) -> None:
main()
mock_fetch.assert_called_once()


@beartype
@patch("formatter.format_raw_digest")
@patch("sources.fetch_all")
@patch("sys.stdout", new_callable=MagicMock)
def test_cmd_fetch(mock_stdout: MagicMock, mock_fetch_all: MagicMock, mock_format: MagicMock) -> None:
mock_fetch_all.return_value = {"hn": [], "github": [], "reddit": [], "x": []}
mock_format.return_value = "Mocked Raw Digest"

cmd_fetch()

mock_stdout.write.assert_any_call("Mocked Raw Digest")


@beartype
@patch("analyzer.analyze_with_claude")
@patch("formatter.format_raw_digest")
@patch("sources.fetch_all")
@patch("sys.stdout", new_callable=MagicMock)
def test_cmd_digest(
mock_stdout: MagicMock, mock_fetch_all: MagicMock, mock_format: MagicMock, mock_analyze: MagicMock
) -> None:
mock_fetch_all.return_value = {"hn": [], "github": [], "reddit": [], "x": []}
mock_format.return_value = "Mocked Raw Digest"
mock_analyze.return_value = "Mocked Digest Analysis"

cmd_digest()

mock_stdout.write.assert_any_call("Mocked Digest Analysis")
148 changes: 148 additions & 0 deletions tools/heartbeat/tests/test_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
from __future__ import annotations

from unittest.mock import MagicMock, patch

from beartype import beartype

from sources import (
GitHubRepo,
HNStory,
RedditPost,
_http_get_json,
_matches_keywords,
fetch_all,
fetch_github_trending,
fetch_hn_stories,
fetch_reddit_posts,
)


@beartype
def test_matches_keywords() -> None:
assert _matches_keywords("This is about AI agent") is True
assert _matches_keywords("Something about Python") is True
assert _matches_keywords("Totally unrelated topic") is False


@beartype
@patch("sources._http_get_json")
def test_fetch_reddit_posts(mock_get: MagicMock) -> None:
mock_get.return_value = {
"data": {
"children": [
{
"data": {
"title": "Test Post",
"permalink": "/r/test/comments/123/",
"score": 100,
"num_comments": 10,
"author": "user1",
"subreddit": "MachineLearning",
"created_utc": 1672531200,
}
}
]
}
}

posts = fetch_reddit_posts()
assert len(posts) == 1
assert isinstance(posts[0], RedditPost)
assert posts[0].title == "Test Post"
assert posts[0].score == 100
assert posts[0].subreddit == "MachineLearning"


@beartype
@patch("sources._http_get_json")
def test_fetch_hn_stories(mock_get: MagicMock) -> None:
# First call for top stories IDs
# Second call for the item itself
def side_effect(url: str) -> list[int] | dict[str, MagicMock]:
if "topstories.json" in url:
return [1, 2]
if "item/1.json" in url:
return {
"id": 1,
"type": "story",
"title": "AI Agent is here",
"url": "http://example.com/ai",
"score": 50,
"descendants": 5,
"by": "author1",
"time": 1672531200,
}
if "item/2.json" in url:
return {
"id": 2,
"type": "story",
"title": "Unrelated",
"score": 10,
"by": "author2",
"time": 1672531200,
}
return {}

mock_get.side_effect = side_effect

stories = fetch_hn_stories()
# Only 1 story matches keywords
assert len(stories) == 1
assert isinstance(stories[0], HNStory)
assert stories[0].title == "AI Agent is here"


@beartype
@patch("sources._http_get_json")
def test_fetch_github_trending(mock_get: MagicMock) -> None:
mock_get.return_value = {
"items": [
{
"name": "cortex",
"full_name": "org/cortex",
"description": "AI agent framework",
"html_url": "http://github.com/org/cortex",
"stargazers_count": 1000,
"language": "Python",
"topics": ["ai", "agents"],
"created_at": "2023-01-01T00:00:00Z",
}
]
}

repos = fetch_github_trending()
assert len(repos) >= 1 # Might be more because of multiple GITHUB_TOPICS
assert isinstance(repos[0], GitHubRepo)
assert repos[0].name == "cortex"
assert repos[0].stars == 1000


@beartype
@patch("sources._http_get_json")
def test_fetch_reddit_posts_error(mock_get: MagicMock) -> None:
mock_get.side_effect = Exception("API error")
posts = fetch_reddit_posts()
assert posts == []


@beartype
@patch("sources._http_get_json")
def test_fetch_all(mock_get: MagicMock) -> None:
mock_get.return_value = {}
res = fetch_all()
assert "hn" in res
assert "github" in res
assert "reddit" in res
assert "x" in res


@beartype
@patch("urllib.request.urlopen")
def test_http_get_json(mock_urlopen: MagicMock) -> None:
mock_response = MagicMock()
mock_response.read.return_value = b'{"key": "value"}'
mock_response.__enter__.return_value = mock_response
mock_urlopen.return_value = mock_response

result = _http_get_json("http://example.com")
assert result == {"key": "value"}
Loading