# tests/test_leaderboard.py """ Unit tests for the Wrdler Leaderboard System. Tests cover: - UserEntry and LeaderboardSettings dataclasses - Qualification logic - Sorting functions - Date/week ID generation - File ID generation and parsing - GameSettings matching """ import pytest from datetime import datetime, timezone, timedelta from unittest.mock import patch, MagicMock from wrdler.leaderboard import ( UserEntry, LeaderboardSettings, GameSettings, get_current_daily_id, get_current_weekly_id, get_daily_leaderboard_path, get_weekly_leaderboard_path, _sort_users, check_qualification, create_user_entry, _sanitize_wordlist_source, _build_file_id, _parse_file_id, MAX_DISPLAY_ENTRIES, ) class TestUserEntry: """Tests for UserEntry dataclass.""" def test_create_entry(self): """Test basic UserEntry creation.""" entry = UserEntry( uid="test-uid-123", username="TestPlayer", word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"], score=42, time=180, timestamp="2025-01-27T12:00:00+00:00", ) assert entry.uid == "test-uid-123" assert entry.username == "TestPlayer" assert len(entry.word_list) == 6 assert entry.score == 42 assert entry.time == 180 assert entry.word_list_difficulty is None assert entry.source_challenge_id is None def test_to_dict_basic(self): """Test to_dict without optional fields.""" entry = UserEntry( uid="test-uid", username="Player", word_list=["A", "B", "C", "D", "E", "F"], score=30, time=120, timestamp="2025-01-27T12:00:00+00:00", ) d = entry.to_dict() assert d["uid"] == "test-uid" assert d["username"] == "Player" assert d["score"] == 30 assert d["time"] == 120 assert "word_list_difficulty" not in d assert "source_challenge_id" not in d def test_to_dict_with_optional_fields(self): """Test to_dict with optional fields.""" entry = UserEntry( uid="test-uid", username="Player", word_list=["A", "B", "C", "D", "E", "F"], score=30, time=120, timestamp="2025-01-27T12:00:00+00:00", word_list_difficulty=117.5, source_challenge_id="challenge-123", ) d = entry.to_dict() assert d["word_list_difficulty"] == 117.5 assert d["source_challenge_id"] == "challenge-123" def test_from_dict_roundtrip(self): """Test to_dict/from_dict roundtrip.""" original = UserEntry( uid="test-uid", username="Player", word_list=["A", "B", "C", "D", "E", "F"], score=30, time=120, timestamp="2025-01-27T12:00:00+00:00", word_list_difficulty=100.0, ) d = original.to_dict() restored = UserEntry.from_dict(d) assert restored.uid == original.uid assert restored.username == original.username assert restored.score == original.score assert restored.time == original.time assert restored.word_list_difficulty == original.word_list_difficulty def test_from_dict_legacy_time_seconds(self): """Test from_dict handles legacy 'time_seconds' field.""" data = { "uid": "test", "username": "Player", "word_list": ["A", "B", "C", "D", "E", "F"], "score": 30, "time_seconds": 150, # Legacy field name "timestamp": "2025-01-27T12:00:00+00:00", } entry = UserEntry.from_dict(data) assert entry.time == 150 class TestLeaderboardSettings: """Tests for LeaderboardSettings dataclass.""" def test_create_leaderboard(self): """Test basic LeaderboardSettings creation.""" lb = LeaderboardSettings( challenge_id="2025-01-27/classic-classic-0", entry_type="daily", ) assert lb.challenge_id == "2025-01-27/classic-classic-0" assert lb.entry_type == "daily" assert lb.game_mode == "classic" assert lb.grid_size == 8 assert len(lb.users) == 0 assert lb.max_display_entries == MAX_DISPLAY_ENTRIES def test_entry_type_values(self): """Test valid entry_type values.""" for entry_type in ["daily", "weekly", "challenge"]: lb = LeaderboardSettings( challenge_id="test", entry_type=entry_type, ) assert lb.entry_type == entry_type def test_get_display_users_limit(self): """Test get_display_users respects max_display_entries.""" users = [ UserEntry( uid=f"uid-{i}", username=f"Player{i}", word_list=["A", "B", "C", "D", "E", "F"], score=100 - i, time=60 + i, timestamp="2025-01-27T12:00:00+00:00", ) for i in range(25) # More than MAX_DISPLAY_ENTRIES ] lb = LeaderboardSettings( challenge_id="test", entry_type="daily", users=users, ) display_users = lb.get_display_users() assert len(display_users) == MAX_DISPLAY_ENTRIES # Should be first 20 (already sorted by creation) assert display_users[0].uid == "uid-0" def test_to_dict_and_from_dict(self): """Test LeaderboardSettings serialization roundtrip.""" user = UserEntry( uid="test-uid", username="Player", word_list=["A", "B", "C", "D", "E", "F"], score=50, time=100, timestamp="2025-01-27T12:00:00+00:00", ) lb = LeaderboardSettings( challenge_id="2025-01-27/easy-easy-0", entry_type="daily", game_mode="easy", users=[user], wordlist_source="test.txt", ) d = lb.to_dict() restored = LeaderboardSettings.from_dict(d) assert restored.challenge_id == lb.challenge_id assert restored.entry_type == lb.entry_type assert restored.game_mode == lb.game_mode assert len(restored.users) == 1 assert restored.wordlist_source == lb.wordlist_source def test_format_matches_challenge_structure(self): """Test that leaderboard format matches challenge settings.json structure.""" lb = LeaderboardSettings( challenge_id="2025-01-27/classic-classic-0", entry_type="daily", game_mode="classic", grid_size=8, wordlist_source="classic.txt", ) d = lb.to_dict() # Key fields that should match challenge format assert "challenge_id" in d assert "entry_type" in d assert "game_mode" in d assert "grid_size" in d assert "puzzle_options" in d assert "users" in d assert "created_at" in d assert "version" in d assert "show_incorrect_guesses" in d assert "enable_free_letters" in d assert "wordlist_source" in d class TestGameSettings: """Tests for GameSettings dataclass.""" def test_create_default_settings(self): """Test default GameSettings creation.""" settings = GameSettings() assert settings.game_mode == "classic" assert settings.wordlist_source == "classic.txt" assert settings.show_incorrect_guesses is True assert settings.enable_free_letters is True def test_settings_matching_same(self): """Test that identical settings match.""" s1 = GameSettings(game_mode="classic", wordlist_source="classic.txt") s2 = GameSettings(game_mode="classic", wordlist_source="classic.txt") assert s1.matches(s2) is True def test_settings_matching_different_mode(self): """Test that different game modes don't match.""" s1 = GameSettings(game_mode="classic", wordlist_source="classic.txt") s2 = GameSettings(game_mode="easy", wordlist_source="classic.txt") assert s1.matches(s2) is False def test_settings_matching_different_wordlist(self): """Test that different wordlists don't match.""" s1 = GameSettings(game_mode="classic", wordlist_source="classic.txt") s2 = GameSettings(game_mode="classic", wordlist_source="easy.txt") assert s1.matches(s2) is False def test_settings_matching_txt_extension_ignored(self): """Test that .txt extension is ignored in comparison.""" s1 = GameSettings(game_mode="classic", wordlist_source="classic.txt") s2 = GameSettings(game_mode="classic", wordlist_source="classic") # Both should have same sanitized source assert s1._get_sanitized_source() == s2._get_sanitized_source() def test_get_file_id_prefix(self): """Test file_id prefix generation.""" settings = GameSettings(game_mode="classic", wordlist_source="classic.txt") assert settings.get_file_id_prefix() == "classic-classic" settings2 = GameSettings(game_mode="too easy", wordlist_source="easy.txt") assert settings2.get_file_id_prefix() == "easy-too_easy" class TestFileIdFunctions: """Tests for file ID generation and parsing.""" def test_sanitize_wordlist_source_removes_txt(self): """Test that .txt extension is removed.""" assert _sanitize_wordlist_source("classic.txt") == "classic" assert _sanitize_wordlist_source("easy.txt") == "easy" assert _sanitize_wordlist_source("my_words.txt") == "my_words" def test_sanitize_wordlist_source_lowercase(self): """Test that output is lowercase.""" assert _sanitize_wordlist_source("CLASSIC.txt") == "classic" assert _sanitize_wordlist_source("MyWords.TXT") == "mywords" def test_sanitize_wordlist_source_no_extension(self): """Test sources without .txt extension.""" assert _sanitize_wordlist_source("classic") == "classic" def test_build_file_id(self): """Test file_id building.""" assert _build_file_id("classic.txt", "classic", 0) == "classic-classic-0" assert _build_file_id("easy.txt", "easy", 1) == "easy-easy-1" assert _build_file_id("classic.txt", "too easy", 2) == "classic-too_easy-2" def test_parse_file_id(self): """Test file_id parsing.""" source, mode, seq = _parse_file_id("classic-classic-0") assert source == "classic" assert mode == "classic" assert seq == 0 source, mode, seq = _parse_file_id("easy-too_easy-5") assert source == "easy" assert mode == "too easy" assert seq == 5 def test_parse_file_id_invalid(self): """Test file_id parsing with invalid format.""" with pytest.raises(ValueError): _parse_file_id("invalid") with pytest.raises(ValueError): _parse_file_id("classic-classic-notanumber") class TestQualification: """Tests for qualification logic.""" def test_qualify_empty_leaderboard(self): """Test that any score qualifies for empty leaderboard.""" assert check_qualification(None, 1, 999) is True def test_qualify_not_full(self): """Test qualification when leaderboard is not full.""" users = [ UserEntry( uid=f"uid-{i}", username=f"Player{i}", word_list=["A", "B", "C", "D", "E", "F"], score=50, time=100, timestamp="2025-01-27T12:00:00+00:00", ) for i in range(10) # Less than MAX_DISPLAY_ENTRIES ] lb = LeaderboardSettings( challenge_id="test", entry_type="daily", users=users, ) # Any score should qualify assert check_qualification(lb, 1, 999) is True def test_qualify_by_score(self): """Test qualification by higher score.""" users = [ UserEntry( uid=f"uid-{i}", username=f"Player{i}", word_list=["A", "B", "C", "D", "E", "F"], score=50 - i, # Scores from 50 down to 31 time=100, timestamp="2025-01-27T12:00:00+00:00", ) for i in range(20) ] lb = LeaderboardSettings( challenge_id="test", entry_type="daily", users=users, ) # Higher than lowest (31) should qualify assert check_qualification(lb, 32, 100) is True # Equal to lowest but faster time should qualify assert check_qualification(lb, 31, 99) is True # Lower than lowest should not qualify assert check_qualification(lb, 30, 100) is False def test_qualify_by_time_tiebreaker(self): """Test qualification using time as tiebreaker.""" users = [ UserEntry( uid=f"uid-{i}", username=f"Player{i}", word_list=["A", "B", "C", "D", "E", "F"], score=50, # All same score time=100 + i, # Times from 100 to 119 timestamp="2025-01-27T12:00:00+00:00", ) for i in range(20) ] lb = LeaderboardSettings( challenge_id="test", entry_type="daily", users=users, ) # Same score but faster than slowest (119) should qualify assert check_qualification(lb, 50, 118) is True # Same score and slower should not qualify assert check_qualification(lb, 50, 120) is False def test_qualify_by_difficulty_tiebreaker(self): """Test qualification using difficulty as final tiebreaker.""" users = [ UserEntry( uid=f"uid-{i}", username=f"Player{i}", word_list=["A", "B", "C", "D", "E", "F"], score=50, # All same score time=100, # All same time timestamp="2025-01-27T12:00:00+00:00", word_list_difficulty=100.0 - i, # Difficulties from 100 to 81 ) for i in range(20) ] lb = LeaderboardSettings( challenge_id="test", entry_type="daily", users=users, ) # Same score/time but higher difficulty than lowest (81) should qualify assert check_qualification(lb, 50, 100, 82.0) is True # Lower difficulty should not qualify assert check_qualification(lb, 50, 100, 80.0) is False def test_not_qualify_lower_score(self): """Test that lower score doesn't qualify for full leaderboard.""" users = [ UserEntry( uid=f"uid-{i}", username=f"Player{i}", word_list=["A", "B", "C", "D", "E", "F"], score=100, # All high scores time=60, timestamp="2025-01-27T12:00:00+00:00", ) for i in range(20) ] lb = LeaderboardSettings( challenge_id="test", entry_type="daily", users=users, ) # Much lower score should not qualify assert check_qualification(lb, 50, 60) is False class TestDateIds: """Tests for date/week ID generation.""" def test_daily_id_format(self): """Test daily ID format is YYYY-MM-DD.""" daily_id = get_current_daily_id() # Should match pattern YYYY-MM-DD assert len(daily_id) == 10 assert daily_id[4] == "-" assert daily_id[7] == "-" # Parse to verify it's a valid date date = datetime.strptime(daily_id, "%Y-%m-%d") assert date is not None def test_weekly_id_format(self): """Test weekly ID format is YYYY-Www.""" weekly_id = get_current_weekly_id() # Should match pattern YYYY-Www assert "-W" in weekly_id parts = weekly_id.split("-W") assert len(parts) == 2 assert len(parts[0]) == 4 # Year assert len(parts[1]) == 2 # Week number with leading zero def test_daily_path(self): """Test daily leaderboard path generation with new folder structure.""" path = get_daily_leaderboard_path("2025-01-27", "classic-classic-0") assert path == "games/leaderboards/daily/2025-01-27/classic-classic-0/settings.json" def test_weekly_path(self): """Test weekly leaderboard path generation with new folder structure.""" path = get_weekly_leaderboard_path("2025-W04", "easy-easy-1") assert path == "games/leaderboards/weekly/2025-W04/easy-easy-1/settings.json" class TestSorting: """Tests for user sorting.""" def test_sort_by_score_desc(self): """Test users are sorted by score descending.""" users = [ UserEntry(uid="1", username="A", word_list=[], score=30, time=100, timestamp="" ), UserEntry(uid="2", username="B", word_list=[], score=50, time=100, timestamp="" ), UserEntry(uid="3", username="C", word_list=[], score=40, time=100, timestamp="" ), ] sorted_users = _sort_users(users) assert sorted_users[0].score == 50 assert sorted_users[1].score == 40 assert sorted_users[2].score == 30 def test_sort_by_time_asc_for_equal_score(self): """Test users with equal score are sorted by time ascending.""" users = [ UserEntry(uid="1", username="A", word_list=[], score=50, time=120, timestamp="" ), UserEntry(uid="2", username="B", word_list=[], score=50, time=80, timestamp="" ), UserEntry(uid="3", username="C", word_list=[], score=50, time=100, timestamp="" ), ] sorted_users = _sort_users(users) assert sorted_users[0].time == 80 assert sorted_users[1].time == 100 assert sorted_users[2].time == 120 def test_sort_by_difficulty_desc_for_equal_score_and_time(self): """Test users with equal score and time are sorted by difficulty descending.""" users = [ UserEntry(uid="1", username="A", word_list=[], score=50, time=100, timestamp="", word_list_difficulty=80.0), UserEntry(uid="2", username="B", word_list=[], score=50, time=100, timestamp="", word_list_difficulty=120.0), UserEntry(uid="3", username="C", word_list=[], score=50, time=100, timestamp="", word_list_difficulty=100.0), ] sorted_users = _sort_users(users) assert sorted_users[0].word_list_difficulty == 120.0 assert sorted_users[1].word_list_difficulty == 100.0 assert sorted_users[2].word_list_difficulty == 80.0 class TestCreateUserEntry: """Tests for create_user_entry helper.""" def test_create_user_entry_basic(self): """Test creating a user entry with basic fields.""" entry = create_user_entry( username="TestPlayer", score=45, time_seconds=150, word_list=["A", "B", "C", "D", "E", "F"], ) assert entry.username == "TestPlayer" assert entry.score == 45 assert entry.time == 150 assert len(entry.word_list) == 6 assert entry.uid is not None # Auto-generated assert entry.timestamp is not None # Auto-generated def test_create_user_entry_with_optional_fields(self): """Test creating a user entry with optional fields.""" entry = create_user_entry( username="TestPlayer", score=45, time_seconds=150, word_list=["A", "B", "C", "D", "E", "F"], word_list_difficulty=110.5, source_challenge_id="challenge-xyz", ) assert entry.word_list_difficulty == 110.5 assert entry.source_challenge_id == "challenge-xyz" class TestUnifiedFormat: """Tests for unified format consistency.""" def test_leaderboard_matches_challenge_structure(self): """Test leaderboard to_dict matches expected challenge structure.""" lb = LeaderboardSettings( challenge_id="2025-01-27/classic-classic-0", entry_type="daily", ) d = lb.to_dict() # All challenge fields should be present required_fields = [ "challenge_id", "entry_type", "game_mode", "grid_size", "puzzle_options", "users", "created_at", "version", "show_incorrect_guesses", "enable_free_letters", "wordlist_source", "game_title", "max_display_entries", ] for field in required_fields: assert field in d, f"Missing field: {field}" def test_entry_type_field_present(self): """Test entry_type is always present in serialized output.""" for entry_type in ["daily", "weekly", "challenge"]: lb = LeaderboardSettings( challenge_id="test", entry_type=entry_type, ) d = lb.to_dict() assert d["entry_type"] == entry_type def test_challenge_id_as_primary_identifier(self): """Test challenge_id serves as primary identifier for all types.""" # Daily uses new folder format daily = LeaderboardSettings(challenge_id="2025-01-27/classic-classic-0", entry_type="daily") assert daily.challenge_id == "2025-01-27/classic-classic-0" # Weekly uses new folder format weekly = LeaderboardSettings(challenge_id="2025-W04/easy-easy-0", entry_type="weekly") assert weekly.challenge_id == "2025-W04/easy-easy-0" # Challenge uses UID format challenge = LeaderboardSettings(challenge_id="20251130T190249Z-ABCDEF", entry_type="challenge") assert challenge.challenge_id == "20251130T190249Z-ABCDEF"