Legg til Jakter Jesper som visningsmodus for netto-scoring (ADR-106)

Bruker beskrev en turneringsform (start på -spillehandicap, brutto mot
par løpende, lavest sluttsum vinner) og spurte om dette egentlig var en
presentasjonsform fremfor en ny turneringsform. Riktig observasjon:
sluttsummen er matematisk identisk med stroke_net sitt netto-til-par.

Ny tournament.net_display_style ('standard'/'hunt', migrasjon 092), kun
gyldig sammen med scoring_method='stroke_net'. _attach_stroke_play_columns
fikk én stroke_val()-hjelper som bytter net_total (gradvis slagindeks-
basert) med gross_total - playing_handicap (hele handicapet trukket fra
med én gang) når hunt er aktiv -- ingen ny motorfunksjon. Ny, generelt
tilgjengelig tiebreak-verdi lowest_playing_hcp.

5 nye pytest-tester + full regresjon (198/198), tsc rent, full-stack
scratch-verifisert (hånd-utregnede tall matchet eksakt, offentlig
følg-live + utskrift arvet visningen uten kodeendring der).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-24 08:46:35 +02:00
parent c8bfe96eb6
commit 7614c4b83a
8 changed files with 584 additions and 10 deletions

36
092_net_hunt_display.sql Normal file
View file

@ -0,0 +1,36 @@
-- =====================================================================
-- TeeCup — migrasjon 092
-- Visningsmodus for netto-scoring ("Jakter Jesper"): start på
-- -spillehandicap, brutto-mot-par løpende -- IKKE en ny scoring_method,
-- se ADR (neste ledige nummer) for hele resonnementet. Pluss en ny,
-- generelt tilgjengelig tiebreak-verdi (lavest spillehandicap).
-- =====================================================================
-- `net_display_style`: 'standard' (uendret oppførsel -- dagens
-- gradvise, slagindeks-baserte netto-visning, default, ingen regresjon
-- for eksisterende turneringer) eller 'hunt' (hele spillehandicapet
-- trukket fra med én gang, brutto-til-par-differansen vist løpende).
-- Kun meningsfullt for scoring_method='stroke_net' -- håndheves i
-- app-laget (samme presedens som _SCORING_METHODS_REQUIRING_HANDICAP),
-- ikke en tverr-kolonne CHECK her.
--
-- `lowest_playing_hcp`: ny tiebreak-verdi (utvider migrasjon 085/086
-- sitt mønster) -- spilleren med lavest SPILLEHANDICAP (tee-spesifikt
-- tall, IKKE WHS-indeksen `lowest_hcp` bruker) i turneringens siste
-- runde vinner uavgjortheten. Generelt tilgjengelig for alle
-- stroke-play-turneringer, ikke bare hunt-visningen -- tiebreak er
-- allerede uavhengig av scoring_method.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament
ADD COLUMN net_display_style text NOT NULL DEFAULT 'standard'
CHECK (net_display_style IN ('standard', 'hunt'));
ALTER TABLE tournament
DROP CONSTRAINT tournament_tiebreak_winner_method_check,
ADD CONSTRAINT tournament_tiebreak_winner_method_check
CHECK (tiebreak_winner_method IN ('none', 'countback', 'lowest_hcp', 'playoff', 'lowest_playing_hcp')),
DROP CONSTRAINT tournament_tiebreak_field_method_check,
ADD CONSTRAINT tournament_tiebreak_field_method_check
CHECK (tiebreak_field_method IN ('none', 'countback', 'lowest_hcp', 'lowest_playing_hcp'));

View file

@ -10616,6 +10616,89 @@ Alle scratch-ressurser (db, rolle, containere, images) ryddet opp.
--- ---
## ADR-106: "Jakter Jesper" er en visningsmodus for netto-scoring, ikke en ny turneringsform (migrasjon 092, 2026-08-23)
**Kontekst:** bruker beskrev en turneringsform: start på -spillehandicap,
hullscore måles brutto mot par gjennom runden, lavest sluttsum vinner,
tiebreak på lavest spillehandicap ved likt resultat. "Kun interessant
dersom man har et leaderboard å forholde seg til."
**Beslutning A -- IKKE en ny scoring_method, en visningsmodus på
eksisterende `stroke_net`.** Sluttsummen er matematisk identisk med
`stroke_net` sitt allerede eksisterende netto-til-par
(`net_total - par_played`): `-handicap + Σ(brutto-par) = (brutto-
handicap)-par = netto-til-par`. Det eneste som faktisk er nytt er
hvordan tallet vises LØPENDE midt i runden -- dagens netto "tjener inn"
handicap-slag gradvis per slagindeks (`allocate_over_played_holes`,
partial allocation basert på hvilke hull som faktisk er spilt), mens
Jakter Jesper trekker fra HELE spillehandicapet med én gang og viser
brutto-til-par-differansen løpende. Vurdert og avvist som egen
scoring_method (ville krevd berøring av ~15 filer -- CHECK-constraint,
to Pydantic-regex-steder, dispatch-gren, cut-gate, frontend-labelkart --
for null reell ny beregningslogikk).
**Beslutning B -- ny kolonne `tournament.net_display_style`
('standard'/'hunt', default 'standard'), KUN gyldig sammen med
scoring_method='stroke_net'** (fail-loudly 400 begge veier: kan ikke
settes til 'hunt' på en annen metode, OG kan ikke bytte bort fra
stroke_net mens 'hunt' står -- se `update_tournament` i
`app/routers/tournaments.py`). `_attach_stroke_play_columns`
(`individual_tournaments.py`) fikk en `stroke_val(row)`-hjelper som
bytter ut `row["net_total"]` med `row["gross_total"] -
row["playing_handicap"]` når hunt er aktiv -- ett sted, ingen duplisert
beregningslogikk. `playing_handicap` hentes nå per (runde, deltaker) i
samme spørring (ny JOIN mot `tournament_round_participant`) siden den
kan variere per runde/tee i en flerrunde-turnering.
**Beslutning C -- ny tiebreak-verdi `lowest_playing_hcp`, GENERELT
tilgjengelig (ikke bundet til hunt-visningen).** Bruker presiserte
"spillehandicap" (det tee-spesifikke tallet som faktisk ga
startverdien), ikke WHS-indeksen den eksisterende `lowest_hcp`-
tiebreaken bruker (`tournament_participant.handicap_index_snapshot`).
Ny `_lowest_playing_hcp_sort_keys` leser
`tournament_round_participant.playing_handicap` for turneringens
ABSOLUTT SISTE runde (samme presedens som `_countback_sort_keys`).
Tiebreak forblir organisator-valgfritt som i dag (bruker bekreftet
eksplisitt: IKKE tvunget på for formatet, samme fleksible to-
uavhengige-innstillinger-mønster som `countback`/`lowest_hcp`/
`playoff`).
**Beslutning D -- omfang: kun org-individuelle turneringer denne
runden.** Bruker bekreftet sekvensiell bygging -- frittstående runder
(`round.play_format`) og en kolonne på lagturnering-øktens individuelle
leaderboard (`fetch_individual_leaderboard`) er bevisst UTENFOR,
egne senere oppfølgingsrunder. Org-LAG-turneringer (Ryder Cup-
modellen) har strukturelt ikke noe `scoring_method`-begrep i det hele
tatt (ADR-011) -- kan aldri hoste denne visningsmodusen direkte, kun
potensielt som en tilleggskolonne på den separate økt-leaderboard-
mekanismen.
**Status:** 5 nye pytest-tester (løpende tall skiller seg fra standard
midt i runden men konvergerer nøyaktig ved 18 fullførte hull -- selve
beviset på ekvivalensen; validering begge veier; `lowest_playing_hcp`
løser en reell uavgjort; manglende spillehandicap sorteres sist) + full
regresjonssuite, 198/198 grønt. `tsc --noEmit` rent.
Full-stack scratch-verifisert (egen db + to one-off-containere, ekte
innlogging inkl. et ekte 2FA-e-post-oppsett for org-eier-tilgang): en
stroke_net-turnering med to spillere (spillehandicap 12/4), hunt PÅ --
leaderboardet viste -12/-2 (hånd-utregnet på forhånd, matchet eksakt).
Slått AV live -- tallene endret seg umiddelbart til standard-visningens
-5/-2 (samme underliggende data, kun presentasjonen endret), ingen
sideoppfriskning nødvendig. Bekreftet at BÅDE den offentlige "Følg
live"-siden OG utskrifts-resultatlisten viste hunt-tallene korrekt UTEN
noen kodeendring der -- beviser at de kun konsumerer `total_label`/
`position` som opake strenger, akkurat som antatt i planen. Mørk modus
bekreftet lesbart. Alle scratch-ressurser ryddet opp.
**Ikke rullet ut ennå** -- venter på brukerens bekreftelse før migrasjon
092 kjøres mot ekte `teecup_db` (additiv, ingen eksisterende data
berøres -- default 'standard'/uendret tiebreak-oppførsel for alle
eksisterende turneringer, men skal likevel bekreftes eksplisitt per
standard sikkerhetsregel).
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -14703,3 +14703,41 @@ Neste steg:
også `teecup_api` som vanlig bivirkning, ingen backend-kode rørt). også `teecup_api` som vanlig bivirkning, ingen backend-kode rørt).
Begge containere friske, rene logger. `https://teecup.golf/health` Begge containere friske, rene logger. `https://teecup.golf/health`
200 etter utrulling. 200 etter utrulling.
166. **"Jakter Jesper" bygget som visningsmodus for netto-scoring, ikke
en ny turneringsform (ADR-106), 2026-08-23.** Bruker beskrev en
turneringsform (start på -spillehandicap, brutto mot par løpende,
lavest sluttsum vinner, tiebreak på lavest spillehandicap) og spurte
om det egentlig var en ny presentasjonsform fremfor en ny
turneringsform -- riktig observasjon: sluttsummen er matematisk
identisk med `stroke_net` sitt netto-til-par.
**Ny kolonne `tournament.net_display_style`** ('standard'/'hunt',
migrasjon 092), kun gyldig sammen med `scoring_method='stroke_net'`
(fail-loudly 400 begge veier). `_attach_stroke_play_columns` fikk én
`stroke_val()`-hjelper som bytter `net_total` (gradvis slagindeks-
basert) med `gross_total - playing_handicap` (hele handicapet trukket
fra med én gang) når hunt er aktiv -- ingen ny motorfunksjon, ingen
duplisert beregning. Ny, GENERELT tilgjengelig tiebreak-verdi
`lowest_playing_hcp` (spillehandicap, ikke WHS-indeksen `lowest_hcp`
bruker) -- organisator-valgfri som de andre, ikke tvunget på for
formatet (brukerbekreftet). `LeaderboardRow`-kontrakten (ADR-101)
RØRT IKKE -- alle felt var allerede opake, ferdig-formaterte strenger.
**Verifisert:** 5 nye pytest-tester (løpende tall skiller seg fra
standard midt i runden, konvergerer eksakt ved 18 fullførte hull;
validering begge veier; `lowest_playing_hcp` løser en reell
uavgjort; manglende spillehandicap sorteres sist) + full
regresjonssuite, 198/198 grønt. `tsc --noEmit` rent. Full-stack
scratch (egen db + to one-off-containere, ekte innlogging inkl. et
ekte 2FA-e-post-oppsett): stroke_net-turnering med to spillere
(spillehandicap 12/4), hunt PÅ -- leaderboardet viste -12/-2,
hånd-utregnet på forhånd og matchet eksakt. Slått av live -- tallene
endret seg umiddelbart til standard-visningens -5/-2 uten
sideoppfriskning. Offentlig "Følg live"-side OG utskrifts-
resultatlisten viste hunt-tallene korrekt UTEN kodeendring der,
bekrefter at de kun konsumerer opake strenger. Mørk modus bekreftet
lesbart. Alle scratch-ressurser ryddet opp.
**Ikke rullet ut ennå** -- venter på brukerens bekreftelse før
migrasjon 092 kjøres mot ekte `teecup_db`.

View file

@ -1785,7 +1785,7 @@ async def _compute_individual_standings(
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`) hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
som faktisk er meningsfullt å lese videre.""" som faktisk er meningsfullt å lese videre."""
tournament = await conn.fetchrow( tournament = await conn.fetchrow(
"SELECT format_type, scoring_method, tiebreak_winner_method, tiebreak_field_method " "SELECT format_type, scoring_method, tiebreak_winner_method, tiebreak_field_method, net_display_style "
"FROM tournament WHERE id = $1", "FROM tournament WHERE id = $1",
tournament_id, tournament_id,
) )
@ -1842,6 +1842,7 @@ async def _compute_individual_standings(
max_sequence=max_sequence, max_sequence=max_sequence,
tiebreak_winner_method=tournament["tiebreak_winner_method"], tiebreak_winner_method=tournament["tiebreak_winner_method"],
tiebreak_field_method=tournament["tiebreak_field_method"], tiebreak_field_method=tournament["tiebreak_field_method"],
net_display_style=tournament["net_display_style"],
) )
# Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en # Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en
# egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på # egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på
@ -2182,6 +2183,31 @@ async def _lowest_hcp_sort_keys(conn, participant_ids: list[str]) -> dict[str, t
return {r["id"]: (r["hcp"] if r["hcp"] is not None else float("inf"),) for r in rows} return {r["id"]: (r["hcp"] if r["hcp"] is not None else float("inf"),) for r in rows}
async def _lowest_playing_hcp_sort_keys(conn, tournament_id: str, participant_ids: list[str]) -> dict[str, tuple]:
"""Tiebreak 'lowest_playing_hcp' (migrasjon 092) -- laveste
SPILLEHANDICAP (tee-spesifikt tall, IKKE WHS-indeksen `lowest_hcp`
bruker) vinner. Leser turneringens ABSOLUTT SISTE runde (samme
presedens som `_countback_sort_keys` -- en reell uavgjort forutsetter
normalt likt antall runder spilt). Manglende verdi (runde ikke spilt
av denne deltakeren, eller handicap ikke beregnet ennå) sorteres
SIST, samme mønster som `_lowest_hcp_sort_keys`."""
last_round = await conn.fetchrow(
"SELECT id FROM tournament_round WHERE tournament_id = $1 ORDER BY sequence DESC LIMIT 1",
tournament_id,
)
if last_round is None:
return {pid: (float("inf"),) for pid in participant_ids}
rows = await conn.fetch(
"SELECT tournament_participant_id::text AS id, playing_handicap::float AS hcp "
"FROM tournament_round_participant "
"WHERE tournament_round_id = $1 AND tournament_participant_id = ANY($2::uuid[])",
last_round["id"],
participant_ids,
)
hcp_by_id = {r["id"]: r["hcp"] for r in rows}
return {pid: (hcp_by_id.get(pid) if hcp_by_id.get(pid) is not None else float("inf"),) for pid in participant_ids}
async def _countback_sort_keys(conn, tournament_id: str, method: str, participant_ids: list[str]) -> dict[str, tuple]: async def _countback_sort_keys(conn, tournament_id: str, method: str, participant_ids: list[str]) -> dict[str, tuple]:
"""Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching """Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching
cards": siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens cards": siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens
@ -2292,6 +2318,7 @@ async def _attach_stroke_play_columns(
max_sequence: int | None = None, max_sequence: int | None = None,
tiebreak_winner_method: str = "none", tiebreak_winner_method: str = "none",
tiebreak_field_method: str = "none", tiebreak_field_method: str = "none",
net_display_style: str = "standard",
) -> None: ) -> None:
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx): """Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
@ -2312,7 +2339,21 @@ async def _attach_stroke_play_columns(
aldri max_sequence, tiebreak gjelder der -- inkludert midt i aldri max_sequence, tiebreak gjelder der -- inkludert midt i
turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det
beste vi vet akkurat "-filosofi som resten av leaderboardet). beste vi vet akkurat "-filosofi som resten av leaderboardet).
`net_display_style` (migrasjon 092, 2026-08-23) -- 'hunt' er IKKE en
ny scoring-motor, kun en annen presentasjon av samme underliggende
data for `stroke_net`-turneringer ("Jakter Jesper", se ADR): i
stedet for å "tjene inn" handicap-slag gradvis per slagindeks
(dagens `net_total`, partial allocation via
`allocate_over_played_holes`), trekkes HELE spillehandicapet fra med
én gang og brutto-til-par-differansen vises løpende -- dramatisk
forskjellig tall MIDT i en runde, men konvergerer til NØYAKTIG samme
sluttall som standard-visningen ved 18 fullførte hull (siden summen
av per-hull-tildelte slag over alle 18 hull uansett er lik hele
spillehandicapet). Kun virksom når `method == "stroke_net"` --
stableford/gross har ingen netto-til-par-visning å endre .
""" """
hunt_active = net_display_style == "hunt" and method == "stroke_net"
rounds_meta = await conn.fetch( rounds_meta = await conn.fetch(
""" """
SELECT sequence, hole_config::text AS hole_config, scheduled_at::text AS scheduled_at, start_hole SELECT sequence, hole_config::text AS hole_config, scheduled_at::text AS scheduled_at, start_hole
@ -2338,9 +2379,12 @@ async def _attach_stroke_play_columns(
SELECT tr.sequence, tr.id::text AS tournament_round_id, SELECT tr.sequence, tr.id::text AS tournament_round_id,
trs.tournament_participant_id::text AS tournament_participant_id, trs.tournament_participant_id::text AS tournament_participant_id,
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points, trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
trp.playing_handicap,
COALESCE(pp.par_played, 0)::int AS par_played COALESCE(pp.par_played, 0)::int AS par_played
FROM tournament_round tr FROM tournament_round tr
JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id
LEFT JOIN tournament_round_participant trp
ON trp.tournament_round_id = tr.id AND trp.tournament_participant_id = trs.tournament_participant_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SUM(h.par) AS par_played SELECT SUM(h.par) AS par_played
FROM tournament_round_participant trp FROM tournament_round_participant trp
@ -2373,6 +2417,18 @@ async def _attach_stroke_play_columns(
"net_total" if method == "stroke_net" else "gross_total" "net_total" if method == "stroke_net" else "gross_total"
) )
def stroke_val(row) -> int | None:
"""Netto/brutto-tallet for én runde-rad -- `hunt_active` bytter
ut den gradvise `net_total` med "trekk fra HELE spillehandicapet
med én gang" (se `net_display_style`-docstringen over). `None`
hvis spillehandicap ikke er beregnet ennå for raden (samme
nøytrale "ingen data"-håndtering som `value_col` allerede har)."""
if not hunt_active:
return row[value_col]
if row["playing_handicap"] is None or row["gross_total"] is None:
return None
return row["gross_total"] - row["playing_handicap"]
# Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre, # Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre,
# uniformt for alle tre metodene -- stableford negeres siden flere poeng # uniformt for alle tre metodene -- stableford negeres siden flere poeng
# er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en # er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en
@ -2387,6 +2443,7 @@ async def _attach_stroke_play_columns(
rounds_out: list[RoundCellOut] = [] rounds_out: list[RoundCellOut] = []
total_par_played = 0 total_par_played = 0
total_holes_played = 0 total_holes_played = 0
total_stroke_val = 0
for seq, row in sorted(pmap.items()): for seq, row in sorted(pmap.items()):
if row["holes_played"] == 0: if row["holes_played"] == 0:
continue continue
@ -2404,9 +2461,10 @@ async def _attach_stroke_play_columns(
) )
) )
else: else:
val = row[value_col] val = stroke_val(row)
if val is None: if val is None:
continue continue
total_stroke_val += val
rounds_out.append( rounds_out.append(
RoundCellOut( RoundCellOut(
round_number=seq, round_number=seq,
@ -2425,7 +2483,7 @@ async def _attach_stroke_play_columns(
if method == "stableford": if method == "stableford":
e.today_label = f"{today_row['stableford_points'] or 0} p" e.today_label = f"{today_row['stableford_points'] or 0} p"
else: else:
val = today_row[value_col] val = stroke_val(today_row)
e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None
else: else:
e.thru_label = "-" e.thru_label = "-"
@ -2449,6 +2507,12 @@ async def _attach_stroke_play_columns(
# runde spilt ennå (total_holes_played == 0) -> total_label forblir # runde spilt ennå (total_holes_played == 0) -> total_label forblir
# None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte, # None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte,
# jevn/poengløs score i stedet for "ikke startet"). # jevn/poengløs score i stedet for "ikke startet").
#
# `hunt_active`: TOTAL kan IKKE gjenbruke `e.net_total` direkte --
# `playing_handicap` kan variere per runde (ulik tee), så
# hunt-summen må bygges runde for runde (`total_stroke_val`,
# akkumulert i løkken over) i stedet for kalleren sin ferdig-
# summerte `net_total`-kolonne.
started_any = total_holes_played > 0 started_any = total_holes_played > 0
if method == "stableford": if method == "stableford":
e.total_label = f"{e.stableford_total or 0} p" if started_any else None e.total_label = f"{e.stableford_total or 0} p" if started_any else None
@ -2457,7 +2521,7 @@ async def _attach_stroke_play_columns(
else -(e.stableford_total - 2 * total_holes_played) else -(e.stableford_total - 2 * total_holes_played)
) )
else: else:
total_val = e.net_total if method == "stroke_net" else e.gross_total total_val = total_stroke_val if hunt_active else (e.net_total if method == "stroke_net" else e.gross_total)
e.total_label = ( e.total_label = (
_to_par_label(total_val - total_par_played) if (total_val is not None and started_any) else None _to_par_label(total_val - total_par_played) if (total_val is not None and started_any) else None
) )
@ -2497,10 +2561,12 @@ async def _attach_stroke_play_columns(
# gruppe faller gjennom til else-grenen under akkurat som 'none': # gruppe faller gjennom til else-grenen under akkurat som 'none':
# delt plassering, ingen automatisk oppløsning. Forskjellen er # delt plassering, ingen automatisk oppløsning. Forskjellen er
# ren informasjon (tiebreak_playoff_rule), ikke rangeringslogikk. # ren informasjon (tiebreak_playoff_rule), ikke rangeringslogikk.
if tiebreak_method in ("countback", "lowest_hcp"): if tiebreak_method in ("countback", "lowest_hcp", "lowest_playing_hcp"):
group_ids = [entries[k].tournament_participant_id for k in range(i, j)] group_ids = [entries[k].tournament_participant_id for k in range(i, j)]
if tiebreak_method == "countback": if tiebreak_method == "countback":
sort_keys = await _countback_sort_keys(conn, tournament_id, method, group_ids) sort_keys = await _countback_sort_keys(conn, tournament_id, method, group_ids)
elif tiebreak_method == "lowest_playing_hcp":
sort_keys = await _lowest_playing_hcp_sort_keys(conn, tournament_id, group_ids)
else: else:
sort_keys = await _lowest_hcp_sort_keys(conn, group_ids) sort_keys = await _lowest_hcp_sort_keys(conn, group_ids)
_assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank) _assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank)

View file

@ -63,7 +63,7 @@ _TOURNAMENT_COLUMNS = """
registration_overflow_policy, registration_requires_approval, hero_image_key, registration_overflow_policy, registration_requires_approval, hero_image_key,
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible, join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible,
cut_after_round, cut_size, cut_applied_at, tiebreak_winner_method, tiebreak_field_method, cut_after_round, cut_size, cut_applied_at, tiebreak_winner_method, tiebreak_field_method,
tiebreak_playoff_rule tiebreak_playoff_rule, net_display_style
""" """
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A). # Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
@ -118,6 +118,14 @@ class Tournament(BaseModel):
# setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i # setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i
# Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer. # Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
tiebreak_playoff_rule: str | None tiebreak_playoff_rule: str | None
# Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- "Jakter
# Jesper": IKKE en ny scoring_method (sluttallet er matematisk identisk
# med stroke_net sitt netto-til-par), kun en annen presentasjon av
# LØPENDE tall midt i runden -- se individual_tournaments.py sin
# _attach_stroke_play_columns. KUN gyldig sammen med
# scoring_method='stroke_net', håndhevet i update_tournament/
# create_tournament under.
net_display_style: str
def _tournament_from_row(row) -> Tournament: def _tournament_from_row(row) -> Tournament:
@ -151,7 +159,8 @@ async def list_tournaments(
t.registration_requires_approval, t.hero_image_key, t.registration_requires_approval, t.hero_image_key,
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled, t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at, t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at,
t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule,
t.net_display_style
FROM tournament t FROM tournament t
LEFT JOIN ( LEFT JOIN (
SELECT tournament_id, SELECT tournament_id,
@ -247,9 +256,16 @@ class TournamentUpdate(BaseModel):
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator, # Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
# se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig # se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig
# for vinner-metoden, ikke felt-metoden. # for vinner-metoden, ikke felt-metoden.
tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|playoff)$") tiebreak_winner_method: str | None = Field(
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$") default=None, pattern="^(none|countback|lowest_hcp|playoff|lowest_playing_hcp)$"
)
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|lowest_playing_hcp)$")
tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000) tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000)
# Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- se
# Tournament sin kommentar. Gyldigheten mot scoring_method='stroke_net'
# håndheves i update_tournament under (ikke her -- denne modellen kjenner
# ikke turneringens gjeldende scoring_method).
net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$")
# hero_image_key er BEVISST IKKE med her -- den settes kun via # hero_image_key er BEVISST IKKE med her -- den settes kun via
# upload_hero_image under, aldri som en fritekst-streng klienten sender, # upload_hero_image under, aldri som en fritekst-streng klienten sender,
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting. # siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
@ -282,6 +298,25 @@ async def update_tournament(
values.append(tournament_id) values.append(tournament_id)
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
# Fail-loudly (migrasjon 092): 'hunt' gir ingen mening utenfor
# stroke_net -- de andre metodene har ingen netto-til-par-visning å
# endre presentasjonen av. Sjekker den EFFEKTIVE tilstanden ETTER
# denne PATCH-en (kan selv sette scoring_method/net_display_style i
# samme kall) -- dekker BEGGE retninger: (a) satt til 'hunt' på en
# ikke-stroke_net-turnering, (b) scoring_method byttet bort fra
# stroke_net mens 'hunt' henger igjen fra før, uendret i denne
# PATCH-en. Unngår en stille, meningsløs kombinasjon i databasen.
if "scoring_method" in updates or "net_display_style" in updates:
current = await conn.fetchrow(
"SELECT scoring_method, net_display_style FROM tournament WHERE id = $1", tournament_id
)
effective_method = updates.get("scoring_method", current["scoring_method"] if current else None)
effective_style = updates.get("net_display_style", current["net_display_style"] if current else "standard")
if effective_style == "hunt" and effective_method != "stroke_net":
raise app_error(
400, "VALIDATION_FAILED",
"Jakter Jesper-visningen krever scoring_method='stroke_net'.",
)
row = await conn.fetchrow( row = await conn.fetchrow(
f""" f"""
UPDATE tournament SET {', '.join(set_clauses)} UPDATE tournament SET {', '.join(set_clauses)}

View file

@ -37,6 +37,7 @@ import {
Plus, Plus,
RefreshCw, RefreshCw,
Settings2, Settings2,
Target,
Trash2, Trash2,
Trophy, Trophy,
Upload, Upload,
@ -153,6 +154,10 @@ type ApiTournamentInfo = {
// tiebreak_winner_method. Fritekst-regel vist proaktivt i Oppsett OG // tiebreak_winner_method. Fritekst-regel vist proaktivt i Oppsett OG
// på leaderboardet, ikke bare når en uavgjort faktisk skjer. // på leaderboardet, ikke bare når en uavgjort faktisk skjer.
tiebreak_playoff_rule: string | null tiebreak_playoff_rule: string | null
// Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- "Jakter
// Jesper". IKKE en ny scoring_method -- kun gyldig sammen med
// scoring_method="stroke_net". "standard" (default) = uendret oppførsel.
net_display_style: string
} }
type ApiParticipant = { type ApiParticipant = {
@ -659,6 +664,29 @@ export function IndividualTournamentDetail({
} }
} }
// Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- "Jakter
// Jesper". Samme optimistiske PATCH-mønster som tiebreak/cut over. Ren
// presentasjon -- endrer ALDRI selve rangeringen, kun hvilket tall som
// vises løpende (se individual_tournaments.py sin
// _attach_stroke_play_columns).
async function updateNetDisplayStyle(style: string) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, net_display_style: style })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ net_display_style: style }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å lagre visningsmodus. Prøv igjen.")
}
}
// Turneringsinnstillinger (2026-08-18, TournamentSettingsCard -- // Turneringsinnstillinger (2026-08-18, TournamentSettingsCard --
// V0-eksportert, se ADR/CHANGELOG #118). Komponenten viser sin EGEN // V0-eksportert, se ADR/CHANGELOG #118). Komponenten viser sin EGEN
// lagre-/feil-tilstand rundt onSave-løftet -- IKKE fang feilen her // lagre-/feil-tilstand rundt onSave-løftet -- IKKE fang feilen her
@ -1093,6 +1121,7 @@ export function IndividualTournamentDetail({
onUpdateFlagMapVisible={updateFlagMapVisible} onUpdateFlagMapVisible={updateFlagMapVisible}
onUpdateCutConfig={updateCutConfig} onUpdateCutConfig={updateCutConfig}
onUpdateTiebreakConfig={updateTiebreakConfig} onUpdateTiebreakConfig={updateTiebreakConfig}
onUpdateNetDisplayStyle={updateNetDisplayStyle}
onUpdateSettings={updateSettings} onUpdateSettings={updateSettings}
onApplyCut={applyCut} onApplyCut={applyCut}
onAddParticipant={addParticipant} onAddParticipant={addParticipant}
@ -1216,6 +1245,7 @@ function SetupTab({
onUpdateCutConfig, onUpdateCutConfig,
onApplyCut, onApplyCut,
onUpdateTiebreakConfig, onUpdateTiebreakConfig,
onUpdateNetDisplayStyle,
onUpdateSettings, onUpdateSettings,
onAddParticipant, onAddParticipant,
onAddNewPlayer, onAddNewPlayer,
@ -1246,6 +1276,7 @@ function SetupTab({
onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void> onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void>
onApplyCut: () => Promise<void> onApplyCut: () => Promise<void>
onUpdateTiebreakConfig: (winner: string, field: string, playoffRule: string | null) => Promise<void> onUpdateTiebreakConfig: (winner: string, field: string, playoffRule: string | null) => Promise<void>
onUpdateNetDisplayStyle: (style: string) => Promise<void>
onUpdateSettings: (next: TournamentSettingsValues) => Promise<void> onUpdateSettings: (next: TournamentSettingsValues) => Promise<void>
onAddParticipant: (playerId: string) => Promise<void> onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void> onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
@ -1778,6 +1809,9 @@ function SetupTab({
<TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} /> <TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} />
</> </>
)} )}
{tournament.scoring_method === "stroke_net" && (
<NetDisplayStyleCard tournament={tournament} onUpdateConfig={onUpdateNetDisplayStyle} />
)}
</div> </div>
)} )}
</TournamentSetupNav> </TournamentSetupNav>
@ -1905,6 +1939,11 @@ const TIEBREAK_OPTIONS_WINNER: { value: string; label: string; hint: string }[]
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T1»." }, { value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T1»." },
{ value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" }, { value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" },
{ value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" }, { value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
{
value: "lowest_playing_hcp",
label: "Lavest spillehandicap",
hint: "Spilleren med lavest spillehandicap i siste runde vinner uavgjortheten",
},
{ value: "playoff", label: "Omspill", hint: "Avgjøres på banen -- beskriv formatet under, vises til alle før turneringen" }, { value: "playoff", label: "Omspill", hint: "Avgjøres på banen -- beskriv formatet under, vises til alle før turneringen" },
] ]
@ -1912,6 +1951,11 @@ const TIEBREAK_OPTIONS_FIELD: { value: string; label: string; hint: string }[] =
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T5» osv." }, { value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T5» osv." },
{ value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" }, { value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" },
{ value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" }, { value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
{
value: "lowest_playing_hcp",
label: "Lavest spillehandicap",
hint: "Spilleren med lavest spillehandicap i siste runde vinner uavgjortheten",
},
] ]
function TiebreakCard({ function TiebreakCard({
@ -2010,6 +2054,43 @@ function TiebreakCard({
) )
} }
// Visningsmodus for netto-scoring ("Jakter Jesper", migrasjon 092,
// 2026-08-23) -- IKKE en ny scoring_method, kun en annen presentasjon av
// LØPENDE tall for stroke_net-turneringer (se ADR/CHANGELOG). Egen,
// enkel bryter-card, samme mønster som CutCard/TiebreakCard rett over.
function NetDisplayStyleCard({
tournament,
onUpdateConfig,
}: {
tournament: ApiTournamentInfo
onUpdateConfig: (style: string) => Promise<void>
}) {
return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<h2 className="text-base font-bold text-foreground">Visningsmodus for netto-score</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Sluttresultatet blir identisk uansett -- dette endrer kun hvordan tallet vises LØPENDE
gjennom runden.
</p>
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
<input
type="checkbox"
checked={tournament.net_display_style === "hunt"}
onChange={(e) => void onUpdateConfig(e.target.checked ? "hunt" : "standard")}
className="mt-1 size-5 shrink-0 accent-primary"
/>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-bold text-foreground">Vis som Jakter Jesper</span>
<span className="text-xs text-muted-foreground">
Alle starter minus spillehandicapet sitt, og hullscore vises brutto mot par
løpende -- i stedet for at slag "tjenes inn" gradvis gjennom runden. Av som standard.
</span>
</span>
</label>
</section>
)
}
// Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin // Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin
// ClassesCard for lagturnering-varianten (samme mønster, men uten // ClassesCard for lagturnering-varianten (samme mønster, men uten
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten). // leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).
@ -5491,6 +5572,12 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
? tournament.tiebreak_playoff_rule ? tournament.tiebreak_playoff_rule
: null : null
// Jakter Jesper-varsel (migrasjon 092, 2026-08-23) -- ren presentasjon,
// samme "informer proaktivt over selve tavlen"-mønster som playoffNote
// over. Forklarer HVORFOR tallene starter dypt i minus, for spillere som
// ikke kjenner formatet fra før.
const huntModeNote = isStrokePlay && tournament.scoring_method === "stroke_net" && tournament.net_display_style === "hunt"
// base = /orgs/{organizationId}/tournaments/{tournamentId} -- utledet i // base = /orgs/{organizationId}/tournaments/{tournamentId} -- utledet i
// stedet for å utvide LeaderboardTab sitt propsett kun for utskrifts-lenken. // stedet for å utvide LeaderboardTab sitt propsett kun for utskrifts-lenken.
const [, , printOrgId, , printTournamentId] = base.split("/") const [, , printOrgId, , printTournamentId] = base.split("/")
@ -5513,6 +5600,14 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
</p> </p>
</div> </div>
)} )}
{huntModeNote && (
<div className="flex items-start gap-3 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3">
<Target aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-primary" />
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
Jakter Jesper: alle starter minus spillehandicapet sitt -- lavest sluttsum vinner.
</p>
</div>
)}
{groups.map((group) => ( {groups.map((group) => (
<div key={group.key} className="flex flex-col gap-2"> <div key={group.key} className="flex flex-col gap-2">
{showSectionHeaders && ( {showSectionHeaders && (

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,221 @@
"""
Visningsmodus for netto-scoring ("Jakter Jesper", migrasjon 092,
2026-08-23). IKKE en ny scoring_method -- sluttallet er matematisk
identisk med stroke_net sitt netto-til-par (`net_total - par_played`).
Det eneste som faktisk er nytt er den LØPENDE visningen: standard
"tjener inn" handicap-slag gradvis per slagindeks etter hvert som hull
spilles, mens 'hunt' trekker fra HELE spillehandicapet med én gang og
viser brutto-til-par-differansen løpende. Se
`_attach_stroke_play_columns` i individual_tournaments.py.
"""
from fastapi import HTTPException
import pytest
from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate,
individual_leaderboard,
update_hole as tournament_update_hole,
)
from app.routers.tournaments import TournamentUpdate, update_tournament
from app.auth import CurrentUser
from tests.conftest import (
add_membership,
create_org,
create_org_hole,
create_course,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
async def _score_holes(tournament_id, round_id, rp_id, org_id, user_id, scores: list[int]) -> None:
user = CurrentUser(user_id=user_id)
for n, gross in enumerate(scores, start=1):
await tournament_update_hole(
tournament_id, round_id, rp_id, n,
TournamentHoleUpdate(gross_strokes=gross, expected_version=None),
organization_id=org_id, user=user,
)
async def _setup_stroke_net_round(org_id, method="stroke_net"):
tournament_id = await create_tournament(org_id, name="Jakter Jesper-turnering")
course_id = await create_course(org_id, name="Hunt Links")
for n in range(1, 19):
await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n)
tee_id = await create_tee(org_id, course_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = $2 WHERE id = $1",
tournament_id, method,
)
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
return tournament_id, course_id, tee_id, round_id
async def _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, name, playing_handicap):
player_id = await create_player(org_id, display_name=name)
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament_round_participant SET course_handicap = $1, playing_handicap = $1 WHERE id = $2",
playing_handicap, rp_id,
)
return tp_id, rp_id
async def test_hunt_differs_mid_round_but_converges_at_completion(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id)
p1, rp1 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P1", 12)
# Hull 1, par -- stroke_index 1 (<=12) -> mottar 1 slag under standard.
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4])
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
# Standard (default): netto = 4-1=3, til par = 3-4 = -1 -- slaget er
# kun DELVIS "tjent inn" ennå (1 av 12 spilte hull).
assert by_id[p1].total_label == "-1"
await update_tournament(tournament_id, TournamentUpdate(net_display_style="hunt"), organization_id=org_id)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
# Hunt: gross(4) - FULLT spillehandicap(12) = -8, til par = -8-4 = -12
# -- hele handicapet trukket fra med én gang, mye "dypere" tall.
assert by_id[p1].total_label == "-12"
# Fullfør runden med par på alle resterende hull.
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4] * 17)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
# Fortsatt -12 under hunt -- flatt hele veien når hvert hull er par.
assert by_id[p1].total_label == "-12"
# Bytt tilbake til standard nå som runden er fullført -- KONVERGERER
# til nøyaktig samme tall som hunt (selve beviset på ekvivalensen).
await update_tournament(tournament_id, TournamentUpdate(net_display_style="standard"), organization_id=org_id)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
assert by_id[p1].total_label == "-12"
async def test_hunt_display_style_rejected_for_non_stroke_net(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id = await create_tournament(org_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stableford' WHERE id = $1",
tournament_id,
)
with pytest.raises(HTTPException) as exc_info:
await update_tournament(tournament_id, TournamentUpdate(net_display_style="hunt"), organization_id=org_id)
assert exc_info.value.status_code == 400
async def test_switching_away_from_stroke_net_rejected_while_hunt_active(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id)
await update_tournament(tournament_id, TournamentUpdate(net_display_style="hunt"), organization_id=org_id)
with pytest.raises(HTTPException) as exc_info:
await update_tournament(
tournament_id, TournamentUpdate(scoring_method="stableford"), organization_id=org_id
)
assert exc_info.value.status_code == 400
# Men lov når begge felt endres sammen i samme kall.
out = await update_tournament(
tournament_id,
TournamentUpdate(scoring_method="stableford", net_display_style="standard"),
organization_id=org_id,
)
assert out.scoring_method == "stableford"
assert out.net_display_style == "standard"
async def test_lowest_playing_hcp_tiebreak_resolves_field_tie(pool):
"""Speiler `test_lowest_hcp_resolves_field_tie_but_not_winner`
(test_tiebreak.py) sitt oppsett nøyaktig, men med `stroke_gross`
(handicap påvirker IKKE selve rangeringen der -- kun tiebreaken --
slik at identiske bruttoscorer faktisk BLIR en reell uavgjort å
løse). `stroke_net` ville gjort dette meningsløst: ulikt
spillehandicap med identisk brutto gir ulik netto, altså ingen
uavgjort i det hele tatt å teste tiebreaken ."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id, method="stroke_gross")
await update_tournament(
tournament_id,
TournamentUpdate(tiebreak_winner_method="none", tiebreak_field_method="lowest_playing_hcp"),
organization_id=org_id,
)
leader, rp_leader = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "Leader", 5)
p2, rp2 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P2", 8)
p3, rp3 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P3", 20)
await _score_holes(tournament_id, round_id, rp_leader, org_id, owner_id, [3] * 18) # klar leder
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18) # uavgjort brutto med p3
await _score_holes(tournament_id, round_id, rp3, org_id, owner_id, [4] * 18) # uavgjort brutto med p2
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
assert by_id[leader].position == "1"
assert by_id[p2].position == "2" # lavere spillehandicap (8 < 20) -> foran
assert by_id[p3].position == "3"
async def test_lowest_playing_hcp_missing_value_sorts_last(pool):
"""Speiler `lowest_hcp` sin regel: manglende data kan ALDRI vinne en
tiebreak ved å mangle -- sorteres sist. `stroke_gross`, samme
begrunnelse som testen over -- spillehandicap er ikke påkrevd for
brutto-scoring (`_SCORING_METHODS_REQUIRING_HANDICAP` gjelder ikke
her), et reelt NULL-scenario for tiebreak-metrikken kan faktisk
oppstå (ulikt stroke_net, der manglende handicap gjør at netto-
tallet aldri blir beregnet i det hele tatt -- deltakeren havner da
helt utenfor rangeringen, ikke i en tiebreak-gruppe)."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id, method="stroke_gross")
# Kun to deltakere, begge uavgjort for 1.-plass -- tiebreak_winner_method
# er den relevante innstillingen her (rank==1), ikke feltmetoden.
await update_tournament(
tournament_id,
TournamentUpdate(tiebreak_winner_method="lowest_playing_hcp", tiebreak_field_method="none"),
organization_id=org_id,
)
p1, rp1 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P1", 10)
# P2 -- ingen spillehandicap beregnet (NULL, samme som "aldri kjørt
# gjennom _compute_round_participant_handicap").
player2 = await create_player(org_id, display_name="P2")
p2 = await create_tournament_participant(org_id, tournament_id, player2)
rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id)
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4] * 18)
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
assert by_id[p1].position == "1" # har en verdi -> vinner uavgjortheten
assert by_id[p2].position == "2" # mangler verdi -> sist