"""Tests for the songs publisher: discovery, validation, and publish scoping. None of these touch a wiki. `discover_publishable` and `select_publish_set` are pure filesystem reads, which is the point — every failure they catch must fail the run before `connect_wiki()` is ever called. """ import pytest PUBLISHED = "title: {title}\nwiki:\n publish: true" UNPUBLISHED = "title: {title}\nwiki:\n publish: false" class TestDiscoverPublishable: def test_collects_only_opted_in_files(self, songs, repo): repo("a.md", PUBLISHED.format(title="A")) repo("b.md", UNPUBLISHED.format(title="B")) posts = songs.discover_publishable(repo.root) assert [p.metadata["title"] for p in posts.values()] == ["A"] def test_skips_wip_directory_even_when_opted_in(self, songs, repo): repo("a.md", PUBLISHED.format(title="A")) repo("wip/draft.md", PUBLISHED.format(title="Draft")) posts = songs.discover_publishable(repo.root) assert [p.metadata["title"] for p in posts.values()] == ["A"] def test_skips_readme(self, songs, repo): repo("README.md", PUBLISHED.format(title="Readme")) assert songs.discover_publishable(repo.root) == {} def test_scans_nested_directories(self, songs, repo): repo("ses/a.md", PUBLISHED.format(title="A")) repo("kairo/deep/b.md", PUBLISHED.format(title="B")) posts = songs.discover_publishable(repo.root) assert sorted(p.metadata["title"] for p in posts.values()) == ["A", "B"] def test_duplicate_titles_in_different_directories_fail(self, songs, repo): repo("ses/a.md", PUBLISHED.format(title="Same page")) repo("kairo/b.md", PUBLISHED.format(title="Same page")) with pytest.raises(ValueError, match="Duplicate wiki title 'Same page'"): songs.discover_publishable(repo.root) def test_duplicate_title_error_names_both_files(self, songs, repo): repo("ses/a.md", PUBLISHED.format(title="Same page")) repo("kairo/b.md", PUBLISHED.format(title="Same page")) with pytest.raises(ValueError) as err: songs.discover_publishable(repo.root) assert "a.md" in str(err.value) and "b.md" in str(err.value) def test_a_title_colliding_with_an_unpublished_file_is_allowed(self, songs, repo): repo("a.md", PUBLISHED.format(title="Same page")) repo("b.md", UNPUBLISHED.format(title="Same page")) assert len(songs.discover_publishable(repo.root)) == 1 def test_publish_as_a_string_is_rejected(self, songs, repo): repo("a.md", 'title: A\nwiki:\n publish: "true"') with pytest.raises(ValueError, match="must be a boolean"): songs.discover_publishable(repo.root) def test_missing_title_is_rejected(self, songs, repo): repo("a.md", "wiki:\n publish: true") with pytest.raises(ValueError, match="missing or empty 'title'"): songs.discover_publishable(repo.root) def test_bad_release_date_is_rejected(self, songs, repo): repo("a.md", PUBLISHED.format(title="A") + "\nrelease_date: 14-06-2026") with pytest.raises(ValueError, match="YYYY-MM-DD"): songs.discover_publishable(repo.root) def test_categories_must_be_a_list(self, songs, repo): repo("a.md", PUBLISHED.format(title="A") + "\ncategories: kairo") with pytest.raises(ValueError, match="'categories' must be a list"): songs.discover_publishable(repo.root) def test_a_leftover_siblings_key_is_ignored_not_an_error(self, songs, repo): # The old macro declaration is inert; a stale one must not fail a run. repo("a.md", PUBLISHED.format(title="A") + "\n siblings:\n - tag: X") assert len(songs.discover_publishable(repo.root)) == 1 class TestSelectPublishSet: def test_publishes_only_the_changed_file(self, songs, repo): a = repo("a.md", PUBLISHED.format(title="A")) repo("b.md", PUBLISHED.format(title="B")) posts = songs.discover_publishable(repo.root) selected = songs.select_publish_set(posts, {a.resolve()}) assert [p.metadata["title"] for p in selected.values()] == ["A"] def test_arms_the_page_a_changed_page_links_to(self, songs, repo): a = repo("a.md", PUBLISHED.format(title="A"), "see [notes](b.md)") repo("b.md", PUBLISHED.format(title="B")) posts = songs.discover_publishable(repo.root) selected = songs.select_publish_set(posts, {a.resolve()}) assert sorted(p.metadata["title"] for p in selected.values()) == ["A", "B"] def test_arms_the_page_that_links_to_a_changed_page(self, songs, repo): repo("a.md", PUBLISHED.format(title="A"), "see [notes](b.md)") b = repo("b.md", PUBLISHED.format(title="B")) posts = songs.discover_publishable(repo.root) selected = songs.select_publish_set(posts, {b.resolve()}) assert sorted(p.metadata["title"] for p in selected.values()) == ["A", "B"] def test_unrelated_pages_stay_out(self, songs, repo): a = repo("a.md", PUBLISHED.format(title="A"), "see [notes](b.md)") repo("b.md", PUBLISHED.format(title="B")) repo("c.md", PUBLISHED.format(title="C")) posts = songs.discover_publishable(repo.root) selected = songs.select_publish_set(posts, {a.resolve()}) assert "C" not in [p.metadata["title"] for p in selected.values()] def test_links_across_directories_are_followed(self, songs, repo): a = repo("ses/a.md", PUBLISHED.format(title="A"), "see [notes](../kairo/b.md)") repo("kairo/b.md", PUBLISHED.format(title="B")) posts = songs.discover_publishable(repo.root) selected = songs.select_publish_set(posts, {a.resolve()}) assert sorted(p.metadata["title"] for p in selected.values()) == ["A", "B"] def test_a_link_to_an_unpublished_file_adds_nothing(self, songs, repo): # resolve_md_links is what rejects this loudly; the graph just drops it. a = repo("a.md", PUBLISHED.format(title="A"), "see [draft](b.md)") repo("b.md", UNPUBLISHED.format(title="B")) posts = songs.discover_publishable(repo.root) selected = songs.select_publish_set(posts, {a.resolve()}) assert [p.metadata["title"] for p in selected.values()] == ["A"] def test_a_self_link_does_not_duplicate(self, songs, repo): a = repo("a.md", PUBLISHED.format(title="A"), "see [self](a.md)") posts = songs.discover_publishable(repo.root) selected = songs.select_publish_set(posts, {a.resolve()}) assert len(selected) == 1 class TestBuildTitleIndex: def test_maps_every_publishable_path_to_its_title(self, songs, repo): a = repo("a.md", PUBLISHED.format(title="A")) posts = songs.discover_publishable(repo.root) assert songs.build_title_index(posts)[str(a.resolve())] == "A" def test_covers_the_whole_repo_not_just_a_changed_file(self, songs, repo): repo("a.md", PUBLISHED.format(title="A")) b = repo("nested/b.md", PUBLISHED.format(title="B")) posts = songs.discover_publishable(repo.root) assert str(b.resolve()) in songs.build_title_index(posts) class TestBuildWikitext: def test_album_injects_both_song_and_album_categories(self, songs): out = songs.build_wikitext({"album": "kairo II"}, "body\n", "url", "sha") assert "[[Category:Songs]]" in out assert "[[Category:Album:kairo II]]" in out def test_no_album_injects_no_category(self, songs): out = songs.build_wikitext({}, "body\n", "url", "sha") assert "[[Category:" not in out def test_extra_categories_are_additive(self, songs): out = songs.build_wikitext( {"album": "kairo II", "categories": ["kairo"]}, "body\n", "url", "sha" ) assert "[[Category:kairo]]" in out assert "[[Category:Album:kairo II]]" in out def test_banner_carries_source_and_commit(self, songs): out = songs.build_wikitext({}, "body\n", "http://g/x", "abc123") assert out.startswith("{{Auto-generated|source=http://g/x|commit=abc123}}") def test_lrc_footer_link_is_appended_when_named(self, songs): out = songs.build_wikitext({}, "body\n", "url", "sha", "A.lrc") assert "[[Media:A.lrc|Synced lyrics (.lrc)]]" in out