""" Unit tests for text_fitting.py functions. Tests the core text fitting algorithms in isolation. """ import pytest from unittest.mock import Mock, patch, MagicMock 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_fitting import ( calculate_available_text_area, largest_rectangle_in_histogram, calculate_text_position, find_optimal_font_size, calculate_text_scaling, check_text_overlap, resolve_text_conflicts ) class TestLargestRectangleInHistogram: """Test the largest_rectangle_in_histogram function""" @pytest.mark.unit def test_simple_histogram(self): """Test with simple histogram data""" heights = [3, 1, 3, 2, 2] result = largest_rectangle_in_histogram(heights) assert len(result) == 4 # x, y, width, height assert result[2] > 0 # width should be positive assert result[3] > 0 # height should be positive @pytest.mark.unit def test_increasing_histogram(self): """Test with increasing height values""" heights = [1, 2, 3, 4, 5] result = largest_rectangle_in_histogram(heights) x, y, width, height = result assert width > 0 and height > 0 # The area should be reasonable area = width * height assert area > 0 @pytest.mark.unit def test_empty_histogram(self): """Test with empty histogram""" heights = [] result = largest_rectangle_in_histogram(heights) x, y, width, height = result assert width == 0 and height == 0 @pytest.mark.unit def test_single_bar_histogram(self): """Test with single bar""" heights = [5] result = largest_rectangle_in_histogram(heights) x, y, width, height = result assert width == 1 and height == 5 @pytest.mark.unit def test_zero_height_bars(self): """Test histogram with zero height bars""" heights = [0, 0, 3, 3, 0, 0] result = largest_rectangle_in_histogram(heights) x, y, width, height = result # Should find the rectangle in the middle assert width == 2 and height == 3 @pytest.mark.unit def test_pyramid_histogram(self): """Test with pyramid-shaped histogram""" heights = [1, 2, 3, 4, 3, 2, 1] result = largest_rectangle_in_histogram(heights) x, y, width, height = result assert width > 0 and height > 0 # Should find a reasonable rectangle class TestCalculateAvailableTextArea: """Test the calculate_available_text_area function""" @pytest.mark.unit def test_no_overlays(self): """Test area calculation with no overlays""" width, height = 500, 400 overlays = [] result = calculate_available_text_area(width, height, overlays) assert result['width'] == width assert result['height'] == height assert result['x'] == 0 assert result['y'] == 0 assert result['area'] == width * height @pytest.mark.unit def test_single_overlay(self): """Test area calculation with single overlay""" width, height = 500, 400 overlays = [{'x': 100, 'y': 100, 'width': 50, 'height': 50}] result = calculate_available_text_area(width, height, overlays) assert result['width'] > 0 assert result['height'] > 0 assert result['x'] >= 0 assert result['y'] >= 0 assert result['area'] > 0 @pytest.mark.unit def test_multiple_overlays(self): """Test area calculation with multiple overlays""" width, height = 500, 400 overlays = [ {'x': 50, 'y': 50, 'width': 100, 'height': 100}, {'x': 300, 'y': 200, 'width': 80, 'height': 80} ] result = calculate_available_text_area(width, height, overlays) assert result['width'] >= 0 assert result['height'] >= 0 assert result['area'] >= 0 @pytest.mark.unit def test_overlays_covering_entire_area(self): """Test when overlays cover most of the area""" width, height = 100, 100 overlays = [{'x': 0, 'y': 0, 'width': 100, 'height': 100}] result = calculate_available_text_area(width, height, overlays) # Should still return something, even if very small assert result['width'] >= 0 assert result['height'] >= 0 class TestCalculateTextPosition: """Test the calculate_text_position function""" @pytest.mark.unit @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_center_alignment(self, mock_draw_class, mock_image_new, sample_font): """Test center alignment positioning""" mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 100, 20) # 100x20 text available_area = {'x': 0, 'y': 0, 'width': 300, 'height': 100} result = calculate_text_position("Test Text", sample_font, available_area, 'center') assert result['x'] == 100 # (300 - 100) / 2 assert result['y'] == 40 # (100 - 20) / 2 assert result['text_width'] == 100 assert result['text_height'] == 20 @pytest.mark.unit @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_top_left_alignment(self, mock_draw_class, mock_image_new, sample_font): """Test top-left alignment positioning""" mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 100, 20) available_area = {'x': 10, 'y': 10, 'width': 200, 'height': 100} result = calculate_text_position("Test Text", sample_font, available_area, 'top-left') assert result['x'] == 10 # area x assert result['y'] == 10 # area y @pytest.mark.unit @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_bottom_right_alignment(self, mock_draw_class, mock_image_new, sample_font): """Test bottom-right alignment positioning""" mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 100, 20) available_area = {'x': 0, 'y': 0, 'width': 300, 'height': 100} result = calculate_text_position("Test Text", sample_font, available_area, 'bottom-right') assert result['x'] == 200 # 300 - 100 assert result['y'] == 80 # 100 - 20 @pytest.mark.unit @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_invalid_alignment_defaults_to_center(self, mock_draw_class, mock_image_new, sample_font): """Test invalid alignment defaults to center""" mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 100, 20) available_area = {'x': 0, 'y': 0, 'width': 300, 'height': 100} result = calculate_text_position("Test Text", sample_font, available_area, 'invalid_alignment') assert result['x'] == 100 # Center alignment assert result['y'] == 40 class TestFindOptimalFontSize: """Test the find_optimal_font_size function""" @pytest.mark.unit @patch('PIL.ImageFont.truetype') @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_find_optimal_size_basic(self, mock_draw_class, mock_image_new, mock_truetype): """Test basic optimal font size finding""" mock_draw = Mock() mock_draw_class.return_value = mock_draw # Mock font that gets larger as size increases def textbbox_side_effect(pos, text, font): size = font.size if hasattr(font, 'size') else 24 return (0, 0, size * 4, size) # width = size * 4, height = size mock_draw.textbbox.side_effect = textbbox_side_effect # Mock fonts with size attribute def mock_font_factory(path, size): font = Mock() font.size = size return font mock_truetype.side_effect = mock_font_factory result = find_optimal_font_size("Test Text", "/fake/font.ttf", 200, 50, 8, 100) assert result >= 8 assert result <= 100 assert result > 0 @pytest.mark.unit @patch('PIL.ImageFont.truetype') def test_find_optimal_size_font_load_error(self, mock_truetype): """Test handling of font loading errors""" mock_truetype.side_effect = OSError("Font not found") result = find_optimal_font_size("Test Text", "/fake/font.ttf", 200, 50, 8, 100) assert result == 8 # Should return min_size on error @pytest.mark.unit @patch('PIL.ImageFont.truetype') @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_find_optimal_size_tight_constraints(self, mock_draw_class, mock_image_new, mock_truetype): """Test with very tight size constraints""" mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 300, 100) # Very large text mock_font = Mock() mock_truetype.return_value = mock_font result = find_optimal_font_size("Test Text", "/fake/font.ttf", 50, 20, 8, 100) # Should return minimum size when text is too large assert result == 8 class TestCalculateTextScaling: """Test the calculate_text_scaling function""" @pytest.mark.unit @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_calculate_scaling_basic(self, mock_draw_class, mock_image_new, sample_font): """Test basic scaling calculation""" mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 100, 50) # Current size 100x50 width_scale, height_scale, uniform_scale = calculate_text_scaling( "Test Text", sample_font, 200, 100 # Target size 200x100 ) assert width_scale == 2.0 # 200 / 100 assert height_scale == 2.0 # 100 / 50 assert uniform_scale == 2.0 # min(2.0, 2.0) @pytest.mark.unit @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_calculate_scaling_different_ratios(self, mock_draw_class, mock_image_new, sample_font): """Test scaling with different width/height ratios""" mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 100, 50) # Current size 100x50 width_scale, height_scale, uniform_scale = calculate_text_scaling( "Test Text", sample_font, 150, 200 # Target size 150x200 ) assert width_scale == 1.5 # 150 / 100 assert height_scale == 4.0 # 200 / 50 assert uniform_scale == 1.5 # min(1.5, 4.0) - use smaller to ensure fit @pytest.mark.unit @patch('PIL.Image.new') @patch('PIL.ImageDraw.Draw') def test_calculate_scaling_zero_dimensions(self, mock_draw_class, mock_image_new, sample_font): """Test scaling with zero current dimensions""" mock_draw = Mock() mock_draw_class.return_value = mock_draw mock_draw.textbbox.return_value = (0, 0, 0, 0) # Zero size width_scale, height_scale, uniform_scale = calculate_text_scaling( "Test Text", sample_font, 100, 50 ) # Should return 1.0 for all when current dimensions are zero assert width_scale == 1.0 assert height_scale == 1.0 assert uniform_scale == 1.0 class TestCheckTextOverlap: """Test the check_text_overlap function""" @pytest.mark.unit def test_no_overlaps(self): """Test positions with no overlaps""" positions = [ {'x': 0, 'y': 0, 'width': 100, 'height': 50}, {'x': 150, 'y': 0, 'width': 100, 'height': 50}, {'x': 0, 'y': 100, 'width': 100, 'height': 50} ] overlaps = check_text_overlap(positions) assert len(overlaps) == 0 @pytest.mark.unit def test_with_overlaps(self): """Test positions with overlaps""" positions = [ {'x': 0, 'y': 0, 'width': 100, 'height': 50}, {'x': 50, 'y': 25, 'width': 100, 'height': 50}, # Overlaps first {'x': 200, 'y': 0, 'width': 100, 'height': 50} ] overlaps = check_text_overlap(positions) assert len(overlaps) == 1 assert (0, 1) in overlaps @pytest.mark.unit def test_multiple_overlaps(self): """Test positions with multiple overlaps""" positions = [ {'x': 0, 'y': 0, 'width': 100, 'height': 100}, {'x': 50, 'y': 50, 'width': 100, 'height': 100}, # Overlaps 0 {'x': 25, 'y': 25, 'width': 50, 'height': 50} # Overlaps 0 and 1 ] overlaps = check_text_overlap(positions) assert len(overlaps) >= 2 # Should detect multiple overlaps overlap_pairs = set(overlaps) assert (0, 1) in overlap_pairs or (1, 0) in overlap_pairs assert (0, 2) in overlap_pairs or (2, 0) in overlap_pairs @pytest.mark.unit def test_empty_positions_list(self): """Test with empty positions list""" overlaps = check_text_overlap([]) assert len(overlaps) == 0 @pytest.mark.unit def test_single_position(self): """Test with single position""" positions = [{'x': 0, 'y': 0, 'width': 100, 'height': 50}] overlaps = check_text_overlap(positions) assert len(overlaps) == 0 class TestResolveTextConflicts: """Test the resolve_text_conflicts function""" @pytest.mark.unit @patch('src.core.text_fitting.check_text_overlap') def test_resolve_no_conflicts(self, mock_check_overlap): """Test conflict resolution when no conflicts exist""" mock_check_overlap.return_value = [] # No overlaps positions = [ {'x': 0, 'y': 0, 'width': 100, 'height': 50}, {'x': 150, 'y': 0, 'width': 100, 'height': 50} ] result = resolve_text_conflicts(positions, 500, 400) # Positions should remain unchanged assert len(result) == 2 assert result[0] == positions[0] assert result[1] == positions[1] @pytest.mark.unit @patch('src.core.text_fitting.check_text_overlap') def test_resolve_vertical_move(self, mock_check_overlap): """Test conflict resolution by moving vertically""" mock_check_overlap.return_value = [(0, 1)] # First two positions overlap positions = [ {'x': 0, 'y': 0, 'width': 100, 'height': 50}, {'x': 50, 'y': 25, 'width': 100, 'height': 50} # Overlaps first ] result = resolve_text_conflicts(positions, 500, 400) assert len(result) == 2 # First position should remain unchanged assert result[0] == positions[0] # Second position should be moved down assert result[1]['y'] == 60 # 0 + 50 + 10 (position + height + margin) assert result[1]['x'] == 50 # x should remain the same @pytest.mark.unit @patch('src.core.text_fitting.check_text_overlap') def test_resolve_horizontal_move(self, mock_check_overlap): """Test conflict resolution by moving horizontally when vertical doesn't fit""" mock_check_overlap.return_value = [(0, 1)] positions = [ {'x': 0, 'y': 300, 'width': 100, 'height': 50}, # Near bottom {'x': 50, 'y': 325, 'width': 100, 'height': 50} # Overlaps, can't move down ] result = resolve_text_conflicts(positions, 500, 400) assert len(result) == 2 # First position unchanged assert result[0] == positions[0] # Second position should be moved right since vertical move won't fit assert result[1]['x'] == 110 # 0 + 100 + 10 (position + width + margin) @pytest.mark.unit def test_resolve_empty_list(self): """Test conflict resolution with empty positions list""" result = resolve_text_conflicts([], 500, 400) assert result == []