2026-09-09 16:16:33 +09:00
|
|
|
"""Tests for the shared render helpers in lib/wiki.py.
|
|
|
|
|
|
|
|
|
|
All pure string in, string out, except the pandoc contract test at the bottom,
|
|
|
|
|
which is the canary for a pandoc upgrade changing how relative links are written.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from lib.wiki import (
|
|
|
|
|
assert_no_unresolved_md_links,
|
|
|
|
|
expand_columns,
|
|
|
|
|
extract_md_link_targets,
|
|
|
|
|
markdown_to_wikitext,
|
2026-09-09 16:37:06 +09:00
|
|
|
public_source_base,
|
2026-09-09 16:16:33 +09:00
|
|
|
resolve_md_links,
|
|
|
|
|
strip_first_h1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Paths need not exist on disk: resolution is non-strict, and only the missing
|
|
|
|
|
# vs. unpublished distinction consults the filesystem (covered with tmp_path).
|
|
|
|
|
SONGS = Path(__file__).resolve().parent / "_paths" / "songs"
|
|
|
|
|
HERE = SONGS / "pulse.md"
|
|
|
|
|
INDEX = {
|
|
|
|
|
str((SONGS / "pulse.md").resolve()): "pulse under latent semantic envelopes",
|
|
|
|
|
str((SONGS / "pulse_dev.md").resolve()): (
|
|
|
|
|
"pulse under latent semantic envelopes - development notes"
|
|
|
|
|
),
|
|
|
|
|
str((SONGS / "two words.md").resolve()): "Two words",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve(wikitext, source=HERE):
|
|
|
|
|
return resolve_md_links(wikitext, source, INDEX)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStripFirstH1:
|
|
|
|
|
def test_removes_heading_and_following_blank_line(self):
|
|
|
|
|
assert strip_first_h1("# Title\n\nbody") == "body"
|
|
|
|
|
|
|
|
|
|
def test_removes_only_the_first(self):
|
|
|
|
|
assert strip_first_h1("# One\n\n# Two\n") == "# Two\n"
|
|
|
|
|
|
|
|
|
|
def test_leaves_body_without_h1_alone(self):
|
|
|
|
|
assert strip_first_h1("## Sub\n\nbody") == "## Sub\n\nbody"
|
|
|
|
|
|
|
|
|
|
def test_finds_the_h1_even_when_a_deeper_heading_precedes_it(self):
|
|
|
|
|
assert strip_first_h1("### Deep\n\n# Title\n") == "### Deep\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExpandColumns:
|
|
|
|
|
def test_splits_on_separator_and_wraps_each_in_poem(self):
|
|
|
|
|
out = expand_columns('<pre class="columns">a\nb\n===\nc</pre>')
|
|
|
|
|
assert out.count("<poem>") == 2
|
|
|
|
|
assert "<poem>\na\nb\n</poem>" in out
|
|
|
|
|
assert "<poem>\nc\n</poem>" in out
|
|
|
|
|
assert out.startswith("<div style=")
|
|
|
|
|
|
|
|
|
|
def test_three_columns_need_two_separators(self):
|
|
|
|
|
out = expand_columns('<pre class="columns">a\n===\nb\n===\nc</pre>')
|
|
|
|
|
assert out.count("<poem>") == 3
|
|
|
|
|
|
|
|
|
|
def test_converts_markdown_emphasis(self):
|
|
|
|
|
out = expand_columns('<pre class="columns">***a*** **b** *c*</pre>')
|
|
|
|
|
assert "'''''a'''''" in out
|
|
|
|
|
assert "'''b'''" in out
|
|
|
|
|
assert "''c''" in out
|
|
|
|
|
|
|
|
|
|
def test_unescapes_inline_html(self):
|
|
|
|
|
out = expand_columns('<pre class="columns"><b>x</b></pre>')
|
|
|
|
|
assert "<b>x</b>" in out
|
|
|
|
|
|
|
|
|
|
def test_leaves_wikitext_without_a_columns_block_untouched(self):
|
|
|
|
|
assert expand_columns("plain '''wikitext'''") == "plain '''wikitext'''"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestResolveMdLinks:
|
|
|
|
|
def test_labelled_link_becomes_piped_wikilink(self):
|
|
|
|
|
assert resolve("see [[pulse_dev.md|dev notes]] ok") == (
|
|
|
|
|
"see [[pulse under latent semantic envelopes - development notes|dev notes]] ok"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_unlabelled_link_uses_the_page_title(self):
|
|
|
|
|
assert resolve("[[pulse_dev.md]]") == (
|
|
|
|
|
"[[pulse under latent semantic envelopes - development notes]]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_label_identical_to_target_drops_the_pipe(self):
|
|
|
|
|
assert resolve("[[pulse_dev.md|pulse_dev.md]]") == (
|
|
|
|
|
"[[pulse under latent semantic envelopes - development notes]]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_label_identical_to_the_page_title_drops_the_pipe(self):
|
|
|
|
|
assert resolve("[[pulse_dev.md|pulse under latent semantic envelopes - development notes]]") == (
|
|
|
|
|
"[[pulse under latent semantic envelopes - development notes]]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_fragment_is_carried_onto_the_title(self):
|
|
|
|
|
assert resolve("[[pulse_dev.md#Notes|x]]") == (
|
|
|
|
|
"[[pulse under latent semantic envelopes - development notes#Notes|x]]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_percent_encoding_in_target_is_decoded(self):
|
|
|
|
|
assert resolve("[[two%20words.md|x]]") == "[[Two words|x]]"
|
|
|
|
|
|
|
|
|
|
def test_parent_directory_target_resolves(self):
|
|
|
|
|
assert resolve_md_links(
|
|
|
|
|
"[[../pulse_dev.md|x]]", SONGS / "sub" / "a.md", INDEX
|
|
|
|
|
) == "[[pulse under latent semantic envelopes - development notes|x]]"
|
|
|
|
|
|
|
|
|
|
def test_external_style_link_shape_also_resolves(self):
|
|
|
|
|
# Insurance against a pandoc version writing relative targets this way.
|
|
|
|
|
assert resolve("[pulse_dev.md dev notes]") == (
|
|
|
|
|
"[[pulse under latent semantic envelopes - development notes|dev notes]]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_absolute_url_is_left_alone(self):
|
|
|
|
|
assert resolve("[https://example.com/x label]") == "[https://example.com/x label]"
|
|
|
|
|
|
|
|
|
|
def test_non_md_wikilink_is_left_alone(self):
|
|
|
|
|
assert resolve("[[Category:Songs]] [[Media:x.lrc|y]]") == (
|
|
|
|
|
"[[Category:Songs]] [[Media:x.lrc|y]]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_label_containing_a_pipe_is_preserved(self):
|
|
|
|
|
assert resolve("[[pulse_dev.md|a|b]]").endswith("|a|b]]")
|
|
|
|
|
|
|
|
|
|
def test_several_links_in_one_body(self):
|
|
|
|
|
out = resolve("[[pulse_dev.md|a]] then [[two%20words.md|b]]")
|
|
|
|
|
assert "|a]]" in out and "[[Two words|b]]" in out
|
|
|
|
|
|
|
|
|
|
def test_missing_file_is_an_error(self):
|
|
|
|
|
with pytest.raises(ValueError, match="does not exist"):
|
|
|
|
|
resolve("[[nope.md|x]]")
|
|
|
|
|
|
|
|
|
|
def test_existing_but_unpublished_file_is_an_error(self, tmp_path):
|
|
|
|
|
(tmp_path / "draft.md").write_text("x", encoding="utf-8")
|
|
|
|
|
with pytest.raises(ValueError, match="not published"):
|
|
|
|
|
resolve_md_links("[[draft.md|x]]", tmp_path / "song.md", {})
|
|
|
|
|
|
|
|
|
|
def test_error_names_the_source_file(self):
|
|
|
|
|
with pytest.raises(ValueError, match=r"pulse\.md"):
|
|
|
|
|
resolve("[[nope.md|x]]")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestUnresolvedLinkGuard:
|
|
|
|
|
def test_accepts_output_with_no_md_reference(self):
|
|
|
|
|
assert_no_unresolved_md_links("[[A page|x]] and [http://e.com y]", HERE)
|
|
|
|
|
|
|
|
|
|
def test_rejects_leftover_wikilink(self):
|
|
|
|
|
with pytest.raises(ValueError, match="unresolved wikilink"):
|
|
|
|
|
assert_no_unresolved_md_links("[[notes.md|x]]", HERE)
|
|
|
|
|
|
|
|
|
|
def test_rejects_leftover_external_style_link(self):
|
|
|
|
|
with pytest.raises(ValueError, match="external-style"):
|
|
|
|
|
assert_no_unresolved_md_links("[notes.md x]", HERE)
|
|
|
|
|
|
|
|
|
|
def test_rejects_raw_markdown_link_left_in_a_columns_fence(self):
|
|
|
|
|
with pytest.raises(ValueError, match="columns"):
|
|
|
|
|
assert_no_unresolved_md_links("<poem>\nsee [x](notes.md)\n</poem>", HERE)
|
|
|
|
|
|
|
|
|
|
def test_prose_mentioning_a_filename_is_not_a_link(self):
|
|
|
|
|
assert_no_unresolved_md_links("edit <code>pulse.md</code> by hand", HERE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExtractMdLinkTargets:
|
|
|
|
|
def test_finds_inline_targets(self):
|
|
|
|
|
assert extract_md_link_targets("a [x](one.md) b [y](../two.md#f)") == [
|
|
|
|
|
"one.md",
|
|
|
|
|
"../two.md#f",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def test_ignores_bodies_without_links(self):
|
|
|
|
|
assert extract_md_link_targets("no links here") == []
|
|
|
|
|
|
|
|
|
|
def test_finds_targets_inside_a_columns_fence(self):
|
|
|
|
|
# The fence cannot render them, but the graph must still see the edge.
|
|
|
|
|
assert extract_md_link_targets("```columns\n[x](one.md)\n```") == ["one.md"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("pandoc") is None, reason="pandoc not installed")
|
|
|
|
|
class TestPandocContract:
|
|
|
|
|
"""Pins the pandoc output shape that resolve_md_links depends on.
|
|
|
|
|
|
|
|
|
|
If a pandoc upgrade starts writing relative link targets differently, this
|
|
|
|
|
fails here instead of silently publishing a broken link.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_version(self):
|
|
|
|
|
out = subprocess.run(
|
|
|
|
|
["pandoc", "--version"], capture_output=True, text=True, check=True
|
|
|
|
|
).stdout
|
|
|
|
|
print(out.splitlines()[0])
|
|
|
|
|
|
|
|
|
|
def test_relative_link_survives_the_full_render(self):
|
|
|
|
|
wikitext = markdown_to_wikitext("see [dev notes](pulse_dev.md) here")
|
|
|
|
|
assert resolve(wikitext).strip() == (
|
|
|
|
|
"see [[pulse under latent semantic envelopes - development notes|dev notes]] here"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_unlabelled_relative_link_survives_the_full_render(self):
|
|
|
|
|
wikitext = markdown_to_wikitext("[pulse_dev.md](pulse_dev.md)")
|
|
|
|
|
assert resolve(wikitext).strip() == (
|
|
|
|
|
"[[pulse under latent semantic envelopes - development notes]]"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_absolute_url_survives_untouched(self):
|
|
|
|
|
wikitext = markdown_to_wikitext("[site](https://example.com/x)")
|
|
|
|
|
assert resolve(wikitext).strip() == "[https://example.com/x site]"
|
|
|
|
|
|
|
|
|
|
def test_columns_fence_renders_to_side_by_side_poems(self):
|
|
|
|
|
wikitext = markdown_to_wikitext("```columns\n**A**\nl1\n===\n**B**\nl2\n```")
|
|
|
|
|
assert wikitext.count("<poem>") == 2
|
|
|
|
|
assert "'''A'''" in wikitext
|
2026-09-09 16:37:06 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPublicSourceBase:
|
|
|
|
|
"""The banner URL is read by the public, so a runner-only address must fail.
|
|
|
|
|
|
|
|
|
|
Every one of these rejections was a page published with a link to
|
|
|
|
|
localhost:3005, which is what the whole guard exists to prevent.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_accepts_a_public_https_url(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "https://git.novoyuuparosk.org/org/repo")
|
|
|
|
|
assert public_source_base() == "https://git.novoyuuparosk.org/org/repo"
|
|
|
|
|
|
|
|
|
|
def test_accepts_a_public_host_with_a_port(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://git.novoyuuparosk.org:1551/org/repo")
|
|
|
|
|
assert public_source_base() == "http://git.novoyuuparosk.org:1551/org/repo"
|
|
|
|
|
|
|
|
|
|
def test_strips_a_trailing_slash(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "https://git.example.org/org/repo/")
|
|
|
|
|
assert public_source_base() == "https://git.example.org/org/repo"
|
|
|
|
|
|
|
|
|
|
def test_strips_surrounding_whitespace(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", " https://git.example.org/org/repo ")
|
|
|
|
|
assert public_source_base() == "https://git.example.org/org/repo"
|
|
|
|
|
|
|
|
|
|
def test_unset_is_rejected(self, monkeypatch):
|
|
|
|
|
monkeypatch.delenv("GITEA_REPO_URL", raising=False)
|
|
|
|
|
with pytest.raises(RuntimeError, match="is unset"):
|
|
|
|
|
public_source_base()
|
|
|
|
|
|
|
|
|
|
def test_empty_is_rejected(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "")
|
|
|
|
|
with pytest.raises(RuntimeError, match="is unset"):
|
|
|
|
|
public_source_base()
|
|
|
|
|
|
|
|
|
|
def test_a_bare_path_is_rejected(self, monkeypatch):
|
|
|
|
|
# What an unset PUBLIC_URL_TO_GITEA leaves the workflow composing.
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "/novoyuuparosk-wiki/ncmr-songs")
|
|
|
|
|
with pytest.raises(RuntimeError, match="not an absolute URL"):
|
|
|
|
|
public_source_base()
|
|
|
|
|
|
|
|
|
|
def test_localhost_is_rejected(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://localhost:3005/org/repo")
|
|
|
|
|
with pytest.raises(RuntimeError, match="loopback hostname"):
|
|
|
|
|
public_source_base()
|
|
|
|
|
|
|
|
|
|
def test_loopback_ip_is_rejected(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://127.0.0.1:3005/org/repo")
|
|
|
|
|
with pytest.raises(RuntimeError, match="loopback or private-range"):
|
|
|
|
|
public_source_base()
|
|
|
|
|
|
|
|
|
|
def test_private_range_ip_is_rejected(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://192.168.1.20:3005/org/repo")
|
|
|
|
|
with pytest.raises(RuntimeError, match="loopback or private-range"):
|
|
|
|
|
public_source_base()
|
|
|
|
|
|
|
|
|
|
def test_mdns_name_is_rejected(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://pi5-16.local:3005/org/repo")
|
|
|
|
|
with pytest.raises(RuntimeError, match="mDNS"):
|
|
|
|
|
public_source_base()
|
|
|
|
|
|
|
|
|
|
def test_rejection_names_the_variable_to_set(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("GITEA_REPO_URL", "http://localhost:3005/org/repo")
|
|
|
|
|
with pytest.raises(RuntimeError, match="PUBLIC_URL_TO_GITEA"):
|
|
|
|
|
public_source_base()
|
|
|
|
|
|
|
|
|
|
def test_reads_the_named_variable(self, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("OTHER_BASE", "https://git.example.org/org/repo")
|
|
|
|
|
assert public_source_base("OTHER_BASE") == "https://git.example.org/org/repo"
|