teecup/test_handicap_engine.py

310 lines
12 KiB
Python

"""
Tester for TeeCup handicap-motor.
Fasitverdiene er hentet fra R&A Rules of Handicapping, Appendix C, der det er
mulig, slik at motoren kan verifiseres mot en autoritativ kilde uavhengig av
resten av systemet (ADR-005).
Kjør: python -m pytest test_handicap_engine.py -v
ev. python test_handicap_engine.py (kjører en enkel selvsjekk uten pytest)
"""
from handicap_engine import (
Format,
HoleResult,
Player,
PerPlayerPercentage,
CombinedPercentage,
WeightedLowHigh,
RankedSplit,
DEFAULT_MATCHPLAY_ALLOWANCES,
allocate_strokes_by_index,
allocate_over_played_holes,
compute_match_state,
course_handicap,
course_handicap_raw,
match_play_strokes,
round_half_up,
unit_playing_handicap,
)
# ---------------------------------------------------------------------------
# Avrunding
# ---------------------------------------------------------------------------
def test_round_half_up_positive():
assert round_half_up(16.2) == 16
assert round_half_up(15.3) == 15
assert round_half_up(26.1) == 26
assert round_half_up(0.5) == 1 # 0,5 alltid opp (ikke banker's)
assert round_half_up(2.5) == 3
assert round_half_up(1.5) == 2
def test_round_half_up_negative():
# Minus-handicap (plusspillere)
assert round_half_up(-2.5) == -2
assert round_half_up(-0.5) == 0
# ---------------------------------------------------------------------------
# Course Handicap
# ---------------------------------------------------------------------------
def test_course_handicap_formula():
# Index 18.0, Slope 113 (nøytral), CR == Par -> nøyaktig 18
assert course_handicap_raw(18.0, 113, 72.0, 72) == 18.0
# Slope 130, CR 71.5, Par 72
raw = course_handicap_raw(10.0, 130, 71.5, 72)
assert abs(raw - (10.0 * 130 / 113 + (71.5 - 72))) < 1e-9
assert course_handicap(10.0, 130, 71.5, 72) == round_half_up(raw)
# ---------------------------------------------------------------------------
# Singles match play (R&A Appendix C, Eksempel 2): 100 %
# A spiller av 0, B mottar 8 slag.
# ---------------------------------------------------------------------------
def test_singles_match_play_appendix_c_example_2():
strat = DEFAULT_MATCHPLAY_ALLOWANCES[Format.SINGLES]
# To spillere med course handicap som skiller 8 (100 % allowance)
a = Player("A", 8.0, 113, 72.0, 72) # CH 8
b = Player("B", 16.0, 113, 72.0, 72) # CH 16
ph_a = unit_playing_handicap([a], strat)
ph_b = unit_playing_handicap([b], strat)
strokes = match_play_strokes([ph_a, ph_b])
assert strokes == [0, 8]
# ---------------------------------------------------------------------------
# Four-ball match play (R&A Appendix C, Eksempel 3): 90 % per spiller
# Course handicaps 10 / 18 / 27 / 39 -> 0 / 7 / 15 / 26
# ---------------------------------------------------------------------------
def test_fourball_match_play_appendix_c_example_3():
strat = DEFAULT_MATCHPLAY_ALLOWANCES[Format.FOURBALL]
chs = [10, 18, 27, 39]
players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate(chs)]
phs = [unit_playing_handicap([p], strat) for p in players]
# 90 % avrundet: 9, 16, 24, 35
assert phs == [9, 16, 24, 35]
strokes = match_play_strokes(phs)
assert strokes == [0, 7, 15, 26]
# ---------------------------------------------------------------------------
# Foursomes match play (R&A Appendix C, Eksempel 4): 50 % av differansen
# mellom lagenes samlede course handicap. Team 2 mottar 19.
# -> lagenes samlede CH skiller 38.
# ---------------------------------------------------------------------------
def test_foursome_match_play_appendix_c_example_4():
strat = DEFAULT_MATCHPLAY_ALLOWANCES[Format.FOURSOME]
# Team 1 samlet CH = 20, Team 2 samlet CH = 58 -> differanse 38
team1 = [Player("A", 8.0, 113, 72.0, 72), Player("B", 12.0, 113, 72.0, 72)] # sum 20
team2 = [Player("C", 28.0, 113, 72.0, 72), Player("D", 30.0, 113, 72.0, 72)] # sum 58
ph1 = unit_playing_handicap(team1, strat) # 50 % av 20 = 10
ph2 = unit_playing_handicap(team2, strat) # 50 % av 58 = 29
assert ph1 == 10 and ph2 == 29
strokes = match_play_strokes([ph1, ph2])
assert strokes == [0, 19]
# ---------------------------------------------------------------------------
# Greensomes: 60 % laveste + 40 % høyeste
# ---------------------------------------------------------------------------
def test_greensome_weighted_allowance():
strat = WeightedLowHigh(0.60, 0.40)
# CH 12 og 20 -> 0,6*12 + 0,4*20 = 7,2 + 8,0 = 15,2 -> 15
p_low = Player("L", 12.0, 113, 72.0, 72)
p_high = Player("H", 20.0, 113, 72.0, 72)
assert unit_playing_handicap([p_low, p_high], strat) == 15
# rekkefølge skal ikke spille noen rolle
assert unit_playing_handicap([p_high, p_low], strat) == 15
# ---------------------------------------------------------------------------
# Scramble: rangert splitt
# ---------------------------------------------------------------------------
def test_scramble_4_ranked_split():
strat = RankedSplit((0.25, 0.20, 0.15, 0.10))
# CH 4, 10, 16, 24 -> 0,25*4 + 0,20*10 + 0,15*16 + 0,10*24
# = 1,0 + 2,0 + 2,4 + 2,4 = 7,8 -> 8
players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate([24, 4, 16, 10])]
assert unit_playing_handicap(players, strat) == 8 # rekkefølge irrelevant
def test_scramble_2_ranked_split():
strat = RankedSplit((0.35, 0.15))
# CH 6 og 18 -> 0,35*6 + 0,15*18 = 2,1 + 2,7 = 4,8 -> 5
players = [Player("A", 18.0, 113, 72.0, 72), Player("B", 6.0, 113, 72.0, 72)]
assert unit_playing_handicap(players, strat) == 5
def test_allowance_is_configurable_not_hardcoded():
"""ADR-005: motoren skal godta en overstyrt allowance (f.eks. 75 %/3/4)."""
strat_75 = PerPlayerPercentage(0.75)
p = Player("X", 20.0, 113, 72.0, 72) # CH 20
assert unit_playing_handicap([p], strat_75) == 15 # 0,75*20
# ---------------------------------------------------------------------------
# Slagfordeling på Stroke Index
# ---------------------------------------------------------------------------
def test_allocate_strokes_basic():
si = list(range(1, 19)) # SI 1..18
# 5 slag -> ett slag på SI 1..5, null ellers
alloc = allocate_strokes_by_index(5, si)
assert sum(alloc) == 5
assert alloc[0] == 1 and alloc[4] == 1 and alloc[5] == 0
def test_allocate_strokes_high_handicap_double():
si = list(range(1, 19))
# 20 slag -> alle hull minst 1, SI 1 og 2 får 2
alloc = allocate_strokes_by_index(20, si)
assert sum(alloc) == 20
assert alloc[0] == 2 and alloc[1] == 2 and alloc[2] == 1
def test_allocate_strokes_zero():
si = list(range(1, 19))
assert allocate_strokes_by_index(0, si) == [0] * 18
def test_allocate_strokes_plus_handicap_gives_back():
si = list(range(1, 19))
# -2 slag: gir tilbake på de to letteste hullene (SI 18 og 17)
alloc = allocate_strokes_by_index(-2, si)
assert sum(alloc) == -2
# SI 18 er indeks 17, SI 17 er indeks 16
assert alloc[17] == -1 and alloc[16] == -1
assert alloc[0] == 0
def test_allocate_strokes_respects_scorecard_order():
# Hullenes SI i kortrekkefølge (ikke sortert): fordelingen skal følge SI-verdien
si = [5, 1, 12, 3, 18, 7, 9, 11, 15, 2, 4, 6, 8, 10, 13, 14, 16, 17]
alloc = allocate_strokes_by_index(3, si)
assert sum(alloc) == 3
# Slag skal ligge på hull med SI 1, 2, 3
for hole_si, strokes in zip(si, alloc):
assert strokes == (1 if hole_si <= 3 else 0)
# ---------------------------------------------------------------------------
# 9-hulls-fordeling (front/back) — "slagene faller på 18-hulls-kortet"
# ---------------------------------------------------------------------------
# Standard 18-hulls stroke index i hullrekkefølge: hull 1 har SI 1, hull 2 SI 3, ...
# Odde SI på front-9, par SI på back-9.
_FRONT_ODD_SI = [1, 3, 5, 7, 9, 11, 13, 15, 17]
_BACK_EVEN_SI = [2, 4, 6, 8, 10, 12, 14, 16, 18]
_ALL18_SI = _FRONT_ODD_SI + _BACK_EVEN_SI # hull 1..18
_FRONT_HOLES = list(range(1, 10)) # hull 1..9
_BACK_HOLES = list(range(10, 19)) # hull 10..18
def test_nine_hole_three_strokes_back_vs_front():
# 3 mottatte slag: faller på SI 1, 2, 3.
back = allocate_over_played_holes(3, _ALL18_SI, _BACK_HOLES)
front = allocate_over_played_holes(3, _ALL18_SI, _FRONT_HOLES)
assert sum(back) == 1 # kun SI 2 på back-9
assert sum(front) == 2 # SI 1 og 3 på front-9
def test_nine_hole_twelve_strokes_is_six_not_ten():
# Kjernetesten: 12 slag på back-9 skal bli 6, ikke 10 (den naive feilen).
back = allocate_over_played_holes(12, _ALL18_SI, _BACK_HOLES)
assert sum(back) == 6
# SI 2,4,6,8,10,12 får slag; SI 14,16,18 får ikke.
assert back == [1, 1, 1, 1, 1, 1, 0, 0, 0]
def test_nine_hole_matches_naive_only_when_low():
# Metodene sammenfaller så lenge totalen ikke overstiger antall spilte hull
# (her 9): opp til 8 er base-slaget i den naive varianten fortsatt 0.
for total in range(0, 9):
correct = sum(allocate_over_played_holes(total, _ALL18_SI, _BACK_HOLES))
naive = sum(allocate_strokes_by_index(total, _BACK_EVEN_SI))
assert correct == naive
# Fra og med 9 spriker de:
assert sum(allocate_over_played_holes(12, _ALL18_SI, _BACK_HOLES)) \
!= sum(allocate_strokes_by_index(12, _BACK_EVEN_SI))
# ---------------------------------------------------------------------------
# Match-status
# ---------------------------------------------------------------------------
def test_match_state_all_square():
results = [HoleResult.SIDE_A, HoleResult.SIDE_B, HoleResult.HALVED]
state = compute_match_state(results, total_holes=18)
assert state.lead == 0
assert state.describe() == "AS"
def test_match_state_two_up():
results = [HoleResult.SIDE_A, HoleResult.SIDE_A, HoleResult.HALVED]
state = compute_match_state(results, total_holes=18)
assert state.lead == 2
assert state.describe() == "2 UP (A)"
def test_match_state_dormie():
# A leder med 2, og det gjenstår nøyaktig 2 hull
results = [HoleResult.SIDE_A] * 2 + [HoleResult.HALVED] * 14
state = compute_match_state(results, total_holes=18)
assert state.holes_remaining == 2
assert state.is_dormie is True
assert state.describe() == "dormie 2 (A)"
def test_match_state_closed_3_and_2():
# A leder med 3 etter 16 hull -> 2 gjenstår -> avgjort "3&2"
results = [HoleResult.SIDE_A] * 3 + [HoleResult.HALVED] * 13
state = compute_match_state(results, total_holes=18)
assert state.is_closed is True
assert state.describe() == "3&2 (A)"
def test_match_state_won_on_last_hole():
# A leder med 1 etter 18 hull -> vunnet "1 UP"
results = [HoleResult.SIDE_A] + [HoleResult.HALVED] * 17
state = compute_match_state(results, total_holes=18)
assert state.holes_remaining == 0
assert state.describe() == "1 UP (A)"
def test_match_state_side_b_leads():
results = [HoleResult.SIDE_B, HoleResult.SIDE_B, HoleResult.SIDE_A]
state = compute_match_state(results, total_holes=18)
assert state.lead == -1
assert state.describe() == "1 UP (B)"
# ---------------------------------------------------------------------------
# Enkel selvsjekk uten pytest
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import traceback
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
passed = 0
failed = 0
for t in tests:
try:
t()
print(f" ok {t.__name__}")
passed += 1
except Exception:
print(f" FEIL {t.__name__}")
traceback.print_exc()
failed += 1
print(f"\n{passed} bestått, {failed} feilet, {len(tests)} totalt")
raise SystemExit(1 if failed else 0)