Files
Text-Texture-Generator-for-…/tests/unit/test_text_processor.py
2025-09-13 10:38:28 +03:00

419 lines
15 KiB
Python

"""
Unit tests for text_processor.py functions.
Tests the core text processing logic 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_processor import (
process_multiline_text,
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 TestProcessMultilineText:
"""Test the process_multiline_text function"""
@pytest.mark.unit
@patch('src.core.text_processor.wrap_text_to_width')
def test_process_single_line_text(self, mock_wrap, sample_font):
"""Test processing single line text"""
mock_wrap.return_value = [SHORT_TEXT]
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)
result = process_multiline_text(SHORT_TEXT, sample_font, 200, 100)
assert result['line_count'] == 1
assert result['lines'] == [SHORT_TEXT]
assert result['total_height'] == 20
@pytest.mark.unit
@patch('src.core.text_processor.wrap_text_to_width')
def test_process_multiline_text_basic(self, mock_wrap, sample_font):
"""Test processing actual multiline text"""
mock_wrap.side_effect = [['Line 1'], ['Line 2'], ['Line 3'], ['Line 4']]
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, 80, 20)
result = process_multiline_text(MULTILINE_TEXT, sample_font, 200, 200)
assert result['line_count'] == 4
assert len(result['lines']) == 4
assert result['total_height'] == 80 # 4 lines * 20 height
@pytest.mark.unit
def test_process_empty_text(self, sample_font):
"""Test processing empty text"""
result = process_multiline_text("", sample_font, 200, 100)
assert result['line_count'] == 1
assert result['lines'] == [""]
@pytest.mark.unit
@patch('src.core.text_processor.wrap_text_to_width')
def test_process_height_limit_exceeded(self, mock_wrap, sample_font):
"""Test text processing when height limit is exceeded"""
mock_wrap.side_effect = [['Line 1'], ['Line 2'], ['Line 3'], ['Line 4']]
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, 80, 30) # Each line is 30px high
# With max_height=50, only 1 line should fit
result = process_multiline_text(MULTILINE_TEXT, sample_font, 200, 50)
assert result['line_count'] <= 2 # Should stop when height limit reached
assert result['total_height'] <= 60 # Should not exceed much
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 == []