major-domo-v2/tests/test_utils_draft_helpers.py
Cal Corum 151cf088da Fix draft cap validation using max_zeroes logic
During the draft, teams draft 32 players and then drop to 26. The cap
calculation must account for remaining draft picks:
- max_zeroes = 32 - projected_roster_size (remaining draft picks)
- players_counted = 26 - max_zeroes (how many current players count)

This allows teams to draft expensive players mid-draft knowing they'll
drop cheap ones later. Previously the code was using min(roster_size, 26)
which didn't account for future picks, causing false cap violations.

Example: WAI with 18 players drafting 19th:
- Old (broken): players_counted = 19, sum all players
- New (fixed): max_zeroes = 13, players_counted = 13, only cheapest 13 count

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 21:42:42 -06:00

638 lines
22 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 during draft
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
IMPORTANT: Cap validation during draft uses "max_zeroes" logic:
- Teams draft up to 32 players, then drop to 26
- max_zeroes = 32 - current_roster_size (remaining draft picks)
- players_counted = 26 - max_zeroes (how many current players count toward cap)
- This allows teams to draft expensive players knowing they'll drop cheap ones later
"""
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 TestValidateCapSpaceDraftBehavior:
"""
Tests for validate_cap_space() function - DRAFT-TIME behavior.
During the draft, the "max_zeroes" logic applies:
- max_zeroes = 32 - projected_roster_size (remaining draft slots)
- players_counted = 26 - max_zeroes (current players that count toward cap)
- This allows teams to accumulate expensive players during draft knowing
they'll drop cheap depth later
"""
@pytest.mark.asyncio
async def test_early_draft_no_players_count(self):
"""
With only 2 players, no current players count toward cap during draft.
Why: Team has 30 more picks to fill (32 - 2).
players_counted = 26 - 30 = -4 → 0 players count
"""
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 = 10.0 # Even expensive player is allowed
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
# With 3 players total, max_zeroes = 32 - 3 = 29
# players_counted = 26 - 29 = -3 → 0
assert is_valid is True
assert projected_total == 0.0 # No players count yet
assert cap_limit == 32.0
@pytest.mark.asyncio
async def test_mid_draft_some_players_count(self):
"""
With 18 players, only 13 cheapest count toward cap during draft.
Why: Team has 13 more picks (32 - 19 after adding new player).
players_counted = 26 - 13 = 13 players count
"""
# Create 18 cheap depth players at 1.0 WAR each
players = [
{'id': i, 'name': f'Player {i}', 'wara': 1.0}
for i in range(1, 19)
]
roster = {
'active': {
'players': players,
'WARa': sum(p['wara'] for p in players)
}
}
new_player_wara = 1.0
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
# With 19 players total, max_zeroes = 32 - 19 = 13
# players_counted = 26 - 13 = 13 players count
# All 19 players at 1.0 WAR, cheapest 13 = 13.0
assert is_valid is True
assert projected_total == 13.0
assert cap_limit == 32.0
@pytest.mark.asyncio
async def test_late_draft_pick_19_like_wai(self):
"""
Simulate WAI scenario: 18 players, drafting 19th, with 29.5 cap.
Why: This is the exact scenario that triggered the bug fix.
With 19 players total:
- max_zeroes = 32 - 19 = 13
- players_counted = 26 - 13 = 13
Only 13 cheapest players count, not all 19.
"""
# Create 18 players - simulate realistic WAR values
players = [
{'id': 1, 'name': 'Star', 'wara': 5.0},
{'id': 2, 'name': 'Good', 'wara': 3.5},
]
# Add 16 depth players at 1.0 WAR each
for i in range(3, 19):
players.append({'id': i, 'name': f'Depth {i}', 'wara': 1.0})
roster = {
'active': {
'players': players,
'WARa': sum(p['wara'] for p in players)
}
}
team = {'abbrev': 'WAI', 'salary_cap': 29.5}
new_player_wara = 2.5 # Zach Neto-like player
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara, team)
# With 19 players total:
# max_zeroes = 32 - 19 = 13
# players_counted = 26 - 13 = 13
# Sorted ascending: 1.0 x 16, 2.5 (new), 3.5, 5.0
# Cheapest 13 = 1.0 x 13 = 13.0 (only depth players count!)
assert is_valid is True
assert projected_total == 13.0 # 13 x 1.0 (all depth players)
assert cap_limit == 29.5
@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_first_pick(self):
"""
Empty roster (first pick) should allow any player.
Why: With 0 players, max_zeroes = 32 - 1 = 31, players_counted = 0.
No players count toward cap for the first pick.
"""
roster = {
'active': {
'players': [],
'WARa': 0.0
}
}
new_player_wara = 10.0 # Any value should work
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
assert is_valid is True
assert projected_total == 0.0 # No players count yet
class TestValidateCapSpacePostDraft:
"""
Tests for validate_cap_space() function - POST-DRAFT behavior.
After draft is complete (32 players), normal cap rules apply:
- max_zeroes = 0 (no more draft picks)
- players_counted = 26 (full cap counting)
- Only cheapest 26 players count toward cap
"""
@pytest.mark.asyncio
async def test_full_roster_cheapest_26_count(self):
"""
With 31 players, adding 32nd player, only cheapest 26 count.
Why: At 32 players, max_zeroes = 0, players_counted = 26.
Normal cap rules apply.
"""
# Create 31 players: 5 expensive (5.0 WAR) and 26 cheap (1.0 WAR)
players = [{'id': i, 'name': f'Expensive {i}', 'wara': 5.0} for i in range(1, 6)]
for i in range(6, 32):
players.append({'id': i, 'name': f'Cheap {i}', 'wara': 1.0})
roster = {
'active': {
'players': players,
'WARa': sum(p['wara'] for p in players)
}
}
new_player_wara = 1.0 # Adding another cheap player
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
# With 32 players: max_zeroes = 0, players_counted = 26
# 27 players at 1.0 WAR, 5 at 5.0 WAR
# Sorted ascending: 1.0 x 27, then 5.0 x 5
# Cheapest 26 = 26 x 1.0 = 26.0 (all expensive players excluded!)
assert is_valid is True
assert projected_total == 26.0
assert cap_limit == 32.0
@pytest.mark.asyncio
async def test_full_roster_over_cap(self):
"""
Full roster that exceeds cap should be invalid.
Why: With 32 players and cheapest 26 exceeding cap, should fail.
"""
# Create 31 players all at 1.5 WAR = 26 * 1.5 = 39.0 > 32.0
players = [{'id': i, 'name': f'Player {i}', 'wara': 1.5} for i in range(1, 32)]
roster = {
'active': {
'players': players,
'WARa': sum(p['wara'] for p in players)
}
}
new_player_wara = 1.5
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara)
# 32 players at 1.5, cheapest 26 = 39.0 > 32.0
assert is_valid is False
assert projected_total == 39.0
assert cap_limit == 32.0
@pytest.mark.asyncio
async def test_star_exclusion_post_draft(self):
"""
After draft, expensive stars can be excluded if enough cheap depth.
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)]
# Add 5 expensive stars at 8.0 WAR each
for i in range(26, 31):
players.append({'id': i, 'name': f'Star {i}', 'wara': 8.0})
roster = {
'active': {
'players': players,
'WARa': sum(p['wara'] for p in players)
}
}
# Drafting another 8.0 WAR superstar
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 8.0)
# With 32 players: max_zeroes = 0, players_counted = 26
# 27 players at 1.0, 6 at 8.0
# Cheapest 26 = 26 x 1.0 = 26.0 (ALL stars excluded!)
assert is_valid is True
assert projected_total == 26.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 a full roster (31 players) that will hit exactly 32.0 when adding 32nd
# With 32 players, cheapest 26 count. Need 26 players summing to ~32.0
# 25 players at 1.28 each = 32.0, plus new 0.0 player = still 32.0 for cheapest 26
players = [{'id': i, 'name': f'Player {i}', 'wara': 1.28} for i in range(1, 26)]
# Add 6 expensive players that won't count (need 31 total)
for i in range(26, 32):
players.append({'id': i, 'name': f'Expensive {i}', 'wara': 10.0})
roster = {
'active': {
'players': players,
'WARa': sum(p['wara'] for p in players)
}
}
# Adding a 0.0 WAR player to get to 32 total
# cheapest 26 = 25 * 1.28 + 0.0 = 32.0
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 0.0)
# With 32 players, cheapest 26 = 25 * 1.28 + 0.0 = 32.0
assert is_valid is True
assert abs(projected_total - 32.0) < 0.01
class TestValidateCapSpaceTeamSpecificCaps:
"""Tests for team-specific salary cap handling."""
@pytest.mark.asyncio
async def test_team_with_custom_cap(self):
"""
Should use team's custom salary cap when provided.
Why: Some teams have different caps (expansion, penalties, etc.)
"""
# Create full roster to get normal cap counting
players = [{'id': i, 'name': f'Player {i}', 'wara': 1.0} for i in range(31)]
roster = {
'active': {
'players': players,
'WARa': 31.0
}
}
team = {'abbrev': 'EXP', 'salary_cap': 25.0} # Lower cap
new_player_wara = 1.0 # Total cheapest 26 = 26.0 > 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 # 26 * 1.0
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.
"""
players = [{'id': i, 'name': f'Player {i}', 'wara': 1.0} for i in range(31)]
roster = {
'active': {
'players': players,
'WARa': 31.0
}
}
team = {'abbrev': 'STD', 'salary_cap': None}
new_player_wara = 1.0
is_valid, projected_total, cap_limit = await validate_cap_space(roster, new_player_wara, team)
assert is_valid is True # 26.0 < 32.0 default cap
assert projected_total == 26.0
assert cap_limit == 32.0 # Default
class TestValidateCapSpaceRealTeamModel:
"""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
# Full roster for normal cap counting
players = [{'id': i, 'name': f'Player {i}', 'wara': 1.0} for i in range(31)]
roster = {
'active': {
'players': players,
'WARa': 31.0
}
}
# Team with custom cap of 25.0
team = Team(
id=1,
abbrev='EXP',
sname='Expansion',
lname='Expansion Team',
season=12,
salary_cap=25.0
)
# Adding 1.0 WAR player: cheapest 26 = 26.0 > 25.0 cap
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 1.0, team)
assert is_valid is False # Over custom 25.0 cap
assert projected_total == 26.0
assert cap_limit == 25.0
@pytest.mark.asyncio
async def test_realistic_draft_scenario_full_roster(self):
"""
Test a realistic scenario with full roster and star exclusion.
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 completed draft with 2 superstars and 29 depth players
players = [
{'id': 0, 'name': 'Superstar 1', 'wara': 8.0},
{'id': 1, 'name': 'Superstar 2', 'wara': 7.0},
]
for i in range(2, 31):
players.append({'id': i, 'name': f'Depth {i}', 'wara': 1.0})
roster = {
'active': {
'players': players,
'WARa': sum(p['wara'] for p in players)
}
}
team = Team(
id=1,
abbrev='STR',
sname='Stars',
lname='All-Stars Team',
season=12,
salary_cap=None # Use default 32.0
)
# Draft final player (1.0 WAR depth)
is_valid, projected_total, cap_limit = await validate_cap_space(roster, 1.0, team)
# With 32 players: max_zeroes = 0, players_counted = 26
# 30 players at 1.0, 2 at 7.0 and 8.0
# Cheapest 26 = 26 x 1.0 = 26.0 (both superstars excluded!)
assert is_valid is True
assert projected_total == 26.0
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"