Del C (ADR-068, migrasjon 072, siste del av den tredelte utvidelsen som startet med Flaggturnering GPS/kart, se ADR-066/067): nytt format eclectic_gross/eclectic_net/eclectic_stableford -- beste resultat per hull på tvers av en turnerings egne runder, krever samme bane (avvist tydelig ved rundeopprettelse ellers). Regnes ut ved lesing, ingen nye tabeller. Bevisst avvik fra opprinnelig plan: integrert som en ny gren i eksisterende individual-leaderboard-endepunkt fremfor et nytt eget endepunkt -- se ADR-068 for begrunnelsen. Tre ikke-relaterte, brukerrapporterte UI-rettelser tatt med i samme runde: avstandsindikatoren brukte "grønn"/"Midt" i stedet for riktige golf-uttrykk "green"/"senter", og "Oppdateres live"-badgen fjernet. "Antall hull"-bryteren i Ny runde-veiviseren fikk samme grønne aksent-valgt-stil som resten av samme skjerm (delt Segmented-primitiv). Se ARCHITECTURE_DECISIONS.md (ADR-068) og CHANGELOG.md (punkt 84) for full begrunnelse og verifiseringslogg. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1239 lines
49 KiB
Python
1239 lines
49 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 datetime import date
|
|
|
|
from handicap_engine import (
|
|
Format,
|
|
HoleResult,
|
|
Player,
|
|
PerPlayerPercentage,
|
|
CombinedPercentage,
|
|
WeightedLowHigh,
|
|
RankedSplit,
|
|
TeamAverage,
|
|
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,
|
|
net_stroke_play_margin,
|
|
compute_skins,
|
|
compute_skins_detail,
|
|
copenhagen_points_for_hole,
|
|
compute_copenhagen,
|
|
compute_copenhagen_detail,
|
|
bbb_points_for_hole,
|
|
compute_bbb,
|
|
FlagResult,
|
|
flag_result,
|
|
flag_lap_and_hole,
|
|
EclecticHoleValue,
|
|
EclecticHolePick,
|
|
eclectic_best_per_hole,
|
|
shamble_hole_score,
|
|
money_ball_hole_score,
|
|
high_low_high_points_for_hole,
|
|
high_low_high_running_score,
|
|
order_of_merit_points_for_position,
|
|
order_of_merit_aggregate,
|
|
max_hole_score_for_handicap,
|
|
net_par,
|
|
round_counts_for_handicap,
|
|
round_half_up,
|
|
round_half_up_decimal,
|
|
score_differential,
|
|
stableford_points_for_hole,
|
|
stableford_total,
|
|
stroke_play_gross_total,
|
|
stroke_play_net_total,
|
|
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_team_average_two_players():
|
|
# CH 6 og 18 -> (6+18)/2 = 12
|
|
players = [Player("A", 18.0, 113, 72.0, 72), Player("B", 6.0, 113, 72.0, 72)]
|
|
assert unit_playing_handicap(players, TeamAverage()) == 12
|
|
|
|
|
|
def test_team_average_three_players_rounds_half_up():
|
|
# CH 8, 14, 20 -> (8+14+20)/3 = 14,0 -> 14
|
|
players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate([8, 14, 20])]
|
|
assert unit_playing_handicap(players, TeamAverage()) == 14
|
|
|
|
|
|
def test_team_average_five_players_rounds_up_at_half():
|
|
# CH 5, 6, 7, 8, 9 -> sum 35 / 5 = 7,0 -> 7. Rekkefølge irrelevant.
|
|
players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate([9, 5, 8, 6, 7])]
|
|
assert unit_playing_handicap(players, TeamAverage()) == 7
|
|
|
|
|
|
def test_team_average_rounding_half_up_boundary():
|
|
# CH 4 og 5 -> 4,5 -> 5 (halvveis alltid opp, samme regel som round_half_up)
|
|
players = [Player("A", 4.0, 113, 72.0, 72), Player("B", 5.0, 113, 72.0, 72)]
|
|
assert unit_playing_handicap(players, TeamAverage()) == 5
|
|
|
|
|
|
def test_team_average_rejects_single_player():
|
|
try:
|
|
TeamAverage().playing_handicap([10.0])
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError("TeamAverage skulle avvist én spiller")
|
|
|
|
|
|
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)"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Netto-slagspill-sammenligning mellom to sider (scramble_solo) -- SØSKEN
|
|
# til match-status over, ikke en del av den: ren totalsum-sammenligning,
|
|
# ingen hull-for-hull-tilstand.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_net_stroke_play_margin_a_wins():
|
|
# A har lavest nettosum -> A leder.
|
|
assert net_stroke_play_margin(net_total_a=68, net_total_b=72) == 4
|
|
|
|
|
|
def test_net_stroke_play_margin_b_wins():
|
|
assert net_stroke_play_margin(net_total_a=75, net_total_b=70) == -5
|
|
|
|
|
|
def test_net_stroke_play_margin_tie():
|
|
assert net_stroke_play_margin(net_total_a=71, net_total_b=71) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_max_hole_score_for_handicap_high_course_handicap_exception_rule_3_1b():
|
|
# Rule 3.1b, ordrett sitert fra WHS Rules of Handicapping 2024, side 37
|
|
# (verifisert direkte mot PDF-en, ikke bare kodens egen gjenfortelling):
|
|
# "Where a Course Handicap is calculated at more than 54 and a player
|
|
# receives 4 or more strokes on a hole, the maximum hole score is
|
|
# par + 5 for handicap purposes." Dette OVERSTYRER den vanlige
|
|
# par+2+slag-cappen -- funnet som et reelt hull i motoren (var verken
|
|
# implementert, testet, eller nevnt i noen ADR før denne testen).
|
|
|
|
# Unntaket slår inn: banehandicap 55 (> 54) OG 4 mottatte slag ->
|
|
# par + 5, IKKE par + 2 + 4 (som ville gitt 10).
|
|
assert max_hole_score_for_handicap(4, strokes_received=4, course_handicap=55) == 9
|
|
|
|
# Grense A -- eksakt 54 er IKKE "mer enn 54": vanlig cap gjelder fortsatt.
|
|
assert max_hole_score_for_handicap(4, strokes_received=4, course_handicap=54) == 4 + 2 + 4
|
|
|
|
# Grense B -- 3 mottatte slag er IKKE "4 eller flere", selv med høyt
|
|
# banehandicap: vanlig cap gjelder fortsatt.
|
|
assert max_hole_score_for_handicap(4, strokes_received=3, course_handicap=60) == 4 + 2 + 3
|
|
|
|
# Flere mottatte slag enn 4 (f.eks. et par-3-hull med 5+ slag ved svært
|
|
# høyt banehandicap) rammes også -- fortsatt par + 5, ikke par+2+5.
|
|
assert max_hole_score_for_handicap(3, strokes_received=6, course_handicap=70) == 8
|
|
|
|
# course_handicap ikke oppgitt (standard None) -- uendret oppførsel fra
|
|
# FØR denne fiksen, selv med 4+ slag. Bakoverkompatibilitet for alle
|
|
# eksisterende kallsteder som ikke (ennå) sender parameteren.
|
|
assert max_hole_score_for_handicap(4, strokes_received=4) == 4 + 2 + 4
|
|
|
|
|
|
def test_adjusted_gross_score_applies_high_course_handicap_exception():
|
|
# Samme unntak (Rule 3.1b) verifisert på hele adjusted_gross_score-veien
|
|
# (course_handicap videreført til max_hole_score_for_handicap per hull),
|
|
# ikke bare på selve cap-funksjonen isolert.
|
|
pars = [4] * 18
|
|
stroke_index = list(range(1, 19))
|
|
strokes_received = allocate_strokes_by_index(55, stroke_index) # banehandicap 55
|
|
# 55 = 3*18 + 1 (base=3, extra=1) -- KUN hull med SI 1 mottar 4 slag,
|
|
# alle 17 andre hull mottar 3 slag hver (se allocate_strokes_by_index).
|
|
assert strokes_received[0] == 4 # SI 1 -- det ENESTE hullet med 4+ slag
|
|
assert strokes_received[9] == 3 # SI 10 -- representativt for resten
|
|
assert strokes_received.count(4) == 1 and strokes_received.count(3) == 17
|
|
scores = [12] * 18 # høyt nok til å treffe enhver rimelig cap på hvert hull
|
|
|
|
ags_with_hcp = adjusted_gross_score(scores, pars, strokes_received, course_handicap=55)
|
|
ags_without_hcp = adjusted_gross_score(scores, pars, strokes_received)
|
|
# Med course_handicap=55: hullet med 4 slag (SI 1) capped til par+5=9
|
|
# via unntaket. De 17 andre hullene (3 slag) capped til par+2+3=9 --
|
|
# samme tall, men via den VANLIGE regelen, uendret av unntaket.
|
|
assert ags_with_hcp == 1 * 9 + 17 * 9 # = 162
|
|
# Uten course_handicap (gammel oppførsel, unntaket slår aldri inn):
|
|
# SI 1-hullet capped til par+2+4=10 i stedet for 9 -- de 17 andre
|
|
# hullene er uendret (unntaket gjaldt dem uansett aldri, siden de har
|
|
# under 4 mottatte slag).
|
|
assert ags_without_hcp == 1 * 10 + 17 * 9 # = 163
|
|
assert ags_with_hcp == ags_without_hcp - 1
|
|
|
|
|
|
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_round_counts_for_handicap_18_hole_intent():
|
|
# Rule 2.2a: intensjon 18 hull, minst 10 av 18 må være spilt.
|
|
assert round_counts_for_handicap(18, holes_planned=18) is True
|
|
assert round_counts_for_handicap(10, holes_planned=18) is True
|
|
assert round_counts_for_handicap(9, holes_planned=18) is False
|
|
|
|
|
|
def test_round_counts_for_handicap_9_hole_intent():
|
|
# Rule 2.2b: intensjon 9 hull, ALLE 9 må spilles -- ikke "minst 9".
|
|
assert round_counts_for_handicap(9, holes_planned=9) is True
|
|
assert round_counts_for_handicap(8, holes_planned=9) is False
|
|
# Færre enn 9 er alltid ugyldig, uansett intensjon.
|
|
assert round_counts_for_handicap(7, holes_planned=18) is False
|
|
|
|
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skins (ADR-039 Beslutning D)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_skins_outright_win_every_hole():
|
|
scores = [[("a", 4), ("b", 5), ("c", 6)], [("a", 3), ("b", 3), ("c", 5)]]
|
|
result = compute_skins(scores, tie_handling="carry")
|
|
# Hull 1: a vinner (laveste 4) -> 1 skin. Hull 2: a/b uavgjort (3) -> carry.
|
|
assert result == {"a": 1.0}, result
|
|
|
|
|
|
def test_skins_carry_over_then_win():
|
|
scores = [
|
|
[("a", 4), ("b", 4), ("c", 5)], # uavgjort a/b -> carry (pott=1)
|
|
[("a", 5), ("b", 5), ("c", 4)], # c vinner alene -> tar pott (1+1=2)
|
|
]
|
|
result = compute_skins(scores, tie_handling="carry")
|
|
assert result == {"c": 2.0}, result
|
|
|
|
|
|
def test_skins_three_way_tie_carries_whole_pot():
|
|
scores = [
|
|
[("a", 4), ("b", 4), ("c", 4)], # alle like -> carry (pott=1)
|
|
[("a", 5), ("b", 5), ("c", 3)], # c vinner alene -> tar hele pott (2)
|
|
]
|
|
result = compute_skins(scores, tie_handling="carry")
|
|
assert result == {"c": 2.0}, result
|
|
|
|
|
|
def test_skins_split_tie_instead_of_carry():
|
|
scores = [[("a", 4), ("b", 4), ("c", 5)]]
|
|
result = compute_skins(scores, tie_handling="split")
|
|
assert result == {"a": 0.5, "b": 0.5}, result
|
|
|
|
|
|
def test_skins_split_after_prior_carry_splits_whole_pot():
|
|
scores = [
|
|
[("a", 5), ("b", 4), ("c", 6)], # b vinner alene -> tar pott (1)
|
|
[("a", 4), ("b", 4), ("c", 5)], # a/b uavgjort -> splittes (pott=1 denne runden)
|
|
]
|
|
result = compute_skins(scores, tie_handling="split")
|
|
assert result == {"b": 1.0 + 0.5, "a": 0.5}, result
|
|
|
|
|
|
def test_skins_empty_hole_skipped_not_an_error():
|
|
scores = [[], [("a", 4), ("b", 5)]]
|
|
result = compute_skins(scores, tie_handling="carry")
|
|
assert result == {"a": 1.0}, result
|
|
|
|
|
|
def test_skins_no_winner_if_never_decided():
|
|
scores = [[("a", 4), ("b", 4)], [("a", 5), ("b", 5)]]
|
|
result = compute_skins(scores, tie_handling="carry")
|
|
assert result == {}, result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# compute_skins_detail (hull-for-hull-forløp, samme totaler som compute_skins)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_skins_detail_matches_compute_skins_totals():
|
|
scores = [
|
|
[("a", 4), ("b", 4), ("c", 5)],
|
|
[("a", 5), ("b", 5), ("c", 4)],
|
|
]
|
|
winnings = compute_skins(scores, tie_handling="carry")
|
|
detail_winnings, _log = compute_skins_detail(scores, tie_handling="carry")
|
|
assert detail_winnings == winnings == {"c": 2.0}
|
|
|
|
|
|
def test_skins_detail_outright_win_log():
|
|
scores = [[("a", 4), ("b", 5), ("c", 6)], [("a", 3), ("b", 3), ("c", 5)]]
|
|
_winnings, log = compute_skins_detail(scores, tie_handling="carry")
|
|
assert len(log) == 2
|
|
assert log[0].values == {"a": 4, "b": 5, "c": 6}
|
|
assert log[0].pot_before == 0.0
|
|
assert log[0].awarded == {"a": 1.0}
|
|
assert log[0].carried is False
|
|
# Hull 2: a/b uavgjort -> carried, ingen tildeling, potten ruller (blir 1.0 for neste).
|
|
assert log[1].awarded == {}
|
|
assert log[1].carried is True
|
|
|
|
|
|
def test_skins_detail_carry_then_win_log():
|
|
scores = [
|
|
[("a", 4), ("b", 4), ("c", 5)], # uavgjort -> carry
|
|
[("a", 5), ("b", 5), ("c", 4)], # c vinner -> tar hele potten (2)
|
|
]
|
|
_winnings, log = compute_skins_detail(scores, tie_handling="carry")
|
|
assert log[0].pot_before == 0.0
|
|
assert log[0].carried is True
|
|
assert log[0].awarded == {}
|
|
assert log[1].pot_before == 1.0
|
|
assert log[1].carried is False
|
|
assert log[1].awarded == {"c": 2.0}
|
|
|
|
|
|
def test_skins_detail_split_log():
|
|
scores = [[("a", 4), ("b", 4), ("c", 5)]]
|
|
_winnings, log = compute_skins_detail(scores, tie_handling="split")
|
|
assert log[0].carried is False
|
|
assert log[0].awarded == {"a": 0.5, "b": 0.5}
|
|
|
|
|
|
def test_skins_detail_empty_hole_logged_but_inert():
|
|
scores = [[], [("a", 4), ("b", 5)]]
|
|
_winnings, log = compute_skins_detail(scores, tie_handling="carry")
|
|
assert log[0].values == {}
|
|
assert log[0].pot_before == 0.0
|
|
assert log[0].awarded == {}
|
|
assert log[0].carried is False
|
|
assert log[1].awarded == {"a": 1.0}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kobenhavner/Copenhagen (2026-07-30) -- 6 poeng per hull mellom 3 spillere.
|
|
# Fasitverdiene er verbatim fra spilletyper-og-spilleformer-2023.pdf s.4,
|
|
# de fire rangeringsmonstrene (4-2-0/4-1-1/3-3-0/2-2-2).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_copenhagen_all_distinct_gives_4_2_0():
|
|
# Lavest slag vinner (higher_is_better=False, standard) -- a=4 slag best.
|
|
points = copenhagen_points_for_hole([("a", 4), ("b", 5), ("c", 6)])
|
|
assert points == {"a": 4, "b": 2, "c": 0}
|
|
assert sum(points.values()) == 6
|
|
|
|
|
|
def test_copenhagen_winner_alone_other_two_tied_gives_4_1_1():
|
|
points = copenhagen_points_for_hole([("a", 4), ("b", 5), ("c", 5)])
|
|
assert points == {"a": 4, "b": 1, "c": 1}
|
|
assert sum(points.values()) == 6
|
|
|
|
|
|
def test_copenhagen_two_tied_best_one_worst_gives_3_3_0():
|
|
points = copenhagen_points_for_hole([("a", 4), ("b", 4), ("c", 5)])
|
|
assert points == {"a": 3, "b": 3, "c": 0}
|
|
assert sum(points.values()) == 6
|
|
|
|
|
|
def test_copenhagen_all_tied_gives_2_2_2():
|
|
points = copenhagen_points_for_hole([("a", 4), ("b", 4), ("c", 4)])
|
|
assert points == {"a": 2, "b": 2, "c": 2}
|
|
assert sum(points.values()) == 6
|
|
|
|
|
|
def test_copenhagen_higher_is_better_for_stableford_points():
|
|
# Stableford: HØYEST poengsum vinner -- a=5 poeng best her.
|
|
points = copenhagen_points_for_hole([("a", 5), ("b", 3), ("c", 2)], higher_is_better=True)
|
|
assert points == {"a": 4, "b": 2, "c": 0}
|
|
|
|
|
|
def test_copenhagen_rejects_wrong_player_count():
|
|
try:
|
|
copenhagen_points_for_hole([("a", 4), ("b", 5)])
|
|
assert False, "skulle kastet ValueError"
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
def test_copenhagen_total_matches_hand_computed_sum():
|
|
scores = [
|
|
[("a", 4), ("b", 5), ("c", 6)], # a: 4, b: 2, c: 0
|
|
[("a", 5), ("b", 5), ("c", 4)], # c alene best -> c:4, a/b delt sist -> 1/1
|
|
[("a", 4), ("b", 4), ("c", 4)], # alle likt -> 2/2/2
|
|
]
|
|
totals = compute_copenhagen(scores)
|
|
assert totals == {"a": 4 + 1 + 2, "b": 2 + 1 + 2, "c": 0 + 4 + 2}
|
|
assert totals == {"a": 7, "b": 5, "c": 6}
|
|
assert sum(totals.values()) == 18 # 3 hull * 6 poeng
|
|
|
|
|
|
def test_copenhagen_incomplete_hole_skipped_not_an_error():
|
|
scores = [[("a", 4), ("b", 5)], [("a", 4), ("b", 5), ("c", 6)]]
|
|
totals = compute_copenhagen(scores)
|
|
assert totals == {"a": 4, "b": 2, "c": 0}
|
|
|
|
|
|
def test_copenhagen_detail_log_matches_totals():
|
|
scores = [[("a", 4), ("b", 5), ("c", 6)]]
|
|
totals, log = compute_copenhagen_detail(scores)
|
|
assert totals == {"a": 4, "b": 2, "c": 0}
|
|
assert len(log) == 1
|
|
assert log[0].values == {"a": 4, "b": 5, "c": 6}
|
|
assert log[0].points == {"a": 4, "b": 2, "c": 0}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bingo Bango Bongo (2026-07-30)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_bbb_points_for_hole_three_different_winners():
|
|
points = bbb_points_for_hole("a", "b", "c")
|
|
assert points == {"a": 1, "b": 1, "c": 1}
|
|
|
|
|
|
def test_bbb_points_for_hole_one_winner_all_three():
|
|
points = bbb_points_for_hole("a", "a", "a")
|
|
assert points == {"a": 3}
|
|
|
|
|
|
def test_bbb_points_for_hole_sweep_bonus_gives_six():
|
|
points = bbb_points_for_hole("a", "a", "a", sweep_bonus_enabled=True)
|
|
assert points == {"a": 6}
|
|
|
|
|
|
def test_bbb_points_for_hole_sweep_bonus_off_gives_three():
|
|
points = bbb_points_for_hole("a", "a", "a", sweep_bonus_enabled=False)
|
|
assert points == {"a": 3}
|
|
|
|
|
|
def test_bbb_points_for_hole_none_categories_award_nothing():
|
|
points = bbb_points_for_hole(None, "b", None)
|
|
assert points == {"b": 1}
|
|
|
|
|
|
def test_bbb_points_for_hole_all_none_is_empty():
|
|
assert bbb_points_for_hole(None, None, None) == {}
|
|
|
|
|
|
def test_compute_bbb_totals_across_holes():
|
|
hole_log = [
|
|
("a", "b", "c"), # a:1 b:1 c:1
|
|
("a", "a", "a"), # a:3 (uten sweep-bonus)
|
|
(None, "b", "b"), # b:2
|
|
]
|
|
totals = compute_bbb(hole_log)
|
|
assert totals == {"a": 1 + 3, "b": 1 + 2, "c": 1}
|
|
assert totals == {"a": 4, "b": 3, "c": 1}
|
|
|
|
|
|
def test_compute_bbb_with_sweep_bonus():
|
|
hole_log = [("a", "a", "a"), ("b", "c", "b")]
|
|
totals = compute_bbb(hole_log, sweep_bonus_enabled=True)
|
|
# Hull 1: a sveiper -> 6 poeng. Hull 2: b vinner 2 av 3 (bingo+bongo), c 1 -> b:2, c:1
|
|
assert totals == {"a": 6, "b": 2, "c": 1}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flaggturnering/Flag tournament (2026-07-30)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_flag_result_completes_all_holes_with_budget_left():
|
|
result = flag_result([5, 4, 6], total_strokes_budget=20)
|
|
assert result == FlagResult(holes_completed=3, ran_out=False, strokes_remaining=5)
|
|
|
|
|
|
def test_flag_result_exact_budget_no_remainder():
|
|
result = flag_result([5, 4, 6], total_strokes_budget=15)
|
|
assert result == FlagResult(holes_completed=3, ran_out=False, strokes_remaining=0)
|
|
|
|
|
|
def test_flag_result_runs_out_mid_round():
|
|
# Budsjett 10: hull 1 (5) -> 5 igjen. Hull 2 (4) -> 1 igjen. Hull 3
|
|
# krever 6, men kun 1 igjen -- går tom PÅ hull 3, kun 2 hull fullført.
|
|
result = flag_result([5, 4, 6], total_strokes_budget=10)
|
|
assert result == FlagResult(holes_completed=2, ran_out=True, strokes_remaining=1)
|
|
|
|
|
|
def test_flag_result_runs_out_on_first_hole():
|
|
result = flag_result([5, 4, 6], total_strokes_budget=3)
|
|
assert result == FlagResult(holes_completed=0, ran_out=True, strokes_remaining=3)
|
|
|
|
|
|
def test_flag_result_no_holes_played_yet():
|
|
result = flag_result([], total_strokes_budget=90)
|
|
assert result == FlagResult(holes_completed=0, ran_out=False, strokes_remaining=90)
|
|
|
|
|
|
# --- Runde 2+ (lap-fortsettelse, 2026-08-14) --------------------------------
|
|
|
|
def test_flag_result_runs_out_mid_lap_two():
|
|
# Lap 1 fullført (18 hull, par 4 hver = 72), budsjett 100 -> 28 igjen inn
|
|
# i lap 2. Lap 2: hull 1 (5) -> 23, hull 2 (4) -> 19, hull 3 krever 30
|
|
# (umulig i praksis, men beviser bare at flag_result() selv ikke bryr
|
|
# seg om laps -- kalleren limer bare sammen sekvensen).
|
|
lap1 = [4] * 18
|
|
lap2_prefix = [5, 4, 30]
|
|
result = flag_result(lap1 + lap2_prefix, total_strokes_budget=100)
|
|
assert result == FlagResult(holes_completed=20, ran_out=True, strokes_remaining=19)
|
|
|
|
|
|
def test_flag_result_completes_two_full_laps_with_budget_left():
|
|
lap1 = [4] * 18
|
|
lap2 = [4] * 18
|
|
result = flag_result(lap1 + lap2, total_strokes_budget=200)
|
|
assert result == FlagResult(holes_completed=36, ran_out=False, strokes_remaining=56)
|
|
|
|
|
|
def test_flag_lap_and_hole_within_lap_one_start_hole_one():
|
|
play_order = list(range(1, 19))
|
|
assert flag_lap_and_hole(0, play_order) == (1, 1)
|
|
assert flag_lap_and_hole(2, play_order) == (1, 3)
|
|
assert flag_lap_and_hole(17, play_order) == (1, 18)
|
|
|
|
|
|
def test_flag_lap_and_hole_wraps_into_lap_two():
|
|
play_order = list(range(1, 19))
|
|
# 18 fullførte hull = akkurat ferdig med lap 1 -- neste er lap 2, hull 1.
|
|
assert flag_lap_and_hole(18, play_order) == (2, 1)
|
|
assert flag_lap_and_hole(20, play_order) == (2, 3)
|
|
assert flag_lap_and_hole(35, play_order) == (2, 18)
|
|
assert flag_lap_and_hole(36, play_order) == (3, 1)
|
|
|
|
|
|
def test_flag_lap_and_hole_respects_non_default_start_hole():
|
|
# start_hole=10 -> play_order er 10,11,...,18,1,2,...,9 (samme sirkulære
|
|
# rekkefølge round-detail.tsx/rounds.py allerede bruker andre steder).
|
|
play_order = [10, 11, 12, 13, 14, 15, 16, 17, 18, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
assert flag_lap_and_hole(0, play_order) == (1, 10)
|
|
assert flag_lap_and_hole(8, play_order) == (1, 18)
|
|
assert flag_lap_and_hole(9, play_order) == (1, 1)
|
|
# Lap 2 gjentar SAMME spillerekkefølge, ikke fysisk hull 1.
|
|
assert flag_lap_and_hole(18, play_order) == (2, 10)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shamble (2026-07-30)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_shamble_hole_score_two_best_of_four():
|
|
# Konkret eksempel fra kilden: scores 4,5,4,6 -- "2 Best Balls of 4" -> 4+4=8.
|
|
assert shamble_hole_score([4, 5, 4, 6], best_n=2) == 8
|
|
|
|
|
|
def test_shamble_hole_score_one_best_of_two():
|
|
assert shamble_hole_score([5, 3], best_n=1) == 3
|
|
|
|
|
|
def test_shamble_hole_score_all_count_equals_sum():
|
|
assert shamble_hole_score([4, 5, 6], best_n=3) == 15
|
|
|
|
|
|
def test_shamble_hole_score_rejects_best_n_out_of_range():
|
|
try:
|
|
shamble_hole_score([4, 5], best_n=3)
|
|
assert False, "skulle kastet ValueError"
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
shamble_hole_score([4, 5], best_n=0)
|
|
assert False, "skulle kastet ValueError"
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Money Ball/Lone Ranger (2026-07-30)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_money_ball_hole_1_uses_player_0():
|
|
# Hull 1 -> rotasjonsindeks 0. Money-ball=scores[0]=5, laveste av [4,6,7] er 4.
|
|
assert money_ball_hole_score([5, 4, 6, 7], hole_number=1) == 5 + 4
|
|
|
|
|
|
def test_money_ball_hole_2_uses_player_1():
|
|
# Hull 2 -> rotasjonsindeks 1. Money-ball=scores[1]=4, laveste av [5,6,7] er 5.
|
|
assert money_ball_hole_score([5, 4, 6, 7], hole_number=2) == 4 + 5
|
|
|
|
|
|
def test_money_ball_rotation_wraps_after_four_holes():
|
|
# Hull 5 -> (5-1)%4 = 0, samme som hull 1.
|
|
assert money_ball_hole_score([5, 4, 6, 7], hole_number=5) == money_ball_hole_score([5, 4, 6, 7], hole_number=1)
|
|
|
|
|
|
def test_money_ball_rejects_wrong_player_count():
|
|
try:
|
|
money_ball_hole_score([4, 5, 6], hole_number=1)
|
|
assert False, "skulle kastet ValueError"
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# High-low-high (2026-07-30) -- verifisert tall for tall mot brukerens eget
|
|
# eksempel: lag 1 = A+B, lag 2 = C+D. Hull 1: A=3, B=0(stryk), C=2, D=2.
|
|
# Hull 2: A=1, B=2, C=3, D=1. Etter hull 1: 1-1. Etter hull 2: 2-1 til lag 2.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_high_low_high_hole_1_matches_user_example():
|
|
# High: A(3) vs C-eller-D(2, likt) -> A vinner (3>2) -> 1p lag1.
|
|
# Low: B(0) vs C-eller-D(2, likt) -> lag2 vinner (2>0) -> 1p lag2.
|
|
points = high_low_high_points_for_hole((3, 0), (2, 2))
|
|
assert points == (1, 1)
|
|
|
|
|
|
def test_high_low_high_hole_2_matches_user_example():
|
|
# High: B(2) vs C(3) -> lag2 vinner -> 1p lag2.
|
|
# Low: A(1) vs D(1) -> UAVGJORT -> 0p til begge (IKKE 0.5/0.5-splitt).
|
|
points = high_low_high_points_for_hole((1, 2), (3, 1))
|
|
assert points == (0, 1)
|
|
|
|
|
|
def test_high_low_high_running_score_matches_user_example():
|
|
hole1 = high_low_high_points_for_hole((3, 0), (2, 2))
|
|
hole2 = high_low_high_points_for_hole((1, 2), (3, 1))
|
|
running_after_1 = high_low_high_running_score([hole1])
|
|
assert running_after_1 == (1, 1), running_after_1 # "1-1 etter hull 1"
|
|
running_after_2 = high_low_high_running_score([hole1, hole2])
|
|
assert running_after_2 == (1, 2), running_after_2 # "2-1 til lag 2" (lag2=2, lag1=1)
|
|
|
|
|
|
def test_high_low_high_tie_on_both_duels_gives_no_points():
|
|
points = high_low_high_points_for_hole((2, 2), (2, 2))
|
|
assert points == (0, 0)
|
|
|
|
|
|
def test_high_low_high_sweep_gives_two_points():
|
|
points = high_low_high_points_for_hole((4, 3), (2, 1))
|
|
assert points == (2, 0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Individuelle scoringsmetoder (ADR-037): bruttoslagspill/nettoslagspill/
|
|
# Stableford
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_stroke_play_gross_total_sums_raw_strokes():
|
|
assert stroke_play_gross_total([5, 4, 6]) == 15
|
|
|
|
|
|
def test_stroke_play_gross_total_empty_is_zero():
|
|
assert stroke_play_gross_total([]) == 0
|
|
|
|
|
|
def test_stroke_play_net_total_subtracts_received_strokes():
|
|
# 5-1, 4-0, 6-1 = 4, 4, 5 -> 13
|
|
assert stroke_play_net_total([5, 4, 6], [1, 0, 1]) == 13
|
|
|
|
|
|
def test_stableford_points_for_hole_net_par_is_two():
|
|
# Par 4, brutto 4, ingen mottatte slag -> netto par -> 2 poeng.
|
|
assert stableford_points_for_hole(par=4, gross_strokes=4, strokes_received=0) == 2
|
|
|
|
|
|
def test_stableford_points_for_hole_net_birdie_is_three():
|
|
# Par 4, brutto 3 -> netto birdie -> 3 poeng.
|
|
assert stableford_points_for_hole(par=4, gross_strokes=3, strokes_received=0) == 3
|
|
|
|
|
|
def test_stableford_points_for_hole_with_received_stroke():
|
|
# Par 4, brutto 6, 1 mottatt slag -> netto 5 (bogey) -> 1 poeng.
|
|
assert stableford_points_for_hole(par=4, gross_strokes=6, strokes_received=1) == 1
|
|
|
|
|
|
def test_stableford_points_for_hole_floors_at_zero():
|
|
# Par 5, brutto 9, 1 mottatt slag -> netto 8 -> 5-8+2 = -1 -> gulvet på 0.
|
|
assert stableford_points_for_hole(par=5, gross_strokes=9, strokes_received=1) == 0
|
|
|
|
|
|
def test_stableford_total_matches_hand_computed_sum():
|
|
pars = [4, 4, 5]
|
|
gross = [4, 6, 9]
|
|
received = [0, 1, 1]
|
|
# Hull for hull: 2 (net par) + 1 (net bogey) + 0 (gulvet) = 3.
|
|
assert stableford_total(pars, gross, received) == 3
|
|
assert stableford_total(pars, gross, received) == sum(
|
|
stableford_points_for_hole(p, g, r) for p, g, r in zip(pars, gross, received)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Order of Merit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_oom_points_for_position_basic():
|
|
table = [10, 8, 6, 4, 2]
|
|
assert order_of_merit_points_for_position("1", table) == 10
|
|
assert order_of_merit_points_for_position("3", table) == 6
|
|
assert order_of_merit_points_for_position("5", table) == 2
|
|
|
|
|
|
def test_oom_points_for_position_tie_shares_value_not_averaged():
|
|
table = [10, 8, 6, 4, 2]
|
|
# To spillere delt 2. plass -- begge får poengverdien for plass 2 (8),
|
|
# IKKE gjennomsnittet av plass 2+3 (7).
|
|
assert order_of_merit_points_for_position("T2", table) == 8
|
|
assert order_of_merit_points_for_position("T2", table) == order_of_merit_points_for_position("T2", table)
|
|
|
|
|
|
def test_oom_points_for_position_beyond_table_is_zero():
|
|
table = [10, 8, 6]
|
|
assert order_of_merit_points_for_position("4", table) == 0
|
|
assert order_of_merit_points_for_position("T10", table) == 0
|
|
|
|
|
|
def test_oom_aggregate_empty_is_none():
|
|
assert order_of_merit_aggregate([], "sum", None) is None
|
|
assert order_of_merit_aggregate([], "average", 2) is None
|
|
|
|
|
|
def test_oom_aggregate_sum_all():
|
|
assert order_of_merit_aggregate([10, 8, 6], "sum", None) == 24
|
|
|
|
|
|
def test_oom_aggregate_average_all():
|
|
assert order_of_merit_aggregate([10, 8, 6], "average", None) == 8
|
|
|
|
|
|
def test_oom_aggregate_best_n_drops_worst():
|
|
# Behold de 2 beste (10, 8) -- dropp den dårligste (6).
|
|
assert order_of_merit_aggregate([10, 8, 6], "sum", 2) == 18
|
|
assert order_of_merit_aggregate([10, 8, 6], "average", 2) == 9
|
|
|
|
|
|
def test_oom_aggregate_best_n_larger_than_available_counts_all():
|
|
assert order_of_merit_aggregate([10, 8], "sum", 5) == 18
|
|
|
|
|
|
def test_oom_aggregate_higher_is_better_gross_uses_negated_values():
|
|
# Brutto 70/75/68 -- kalleren negerer (-70/-75/-68) slik at "størst er
|
|
# best" fortsatt betyr "lavest brutto er best". Behold de 2 beste
|
|
# (dvs. laveste to brutto-tall: 68, 70) -> negert sum -138, altså
|
|
# ekte sum 138.
|
|
negated = [-70, -75, -68]
|
|
assert order_of_merit_aggregate(negated, "sum", 2) == -138
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Eclectic (org individuell turnering, "Del C") -- beste resultat per hull
|
|
# på tvers av rundene.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_eclectic_picks_lowest_gross_per_hole_across_rounds():
|
|
# Hull 1: runde 0 ga 5, runde 1 ga 4 -- 4 skal plukkes.
|
|
# Hull 2: runde 0 ga 3, runde 1 ga 6 -- 3 skal plukkes.
|
|
values_by_hole = [
|
|
[EclecticHoleValue(round_index=0, value=5), EclecticHoleValue(round_index=1, value=4)],
|
|
[EclecticHoleValue(round_index=0, value=3), EclecticHoleValue(round_index=1, value=6)],
|
|
]
|
|
result = eclectic_best_per_hole(values_by_hole, "gross")
|
|
assert result.total == 4 + 3
|
|
assert result.holes == [
|
|
EclecticHolePick(hole_number=1, value=4, round_index=1),
|
|
EclecticHolePick(hole_number=2, value=3, round_index=0),
|
|
]
|
|
|
|
|
|
def test_eclectic_best_result_comes_from_different_rounds_for_different_holes():
|
|
# Beviser at plukkingen faktisk skjer PER HULL, ikke "velg beste hele
|
|
# runde": runde 0 er best på hull 1, runde 1 er best på hull 2 -- ingen
|
|
# av rundene alene ville gitt denne totalen (5 hadde vært verre enn 4
|
|
# på hull 1, og 6 hadde vært verre enn 3 på hull 2, uansett hvilken
|
|
# HELE runde man valgte).
|
|
values_by_hole = [
|
|
[EclecticHoleValue(round_index=0, value=4), EclecticHoleValue(round_index=1, value=6)],
|
|
[EclecticHoleValue(round_index=0, value=7), EclecticHoleValue(round_index=1, value=3)],
|
|
]
|
|
result = eclectic_best_per_hole(values_by_hole, "gross")
|
|
assert [h.round_index for h in result.holes] == [0, 1]
|
|
assert result.total == 4 + 3
|
|
|
|
|
|
def test_eclectic_net_also_picks_lowest():
|
|
values_by_hole = [[EclecticHoleValue(round_index=0, value=2), EclecticHoleValue(round_index=1, value=1)]]
|
|
result = eclectic_best_per_hole(values_by_hole, "net")
|
|
assert result.total == 1
|
|
assert result.holes[0].round_index == 1
|
|
|
|
|
|
def test_eclectic_stableford_picks_highest():
|
|
values_by_hole = [[EclecticHoleValue(round_index=0, value=2), EclecticHoleValue(round_index=1, value=4)]]
|
|
result = eclectic_best_per_hole(values_by_hole, "stableford")
|
|
assert result.total == 4
|
|
assert result.holes[0].round_index == 1
|
|
|
|
|
|
def test_eclectic_hole_with_no_recorded_value_is_excluded_not_zero():
|
|
# Hull 2 er ikke spilt i NOEN runde ennå -- skal utelates fra
|
|
# `holes` (og dermed fra totalen), ikke telle som 0.
|
|
values_by_hole = [
|
|
[EclecticHoleValue(round_index=0, value=5)],
|
|
[],
|
|
[EclecticHoleValue(round_index=0, value=4)],
|
|
]
|
|
result = eclectic_best_per_hole(values_by_hole, "gross")
|
|
assert result.total == 9
|
|
assert [h.hole_number for h in result.holes] == [1, 3]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|