test+ci: add regression tests and GitHub Actions workflow
This commit is contained in:
30
.github/workflows/ci.yml
vendored
Normal file
30
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||
36
tests/conftest.py
Normal file
36
tests/conftest.py
Normal 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
28
tests/test_cli.py
Normal 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
43
tests/test_config.py
Normal 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
|
||||
35
tests/test_config_normalization.py
Normal file
35
tests/test_config_normalization.py
Normal 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
31
tests/test_filter.py
Normal 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"]
|
||||
22
tests/test_serialization.py
Normal file
22
tests/test_serialization.py
Normal 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"
|
||||
Reference in New Issue
Block a user