From 8ddb23a72ccb5e9b6ce7987c41f19dbdfc812945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ruben=20Fern=C3=A1ndez-Fuertes?= Date: Wed, 11 Mar 2026 16:05:26 +0100 Subject: [PATCH] Boost test coverage from 75% to 90% Add test_save_results.py (22 tests) covering OCR 3 rendering: tables, hyperlinks, headers/footers, page dimensions, figures, truncation notes, and original file copying. Add test_cli_overrides.py (18 tests) covering CLI config override precedence, error handling, and process() orchestration (single file success/skip/reprocess/failure, directory processing). --- tests/test_cli_overrides.py | 286 ++++++++++++++++++++++++++++++++++++ tests/test_save_results.py | 280 +++++++++++++++++++++++++++++++++++ 2 files changed, 566 insertions(+) create mode 100644 tests/test_cli_overrides.py create mode 100644 tests/test_save_results.py diff --git a/tests/test_cli_overrides.py b/tests/test_cli_overrides.py new file mode 100644 index 0000000..779fbde --- /dev/null +++ b/tests/test_cli_overrides.py @@ -0,0 +1,286 @@ +"""Tests for CLI config overrides and process() orchestration.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from mistral_ocr.cli import main +from mistral_ocr.config import Config +from mistral_ocr.processor import OCRProcessor + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _mock_processor(): + """Return a mock OCRProcessor that does nothing.""" + mock = MagicMock(spec=OCRProcessor) + mock.errors = [] + mock.process.return_value = None + return mock + + +# --------------------------------------------------------------------------- +# CLI config override tests +# --------------------------------------------------------------------------- + + +class TestCliOverrides: + """CLI flags override env/config defaults only when explicitly passed.""" + + def test_model_override(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf), "--model", "custom-model"]) + config = mock_cls.call_args[0][0] + assert config.model == "custom-model" + + def test_no_images_override(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf), "--no-images"]) + config = mock_cls.call_args[0][0] + assert config.include_images is False + + def test_table_format_override(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf), "--table-format", "html"]) + config = mock_cls.call_args[0][0] + assert config.table_format == "html" + + def test_extract_headers_override(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf), "--extract-headers"]) + config = mock_cls.call_args[0][0] + assert config.extract_header is True + + def test_extract_footers_override(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf), "--extract-footers"]) + config = mock_cls.call_args[0][0] + assert config.extract_footer is True + + def test_workers_override(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf), "--workers", "4"]) + config = mock_cls.call_args[0][0] + assert config.max_workers == 4 + + def test_max_pages_override(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf), "--max-pages", "100"]) + config = mock_cls.call_args[0][0] + assert config.max_pages == 100 + + def test_no_metadata_override(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf), "--no-metadata"]) + config = mock_cls.call_args[0][0] + assert config.include_metadata is False + + def test_no_page_headings_override(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf), "--no-page-headings"]) + config = mock_cls.call_args[0][0] + assert config.include_page_headings is False + + def test_defaults_not_overridden(self, runner, tmp_path, monkeypatch): + """When no CLI flags are passed, config keeps env/default values.""" + monkeypatch.setenv("MISTRAL_API_KEY", "key") + monkeypatch.setenv("INCLUDE_IMAGES", "false") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + runner.invoke(main, [str(pdf)]) + config = mock_cls.call_args[0][0] + # Env says false, CLI didn't override — should stay false + assert config.include_images is False + + +# --------------------------------------------------------------------------- +# CLI error handling +# --------------------------------------------------------------------------- + + +class TestCliErrors: + def test_missing_api_key(self, runner, tmp_path, monkeypatch): + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + result = runner.invoke(main, [str(pdf)]) + assert result.exit_code == 1 + assert "MISTRAL_API_KEY" in result.output + + def test_nonexistent_path(self, runner, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + result = runner.invoke(main, ["/nonexistent/path"]) + assert result.exit_code == 1 + assert "does not exist" in result.output + + def test_api_key_via_flag(self, runner, tmp_path, monkeypatch): + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + result = runner.invoke(main, [str(pdf), "--api-key", "my-key"]) + assert result.exit_code == 0 + + def test_version_flag(self, runner): + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "1.2.0" in result.output + + def test_verbose_flag(self, runner, tmp_path, monkeypatch): + monkeypatch.setenv("MISTRAL_API_KEY", "key") + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF") + + with patch("mistral_ocr.cli.OCRProcessor") as mock_cls: + mock_cls.return_value = _mock_processor() + result = runner.invoke(main, [str(pdf), "--verbose"]) + assert result.exit_code == 0 + config = mock_cls.call_args[0][0] + assert config.verbose is True + + +# --------------------------------------------------------------------------- +# process() orchestration +# --------------------------------------------------------------------------- + + +class TestProcessOrchestration: + """Test the process() method routing and skip logic.""" + + def _make_real_processor(self, **overrides): + defaults = {"api_key": "test", "save_original_images": False} + defaults.update(overrides) + proc = OCRProcessor.__new__(OCRProcessor) + proc.config = Config(**defaults) + proc.client = MagicMock() + proc.errors = [] + proc.processed_files = [] + import threading + + proc._lock = threading.Lock() + return proc + + def test_single_file_success(self, tmp_path): + proc = self._make_real_processor() + img = tmp_path / "doc.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + + response = SimpleNamespace(pages=[SimpleNamespace(index=0, markdown="Hello", images=[])]) + proc.client.ocr.process.return_value = response + + proc.process(img) + assert len(proc.processed_files) == 1 + assert len(proc.errors) == 0 + # Check output was created + out_dir = tmp_path / "mistral_ocr_output" + assert (out_dir / "doc" / "doc.md").exists() + + def test_single_file_skip_already_processed(self, tmp_path): + proc = self._make_real_processor() + img = tmp_path / "doc.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + + # First process + response = SimpleNamespace(pages=[SimpleNamespace(index=0, markdown="Hello", images=[])]) + proc.client.ocr.process.return_value = response + proc.process(img) + + # Second process — should skip + proc.client.ocr.process.reset_mock() + proc.process(img) + proc.client.ocr.process.assert_not_called() + + def test_single_file_reprocess(self, tmp_path): + proc = self._make_real_processor() + img = tmp_path / "doc.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + + response = SimpleNamespace(pages=[SimpleNamespace(index=0, markdown="Hello", images=[])]) + proc.client.ocr.process.return_value = response + proc.process(img) + + # Reprocess with flag + proc.process(img, reprocess=True) + assert proc.client.ocr.process.call_count == 2 + + def test_single_file_failure(self, tmp_path): + proc = self._make_real_processor() + img = tmp_path / "doc.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + + proc.client.ocr.process.side_effect = RuntimeError("API down") + proc.process(img) + assert len(proc.errors) == 1 + assert len(proc.processed_files) == 0 + + def test_nonexistent_path_raises(self, tmp_path): + proc = self._make_real_processor() + with pytest.raises(ValueError, match="does not exist"): + proc.process(tmp_path / "nope.png") + + def test_directory_processing(self, tmp_path): + proc = self._make_real_processor() + input_dir = tmp_path / "input" + input_dir.mkdir() + for name in ["a.png", "b.png"]: + (input_dir / name).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + + response = SimpleNamespace(pages=[SimpleNamespace(index=0, markdown="text", images=[])]) + proc.client.ocr.process.return_value = response + proc.process(input_dir) + assert len(proc.processed_files) == 2 diff --git a/tests/test_save_results.py b/tests/test_save_results.py new file mode 100644 index 0000000..d4cb5fa --- /dev/null +++ b/tests/test_save_results.py @@ -0,0 +1,280 @@ +"""Tests for save_results rendering: OCR 3 features, images, truncation, originals.""" + +import base64 +from types import SimpleNamespace + +from mistral_ocr.config import Config +from mistral_ocr.processor import OCRProcessor + + +def _make_processor(**config_kwargs): + proc = OCRProcessor.__new__(OCRProcessor) + config_kwargs.setdefault("save_original_images", False) + proc.config = Config(api_key="test", **config_kwargs) + proc.errors = [] + proc.processed_files = [] + return proc + + +def _page(index=0, markdown="text", **kwargs): + return SimpleNamespace(index=index, markdown=markdown, images=[], **kwargs) + + +def _result(tmp_path, response, name="doc.pdf"): + fp = tmp_path / name + fp.write_bytes(b"%PDF-1.4 fake") + return {"file_path": fp, "response": response} + + +# --------------------------------------------------------------------------- +# Original file copy +# --------------------------------------------------------------------------- + + +class TestSaveOriginals: + def test_copies_original_when_enabled(self, tmp_path): + proc = _make_processor(save_original_images=True) + out = tmp_path / "out" + out.mkdir() + res = _result(tmp_path, SimpleNamespace(pages=[_page()])) + proc.save_results(res, out) + assert (out / "doc" / "doc.pdf").exists() + + def test_skips_copy_when_disabled(self, tmp_path): + proc = _make_processor(save_original_images=False) + out = tmp_path / "out" + out.mkdir() + res = _result(tmp_path, SimpleNamespace(pages=[_page()])) + proc.save_results(res, out) + assert not (out / "doc" / "doc.pdf").exists() + + def test_original_link_in_metadata(self, tmp_path): + proc = _make_processor(save_original_images=True, include_metadata=True) + out = tmp_path / "out" + out.mkdir() + res = _result(tmp_path, SimpleNamespace(pages=[_page()])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "**Original:**" in md + assert "doc.pdf" in md + + +# --------------------------------------------------------------------------- +# Truncation note +# --------------------------------------------------------------------------- + + +class TestTruncationNote: + def test_truncation_note_rendered(self, tmp_path): + proc = _make_processor(include_metadata=True) + out = tmp_path / "out" + out.mkdir() + response = SimpleNamespace( + pages=[_page()], truncated="Processed 50 of 200 pages (--max-pages)" + ) + res = _result(tmp_path, response) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "**Note:** Processed 50 of 200" in md + + def test_no_truncation_note_when_absent(self, tmp_path): + proc = _make_processor(include_metadata=True) + out = tmp_path / "out" + out.mkdir() + res = _result(tmp_path, SimpleNamespace(pages=[_page()])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "**Note:**" not in md + + +# --------------------------------------------------------------------------- +# Page dimensions (OCR 3) +# --------------------------------------------------------------------------- + + +class TestPageDimensions: + def test_dimensions_rendered(self, tmp_path): + proc = _make_processor(include_page_headings=True) + out = tmp_path / "out" + out.mkdir() + dims = SimpleNamespace(width=612, height=792) + page = _page(dimensions=dims) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "612 x 792" in md + + def test_no_dimensions_when_absent(self, tmp_path): + proc = _make_processor() + out = tmp_path / "out" + out.mkdir() + res = _result(tmp_path, SimpleNamespace(pages=[_page()])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "Page size" not in md + + +# --------------------------------------------------------------------------- +# Headers and footers (OCR 3) +# --------------------------------------------------------------------------- + + +class TestHeaderFooter: + def test_header_rendered(self, tmp_path): + proc = _make_processor() + out = tmp_path / "out" + out.mkdir() + page = _page(header="Chapter 1") + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "> **Header:** Chapter 1" in md + + def test_footer_rendered(self, tmp_path): + proc = _make_processor() + out = tmp_path / "out" + out.mkdir() + page = _page(footer="Page 1 of 10") + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "> **Footer:** Page 1 of 10" in md + + def test_no_header_when_empty(self, tmp_path): + proc = _make_processor() + out = tmp_path / "out" + out.mkdir() + page = _page(header="") + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "**Header:**" not in md + + +# --------------------------------------------------------------------------- +# Tables (OCR 3) +# --------------------------------------------------------------------------- + + +class TestTables: + def test_table_saved_as_markdown(self, tmp_path): + proc = _make_processor(table_format="markdown") + out = tmp_path / "out" + out.mkdir() + table = SimpleNamespace(content="| A | B |\n|---|---|\n| 1 | 2 |") + page = _page(tables=[table]) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + table_path = out / "doc" / "tables" / "page1_table1.md" + assert table_path.exists() + assert "| A | B |" in table_path.read_text() + md = (out / "doc" / "doc.md").read_text() + assert "[Table 1](./tables/page1_table1.md)" in md + + def test_table_saved_as_html(self, tmp_path): + proc = _make_processor(table_format="html") + out = tmp_path / "out" + out.mkdir() + table = SimpleNamespace(content="
1
") + page = _page(tables=[table]) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + table_path = out / "doc" / "tables" / "page1_table1.html" + assert table_path.exists() + assert "" in table_path.read_text() + + def test_table_falls_back_to_markdown_attr(self, tmp_path): + proc = _make_processor(table_format="markdown") + out = tmp_path / "out" + out.mkdir() + table = SimpleNamespace(markdown="| X |") # no 'content' attr + page = _page(tables=[table]) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + assert "| X |" in (out / "doc" / "tables" / "page1_table1.md").read_text() + + +# --------------------------------------------------------------------------- +# Hyperlinks (OCR 3) +# --------------------------------------------------------------------------- + + +class TestHyperlinks: + def test_hyperlinks_rendered(self, tmp_path): + proc = _make_processor() + out = tmp_path / "out" + out.mkdir() + link = SimpleNamespace(text="Example", url="https://example.com") + page = _page(hyperlinks=[link]) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "**Hyperlinks:**" in md + assert "[Example](https://example.com)" in md + + def test_hyperlink_without_text(self, tmp_path): + proc = _make_processor() + out = tmp_path / "out" + out.mkdir() + link = SimpleNamespace(text="", url="https://example.com") + page = _page(hyperlinks=[link]) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "[https://example.com](https://example.com)" in md + + def test_hyperlink_with_href_fallback(self, tmp_path): + proc = _make_processor() + out = tmp_path / "out" + out.mkdir() + link = SimpleNamespace(text="Link", href="https://test.com") # no 'url' attr + page = _page(hyperlinks=[link]) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + md = (out / "doc" / "doc.md").read_text() + assert "[Link](https://test.com)" in md + + +# --------------------------------------------------------------------------- +# Figures / images +# --------------------------------------------------------------------------- + + +class TestFigures: + def test_image_saved_and_linked(self, tmp_path): + proc = _make_processor(include_images=True) + out = tmp_path / "out" + out.mkdir() + # 1x1 red PNG as base64 + b64 = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50).decode() + img = SimpleNamespace(image_base64=b64, id="fig1.png") + page = SimpleNamespace(index=0, markdown="text", images=[img]) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + assert (out / "doc" / "figures" / "page1_img1.png").exists() + md = (out / "doc" / "doc.md").read_text() + assert "![Image 1](./figures/page1_img1.png)" in md + + def test_no_figures_when_images_disabled(self, tmp_path): + proc = _make_processor(include_images=False) + out = tmp_path / "out" + out.mkdir() + b64 = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50).decode() + img = SimpleNamespace(image_base64=b64, id="fig1.png") + page = SimpleNamespace(index=0, markdown="text", images=[img]) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + assert not (out / "doc" / "figures").exists() + md = (out / "doc" / "doc.md").read_text() + assert "![Image" not in md + + def test_image_default_extension(self, tmp_path): + proc = _make_processor(include_images=True) + out = tmp_path / "out" + out.mkdir() + b64 = base64.b64encode(b"\x89PNG" + b"\x00" * 50).decode() + img = SimpleNamespace(image_base64=b64, id="no_ext") # no extension in id + page = SimpleNamespace(index=0, markdown="text", images=[img]) + res = _result(tmp_path, SimpleNamespace(pages=[page])) + proc.save_results(res, out) + assert (out / "doc" / "figures" / "page1_img1.png").exists()