""" Unit tests for text_processor.py functions. Tests the core text processing logic in isolation. """ import pytest from unittest.mock import Mock, patch import sys import os # Add src to path for importing modules sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src')) from fixtures.sample_data import * from src.core.text_processor import ( wrap_text_to_width, render_text_with_stroke, calculate_text_metrics, apply_text_effects, optimize_text_layout, rectangles_overlap, find_non_overlapping_position, validate_text_rendering ) class TestWrapTextToWidth: """Test the wrap_text_to_width function""" @pytest.mark.unit def test_wrap_text_short_text_fits(self, sample_font, mock_image_draw): """Test that short text that fits is returned as-is""" # Mock textbbox to return small dimensions mock_image_draw.textbbox.return_value = (0, 0, 50, 20) result = wrap_text_to_width(SHORT_TEXT, sample_font, 100) assert result == [SHORT_TEXT] assert len(result) == 1 @pytest.mark.unit def test_wrap_text_empty_string(self, sample_font): """Test empty string input""" result = wrap_text_to_width("", sample_font, 100) assert result == [""] @pytest.mark.unit def test_wrap_text_whitespace_only(self, sample_font): """Test whitespace-only string""" result = wrap_text_to_width(" ", sample_font, 100) assert result == [""] @pytest.mark.unit @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_wrap_text_needs_wrapping(self, mock_draw_class, mock_image_new, sample_font): """Test text that needs to be wrapped""" # Setup mocks mock_img = Mock() mock_draw = Mock() mock_image_new.return_value = mock_img mock_draw_class.return_value = mock_draw # Mock textbbox to simulate text getting too wide def textbbox_side_effect(pos, text, font): if text == LONG_TEXT: return (0, 0, 300, 20) # Too wide for max_width=100 elif "This is a much longer" in text: return (0, 0, 120, 20) # Still too wide elif "This is a much" in text: return (0, 0, 80, 20) # Fits elif "longer piece of text" in text: return (0, 0, 90, 20) # Fits else: return (0, 0, 50, 20) # Default small size mock_draw.textbbox.side_effect = textbbox_side_effect result = wrap_text_to_width(LONG_TEXT, sample_font, 100) assert len(result) > 1 # Should be split into multiple lines assert all(isinstance(line, str) for line in result) @pytest.mark.unit def test_wrap_text_single_word_too_long(self, sample_font): """Test handling of single word that's too long""" with patch('PIL.Image.new'), patch('PIL.ImageDraw.Draw') as mock_draw_class: mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 200, 20) # Too wide result = wrap_text_to_width("SuperLongWordThatCannotFit", sample_font, 50) # Should still return the word, even if it doesn't fit assert len(result) >= 1 assert "SuperLongWordThatCannotFit" in result class TestCalculateTextMetrics: """Test the calculate_text_metrics function""" @pytest.mark.unit def test_calculate_basic_metrics(self): """Test basic text metrics calculation""" with patch('PIL.Image.new'), patch('PIL.ImageDraw.Draw') as mock_draw_class: mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (2, 1, 102, 21) # x1, y1, x2, y2 # Create a mock font with getmetrics method mock_font = Mock() mock_font.getmetrics.return_value = (18, 4) # ascent, descent result = calculate_text_metrics(SHORT_TEXT, mock_font) assert result['width'] == 100 # 102 - 2 assert result['height'] == 20 # 21 - 1 assert result['left_bearing'] == 2 assert result['top_bearing'] == 1 assert result['ascent'] == 18 assert result['descent'] == 4 @pytest.mark.unit def test_calculate_metrics_font_error(self): """Test metrics calculation when font.getmetrics fails""" with patch('PIL.Image.new'), patch('PIL.ImageDraw.Draw') as mock_draw_class: mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 100, 20) # Create a mock font with getmetrics that raises exception mock_font = Mock() mock_font.getmetrics.side_effect = Exception("Font error") result = calculate_text_metrics(SHORT_TEXT, mock_font) assert result['width'] == 100 assert result['height'] == 20 assert result['ascent'] == 20 # Should default to height assert result['descent'] == 0 class TestRectanglesOverlap: """Test the rectangles_overlap function""" @pytest.mark.unit def test_no_overlap_separate_rects(self): """Test rectangles that don't overlap""" rect1 = (0, 0, 50, 50) # Top-left rect2 = (100, 100, 50, 50) # Bottom-right, separate assert not rectangles_overlap(rect1, rect2) @pytest.mark.unit def test_clear_overlap(self): """Test rectangles that clearly overlap""" rect1 = (0, 0, 100, 100) rect2 = (50, 50, 100, 100) # Overlaps in center assert rectangles_overlap(rect1, rect2) @pytest.mark.unit def test_touching_edges_no_overlap(self): """Test rectangles that touch but don't overlap""" rect1 = (0, 0, 50, 50) rect2 = (50, 0, 50, 50) # Touching right edge assert not rectangles_overlap(rect1, rect2) @pytest.mark.unit def test_identical_rectangles(self): """Test identical rectangles (complete overlap)""" rect1 = (10, 10, 50, 50) rect2 = (10, 10, 50, 50) assert rectangles_overlap(rect1, rect2) @pytest.mark.unit def test_one_inside_other(self): """Test one rectangle completely inside another""" rect1 = (0, 0, 100, 100) # Outer rect2 = (25, 25, 50, 50) # Inner assert rectangles_overlap(rect1, rect2) class TestFindNonOverlappingPosition: """Test the find_non_overlapping_position function""" @pytest.mark.unit def test_find_position_no_existing_blocks(self): """Test finding position when no existing blocks""" x, y = find_non_overlapping_position(100, 50, [], 500, 400) assert x == 0 assert y == 0 @pytest.mark.unit def test_find_position_with_existing_blocks(self): """Test finding position avoiding existing blocks""" existing_blocks = [ {'x': 0, 'y': 0, 'width': 100, 'height': 50} ] x, y = find_non_overlapping_position(100, 50, existing_blocks, 500, 400) # Should find a position that doesn't overlap assert x >= 0 and y >= 0 # Verify it doesn't overlap with existing block new_rect = (x, y, 100, 50) existing_rect = (0, 0, 100, 50) assert not rectangles_overlap(new_rect, existing_rect) @pytest.mark.unit def test_find_position_no_space_available(self): """Test when no space is available (fallback to 0,0)""" # Fill entire canvas with blocks existing_blocks = [] for y in range(0, 400, 20): for x in range(0, 500, 20): existing_blocks.append({'x': x, 'y': y, 'width': 20, 'height': 20}) x, y = find_non_overlapping_position(100, 50, existing_blocks, 500, 400) # Should fall back to 0, 0 assert x == 0 and y == 0 class TestValidateTextRendering: """Test the validate_text_rendering function""" @pytest.mark.unit def test_validate_valid_positions(self): """Test validation with valid text positions""" mock_img = Mock() mock_img.width = 500 mock_img.height = 400 positions = [ {'x': 10, 'y': 10, 'width': 100, 'height': 50}, {'x': 200, 'y': 200, 'width': 80, 'height': 40} ] results = validate_text_rendering(mock_img, positions) assert len(results) == 2 assert all(r['valid'] for r in results) assert all(len(r['issues']) == 0 for r in results) @pytest.mark.unit def test_validate_negative_positions(self): """Test validation with negative positions""" mock_img = Mock() mock_img.width = 500 mock_img.height = 400 positions = [ {'x': -10, 'y': 10, 'width': 100, 'height': 50} ] results = validate_text_rendering(mock_img, positions) assert len(results) == 1 assert not results[0]['valid'] assert 'Negative position' in results[0]['issues'] @pytest.mark.unit def test_validate_exceeds_bounds(self): """Test validation with positions exceeding image bounds""" mock_img = Mock() mock_img.width = 500 mock_img.height = 400 positions = [ {'x': 450, 'y': 10, 'width': 100, 'height': 50}, # Exceeds width {'x': 10, 'y': 350, 'width': 100, 'height': 100} # Exceeds height ] results = validate_text_rendering(mock_img, positions) assert len(results) == 2 assert not results[0]['valid'] assert not results[1]['valid'] assert 'Exceeds image width' in results[0]['issues'] assert 'Exceeds image height' in results[1]['issues'] @pytest.mark.unit def test_validate_zero_dimensions(self): """Test validation with zero dimensions""" mock_img = Mock() mock_img.width = 500 mock_img.height = 400 positions = [ {'x': 10, 'y': 10, 'width': 0, 'height': 50}, {'x': 10, 'y': 100, 'width': 100, 'height': 0} ] results = validate_text_rendering(mock_img, positions) assert len(results) == 2 assert not results[0]['valid'] assert not results[1]['valid'] assert 'Zero dimensions' in results[0]['issues'] assert 'Zero dimensions' in results[1]['issues'] class TestOptimizeTextLayout: """Test the optimize_text_layout function""" @pytest.mark.unit @patch('src.core.text_processor.find_non_overlapping_position') def test_optimize_no_overlaps(self, mock_find_pos): """Test optimization when no overlaps exist""" text_blocks = [ {'x': 0, 'y': 0, 'width': 100, 'height': 50, 'text': 'Block 1'}, {'x': 200, 'y': 200, 'width': 80, 'height': 40, 'text': 'Block 2'} ] result = optimize_text_layout(text_blocks, 500, 400) assert len(result) == 2 # Positions should remain unchanged since no overlaps assert result[0]['x'] == 0 and result[0]['y'] == 0 assert result[1]['x'] == 200 and result[1]['y'] == 200 # find_non_overlapping_position should not be called mock_find_pos.assert_not_called() @pytest.mark.unit @patch('src.core.text_processor.find_non_overlapping_position') def test_optimize_with_overlaps(self, mock_find_pos): """Test optimization when overlaps exist""" mock_find_pos.return_value = (150, 100) # New position text_blocks = [ {'x': 0, 'y': 0, 'width': 100, 'height': 100, 'text': 'Block 1'}, {'x': 50, 'y': 50, 'width': 100, 'height': 100, 'text': 'Block 2'} # Overlaps ] result = optimize_text_layout(text_blocks, 500, 400) assert len(result) == 2 # First block should remain unchanged assert result[0]['x'] == 0 and result[0]['y'] == 0 # Second block should be moved to avoid overlap assert result[1]['x'] == 150 and result[1]['y'] == 100 # find_non_overlapping_position should be called mock_find_pos.assert_called_once() @pytest.mark.unit def test_optimize_empty_list(self): """Test optimization with empty text blocks list""" result = optimize_text_layout([], 500, 400) assert result == []