2026-07-16 07:18:01 +02:00
|
|
|
"""
|
|
|
|
|
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)
|
|
|
|
|
"""
|
|
|
|
|
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
from datetime import date
|
|
|
|
|
|
2026-07-16 07:18:01 +02:00
|
|
|
from handicap_engine import (
|
|
|
|
|
Format,
|
|
|
|
|
HoleResult,
|
|
|
|
|
Player,
|
|
|
|
|
PerPlayerPercentage,
|
|
|
|
|
CombinedPercentage,
|
|
|
|
|
WeightedLowHigh,
|
|
|
|
|
RankedSplit,
|
|
|
|
|
DEFAULT_MATCHPLAY_ALLOWANCES,
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
adjusted_gross_score,
|
2026-07-16 07:18:01 +02:00
|
|
|
allocate_strokes_by_index,
|
|
|
|
|
allocate_over_played_holes,
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
apply_index_caps,
|
2026-07-16 07:18:01 +02:00
|
|
|
compute_match_state,
|
|
|
|
|
course_handicap,
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
course_handicap_9,
|
|
|
|
|
course_handicap_9_raw,
|
2026-07-16 07:18:01 +02:00
|
|
|
course_handicap_raw,
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
handicap_index_from_differentials,
|
|
|
|
|
low_handicap_index,
|
2026-07-16 07:18:01 +02:00
|
|
|
match_play_strokes,
|
Ferdig og live. Kort oppsummert:
Bygget: hele frontend for ADR-039-spillformene — utvidet spilleform-velgeren i "Ny runde" til alle åtte formater med skins-konfig, en ny SidesPanel i "Spillere og runde" (opprett/slett sider, tildel/fjern spillere med sanntids kapasitetssperre), en ny FormatResultPanel (løpende matchstatus for match/fourball/foursome/greensome/scramble, skins-tavle for skins), setup_complete-gating av "Fullfør runde", og et eget delt-ball-scorekort+veiviser for foursome/greensome/scramble.
Testet reelt i nettleser (Chrome DevTools mot en isolert scratch-backend, ikke bare typesjekk) — spilte gjennom en komplett match-, skins- og foursome-runde fra bunnen av. Fant og fikset to reelle stale-state-buger underveis (setup-melding og matchstatus ble stående utdatert etter side-tildeling til de ikke lenger refetchet runden). Skins- og foursome-handicap-matematikken kryssjekket for hånd og stemte eksakt.
Rullet ut mot ekte systemer — ingen migrasjon, kun teecup_frontend bygget på nytt, begge containere boot-et rent, teeoff.no upåvirket. ADR-039 er dermed helt ferdig, backend og frontend.
2026-07-28 13:00:50 +02:00
|
|
|
compute_skins,
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
compute_skins_detail,
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
max_hole_score_for_handicap,
|
|
|
|
|
net_par,
|
Migrasjonen er kjørt, og jeg har fortsatt helt til et komplett, verifisert API-lag.
Underveis fant og fikset jeg et reelt hull: round_participant manglet kolonner for selve rating-tallene (kun tee-navnet var lagret) — løst med migrasjon 021, kjørt mot ekte database sammen med resten.
Bygget og rullet ut:
app/routers/rounds.py — opprett/liste/hent/slett runde, legg til/fjern gjestedeltakere, registrer statistikk hull for hull, og en fullfør-endepunkt som kjører hele kjeden (Adjusted Gross Score → Score Differential → tellende-vurdering).
26 scratch-sjekker grønt, pluss en egen test mot den ekte kjørende teeoff-tjenesten som bekreftet at live-oppslaget fungerer nøyaktig som tiltenkt (ingen bane-kopi skrives noe sted).
Begge migrasjoner + ny kode er nå live mot ekte teecup_db/teecup_api. teeoff.no upåvirket.
Jeg la også inn /rounds og /personal-courses i frontendens proxy-oppsett proaktivt, siden prosjektet har blitt bitt av glemte proxy-ruter før — ikke deployet ennå, tas med neste gang frontend faktisk bygges.
Notatet om slaglengde-måling og avstand-til-punkter-på-banen er lagret i CLAUDE.md og FEATURE_BACKLOG.md, med en tydelig flagget avhengighet: dette krever geografiske/GPS-data som ingen nåværende kilde (verken teeoff eller den nye banekatalogen) har.
2026-07-22 11:31:08 +02:00
|
|
|
round_counts_for_handicap,
|
2026-07-16 07:18:01 +02:00
|
|
|
round_half_up,
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
round_half_up_decimal,
|
|
|
|
|
score_differential,
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
stableford_points_for_hole,
|
|
|
|
|
stableford_total,
|
|
|
|
|
stroke_play_gross_total,
|
|
|
|
|
stroke_play_net_total,
|
2026-07-16 07:18:01 +02:00
|
|
|
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)"
|
|
|
|
|
|
|
|
|
|
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
Migrasjonen er kjørt, og jeg har fortsatt helt til et komplett, verifisert API-lag.
Underveis fant og fikset jeg et reelt hull: round_participant manglet kolonner for selve rating-tallene (kun tee-navnet var lagret) — løst med migrasjon 021, kjørt mot ekte database sammen med resten.
Bygget og rullet ut:
app/routers/rounds.py — opprett/liste/hent/slett runde, legg til/fjern gjestedeltakere, registrer statistikk hull for hull, og en fullfør-endepunkt som kjører hele kjeden (Adjusted Gross Score → Score Differential → tellende-vurdering).
26 scratch-sjekker grønt, pluss en egen test mot den ekte kjørende teeoff-tjenesten som bekreftet at live-oppslaget fungerer nøyaktig som tiltenkt (ingen bane-kopi skrives noe sted).
Begge migrasjoner + ny kode er nå live mot ekte teecup_db/teecup_api. teeoff.no upåvirket.
Jeg la også inn /rounds og /personal-courses i frontendens proxy-oppsett proaktivt, siden prosjektet har blitt bitt av glemte proxy-ruter før — ikke deployet ennå, tas med neste gang frontend faktisk bygges.
Notatet om slaglengde-måling og avstand-til-punkter-på-banen er lagret i CLAUDE.md og FEATURE_BACKLOG.md, med en tydelig flagget avhengighet: dette krever geografiske/GPS-data som ingen nåværende kilde (verken teeoff eller den nye banekatalogen) har.
2026-07-22 11:31:08 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
Ferdig og live. Kort oppsummert:
Bygget: hele frontend for ADR-039-spillformene — utvidet spilleform-velgeren i "Ny runde" til alle åtte formater med skins-konfig, en ny SidesPanel i "Spillere og runde" (opprett/slett sider, tildel/fjern spillere med sanntids kapasitetssperre), en ny FormatResultPanel (løpende matchstatus for match/fourball/foursome/greensome/scramble, skins-tavle for skins), setup_complete-gating av "Fullfør runde", og et eget delt-ball-scorekort+veiviser for foursome/greensome/scramble.
Testet reelt i nettleser (Chrome DevTools mot en isolert scratch-backend, ikke bare typesjekk) — spilte gjennom en komplett match-, skins- og foursome-runde fra bunnen av. Fant og fikset to reelle stale-state-buger underveis (setup-melding og matchstatus ble stående utdatert etter side-tildeling til de ikke lenger refetchet runden). Skins- og foursome-handicap-matematikken kryssjekket for hånd og stemte eksakt.
Rullet ut mot ekte systemer — ingen migrasjon, kun teecup_frontend bygget på nytt, begge containere boot-et rent, teeoff.no upåvirket. ADR-039 er dermed helt ferdig, backend og frontend.
2026-07-28 13:00:50 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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}
|
|
|
|
|
|
|
|
|
|
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 07:18:01 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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)
|