""" 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 datetime import date from handicap_engine import ( Format, HoleResult, Player, PerPlayerPercentage, CombinedPercentage, WeightedLowHigh, RankedSplit, DEFAULT_MATCHPLAY_ALLOWANCES, adjusted_gross_score, allocate_strokes_by_index, allocate_over_played_holes, apply_index_caps, compute_match_state, course_handicap, course_handicap_9, course_handicap_9_raw, course_handicap_raw, handicap_index_from_differentials, low_handicap_index, match_play_strokes, max_hole_score_for_handicap, net_par, round_half_up, round_half_up_decimal, score_differential, 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)" # --------------------------------------------------------------------------- # HCP-indeksberegning fra spilte runder (ADR-033) # # Fasitverdiene under er, der mulig, hentet direkte fra egne diagrammer og # tallregneeksempler i "WHS Rules of Handicapping" (effektiv januar 2024, # USGA/R&A) — samme prinsipp som resten av filen (ADR-005): verifiser mot en # autoritativ kilde, ikke bare intern konsistens. # --------------------------------------------------------------------------- def test_round_half_up_decimal_rule_5_1c_examples(): # Rule 5.1c sine tre eksplisitte eksempler, inkl. negative verdier. assert round_half_up_decimal(-1.54, 1) == -1.5 assert round_half_up_decimal(-1.55, 1) == -1.5 assert round_half_up_decimal(-1.56, 1) == -1.6 assert round_half_up_decimal(15.25, 1) == 15.3 def test_net_par(): # Net Par (Rule 3.2b/2) -- par + mottatte handicapslag. assert net_par(par=4, strokes_received=1) == 5 assert net_par(par=4, strokes_received=0) == 4 def test_max_hole_score_for_handicap(): # Net Double Bogey (Rule 3.1b): par + 2 + mottatte slag. assert max_hole_score_for_handicap(4, 1) == 7 # Diagram 3.1b sitt eksempel: par 4, 1 mottatt slag -> maks 7. assert max_hole_score_for_handicap(par=4, strokes_received=1) == 7 # Før indeks etablert (Rule 3.1a): par + 5, uavhengig av slag. assert max_hole_score_for_handicap(5, strokes_received=0, index_established=False) == 10 def test_adjusted_gross_score_diagram_3_1b_worked_example(): # John Smith, HCP 16, Diagram 3.1b. Front-9 (par/SI/score) er lest # tydelig og eksakt fra diagrammet (Out = 35 par / 43 gross, begge # verifisert ved summering) -- brukt uendret her. # # Back-9: par/SI-radene er like eksakte (In = 35 par, verifisert), MEN # selve score-tallrekken i bilde-utsnittet er for utydelig til å stole # blindt på hver enkelt siffer -- summen min (46) stemte ikke med # diagrammets oppgitte "In 45". Eneste back-9-tallet jeg er HELT sikker # på (egen, tydelig uthevet boks i diagrammet, med piler): hull 17 # (par 4, SI 6, mottar 1 slag ved HCP 16) hadde bruttoscore 9, capped # til Net Double Bogey 7. De ANDRE åtte back-9-scorene under er derfor # egenkomponerte (ikke hentet fra diagrammet) -- valgt lavt nok til at # ingen av dem selv trigger en cap, slik at testen isolert kan # verifisere nøyaktig den ene, kildebelagte capping-hendelsen. front9_pars = [4, 4, 3, 4, 5, 4, 3, 4, 4] front9_si = [7, 13, 3, 15, 11, 1, 17, 5, 9] front9_scores = [5, 5, 6, 4, 5, 5, 3, 5, 5] assert sum(front9_pars) == 35 and sum(front9_scores) == 43 # Out, begge fra diagrammet back9_pars = [3, 4, 5, 3, 4, 5, 3, 4, 4] back9_si = [18, 12, 4, 14, 8, 2, 16, 6, 10] assert sum(back9_pars) == 35 # In (par), fra diagrammet # Egenkomponerte back-9-scorer (se kommentar over) -- hull 17 (indeks 7, # par 4, SI 6) er det ENESTE kildebelagte tallet i denne rekken: gross 9. back9_scores = [3, 3, 3, 3, 3, 3, 3, 9, 3] # index 7 = hull 17 pars = front9_pars + back9_pars stroke_index = front9_si + back9_si scores = front9_scores + back9_scores strokes_received = allocate_strokes_by_index(16, stroke_index) # HCP 16 hole17_index = len(front9_pars) + back9_si.index(6) # hole 17 = 0-indexert 16 assert hole17_index == 16 assert pars[hole17_index] == 4 and stroke_index[hole17_index] == 6 assert strokes_received[hole17_index] == 1 # SI 6 <= 16 -> mottar slag assert max_hole_score_for_handicap(4, 1) == 7 # Net Double Bogey, diagrammets "Max 7" ags = adjusted_gross_score(scores, pars, strokes_received) # Eneste hull som overskrider sin cap er hull 17 (gross 9 -> capped 7): # adjusted = gross_total - (9 - 7). assert ags == sum(scores) - 2 def test_adjusted_gross_score_unplayed_holes_use_net_par(): # 9 spilte hull (front) + 9 uspilte (None) -- uspilte fylles med Net Par # (TeeCups kildebelagte erstatning for WHS sin upubliserte Expected Score, # se ADR-033 og moduldoc i handicap_engine.py). pars = [4] * 18 stroke_index = list(range(1, 19)) strokes_received = allocate_strokes_by_index(9, stroke_index) # 9 slag -> 1 på SI 1-9 scores = [4] * 9 + [None] * 9 ags = adjusted_gross_score(scores, pars, strokes_received) # Spilte 9: par 4, ingen over cap -> 9*4 = 36. # Uspilte 9 (SI 10-18, 0 mottatte slag hver) -> Net Par = par + 0 = 4 hver -> 36. assert ags == 72 def test_score_differential_formula(): # Egen tallsjekk mot selve formelen (Rule 5.1a), ingen offisiell # diagram-fasit for akkurat denne kombinasjonen -- verifiserer # utregningen, ikke bare at funksjonen kjører. diff = score_differential(adjusted_gross_score_value=90, course_rating=71.5, slope_rating=128) expected = round_half_up_decimal((113 / 128) * (90 - 71.5), 1) assert diff == expected == 16.3 def test_handicap_index_initial_three_scores_rule_5_2a_example_1(): # Rule 5.2a klargjøring: tre differensialer 15,3 / 15,2 / 16,6 # -> laveste 1 (15,2), justering -2,0 -> initial indeks 13,2. idx = handicap_index_from_differentials([15.3, 15.2, 16.6]) assert idx == 13.2 def test_handicap_index_initial_three_scores_rule_5_2a_example_2(): # Rule 5.2a klargjøring, andre eksempel: 40,7 / 42,4 / 36,1 # -> laveste 1 (36,1), justering -2,0 -> initial indeks 34,1. idx = handicap_index_from_differentials([40.7, 42.4, 36.1]) assert idx == 34.1 def test_handicap_index_six_scores_rule_5_2a_example_2_continued(): # Samme klargjøring, spilleren legger til tre nye: 45,9 / 43,6 / 45,0. # Seks totalt -> snitt av laveste 2 (36,1 og 40,7 = 38,4), justering # -1,0 -> indeks 37,4. idx = handicap_index_from_differentials([40.7, 42.4, 36.1, 45.9, 43.6, 45.0]) assert idx == 37.4 def test_handicap_index_fewer_than_three_returns_none(): assert handicap_index_from_differentials([]) is None assert handicap_index_from_differentials([20.0, 21.0]) is None def test_handicap_index_twenty_scores_uses_lowest_eight(): # Rule 5.2b: 20 differensialer -> snitt av laveste 8, ingen justering. diffs = [float(v) for v in range(1, 21)] # 1..20 idx = handicap_index_from_differentials(diffs) # Laveste 8: 1..8 -> snitt 4,5 assert idx == 4.5 def test_low_handicap_index_within_window(): history = [ (date(2025, 1, 1), 18.0), (date(2025, 6, 1), 15.0), (date(2025, 12, 1), 20.0), ] # Vindu 365 dager tilbake fra 2025-12-15 dekker alle tre -> laveste 15.0. assert low_handicap_index(history, date(2025, 12, 15)) == 15.0 def test_low_handicap_index_excludes_old_entries(): history = [ (date(2023, 1, 1), 5.0), # for gammel, faller utenfor 365-dagersvinduet (date(2025, 11, 1), 15.0), ] assert low_handicap_index(history, date(2025, 12, 15)) == 15.0 def test_low_handicap_index_empty_returns_none(): assert low_handicap_index([], date(2025, 1, 1)) is None def test_apply_index_caps_diagram_5_8(): # Diagram 5.8: Low Handicap Index-referanse 20,0. Soft cap-trigger +3 # (23,0), hard cap-tak +5 (25,0). assert apply_index_caps(new_index=22.0, low_handicap_index_value=20.0) == 22.0 # under soft cap assert apply_index_caps(new_index=23.0, low_handicap_index_value=20.0) == 23.0 # akkurat på triggeren # Økning 4,0 -> 3,0 uendret + halvparten av resten (1,0) = 3,5 -> 23,5 assert apply_index_caps(new_index=24.0, low_handicap_index_value=20.0) == 23.5 # Stor økning -> hard cap-taket, aldri over 25,0 assert apply_index_caps(new_index=40.0, low_handicap_index_value=20.0) == 25.0 def test_apply_index_caps_no_lower_limit(): # Ingen nedre grense -- indeksen kan synke fritt, ingen cap i den retningen. assert apply_index_caps(new_index=5.0, low_handicap_index_value=20.0) == 5.0 def test_course_handicap_9_halves_index_rule_6_1b(): # Rule 6.1b: indeksen HALVERES før den ganges med 9-hulls slope/113 -- # dette AVVIKER fra 18-hulls-formelen (course_handicap_raw), som IKKE # halverer. Verifiserer nettopp dette avviket, siden ingen tallmessig # offisiell fasit ble gitt for akkurat denne kombinasjonen i kilden. idx, slope9, rating9, par9 = 20.0, 132, 36.1, 36 ch9 = course_handicap_9_raw(idx, slope9, rating9, par9) # Skal IKKE være det samme som å bruke full indeks (ville gitt et annet tall). full_index_variant = course_handicap_raw(idx, slope9, rating9, par9) assert ch9 != full_index_variant expected = (idx / 2.0) * (slope9 / 113.0) + (rating9 - par9) assert abs(ch9 - expected) < 1e-9 assert course_handicap_9(idx, slope9, rating9, par9) == round_half_up(expected) # --------------------------------------------------------------------------- # 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)