Files
novoyuuparosk-auto-wiki/tests/test_songs_publish.py
T
wan f5625cde98 songs: replace the wiki.siblings macro with plain Markdown links
The `wiki.siblings` bill of materials is gone. A related co-publication is
now nominated with an ordinary relative Markdown link — `[label](notes.md)` —
resolved at publish time against the target file's own `title` frontmatter.
No macro namespace, no per-file declaration, and the link still works in
Gitea's blob view and any editor preview.

The macro could not be made safe. Substitution was `str.replace(tag, …)` over
the whole rendered page: no word boundary, so a tag also occurring in lyrics or
a code block got rewritten; no collision check, so overlapping tags corrupted
each other in declaration order. SCHEMA.md documented the placeholder as
`[[TAG]]` while the code replaced the bare tag, so following the schema
produced `[[[[Title|label]]]]`.

Fixes a live corruption bug in the process. `sibling_map` was built only from
the files in the publish batch, and an unresolved sibling degraded to plain
text — so pushing `ses/pulse.md` alone republished it with its wikilink
silently stripped to the bare words "Dev gibberish". SCHEMA.md asserted the
workflow "always runs --all", which it never did on an ordinary push.

The repository is now always scanned in full, whatever a run publishes, so
link resolution and title-collision detection can never depend on which files
a push touched. Writes stay bounded to the push diff plus one hop in each link
direction: the page a changed page links to (so a link cannot point at an
unpublished page) and the pages linking to it (so an edited `title` does not
leave stale inbound links). Publishing is not instant; reading is cheap.

An unresolvable `.md` link is now a hard error, never graceful degradation, and
linking to a file still does not publish it — `wiki.publish: true` remains the
only gate. A leftover-link guard fails the run on any link shape the resolver
did not recognise, including a Markdown link written inside a verbatim
```columns fence, so a pandoc upgrade cannot silently publish literal text.

Also:

- render before write: every page in a run is rendered before the first is
  published, so a broken page leaves the wiki untouched instead of
  half-updated. `publish_one` is split into `render_page` (pure) and the write.
- pin pandoc's stdout to UTF-8. `text=True` decoded it with the ambient locale
  encoding; on any non-UTF-8 locale the Japanese and Chinese lyrics died inside
  subprocess's reader thread, `stdout` came back None, and the real error was
  lost behind a TypeError. Latent on the Debian runner via PEP 538 only.
- title collisions are detected repo-wide, not just within a push, and before
  any wiki contact.
- tests/, run by .gitea/workflows/test.yml in the publish runner image. The
  pandoc output contract the resolver depends on is now pinned by a test
  instead of by prose — which is how SCHEMA.md came to assert something false.
- drop an f-string with nothing to interpolate.
- quote the SOURCE_REF interpolation in the workflow instead of pasting an
  input straight into shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:24:36 +09:00

170 lines
8.1 KiB
Python

"""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