wrdler / tests /test_leaderboard.py
Surn's picture
Leaderboard - Storage strategy Simplification
9c7fde6
# 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"