chore: prepare release v0.4.0
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""Coverage tests for ``Post`` properties — non-happy-path branches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from volumen.post import Post
|
||||
|
||||
|
||||
def _post(metadata: dict[str, object] | None = None, body: str = "") -> Post:
|
||||
return Post(metadata=metadata, body=body)
|
||||
|
||||
|
||||
class TestTagsProperty:
|
||||
def test_tags_default_is_empty(self) -> None:
|
||||
assert _post().tags == []
|
||||
|
||||
def test_tags_list_of_strings(self) -> None:
|
||||
assert _post({"tags": ["python", "blog"]}).tags == ["python", "blog"]
|
||||
|
||||
def test_tags_non_list_coerces_to_empty(self) -> None:
|
||||
# When ``tags`` is set to something that isn't a list, the property
|
||||
# returns an empty list rather than raising.
|
||||
assert _post({"tags": "python"}).tags == []
|
||||
assert _post({"tags": 42}).tags == []
|
||||
assert _post({"tags": None}).tags == []
|
||||
|
||||
|
||||
class TestTranslationsProperty:
|
||||
def test_default_is_empty(self) -> None:
|
||||
assert _post().translations == {}
|
||||
|
||||
def test_dict_coerces_values_to_strings(self) -> None:
|
||||
out = _post({"translations": {"cs": "cs-slug", "de": "de-slug"}}).translations
|
||||
assert out == {"cs": "cs-slug", "de": "de-slug"}
|
||||
|
||||
def test_non_dict_returns_empty(self) -> None:
|
||||
assert _post({"translations": ["cs", "de"]}).translations == {}
|
||||
assert _post({"translations": "cs"}).translations == {}
|
||||
|
||||
|
||||
class TestFediverseCreator:
|
||||
def test_none(self) -> None:
|
||||
assert _post().fediverse_creator is None
|
||||
|
||||
def test_empty_string_is_none(self) -> None:
|
||||
assert _post({"fediverse_creator": ""}).fediverse_creator is None
|
||||
assert _post({"fediverse_creator": " "}).fediverse_creator is None
|
||||
|
||||
def test_valid_fediverse_creator_regex(self) -> None:
|
||||
p = _post({"fediverse_creator": "@alice@example.com"})
|
||||
assert p.fediverse_creator == "@alice@example.com"
|
||||
assert p.valid_fediverse_creator is True
|
||||
|
||||
def test_invalid_fediverse_creator(self) -> None:
|
||||
# No `@host` part → regex doesn't match.
|
||||
p = _post({"fediverse_creator": "alice"})
|
||||
assert p.fediverse_creator == "alice"
|
||||
assert p.valid_fediverse_creator is False
|
||||
|
||||
|
||||
class TestPublishAt:
|
||||
def test_none(self) -> None:
|
||||
assert _post().publish_at is None
|
||||
|
||||
def test_date_object(self) -> None:
|
||||
assert _post({"publish_at": date(2026, 6, 1)}).publish_at == date(2026, 6, 1)
|
||||
|
||||
def test_iso_string(self) -> None:
|
||||
assert _post({"publish_at": "2026-06-01"}).publish_at == date(2026, 6, 1)
|
||||
|
||||
def test_invalid_string_returns_none(self) -> None:
|
||||
assert _post({"publish_at": "not-a-date"}).publish_at is None
|
||||
assert _post({"publish_at": 12345}).publish_at is None
|
||||
|
||||
|
||||
class TestScheduled:
|
||||
def test_past_date_is_not_scheduled(self) -> None:
|
||||
past = (date.today() - timedelta(days=1)).isoformat()
|
||||
assert _post({"publish_at": past}).scheduled is False
|
||||
|
||||
def test_future_date_is_scheduled(self) -> None:
|
||||
future = (date.today() + timedelta(days=1)).isoformat()
|
||||
assert _post({"publish_at": future}).scheduled is True
|
||||
|
||||
def test_no_publish_at(self) -> None:
|
||||
assert _post().scheduled is False
|
||||
|
||||
|
||||
class TestReadingTime:
|
||||
def test_empty_body_minimum_one_minute(self) -> None:
|
||||
assert _post(body="").reading_time == 1
|
||||
|
||||
def test_short_body(self) -> None:
|
||||
# 5 words → ceil(5/200) = 1.
|
||||
assert _post(body="one two three four five").reading_time == 1
|
||||
|
||||
def test_long_body(self) -> None:
|
||||
body = " ".join(f"word{i}" for i in range(500))
|
||||
# 500 words → ceil(500/200) = 3.
|
||||
assert _post(body=body).reading_time == 3
|
||||
|
||||
|
||||
class TestExcerpt:
|
||||
def test_explicit_excerpt(self) -> None:
|
||||
assert _post({"excerpt": "Short summary"}).excerpt == "Short summary"
|
||||
|
||||
def test_derived_from_body(self) -> None:
|
||||
# No explicit excerpt → derived from first non-heading paragraph.
|
||||
p = _post(body="Hello **world**.")
|
||||
assert "Hello" in p.excerpt
|
||||
assert "<" not in p.excerpt # HTML stripped
|
||||
assert "*" not in p.excerpt # Markdown stripped
|
||||
|
||||
def test_derived_empty_when_only_headings(self) -> None:
|
||||
assert _post(body="# Heading\n\n## Subheading").excerpt == ""
|
||||
|
||||
|
||||
class TestSummaryAndDetail:
|
||||
def test_summary_includes_all_known_fields(self) -> None:
|
||||
p = _post(
|
||||
{
|
||||
"title": "T",
|
||||
"slug": "s",
|
||||
"date": "2026-01-15",
|
||||
"lang": "en",
|
||||
"tags": ["x"],
|
||||
"author": "A",
|
||||
"fediverse_creator": "@a@example.com",
|
||||
"cover": "media/c.webp",
|
||||
},
|
||||
body="Body.",
|
||||
)
|
||||
s = p.summary()
|
||||
assert s["slug"] == "s"
|
||||
assert s["title"] == "T"
|
||||
assert s["date"] == "2026-01-15"
|
||||
assert s["lang"] == "en"
|
||||
assert s["tags"] == ["x"]
|
||||
assert s["author"] == "A"
|
||||
assert s["fediverse_creator"] == "@a@example.com"
|
||||
assert s["cover"] == "media/c.webp"
|
||||
assert s["url"] == "/api/volumen/posts/s"
|
||||
|
||||
def test_detail_includes_body_and_html(self) -> None:
|
||||
p = _post({"title": "T", "slug": "s"}, body="Hello.")
|
||||
d = p.detail()
|
||||
assert d["body"] == "Hello."
|
||||
assert "html" in d
|
||||
assert "<p>" in d["html"]
|
||||
Reference in New Issue
Block a user