from __future__ import annotations from datetime import datetime, timezone from pathlib import Path import sys import unittest import xml.etree.ElementTree as ET SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" sys.path.insert(0, str(SCRIPTS)) from feed_generator import ( # noqa: E402 ATOM_NS, CONTENT_NS, Feed, FeedEntry, absolute_html_urls, atom_xml, build_feed_catalog, rss_xml, select_recent_activity, ) class FakeQuery: def __init__(self, records): self.records = list(records) def include_hidden(self, include): records = ( self.records if include else [item for item in self.records if not item.is_hidden] ) return FakeQuery(records) def order_by(self, *fields): records = list(self.records) for field in reversed(fields): reverse = field.startswith("-") name = field.lstrip("-") records.sort(key=lambda item: str(item.data.get(name, "")), reverse=reverse) return FakeQuery(records) def __iter__(self): return iter(self.records) class FakeRecord: def __init__(self, path, *, children=(), hidden=False, discoverable=True, **data): self.path = path self.url_path = f"{path}/" if path != "/" else "/" self.children = FakeQuery(children) self.is_hidden = hidden self.is_undiscoverable = not discoverable self.data = {"body": None, **data} self.pad = None def __getitem__(self, name): if name == "_model": return self.data.get("model") return self.data[name] class FakePad: def __init__(self, records): self.records = records for record in records.values(): record.pad = self def get(self, path): return self.records.get(path) def make_pad(): article_old = FakeRecord( "/articles/older", model="entry", title="Older & wiser", summary="An article", date="2026-01-01", updated="2026-01-03", author="Article Author", ) article_new = FakeRecord( "/articles/newer", model="entry", title="Newer article", summary="Newest article", date="2026-04-01", author="Article Author", ) article_draft = FakeRecord( "/articles/draft", model="entry", title="Draft article", date="2026-05-01", hidden=True, ) blog = FakeRecord( "/blog/dispatch", model="entry", title="Dispatch", summary="A dispatch", date="2026-03-01", author="Blog Author", ) alpha_old = FakeRecord( "/projects/alpha/devlog/one", model="devlog-entry", title="Alpha one", summary="First", date="2026-01-10", source_commit_time="2026-01-10 12:00:00 +0000", author="Alpha Author", ) alpha_new = FakeRecord( "/projects/alpha/devlog/two", model="devlog-entry", title="Alpha two", summary="Second", date="2026-03-15", source_commit_time="2026-03-15 12:00:00 +0000", author="Alpha Author", ) alpha_hidden = FakeRecord( "/projects/alpha/devlog/hidden", model="devlog-entry", title="Hidden alpha entry", date="2026-04-15", hidden=True, ) beta_entry = FakeRecord( "/projects/beta/devlog/only", model="devlog-entry", title="Beta only", summary="Beta history", date="2026-02-01", source_commit_time="2026-02-01 12:00:00 +0000", author="Beta Author", ) alpha = FakeRecord( "/projects/alpha", model="project", title="Alpha", summary="Alpha summary", date="2025-12-01", repository_commit_date="2026-03-15 12:00:00 +0000", author="Alpha Author", ) beta = FakeRecord( "/projects/beta", model="project", title="Beta", summary="Beta summary", date="2026-01-01", repository_commit_date="2026-02-01 12:00:00 +0000", author="Beta Author", ) records = { "/articles": FakeRecord( "/articles", children=[article_old, article_new, article_draft], model="section", summary="Article feed description", ), "/blog": FakeRecord( "/blog", children=[blog], model="section", summary="Blog feed description" ), "/projects": FakeRecord( "/projects", children=[alpha, beta], model="section" ), "/projects/alpha/devlog": FakeRecord( "/projects/alpha/devlog", children=[alpha_old, alpha_new, alpha_hidden], model="devlog", ), "/projects/beta/devlog": FakeRecord( "/projects/beta/devlog", children=[beta_entry], model="devlog" ), } for record in [ article_old, article_new, article_draft, blog, alpha, beta, alpha_old, alpha_new, alpha_hidden, beta_entry, ]: records[record.path] = record return FakePad(records) class FeedGeneratorTests(unittest.TestCase): def test_recent_activity_matches_home_rules_and_excludes_hidden_records(self): paths = [record.path for record in select_recent_activity(make_pad())] self.assertEqual( [ "/articles/newer", "/projects/alpha/devlog/two", "/blog/dispatch", "/projects/beta/devlog/only", "/articles/older", ], paths, ) self.assertNotIn("/articles/draft", paths) self.assertNotIn("/projects/alpha/devlog/hidden", paths) self.assertNotIn("/projects/alpha/devlog/one", paths) def test_atom_and_rss_use_equivalent_entries_and_stable_canonical_ids(self): _, feeds = build_feed_catalog(make_pad()) recent = feeds[0] atom = ET.fromstring(atom_xml(recent)) rss = ET.fromstring(rss_xml(recent)) atom_ids = [ node.text for node in atom.findall(f"{{{ATOM_NS}}}entry/{{{ATOM_NS}}}id") ] rss_ids = [node.text for node in rss.findall("channel/item/guid")] self.assertEqual(atom_ids, rss_ids) self.assertEqual([entry.stable_id for entry in recent.entries], atom_ids) self.assertTrue( all(value.startswith("https://www.labyricorn.com/") for value in atom_ids) ) self.assertNotIn("Draft article", atom_xml(recent).decode("utf-8")) articles = feeds[1] older = next(entry for entry in articles.entries if entry.title == "Older & wiser") self.assertEqual("2026-01-01", older.published.date().isoformat()) self.assertEqual("2026-01-03", older.updated.date().isoformat()) def test_project_feeds_start_with_overview_then_oldest_devlogs(self): _, feeds = build_feed_catalog(make_pad()) alpha = next( feed for feed in feeds if feed.atom_url.endswith("/projects/alpha/feed.xml") ) beta = next( feed for feed in feeds if feed.atom_url.endswith("/projects/beta/feed.xml") ) self.assertEqual( ["Alpha", "Alpha one", "Alpha two"], [item.title for item in alpha.entries], ) self.assertEqual(["Beta", "Beta only"], [item.title for item in beta.entries]) self.assertNotIn("Beta only", [item.title for item in alpha.entries]) self.assertEqual( "https://www.labyricorn.com/projects/alpha/", alpha.entries[0].stable_id, ) def test_full_content_urls_become_absolute(self): rendered = absolute_html_urls( '

NotesMail

', "https://www.labyricorn.com/articles/example/", ) self.assertIn('href="https://www.labyricorn.com/articles/example/notes/"', rendered) self.assertIn('src="https://www.labyricorn.com/articles/example/cover.png"', rendered) self.assertIn("https://www.labyricorn.com/large.png 2x", rendered) self.assertIn('href="mailto:test@example.com"', rendered) unsafe = absolute_html_urls( 'Unsafe', "https://www.labyricorn.com/", ) self.assertEqual('Unsafe', unsafe) def test_xml_serializers_preserve_full_html_and_special_characters(self): entry = FeedEntry( title="Signals & systems", canonical_url="https://www.labyricorn.com/articles/signals/", stable_id="https://www.labyricorn.com/articles/signals/", summary="A & test", content_html='

Readable & complete content.

', published=datetime(2026, 1, 1, tzinfo=timezone.utc), updated=datetime(2026, 1, 2, tzinfo=timezone.utc), author="Test Author", ) feed = Feed( title="Test & Feed", description="Description & details", canonical_url="https://www.labyricorn.com/", atom_url="https://www.labyricorn.com/feed.xml", rss_url="https://www.labyricorn.com/rss.xml", entries=(entry,), ) atom = ET.fromstring(atom_xml(feed)) rss = ET.fromstring(rss_xml(feed)) atom_content = atom.find(f"{{{ATOM_NS}}}entry/{{{ATOM_NS}}}content") rss_content = rss.find(f"channel/item/{{{CONTENT_NS}}}encoded") self.assertEqual(entry.content_html, atom_content.text) self.assertEqual(entry.content_html, rss_content.text) self.assertEqual(entry.stable_id, rss.findtext("channel/item/guid")) if __name__ == "__main__": unittest.main()