"""Tests for the EPUB builder module.""" from __future__ import annotations import logging from pathlib import Path from unittest.mock import MagicMock, patch import pytest # Fixtures are imported from conftest.py automatically by pytest # Import from parent directory (handled by conftest.py sys.path) from build_epub import ( BuildState, ChapterCollector, EPUBConfig, MermaidRenderer, MermaidRenderError, ValidationError, create_chapter_html, extract_all_mermaid_blocks, get_chapter_order, sanitize_mermaid, setup_logging, validate_inputs, ) # ============================================================================= # BuildState Tests # ============================================================================= class TestBuildState: """Tests for BuildState dataclass.""" def test_initial_state(self, state: BuildState) -> None: """Test that initial state is empty.""" assert state.mermaid_counter == 0 assert len(state.mermaid_cache) == 0 assert len(state.mermaid_added_to_book) == 0 assert len(state.path_to_chapter) == 0 def test_state_modification(self, state: BuildState) -> None: """Test that state can be modified.""" state.mermaid_counter = 5 state.mermaid_cache["key"] = (b"data", "file.png") state.mermaid_added_to_book.add("file.png") state.path_to_chapter["README.md"] = "chap_01.xhtml" assert state.mermaid_counter == 5 assert state.mermaid_cache["key"] == (b"data", "file.png") assert "file.png" in state.mermaid_added_to_book assert state.path_to_chapter["README.md"] == "chap_01.xhtml" def test_reset(self, state: BuildState) -> None: """Test that reset clears all state.""" state.mermaid_counter = 5 state.mermaid_cache["key"] = (b"data", "file.png") state.mermaid_added_to_book.add("file.png") state.path_to_chapter["README.md"] = "chap_01.xhtml" state.reset() assert state.mermaid_counter == 0 assert len(state.mermaid_cache) == 0 assert len(state.mermaid_added_to_book) == 0 assert len(state.path_to_chapter) == 0 # ============================================================================= # EPUBConfig Tests # ============================================================================= class TestEPUBConfig: """Tests for EPUBConfig dataclass.""" def test_required_fields(self, tmp_path: Path) -> None: """Test that required fields must be provided.""" config = EPUBConfig( root_path=tmp_path, output_path=tmp_path / "out.epub", ) assert config.root_path == tmp_path assert config.output_path == tmp_path / "out.epub" def test_default_values(self, tmp_path: Path) -> None: """Test that default values are set correctly.""" config = EPUBConfig( root_path=tmp_path, output_path=tmp_path / "out.epub", ) assert config.identifier == "claude-howto-guide" assert config.title == "Claude Code How-To Guide" assert config.language == "en" assert config.author == "Claude Code Community" assert config.mmdc_path == "mmdc" def test_custom_values(self, tmp_path: Path) -> None: """Test that custom values override defaults.""" config = EPUBConfig( root_path=tmp_path, output_path=tmp_path / "out.epub", title="Custom Title", mmdc_path="/usr/local/bin/mmdc", ) assert config.title == "Custom Title" assert config.mmdc_path == "/usr/local/bin/mmdc" # ============================================================================= # Validation Tests # ============================================================================= class TestValidation: """Tests for input validation.""" def test_valid_inputs(self, config: EPUBConfig, logger: logging.Logger) -> None: """Test that valid inputs pass validation.""" # Should not raise validate_inputs(config, logger) def test_missing_root_path(self, tmp_path: Path, logger: logging.Logger) -> None: """Test that missing root path raises ValidationError.""" config = EPUBConfig( root_path=tmp_path / "nonexistent", output_path=tmp_path / "out.epub", ) with pytest.raises(ValidationError, match="Root path does not exist"): validate_inputs(config, logger) def test_root_path_is_file(self, tmp_path: Path, logger: logging.Logger) -> None: """Test that file as root path raises ValidationError.""" file_path = tmp_path / "file.txt" file_path.write_text("content") config = EPUBConfig( root_path=file_path, output_path=tmp_path / "out.epub", ) with pytest.raises(ValidationError, match="Root path is not a directory"): validate_inputs(config, logger) def test_no_markdown_files(self, tmp_path: Path, logger: logging.Logger) -> None: """Test that directory with no markdown files raises ValidationError.""" empty_dir = tmp_path / "empty" empty_dir.mkdir() config = EPUBConfig( root_path=empty_dir, output_path=tmp_path / "out.epub", ) with pytest.raises(ValidationError, match="No markdown files found"): validate_inputs(config, logger) def test_missing_output_directory( self, tmp_project: Path, logger: logging.Logger ) -> None: """Test that missing output directory raises ValidationError.""" config = EPUBConfig( root_path=tmp_project, output_path=tmp_project / "nonexistent" / "out.epub", ) with pytest.raises(ValidationError, match="Output directory does not exist"): validate_inputs(config, logger) # ============================================================================= # Mermaid Processing Tests # ============================================================================= class TestMermaidProcessing: """Tests for Mermaid diagram processing.""" def test_sanitize_mermaid_numbered_list(self) -> None: """Test that numbered lists in brackets are escaped.""" input_code = 'A["1. First item"] --> B["2. Second item"]' expected = 'A["1\\. First item"] --> B["2\\. Second item"]' assert sanitize_mermaid(input_code) == expected def test_sanitize_mermaid_no_change(self) -> None: """Test that code without numbered lists is unchanged.""" input_code = "A --> B --> C" assert sanitize_mermaid(input_code) == input_code def test_extract_mermaid_blocks( self, tmp_path: Path, logger: logging.Logger ) -> None: """Test extraction of Mermaid blocks from files.""" # Create test file with mermaid blocks md_file = tmp_path / "test.md" md_file.write_text( """# Test ```mermaid graph TD A --> B ``` Some text ```mermaid graph LR C --> D ``` """ ) diagrams = extract_all_mermaid_blocks([(md_file, "Test")], logger) assert len(diagrams) == 2 assert diagrams[0][0] == 1 # First diagram index assert diagrams[1][0] == 2 # Second diagram index assert "A --> B" in diagrams[0][1] assert "C --> D" in diagrams[1][1] def test_extract_mermaid_blocks_deduplication( self, tmp_path: Path, logger: logging.Logger ) -> None: """Test that duplicate Mermaid blocks are deduplicated.""" md_file1 = tmp_path / "test1.md" md_file2 = tmp_path / "test2.md" same_diagram = """```mermaid graph TD A --> B ```""" md_file1.write_text(f"# File 1\n\n{same_diagram}") md_file2.write_text(f"# File 2\n\n{same_diagram}") diagrams = extract_all_mermaid_blocks( [(md_file1, "Test1"), (md_file2, "Test2")], logger ) # Should only have one diagram since they're identical assert len(diagrams) == 1 # ============================================================================= # Chapter Collection Tests # ============================================================================= class TestChapterCollector: """Tests for ChapterCollector class.""" def test_collect_single_file(self, tmp_path: Path, state: BuildState) -> None: """Test collecting a single markdown file.""" readme = tmp_path / "README.md" readme.write_text("# Test") collector = ChapterCollector(tmp_path, state) chapters = collector.collect_all_chapters([("README.md", "Introduction")]) assert len(chapters) == 1 assert chapters[0].file_path == readme assert chapters[0].display_name == "Introduction" assert chapters[0].chapter_filename == "chap_01.xhtml" assert state.path_to_chapter["README.md"] == "chap_01.xhtml" def test_collect_folder(self, tmp_project: Path, state: BuildState) -> None: """Test collecting a folder with multiple files.""" collector = ChapterCollector(tmp_project, state) chapters = collector.collect_all_chapters([("01-test-chapter", "Test Chapter")]) assert len(chapters) == 2 # README.md and section.md assert chapters[0].is_folder_overview is True assert chapters[0].folder_name == "Test Chapter" assert chapters[1].is_folder_overview is False def test_path_mapping(self, tmp_project: Path, state: BuildState) -> None: """Test that path mapping is built correctly.""" collector = ChapterCollector(tmp_project, state) collector.collect_all_chapters( [ ("README.md", "Introduction"), ("01-test-chapter", "Test Chapter"), ] ) assert "README.md" in state.path_to_chapter assert "01-test-chapter" in state.path_to_chapter assert "01-test-chapter/README.md" in state.path_to_chapter # ============================================================================= # HTML Generation Tests # ============================================================================= class TestHTMLGeneration: """Tests for HTML generation.""" def test_create_chapter_html_overview(self) -> None: """Test creating HTML for an overview chapter.""" html = create_chapter_html( display_name="Introduction", file_title="Introduction", html_content="
Content
", is_overview=True, ) assert "" in html assert 'Introduction" in html assert "Content
" in html def test_create_chapter_html_section(self) -> None: """Test creating HTML for a section chapter.""" html = create_chapter_html( display_name="Chapter", file_title="Section", html_content="Content
", is_overview=False, ) assert "Content
", is_overview=True, ) assert "<script>" in html # Note: Python's html.escape uses ' for single quotes assert "