teecup/tests/test_tiebreak.py
Erol Haagenrud 0c864f0705 Omspill-tiebreak for vinneren + direkte HCP-retting per deltaker (ADR-090)
'playoff' som ny tiebreak_winner_method-verdi -- rangeres som 'none'
(appen kan ikke avgjøre et omspill selv), men regelen vises proaktivt i
Oppsett og som banner på leaderboardet. HCP: la til handicap_index_
snapshot i TournamentParticipantUpdate + inline redigering i
Deltakere-lista, uten å røre allerede frosne runde-verdier. Migrasjon
086 lagt til, men IKKE anvendt mot ekte teecup_db ennå.
2026-08-18 17:29:59 +02:00

221 lines
11 KiB
Python

"""
Tiebreak ved likt resultat i individuelle turneringer (migrasjon 085,
2026-08-18). Bruker: "Ved likt resultat ... Skal laveste hcp plasseres
foran, beste siste 18/9/6/3/siste hull plasseres foran, eller deles
plasseringene. Her kan svaret være forskjellig for vinner og resten av
feltet." Metoden er en fri organisator-innstilling, ikke fastlåst --
to UAVHENGIGE felt (vinner/felt), og påvirker ALDRI Cut-grensen (kun
sluttresultatets plasseringsvisning).
"""
from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate,
apply_cut,
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_single_round(org_id, owner_id, method="stroke_gross"):
tournament_id = await create_tournament(org_id, name="Tiebreak-turnering")
course_id = await create_course(org_id, name="Tiebreak 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 _set_tiebreak(org_id, tournament_id, winner: str, field: str):
await update_tournament(
tournament_id,
TournamentUpdate(tiebreak_winner_method=winner, tiebreak_field_method=field),
organization_id=org_id,
)
async def test_countback_resolves_winner_tie_by_better_back_nine(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_single_round(org_id, owner_id)
await _set_tiebreak(org_id, tournament_id, winner="countback", field="none")
p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1"))
p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2"))
p3 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P3"))
rp1 = await create_tournament_round_participant(org_id, round_id, p1, tee_id)
rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id)
rp3 = await create_tournament_round_participant(org_id, round_id, p3, tee_id)
# P1: front9 alle par (36), back9 to birdier (34) -> total 70, back9=34.
p1_scores = [4] * 9 + [3, 3] + [4] * 7
# P2: front9 to birdier (34), back9 alle par (36) -> total 70, back9=36 (dårligere enn P1 sin back9).
p2_scores = [3, 3] + [4] * 7 + [4] * 9
# P3: klart bak, ingen uavgjort.
p3_scores = [5] * 18
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, p1_scores)
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, p2_scores)
await _score_holes(tournament_id, round_id, rp3, org_id, owner_id, p3_scores)
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" # bedre back9 -> sole leder, IKKE "T1"
assert by_id[p1].is_leader is True
assert by_id[p2].position == "2"
assert by_id[p2].is_leader is False
assert by_id[p3].position == "3"
async def test_lowest_hcp_resolves_field_tie_but_not_winner(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_single_round(org_id, owner_id)
# Vinner-tiebreak IKKE satt (none) -- kun felt-tiebreak skal virke.
await _set_tiebreak(org_id, tournament_id, winner="none", field="lowest_hcp")
leader = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="Leader"))
p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2"))
p3 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P3"))
async with app_db.org_connection(org_id) as conn:
await conn.execute("UPDATE tournament_participant SET handicap_index_snapshot = 8.0 WHERE id = $1", p2)
await conn.execute("UPDATE tournament_participant SET handicap_index_snapshot = 20.0 WHERE id = $1", p3)
rp_leader = await create_tournament_round_participant(org_id, round_id, leader, tee_id)
rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id)
rp3 = await create_tournament_round_participant(org_id, round_id, p3, tee_id)
await _score_holes(tournament_id, round_id, rp_leader, org_id, owner_id, [3] * 18) # klar leder, -18
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18) # uavgjort med p3, E
await _score_holes(tournament_id, round_id, rp3, org_id, owner_id, [4] * 18) # uavgjort med p2, E
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[leader].is_leader is True
assert by_id[p2].position == "2" # lavere HCP (8.0 < 20.0) -> foran
assert by_id[p3].position == "3"
async def test_tiebreak_method_none_keeps_shared_position(pool):
"""Standard/uendret oppførsel -- ingen tiebreak konfigurert i det hele
tatt (default 'none' for begge felt, ingen regresjon)."""
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_single_round(org_id, owner_id)
p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1"))
p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2"))
rp1 = await create_tournament_round_participant(org_id, round_id, p1, tee_id)
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 == "T1"
assert by_id[p2].position == "T1"
assert by_id[p1].is_leader is True
assert by_id[p2].is_leader is True
async def test_playoff_method_keeps_shared_position_like_none(pool):
"""'playoff' (migrasjon 086) -- appen kan ikke selv avgjøre et
omspill, så en tiebreak_winner_method='playoff'-turnering skal
oppføre seg NØYAKTIG som 'none' i selve rangeringen (delt plassering,
ingen automatisk oppløsning) -- forskjellen er ren informasjon
(tiebreak_playoff_rule), ikke rangeringslogikk."""
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_single_round(org_id, owner_id)
await update_tournament(
tournament_id,
TournamentUpdate(tiebreak_winner_method="playoff", tiebreak_playoff_rule="Sudden death fra hull 18."),
organization_id=org_id,
)
p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1"))
p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2"))
rp1 = await create_tournament_round_participant(org_id, round_id, p1, tee_id)
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 == "T1"
assert by_id[p2].position == "T1"
assert by_id[p1].is_leader is True
assert by_id[p2].is_leader is True
async def test_tiebreak_never_affects_cut_boundary(pool):
"""Bruker bekreftet eksplisitt: tiebreak påvirker KUN sluttresultatet,
ALDRI selve cut-grensen -- full delt plass på grensen uansett
tiebreak-innstilling."""
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, round1_id = await _setup_single_round(org_id, owner_id)
round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2)
# Countback konfigurert -- skal likevel IKKE påvirke cutten.
await _set_tiebreak(org_id, tournament_id, winner="countback", field="countback")
await update_tournament(
tournament_id, TournamentUpdate(cut_after_round=1, cut_size=1), organization_id=org_id,
)
p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1"))
p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2"))
rp1 = await create_tournament_round_participant(org_id, round1_id, p1, tee_id)
rp2 = await create_tournament_round_participant(org_id, round1_id, p2, tee_id)
# Identisk totalt OG identisk back9 -- ekte fullt uavgjort, selv med countback slått på.
await _score_holes(tournament_id, round1_id, rp1, org_id, owner_id, [4] * 18)
await _score_holes(tournament_id, round1_id, rp2, org_id, owner_id, [4] * 18)
result = await apply_cut(tournament_id, organization_id=org_id)
assert result.cut_size == 1
assert result.survivors == 2 # begge slipper gjennom -- delt 1.-plass, IKKE brutt av countback
assert result.cut_count == 0