abc123 / crossword-app /backend-py /test-unit /test_crossword_generator.py
vimalk78's picture
Add complete Python backend with AI-powered crossword generation
38c016b
"""
Unit tests for CrosswordGenerator to ensure robust crossword generation.
"""
import pytest
import asyncio
from unittest.mock import Mock, patch
import sys
from pathlib import Path
# Add project root to path for imports
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from src.services.crossword_generator import CrosswordGenerator
@pytest.fixture
def sample_words():
"""Sample word data for testing."""
return [
{"word": "DOG", "clue": "Man's best friend", "similarity": 0.8, "source": "test"},
{"word": "ELEPHANT", "clue": "Large mammal with trunk", "similarity": 0.7, "source": "test"},
{"word": "CAT", "clue": "Feline pet", "similarity": 0.9, "source": "test"},
{"word": "BUTTERFLY", "clue": "Colorful flying insect", "similarity": 0.6, "source": "test"},
{"word": "TIGER", "clue": "Striped big cat", "similarity": 0.75, "source": "test"},
{"word": "WHALE", "clue": "Largest marine mammal", "similarity": 0.65, "source": "test"},
]
@pytest.fixture
def mock_vector_service():
"""Mock vector search service for testing."""
mock_service = Mock()
mock_service.is_initialized = True
return mock_service
class TestCrosswordGenerator:
"""Test cases for CrosswordGenerator."""
def test_init(self):
"""Test generator initialization."""
generator = CrosswordGenerator()
assert generator.max_attempts == 100
assert generator.min_words == 6
assert generator.max_words == 10
assert generator.vector_service is None
def test_init_with_vector_service(self, mock_vector_service):
"""Test generator initialization with vector service."""
generator = CrosswordGenerator(vector_service=mock_vector_service)
assert generator.vector_service == mock_vector_service
def test_sort_words_for_crossword(self, sample_words):
"""Test word sorting by crossword suitability."""
generator = CrosswordGenerator()
sorted_words = generator._sort_words_for_crossword(sample_words)
# Should return list of dicts with crossword_score
assert len(sorted_words) == len(sample_words)
assert all(isinstance(w, dict) for w in sorted_words)
assert all("crossword_score" in w for w in sorted_words)
# Scores should be in descending order (with some randomization tolerance)
scores = [w["crossword_score"] for w in sorted_words]
# Allow for some randomization but generally descending
assert len(scores) > 0
def test_filter_by_difficulty(self, sample_words):
"""Test difficulty filtering."""
generator = CrosswordGenerator()
# Test easy difficulty (3-8 chars)
easy_words = generator._filter_by_difficulty(sample_words, "easy")
easy_lengths = [len(w["word"]) for w in easy_words]
assert all(3 <= length <= 8 for length in easy_lengths)
# Test medium difficulty (4-10 chars)
medium_words = generator._filter_by_difficulty(sample_words, "medium")
medium_lengths = [len(w["word"]) for w in medium_words]
assert all(4 <= length <= 10 for length in medium_lengths)
# Test hard difficulty (5-15 chars)
hard_words = generator._filter_by_difficulty(sample_words, "hard")
hard_lengths = [len(w["word"]) for w in hard_words]
assert all(5 <= length <= 15 for length in hard_lengths)
def test_calculate_grid_size(self):
"""Test grid size calculation."""
generator = CrosswordGenerator()
# Test with short words
short_words = ["DOG", "CAT", "BAT"]
size = generator._calculate_grid_size(short_words)
assert size >= 8 # Minimum size
assert size >= 3 # Longest word length
# Test with longer words
long_words = ["ELEPHANT", "BUTTERFLY", "HIPPOPOTAMUS"]
size = generator._calculate_grid_size(long_words)
assert size >= 12 # Longest word (HIPPOPOTAMUS)
def test_create_grid_word_processing(self, sample_words):
"""Test the critical word processing logic that was causing index errors."""
generator = CrosswordGenerator()
# This tests the fix for the list index out of range error
result = generator._create_grid(sample_words)
# Should not crash and should return a result or None
assert result is None or isinstance(result, dict)
# If result exists, it should have the correct structure
if result:
assert "grid" in result
assert "clues" in result
assert "placed_words" in result
def test_create_grid_empty_words(self):
"""Test grid creation with empty word list."""
generator = CrosswordGenerator()
result = generator._create_grid([])
assert result is None
def test_create_grid_malformed_words(self):
"""Test grid creation with malformed word data."""
generator = CrosswordGenerator()
# Test with various malformed inputs
malformed_words = [
"just_string", # String instead of dict
{"no_word_key": "value"}, # Dict without 'word' key
{"word": ""}, # Empty word
None, # None value
123, # Number
]
# Should not crash, might return None
result = generator._create_grid(malformed_words)
assert result is None or isinstance(result, dict)
def test_can_place_word_horizontal(self):
"""Test horizontal word placement validation."""
generator = CrosswordGenerator()
grid = [["." for _ in range(10)] for _ in range(10)]
# Test valid placement
assert generator._can_place_word(grid, "TEST", 5, 3, "horizontal")
# Test boundary violations
assert not generator._can_place_word(grid, "TOOLONG", 5, 7, "horizontal") # Too long
assert not generator._can_place_word(grid, "TEST", 5, -1, "horizontal") # Negative col
assert not generator._can_place_word(grid, "TEST", -1, 3, "horizontal") # Negative row
def test_can_place_word_vertical(self):
"""Test vertical word placement validation."""
generator = CrosswordGenerator()
grid = [["." for _ in range(10)] for _ in range(10)]
# Test valid placement
assert generator._can_place_word(grid, "TEST", 3, 5, "vertical")
# Test boundary violations
assert not generator._can_place_word(grid, "TOOLONG", 7, 5, "vertical") # Too long
assert not generator._can_place_word(grid, "TEST", -1, 5, "vertical") # Negative row
assert not generator._can_place_word(grid, "TEST", 3, -1, "vertical") # Negative col
def test_place_and_remove_word(self):
"""Test word placement and removal."""
generator = CrosswordGenerator()
grid = [["." for _ in range(10)] for _ in range(10)]
# Place word horizontally
original_state = generator._place_word(grid, "TEST", 5, 3, "horizontal")
# Check placement
assert grid[5][3] == "T"
assert grid[5][4] == "E"
assert grid[5][5] == "S"
assert grid[5][6] == "T"
# Remove word
generator._remove_word(grid, original_state)
# Check removal
assert grid[5][3] == "."
assert grid[5][4] == "."
assert grid[5][5] == "."
assert grid[5][6] == "."
def test_find_word_intersections(self):
"""Test finding intersections between words."""
generator = CrosswordGenerator()
# Test words with common letters
intersections = generator._find_word_intersections("CAT", "DOG")
assert len(intersections) == 0 # No common letters
intersections = generator._find_word_intersections("CAT", "ACE")
assert len(intersections) >= 1 # Common 'A' and 'C'
# Verify intersection format
for intersection in intersections:
assert "word_pos" in intersection
assert "placed_pos" in intersection
assert isinstance(intersection["word_pos"], int)
assert isinstance(intersection["placed_pos"], int)
def test_create_simple_cross(self, sample_words):
"""Test simple cross creation as fallback."""
generator = CrosswordGenerator()
# Use words that have intersections
words_with_intersection = [
{"word": "CAT", "clue": "Feline"},
{"word": "ACE", "clue": "Playing card"},
]
word_list = ["CAT", "ACE"]
result = generator._create_simple_cross(word_list, words_with_intersection)
if result: # If intersection found
assert "grid" in result
assert "clues" in result
assert "placed_words" in result
assert len(result["placed_words"]) == 2
def test_generate_clues(self, sample_words):
"""Test clue generation for placed words."""
generator = CrosswordGenerator()
placed_words = [
{"word": "DOG", "row": 0, "col": 0, "direction": "horizontal", "number": 1},
{"word": "CAT", "row": 0, "col": 0, "direction": "vertical", "number": 2},
]
clues = generator._generate_clues(sample_words, placed_words)
assert len(clues) == 2
for clue in clues:
assert "number" in clue
assert "word" in clue
assert "text" in clue
assert "direction" in clue
assert clue["direction"] in ["across", "down"]
assert "position" in clue
@pytest.mark.asyncio
async def test_select_words_with_vector_service(self, mock_vector_service, sample_words):
"""Test word selection with vector service."""
# Mock vector service methods
mock_vector_service.find_similar_words.return_value = sample_words
generator = CrosswordGenerator(vector_service=mock_vector_service)
words = await generator._select_words(["Animals"], "medium", True)
assert len(words) <= generator.max_words
assert all(isinstance(w, dict) for w in words)
mock_vector_service.find_similar_words.assert_called_once()
@pytest.mark.asyncio
async def test_select_words_without_vector_service(self):
"""Test word selection without vector service."""
generator = CrosswordGenerator()
# Should fallback to empty/static words
words = await generator._select_words(["Animals"], "medium", True)
# Without vector service and no static files, should return empty or minimal
assert isinstance(words, list)
@pytest.mark.asyncio
async def test_generate_puzzle_success(self, mock_vector_service, sample_words):
"""Test successful puzzle generation."""
mock_vector_service.find_similar_words.return_value = sample_words
generator = CrosswordGenerator(vector_service=mock_vector_service)
# Mock the grid creation to return a simple result
with patch.object(generator, '_create_grid') as mock_create_grid:
mock_create_grid.return_value = {
"grid": [["T", "E", "S", "T"], [".", ".", ".", "."]],
"placed_words": [{"word": "TEST", "row": 0, "col": 0, "direction": "horizontal", "number": 1}],
"clues": [{"number": 1, "word": "TEST", "text": "A test", "direction": "across", "position": {"row": 0, "col": 0}}]
}
result = await generator.generate_puzzle(["Animals"], "medium", True)
assert result is not None
assert "grid" in result
assert "clues" in result
assert "metadata" in result
assert result["metadata"]["topics"] == ["Animals"]
assert result["metadata"]["difficulty"] == "medium"
assert result["metadata"]["aiGenerated"] is True
@pytest.mark.asyncio
async def test_generate_puzzle_insufficient_words(self, mock_vector_service):
"""Test puzzle generation with insufficient words."""
# Return too few words
mock_vector_service.find_similar_words.return_value = [
{"word": "CAT", "clue": "Feline", "similarity": 0.8, "source": "test"}
]
generator = CrosswordGenerator(vector_service=mock_vector_service)
with pytest.raises(Exception, match="Not enough words generated"):
await generator.generate_puzzle(["Animals"], "medium", True)
@pytest.mark.asyncio
async def test_generate_puzzle_grid_creation_fails(self, mock_vector_service, sample_words):
"""Test puzzle generation when grid creation fails."""
mock_vector_service.find_similar_words.return_value = sample_words
generator = CrosswordGenerator(vector_service=mock_vector_service)
# Mock grid creation to fail
with patch.object(generator, '_create_grid', return_value=None):
with pytest.raises(Exception, match="Could not create crossword grid"):
await generator.generate_puzzle(["Animals"], "medium", True)
if __name__ == "__main__":
pytest.main([__file__, "-v"])