from __future__ import annotations import logging from datetime import date from pathlib import Path from typing import Callable import pytest from tenbackward.matching import ( MatchedPost, find_anniversary_matches, iter_anniversary_paths, ) SITE_URL = "https://chaospott.de" _MATCHING_LOGGER = "tenbackward.matching" def _write_post(root: Path, rel_path: str, *, body: str = "post") -> None: full = root / rel_path full.parent.mkdir(parents=True, exist_ok=True) full.write_text(body, encoding="utf-8") def _capture_matching_warnings(call: Callable[[], object]) -> list[logging.LogRecord]: records: list[logging.LogRecord] = [] handler = logging.Handler() handler.setLevel(logging.WARNING) handler.emit = records.append # type: ignore[assignment] logger = logging.getLogger(_MATCHING_LOGGER) previous_level = logger.level logger.setLevel(logging.WARNING) logger.addHandler(handler) try: call() finally: logger.removeHandler(handler) logger.setLevel(previous_level) return records def _event_names(records: list[logging.LogRecord]) -> list[str]: return [getattr(record, "event", None) for record in records] def test_match_uses_frontmatter_date_and_title(tmp_path: Path) -> None: root = tmp_path / "_posts" / "blog" _write_post( root, "2014/2014-08-04-foo.md", body="---\ntitle: Foo\ndate: 2014-08-04\n---\nbody\n", ) today = date(2024, 8, 4) matches = find_anniversary_matches(root, SITE_URL, today=today) assert len(matches) == 1 match = matches[0] assert isinstance(match, MatchedPost) assert match.path == "2014/2014-08-04-foo.md" assert match.title == "Foo" assert match.date == date(2014, 8, 4) assert match.url == "https://chaospott.de/2014/08/04/foo/" def test_match_falls_back_to_filename_when_frontmatter_missing(tmp_path: Path) -> None: root = tmp_path / "_posts" / "blog" _write_post(root, "2015/2015-03-10-no-frontmatter.md", body="no frontmatter at all\n") _write_post( root, "2015/2015-03-11-broken-frontmatter.md", body="---\ndate: not-a-date\n: :\n" ) today = date(2025, 3, 10) matches: list[MatchedPost] = [] records = _capture_matching_warnings( lambda: matches.extend(find_anniversary_matches(root, SITE_URL, today=today)) ) paths = {m.path for m in matches} assert "2015/2015-03-10-no-frontmatter.md" in paths assert "2015/2015-03-11-broken-frontmatter.md" not in paths match = next(m for m in matches if m.path == "2015/2015-03-10-no-frontmatter.md") assert match.title == "no-frontmatter" assert match.date == date(2015, 3, 10) assert match.url == "https://chaospott.de/2015/03/10/no-frontmatter/" def test_unpublished_post_is_skipped(tmp_path: Path) -> None: root = tmp_path / "_posts" / "blog" _write_post( root, "2014/2014-08-04-draft.md", body="---\ntitle: Draft\ndate: 2014-08-04\npublished: false\n---\n", ) today = date(2024, 8, 4) matches: list[MatchedPost] = [] records = _capture_matching_warnings( lambda: matches.extend(find_anniversary_matches(root, SITE_URL, today=today)) ) assert matches == [] assert _event_names(records) == ["post_unpublished"] def test_iter_anniversary_paths_yields_only_path(tmp_path: Path) -> None: root = tmp_path / "_posts" / "blog" _write_post( root, "2014/2014-08-04-foo.md", body="---\ntitle: Foo\ndate: 2014-08-04\n---\n", ) paths = list(iter_anniversary_paths(root, SITE_URL, today=date(2024, 8, 4))) assert paths == ["2014/2014-08-04-foo.md"] @pytest.mark.parametrize( "today, source_iso, expected_match", [ # Normal exact match (Mar 1 source, Mar 1 today) -> match (date(2025, 3, 1), "2015-03-01", True), # Mar 1 source (2016, the day after Feb 29) in a non-leap current year -> no match (date(2025, 3, 1), "2016-03-01", False), # Feb 29 source in a non-leap current year, today is Mar 1: source year # (2016) does not equal target_year (2015), so it does NOT match under # the strict year-equality rule. (date(2025, 3, 1), "2016-02-29", False), ], ) def test_leap_day_matching( tmp_path: Path, today: date, source_iso: str, expected_match: bool ) -> None: root = tmp_path / "_posts" / "blog" target_year = today.year - 10 rel = f"{target_year}/{source_iso}-leap.md" _write_post( root, rel, body=f"---\ntitle: Leap\ndate: {source_iso}\n---\n", ) matches = find_anniversary_matches(root, SITE_URL, today=today) matched_paths = [m.path for m in matches] if expected_match: assert rel in matched_paths else: assert rel not in matched_paths def test_match_uses_datetime_frontmatter_date(tmp_path: Path) -> None: root = tmp_path / "_posts" / "blog" _write_post( root, "2014/2014-08-04-with-time.md", body="---\ntitle: WithTime\ndate: 2014-08-04T12:30:00\n---\n", ) today = date(2024, 8, 4) matches = find_anniversary_matches(root, SITE_URL, today=today) assert len(matches) == 1 assert matches[0].date == date(2014, 8, 4) def test_match_returns_results_in_deterministic_order(tmp_path: Path) -> None: root = tmp_path / "_posts" / "blog" for slug, day in [("alpha", 1), ("beta", 2), ("gamma", 3)]: _write_post( root, f"2014/2014-08-0{day}-{slug}.md", body=f"---\ntitle: {slug}\ndate: 2014-08-0{day}\n---\n", ) today = date(2024, 8, 2) matches = find_anniversary_matches(root, SITE_URL, today=today) assert [m.path for m in matches] == ["2014/2014-08-02-beta.md"] def test_missing_post_root_returns_empty(tmp_path: Path) -> None: today = date(2024, 8, 4) assert find_anniversary_matches(tmp_path / "does-not-exist", SITE_URL, today=today) == [] def test_invalid_filename_is_skipped(tmp_path: Path) -> None: root = tmp_path / "_posts" / "blog" _write_post(root, "2014/not-a-date.md", body="---\ntitle: Bad\n---\n") today = date(2024, 8, 4) matches: list[MatchedPost] = [] records = _capture_matching_warnings( lambda: matches.extend(find_anniversary_matches(root, SITE_URL, today=today)) ) assert matches == [] assert "post_filename_invalid" in _event_names(records)