test+ci: add regression tests and GitHub Actions workflow

This commit is contained in:
jackwener
2026-03-05 16:14:05 +08:00
parent 4c08d09304
commit 6f322ff2d6
7 changed files with 225 additions and 0 deletions

36
tests/conftest.py Normal file
View File

@@ -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

28
tests/test_cli.py Normal file
View File

@@ -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

43
tests/test_config.py Normal file
View File

@@ -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

View File

@@ -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

31
tests/test_filter.py Normal file
View File

@@ -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"]

View File

@@ -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"