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å.
This commit is contained in:
parent
fb00c65430
commit
0c864f0705
8 changed files with 413 additions and 22 deletions
30
086_tournament_playoff.sql
Normal file
30
086_tournament_playoff.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 086
|
||||||
|
-- Omspill (playoff) som eget tiebreak-valg for VINNEREN + fritekst-
|
||||||
|
-- regel som skal informeres om før turneringen
|
||||||
|
-- =====================================================================
|
||||||
|
-- Bruker, etter tiebreak (ADR-089): "ved lik score for leder; nytt
|
||||||
|
-- valg: omspill med avgjørelse. Dette bør informeres om før
|
||||||
|
-- turneringen. (Eks: Omspill på hull 18 til vinner er kåret.)"
|
||||||
|
--
|
||||||
|
-- KUN et gyldig valg for `tiebreak_winner_method` (ikke `tiebreak_
|
||||||
|
-- field_method`) -- bruker sin egen formulering var eksplisitt "ved
|
||||||
|
-- lik score for LEDER", og et omspill for f.eks. delt 8.-plass er ikke
|
||||||
|
-- hva som ble bedt om. Appen kan ikke selv AVGJØRE et omspill (det
|
||||||
|
-- skjer på banen, utenfor scoring-systemet) -- rangeringslogikken
|
||||||
|
-- behandler derfor `'playoff'` likt som `'none'` (delt plassering,
|
||||||
|
-- ingen automatisk oppløsning), se `_attach_stroke_play_columns`.
|
||||||
|
-- Forskjellen er ren INFORMASJON: `tiebreak_playoff_rule` er fritekst
|
||||||
|
-- organisator setter (f.eks. "Sudden death fra hull 18, til vinner er
|
||||||
|
-- kåret") -- vist i Oppsett OG på leaderboardet (organisatorens/
|
||||||
|
-- spillernes felles visningsflate) FØR en eventuell uavgjort faktisk
|
||||||
|
-- oppstår, ikke bare når den skjer.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
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')),
|
||||||
|
ADD COLUMN tiebreak_playoff_rule text;
|
||||||
|
|
@ -9179,6 +9179,67 @@ teecup_frontend && up -d` for begge. Rene logger, 200 OK.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-090: Omspill (playoff) som tiebreak-valg for vinneren + direkte HCP-retting per deltaker (2026-08-18)
|
||||||
|
|
||||||
|
To mindre, beslektede oppfølgere samme dag som ADR-089.
|
||||||
|
|
||||||
|
**Del A -- Omspill.** Bruker: "ved lik score for leder; nytt valg:
|
||||||
|
omspill med avgjørelse. Dette bør informeres om før turneringen. (Eks:
|
||||||
|
Omspill på hull 18 til vinner er kåret.)"
|
||||||
|
|
||||||
|
Migrasjon 086: `tiebreak_winner_method` sin CHECK utvidet med `'playoff'`
|
||||||
|
(KUN gyldig for vinner-metoden -- bruker sin egen formulering var
|
||||||
|
eksplisitt "ved lik score for LEDER", ikke feltet forøvrig), ny
|
||||||
|
`tiebreak_playoff_rule text` (fritekst, f.eks. "Sudden death fra hull
|
||||||
|
18, til vinner er kåret"). Appen kan ikke selv AVGJØRE et omspill (det
|
||||||
|
skjer på banen, utenfor scoring-systemet) -- `_attach_stroke_play_
|
||||||
|
columns` behandler derfor `'playoff'` NØYAKTIG som `'none'` i selve
|
||||||
|
rangeringen (delt plassering, ingen automatisk oppløsning, samme
|
||||||
|
`is_leader=true` for alle tiede ledere). Forskjellen er ren
|
||||||
|
INFORMASJON: regelen vises proaktivt to steder -- i Oppsett (`TiebreakCard`,
|
||||||
|
et nytt tekstfelt som dukker opp når "Omspill" velges) OG på selve
|
||||||
|
leaderboardet (`LeaderboardTab` i `individual-tournament-detail.tsx`,
|
||||||
|
en gull-farget banner øverst, ALLTID synlig når konfigurert -- ikke
|
||||||
|
bare når en uavgjort faktisk oppstår, per "dette bør informeres om FØR
|
||||||
|
turneringen").
|
||||||
|
|
||||||
|
**Del B -- direkte HCP-retting per deltaker.** Bruker: "Hvordan ...
|
||||||
|
justerer hcp på spillere?" Undersøkt: å redigere en spiller i org-
|
||||||
|
spillerpoolen (ADR-085) endrer KUN grunnlaget for FREMTIDIGE
|
||||||
|
turnering-påmeldinger -- en allerede satt `tournament_participant.
|
||||||
|
handicap_index_snapshot` ble ALDRI rørt (samme "frosset snapshot"-
|
||||||
|
filosofi `PlayerUpdate` sin docstring allerede dokumenterer for
|
||||||
|
lagturneringers `team_roster`). Ingen vei fantes til å rette EN
|
||||||
|
SPESIFIKK deltakers indeks direkte i turneringen den faktisk gjelder
|
||||||
|
for.
|
||||||
|
|
||||||
|
Løst ved å legge `handicap_index_snapshot` til `TournamentParticipantUpdate`
|
||||||
|
(endepunktet var allerede fullt generisk -- ingen andre backend-endringer
|
||||||
|
nødvendig). Endrer BEVISST kun selve `tournament_participant`-raden --
|
||||||
|
allerede opprettede `tournament_round_participant.course_handicap`/
|
||||||
|
`playing_handicap` for runder deltakeren allerede er satt opp i, forblir
|
||||||
|
UENDRET (samme "ingen retroaktiv omskriving av frosne runde-verdier"-
|
||||||
|
prinsipp, bekreftet med egen test). Frontend: `Deltakere`-lista i Oppsett
|
||||||
|
fikk en ny inline `HcpInput` (lite tallfelt, forpliktet på blur, samme
|
||||||
|
mønster som andre tallfelt i appen) i stedet for den tidligere rene
|
||||||
|
tekstvisningen av HCP.
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 1 ny
|
||||||
|
test for omspill (bekrefter delt plassering, identisk til `'none'`) +
|
||||||
|
2 nye tester for HCP-rettingen (verdien settes, OG at en allerede
|
||||||
|
frosset runde-verdi IKKE skrives over) -- 140/140 grønt (opp fra 137).
|
||||||
|
Ingen egen scratch-/mock-runde denne gangen -- begge er små, godt
|
||||||
|
avgrensede tilføyelser til allerede scratch-verifiserte komponenter
|
||||||
|
(`TiebreakCard` fra ADR-089, `Deltakere`-lista sitt etablerte
|
||||||
|
inline-rediger-mønster fra klasse-/statistikknivå-feltene rett ved
|
||||||
|
siden av), vurdert som lav restrisiko gitt sterk typesikkerhet og
|
||||||
|
testdekning.
|
||||||
|
|
||||||
|
**Rullet ut:** venter på bekreftelse (migrasjon 086 mot ekte
|
||||||
|
`teecup_db` krever egen bekreftelse, se CLAUDE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -12815,3 +12815,26 @@ Neste steg:
|
||||||
**Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build
|
**Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build
|
||||||
teecup_api teecup_frontend && up -d` for begge, rene logger, 200
|
teecup_api teecup_frontend && up -d` for begge, rene logger, 200
|
||||||
OK.
|
OK.
|
||||||
|
|
||||||
|
120. **Omspill (playoff) + direkte HCP-retting per deltaker -- 2026-08-18
|
||||||
|
(ADR-090).** To mindre oppfølgere: (A) "ved lik score for leder;
|
||||||
|
nytt valg: omspill med avgjørelse. Dette bør informeres om før
|
||||||
|
turneringen" -- ny `'playoff'`-verdi for `tiebreak_winner_method`
|
||||||
|
(migrasjon 086, kun gyldig for vinneren), fritekst `tiebreak_
|
||||||
|
playoff_rule`. Rangeringen behandler `'playoff'` identisk til
|
||||||
|
`'none'` (appen kan ikke selv avgjøre et omspill) -- forskjellen er
|
||||||
|
at regelen nå vises PROAKTIVT, både i Oppsett og som en gull-banner
|
||||||
|
øverst på leaderboardet, uansett om noen faktisk er tiet akkurat nå.
|
||||||
|
(B) "Hvordan justerer jeg hcp på spillere?" -- spillerpoolen
|
||||||
|
(ADR-085) rørte aldri en allerede frosset `tournament_participant.
|
||||||
|
handicap_index_snapshot`. Lagt til i `TournamentParticipantUpdate`
|
||||||
|
(allerede generisk endepunkt, ingen andre backend-endringer), ny
|
||||||
|
inline `HcpInput` i Deltakere-lista. Endrer bevisst KUN selve
|
||||||
|
snapshot-raden -- allerede opprettede runde-handicap forblir frosset.
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 3 nye
|
||||||
|
backend-tester -- 140/140 grønt (opp fra 137). Ingen egen scratch-
|
||||||
|
runde (små tilføyelser til allerede scratch-verifiserte flater).
|
||||||
|
|
||||||
|
**Rullet ut:** venter på bekreftelse (migrasjon 086 mot ekte
|
||||||
|
`teecup_db` krever egen bekreftelse, se CLAUDE.md).
|
||||||
|
|
|
||||||
|
|
@ -306,10 +306,27 @@ class TournamentParticipantCreate(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class TournamentParticipantUpdate(BaseModel):
|
class TournamentParticipantUpdate(BaseModel):
|
||||||
"""exclude_unset, samme PATCH-semantikk som resten av appen."""
|
"""exclude_unset, samme PATCH-semantikk som resten av appen.
|
||||||
|
|
||||||
|
`handicap_index_snapshot` (2026-08-18) -- bruker spurte "hvordan
|
||||||
|
justerer jeg hcp på spillere?": å redigere spillerens indeks i selve
|
||||||
|
spillerpoolen (ADR-085) endrer KUN grunnlaget for FREMTIDIGE
|
||||||
|
turnering-påmeldinger -- en allerede satt `handicap_index_snapshot`
|
||||||
|
på en eksisterende `tournament_participant` ble ALDRI rørt (samme
|
||||||
|
"frosset snapshot"-filosofi som `PlayerUpdate` sin docstring allerede
|
||||||
|
dokumenterer for lagturneringers `team_roster`). Dette feltet er den
|
||||||
|
manglende veien til å rette EN SPESIFIKK deltakers indeks direkte i
|
||||||
|
turneringen den faktisk gjelder for. Endrer BEVISST KUN selve
|
||||||
|
tournament_participant-raden -- allerede opprettede `tournament_
|
||||||
|
round_participant.course_handicap`/`playing_handicap` for runder
|
||||||
|
deltakeren allerede er satt opp i, forblir UENDRET (samme "ingen
|
||||||
|
retroaktiv omskriving av frosne runde-verdier"-prinsipp) -- kun
|
||||||
|
NYE rundepåmeldinger fra dette punktet av bruker den rettede
|
||||||
|
indeksen."""
|
||||||
|
|
||||||
class_id: str | None = None
|
class_id: str | None = None
|
||||||
stat_level: StatLevel | None = None
|
stat_level: StatLevel | None = None
|
||||||
|
handicap_index_snapshot: float | None = Field(default=None, ge=-10, le=54)
|
||||||
|
|
||||||
|
|
||||||
class TournamentParticipantOut(BaseModel):
|
class TournamentParticipantOut(BaseModel):
|
||||||
|
|
@ -2114,7 +2131,12 @@ async def _attach_stroke_play_columns(
|
||||||
if tied and not not_started_group and max_sequence is None:
|
if tied and not not_started_group and max_sequence is None:
|
||||||
tiebreak_method = tiebreak_winner_method if rank == 1 else tiebreak_field_method
|
tiebreak_method = tiebreak_winner_method if rank == 1 else tiebreak_field_method
|
||||||
|
|
||||||
if tiebreak_method != "none":
|
# 'playoff' (migrasjon 086) behandles IKKE her -- appen kan ikke
|
||||||
|
# selv avgjøre et omspill (det skjer på banen), så en "playoff"-
|
||||||
|
# gruppe faller gjennom til else-grenen under akkurat som 'none':
|
||||||
|
# delt plassering, ingen automatisk oppløsning. Forskjellen er
|
||||||
|
# ren informasjon (tiebreak_playoff_rule), ikke rangeringslogikk.
|
||||||
|
if tiebreak_method in ("countback", "lowest_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)
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,8 @@ _TOURNAMENT_COLUMNS = """
|
||||||
visibility, description, registration_deadline, registration_capacity,
|
visibility, description, registration_deadline, registration_capacity,
|
||||||
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 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).
|
||||||
|
|
@ -110,6 +111,13 @@ class Tournament(BaseModel):
|
||||||
# feltet kan ha ulik metode.
|
# feltet kan ha ulik metode.
|
||||||
tiebreak_winner_method: str
|
tiebreak_winner_method: str
|
||||||
tiebreak_field_method: str
|
tiebreak_field_method: str
|
||||||
|
# Omspill (migrasjon 086, 2026-08-18) -- 'playoff' er KUN gyldig for
|
||||||
|
# tiebreak_winner_method (ikke felt-metoden), se individual_
|
||||||
|
# tournaments.py sin kommentar for hvorfor rangeringen behandler den
|
||||||
|
# likt som 'none'. tiebreak_playoff_rule er fritekst organisator
|
||||||
|
# setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i
|
||||||
|
# Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
||||||
|
tiebreak_playoff_rule: str | None
|
||||||
|
|
||||||
|
|
||||||
def _tournament_from_row(row) -> Tournament:
|
def _tournament_from_row(row) -> Tournament:
|
||||||
|
|
@ -143,7 +151,7 @@ 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_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule
|
||||||
FROM tournament t
|
FROM tournament t
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT tournament_id,
|
SELECT tournament_id,
|
||||||
|
|
@ -237,9 +245,11 @@ class TournamentUpdate(BaseModel):
|
||||||
cut_after_round: int | None = Field(default=None, ge=1)
|
cut_after_round: int | None = Field(default=None, ge=1)
|
||||||
cut_size: int | None = Field(default=None, gt=0)
|
cut_size: int | None = Field(default=None, gt=0)
|
||||||
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
|
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
|
||||||
# se Tournament sin kommentar.
|
# se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig
|
||||||
tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
|
# for vinner-metoden, ikke felt-metoden.
|
||||||
|
tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|playoff)$")
|
||||||
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
|
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
|
||||||
|
tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000)
|
||||||
# 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.
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,10 @@ type ApiTournamentInfo = {
|
||||||
// plasseringsvisning. "none" = delt plassering (uendret oppførsel).
|
// plasseringsvisning. "none" = delt plassering (uendret oppførsel).
|
||||||
tiebreak_winner_method: string
|
tiebreak_winner_method: string
|
||||||
tiebreak_field_method: string
|
tiebreak_field_method: string
|
||||||
|
// Omspill (migrasjon 086, 2026-08-18) -- "playoff" er kun gyldig for
|
||||||
|
// tiebreak_winner_method. Fritekst-regel vist proaktivt i Oppsett OG
|
||||||
|
// på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
||||||
|
tiebreak_playoff_rule: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiParticipant = {
|
type ApiParticipant = {
|
||||||
|
|
@ -558,16 +562,25 @@ export function IndividualTournamentDetail({
|
||||||
// Tiebreak (migrasjon 085, 2026-08-18) -- ren KONFIGURASJON, samme
|
// Tiebreak (migrasjon 085, 2026-08-18) -- ren KONFIGURASJON, samme
|
||||||
// optimistiske PATCH-mønster som scoringsmetode/cut over. Påvirker
|
// optimistiske PATCH-mønster som scoringsmetode/cut over. Påvirker
|
||||||
// ALDRI Cut-grensen, kun sluttresultatets plasseringsvisning.
|
// ALDRI Cut-grensen, kun sluttresultatets plasseringsvisning.
|
||||||
async function updateTiebreakConfig(winner: string, field: string) {
|
async function updateTiebreakConfig(winner: string, field: string, playoffRule: string | null) {
|
||||||
if (!tournament) return
|
if (!tournament) return
|
||||||
const previous = tournament
|
const previous = tournament
|
||||||
setTournament({ ...tournament, tiebreak_winner_method: winner, tiebreak_field_method: field })
|
setTournament({
|
||||||
|
...tournament,
|
||||||
|
tiebreak_winner_method: winner,
|
||||||
|
tiebreak_field_method: field,
|
||||||
|
tiebreak_playoff_rule: playoffRule,
|
||||||
|
})
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ tiebreak_winner_method: winner, tiebreak_field_method: field }),
|
body: JSON.stringify({
|
||||||
|
tiebreak_winner_method: winner,
|
||||||
|
tiebreak_field_method: field,
|
||||||
|
tiebreak_playoff_rule: playoffRule,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error()
|
if (!res.ok) throw new Error()
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -748,6 +761,28 @@ export function IndividualTournamentDetail({
|
||||||
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
|
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retting av EN deltakers HCP direkte i turneringen (2026-08-18) --
|
||||||
|
// spillerpoolen (ADR-085) endrer kun grunnlaget for FREMTIDIGE
|
||||||
|
// rundepåmeldinger, aldri en allerede satt handicap_index_snapshot her.
|
||||||
|
// Rører ALDRI allerede opprettede tournament_round_participant.course_
|
||||||
|
// handicap for runder deltakeren allerede er satt opp i (samme "frosset
|
||||||
|
// snapshot"-prinsipp som ADR-085 sin bulk-import allerede har).
|
||||||
|
async function setParticipantHandicap(participantId: string, handicap: number | null) {
|
||||||
|
setError(null)
|
||||||
|
const res = await fetch(`${base}/participants/${participantId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ handicap_index_snapshot: handicap }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(await errorMessage(res, "Klarte ikke å endre handicap."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated: ApiParticipant = await res.json()
|
||||||
|
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
|
||||||
|
}
|
||||||
|
|
||||||
async function createClass(name: string, defaultTeeId: string | null) {
|
async function createClass(name: string, defaultTeeId: string | null) {
|
||||||
setError(null)
|
setError(null)
|
||||||
const res = await fetch(`${base}/classes`, {
|
const res = await fetch(`${base}/classes`, {
|
||||||
|
|
@ -970,6 +1005,7 @@ export function IndividualTournamentDetail({
|
||||||
onDeleteClass={deleteClass}
|
onDeleteClass={deleteClass}
|
||||||
onSetParticipantClass={setParticipantClass}
|
onSetParticipantClass={setParticipantClass}
|
||||||
onSetParticipantStatLevel={setParticipantStatLevel}
|
onSetParticipantStatLevel={setParticipantStatLevel}
|
||||||
|
onSetParticipantHandicap={setParticipantHandicap}
|
||||||
onError={setError}
|
onError={setError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1067,6 +1103,7 @@ function SetupTab({
|
||||||
onDeleteClass,
|
onDeleteClass,
|
||||||
onSetParticipantClass,
|
onSetParticipantClass,
|
||||||
onSetParticipantStatLevel,
|
onSetParticipantStatLevel,
|
||||||
|
onSetParticipantHandicap,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
base: string
|
base: string
|
||||||
|
|
@ -1081,7 +1118,7 @@ function SetupTab({
|
||||||
onUpdateFlagMapVisible: (visible: boolean) => void
|
onUpdateFlagMapVisible: (visible: boolean) => void
|
||||||
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) => Promise<void>
|
onUpdateTiebreakConfig: (winner: string, field: string, playoffRule: string | null) => 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>
|
||||||
|
|
@ -1098,6 +1135,7 @@ function SetupTab({
|
||||||
participantId: string,
|
participantId: string,
|
||||||
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise<void>
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||||
|
|
@ -1237,6 +1275,7 @@ function SetupTab({
|
||||||
onRemoveParticipant={onRemoveParticipant}
|
onRemoveParticipant={onRemoveParticipant}
|
||||||
onSetClass={onSetParticipantClass}
|
onSetClass={onSetParticipantClass}
|
||||||
onSetStatLevel={onSetParticipantStatLevel}
|
onSetStatLevel={onSetParticipantStatLevel}
|
||||||
|
onSetHandicap={onSetParticipantHandicap}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -1350,8 +1389,20 @@ function CutCard({
|
||||||
// organisator ved oppsett, IKKE fastlåst i koden -- derfor to
|
// organisator ved oppsett, IKKE fastlåst i koden -- derfor to
|
||||||
// uavhengige nedtrekk. Samme "vanlig innstillingskort"-mønster som
|
// uavhengige nedtrekk. Samme "vanlig innstillingskort"-mønster som
|
||||||
// CutCard rett over.
|
// CutCard rett over.
|
||||||
const TIEBREAK_OPTIONS: { value: string; label: string; hint: string }[] = [
|
// "Omspill" (migrasjon 086, 2026-08-18) -- KUN et gyldig valg for
|
||||||
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T1», «T5» osv." },
|
// vinneren (bruker sin egen formulering: "ved lik score for LEDER"),
|
||||||
|
// ikke for resten av feltet -- appen kan ikke selv avgjøre et omspill,
|
||||||
|
// det skjer på banen. tiebreak_playoff_rule er fritekst vist proaktivt
|
||||||
|
// her OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
||||||
|
const TIEBREAK_OPTIONS_WINNER: { value: string; label: string; hint: string }[] = [
|
||||||
|
{ 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: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
|
||||||
|
{ value: "playoff", label: "Omspill", hint: "Avgjøres på banen -- beskriv formatet under, vises til alle før turneringen" },
|
||||||
|
]
|
||||||
|
|
||||||
|
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: "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" },
|
||||||
]
|
]
|
||||||
|
|
@ -1361,17 +1412,21 @@ function TiebreakCard({
|
||||||
onUpdateConfig,
|
onUpdateConfig,
|
||||||
}: {
|
}: {
|
||||||
tournament: ApiTournamentInfo
|
tournament: ApiTournamentInfo
|
||||||
onUpdateConfig: (winner: string, field: string) => Promise<void>
|
onUpdateConfig: (winner: string, field: string, playoffRule: string | null) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const [winner, setWinner] = useState(tournament.tiebreak_winner_method)
|
const [winner, setWinner] = useState(tournament.tiebreak_winner_method)
|
||||||
const [field, setField] = useState(tournament.tiebreak_field_method)
|
const [field, setField] = useState(tournament.tiebreak_field_method)
|
||||||
|
const [playoffRule, setPlayoffRule] = useState(tournament.tiebreak_playoff_rule ?? "")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const dirty = winner !== tournament.tiebreak_winner_method || field !== tournament.tiebreak_field_method
|
const dirty =
|
||||||
|
winner !== tournament.tiebreak_winner_method ||
|
||||||
|
field !== tournament.tiebreak_field_method ||
|
||||||
|
playoffRule !== (tournament.tiebreak_playoff_rule ?? "")
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
await onUpdateConfig(winner, field)
|
await onUpdateConfig(winner, field, winner === "playoff" ? playoffRule.trim() || null : null)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1391,14 +1446,14 @@ function TiebreakCard({
|
||||||
onChange={(e) => setWinner(e.target.value)}
|
onChange={(e) => setWinner(e.target.value)}
|
||||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
{TIEBREAK_OPTIONS.map((o) => (
|
{TIEBREAK_OPTIONS_WINNER.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
{o.label}
|
{o.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{TIEBREAK_OPTIONS.find((o) => o.value === winner)?.hint}
|
{TIEBREAK_OPTIONS_WINNER.find((o) => o.value === winner)?.hint}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-1 flex-col gap-1.5">
|
<label className="flex flex-1 flex-col gap-1.5">
|
||||||
|
|
@ -1408,18 +1463,34 @@ function TiebreakCard({
|
||||||
onChange={(e) => setField(e.target.value)}
|
onChange={(e) => setField(e.target.value)}
|
||||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
{TIEBREAK_OPTIONS.map((o) => (
|
{TIEBREAK_OPTIONS_FIELD.map((o) => (
|
||||||
<option key={o.value} value={o.value}>
|
<option key={o.value} value={o.value}>
|
||||||
{o.label}
|
{o.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{TIEBREAK_OPTIONS.find((o) => o.value === field)?.hint}
|
{TIEBREAK_OPTIONS_FIELD.find((o) => o.value === field)?.hint}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{winner === "playoff" && (
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Omspill-format</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={playoffRule}
|
||||||
|
onChange={(e) => setPlayoffRule(e.target.value)}
|
||||||
|
placeholder="F.eks. Sudden death fra hull 18, til vinner er kåret."
|
||||||
|
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Vises på leaderboardet slik at deltakerne kjenner formatet før turneringen.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
|
|
@ -1605,6 +1676,7 @@ function ParticipantsCard({
|
||||||
onRemoveParticipant,
|
onRemoveParticipant,
|
||||||
onSetClass,
|
onSetClass,
|
||||||
onSetStatLevel,
|
onSetStatLevel,
|
||||||
|
onSetHandicap,
|
||||||
}: {
|
}: {
|
||||||
organizationId: string
|
organizationId: string
|
||||||
tournamentId: string
|
tournamentId: string
|
||||||
|
|
@ -1620,6 +1692,7 @@ function ParticipantsCard({
|
||||||
participantId: string,
|
participantId: string,
|
||||||
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
onSetHandicap: (participantId: string, handicap: number | null) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const [showImport, setShowImport] = useState(false)
|
const [showImport, setShowImport] = useState(false)
|
||||||
|
|
||||||
|
|
@ -1660,9 +1733,11 @@ function ParticipantsCard({
|
||||||
<li key={p.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
<li key={p.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
|
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
|
||||||
<span className="text-xs font-semibold text-muted-foreground">
|
<HcpInput
|
||||||
{p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
|
playerName={p.player_name}
|
||||||
</span>
|
value={p.handicap_index_snapshot}
|
||||||
|
onCommit={(v) => onSetHandicap(p.id, v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{classes.length > 0 && (
|
{classes.length > 0 && (
|
||||||
<select
|
<select
|
||||||
|
|
@ -1720,6 +1795,56 @@ function ParticipantsCard({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retting av EN deltakers HCP direkte i turneringen (2026-08-18) --
|
||||||
|
// lokal utkast-tilstand, forpliktet KUN på blur (ikke per tastetrykk) --
|
||||||
|
// samme mønster som tallfelt ellers i appen. Tomt felt = fjern HCP-en
|
||||||
|
// (spilleren mister evt. adgang til netto-/Stableford-runder som krever
|
||||||
|
// den, samme validering som ved ny påmelding).
|
||||||
|
function HcpInput({
|
||||||
|
playerName,
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
}: {
|
||||||
|
playerName: string
|
||||||
|
value: number | null
|
||||||
|
onCommit: (next: number | null) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(value !== null ? String(value) : "")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value !== null ? String(value) : "")
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const trimmed = draft.trim()
|
||||||
|
if (trimmed === "") {
|
||||||
|
if (value !== null) void onCommit(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const n = Number(trimmed)
|
||||||
|
if (Number.isFinite(n) && n !== value) void onCommit(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">Hcp</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step={0.1}
|
||||||
|
min={-10}
|
||||||
|
max={54}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
aria-label={`HCP for ${playerName}`}
|
||||||
|
placeholder="—"
|
||||||
|
className="h-8 w-16 rounded-lg border border-border bg-background px-2 text-xs font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Splitter et fritekst-navn i for-/etternavn -- samme "første ord =
|
// Splitter et fritekst-navn i for-/etternavn -- samme "første ord =
|
||||||
// fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin
|
// fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin
|
||||||
// backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra
|
// backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra
|
||||||
|
|
@ -4456,8 +4581,26 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Omspill-varsel (migrasjon 086, 2026-08-18) -- vist proaktivt her,
|
||||||
|
// FØR en eventuell uavgjort faktisk oppstår ("Dette bør informeres om
|
||||||
|
// før turneringen"), ikke bare når leaderboardet viser en delt
|
||||||
|
// førsteplass. Én global innstilling, vises én gang over gruppene
|
||||||
|
// (ikke per klasse-seksjon).
|
||||||
|
const playoffNote =
|
||||||
|
isStrokePlay && tournament.tiebreak_winner_method === "playoff" && tournament.tiebreak_playoff_rule
|
||||||
|
? tournament.tiebreak_playoff_rule
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{playoffNote && (
|
||||||
|
<div className="flex items-start gap-3 rounded-2xl border border-gold bg-gold/10 px-4 py-3">
|
||||||
|
<Trophy aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-gold-foreground" />
|
||||||
|
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
|
||||||
|
Ved likt resultat for 1. plass: {playoffNote}
|
||||||
|
</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 && (
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,38 @@ async def test_tiebreak_method_none_keeps_shared_position(pool):
|
||||||
assert by_id[p2].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):
|
async def test_tiebreak_never_affects_cut_boundary(pool):
|
||||||
"""Bruker bekreftet eksplisitt: tiebreak påvirker KUN sluttresultatet,
|
"""Bruker bekreftet eksplisitt: tiebreak påvirker KUN sluttresultatet,
|
||||||
ALDRI selve cut-grensen -- full delt plass på grensen uansett
|
ALDRI selve cut-grensen -- full delt plass på grensen uansett
|
||||||
|
|
|
||||||
70
tests/test_tournament_participant_hcp.py
Normal file
70
tests/test_tournament_participant_hcp.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""
|
||||||
|
Retting av en spillers handicap-indeks DIREKTE i en turnering
|
||||||
|
(2026-08-18). Bruker: "Hvordan ... justerer hcp på spillere?" -- å
|
||||||
|
redigere spilleren i org-spillerpoolen (ADR-085) rører aldri en
|
||||||
|
allerede satt `tournament_participant.handicap_index_snapshot` (samme
|
||||||
|
"frosset snapshot"-filosofi som lagturneringers team_roster), så det
|
||||||
|
manglet en direkte vei til å rette EN SPESIFIKK deltakers indeks i
|
||||||
|
turneringen den faktisk gjelder for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.routers.individual_tournaments import (
|
||||||
|
TournamentParticipantUpdate,
|
||||||
|
update_tournament_participant,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
create_org,
|
||||||
|
create_course,
|
||||||
|
create_player,
|
||||||
|
create_tee,
|
||||||
|
create_tournament,
|
||||||
|
create_tournament_participant,
|
||||||
|
create_tournament_round,
|
||||||
|
create_tournament_round_participant,
|
||||||
|
)
|
||||||
|
|
||||||
|
import app.db as app_db
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_handicap_snapshot(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
player_id = await create_player(org_id, display_name="Spiller")
|
||||||
|
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE tournament_participant SET handicap_index_snapshot = 24.0 WHERE id = $1", tp_id
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await update_tournament_participant(
|
||||||
|
tournament_id, tp_id, TournamentParticipantUpdate(handicap_index_snapshot=11.5), organization_id=org_id
|
||||||
|
)
|
||||||
|
assert updated.handicap_index_snapshot == 11.5
|
||||||
|
|
||||||
|
|
||||||
|
async def test_updating_snapshot_does_not_retroactively_change_already_frozen_round_handicap(pool):
|
||||||
|
"""Samme "frosset snapshot"-prinsipp som PlayerUpdate allerede
|
||||||
|
dokumenterer for team_roster -- en rettelse i tournament_participant
|
||||||
|
skal ALDRI skrive over et allerede satt tournament_round_participant.
|
||||||
|
course_handicap/playing_handicap for en runde deltakeren allerede er
|
||||||
|
satt opp i."""
|
||||||
|
org_id = await create_org()
|
||||||
|
tournament_id = await create_tournament(org_id)
|
||||||
|
course_id = await create_course(org_id)
|
||||||
|
tee_id = await create_tee(org_id, course_id)
|
||||||
|
round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
||||||
|
player_id = await create_player(org_id, display_name="Spiller")
|
||||||
|
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, course_handicap=10)
|
||||||
|
|
||||||
|
await update_tournament_participant(
|
||||||
|
tournament_id, tp_id, TournamentParticipantUpdate(handicap_index_snapshot=2.0), organization_id=org_id
|
||||||
|
)
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
frozen = await conn.fetchval(
|
||||||
|
"SELECT course_handicap FROM tournament_round_participant WHERE id = $1", rp_id
|
||||||
|
)
|
||||||
|
assert frozen == 10 # uendret, ikke omregnet fra den nye indeksen
|
||||||
Loading…
Reference in a new issue