Root Cause Fixes: - Add _extract_items_and_count_from_response() override to DraftPickService to handle API returning 'picks' key instead of 'draftpicks' - Add custom from_api_data() to DraftPick model to handle API field mapping (origowner/owner/player -> origowner_id/owner_id/player_id) Enhancements: - Add timer status to /draft-admin set-pick success message - Shows relative deadline timestamp when timer active - Shows "Timer Inactive" when timer not running Also includes related draft module improvements from prior work. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
535 lines
18 KiB
Python
535 lines
18 KiB
Python
"""
|
|
Unit tests for draft helper functions in utils/draft_helpers.py.
|
|
|
|
These tests verify:
|
|
1. calculate_pick_details() correctly handles linear and snake draft formats
|
|
2. calculate_overall_from_round_position() is the inverse of calculate_pick_details()
|
|
3. validate_cap_space() correctly validates roster cap space with team-specific caps
|
|
4. Other helper functions work correctly
|
|
|
|
Why these tests matter:
|
|
- Draft pick calculations are critical for correct draft order
|
|
- Cap space validation prevents illegal roster configurations
|
|
- These functions are used throughout the draft system
|
|
"""
|
|
|
|
import pytest
|
|
from utils.draft_helpers import (
|
|
calculate_pick_details,
|
|
calculate_overall_from_round_position,
|
|
validate_cap_space,
|
|
format_pick_display,
|
|
get_next_pick_overall,
|
|
is_draft_complete,
|
|
get_round_name,
|
|
)
|
|
|
|
|
|
class TestCalculatePickDetails:
|
|
"""Tests for calculate_pick_details() function."""
|
|
|
|
def test_round_1_pick_1(self):
|
|
"""
|
|
Overall pick 1 should be Round 1, Pick 1.
|
|
|
|
Why: First pick of the draft is the simplest case.
|
|
"""
|
|
round_num, position = calculate_pick_details(1)
|
|
assert round_num == 1
|
|
assert position == 1
|
|
|
|
def test_round_1_pick_16(self):
|
|
"""
|
|
Overall pick 16 should be Round 1, Pick 16.
|
|
|
|
Why: Last pick of round 1 in a 16-team draft.
|
|
"""
|
|
round_num, position = calculate_pick_details(16)
|
|
assert round_num == 1
|
|
assert position == 16
|
|
|
|
def test_round_2_pick_1(self):
|
|
"""
|
|
Overall pick 17 should be Round 2, Pick 1.
|
|
|
|
Why: First pick of round 2 (linear format for rounds 1-10).
|
|
"""
|
|
round_num, position = calculate_pick_details(17)
|
|
assert round_num == 2
|
|
assert position == 1
|
|
|
|
def test_round_10_pick_16(self):
|
|
"""
|
|
Overall pick 160 should be Round 10, Pick 16.
|
|
|
|
Why: Last pick of linear draft section.
|
|
"""
|
|
round_num, position = calculate_pick_details(160)
|
|
assert round_num == 10
|
|
assert position == 16
|
|
|
|
def test_round_11_pick_1_snake_begins(self):
|
|
"""
|
|
Overall pick 161 should be Round 11, Pick 1.
|
|
|
|
Why: First pick of snake draft. Same team as Round 10 Pick 16
|
|
gets first pick of Round 11.
|
|
"""
|
|
round_num, position = calculate_pick_details(161)
|
|
assert round_num == 11
|
|
assert position == 1
|
|
|
|
def test_round_11_pick_16(self):
|
|
"""
|
|
Overall pick 176 should be Round 11, Pick 16.
|
|
|
|
Why: Last pick of round 11 (odd snake round = forward order).
|
|
"""
|
|
round_num, position = calculate_pick_details(176)
|
|
assert round_num == 11
|
|
assert position == 16
|
|
|
|
def test_round_12_snake_reverses(self):
|
|
"""
|
|
Round 12 should be in reverse order (snake).
|
|
|
|
Why: Even rounds in snake draft reverse the order.
|
|
"""
|
|
# Pick 177 = Round 12, Pick 16 (starts with last team)
|
|
round_num, position = calculate_pick_details(177)
|
|
assert round_num == 12
|
|
assert position == 16
|
|
|
|
# Pick 192 = Round 12, Pick 1 (ends with first team)
|
|
round_num, position = calculate_pick_details(192)
|
|
assert round_num == 12
|
|
assert position == 1
|
|
|
|
|
|
class TestCalculateOverallFromRoundPosition:
|
|
"""Tests for calculate_overall_from_round_position() function."""
|
|
|
|
def test_round_1_pick_1(self):
|
|
"""Round 1, Pick 1 should be overall pick 1."""
|
|
overall = calculate_overall_from_round_position(1, 1)
|
|
assert overall == 1
|
|
|
|
def test_round_1_pick_16(self):
|
|
"""Round 1, Pick 16 should be overall pick 16."""
|
|
overall = calculate_overall_from_round_position(1, 16)
|
|
assert overall == 16
|
|
|
|
def test_round_10_pick_16(self):
|
|
"""Round 10, Pick 16 should be overall pick 160."""
|
|
overall = calculate_overall_from_round_position(10, 16)
|
|
assert overall == 160
|
|
|
|
def test_round_11_pick_1(self):
|
|
"""Round 11, Pick 1 should be overall pick 161."""
|
|
overall = calculate_overall_from_round_position(11, 1)
|
|
assert overall == 161
|
|
|
|
def test_round_12_pick_16_snake(self):
|
|
"""Round 12, Pick 16 should be overall pick 177 (snake reverses)."""
|
|
overall = calculate_overall_from_round_position(12, 16)
|
|
assert overall == 177
|
|
|
|
def test_inverse_relationship_linear(self):
|
|
"""
|
|
calculate_overall_from_round_position should be inverse of calculate_pick_details
|
|
for linear rounds (1-10).
|
|
|
|
Why: These functions must be inverses for draft logic to work correctly.
|
|
"""
|
|
for overall in range(1, 161): # All linear picks
|
|
round_num, position = calculate_pick_details(overall)
|
|
calculated_overall = calculate_overall_from_round_position(round_num, position)
|
|
assert calculated_overall == overall, f"Failed for overall={overall}"
|
|
|
|
def test_inverse_relationship_snake(self):
|
|
"""
|
|
calculate_overall_from_round_position should be inverse of calculate_pick_details
|
|
for snake rounds (11+).
|
|
|
|
Why: These functions must be inverses for draft logic to work correctly.
|
|
"""
|
|
for overall in range(161, 257): # First 6 snake rounds
|
|
round_num, position = calculate_pick_details(overall)
|
|
calculated_overall = calculate_overall_from_round_position(round_num, position)
|
|
assert calculated_overall == overall, f"Failed for overall={overall}"
|
|
|
|
|
|
class TestValidateCapSpace:
|
|
"""Tests for validate_cap_space() function."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_valid_under_cap(self):
|
|
"""
|
|
Drafting a player that keeps team under cap should be valid.
|
|
|
|
Why: Normal case - team is under cap and pick should be allowed.
|
|
The 26 cheapest players are summed (all 3 in this case since < 26).
|
|
"""
|
|
roster = {
|
|
'active': {
|
|
'players': [
|
|
{'id': 1, 'name': 'Player 1', 'wara': 5.0},
|
|
{'id': 2, 'name': 'Player 2', 'wara': 4.0},
|
|
],
|
|
'WARa': 9.0
|
|
}
|
|
}
|
|
new_player_wara = 3.0
|
|
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
|
|
|
|
assert is_valid is True
|
|
assert projected_total == 12.0 # 3 + 4 + 5 (all players, sorted ascending)
|
|
assert cap_limit == 32.0 # Default cap
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_over_cap(self):
|
|
"""
|
|
Drafting a player that puts team over cap should be invalid.
|
|
|
|
Why: Must prevent illegal roster configurations.
|
|
With 26 players all at 1.5 WAR, sum = 39.0 which exceeds 32.0 cap.
|
|
"""
|
|
# Create roster with 25 players at 1.5 WAR each
|
|
players = [{'id': i, 'name': f'Player {i}', 'wara': 1.5} for i in range(25)]
|
|
roster = {
|
|
'active': {
|
|
'players': players,
|
|
'WARa': 37.5 # 25 * 1.5
|
|
}
|
|
}
|
|
new_player_wara = 1.5 # Adding another 1.5 player = 26 * 1.5 = 39.0
|
|
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
|
|
|
|
assert is_valid is False
|
|
assert projected_total == 39.0 # 26 * 1.5
|
|
assert cap_limit == 32.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_specific_cap(self):
|
|
"""
|
|
Should use team's custom salary cap when provided.
|
|
|
|
Why: Some teams have different caps (expansion, penalties, etc.)
|
|
"""
|
|
roster = {
|
|
'active': {
|
|
'players': [
|
|
{'id': 1, 'name': 'Player 1', 'wara': 10.0},
|
|
{'id': 2, 'name': 'Player 2', 'wara': 10.0},
|
|
],
|
|
'WARa': 20.0
|
|
}
|
|
}
|
|
team = {'abbrev': 'EXP', 'salary_cap': 25.0} # Expansion team with lower cap
|
|
new_player_wara = 6.0 # Total = 26.0 which exceeds 25.0 cap
|
|
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara, team)
|
|
|
|
assert is_valid is False # Over custom 25.0 cap
|
|
assert projected_total == 26.0 # 6 + 10 + 10 (sorted ascending)
|
|
assert cap_limit == 25.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_team_with_none_cap_uses_default(self):
|
|
"""
|
|
Team with salary_cap=None should use default cap.
|
|
|
|
Why: Backwards compatibility for teams without custom caps.
|
|
"""
|
|
roster = {
|
|
'active': {
|
|
'players': [
|
|
{'id': 1, 'name': 'Player 1', 'wara': 10.0},
|
|
],
|
|
'WARa': 10.0
|
|
}
|
|
}
|
|
team = {'abbrev': 'STD', 'salary_cap': None}
|
|
new_player_wara = 5.0
|
|
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara, team)
|
|
|
|
assert is_valid is True
|
|
assert projected_total == 15.0 # 5 + 10
|
|
assert cap_limit == 32.0 # Default
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cap_counting_logic_cheapest_26(self):
|
|
"""
|
|
Only the 26 CHEAPEST players should count toward cap.
|
|
|
|
Why: League rules - expensive stars can be "excluded" if you have
|
|
enough cheap depth players. This rewards roster construction.
|
|
"""
|
|
# Create 27 players: 1 expensive star (10.0) and 26 cheap players (1.0 each)
|
|
players = [{'id': 0, 'name': 'Star', 'wara': 10.0}] # Expensive star
|
|
for i in range(1, 27):
|
|
players.append({'id': i, 'name': f'Cheap {i}', 'wara': 1.0})
|
|
|
|
roster = {
|
|
'active': {
|
|
'players': players,
|
|
'WARa': sum(p['wara'] for p in players) # 10 + 26 = 36
|
|
}
|
|
}
|
|
new_player_wara = 1.0 # Adding another cheap player
|
|
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
|
|
|
|
# With 28 players total, only cheapest 26 count
|
|
# Sorted ascending: 27 players at 1.0, then 1 at 10.0
|
|
# Cheapest 26 = 26 * 1.0 = 26.0 (the star is EXCLUDED)
|
|
assert is_valid is True
|
|
assert projected_total == 26.0
|
|
assert cap_limit == 32.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_roster_structure(self):
|
|
"""
|
|
Invalid roster structure should raise ValueError.
|
|
|
|
Why: Defensive programming - catch malformed data early.
|
|
"""
|
|
with pytest.raises(ValueError, match="Invalid roster structure"):
|
|
await validate_cap_space({}, 1.0)
|
|
|
|
with pytest.raises(ValueError, match="Invalid roster structure"):
|
|
await validate_cap_space(None, 1.0)
|
|
|
|
with pytest.raises(ValueError, match="Invalid roster structure"):
|
|
await validate_cap_space({'other': {}}, 1.0)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_roster(self):
|
|
"""
|
|
Empty roster should allow any player (well under cap).
|
|
|
|
Why: First pick of draft has empty roster.
|
|
"""
|
|
roster = {
|
|
'active': {
|
|
'players': [],
|
|
'WARa': 0.0
|
|
}
|
|
}
|
|
new_player_wara = 5.0
|
|
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
|
|
|
|
assert is_valid is True
|
|
assert projected_total == 5.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tolerance_boundary(self):
|
|
"""
|
|
Values at or just below cap + tolerance should be valid.
|
|
|
|
Why: Floating point tolerance prevents false positives.
|
|
"""
|
|
# Create 25 players at 1.28 WAR each = 32.0 total
|
|
players = [{'id': i, 'name': f'Player {i}', 'wara': 1.28} for i in range(25)]
|
|
roster = {
|
|
'active': {
|
|
'players': players,
|
|
'WARa': 32.0
|
|
}
|
|
}
|
|
|
|
# Adding 0.0 WAR player keeps us at exactly cap - should be valid
|
|
is_valid, projected_total, _ = await validate_cap_space(roster, 0.0)
|
|
assert is_valid is True
|
|
assert abs(projected_total - 32.0) < 0.01
|
|
|
|
# Adding 0.002 WAR player puts us just over tolerance - should be invalid
|
|
is_valid, _, _ = await validate_cap_space(roster, 0.003)
|
|
assert is_valid is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_star_exclusion_scenario(self):
|
|
"""
|
|
Test realistic scenario where an expensive star is excluded from cap.
|
|
|
|
Why: This is the key feature - teams can build around stars by
|
|
surrounding them with cheap depth players.
|
|
"""
|
|
# 26 cheap players at 1.0 WAR each
|
|
players = [{'id': i, 'name': f'Depth {i}', 'wara': 1.0} for i in range(26)]
|
|
roster = {
|
|
'active': {
|
|
'players': players,
|
|
'WARa': 26.0
|
|
}
|
|
}
|
|
|
|
# Drafting a 10.0 WAR superstar
|
|
# With 27 players, cheapest 26 count = 26 * 1.0 = 26.0 (star excluded!)
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 10.0)
|
|
|
|
assert is_valid is True
|
|
assert projected_total == 26.0 # Star is excluded from cap calculation
|
|
assert cap_limit == 32.0
|
|
|
|
|
|
class TestFormatPickDisplay:
|
|
"""Tests for format_pick_display() function."""
|
|
|
|
def test_format_pick_1(self):
|
|
"""First pick should display correctly."""
|
|
result = format_pick_display(1)
|
|
assert result == "Round 1, Pick 1 (Overall #1)"
|
|
|
|
def test_format_pick_45(self):
|
|
"""Middle pick should display correctly."""
|
|
result = format_pick_display(45)
|
|
assert "Round 3" in result
|
|
assert "Overall #45" in result
|
|
|
|
def test_format_pick_161(self):
|
|
"""First snake pick should display correctly."""
|
|
result = format_pick_display(161)
|
|
assert "Round 11" in result
|
|
assert "Overall #161" in result
|
|
|
|
|
|
class TestGetNextPickOverall:
|
|
"""Tests for get_next_pick_overall() function."""
|
|
|
|
def test_next_pick(self):
|
|
"""Next pick should increment by 1."""
|
|
assert get_next_pick_overall(1) == 2
|
|
assert get_next_pick_overall(160) == 161
|
|
assert get_next_pick_overall(512) == 513
|
|
|
|
|
|
class TestIsDraftComplete:
|
|
"""Tests for is_draft_complete() function."""
|
|
|
|
def test_draft_not_complete(self):
|
|
"""Draft should not be complete before total picks."""
|
|
assert is_draft_complete(1, total_picks=512) is False
|
|
assert is_draft_complete(511, total_picks=512) is False
|
|
assert is_draft_complete(512, total_picks=512) is False
|
|
|
|
def test_draft_complete(self):
|
|
"""Draft should be complete after total picks."""
|
|
assert is_draft_complete(513, total_picks=512) is True
|
|
assert is_draft_complete(600, total_picks=512) is True
|
|
|
|
|
|
class TestGetRoundName:
|
|
"""Tests for get_round_name() function."""
|
|
|
|
def test_round_1(self):
|
|
"""Round 1 should just say 'Round 1'."""
|
|
assert get_round_name(1) == "Round 1"
|
|
|
|
def test_round_11_snake_begins(self):
|
|
"""Round 11 should indicate snake draft begins."""
|
|
result = get_round_name(11)
|
|
assert "Round 11" in result
|
|
assert "Snake" in result
|
|
|
|
def test_regular_round(self):
|
|
"""Regular rounds should just show round number."""
|
|
assert get_round_name(5) == "Round 5"
|
|
assert get_round_name(20) == "Round 20"
|
|
|
|
|
|
class TestRealTeamModelIntegration:
|
|
"""Integration tests using the actual Team Pydantic model."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_cap_space_with_real_team_model(self):
|
|
"""
|
|
validate_cap_space should work with real Team Pydantic model.
|
|
|
|
Why: End-to-end test with actual production model.
|
|
"""
|
|
from models.team import Team
|
|
|
|
roster = {
|
|
'active': {
|
|
'players': [
|
|
{'id': 1, 'name': 'Star', 'wara': 8.0},
|
|
{'id': 2, 'name': 'Good', 'wara': 4.0},
|
|
],
|
|
'WARa': 12.0
|
|
}
|
|
}
|
|
|
|
# Team with custom cap of 20.0
|
|
team = Team(
|
|
id=1,
|
|
abbrev='EXP',
|
|
sname='Expansion',
|
|
lname='Expansion Team',
|
|
season=12,
|
|
salary_cap=20.0
|
|
)
|
|
|
|
# Adding 10.0 WAR player: sorted ascending [4.0, 8.0, 10.0] = 22.0 total
|
|
# 22.0 > 20.0 cap, so invalid
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 10.0, team)
|
|
|
|
assert is_valid is False
|
|
assert projected_total == 22.0 # 4 + 8 + 10
|
|
assert cap_limit == 20.0
|
|
|
|
# Adding 5.0 WAR player: sorted ascending [4.0, 5.0, 8.0] = 17.0 total
|
|
# 17.0 < 20.0 cap, so valid
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 5.0, team)
|
|
|
|
assert is_valid is True
|
|
assert projected_total == 17.0 # 4 + 5 + 8
|
|
assert cap_limit == 20.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realistic_draft_scenario(self):
|
|
"""
|
|
Test a realistic draft scenario where team has built around stars.
|
|
|
|
Why: Validates the complete workflow with real Team model and
|
|
demonstrates the cap exclusion mechanic working as intended.
|
|
"""
|
|
from models.team import Team
|
|
|
|
# Team has 2 superstars (8.0, 7.0) and 25 cheap depth players (1.0 each)
|
|
players = [
|
|
{'id': 0, 'name': 'Superstar 1', 'wara': 8.0},
|
|
{'id': 1, 'name': 'Superstar 2', 'wara': 7.0},
|
|
]
|
|
for i in range(2, 27):
|
|
players.append({'id': i, 'name': f'Depth {i}', 'wara': 1.0})
|
|
|
|
roster = {
|
|
'active': {
|
|
'players': players,
|
|
'WARa': sum(p['wara'] for p in players) # 8 + 7 + 25 = 40.0
|
|
}
|
|
}
|
|
|
|
team = Team(
|
|
id=1,
|
|
abbrev='STR',
|
|
sname='Stars',
|
|
lname='All-Stars Team',
|
|
season=12,
|
|
salary_cap=None # Use default 32.0
|
|
)
|
|
|
|
# Draft another 1.0 WAR depth player
|
|
# With 28 players, only cheapest 26 count
|
|
# Sorted: [1.0 x 26, 7.0, 8.0] - cheapest 26 = 26 * 1.0 = 26.0
|
|
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 1.0, team)
|
|
|
|
assert is_valid is True
|
|
assert projected_total == 26.0 # Both superstars excluded!
|
|
assert cap_limit == 32.0
|