""" Unit tests for run_inference_multi, run_inference delegation, and decode gr.File dispatch. These tests NEVER load model weights — BUREAUCAT_NO_MODEL=1 is set before importing app. Test coverage: (a) app.run_inference_multi is callable and accepts a list arg (inspect signature has 'images' as first param) (b) run_inference delegates — patch app.run_inference_multi with a stub and assert run_inference(img, ...) forwards [img] (c) gr.File payload dispatch in decode: skips None paths, truncates a 7-file list to MAX_PAGES_HARD, and passes exactly MAX_PAGES_HARD images through unchanged. (Updated from Gallery payload to gr.File list[str] payload in 03-02.) """ import inspect import io import os import sys import tempfile from unittest.mock import patch, MagicMock from PIL import Image as PILImage # Set escape hatch BEFORE importing app so model weights are never downloaded. os.environ["BUREAUCAT_NO_MODEL"] = "1" # Ensure project root is on the path when running from tests/ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import app from app import StructuredResult def _stub_result(raw="stub"): """Build a minimal StructuredResult for use as a stub return value.""" return StructuredResult( transcription="", quip="", tldr="", why="", actions="", deadlines="", severity=None, raw=raw, ) def _make_tmp_png() -> str: """ Write a 1×1 white PNG to a NamedTemporaryFile and return its path. The file is not auto-deleted (delete=False) so decode() can open it. Callers are responsible for cleanup; for test isolation use teardown or tmp_path. """ img = PILImage.new("RGB", (1, 1), color=(255, 255, 255)) buf = io.BytesIO() img.save(buf, "PNG") buf.seek(0) with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as fh: fh.write(buf.read()) return fh.name # --------------------------------------------------------------------------- # (a) run_inference_multi signature # --------------------------------------------------------------------------- def test_run_inference_multi_exists(): """app.run_inference_multi should exist as a callable.""" assert hasattr(app, "run_inference_multi"), "app.run_inference_multi not found" assert callable(app.run_inference_multi), "app.run_inference_multi is not callable" def test_run_inference_multi_first_param_is_images(): """First parameter of run_inference_multi should be named 'images'.""" sig = inspect.signature(app.run_inference_multi) params = list(sig.parameters.keys()) assert params[0] == "images", ( f"Expected first param 'images', got '{params[0]}'" ) def test_run_inference_multi_accepts_list_type_hint_or_annotation(): """run_inference_multi signature has at least 6 parameters (images, language, beginner_mode, mdl, proc, image_patch_size).""" sig = inspect.signature(app.run_inference_multi) assert len(sig.parameters) >= 6, ( f"Expected at least 6 params, got {len(sig.parameters)}: {list(sig.parameters)}" ) # --------------------------------------------------------------------------- # (b) run_inference delegates to run_inference_multi with a single-element list # --------------------------------------------------------------------------- def test_run_inference_delegates_to_multi(): """run_inference(img, ...) should call run_inference_multi([img], ...) exactly once.""" fake_img = MagicMock(name="PIL.Image") stub_ret = _stub_result() captured_calls = [] def stub_multi(images, language, beginner_mode, mdl, proc, image_patch_size): captured_calls.append({ "images": images, "language": language, "beginner_mode": beginner_mode, }) return stub_ret with patch.object(app, "run_inference_multi", stub_multi): result = app.run_inference( fake_img, "English", False, None, None, 16 ) assert len(captured_calls) == 1, "run_inference_multi should be called exactly once" assert captured_calls[0]["images"] == [fake_img], ( f"Expected [fake_img], got {captured_calls[0]['images']}" ) assert result is stub_ret def test_run_inference_forwards_language_and_beginner_mode(): """run_inference correctly forwards language and beginner_mode parameters.""" fake_img = MagicMock(name="PIL.Image") stub_ret = _stub_result() captured_calls = [] def stub_multi(images, language, beginner_mode, mdl, proc, image_patch_size): captured_calls.append({"language": language, "beginner_mode": beginner_mode}) return stub_ret with patch.object(app, "run_inference_multi", stub_multi): app.run_inference(fake_img, "Swedish", True, None, None, 14) assert captured_calls[0]["language"] == "Swedish" assert captured_calls[0]["beginner_mode"] is True # --------------------------------------------------------------------------- # (c) decode gr.File payload dispatch and MAX_PAGES_HARD truncation # Updated in 03-02: decode() now accepts list[str] file paths (gr.File type="filepath") # instead of the old Gallery list[(PIL.Image, caption)] tuples. # --------------------------------------------------------------------------- def test_page_cap_constants_exist(): """MAX_PAGES_SOFT and MAX_PAGES_HARD constants must exist.""" assert hasattr(app, "MAX_PAGES_SOFT"), "app.MAX_PAGES_SOFT not found" assert hasattr(app, "MAX_PAGES_HARD"), "app.MAX_PAGES_HARD not found" assert app.MAX_PAGES_SOFT == 3, f"Expected MAX_PAGES_SOFT=3, got {app.MAX_PAGES_SOFT}" assert app.MAX_PAGES_HARD == 5, f"Expected MAX_PAGES_HARD=5, got {app.MAX_PAGES_HARD}" def test_decode_empty_gallery_returns_error_sentinel(): """decode([]) returns a StructuredResult with error sentinel in .raw.""" result = app.decode([], "English", False) assert isinstance(result, StructuredResult) assert "upload" in result.raw.lower() or "please" in result.raw.lower(), ( f"Expected upload error sentinel, got: {result.raw!r}" ) def test_decode_none_gallery_returns_error_sentinel(): """decode(None) returns a StructuredResult error sentinel.""" result = app.decode(None, "English", False) assert isinstance(result, StructuredResult) assert result.raw, "Expected non-empty raw in error sentinel" def test_decode_file_list_with_none_paths_filters_them(): """decode skips None entries in the gr.File list[str] payload.""" # Create 2 real temp PNG files and pass [path1, None, path2] — None is skipped. path1 = _make_tmp_png() path2 = _make_tmp_png() file_payload = [path1, None, path2] captured_images = [] stub_ret = _stub_result() def stub_multi(images, language, beginner_mode, mdl, proc, image_patch_size): captured_images.extend(images) return stub_ret try: with patch.object(app, "run_inference_multi", stub_multi): result = app.decode(file_payload, "English", False) assert len(captured_images) == 2, ( f"Expected 2 non-None images, got {len(captured_images)}" ) assert result is stub_ret finally: os.unlink(path1) os.unlink(path2) def test_decode_truncates_at_max_pages_hard(): """decode truncates a 7-file list to MAX_PAGES_HARD (5) images.""" # Create 7 real temp PNG files — all valid images. paths = [_make_tmp_png() for _ in range(7)] captured_images = [] stub_ret = _stub_result() def stub_multi(images, language, beginner_mode, mdl, proc, image_patch_size): captured_images.extend(images) return stub_ret try: with patch.object(app, "run_inference_multi", stub_multi): result = app.decode(paths, "English", False) assert len(captured_images) == app.MAX_PAGES_HARD, ( f"Expected {app.MAX_PAGES_HARD} images after truncation, got {len(captured_images)}" ) assert result is stub_ret finally: for p in paths: os.unlink(p) def test_decode_exactly_max_pages_hard_passes_through(): """decode allows exactly MAX_PAGES_HARD files without truncation.""" paths = [_make_tmp_png() for _ in range(app.MAX_PAGES_HARD)] captured_images = [] stub_ret = _stub_result() def stub_multi(images, language, beginner_mode, mdl, proc, image_patch_size): captured_images.extend(images) return stub_ret try: with patch.object(app, "run_inference_multi", stub_multi): app.decode(paths, "English", False) assert len(captured_images) == app.MAX_PAGES_HARD finally: for p in paths: os.unlink(p) if __name__ == "__main__": import pytest pytest.main([__file__, "-v"])