From a08f958c57bd4fb5392ae2bf325761985cebc243 Mon Sep 17 00:00:00 2001 From: Shane Hutchins Date: Fri, 24 Apr 2026 11:17:02 -0400 Subject: [PATCH] Add importer unit tests, loosen cibutler asserts, bump to 0.8.2 Closes coverage gaps on two importer helper modules that were only exercised indirectly through pipeline tests: ImageHandler (extraction, naming, MIME mapping, mammoth callback) and LeafpressMarkdownConverter (fenced code blocks, pipe tables, option overrides). Also loosens the hard-coded page-count asserts in the cibutler integration test, which were breaking whenever new pages were added to the external docs site. Reformats two test files flagged by ruff. --- pyproject.toml | 2 +- tests/test_cibutler_integration.py | 6 +- tests/test_cli.py | 5 +- tests/test_importer_image_handler.py | 143 ++++++++++++++++++++++ tests/test_importer_markdown_converter.py | 98 +++++++++++++++ tests/test_package_version.py | 33 ++--- uv.lock | 2 +- 7 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 tests/test_importer_image_handler.py create mode 100644 tests/test_importer_markdown_converter.py diff --git a/pyproject.toml b/pyproject.toml index 7ac56cf..47a60da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "leafpress" -version = "0.8.1" +version = "0.8.2" description = "Convert MkDocs sites to PDF, Word, HTML, ODT, EPUB, and Markdown documents with branding" readme = "README.md" authors = [{name = "Shane Hutchins", email = "hutchins@users.noreply.github.com"}] diff --git a/tests/test_cibutler_integration.py b/tests/test_cibutler_integration.py index 40beee9..aef0aaf 100644 --- a/tests/test_cibutler_integration.py +++ b/tests/test_cibutler_integration.py @@ -1,7 +1,7 @@ """Integration tests using the OSDU CIButler MkDocs site. These tests exercise leafpress against a real MkDocs project with Material theme, -pymdownx extensions (including !!python/name references), and 12 markdown pages. +pymdownx extensions (including !!python/name references), and auto-discovered pages. """ from pathlib import Path @@ -98,7 +98,7 @@ def test_nav_auto_discovered(self, cibutler_config: MkDocsConfig) -> None: page_paths = [str(p.path) for p in pages if p.path is not None] assert any("index.md" in p for p in page_paths) assert any("install.md" in p for p in page_paths) - assert len(page_paths) == 14 + assert len(page_paths) >= 14 def test_all_pages_exist(self, cibutler_config: MkDocsConfig) -> None: pages = flatten_nav(cibutler_config.nav_items) @@ -116,7 +116,7 @@ def test_all_pages_render(self, cibutler_html_pages: list[tuple]) -> None: content_pages = [ (item, html) for item, html in cibutler_html_pages if item.path is not None ] - assert len(content_pages) == 14 + assert len(content_pages) >= 14 for item, html in content_pages: assert len(html) > 0, f"Empty HTML for {item.path}" diff --git a/tests/test_cli.py b/tests/test_cli.py index 42b6e4b..bd93490 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -195,10 +195,7 @@ def test_convert_monorepo_config_no_mkdocs_required(tmp_path: Path) -> None: # Create monorepo config in tmp_path (no mkdocs.yml here) config = tmp_path / "leafpress.yml" config.write_text( - 'company_name: "Test Corp"\n' - 'project_name: "Monorepo Docs"\n' - "projects:\n" - " - services/api\n" + 'company_name: "Test Corp"\nproject_name: "Monorepo Docs"\nprojects:\n - services/api\n' ) out = tmp_path / "output" diff --git a/tests/test_importer_image_handler.py b/tests/test_importer_image_handler.py new file mode 100644 index 0000000..d71dda4 --- /dev/null +++ b/tests/test_importer_image_handler.py @@ -0,0 +1,143 @@ +"""Tests for the importer ImageHandler.""" + +from __future__ import annotations + +import hashlib +from io import BytesIO +from pathlib import Path + +import pytest +from helpers import make_png + +from leafpress.importer.image_handler import ( + ImageHandler, + content_type_for_extension, +) + + +class TestSaveImage: + def test_writes_file_to_assets_dir(self, tmp_path: Path) -> None: + handler = ImageHandler(tmp_path / "assets") + data = make_png() + + rel_path = handler.save_image(data, "image/png") + + saved = tmp_path / "assets" / Path(rel_path).name + assert saved.exists() + assert saved.read_bytes() == data + + def test_returns_relative_markdown_path(self, tmp_path: Path) -> None: + handler = ImageHandler(tmp_path / "assets") + rel_path = handler.save_image(make_png(), "image/png") + assert rel_path.startswith("assets/image-001-") + assert rel_path.endswith(".png") + + def test_filename_includes_counter_and_hash(self, tmp_path: Path) -> None: + handler = ImageHandler(tmp_path / "assets") + data = make_png() + expected_hash = hashlib.sha256(data).hexdigest()[:12] + + rel_path = handler.save_image(data, "image/png") + + assert f"image-001-{expected_hash}.png" in rel_path + + def test_counter_increments_across_calls(self, tmp_path: Path) -> None: + handler = ImageHandler(tmp_path / "assets") + first = handler.save_image(make_png(), "image/png") + second = handler.save_image(b"other-bytes", "image/jpeg") + + assert "image-001-" in first + assert "image-002-" in second + assert second.endswith(".jpg") + + def test_creates_assets_dir_if_missing(self, tmp_path: Path) -> None: + assets_dir = tmp_path / "deep" / "nested" / "assets" + assert not assets_dir.exists() + + handler = ImageHandler(assets_dir) + handler.save_image(make_png(), "image/png") + + assert assets_dir.is_dir() + + def test_unknown_content_type_defaults_to_png(self, tmp_path: Path) -> None: + handler = ImageHandler(tmp_path / "assets") + rel_path = handler.save_image(b"data", "application/octet-stream") + assert rel_path.endswith(".png") + + @pytest.mark.parametrize( + ("content_type", "expected_ext"), + [ + ("image/png", ".png"), + ("image/jpeg", ".jpg"), + ("image/gif", ".gif"), + ("image/svg+xml", ".svg"), + ("image/bmp", ".bmp"), + ("image/tiff", ".tiff"), + ("image/webp", ".webp"), + ("image/x-emf", ".emf"), + ("image/x-wmf", ".wmf"), + ], + ) + def test_extension_mapping(self, tmp_path: Path, content_type: str, expected_ext: str) -> None: + handler = ImageHandler(tmp_path / "assets") + rel_path = handler.save_image(b"x", content_type) + assert rel_path.endswith(expected_ext) + + def test_saved_images_tracks_paths(self, tmp_path: Path) -> None: + handler = ImageHandler(tmp_path / "assets") + handler.save_image(make_png(), "image/png") + handler.save_image(b"x", "image/jpeg") + + assert len(handler.saved_images) == 2 + assert all(p.exists() for p in handler.saved_images) + + def test_saved_images_returns_copy(self, tmp_path: Path) -> None: + handler = ImageHandler(tmp_path / "assets") + handler.save_image(make_png(), "image/png") + + snapshot = handler.saved_images + snapshot.clear() + + assert len(handler.saved_images) == 1 + + +class TestHandleImage: + def test_reads_image_via_open_callback(self, tmp_path: Path) -> None: + handler = ImageHandler(tmp_path / "assets") + data = make_png() + + class FakeMammothImage: + content_type = "image/png" + + def open(self) -> BytesIO: + return BytesIO(data) + + result = handler.handle_image(FakeMammothImage()) + + assert "src" in result + assert result["src"].startswith("assets/image-001-") + assert result["src"].endswith(".png") + assert len(handler.saved_images) == 1 + + +class TestContentTypeForExtension: + @pytest.mark.parametrize( + ("ext", "expected"), + [ + (".png", "image/png"), + (".jpg", "image/jpeg"), + (".jpeg", "image/jpeg"), + (".gif", "image/gif"), + (".svg", "image/svg+xml"), + (".bmp", "image/bmp"), + (".tiff", "image/tiff"), + (".webp", "image/webp"), + (".emf", "image/x-emf"), + (".wmf", "image/x-wmf"), + ], + ) + def test_known_extensions(self, ext: str, expected: str) -> None: + assert content_type_for_extension(ext) == expected + + def test_unknown_extension_defaults_to_png(self) -> None: + assert content_type_for_extension(".xyz") == "image/png" diff --git a/tests/test_importer_markdown_converter.py b/tests/test_importer_markdown_converter.py new file mode 100644 index 0000000..d97fd8c --- /dev/null +++ b/tests/test_importer_markdown_converter.py @@ -0,0 +1,98 @@ +"""Tests for LeafpressMarkdownConverter.""" + +from __future__ import annotations + +from leafpress.importer.markdown_converter import LeafpressMarkdownConverter + + +def _convert(html: str) -> str: + return LeafpressMarkdownConverter().convert(html) + + +class TestOptions: + def test_headings_use_atx_style(self) -> None: + result = _convert("

Title

Subtitle

") + assert "# Title" in result + assert "## Subtitle" in result + + def test_bullets_use_dash(self) -> None: + result = _convert("") + assert "- one" in result + assert "- two" in result + + def test_strong_uses_asterisks(self) -> None: + result = _convert("

bold

") + assert "**bold**" in result + + +class TestConvertPre: + def test_pre_without_language_is_fenced(self) -> None: + result = _convert("
print('hi')
") + assert "```\nprint('hi')\n```" in result + + def test_pre_with_code_language_class(self) -> None: + result = _convert("
print('hi')
") + assert "```python" in result + assert "print('hi')" in result + assert result.rstrip().endswith("```") + + def test_pre_with_non_language_class_has_no_lang(self) -> None: + result = _convert('
x = 1
') + assert "```\n" in result + assert "x = 1" in result + + def test_pre_picks_first_language_class(self) -> None: + result = _convert( + '
fn main() {}
' + ) + assert "```rust" in result + + +class TestConvertTable: + def test_simple_table_with_header(self) -> None: + html = """ + + + + +
NameAge
Alice30
Bob25
+ """ + result = _convert(html) + assert "| Name" in result + assert "| Age" in result + assert "| Alice" in result + assert "| Bob" in result + # separator row of dashes + assert "| ---" in result + + def test_ragged_rows_are_padded(self) -> None: + html = """ + + + +
ABC
12
+ """ + result = _convert(html) + lines = [ln for ln in result.splitlines() if ln.startswith("|")] + # All table lines should have the same pipe count (4: leading + 3 separators) + pipe_counts = {ln.count("|") for ln in lines} + assert pipe_counts == {4} + + def test_columns_padded_to_min_width_three(self) -> None: + html = "
A
B
" + result = _convert(html) + # Header cell "A" padded to width 3 -> "| A |" + assert "| A |" in result + assert "| --- |" in result + + def test_empty_table_returns_input_text(self) -> None: + html = "
" + result = _convert(html) + # No pipe-table output when there are no rows + assert "|" not in result + + def test_cell_with_newlines_is_flattened(self) -> None: + html = "
H
line one
line two
" + result = _convert(html) + table_lines = [ln for ln in result.splitlines() if ln.startswith("|")] + assert any("line one line two" in ln for ln in table_lines) diff --git a/tests/test_package_version.py b/tests/test_package_version.py index 56b9eb3..d0c7231 100644 --- a/tests/test_package_version.py +++ b/tests/test_package_version.py @@ -44,25 +44,19 @@ def test_candidate_dirs_includes_start(tmp_path: Path) -> None: def test_pyproject_pep621(tmp_path: Path) -> None: (tmp_path / ".git").mkdir() - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "myapp"\nversion = "1.2.3"\n' - ) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"\nversion = "1.2.3"\n') assert detect_package_version(tmp_path) == "1.2.3" def test_pyproject_poetry(tmp_path: Path) -> None: (tmp_path / ".git").mkdir() - (tmp_path / "pyproject.toml").write_text( - '[tool.poetry]\nname = "myapp"\nversion = "2.0.0"\n' - ) + (tmp_path / "pyproject.toml").write_text('[tool.poetry]\nname = "myapp"\nversion = "2.0.0"\n') assert detect_package_version(tmp_path) == "2.0.0" def test_cargo_toml(tmp_path: Path) -> None: (tmp_path / ".git").mkdir() - (tmp_path / "Cargo.toml").write_text( - '[package]\nname = "myapp"\nversion = "0.3.1"\n' - ) + (tmp_path / "Cargo.toml").write_text('[package]\nname = "myapp"\nversion = "0.3.1"\n') assert detect_package_version(tmp_path) == "0.3.1" @@ -121,9 +115,7 @@ def test_csproj_version_prefix(tmp_path: Path) -> None: def test_finds_manifest_in_parent(tmp_path: Path) -> None: """Manifest in project root should be found when starting from docs subdir.""" (tmp_path / ".git").mkdir() - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "myapp"\nversion = "9.9.9"\n' - ) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"\nversion = "9.9.9"\n') docs = tmp_path / "docs" docs.mkdir() assert detect_package_version(docs) == "9.9.9" @@ -131,9 +123,7 @@ def test_finds_manifest_in_parent(tmp_path: Path) -> None: def test_does_not_cross_vcs_boundary(tmp_path: Path) -> None: """Should not find a manifest above the .git root.""" - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "outer"\nversion = "0.0.1"\n' - ) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "outer"\nversion = "0.0.1"\n') inner = tmp_path / "inner" inner.mkdir() (inner / ".git").mkdir() @@ -149,12 +139,8 @@ def test_no_manifest_returns_none(tmp_path: Path) -> None: def test_priority_pyproject_over_cargo(tmp_path: Path) -> None: """pyproject.toml takes priority over Cargo.toml in the same directory.""" (tmp_path / ".git").mkdir() - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "myapp"\nversion = "1.0.0"\n' - ) - (tmp_path / "Cargo.toml").write_text( - '[package]\nname = "myapp"\nversion = "2.0.0"\n' - ) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"\nversion = "1.0.0"\n') + (tmp_path / "Cargo.toml").write_text('[package]\nname = "myapp"\nversion = "2.0.0"\n') assert detect_package_version(tmp_path) == "1.0.0" @@ -228,10 +214,7 @@ def test_pom_xml_no_namespace(tmp_path: Path) -> None: """pom.xml without a namespace should still detect version.""" (tmp_path / ".git").mkdir() (tmp_path / "pom.xml").write_text( - '\n' - "\n" - " 1.0.0\n" - "\n" + '\n\n 1.0.0\n\n' ) assert detect_package_version(tmp_path) == "1.0.0" diff --git a/uv.lock b/uv.lock index 5abaea1..4719464 100644 --- a/uv.lock +++ b/uv.lock @@ -416,7 +416,7 @@ wheels = [ [[package]] name = "leafpress" -version = "0.8.1" +version = "0.8.2" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },