|
|
|
|
|
""" |
|
|
Unit tests for the Wrdler Leaderboard System. |
|
|
|
|
|
Tests cover: |
|
|
- UserEntry and LeaderboardSettings dataclasses |
|
|
- Qualification logic |
|
|
- Sorting functions |
|
|
- Date/week ID generation |
|
|
""" |
|
|
|
|
|
import pytest |
|
|
from datetime import datetime, timezone, timedelta |
|
|
from unittest.mock import patch, MagicMock |
|
|
|
|
|
from wrdler.leaderboard import ( |
|
|
UserEntry, |
|
|
LeaderboardSettings, |
|
|
get_current_daily_id, |
|
|
get_current_weekly_id, |
|
|
get_daily_leaderboard_path, |
|
|
get_weekly_leaderboard_path, |
|
|
_sort_users, |
|
|
check_qualification, |
|
|
create_user_entry, |
|
|
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, |
|
|
"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", |
|
|
entry_type="daily", |
|
|
) |
|
|
assert lb.challenge_id == "2025-01-27" |
|
|
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) |
|
|
] |
|
|
|
|
|
lb = LeaderboardSettings( |
|
|
challenge_id="test", |
|
|
entry_type="daily", |
|
|
users=users, |
|
|
) |
|
|
|
|
|
display_users = lb.get_display_users() |
|
|
assert len(display_users) == MAX_DISPLAY_ENTRIES |
|
|
|
|
|
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", |
|
|
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", |
|
|
entry_type="daily", |
|
|
game_mode="classic", |
|
|
grid_size=8, |
|
|
wordlist_source="classic.txt", |
|
|
) |
|
|
d = lb.to_dict() |
|
|
|
|
|
|
|
|
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 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) |
|
|
] |
|
|
|
|
|
lb = LeaderboardSettings( |
|
|
challenge_id="test", |
|
|
entry_type="daily", |
|
|
users=users, |
|
|
) |
|
|
|
|
|
|
|
|
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, |
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
assert check_qualification(lb, 32, 100) is True |
|
|
|
|
|
assert check_qualification(lb, 31, 99) is True |
|
|
|
|
|
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, |
|
|
time=100 + i, |
|
|
timestamp="2025-01-27T12:00:00+00:00", |
|
|
) |
|
|
for i in range(20) |
|
|
] |
|
|
|
|
|
lb = LeaderboardSettings( |
|
|
challenge_id="test", |
|
|
entry_type="daily", |
|
|
users=users, |
|
|
) |
|
|
|
|
|
|
|
|
assert check_qualification(lb, 50, 118) is True |
|
|
|
|
|
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, |
|
|
time=100, |
|
|
timestamp="2025-01-27T12:00:00+00:00", |
|
|
word_list_difficulty=100.0 - i, |
|
|
) |
|
|
for i in range(20) |
|
|
] |
|
|
|
|
|
lb = LeaderboardSettings( |
|
|
challenge_id="test", |
|
|
entry_type="daily", |
|
|
users=users, |
|
|
) |
|
|
|
|
|
|
|
|
assert check_qualification(lb, 50, 100, 82.0) is True |
|
|
|
|
|
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, |
|
|
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, |
|
|
) |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
assert len(daily_id) == 10 |
|
|
assert daily_id[4] == "-" |
|
|
assert daily_id[7] == "-" |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
assert "-W" in weekly_id |
|
|
parts = weekly_id.split("-W") |
|
|
assert len(parts) == 2 |
|
|
assert len(parts[0]) == 4 |
|
|
assert len(parts[1]) == 2 |
|
|
|
|
|
def test_daily_path(self): |
|
|
"""Test daily leaderboard path generation.""" |
|
|
path = get_daily_leaderboard_path("2025-01-27") |
|
|
assert path == "leaderboards/daily/2025-01-27.json" |
|
|
|
|
|
def test_weekly_path(self): |
|
|
"""Test weekly leaderboard path generation.""" |
|
|
path = get_weekly_leaderboard_path("2025-W04") |
|
|
assert path == "leaderboards/weekly/2025-W04.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 |
|
|
assert entry.timestamp is not None |
|
|
|
|
|
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", |
|
|
entry_type="daily", |
|
|
) |
|
|
d = lb.to_dict() |
|
|
|
|
|
|
|
|
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 = LeaderboardSettings(challenge_id="2025-01-27", entry_type="daily") |
|
|
assert daily.challenge_id == "2025-01-27" |
|
|
|
|
|
|
|
|
weekly = LeaderboardSettings(challenge_id="2025-W04", entry_type="weekly") |
|
|
assert weekly.challenge_id == "2025-W04" |
|
|
|
|
|
|
|
|
challenge = LeaderboardSettings(challenge_id="20251130T190249Z-ABCDEF", entry_type="challenge") |
|
|
assert challenge.challenge_id == "20251130T190249Z-ABCDEF" |
|
|
|