"""
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 '' in html
assert '' in html
assert '' 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 "" in html
assert "" 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 "" 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 "" 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 "bold" in html
assert "italic" 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
|