diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 6bb8d48..bdd3f55 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -7310,6 +7310,96 @@ Containeren startet rent. --- +## ADR-074: Order of Merit -- lag-leaderboard + eclectic-total-bug fikset ("Steg 0+1" av gjenstår-listen) (2026-08-15) + +Foranledning: bruker ba om å gå videre med de to store gjenstående OOM- +punktene fra ADR-043 (spiller-OOM, 2026-08-04): lag-OOM sin faktiske +leaderboard-beregning (skjema fantes, `GET .../leaderboard` avviste +eksplisitt for `kind='team'`), og eclectic-aggregering på tvers av +lenkede turneringer. Utvidet til tre punkter da FEATURE_BACKLOG.md sin +"gjenstår"-liste viste seg å ha et tredje, ikke opprinnelig nevnt punkt +(offentlig/delt visning) -- bekreftet med bruker at alle tre tas i denne +runden (se ADR-075/076 for de to andre). + +**Bifunn tatt FØRST, uavhengig av resten:** `_contribution_for_entry` +(brukt av BÅDE eksisterende sum/snitt-OOM-er og den nye eclectic- +aggregeringen) leste alltid `entry.gross_total`/`net_total`/ +`stableford_total` for en lenket turnering -- ALDRI `entry. +eclectic_total`, selv når den lenkede turneringens `scoring_method` var +`eclectic_gross`/`net`/`stableford` (ADR-068, bygget 2026-08-14, EFTER +OOM-en selv 2026-08-04 -- aldri revidert siden). Konsekvens: en klubb som +lenket en eclectic-scoret turnering inn i en stableford/brutto/netto-OOM +fikk stille FEIL tall -- spillerens rå slagsum på tvers av rundene (et +tall som ikke engang avgjorde DEN turneringens egen vinner), i stedet +for dens faktiske "drømmerunde"-resultat. Fikset: `_contribution_for_ +entry` tar nå også imot turneringens `scoring_method` (allerede +tilgjengelig fra `_compute_individual_standings` sin returnerte +metadata, kun forkastet før) og leser `eclectic_total` når relevant. + +**Lag-OOM sin leaderboard** (ADR-043 Beslutning C, aldri bygget): +- Ny CRUD (`order_of_merit_team`/`_team_member`, tabellene fantes + allerede fra migrasjon 055 -- INGEN ny migrasjon i dette steget): + `POST/GET/DELETE .../order-of-merits/{id}/teams`, + `POST/DELETE .../teams/{id}/members`. Samme mønster som + `order_of_merit_link`-CRUD-en (eksisterer-sjekk + `translate_db_ + errors()`). `player_id`-FK-en i skjemaet er IKKE org-scopet (migrasjon + 055-avgjørelse) -- egen eksplisitt eksistenssjekk lagt til i + `add_order_of_merit_team_member` for å ikke stole blindt på klienten. +- Leaderboard-grenen for `kind='team'` erstatter den tidligere eksplisitte + avvisningen: for hvert lag, regn ut HVERT medlems allerede-etablerte + individuelle OOM-sluttresultat (samme `_player_eligibility`+ + `_aggregate_value`-par som spillernivået, faktorisert ut og delt), slå + deretter disse medlemsverdiene sammen med SAMME `order_of_merit_ + aggregate`-kall -- gjenbrukt EN GANG TIL på lagnivå, med OOM-ens + ALLEREDE LAGREDE `aggregation_mode`/`count_best_n`. **Bevisst + designvalg, ingen ny konfig**: ETT lagret aggregeringsvalg styrer + BEGGE nivåer -- `count_best_n=1` betyr dermed BÅDE "hver spillers + beste 1 av N turneringer" OG "lagets beste 1 av M medlemmer" samtidig + (bekreftet i test: `test_team_leaderboard_best_n_applies_at_both_ + levels`, ikke en overraskelse men en direkte konsekvens av + gjenbruket). `aggregation_mode='eclectic'` er allerede sperret for + `kind='team'` ved opprettelse (eksisterende sjekk, uendret) -- lagnivået + trenger derfor aldri håndtere eclectic. +- `OrderOfMeritLeaderboardEntry` sitt `player_id`/`player_name`-par + gjenbrukes UENDRET for lagnivået (bærer der lagets EGEN id/navn) -- + ingen ny modell, kalleren vet allerede OOM-ens `kind`. + +**Frontend**: `order-of-merit-detail.tsx` sin `kind === "team"`- +placeholder ("Lag-resultatliste er ikke bygget ennå") fjernet helt -- +`LeaderboardTable` var allerede generisk nok til å vise lag-resultater +uendret (leser kun `player_id`/`player_name`/`value`/`position`, ingen +spiller-spesifikk logikk å fjerne). Ny "Lag"-seksjon (kun for +`kind='team'`) mellom Innstillinger og Resultatliste -- samme liste+ +skjema-mønster som Lenkede turneringer-seksjonen: opprett/slett lag, +legg til/fjern medlem fra org-ens spillerpool (enkelt ` setName(e.target.value)} + placeholder="Lagnavn" + className="h-10 flex-1 rounded-lg text-sm" + /> + + + + )} + + ) +} + +function TeamCard({ + team, + players, + onDelete, + onAddMember, + onRemoveMember, +}: { + team: ApiOomTeam + players: ApiPlayer[] + onDelete: () => Promise + onAddMember: (playerId: string) => Promise + onRemoveMember: (memberId: string) => Promise +}) { + const memberPlayerIds = new Set(team.members.map((m) => m.player_id)) + const availablePlayers = players.filter((p) => !memberPlayerIds.has(p.id)) + const [selectedPlayerId, setSelectedPlayerId] = useState("") + + return ( +
  • +
    + {team.name} + +
    + + {team.members.length > 0 && ( +
      + {team.members.map((m) => ( +
    • + {m.player_name} + +
    • + ))} +
    + )} + + {availablePlayers.length > 0 && ( +
    + + +
    + )} +
  • + ) +} + function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: OomResultType }) { const eligible = entries.filter((e) => e.eligible) const ineligible = entries.filter((e) => !e.eligible) diff --git a/tests/test_order_of_merit.py b/tests/test_order_of_merit.py new file mode 100644 index 0000000..c327fd5 --- /dev/null +++ b/tests/test_order_of_merit.py @@ -0,0 +1,257 @@ +""" +Order of Merit (ADR-043) -- Steg 0/1/2-tester (ADR-074/075). Kaller de +faktiske router-funksjonene direkte (samme mønster som +test_tournament_hole_stats.py/test_hole_history.py). +""" + +import pytest +from fastapi import HTTPException + +from app.auth import CurrentUser +from app.routers.individual_tournaments import ( + HoleUpdate as TournamentHoleUpdate, + update_hole as tournament_update_hole, +) +from app.routers.order_of_merit import ( + OrderOfMeritCreate, + OrderOfMeritLinkCreate, + OrderOfMeritTeamCreate, + OrderOfMeritTeamMemberCreate, + add_order_of_merit_team_member, + create_order_of_merit, + create_order_of_merit_team, + delete_order_of_merit_team, + link_tournament_to_order_of_merit, + list_order_of_merit_teams, + order_of_merit_leaderboard, + remove_order_of_merit_team_member, +) + +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_all_holes(tournament_id, round_id, rp_id, org_id, user_id, score): + user = CurrentUser(user_id=user_id) + for n in range(1, 19): + await tournament_update_hole( + tournament_id, round_id, rp_id, n, + TournamentHoleUpdate(gross_strokes=score, expected_version=None), + organization_id=org_id, user=user, + ) + + +async def _setup_eclectic_tournament(org_id, user_id, method="eclectic_stableford"): + tournament_id = await create_tournament(org_id, name="Eclectic OOM-turnering") + course_id = await create_course(org_id, name="OOM 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, + ) + player_id = await create_player(org_id, user_id=user_id, display_name="OOM Spiller") + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + + round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1) + round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2) + rp1_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id, course_handicap=0) + rp2_id = await create_tournament_round_participant(org_id, round2_id, tp_id, tee_id, course_handicap=0) + + # Runde 1: par (4) på alle 18 hull -> 2 poeng/hull = 36 Stableford-poeng. + await _score_all_holes(tournament_id, round1_id, rp1_id, org_id, user_id, score=4) + # Runde 2: bogey (5) på alle 18 hull -> 1 poeng/hull = 18 Stableford-poeng. + await _score_all_holes(tournament_id, round2_id, rp2_id, org_id, user_id, score=5) + # Rå SUM over begge runder = 36 + 18 = 54. Eclectic (beste PER HULL, + # runde 1 vinner hver gang) = 18 hull * 2 poeng = 36 -- tydelig + # forskjellig fra rå-summen, beviser at riktig tall faktisk brukes. + return tournament_id, player_id + + +async def test_oom_stableford_uses_eclectic_total_not_raw_sum(pool): + """ADR-074: en lenket eclectic_stableford-turnering skal bidra med + entry.eclectic_total (36), IKKE rå stableford_total på tvers av + begge runder (54).""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, player_id = await _setup_eclectic_tournament(org_id, owner_id) + + oom = await create_order_of_merit( + OrderOfMeritCreate(name="Sesong-OOM", kind="player", result_type="stableford", aggregation_mode="sum"), + organization_id=org_id, + ) + await link_tournament_to_order_of_merit( + oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_id), organization_id=org_id + ) + + leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id) + assert len(leaderboard) == 1 + assert leaderboard[0].value == 36.0 + assert leaderboard[0].value != 54.0 + + +# --------------------------------------------------------------------------- +# Steg 1 (ADR-074): lag-OOM CRUD + leaderboard. +# --------------------------------------------------------------------------- + +async def _setup_stroke_gross_tournament(org_id, name, participants: list[tuple[str, int]]): + """participants: liste av (player_id, gross-score-per-hull) -- ETT + 18-hulls slagspill-runde, samme score på alle 18 hull for enkelhets + skyld.""" + tournament_id = await create_tournament(org_id, name=name) + course_id = await create_course(org_id, name=f"{name} bane") + 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 = 'stroke_gross' WHERE id = $1", + tournament_id, + ) + round_id = await create_tournament_round(org_id, tournament_id, course_id) + for player_id, score in participants: + 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=0) + user_id = None + async with app_db.org_connection(org_id) as conn: + user_id = await conn.fetchval("SELECT user_id::text FROM player WHERE id = $1", player_id) + await _score_all_holes(tournament_id, round_id, rp_id, org_id, user_id, score=score) + return tournament_id + + +async def test_team_leaderboard_sums_member_individual_totals(pool): + """To turneringer lenket til en gross/sum lag-OOM. Spiller 1: 72+90=162. + Spiller 2: 90+108=198. Ett lag med begge -> forventet lagsum 360.""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + user1_id = await create_user(display_name="Spiller Én") + user2_id = await create_user(display_name="Spiller To") + player1_id = await create_player(org_id, user_id=user1_id, display_name="Spiller Én") + player2_id = await create_player(org_id, user_id=user2_id, display_name="Spiller To") + + tournament_a = await _setup_stroke_gross_tournament( + org_id, "Turnering A", [(player1_id, 4), (player2_id, 5)] + ) + tournament_b = await _setup_stroke_gross_tournament( + org_id, "Turnering B", [(player1_id, 5), (player2_id, 6)] + ) + + oom = await create_order_of_merit( + OrderOfMeritCreate(name="Lag-OOM", kind="team", result_type="gross", aggregation_mode="sum"), + organization_id=org_id, + ) + await link_tournament_to_order_of_merit( + oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id + ) + await link_tournament_to_order_of_merit( + oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id + ) + + team = await create_order_of_merit_team( + oom.id, OrderOfMeritTeamCreate(name="Laget"), organization_id=org_id + ) + await add_order_of_merit_team_member( + oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player1_id), organization_id=org_id + ) + await add_order_of_merit_team_member( + oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player2_id), organization_id=org_id + ) + + leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id) + assert len(leaderboard) == 1 + assert leaderboard[0].player_id == team.id + assert leaderboard[0].player_name == "Laget" + assert leaderboard[0].value == 360.0 + assert leaderboard[0].results_available == 2 + assert leaderboard[0].results_counted == 2 + assert leaderboard[0].eligible is True + + +async def test_team_leaderboard_best_n_applies_at_both_levels(pool): + """Samme oppsett som over, men count_best_n=1. ADR-074: ETT lagret + aggregeringsvalg styrer BEGGE nivåer -- count_best_n=1 betyr derfor + "hver spillers beste 1 av 2 turneringer" OG "lagets beste 1 av 2 + medlemmer", ikke bare det ene. Spiller 1 sin beste (laveste) turnering + er 72, spiller 2 sin er 90 -- laget teller da KUN 72 (sitt beste + medlem, som allerede er redusert til sitt eget beste resultat).""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + user1_id = await create_user() + user2_id = await create_user() + player1_id = await create_player(org_id, user_id=user1_id) + player2_id = await create_player(org_id, user_id=user2_id) + + tournament_a = await _setup_stroke_gross_tournament(org_id, "A", [(player1_id, 4), (player2_id, 5)]) + tournament_b = await _setup_stroke_gross_tournament(org_id, "B", [(player1_id, 5), (player2_id, 6)]) + + oom = await create_order_of_merit( + OrderOfMeritCreate( + name="Lag-OOM beste-1", kind="team", result_type="gross", aggregation_mode="sum", count_best_n=1 + ), + organization_id=org_id, + ) + await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id) + await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id) + + team = await create_order_of_merit_team(oom.id, OrderOfMeritTeamCreate(name="Laget"), organization_id=org_id) + await add_order_of_merit_team_member(oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player1_id), organization_id=org_id) + await add_order_of_merit_team_member(oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player2_id), organization_id=org_id) + + leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id) + assert leaderboard[0].value == 72.0 + assert leaderboard[0].results_counted == 1 + + +async def test_team_crud_and_kind_guard(pool): + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + player_id = await create_player(org_id) + + player_oom = await create_order_of_merit( + OrderOfMeritCreate(name="Spiller-OOM", kind="player", result_type="gross"), organization_id=org_id + ) + with pytest.raises(HTTPException) as exc_info: + await create_order_of_merit_team( + player_oom.id, OrderOfMeritTeamCreate(name="Skal feile"), organization_id=org_id + ) + assert exc_info.value.status_code == 400 + + team_oom = await create_order_of_merit( + OrderOfMeritCreate(name="Lag-OOM", kind="team", result_type="gross"), organization_id=org_id + ) + team = await create_order_of_merit_team(team_oom.id, OrderOfMeritTeamCreate(name="Laget"), organization_id=org_id) + member = await add_order_of_merit_team_member( + team_oom.id, team.id, OrderOfMeritTeamMemberCreate(player_id=player_id), organization_id=org_id + ) + + teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id) + assert len(teams) == 1 + assert len(teams[0].members) == 1 + assert teams[0].members[0].player_id == player_id + + await remove_order_of_merit_team_member(team_oom.id, team.id, member.id, organization_id=org_id) + teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id) + assert len(teams[0].members) == 0 + + await delete_order_of_merit_team(team_oom.id, team.id, organization_id=org_id) + teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id) + assert len(teams) == 0