From 29827440d1f48d185afbdd4d025e8daefb1a1c37 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 15 Nov 2025 21:01:01 -0500 Subject: [PATCH 1/4] Add pytest testing infastructure - Add pytest as optional test dependency - Configure pytest with pytest.ini --- pyproject.toml | 5 +++++ pytest.ini | 4 ++++ tests/__init__.py | 0 3 files changed, 9 insertions(+) create mode 100644 pytest.ini create mode 100644 tests/__init__.py diff --git a/pyproject.toml b/pyproject.toml index 58b76bb..1b95f08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,11 @@ dependencies = [ "requests>=2.25.0", ] +[project.optional-dependencies] +test = [ + "pytest>=7.0", +] + [project.urls] Homepage = "https://github.com/masonlet/github-visualizer" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3a87564 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +minversion = 6.0 +addopts = -v +testpaths = tests \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 36cc2dbcf3cb32f9f26222c50be6876e15a72666 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 15 Nov 2025 22:16:18 -0500 Subject: [PATCH 2/4] Add unit testings for graph_data, commit_api, and cache - TestGetCommitDates: counting by date, invalid timestamps, misisng keys, same-day grouping - TestGetIntensityChar: intensity level mapping validation - TestCreateWeekGrid: grid structure validation, sunday alignment validation - TestPopulateGrid: commit count population testing - TestGetRepoCommits: cache validity, API fetching, pagination, error handling, format transformation - TestCachePaths: user and commit cache path creation - TestIsCacheValid: nonexistent files, valid cache, stat error handling - TestFormatTime: time delta formatting (just now, minute(s), hour(s)) - Fix create_week_grid calculation bug --- .../visualizer/graph_data.py | 2 +- tests/test_cache.py | 60 +++++++++ tests/test_commit_api.py | 118 ++++++++++++++++++ tests/test_graph_data.py | 89 +++++++++++++ 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 tests/test_cache.py create mode 100644 tests/test_commit_api.py create mode 100644 tests/test_graph_data.py diff --git a/src/github_visualizer/visualizer/graph_data.py b/src/github_visualizer/visualizer/graph_data.py index c490cfe..33e7980 100644 --- a/src/github_visualizer/visualizer/graph_data.py +++ b/src/github_visualizer/visualizer/graph_data.py @@ -57,7 +57,7 @@ def create_week_grid(weeks: int = 52) -> list[list[tuple[str, int]]]: today = datetime.now().date() days_since_sunday = (today.weekday() + 1) % 7 end_date = today - timedelta(days=days_since_sunday) - start_date = end_date - timedelta(weeks=weeks - 1) + start_date = end_date - timedelta(weeks=weeks) + timedelta(days=1) grid = [[] for _ in range(7)] current_date = start_date diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..156e290 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,60 @@ +"""Tests for cache module.""" + +from datetime import timedelta, datetime +from pathlib import Path +import pytest +from unittest.mock import patch + +from github_visualizer.fetch.cache.formatting import format_time +from github_visualizer.fetch.cache.paths import get_user_cache_path, get_commit_cache_path +from github_visualizer.fetch.cache.validation import is_cache_valid +from github_visualizer.config import CACHE_DIR + +class TestFormatTime: + def test_just_now(self): + assert format_time(timedelta(seconds=30)) == "just now" + + + def test_minutes(self): + assert format_time(timedelta(minutes=1)) == "1 minute ago" + assert format_time(timedelta(minutes=5)) == "5 minutes ago" + + + def test_hours(self): + assert format_time(timedelta(hours=1)) == "1 hour ago" + assert format_time(timedelta(hours=3)) == "3 hours ago" + + +class TestCachePaths: + def test_get_user_cache_path_creates_dir(self, tmp_path): + username = "testuser" + with patch("github_visualizer.fetch.cache.paths.CACHE_DIR", tmp_path): + path = get_user_cache_path(username) + assert path == tmp_path / f"{username}.json" + + + def test_get_commit_cache_path_creates_dir(self, tmp_path): + username = "testuser" + repo = "testrepo" + with patch("github_visualizer.fetch.cache.paths.CACHE_DIR", tmp_path): + path = get_commit_cache_path(username, repo) + assert path == tmp_path / username / f"{repo}_commits.json" + + +class TestIsCacheValid: + def test_returns_false_for_nonexistent_file(self, tmp_path): + path = tmp_path / "nonexistent.json" + assert not is_cache_valid(path) + + + def test_returns_true_for_valid_cache(self, tmp_path): + path = tmp_path / "cache.json" + path.touch() + assert is_cache_valid(path) + + + def test_returns_false_on_stat_error(self): + path = Path("dummy.json") + with patch("github_visualizer.fetch.cache.validation.Path.exists", return_value=True), \ + patch("github_visualizer.fetch.cache.validation.Path.stat", side_effect=OSError): + assert not is_cache_valid(path) \ No newline at end of file diff --git a/tests/test_commit_api.py b/tests/test_commit_api.py new file mode 100644 index 0000000..08f424b --- /dev/null +++ b/tests/test_commit_api.py @@ -0,0 +1,118 @@ +"""Tests for commit API module.""" + +import json +import requests +import pytest +from unittest.mock import patch, Mock +from github_visualizer.fetch.commit_api import get_repo_commits, get_commit_cache_path + +def make_fake_commit(message: str, date: str): + return { + "commit": { + "message": message, + "author": {"date": date} + } + } + +class TestGetRepoCommits: + def test_returns_cached_data_when_valid(self, tmp_path): + username = "user" + repo = "repo" + cache_path = tmp_path / f"{repo}_commits.json" + cached_data = [{"repo": repo, "message": "cached commit", "timestamp": "2025-01-01T12:00:00Z"}] + cache_path.write_text(json.dumps(cached_data)) + + with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \ + patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=True): + commits = get_repo_commits(username, repo) + + assert commits == cached_data + + def test_fetches_from_api_when_cache_invalid(self, tmp_path): + username = "user" + repo = "repo" + fake_api_response = [make_fake_commit("new commit", "2025-11-15T12:00:00Z")] + + cache_path = tmp_path / f"{repo}_commits.json" + + with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \ + patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=False), \ + patch("github_visualizer.fetch.commit_api.requests.get") as mock_get: + mock_get.side_effect = [ + Mock(json=Mock(return_value=fake_api_response), raise_for_status=Mock()), + Mock(json=Mock(return_value=[]), raise_for_status=Mock()) + ] + + commits = get_repo_commits(username, repo) + + assert commits[0]["message"] == "new commit" + cached_text = cache_path.read_text() + assert "new commit" in cached_text + + def test_handles_pagination(self, tmp_path): + username = "user" + repo = "repo" + page1 = [make_fake_commit("commit1", "2025-11-15T12:00:00Z")] + page2 = [make_fake_commit("commit2", "2025-11-15T13:00:00Z")] + + cache_path = tmp_path / f"{repo}_commits.json" + + with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \ + patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=False), \ + patch("github_visualizer.fetch.commit_api.requests.get") as mock_get: + mock_get.side_effect = [ + Mock(json=Mock(return_value=page1), raise_for_status=Mock()), + Mock(json=Mock(return_value=page2), raise_for_status=Mock()), + Mock(json=Mock(return_value=[]), raise_for_status=Mock()) + ] + + commits = get_repo_commits(username, repo) + + assert len(commits) == 2 + assert commits[0]["message"] == "commit1" + assert commits[1]["message"] == "commit2" + + def test_preserves_partial_data_on_error(self, tmp_path): + username = "user" + repo = "repo" + page1 = [make_fake_commit("commit1", "2025-11-15T12:00:00Z")] + + cache_path = tmp_path / f"{repo}_commits.json" + + with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \ + patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=False), \ + patch("github_visualizer.fetch.commit_api.requests.get") as mock_get, \ + patch("github_visualizer.fetch.commit_api.handle_api_error") as mock_error: + mock_get.side_effect = [ + Mock(json=Mock(return_value=page1), raise_for_status=Mock()), + requests.RequestException("Network error") + ] + + commits = get_repo_commits(username, repo) + + assert len(commits) == 1 + assert commits[0]["message"] == "commit1" + + def test_transforms_commit_format_correctly(self, tmp_path): + username = "user" + repo = "repo" + api_response = [ + make_fake_commit("my message", "2025-11-15T12:34:56Z") + ] + + cache_path = tmp_path / f"{repo}_commits.json" + + with patch("github_visualizer.fetch.commit_api.get_commit_cache_path", return_value=cache_path), \ + patch("github_visualizer.fetch.commit_api.is_cache_valid", return_value=False), \ + patch("github_visualizer.fetch.commit_api.requests.get") as mock_get: + mock_get.side_effect = [ + Mock(json=Mock(return_value=api_response), raise_for_status=Mock()), + Mock(json=Mock(return_value=[]), raise_for_status=Mock()) + ] + + commits = get_repo_commits(username, repo) + + c = commits[0] + assert c["repo"] == repo + assert c["message"] == "my message" + assert c["timestamp"] == "2025-11-15T12:34:56Z" \ No newline at end of file diff --git a/tests/test_graph_data.py b/tests/test_graph_data.py new file mode 100644 index 0000000..e7583ff --- /dev/null +++ b/tests/test_graph_data.py @@ -0,0 +1,89 @@ +"""Tests for commit API module.""" + +import pytest +from datetime import datetime, timedelta +from github_visualizer.visualizer.graph_data import ( + get_commit_dates, + get_intensity_char, + create_week_grid, + populate_grid +) + + +def make_commit(timestamp: str): + return {"repo": "repo", "message": "msg", "timestamp": timestamp} + + +class TestGetCommitDates: + def test_counts_commits_by_date(self): + commits = [ + make_commit("2025-11-01T12:00:00Z"), + make_commit("2025-11-01T13:00:00Z"), + make_commit("2025-11-02T09:00:00Z"), + ] + date_counts = get_commit_dates(commits) + assert date_counts["2025-11-01"] == 2 + assert date_counts["2025-11-02"] == 1 + + + def test_handles_invalid_timestamps(self): + commits = [ + make_commit("invalid"), + make_commit("2025-11-01T12:00:00Z") + ] + date_counts = get_commit_dates(commits) + assert "2025-11-01" in date_counts + assert len(date_counts) == 1 + + + def test_ignores_missing_keys(self): + commits = [{"repo": "repo"}] + date_counts = get_commit_dates(commits) + assert date_counts == {} + + + def test_groups_same_day_commits(self): + commits = [ + make_commit("2025-11-01T01:00:00Z"), + make_commit("2025-11-01T23:59:59Z") + ] + date_counts = get_commit_dates(commits) + assert date_counts["2025-11-01"] == 2 + + +class TestGetIntensityChar: + def test_maps_counts_to_intensity_levels(self): + from github_visualizer.config import INTENSITY_CHARS, INTENSITY_LEVELS + for i, level in enumerate(INTENSITY_LEVELS): + if i > 0: + assert get_intensity_char(level - 1) == INTENSITY_CHARS[i - 1] + assert get_intensity_char(level) == INTENSITY_CHARS[i] + + +class TestCreateWeekGrid: + def test_creates_correct_grid_structure(self): + grid = create_week_grid(weeks=2) + assert len(grid) == 7 + for row in grid: + assert len(row) >= 2 + for date, count in row: + assert isinstance(date, str) + assert count == 0 + + + def test_aligns_to_sunday(self): + grid = create_week_grid(weeks=1) + first_day_of_first_row = grid[0][0][0] + weekday = datetime.fromisoformat(first_day_of_first_row).weekday() + assert (weekday + 1) % 7 == 0 + +class TestPopulateGrid: + def test_fills_grid_with_commit_counts(self): + grid = create_week_grid(weeks=1) + dates = [row[0][0] for row in grid[:2]] + commit_counts = {dates[0]: 3, dates[1]: 1} + populated = populate_grid(grid, commit_counts) + assert populated[0][0][1] == 3 + assert populated[1][0][1] == 1 + for row in populated[2:]: + assert row[0][1] == 0 \ No newline at end of file From f4c8db034a6cc04d9622e04bacba677f2b4cf887 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 15 Nov 2025 22:19:49 -0500 Subject: [PATCH 3/4] GitHub Actions workflow --- .github/workflows/test.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..56584e4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Tests + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + - name: Install dependencies + run: pip install -e .[test] + - name: Run pytest + run: pytest From bf79ad4c7917358a140ede88f3dbb8004063bd17 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Sat, 15 Nov 2025 22:25:52 -0500 Subject: [PATCH 4/4] Update README with testing badge and instructions --- README.md | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 54fe986..b7c7d32 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # GitHub Visualizer -GitHub Visualizer is a Python utility for exploring and visualizing activity in GitHub repositories. - +![Tests](https://github.com/masonlet/github-visualizer/actions/workflows/test.yml/badge.svg) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![Python 3.6+](https://img.shields.io/badge/python-3.6%2B-blue.svg)]() +GitHub Visualizer is a Python utility for exploring and visualizing activity in GitHub repositories. ## Table of Contents - [Features](#features) @@ -17,6 +17,8 @@ GitHub Visualizer is a Python utility for exploring and visualizing activity in
+ + ## Features - **Repository Overview**: List all repositories for a user with commit previews - **Contribution Graph**: GitHub-style heatmap showing commit activity over time @@ -25,6 +27,8 @@ GitHub Visualizer is a Python utility for exploring and visualizing activity in
+ + ## Prerequisites - Python 3.6 or higher - pip (Python package manager) @@ -36,15 +40,10 @@ GitHub Visualizer is a Python utility for exploring and visualizing activity in pip install git+https://github.com/masonlet/github-visualizer.git ``` -### From Source -```bash -git clone https://github.com/masonlet/github-visualizer.git -cd github-visualizer -pip install -e . -``` -
+ + ## Usage ### Interactive Mode @@ -92,8 +91,10 @@ github-visualizer masonlet --token ghp_xxxxx --refresh --weeks 26
-## Building the Project -### 1. Clone the Repository + + +## Running Tests +### 1. Clone github-visualizer ```bash git clone https://github.com/masonlet/github-visualizer.git cd github-visualizer @@ -104,12 +105,21 @@ cd github-visualizer pip install -e . ``` -### 3. Run the Tool +### 3. Run Tests ```bash -github-visualizer +# Run all tests +pytest + +# Run specific test file +pytest tests/test_commit_api.py + +# Run tests with flags +pytest -V ```
+ + ## License -MIT License — see [LICENSE](./LICENSE) for details. \ No newline at end of file +MIT License — see [LICENSE](./LICENSE) for details.