""" Comprehensive tests for PDFService. Tests PDF generation, markdown conversion, CSS handling, and metadata. """ import io import subprocess import pytest from unittest.mock import patch def _host_has_cjk_fonts() -> bool: """True if fontconfig reports any CJK-capable family on the host. Used to gate the strong glyph-embedding assertion in test_handles_cjk_content_embeds_glyphs: Docker CI (where this PR installs fonts-noto-cjk) and properly-configured dev hosts run the assertion; bare pip/macOS/Windows installs without Noto CJK skip it. """ try: result = subprocess.run( ["fc-list", ":lang=zh"], capture_output=True, text=True, timeout=5, ) except (FileNotFoundError, subprocess.TimeoutExpired): return False return bool(result.stdout.strip()) class TestPDFServiceInit: """Tests for PDFService initialization.""" def test_init_creates_minimal_css(self): """Test that initialization creates minimal CSS.""" from local_deep_research.web.services.pdf_service import PDFService service = PDFService() assert service.minimal_css is not None def test_minimal_css_includes_page_settings(self): """Test that minimal CSS includes @page settings.""" from local_deep_research.web.services.pdf_service import PDFService service = PDFService() # The CSS object wraps the string, so we check it was created assert service.minimal_css is not None class TestPDFServiceMarkdownToHtml: """Tests for _markdown_to_html method.""" @pytest.fixture def service(self): """Create PDFService instance.""" from local_deep_research.web.services.pdf_service import PDFService return PDFService() def test_converts_basic_markdown(self, service, simple_markdown): """Test conversion of basic markdown to HTML.""" html = service._markdown_to_html(simple_markdown) # Markdown adds id attribute to headings assert "" in html assert "

This is a test.

" in html def test_includes_doctype_and_structure(self, service, simple_markdown): """Test that HTML includes proper structure.""" html = service._markdown_to_html(simple_markdown) assert "" in html assert "" in html assert "" in html assert "" in html assert "" in html assert "" in html assert "" in html def test_includes_charset_meta(self, service, simple_markdown): """Test that HTML includes UTF-8 charset.""" html = service._markdown_to_html(simple_markdown) assert '' in html def test_includes_title_when_provided(self, service, simple_markdown): """Test that title is included when provided.""" html = service._markdown_to_html(simple_markdown, title="Test Title") assert "Test Title" in html def test_excludes_title_when_not_provided(self, service, simple_markdown): """Test that no title tag when title not provided.""" html = service._markdown_to_html(simple_markdown) assert "" not in html def test_includes_metadata_as_meta_tags( self, service, simple_markdown, sample_metadata ): """Test that metadata is included as meta tags.""" html = service._markdown_to_html( simple_markdown, metadata=sample_metadata ) assert '<meta name="author" content="Test Author">' in html assert '<meta name="date" content="2024-01-15">' in html assert '<meta name="subject" content="Test Subject">' in html def test_includes_ldr_attribution_footer(self, service, simple_markdown): """Test that LDR attribution footer is included.""" html = service._markdown_to_html(simple_markdown) assert "Generated by" in html assert "Local Deep Research" in html def test_converts_tables(self, service, sample_markdown): """Test that markdown tables are converted.""" html = service._markdown_to_html(sample_markdown) assert "<table>" in html assert "<th>" in html assert "Column 1" in html def test_converts_code_blocks(self, service, sample_markdown): """Test that fenced code blocks are converted.""" html = service._markdown_to_html(sample_markdown) assert "<code" in html or "<pre>" in html assert "def example():" in html def test_converts_lists(self, service, sample_markdown): """Test that lists are converted.""" html = service._markdown_to_html(sample_markdown) assert "<li>" in html assert "Item 1" in html def test_converts_bold_and_italic(self, service, sample_markdown): """Test that bold and italic text are converted.""" html = service._markdown_to_html(sample_markdown) assert "<strong>bold</strong>" in html assert "<em>italic</em>" in html def test_handles_special_characters( self, service, markdown_with_special_chars ): """Test handling of special characters.""" html = service._markdown_to_html(markdown_with_special_chars) # Unicode should pass through assert "café" in html assert "naïve" in html assert "日本語" in html class TestPDFServiceMarkdownToPdf: """Tests for markdown_to_pdf method.""" @pytest.fixture def service(self): """Create PDFService instance.""" from local_deep_research.web.services.pdf_service import PDFService return PDFService() def test_returns_bytes(self, service, simple_markdown): """Test that markdown_to_pdf returns bytes.""" result = service.markdown_to_pdf(simple_markdown) assert isinstance(result, bytes) def test_returns_valid_pdf(self, service, simple_markdown): """Test that result is a valid PDF (starts with %PDF).""" result = service.markdown_to_pdf(simple_markdown) assert result.startswith(b"%PDF") def test_pdf_has_reasonable_size(self, service, simple_markdown): """Test that generated PDF has a reasonable size.""" result = service.markdown_to_pdf(simple_markdown) # A simple PDF should be at least a few KB assert len(result) > 1000 def test_applies_title(self, service, simple_markdown): """Test that title is applied to PDF.""" result = service.markdown_to_pdf(simple_markdown, title="My Title") # PDF was generated successfully assert result.startswith(b"%PDF") assert len(result) > 0 def test_applies_metadata(self, service, simple_markdown, sample_metadata): """Test that metadata is applied to PDF.""" result = service.markdown_to_pdf( simple_markdown, metadata=sample_metadata ) assert result.startswith(b"%PDF") def test_applies_custom_css(self, service, simple_markdown, custom_css): """Test that custom CSS is applied.""" result = service.markdown_to_pdf(simple_markdown, custom_css=custom_css) assert result.startswith(b"%PDF") def test_uses_minimal_css_when_no_custom_css( self, service, simple_markdown ): """Test that minimal CSS is used by default.""" result = service.markdown_to_pdf(simple_markdown) assert result.startswith(b"%PDF") def test_handles_empty_markdown(self, service): """Test handling of empty markdown.""" result = service.markdown_to_pdf("") assert result.startswith(b"%PDF") def test_handles_large_markdown(self, service): """Test handling of large markdown content.""" # Create a large markdown document large_content = "# Large Document\n\n" large_content += ("This is a paragraph. " * 100 + "\n\n") * 50 result = service.markdown_to_pdf(large_content) assert result.startswith(b"%PDF") # Large content should produce larger PDF assert len(result) > 10000 def test_handles_markdown_with_all_features(self, service, sample_markdown): """Test handling of markdown with tables, code, lists.""" result = service.markdown_to_pdf(sample_markdown) assert result.startswith(b"%PDF") def test_handles_cjk_content(self, service): """Regression guard for #4055 — CJK text must not crash export. Glyph visibility depends on the host having CJK fonts installed (e.g. fonts-noto-cjk). This test pins the no-crash contract; the glyph-embedding contract is checked separately in test_handles_cjk_content_embeds_glyphs. """ cjk_markdown = ( "# 中文标题\n\n这是一个测试。\n\n" "## 日本語の見出し\n\nテスト本文。\n\n" "## 한국어 제목\n\n본문 테스트.\n" ) result = service.markdown_to_pdf(cjk_markdown) assert result.startswith(b"%PDF") assert len(result) > 1000 @pytest.mark.skipif( not _host_has_cjk_fonts(), reason="no CJK fonts on host; install fonts-noto-cjk to run", ) def test_handles_cjk_content_embeds_glyphs(self, service): """End-to-end glyph check for #4055. Round-trips CJK text through markdown → PDF → text extraction. If no CJK-capable font is available to WeasyPrint, glyphs are dropped and pypdf's extract_text returns the rendered text without them. What this catches: removal of fonts-noto-cjk from the runtime Docker image (the load-bearing half of the fix) — pytest-tests runs inside that image, so the test fails when fonts are gone. What this does NOT catch: removal of the CSS CJK fallback list. On Linux, fontconfig auto-substitutes a glyph-bearing family even for a Latin-only CSS stack, so the assertion still passes. The CSS fallbacks matter on Windows/macOS, which CI doesn't run. """ import pypdf cjk_markdown = ( "# 中文标题\n\n这是一个测试。\n\n" "## 日本語の見出し\n\nテスト本文。\n\n" "## 한국어 제목\n\n본문 테스트.\n" ) pdf_bytes = service.markdown_to_pdf(cjk_markdown) reader = pypdf.PdfReader(io.BytesIO(pdf_bytes)) extracted = "".join(page.extract_text() for page in reader.pages) for phrase in ( "中文标题", "这是一个测试", "日本語", "テスト", "한국어", "본문", ): assert phrase in extracted, ( f"CJK glyphs missing from PDF: {phrase!r} not found in " f"extracted text — WeasyPrint could not match a glyph " f"(check that fonts-noto-cjk is installed on the host)" ) def test_logs_pdf_size(self, service, simple_markdown): """Test that PDF size is logged.""" with patch( "local_deep_research.web.services.pdf_service.logger" ) as mock_logger: service.markdown_to_pdf(simple_markdown) mock_logger.info.assert_called_once() call_args = mock_logger.info.call_args[0][0] assert "Generated PDF" in call_args assert "bytes" in call_args class TestPDFServiceErrorHandling: """Tests for error handling in PDFService.""" @pytest.fixture def service(self): """Create PDFService instance.""" from local_deep_research.web.services.pdf_service import PDFService return PDFService() def test_raises_exception_on_html_error(self, service): """Test that exceptions are raised on HTML conversion errors.""" with patch.object( service, "_markdown_to_html", side_effect=Exception("HTML error") ): with pytest.raises(Exception) as exc_info: service.markdown_to_pdf("test") assert "HTML error" in str(exc_info.value) def test_logs_exception_on_error(self, service): """Test that exceptions are logged.""" with patch.object( service, "_markdown_to_html", side_effect=Exception("Test error") ): with patch( "local_deep_research.web.services.pdf_service.logger" ) as mock_logger: with pytest.raises(Exception): service.markdown_to_pdf("test") mock_logger.exception.assert_called_once() class TestPDFServiceSingleton: """Tests for get_pdf_service singleton.""" def test_get_pdf_service_returns_instance(self): """Test that get_pdf_service returns a PDFService instance.""" from local_deep_research.web.services.pdf_service import ( get_pdf_service, PDFService, ) service = get_pdf_service() assert isinstance(service, PDFService) def test_get_pdf_service_returns_same_instance(self): """Test that get_pdf_service returns the same instance.""" from local_deep_research.web.services.pdf_service import ( get_pdf_service, ) service1 = get_pdf_service() service2 = get_pdf_service() assert service1 is service2 def test_singleton_can_be_reset(self): """Test that singleton can be reset for testing.""" import local_deep_research.web.services.pdf_service as pdf_module # Reset singleton pdf_module._pdf_service = None service1 = pdf_module.get_pdf_service() # Reset again pdf_module._pdf_service = None service2 = pdf_module.get_pdf_service() # Different instances after reset assert service1 is not service2