diff --git a/backend/tests/unit/models/test_pending_x_check.py b/backend/tests/unit/models/test_pending_x_check.py new file mode 100644 index 0000000..17f1afb --- /dev/null +++ b/backend/tests/unit/models/test_pending_x_check.py @@ -0,0 +1,465 @@ +""" +Unit tests for PendingXCheck model. + +Tests the interactive x-check state model including validation, +field constraints, and workflow state tracking. +""" + +import pytest +from pydantic import ValidationError + +from app.models.game_models import PendingXCheck + + +class TestPendingXCheckCreation: + """Test PendingXCheck model creation and basic validation.""" + + def test_create_minimal_pending_x_check(self): + """Should create PendingXCheck with required fields only.""" + pending = PendingXCheck( + position="SS", + ab_roll_id="test123", + d20_roll=12, + d6_individual=[3, 4, 5], + d6_total=12, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=42, + ) + + assert pending.position == "SS" + assert pending.ab_roll_id == "test123" + assert pending.d20_roll == 12 + assert pending.d6_individual == [3, 4, 5] + assert pending.d6_total == 12 + assert pending.chart_row == ["G1", "G2", "G3", "SI1", "SI2"] + assert pending.chart_type == "infield" + assert pending.defender_lineup_id == 42 + + # Optional fields should be None + assert pending.spd_d20 is None + assert pending.selected_result is None + assert pending.error_result is None + assert pending.decide_runner_base is None + assert pending.decide_target_base is None + assert pending.decide_advance is None + assert pending.decide_throw is None + assert pending.decide_d20 is None + + def test_create_with_spd_d20(self): + """Should create PendingXCheck with SPD d20 pre-rolled.""" + pending = PendingXCheck( + position="C", + ab_roll_id="test456", + d20_roll=10, + d6_individual=[2, 3, 4], + d6_total=9, + chart_row=["G1", "SPD", "G3", "SI1", "SI2"], + chart_type="catcher", + spd_d20=15, + defender_lineup_id=99, + ) + + assert pending.spd_d20 == 15 + assert "SPD" in pending.chart_row + + def test_create_with_result_selection(self): + """Should create PendingXCheck with player selections.""" + pending = PendingXCheck( + position="LF", + ab_roll_id="test789", + d20_roll=18, + d6_individual=[5, 5, 6], + d6_total=16, + chart_row=["F1", "F2", "F2", "F3", "F3"], + chart_type="outfield", + defender_lineup_id=7, + selected_result="F2", + error_result="E1", + ) + + assert pending.selected_result == "F2" + assert pending.error_result == "E1" + + def test_create_with_decide_data(self): + """Should create PendingXCheck with DECIDE workflow data.""" + pending = PendingXCheck( + position="2B", + ab_roll_id="test999", + d20_roll=8, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=14, + selected_result="G2", + error_result="NO", + decide_runner_base=2, + decide_target_base=3, + decide_advance=True, + decide_throw="runner", + decide_d20=17, + ) + + assert pending.decide_runner_base == 2 + assert pending.decide_target_base == 3 + assert pending.decide_advance is True + assert pending.decide_throw == "runner" + assert pending.decide_d20 == 17 + + +class TestPendingXCheckValidation: + """Test field validation for PendingXCheck.""" + + def test_d20_roll_must_be_1_to_20(self): + """Should reject d20 values outside 1-20 range.""" + with pytest.raises(ValidationError) as exc_info: + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=0, # Invalid + d6_individual=[1, 2, 3], + d6_total=6, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + assert "d20_roll" in str(exc_info.value) + + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=21, # Invalid + d6_individual=[1, 2, 3], + d6_total=6, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + def test_d6_individual_must_have_exactly_3_dice(self): + """Should reject d6_individual with wrong number of dice.""" + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[1, 2], # Too few + d6_total=3, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[1, 2, 3, 4], # Too many + d6_total=10, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + def test_d6_individual_values_must_be_1_to_6(self): + """Should reject d6 values outside 1-6 range.""" + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[0, 2, 3], # 0 invalid + d6_total=5, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[1, 7, 3], # 7 invalid + d6_total=11, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + def test_d6_total_must_be_3_to_18(self): + """Should reject d6_total outside 3-18 range.""" + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[1, 1, 1], + d6_total=2, # Invalid + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[6, 6, 6], + d6_total=19, # Invalid + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + def test_chart_row_must_have_exactly_5_columns(self): + """Should reject chart_row with wrong number of columns.""" + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3"], # Too few + chart_type="infield", + defender_lineup_id=1, + ) + + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2", "Extra"], # Too many + chart_type="infield", + defender_lineup_id=1, + ) + + def test_chart_type_must_be_valid(self): + """Should reject invalid chart_type values.""" + with pytest.raises(ValidationError) as exc_info: + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="invalid", # Must be infield/outfield/catcher + defender_lineup_id=1, + ) + assert "chart_type" in str(exc_info.value) + + def test_error_result_must_be_valid(self): + """Should reject invalid error_result values.""" + with pytest.raises(ValidationError) as exc_info: + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + error_result="INVALID", # Must be NO/E1/E2/E3/RP + ) + assert "error_result" in str(exc_info.value) + + def test_decide_throw_must_be_valid(self): + """Should reject invalid decide_throw values.""" + with pytest.raises(ValidationError) as exc_info: + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + decide_throw="invalid", # Must be runner/first + ) + assert "decide_throw" in str(exc_info.value) + + def test_decide_runner_base_must_be_1_to_3(self): + """Should reject decide_runner_base outside 1-3 range.""" + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + decide_runner_base=0, # Invalid + ) + + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + decide_runner_base=4, # Invalid (home is target, not source) + ) + + def test_decide_target_base_must_be_2_to_4(self): + """Should reject decide_target_base outside 2-4 range.""" + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + decide_target_base=1, # Invalid (can't advance backwards) + ) + + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + decide_target_base=5, # Invalid + ) + + def test_spd_d20_must_be_1_to_20(self): + """Should reject spd_d20 outside 1-20 range.""" + with pytest.raises(ValidationError): + PendingXCheck( + position="C", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "SPD", "G3", "SI1", "SI2"], + chart_type="catcher", + defender_lineup_id=1, + spd_d20=0, # Invalid + ) + + with pytest.raises(ValidationError): + PendingXCheck( + position="C", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "SPD", "G3", "SI1", "SI2"], + chart_type="catcher", + defender_lineup_id=1, + spd_d20=21, # Invalid + ) + + def test_decide_d20_must_be_1_to_20(self): + """Should reject decide_d20 outside 1-20 range.""" + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + decide_d20=0, # Invalid + ) + + with pytest.raises(ValidationError): + PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + decide_d20=21, # Invalid + ) + + +class TestPendingXCheckMutability: + """Test that PendingXCheck allows mutation during workflow.""" + + def test_can_update_selected_result(self): + """Should allow updating selected_result after creation.""" + pending = PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + assert pending.selected_result is None + + # Should be able to mutate + pending.selected_result = "G2" + assert pending.selected_result == "G2" + + def test_can_update_error_result(self): + """Should allow updating error_result after creation.""" + pending = PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + pending.error_result = "E1" + assert pending.error_result == "E1" + + def test_can_update_decide_fields(self): + """Should allow updating DECIDE fields during workflow.""" + pending = PendingXCheck( + position="SS", + ab_roll_id="test", + d20_roll=10, + d6_individual=[3, 3, 3], + d6_total=9, + chart_row=["G1", "G2", "G3", "SI1", "SI2"], + chart_type="infield", + defender_lineup_id=1, + ) + + # Simulate DECIDE workflow + pending.decide_runner_base = 2 + pending.decide_target_base = 3 + pending.decide_advance = True + pending.decide_throw = "first" + + assert pending.decide_runner_base == 2 + assert pending.decide_target_base == 3 + assert pending.decide_advance is True + assert pending.decide_throw == "first"