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