"""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, public_source_base, 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('
a\nb\n===\nc
') assert out.count("") == 2 assert "\na\nb\n" in out assert "\nc\n" in out assert out.startswith("
a\n===\nb\n===\nc') assert out.count("") == 3 def test_converts_markdown_emphasis(self): out = expand_columns('
***a*** **b** *c*
') assert "'''''a'''''" in out assert "'''b'''" in out assert "''c''" in out def test_unescapes_inline_html(self): out = expand_columns('
<b>x</b>
') assert "x" 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("\nsee [x](notes.md)\n", HERE) def test_prose_mentioning_a_filename_is_not_a_link(self): assert_no_unresolved_md_links("edit pulse.md 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("") == 2 assert "'''A'''" in wikitext 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"