import os # The app refuses to start without SECRET_KEY outside development; declare that # these are tests before importing it. os.environ.setdefault('PDF_OCR_TESTING', '1') import unittest import tempfile import shutil import json import time import threading import re from unittest.mock import patch, MagicMock from PIL import Image import colorama from colorama import Fore, Style from app import ( # noqa: E402 app, allowed_file, secure_clean_filename, check_dependency, sanitize_text, enhance_image, process_image, fix_common_ocr_errors, save_as_markdown, save_as_html, is_within_upload_folder, looks_like_pdf, save_output, cleanup_old_files, process_pdf_with_progress, parse_preprocess_options, otsu_threshold, run_task_in_background, env_int, log_safe, ConversionCancelled, TASKS, TASK_TIMEOUT, STALE_TASK_TIMEOUT, detect_and_correct_orientation, estimate_skew_angle, extract_native_page_text ) def _temp_path(suffix: str) -> str: """Reserve a temporary path without tempfile.mktemp. `tempfile.mktemp` only returns a name, leaving a window in which another process can create the file first; mkstemp creates it atomically. """ fd, path = tempfile.mkstemp(suffix=suffix) os.close(fd) return path # Initialize colorama for colored terminal output colorama.init(autoreset=True) # Custom test result class for colored output class ColorTextTestResult(unittest.TextTestResult): def addSuccess(self, test): self.stream.write(f"{Fore.GREEN}✓{Style.RESET_ALL} ") super().addSuccess(test) def addError(self, test, err): self.stream.write(f"{Fore.RED}✗{Style.RESET_ALL} ") super().addError(test, err) def addFailure(self, test, err): self.stream.write(f"{Fore.RED}✗{Style.RESET_ALL} ") super().addFailure(test, err) def addSkip(self, test, reason): self.stream.write(f"{Fore.YELLOW}s{Style.RESET_ALL} ") super().addSkip(test, reason) def printErrorList(self, flavour, errors): for test, err in errors: self.stream.writeln(self.separator1) self.stream.writeln(f"{Fore.RED if flavour == 'ERROR' else Fore.YELLOW}{flavour}: {self.getDescription(test)}{Style.RESET_ALL}") self.stream.writeln(self.separator2) self.stream.writeln(f"{err}") # Custom test runner that uses our colored result class class ColorTextTestRunner(unittest.TextTestRunner): def __init__(self, **kwargs): kwargs.setdefault('resultclass', ColorTextTestResult) super().__init__(**kwargs) class TestOCRApp(unittest.TestCase): def setUp(self): app.config['TESTING'] = True app.config['WTF_CSRF_ENABLED'] = False self.app = app.test_client() self.app_context = app.app_context() self.app_context.push() # Create temp upload folder for testing self.test_upload_folder = tempfile.mkdtemp() self.original_upload_folder = app.config['UPLOAD_FOLDER'] app.config['UPLOAD_FOLDER'] = self.test_upload_folder def tearDown(self): # Clean up after tests TASKS.clear() shutil.rmtree(self.test_upload_folder, ignore_errors=True) app.config['UPLOAD_FOLDER'] = self.original_upload_folder self.app_context.pop() TASKS.clear() def test_allowed_file(self): self.assertTrue(allowed_file('test.pdf')) self.assertFalse(allowed_file('test.docx')) self.assertFalse(allowed_file('')) self.assertFalse(allowed_file(None)) def test_secure_clean_filename(self): self.assertEqual(secure_clean_filename('test.pdf'), 'test.pdf') self.assertEqual(secure_clean_filename('test file.pdf'), 'test_file.pdf') self.assertEqual(secure_clean_filename('../dangerous.pdf'), 'dangerous.pdf') self.assertEqual(secure_clean_filename('file!@#$%^&*.pdf'), 'file.pdf') def test_sanitize_text(self): # Test with control characters self.assertEqual(sanitize_text("Hello\x00World"), "HelloWorld") self.assertEqual(sanitize_text("Line1\x0BLine2"), "Line1Line2") # Test with normal text self.assertEqual(sanitize_text("Normal text"), "Normal text") # Test with empty or None self.assertEqual(sanitize_text(""), "") self.assertEqual(sanitize_text(None), "") def test_fix_common_ocr_errors(self): # Punctuation spacing is normalised. self.assertEqual(fix_common_ocr_errors("Hello , world ."), "Hello, world.") # Words hyphenated across a line break are re-joined. self.assertEqual(fix_common_ocr_errors("exam-\nple"), "example") # Trailing whitespace is stripped, excessive blank lines collapsed. self.assertEqual(fix_common_ocr_errors("Para1 \n\n\n\nPara2"), "Para1\n\nPara2") # Line structure is preserved by default (no reflow). self.assertEqual(fix_common_ocr_errors("Line1\nLine2"), "Line1\nLine2") # Reflow is opt-in. self.assertEqual(fix_common_ocr_errors("Line1\nLine2", reflow=True), "Line1 Line2") # Test with empty self.assertEqual(fix_common_ocr_errors(""), "") self.assertEqual(fix_common_ocr_errors(None), None) def test_fix_common_ocr_errors_never_corrupts_content(self): """Regression guard: the clean-up pass must not rewrite characters. A previous implementation applied blind substitutions ('0'->'O', '1'->'I', '5'->'S', 'rn'->'m', 'cl'->'d'), silently destroying every digit in every converted document. Nothing may reintroduce that. """ invoice = ( "ACME Corporation\n" "Invoice No. 2024-0042\n" "IBAN: IT60X0542811101000000123456\n" "Total due: 1,250.00 EUR" ) self.assertEqual(fix_common_ocr_errors(invoice), invoice) # The specific substitutions that used to corrupt output. self.assertEqual(fix_common_ocr_errors("015"), "015") self.assertEqual(fix_common_ocr_errors("modern class vvv"), "modern class vvv") @patch('PIL.ImageEnhance.Contrast') @patch('PIL.Image.Image.filter') @patch('PIL.Image.Image.convert') def test_enhance_image(self, mock_convert, mock_filter, mock_contrast): # Setup mocks test_image = Image.new('RGB', (100, 100)) mock_filter.return_value = test_image mock_enhancer = MagicMock() mock_enhancer.enhance.return_value = test_image mock_contrast.return_value = mock_enhancer mock_convert.return_value = test_image # Call the function result = enhance_image(test_image) # Verify mocks were called mock_filter.assert_called_once() mock_contrast.assert_called_once() # For non-L mode image, convert should be called mock_convert.assert_called_once_with('L') # Result should be the test image self.assertEqual(result, test_image) @patch('app.logger') def test_enhance_image_error_handling(self, mock_logger): # Setup mock test_image = Image.new('RGB', (100, 100)) mock_logger.warning = MagicMock() # Mock an error when enhancing with patch('PIL.Image.Image.filter', side_effect=Exception("Test error")): # Call the function result = enhance_image(test_image) # Should return original image on error self.assertEqual(result, test_image) # Should log a warning mock_logger.warning.assert_called_once() def test_parse_preprocess_options(self): opts = parse_preprocess_options({ 'pre-grayscale': '1', 'pre-sharpen': '1', 'pre-threshold': '1', 'pre-contrast': '1.8', 'pre-deskew': '1', }) self.assertEqual(opts, { "grayscale": True, "sharpen": True, "threshold": True, "contrast": 1.8, "deskew": True, }) # Unchecked boxes are absent from the form, not sent as '0'. opts = parse_preprocess_options({'pre-contrast': '1.0'}) self.assertFalse(opts["grayscale"]) self.assertFalse(opts["threshold"]) self.assertFalse(opts["deskew"]) # A hand-crafted request cannot ask for an absurd contrast factor. self.assertEqual(parse_preprocess_options({'pre-contrast': '999'})["contrast"], 2.5) self.assertEqual(parse_preprocess_options({'pre-contrast': '-5'})["contrast"], 0.5) self.assertEqual(parse_preprocess_options({'pre-contrast': 'abc'})["contrast"], 1.5) def test_estimate_skew_angle(self): from PIL import ImageDraw # Create synthetic image with horizontal text bars im = Image.new('L', (400, 300), color=255) draw = ImageDraw.Draw(im) for y in range(40, 260, 30): draw.rectangle([50, y, 350, y + 10], fill=0) # Straight image should yield 0 angle angle_straight = estimate_skew_angle(im) self.assertEqual(angle_straight, 0.0) # Skewed by 4 degrees counter-clockwise skewed = im.rotate(4.0, fillcolor=255, expand=False) corr_angle = estimate_skew_angle(skewed) self.assertAlmostEqual(corr_angle, -4.0, delta=1.0) def test_detect_and_correct_orientation(self): test_img = Image.new('RGB', (100, 100), color='white') # Mock pytesseract.image_to_osd returning 90 degrees rotation with patch('pytesseract.image_to_osd', return_value={'rotate': 90, 'orientation_conf': 15.0}): rotated_img, angle = detect_and_correct_orientation(test_img) self.assertEqual(angle, 90) self.assertIsNotNone(rotated_img) # When OSD fails, original image is returned with angle 0 with patch('pytesseract.image_to_osd', side_effect=Exception("OSD error")): orig_img, angle = detect_and_correct_orientation(test_img) self.assertEqual(angle, 0) self.assertEqual(orig_img, test_img) def test_extract_native_page_text(self): mock_proc = MagicMock() mock_proc.returncode = 0 mock_proc.stdout = "This is a digital text document page with enough words to be substantial." with patch('subprocess.run', return_value=mock_proc): text = extract_native_page_text('/dummy/path.pdf', 1) self.assertIsNotNone(text) self.assertIn("substantial", text) # Test when page is scanned (empty text) mock_proc.stdout = " \n " with patch('subprocess.run', return_value=mock_proc): text = extract_native_page_text('/dummy/path.pdf', 1) self.assertIsNone(text) def test_save_output_docx_paragraphs(self): out = _temp_path('.docx') try: results = {0: "First paragraph\n\nSecond paragraph", 1: "Page two paragraph"} save_output(results, "docx", out, "test") self.assertTrue(os.path.exists(out)) from docx import Document as ReadDoc doc = ReadDoc(out) # Both paragraphs on page 0 + page 1 paragraph texts = [p.text for p in doc.paragraphs if p.text.strip()] self.assertIn("First paragraph", texts) self.assertIn("Second paragraph", texts) self.assertIn("Page two paragraph", texts) finally: if os.path.exists(out): os.remove(out) def test_otsu_threshold_separates_two_populations(self): # Half the pixels black, half white: the cutoff must land between them. image = Image.new('L', (10, 10), color=0) for y in range(5): for x in range(10): image.putpixel((x, y), 220) cutoff = otsu_threshold(image) # The cutoff is the top of the dark class: `p > cutoff` must send the # 0-valued pixels to black and the 220-valued ones to white. self.assertGreaterEqual(cutoff, 0) self.assertLess(cutoff, 220) # A uniform image has no split to find; the fallback must not divide by # zero or blank the page. self.assertEqual(otsu_threshold(Image.new('L', (4, 4), color=0)), 128) self.assertEqual(otsu_threshold(Image.new('L', (0, 0), color=0)), 128) def test_enhance_image_threshold_binarises(self): image = Image.new('L', (20, 20), color=0) for x in range(20): for y in range(10): image.putpixel((x, y), 200) result = enhance_image(image, { "grayscale": True, "sharpen": False, "contrast": 1.0, "threshold": True, }) self.assertEqual(set(result.convert('L').tobytes()), {0, 255}) def test_enhance_image_honours_disabled_options(self): image = Image.new('RGB', (10, 10), color='white') result = enhance_image(image, { "grayscale": False, "sharpen": False, "contrast": 1.0, "threshold": False, }) # Nothing was asked for, so the mode is untouched. self.assertEqual(result.mode, 'RGB') @patch('app.logger') def test_preprocessing_reaches_path_based_engines(self, mock_logger): """EasyOCR/PaddleOCR read the file from disk, not the PIL object. Without writing the enhanced page back out they silently received the untouched render, so the preprocessing checkbox did nothing for them. """ img = Image.new('RGB', (40, 40), color='white') img_path = os.path.join(self.test_upload_folder, 'page.png') img.save(img_path) fake_reader = MagicMock() fake_reader.readtext.return_value = ["text"] fake_module = MagicMock() fake_module.Reader = MagicMock(return_value=fake_reader) import sys with patch.dict(sys.modules, {'easyocr': fake_module}): process_image(0, img_path, "easyocr", "eng", preprocess=True) used_path = fake_reader.readtext.call_args[0][0] self.assertNotEqual(used_path, img_path) self.assertTrue(used_path.endswith('.pre.png')) # The temporary preprocessed copy is cleaned up afterwards. self.assertFalse(os.path.exists(used_path)) def test_save_as_markdown(self): test_results = {0: "Test page 1", 1: "Test page 2\n\nParagraph 2"} test_output = _temp_path('.md') try: save_as_markdown(test_results, test_output) # Check file exists self.assertTrue(os.path.exists(test_output)) # Check file content with open(test_output, 'r') as f: content = f.read() self.assertIn("Test page 1", content) self.assertIn("Test page 2", content) self.assertIn("Paragraph 2", content) self.assertIn("---", content) # Page separator finally: # Clean up if os.path.exists(test_output): os.remove(test_output) def test_save_as_html(self): test_results = {0: "Test page 1", 1: "Test page 2\n\nParagraph 2", 2: "Test with & entities"} test_output = _temp_path('.html') test_title = "Test Document" try: save_as_html(test_results, test_output, test_title) # Check file exists self.assertTrue(os.path.exists(test_output)) # Check file content with open(test_output, 'r') as f: content = f.read() self.assertIn("", content) self.assertIn(f"
Test page 1
", content) self.assertIn("Test page 2
", content) self.assertIn("Paragraph 2
", content) self.assertIn("