From 6f322ff2d6d209545a95191d76ce07c6ac4cee4d Mon Sep 17 00:00:00 2001 From: jackwener Date: Thu, 5 Mar 2026 16:14:05 +0800 Subject: [PATCH] test+ci: add regression tests and GitHub Actions workflow --- .github/workflows/ci.yml | 30 +++++++++++++++++++++ tests/conftest.py | 36 +++++++++++++++++++++++++ tests/test_cli.py | 28 +++++++++++++++++++ tests/test_config.py | 43 ++++++++++++++++++++++++++++++ tests/test_config_normalization.py | 35 ++++++++++++++++++++++++ tests/test_filter.py | 31 +++++++++++++++++++++ tests/test_serialization.py | 22 +++++++++++++++ 7 files changed, 225 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_config_normalization.py create mode 100644 tests/test_filter.py create mode 100644 tests/test_serialization.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8981b51 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v6 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --extra dev + + - name: Lint + run: uv run ruff check . + + - name: Test + run: uv run pytest -q diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ed8a42f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from twitter_cli.models import Author, Metrics, Tweet + + +@pytest.fixture() +def tweet_factory(): + def _make_tweet(tweet_id: str = "1", **overrides: Any) -> Tweet: + metrics = overrides.pop( + "metrics", + Metrics(likes=10, retweets=2, replies=1, quotes=0, views=120, bookmarks=3), + ) + author = overrides.pop( + "author", + Author(id="u1", name="Alice", screen_name="alice", verified=False), + ) + return Tweet( + id=tweet_id, + text=overrides.pop("text", "hello"), + author=author, + metrics=metrics, + created_at=overrides.pop("created_at", "2025-01-01"), + media=overrides.pop("media", []), + urls=overrides.pop("urls", []), + is_retweet=overrides.pop("is_retweet", False), + lang=overrides.pop("lang", "en"), + retweeted_by=overrides.pop("retweeted_by", None), + quoted_tweet=overrides.pop("quoted_tweet", None), + score=overrides.pop("score", 0.0), + ) + + return _make_tweet diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d818867 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from click.testing import CliRunner + +from twitter_cli.cli import cli +from twitter_cli.models import UserProfile +from twitter_cli.serialization import tweets_to_json + + +def test_cli_user_command_works_with_client_factory(monkeypatch) -> None: + class FakeClient: + def fetch_user(self, screen_name: str) -> UserProfile: + return UserProfile(id="1", name="Alice", screen_name=screen_name) + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda: FakeClient()) + runner = CliRunner() + result = runner.invoke(cli, ["user", "alice"]) + assert result.exit_code == 0 + + +def test_cli_feed_json_input_path(tmp_path, tweet_factory) -> None: + json_path = tmp_path / "tweets.json" + json_path.write_text(tweets_to_json([tweet_factory("1")]), encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(cli, ["feed", "--input", str(json_path), "--json"]) + assert result.exit_code == 0 + assert '"id": "1"' in result.output diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7ab1f3f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path + +from twitter_cli.config import DEFAULT_CONFIG, load_config + + +def test_load_config_supports_block_list_yaml(tmp_path: Path) -> None: + config_file = tmp_path / "config.yaml" + config_file.write_text( + "\n".join( + [ + "fetch:", + " count: 25", + "filter:", + " mode: score", + " lang:", + " - en", + " - zh", + ] + ), + encoding="utf-8", + ) + + config = load_config(str(config_file)) + assert config["fetch"]["count"] == 25 + assert config["filter"]["mode"] == "score" + assert config["filter"]["lang"] == ["en", "zh"] + + +def test_load_config_invalid_yaml_falls_back_to_defaults(tmp_path: Path) -> None: + config_file = tmp_path / "config.yaml" + config_file.write_text("fetch: [", encoding="utf-8") + + config = load_config(str(config_file)) + assert config["fetch"]["count"] == DEFAULT_CONFIG["fetch"]["count"] + assert config["filter"]["mode"] == DEFAULT_CONFIG["filter"]["mode"] + + +def test_load_config_does_not_mutate_defaults(tmp_path: Path) -> None: + config = load_config(str(tmp_path / "missing-config.yaml")) + config["filter"]["weights"]["likes"] = 999 + assert DEFAULT_CONFIG["filter"]["weights"]["likes"] == 1.0 diff --git a/tests/test_config_normalization.py b/tests/test_config_normalization.py new file mode 100644 index 0000000..a9a2d08 --- /dev/null +++ b/tests/test_config_normalization.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pathlib import Path + +from twitter_cli.config import load_config + + +def test_filter_normalization_for_invalid_values(tmp_path: Path) -> None: + config_file = tmp_path / "config.yaml" + config_file.write_text( + "\n".join( + [ + "fetch:", + " count: -5", + "filter:", + " mode: unknown", + " topN: -1", + " minScore: abc", + " lang: zh", + " weights:", + " likes: bad", + " retweets: 4", + ] + ), + encoding="utf-8", + ) + + config = load_config(str(config_file)) + assert config["fetch"]["count"] == 1 + assert config["filter"]["mode"] == "topN" + assert config["filter"]["topN"] == 1 + assert config["filter"]["minScore"] == 50.0 + assert config["filter"]["lang"] == [] + assert config["filter"]["weights"]["likes"] == 1.0 + assert config["filter"]["weights"]["retweets"] == 4.0 diff --git a/tests/test_filter.py b/tests/test_filter.py new file mode 100644 index 0000000..32470c4 --- /dev/null +++ b/tests/test_filter.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from twitter_cli.filter import filter_tweets + + +def test_filter_tweets_does_not_mutate_input(tweet_factory) -> None: + tweet = tweet_factory("1", score=0.0) + output = filter_tweets([tweet], {"mode": "all", "weights": {}}) + + assert tweet.score == 0.0 + assert output[0].score > 0.0 + assert output[0] is not tweet + + +def test_filter_tweets_applies_language_and_retweet_filters(tweet_factory) -> None: + tweets = [ + tweet_factory("1", lang="en", is_retweet=False), + tweet_factory("2", lang="zh", is_retweet=False), + tweet_factory("3", lang="en", is_retweet=True), + ] + output = filter_tweets( + tweets, + { + "mode": "all", + "lang": ["en"], + "excludeRetweets": True, + "weights": {}, + }, + ) + + assert [tweet.id for tweet in output] == ["1"] diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..0df50e7 --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from twitter_cli.serialization import tweet_from_dict, tweet_to_dict, tweets_from_json, tweets_to_json + + +def test_tweet_roundtrip_dict(tweet_factory) -> None: + tweet = tweet_factory("42") + payload = tweet_to_dict(tweet) + restored = tweet_from_dict(payload) + + assert restored.id == tweet.id + assert restored.author.screen_name == tweet.author.screen_name + assert restored.metrics.likes == tweet.metrics.likes + + +def test_tweets_json_roundtrip(tweet_factory) -> None: + tweets = [tweet_factory("1"), tweet_factory("2", lang="zh")] + raw = tweets_to_json(tweets) + restored = tweets_from_json(raw) + + assert [tweet.id for tweet in restored] == ["1", "2"] + assert restored[1].lang == "zh"