From 47bebc46a2f75766585d51903f389dbe43134c33 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Thu, 30 Jul 2026 07:12:05 +0200 Subject: [PATCH] =?UTF-8?q?Backend-laget=20(migrasjon=20040=20+=20motor=20?= =?UTF-8?q?+=20API)=20er=20ferdig=20bygget=20og=20grundig=20scratch-verifi?= =?UTF-8?q?sert=20=E2=80=94=2063/63=20motor-tester,=2052/52=20API-sjekker,?= =?UTF-8?q?=20test=5Fisolation.sql=20fortsatt=2012/12.=20Dokumentasjonen?= =?UTF-8?q?=20er=20oppdatert=20(CLAUDE.md/FEATURE=5FBACKLOG.md/ARCHITECTUR?= =?UTF-8?q?E=5FDECISIONS.md).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ARCHITECTURE_DECISIONS.md | 17 +- CLAUDE.md | 77 ++ FEATURE_BACKLOG.md | 83 ++- __pycache__/handicap_engine.cpython-312.pyc | Bin 28319 -> 30044 bytes app/main.py | 2 + app/routers/individual_tournaments.py | 753 ++++++++++++++++++++ app/routers/tournaments.py | 18 +- handicap_engine.py | 34 + test_handicap_engine.py | 53 ++ 9 files changed, 1026 insertions(+), 11 deletions(-) create mode 100644 app/routers/individual_tournaments.py diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index d5bbb93..61c9d45 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -3344,11 +3344,18 @@ prinsipp som ADR-005) + én ny CHECK-verdi, ikke en skjemaendring. (en "sesong"/"serie", org-scopet, RLS som ellers) som grupperer flere `tournament`-rader og akkumulerer poeng — IKKE designet i denne runden. -- **Migrasjon/kode** — denne ADR-en er ren struktur-beslutning, ingen - migrasjon er skrevet ennå. Neste steg er å legge frem et konkret - migrasjonsutkast (nye tabeller + `tournament.format_type`-kolonne) til - gjennomgang før noe kjøres, samme "vis planen FØR noe skrives"-mønster - som ellers i prosjektet. +- **Migrasjon/kode — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-30, IKKE ENNÅ + RULLET UT.** Migrasjon `040_individual_tournaments.sql` (alle fem nye + tabeller + `tournament.format_type`/`scoring_method`), nye motorfunksjoner + i `handicap_engine.py` (`stroke_play_gross_total`/`stroke_play_net_total`/ + `stableford_points_for_hole`/`stableford_total`, 8 nye tester), og et + fullt CRUD-API (`app/routers/individual_tournaments.py`: runder/ + turnering-deltakere/rundedeltakere/hull-for-hull-scoring/leaderboard). + Se FEATURE_BACKLOG.md ("Utvidelse 2026-07-26"-seksjonens oppdatering + 2026-07-30) for full detalj om design, funn under bygging og + verifisering (52 API-sjekker + 63 motor-tester). Frontend er bevisst + IKKE bygget i denne runden — neste steg i samme "motor → skjema → API → + frontend"-rekkefølge som ADR-033/038/039. --- diff --git a/CLAUDE.md b/CLAUDE.md index f97fd29..8500347 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5512,6 +5512,83 @@ Ferdig og verifisert: turneringer siden i dag) -- se ARCHITECTURE_DECISIONS.md sitt åpne spørsmål 4 (Scramble-grensesnitt), som dermed er helt avsluttet. +- **ADR-037 (individuelle/flerrunde-turneringer): migrasjon + motor + API + BYGGET OG SCRATCH-VERIFISERT, IKKE ENNÅ RULLET UT (2026-07-30):** bruker + ba om å gå videre med anbefalingen fra en dypdykk-gjennomgang av + .md-filene -- det eneste gjenværende punktet med en ferdig, load-bærende + strukturbeslutning (ADR-037, 2026-07-26) uten kode. Bygget i tre lag, + samme "test i isolasjon FØR resten"-rekkefølge som ADR-005/033/038/039. + **Migrasjon `040_individual_tournaments.sql`:** `tournament.format_type` + (`team`/`individual`, default `team` -- alle eksisterende rader uendret) + + `tournament.scoring_method` (`stroke_gross`/`stroke_net`/`stableford`, + nullable). Fem nye, RLS-beskyttede tabeller: `tournament_round` (samme + rolle som `session`, peker til org-ens EGEN bane -- ingen snapshot, + ulikt ADR-033), `tournament_participant` (samme rolle som `team_roster`, + fryser `handicap_index_snapshot`), `tournament_round_participant` + (tee + cachet course/playing-handicap PER runde), `tournament_round_hole` + (rå brutto slag, kilde-sannhet), `tournament_round_score` (ferdig + utregnet brutto/netto/Stableford-total PER deltaker PER runde, cachet -- + samme mønster som `match.status_text`/`points_side_a/b`; sammenlagt over + flere runder summeres VED LESING i leaderboardet, ingen egen tredje + cache-tabell, Beslutning C). + **Scratch-verifisert alene FØR API-et ble bygget:** alle 40 migrasjoner + kjørte rent i rekkefølge, `test_isolation.sql` fortsatt 12/12, 10 egne + funksjonelle sjekker (kryss-org-isolasjon på BÅDE lesing og skriving, + `format_type`-CHECK+default, unik-constraints, `gross_strokes`-CHECK, + kaskade-sletting). + **Motor** (`handicap_engine.py`, ny seksjon rett etter + `allocate_over_played_holes`): `stroke_play_gross_total`/ + `stroke_play_net_total`/`stableford_points_for_hole`/`stableford_total` + -- rene funksjoner, ingen ny slagfordeling (bruker samme + `allocate_over_played_holes`-output som resten av motoren). 8 nye + tester, alle 63 (55 eksisterende + 8 nye) bestått i + `test_handicap_engine.py`. + **API** (nytt `app/routers/individual_tournaments.py`, registrert i + `main.py`): CRUD for runder/turnering-deltakere/rundedeltakere (med + handicap-beregning ved tilføyelse -- v1 har INGEN allowance-prosent for + individuelle turneringer, `playing_handicap` er alltid identisk med + avrundet `course_handicap`, ulikt lagturneringenes komplekse relative + `AllowanceStrategy`-familie, som er bygget for et to-siders oppgjør og + ikke gir mening for et flatt felt), hull-for-hull-scoring + (`PATCH .../holes/{n}`, cacher totalen på nytt ved hver innsending), + leaderboard som summerer på tvers av runder ved lesing. + `tournaments.py` sin `TournamentCreate`/`TournamentUpdate`/`Tournament` + utvidet med `format_type`/`scoring_method` (samme `exclude_unset`-PATCH- + mønster som resten av filen). + **To reelle funn, begge fikset FØR utrulling:** + 1. Leaderboard-endepunktet kunne IKKE hete + `/orgs/{id}/tournaments/{id}/leaderboard` -- den stien er allerede + `tournaments.py` sitt LAG-leaderboard, og siden `tournaments.router` + registreres FØR `individual_tournaments.router` i `main.py`, ville + det stille skygget for det nye endepunktet (funnet presist ved en + ekte API-test som krasjet på feil responsform). Løst med et eget + navn, `/individual-leaderboard` -- samme kollisjonsklasse som + `/rounds` vs. `/my-rounds` tidligere, denne gangen unngått fra start. + 2. `list_rounds`/`list_tournament_participants` manglet en eksplisitt + "finnes turneringen"-sjekk (samme mønster `list_sessions` allerede + har) -- ga stille en tom liste under RLS for en fremmed + turnering-id i stedet for 404 (ingen sikkerhetslekkasje, RLS + blokkerte fortsatt all faktisk data, men inkonsistent med resten av + API-et). Rettet til å matche `list_sessions` presist. + **Scratch-API-verifisert grundig, 52/52 sjekker** (isolert + `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs + API-container, ekte HTTP via `requests`, ekte magic-link-innlogging via + dev-log): full happy path fra org til leaderboard, hånd-utregnet netto- + kryssjekk for to spillere (course handicap 11/20, stemte eksakt), + `front_9`-runde avviser hull utenfor omfang, kryss-org-isolasjon + (bekreftet BÅDE lesing og en FK-basert skrivesperre), slette-vern (runde + MED deltakere avvist 409, turnering-deltaker fortsatt referert av en + rundedeltaker avvist 400 RESTRICT-FK, løst opp igjen etter fjerning). + `test_isolation.sql` 12/12 uendret (additiv migrasjon). + **IKKE bygget i denne runden, bevisst neste steg:** frontend (ingen + skjerm ennå -- samme lagdelings-rekkefølge som ADR-033/038/039). + Autorisasjon er bredt org-medlemskap for ALT i denne runden, inkl. selve + scoreregistreringen -- en senere innstramming (analogt ADR-023s + kaptein-only) er en naturlig, separat oppfølger. De fem konkrete + formatene (Københavner m.fl.) og Order of Merit fortsatt ikke designet. + **IKKE rullet ut mot ekte `teecup_db` ennå** -- venter på brukerens + eksplisitte bekreftelse. + Neste steg: 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet (2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 0e0b830..cee89fd 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -665,9 +665,86 @@ lenger ned i denne filen ("Frittstående runder: flere flighter..."). **Fortsatt IKKE avgjort:** de fem konkrete formatenes egne poengregler (punkt utenfor denne strukturrunden), og (3) Order of Merit — bekreftet -som naturlig SISTE steg, ikke designet. **Ingen migrasjon skrevet** — -ADR-037 er ren struktur-beslutning, neste steg er et konkret -migrasjonsutkast til gjennomgang. +som naturlig SISTE steg, ikke designet. + +### Oppdatering 2026-07-30: migrasjon + motor + API BYGGET OG SCRATCH-VERIFISERT, IKKE ENNÅ RULLET UT + +Migrasjon `040_individual_tournaments.sql` skrevet nøyaktig etter ADR-037s +fire beslutninger (`tournament.format_type`/`scoring_method`, +`tournament_round`, `tournament_participant`, `tournament_round_participant`, +`tournament_round_hole`, `tournament_round_score` — full RLS org-isolasjon +på alle fem nye tabellene). Scratch-verifisert alene FØR API-et ble bygget +(alle 40 migrasjoner kjørte rent i rekkefølge, `test_isolation.sql` 12/12, +10 egne funksjonelle sjekker: kryss-org-isolasjon på lesing OG skriving, +`format_type`-CHECK+default, unik-constraints, `gross_strokes`-CHECK, +kaskade-sletting). + +**Motor** (`handicap_engine.py`, ny seksjon rett etter +`allocate_over_played_holes`): `stroke_play_gross_total`/ +`stroke_play_net_total`/`stableford_points_for_hole`/`stableford_total` — +rene funksjoner, ingen ny fordelingslogikk (bruker samme +`allocate_over_played_holes`-output som resten av motoren). 8 nye tester i +`test_handicap_engine.py`, alle 63 (55 eksisterende + 8 nye) bestått. + +**API** (nytt `app/routers/individual_tournaments.py`, registrert i +`main.py`): CRUD for runder/turnering-deltakere/rundedeltakere (med +handicap-beregning ved tilføyelse -- v1 har INGEN allowance-prosent for +individuelle turneringer, `playing_handicap` er alltid identisk med +avrundet `course_handicap`, ulikt lagturneringenes +`AllowanceStrategy`-familie som er bygget for et relativt to-siders +oppgjør), hull-for-hull-scoring (`PATCH .../holes/{n}`, cacher +brutto/netto/Stableford-total på nytt ved hver innsending -- samme mønster +som `recompute_and_cache_match_state`), og et leaderboard som summerer +`tournament_round_score` PÅ TVERS AV RUNDER ved lesing (Beslutning C). +`tournaments.py` sin `TournamentCreate`/`TournamentUpdate`/`Tournament` +utvidet med `format_type`/`scoring_method` (samme `exclude_unset`-PATCH- +mønster som resten av filen). + +**Reelt funn under bygging, ikke antatt riktig:** leaderboard-endepunktet +kunne IKKE hete `/orgs/{id}/tournaments/{id}/leaderboard` -- den stien er +allerede `tournaments.py` sitt LAG-leaderboard (points_side_a/b, forventer +nøyaktig to lag), og siden `tournaments.router` registreres FØR +`individual_tournaments.router` i `main.py`, ville det eksisterende +endepunktet stille skygget for det nye (funnet presist ved en ekte +API-test som krasjet på `points_side_a`-formen den ikke fikk). Løst ved å +gi det et eget navn, `/individual-leaderboard` -- samme kollisjonsklasse +som `/rounds` vs. `/my-rounds` tidligere i prosjektet, denne gangen unngått +fra start i stedet for oppdaget i produksjon. + +**Reelt funn under selve scratch-API-testingen, fikset FØR utrulling:** +`list_rounds`/`list_tournament_participants` manglet en eksplisitt +"finnes turneringen"-sjekk (samme mønster `list_sessions` allerede har for +lagturneringer) -- ga stille en tom liste under RLS for en fremmed +turnering-id i stedet for 404. Ingen sikkerhetslekkasje (RLS blokkerte +fortsatt all faktisk data), men inkonsistent med resten av API-et. Begge +rettet til å matche `list_sessions` sin konvensjon presist. + +**Scratch-API-verifisert grundig, 52/52 sjekker** (isolert +`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs +API-container, ekte HTTP via `requests`, ekte magic-link-innlogging via +dev-log): full happy path (org→spillere→bane→18 hull→tee→individuell +turnering→runde→to turnering-deltakere→to rundedeltakere med riktig +beregnet course handicap→hull-for-hull-scoring→leaderboard), hånd- +utregnet netto-kryssjekk for begge spillere (course handicap 11/20, +stemte eksakt), `front_9`-runde avviser hull utenfor omfang, kryss-org- +isolasjon (bekreftet BÅDE at en fremmed org ikke ser dataene OG at en +FK-basert skriving på tvers av org blokkeres), slette-vern (runde MED +deltakere avvist 409, turnering-deltaker fortsatt referert av en +rundedeltaker avvist 400 RESTRICT-FK). `test_isolation.sql` 12/12 +uendret (additiv migrasjon). + +**IKKE bygget i denne runden, bevisst neste steg:** frontend (ingen +skjerm for å opprette individuelle turneringer/runder/deltakere/scoring +ennå) -- samme lagdelings-rekkefølge som ADR-033/ADR-038/ADR-039 (motor → +skjema → API → frontend). Autorisasjon er i denne runden bredt +org-medlemskap for ALT inkl. selve scoreregistreringen -- en senere +innstramming (analogt ADR-023s kaptein-only for lagturneringer) er en +naturlig, men separat oppfølger. De fem konkrete formatene (Københavner +m.fl.) og Order of Merit fortsatt ikke designet. + +**IKKE rullet ut mot ekte `teecup_db` ennå** -- venter på brukerens +eksplisitte bekreftelse (migrasjon 040 + redeploy av `teecup_api`), per +CLAUDE.md sin ufravikelige regel. --- diff --git a/__pycache__/handicap_engine.cpython-312.pyc b/__pycache__/handicap_engine.cpython-312.pyc index 3c04064901c7448d28ffb63aaf95fc00eda615de..9ca7fdf6650674c52055d85201f5076708278ce0 100644 GIT binary patch delta 2855 zcmaJDYiwK9`J8)gVvjP6KF|QrHmAZLeo0N$+>k*9osqg zI!!_{M^gm-0a~LYEde3c!Pr0%W2*OuKWtFY(8?rt83F>W29$|yu(fs96(&vlzH{?x zQ|83q`Of!#9{DF1=qG=po^u5SE&`v&KYHx3sorr*uPv%E1|$MQU~ z^p7#-0^SOyGdG-Gc8ul2>0<@V^Blcdo<+tlm%Dpd;h3YND37qH$WouErY8>`XLX>o zhQ(MtoTaRnZGp4wW_9bv;%qDM%5Td{ux-H8STf^O{y_8k*!SUD!??Jp7Bv~H4Q_SJ zWC!7_XDRj|oLg8w3&6RRonY;7Zd1rU!X6qU?2x`)&!4hkQ3&a{TeK;@s)vF8$dqEy zrmTZ>?0{qj76M{}?C_YqV<&2qcYwx`B^tZf5IYLq8c=3m5M(-+$TWi0#}`>0W+AY; z`xfd5dlaZmgJJ&q7R0XS1+(L%{sxNYpMod-oJ?ib;OXiw5HOq#~l%bZ{ zwBkBsq{);jt@O0sXgpY50R?pGb42{fC!@!zq znw1&%a4H6XHpVD@J#P6xAF_D-kQ-uEX@ z%e1>CYir>s(2c>*ee8h1o#xQj1R18o$`CvxBkJOM50haPsZ1FHskupqQmd(FfDkal zT`~Pwg0re*LXSr@K(+hY+vVWwOlx)80@-RwGR!w=8>_2oXE{loyP%v_tHS;L(c_!6 zND5}l)RHioHCm;m#}Zv#dQ9s|MD#&ThpB`yJ^K=mMI$=bwMdkMsPyIEEbVb9N}BL( z5G2f|+8ZA5+js@s7V0~rf@uSv2@Jp*)m6rPMxkUP8aIs)q!j8&fWt3A(v7vg3y@KA z*-6~R*;O0PyEcsO`2;4n?^MO4I_{qgk5^5*wX97`FD<$gRAtez*-J+oi1GtvhjvWO zw(C1)y?S=mmo#B+iPN`iargWpffEOO>#_a20cj-^M}P!Z&Cn$d&Ou$2c1%fK$vdB_z=AR<^8GxJ1Z0$|OqFT?dj1LRE9yH|2 zTqWu<$EW$elY36xdFq~PD#@!jUH>wDx%LBR)fE+Z-$=thTwO}}=b+?Qc@w%^*dZ+8 ze0K|P@fhQO2lfhd`8_~je=6OcE9mo5$(qUb(~a4>d#6kGT(biuN7Q`BWun?0va90V z)>isE;XTmk{u(_ZC|AVM17-AU@vHVNnb!{dTJilGSvdsnBKQ}A8)D#rM`%^%`~wOt zQ=uii49U2kat8TT#1O0#SK4amRpF^9$?Q7VpwRtdAW*eY+Pj8A(w_8j1Flf|ZlvO? zY5BVZZT|KcNzVjWv=-vH>(GY1*PAOiYc z_eSKw6cc`3yr4fvH;JZWzeryP0%)H_x|H}4VBhA&qK0_{Ryyc+@N>xXB6#m6DP9E@ z{mw=F59awF&SPlLlC)W)Htz*(Ruga_Cs2~{d0hxYIt2$6NRstOX=}${YYCZJgD;^FGS%d(X$jrHFzBD z-C@oLL91N674cI|d=&|l%hFe)ysUo>Vp6FHFn_b??D+zInCy?1rr$t&Zz6aH0sbW6 zA0xPk;1U9S804y1k;;XUQ;q{D=Z)ih$#LS?dybvHWiP&UHuNS~DyHw|+kp%J#Q4lc iGVeumYEcfhqb%oA=sucr+3DI`p`ALe6^l1}Fa8hsin*fz delta 1277 zcmZuvUrbYH6u;-Tx6lHWII1AhYL-a4MT>yQA5r6<86;Vx43JXnww?+Fyp-Fb*&fuG z#>B+Lkn><-j6S%;hwg=ZFVhGAg=nTRdzjf4#z@dHlRa#U+2&+%+c~$J6Y<`g-}(1D zzw>?f`(}QGi{F9wUP*~Z<7eyj!J*>=6W(&TI#G>qVB&OTTMXS>(0d*h^D#d0OCW$)nsr51x;0EJE=z<5X#TS zSkKnKl+PVQxQT0l*Vcxyfop4UL>21y!F>qd<*)`#nsuxUjN(3yYISXoh8W+qyFIGOD?0J#2*?>WkQ)mB0io9se{!X(vf?}F><Cs=Y$99={t4K!UxO>8rEH^Z6APXI@i9F zzUf0_I{7)p~ z7pN4d5SaT99Q0>*jXtN*Y_weM?%AS4HJ#{P|F$joNLaR@J>)TmL8ld$Y_otpaE_*X z*ZN+|r_#t<#vjb|t8I~=0Cv)bXxg(NtpY8Si*D2xG&LJt0S17kwW(+QM*!w&EY|38 zaKaMkBozyRLp_Sc96qOH4S@oILV;`46F&>1^mDw~^|`1!D1_lRZPp;i(~eXUtsUYO z97&pH!b<?gs7%Y6L{?&{6 z*DpNL%QL}hVxI+nXY+AcXJTwTLC+4AmOmAWJ-{Z<y% TournamentRound: + async with org_connection(organization_id) as conn, translate_db_errors(): + tournament = await conn.fetchrow( + "SELECT format_type FROM tournament WHERE id = $1", tournament_id + ) + if tournament is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + if tournament["format_type"] != "individual": + raise app_error( + 400, "VALIDATION_FAILED", "Runder kan kun opprettes for individuelle turneringer." + ) + row = await conn.fetchrow( + f""" + WITH inserted AS ( + INSERT INTO tournament_round + (organization_id, tournament_id, sequence, name, hole_config, + course_id, scheduled_at, tee_interval_minutes, start_hole) + VALUES ($1, $2, $3, $4, $5::hole_scope, $6, $7, $8, $9) + RETURNING * + ) + SELECT {_ROUND_COLUMNS} FROM inserted tr JOIN course c ON c.id = tr.course_id + """, + organization_id, + tournament_id, + body.sequence, + body.name, + body.hole_config, + body.course_id, + body.scheduled_at, + body.tee_interval_minutes, + body.start_hole, + ) + return TournamentRound(**dict(row)) + + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds", + response_model=list[TournamentRound], +) +async def list_rounds( + tournament_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[TournamentRound]: + async with org_connection(organization_id) as conn: + exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) + if exists is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + rows = await conn.fetch( + f""" + SELECT {_ROUND_COLUMNS} FROM tournament_round tr + JOIN course c ON c.id = tr.course_id + WHERE tr.tournament_id = $1 + ORDER BY tr.sequence + """, + tournament_id, + ) + return [TournamentRound(**dict(r)) for r in rows] + + +@router.delete("/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}", status_code=204) +async def delete_round( + tournament_id: str, + round_id: str, + organization_id: str = Depends(get_authorized_org), +) -> None: + async with org_connection(organization_id) as conn: + has_participants = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM tournament_round_participant WHERE tournament_round_id = $1)", + round_id, + ) + if has_participants: + raise app_error( + 409, + "NOT_EMPTY", + "Runden har allerede deltakere -- fjern dem først, eller la runden stå.", + ) + deleted = await conn.fetchval( + "DELETE FROM tournament_round WHERE id = $1 AND tournament_id = $2 RETURNING id", + round_id, + tournament_id, + ) + if deleted is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + + +# ===================================================================== +# Turnering-deltakere (tournament_participant -- flatt felt, intet lag) +# ===================================================================== + +class TournamentParticipantCreate(BaseModel): + player_id: str + + +class TournamentParticipantOut(BaseModel): + id: str + player_id: str + player_name: str + handicap_index_snapshot: float | None + + +@router.post( + "/orgs/{organization_id}/tournaments/{tournament_id}/participants", + response_model=TournamentParticipantOut, + status_code=201, +) +async def add_tournament_participant( + tournament_id: str, + body: TournamentParticipantCreate, + organization_id: str = Depends(get_authorized_org), +) -> TournamentParticipantOut: + async with org_connection(organization_id) as conn, translate_db_errors(): + player = await conn.fetchrow( + "SELECT display_name, handicap_index FROM player WHERE id = $1", body.player_id + ) + if player is None: + raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.") + row = await conn.fetchrow( + """ + INSERT INTO tournament_participant (organization_id, tournament_id, player_id, handicap_index_snapshot) + VALUES ($1, $2, $3, $4) + RETURNING id::text + """, + organization_id, + tournament_id, + body.player_id, + player["handicap_index"], + ) + return TournamentParticipantOut( + id=row["id"], + player_id=body.player_id, + player_name=player["display_name"], + handicap_index_snapshot=player["handicap_index"], + ) + + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/participants", + response_model=list[TournamentParticipantOut], +) +async def list_tournament_participants( + tournament_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[TournamentParticipantOut]: + async with org_connection(organization_id) as conn: + exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) + if exists is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + rows = await conn.fetch( + """ + SELECT tp.id::text AS id, tp.player_id::text AS player_id, + p.display_name AS player_name, + tp.handicap_index_snapshot::float AS handicap_index_snapshot + FROM tournament_participant tp + JOIN player p ON p.id = tp.player_id + WHERE tp.tournament_id = $1 + ORDER BY p.display_name + """, + tournament_id, + ) + return [TournamentParticipantOut(**dict(r)) for r in rows] + + +@router.delete( + "/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}", + status_code=204, +) +async def remove_tournament_participant( + tournament_id: str, + participant_id: str, + organization_id: str = Depends(get_authorized_org), +) -> None: + async with org_connection(organization_id) as conn, translate_db_errors(): + deleted = await conn.fetchval( + "DELETE FROM tournament_participant WHERE id = $1 AND tournament_id = $2 RETURNING id", + participant_id, + tournament_id, + ) + if deleted is None: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.") + + +# ===================================================================== +# Deltaker PÅ ÉN RUNDE (tee + cachet handicap for akkurat den runden) +# ===================================================================== + +class RoundParticipantCreate(BaseModel): + tournament_participant_id: str + tee_id: str + + +class RoundParticipantOut(BaseModel): + id: str + tournament_participant_id: str + player_name: str + tee_id: str + tee_name: str + course_handicap: int | None + playing_handicap: int | None + + +async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None: + """Regner course_handicap/playing_handicap for ÉN rundedeltaker. + + v1 har ingen allowance-prosent for individuelle turneringer (ulikt + lagturneringenes AllowanceStrategy-familie, som er bygget for et + relativt to-siders oppgjør -- ikke relevant her) -- playing_handicap er + derfor alltid identisk med (avrundet) course_handicap. Setter INGENTING + hvis spilleren mangler handicap_index_snapshot eller en matchende + tee_rating -- kalleren har allerede validert dette FØR innsetting når + scoring_method krever det (se add_round_participant). + """ + row = await conn.fetchrow( + """ + SELECT tp.handicap_index_snapshot::float AS handicap_index_snapshot, + rating.course_rating::float AS course_rating, + rating.slope_rating::float AS slope_rating, + rating.par AS par + FROM tournament_round_participant trp + JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id + JOIN player p ON p.id = tp.player_id + JOIN tee_rating rating ON rating.tee_id = trp.tee_id + AND rating.scope = 'full_18' + AND rating.gender = p.gender + WHERE trp.id = $1 + """, + round_participant_id, + ) + if row is None or row["handicap_index_snapshot"] is None: + return + ch = round_half_up( + course_handicap_raw( + row["handicap_index_snapshot"], row["slope_rating"], row["course_rating"], row["par"] + ) + ) + await conn.execute( + "UPDATE tournament_round_participant SET course_handicap = $1, playing_handicap = $1 WHERE id = $2", + ch, + round_participant_id, + ) + + +@router.post( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants", + response_model=RoundParticipantOut, + status_code=201, +) +async def add_round_participant( + tournament_id: str, + round_id: str, + body: RoundParticipantCreate, + organization_id: str = Depends(get_authorized_org), +) -> RoundParticipantOut: + async with org_connection(organization_id) as conn, translate_db_errors(): + round_row = await conn.fetchrow( + """ + SELECT tr.course_id::text AS course_id, t.scoring_method + FROM tournament_round tr + JOIN tournament t ON t.id = tr.tournament_id + WHERE tr.id = $1 AND tr.tournament_id = $2 + """, + round_id, + tournament_id, + ) + if round_row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + + tee_course_id = await conn.fetchval("SELECT course_id::text FROM tee WHERE id = $1", body.tee_id) + if tee_course_id is None or tee_course_id != round_row["course_id"]: + raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke rundens bane.") + + requires_handicap = round_row["scoring_method"] in _SCORING_METHODS_REQUIRING_HANDICAP + if requires_handicap: + player_row = await conn.fetchrow( + """ + SELECT tp.handicap_index_snapshot, p.gender + FROM tournament_participant tp + JOIN player p ON p.id = tp.player_id + WHERE tp.id = $1 + """, + body.tournament_participant_id, + ) + if player_row is None: + raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.") + if player_row["handicap_index_snapshot"] is None: + raise app_error( + 400, + "VALIDATION_FAILED", + "Denne spilleren mangler handicap-indeks -- påkrevd for " + "nettoslagspill/Stableford.", + ) + if player_row["gender"] is None: + raise app_error( + 400, + "VALIDATION_FAILED", + "Denne spilleren mangler registrert kjønn -- trengs for å finne riktig tee-rating.", + ) + has_rating = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)", + body.tee_id, + player_row["gender"], + ) + if not has_rating: + kjonn_tekst = "dame" if player_row["gender"] == "f" else "herre" + raise app_error( + 400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet." + ) + + row = await conn.fetchrow( + """ + WITH inserted AS ( + INSERT INTO tournament_round_participant + (organization_id, tournament_round_id, tournament_participant_id, tee_id) + VALUES ($1, $2, $3, $4) + RETURNING id + ) + SELECT inserted.id::text AS id + FROM inserted + """, + organization_id, + round_id, + body.tournament_participant_id, + body.tee_id, + ) + round_participant_id = row["id"] + await _compute_round_participant_handicap(conn, round_participant_id) + + out = await conn.fetchrow( + """ + SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, + p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name, + trp.course_handicap, trp.playing_handicap + FROM tournament_round_participant trp + JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id + JOIN player p ON p.id = tp.player_id + JOIN tee ON tee.id = trp.tee_id + WHERE trp.id = $1 + """, + round_participant_id, + ) + return RoundParticipantOut(**dict(out)) + + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants", + response_model=list[RoundParticipantOut], +) +async def list_round_participants( + tournament_id: str, + round_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[RoundParticipantOut]: + async with org_connection(organization_id) as conn: + rows = await conn.fetch( + """ + SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, + p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name, + trp.course_handicap, trp.playing_handicap + FROM tournament_round_participant trp + JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id + JOIN player p ON p.id = tp.player_id + JOIN tee ON tee.id = trp.tee_id + WHERE trp.tournament_round_id = $1 + ORDER BY p.display_name + """, + round_id, + ) + return [RoundParticipantOut(**dict(r)) for r in rows] + + +@router.delete( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants/{round_participant_id}", + status_code=204, +) +async def remove_round_participant( + tournament_id: str, + round_id: str, + round_participant_id: str, + organization_id: str = Depends(get_authorized_org), +) -> None: + async with org_connection(organization_id) as conn: + deleted = await conn.fetchval( + "DELETE FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2 RETURNING id", + round_participant_id, + round_id, + ) + if deleted is None: + raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") + + +# ===================================================================== +# Hull-for-hull-score (tournament_round_hole -- kilde-sannhet) +# ===================================================================== + +class HoleUpdate(BaseModel): + gross_strokes: int = Field(ge=1, le=20) + + +class RoundHoleOut(BaseModel): + hole_number: int + par: int + stroke_index: int + gross_strokes: int | None + strokes_received: int | None + + +async def _recompute_round_score(conn, round_id: str, round_participant_id: str, organization_id: str) -> None: + """Regner ferdig totalsum (brutto/netto/Stableford) på nytt for ÉN + deltaker i ÉN runde, og cacher resultatet -- kalt etter HVER + hull-innsending (samme mønster som recompute_and_cache_match_state). + """ + ctx = await conn.fetchrow( + """ + SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, + trp.tournament_participant_id::text AS tournament_participant_id, + trp.playing_handicap + FROM tournament_round_participant trp + JOIN tournament_round tr ON tr.id = trp.tournament_round_id + WHERE trp.id = $1 + """, + round_participant_id, + ) + holes = await conn.fetch( + """ + SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes + FROM hole h + LEFT JOIN tournament_round_hole trh + ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number + WHERE h.course_id = $2 + ORDER BY h.hole_number + """, + round_participant_id, + ctx["course_id"], + ) + played = [h for h in holes if h["gross_strokes"] is not None] + holes_played = len(played) + gross_total = stroke_play_gross_total([h["gross_strokes"] for h in played]) if played else None + + net_total = None + stableford_points = None + if played and ctx["playing_handicap"] is not None: + all_18_si = [h["stroke_index"] for h in holes] + played_numbers = [h["hole_number"] for h in played] + received = allocate_over_played_holes(ctx["playing_handicap"], all_18_si, played_numbers) + gross_list = [h["gross_strokes"] for h in played] + pars = [h["par"] for h in played] + net_total = stroke_play_net_total(gross_list, received) + stableford_points = stableford_total(pars, gross_list, received) + + await conn.execute( + """ + INSERT INTO tournament_round_score + (organization_id, tournament_round_id, tournament_participant_id, + holes_played, gross_total, net_total, stableford_points, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET + holes_played = $4, gross_total = $5, net_total = $6, + stableford_points = $7, updated_at = now() + """, + organization_id, + round_id, + ctx["tournament_participant_id"], + holes_played, + gross_total, + net_total, + stableford_points, + ) + + +@router.patch( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" + "/participants/{round_participant_id}/holes/{hole_number}", + response_model=RoundHoleOut, +) +async def update_hole( + tournament_id: str, + round_id: str, + round_participant_id: str, + hole_number: int, + body: HoleUpdate, + organization_id: str = Depends(get_authorized_org), +) -> RoundHoleOut: + async with org_connection(organization_id) as conn, translate_db_errors(): + round_row = await conn.fetchrow( + "SELECT course_id::text AS course_id, hole_config::text AS hole_config FROM tournament_round WHERE id = $1", + round_id, + ) + if round_row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + if hole_number not in played_hole_numbers(round_row["hole_config"]): + raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.") + + rp = await conn.fetchval( + "SELECT id FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2", + round_participant_id, + round_id, + ) + if rp is None: + raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") + + hole = await conn.fetchrow( + "SELECT par, stroke_index FROM hole WHERE course_id = $1 AND hole_number = $2", + round_row["course_id"], + hole_number, + ) + if hole is None: + raise app_error( + 400, "VALIDATION_FAILED", "Banen mangler hull-data for dette hullnummeret." + ) + + await conn.execute( + """ + INSERT INTO tournament_round_hole + (organization_id, tournament_round_participant_id, hole_number, gross_strokes, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET + gross_strokes = $4, updated_at = now() + """, + organization_id, + round_participant_id, + hole_number, + body.gross_strokes, + ) + await _recompute_round_score(conn, round_id, round_participant_id, organization_id) + + strokes_received = None + playing_handicap = await conn.fetchval( + "SELECT playing_handicap FROM tournament_round_participant WHERE id = $1", round_participant_id + ) + if playing_handicap is not None: + all_18 = await conn.fetch( + "SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", + round_row["course_id"], + ) + allocation = allocate_over_played_holes( + playing_handicap, [h["stroke_index"] for h in all_18], [hole_number] + ) + strokes_received = allocation[0] + + return RoundHoleOut( + hole_number=hole_number, + par=hole["par"], + stroke_index=hole["stroke_index"], + gross_strokes=body.gross_strokes, + strokes_received=strokes_received, + ) + + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" + "/participants/{round_participant_id}/holes", + response_model=list[RoundHoleOut], +) +async def list_round_participant_holes( + tournament_id: str, + round_id: str, + round_participant_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[RoundHoleOut]: + async with org_connection(organization_id) as conn: + round_row = await conn.fetchrow( + "SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1", round_id + ) + if round_row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + playing_handicap = await conn.fetchval( + "SELECT playing_handicap FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2", + round_participant_id, + round_id, + ) + holes = await conn.fetch( + """ + SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes + FROM hole h + LEFT JOIN tournament_round_hole trh + ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number + WHERE h.course_id = $2 + ORDER BY h.hole_number + """, + round_participant_id, + round_row["course_id"], + ) + allocation = ( + allocate_over_played_holes( + playing_handicap, [h["stroke_index"] for h in holes], [h["hole_number"] for h in holes] + ) + if playing_handicap is not None + else [None] * len(holes) + ) + return [ + RoundHoleOut( + hole_number=h["hole_number"], + par=h["par"], + stroke_index=h["stroke_index"], + gross_strokes=h["gross_strokes"], + strokes_received=a, + ) + for h, a in zip(holes, allocation) + ] + + +# ===================================================================== +# Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C) +# ===================================================================== + +class LeaderboardEntry(BaseModel): + tournament_participant_id: str + player_name: str + rounds_played: int + gross_total: int | None + net_total: int | None + stableford_total: int | None + + +@router.get( + # IKKE /leaderboard -- den stien er allerede tournaments.py sitt + # LAG-leaderboard (points_side_a/b, forutsetter nøyaktig to lag). Samme + # klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått + # her ved å gi den et eget, ikke-overlappende navn fra start. + "/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard", + response_model=list[LeaderboardEntry], +) +async def individual_leaderboard( + tournament_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[LeaderboardEntry]: + async with org_connection(organization_id) as conn: + tournament = await conn.fetchrow( + "SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id + ) + if tournament is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + if tournament["format_type"] != "individual": + raise app_error(400, "VALIDATION_FAILED", "Kun individuelle turneringer har dette leaderboardet.") + + rows = await conn.fetch( + """ + SELECT tp.id::text AS tournament_participant_id, p.display_name AS player_name, + count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played, + SUM(trs.gross_total)::int AS gross_total, + SUM(trs.net_total)::int AS net_total, + SUM(trs.stableford_points)::int AS stableford_total + FROM tournament_participant tp + JOIN player p ON p.id = tp.player_id + LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id + WHERE tp.tournament_id = $1 + GROUP BY tp.id, p.display_name + """, + tournament_id, + ) + entries = [LeaderboardEntry(**dict(r)) for r in rows] + + method = tournament["scoring_method"] + if method == "stableford": + entries.sort(key=lambda e: (e.stableford_total is None, -(e.stableford_total or 0))) + elif method == "stroke_net": + entries.sort(key=lambda e: (e.net_total is None, e.net_total or 0)) + else: + entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0)) + return entries diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index a222a14..507af73 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -41,13 +41,19 @@ class TournamentCreate(BaseModel): name: str = Field(min_length=1, max_length=200) start_date: date | None = None end_date: date | None = None + # ADR-037: 'team' (dagens Ryder Cup-modell, uendret default) eller + # 'individual' (flatt felt, egen tabellstruktur -- se + # 040_individual_tournaments.sql). scoring_method er kun meningsfullt + # for individual, håndhevet i app-laget når runder/score bygges på den. + format_type: str = Field(default="team", pattern="^(team|individual)$") + scoring_method: str | None = Field(default=None, pattern="^(stroke_gross|stroke_net|stableford)$") _TOURNAMENT_COLUMNS = """ id::text, name, status::text, start_date, end_date, visibility, description, registration_deadline, registration_capacity, registration_overflow_policy, registration_requires_approval, hero_image_key, - join_code + join_code, format_type, scoring_method """ # Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A). @@ -72,6 +78,8 @@ class Tournament(BaseModel): registration_requires_approval: bool hero_image_key: str | None join_code: str + format_type: str + scoring_method: str | None @router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament]) @@ -124,8 +132,8 @@ async def create_tournament( try: row = await conn.fetchrow( f""" - INSERT INTO tournament (organization_id, name, start_date, end_date, join_code) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO tournament (organization_id, name, start_date, end_date, join_code, format_type, scoring_method) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING {_TOURNAMENT_COLUMNS} """, organization_id, @@ -133,6 +141,8 @@ async def create_tournament( body.start_date, body.end_date, _generate_join_code(), + body.format_type, + body.scoring_method, ) break except asyncpg.UniqueViolationError: @@ -148,6 +158,8 @@ class TournamentUpdate(BaseModel): status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$") visibility: str | None = Field(default=None, pattern="^(public|org|participants)$") + format_type: str | None = Field(default=None, pattern="^(team|individual)$") + scoring_method: str | None = Field(default=None, pattern="^(stroke_gross|stroke_net|stableford)$") description: str | None = None registration_deadline: datetime | None = None registration_capacity: int | None = Field(default=None, gt=0) diff --git a/handicap_engine.py b/handicap_engine.py index b2a671e..b2e510b 100644 --- a/handicap_engine.py +++ b/handicap_engine.py @@ -502,6 +502,40 @@ def allocate_over_played_holes( return [full[hole - 1] for hole in played_hole_numbers] +# --------------------------------------------------------------------------- +# Individuelle scoringsmetoder (ADR-037): bruttoslagspill/nettoslagspill/ +# Stableford for et flatt felt av spillere, ingen motpart/side involvert. +# `strokes_received` her kommer ALLTID fra allocate_strokes_by_index/ +# allocate_over_played_holes over -- disse funksjonene fordeler ingen slag +# selv, de bare summerer et allerede fordelt resultat. +# --------------------------------------------------------------------------- + +def stroke_play_gross_total(gross_strokes: Sequence[int]) -> int: + """Sum av rå bruttoslag over de spilte hullene. Ingen handicap involvert.""" + return sum(gross_strokes) + + +def stroke_play_net_total(gross_strokes: Sequence[int], strokes_received: Sequence[int]) -> int: + """Sum av netto slag (brutto minus mottatte slag) over de spilte hullene.""" + return sum(g - s for g, s in zip(gross_strokes, strokes_received)) + + +def stableford_points_for_hole(par: int, gross_strokes: int, strokes_received: int) -> int: + """Stableford-poeng for ETT hull: 2 poeng for netto par, +/-1 poeng per + slag avvik, gulvet på 0 (netto dobbel bogey eller dårligere gir 0 poeng). + """ + net = gross_strokes - strokes_received + return max(0, par - net + 2) + + +def stableford_total(pars: Sequence[int], gross_strokes: Sequence[int], strokes_received: Sequence[int]) -> int: + """Sum av Stableford-poeng over de spilte hullene.""" + return sum( + stableford_points_for_hole(par, g, s) + for par, g, s in zip(pars, gross_strokes, strokes_received) + ) + + # --------------------------------------------------------------------------- # Match-status ("2 UP", "dormie", "3&2", "AS") # --------------------------------------------------------------------------- diff --git a/test_handicap_engine.py b/test_handicap_engine.py index 66e095c..76c1590 100644 --- a/test_handicap_engine.py +++ b/test_handicap_engine.py @@ -40,6 +40,10 @@ from handicap_engine import ( 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, ) @@ -618,6 +622,55 @@ def test_skins_detail_empty_hole_logged_but_inert(): assert log[1].awarded == {"a": 1.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) + ) + + # --------------------------------------------------------------------------- # Enkel selvsjekk uten pytest # ---------------------------------------------------------------------------