Ny delt app/hole_history.py bygger broen mellom frittstående runders og org-turneringers to ulike bane-identitetssystemer (teeoff facility+course-id vs. org-scopet course.external_course_ref, og tilsvarende for GolfAPI) -- custom/håndlagde baner ekskluderes bevisst, ordinære baner telles med fra begge kilder og slås sammen. Turneringssiden håndterer at spilleren kan ha spilt i flere organisasjoner via samme N+1-per-org-mønster /auth/me allerede bruker (player_organizations_for_user, migrasjon 015). To nye GET .../history-endepunkt (rounds.py og individual_tournaments.py) returnerer samme kombinerte historikk uansett hvilken side som spør. Nytt HoleHistoryPanel i hole-stat-inputs.tsx viser et ekspanderbart sammendrag (antall ganger, snitt slag, GIR%, putter) + enkeltinstanser, lagt til i ScoringWizard og det nye HoleStatsSheet (ADR-071). Ingen migrasjon -- ren lesefunksjon oppå migrasjon 075. Se ADR-072/ CHANGELOG for full detalj og verifisering (73 backend-tester, inkl. integrasjonstest som beviser selve bane-broen på tvers av organisasjoner). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
256 lines
10 KiB
Python
256 lines
10 KiB
Python
"""
|
|
Spillerens per-hull-historikk (ADR-072). To lag med tester:
|
|
|
|
1. Rene enhetstester av summarize_hole_history() -- ingen db, bevis at
|
|
GIR%/fairway%/gjennomsnitt regnes riktig og hopper over rader uten data.
|
|
2. Integrasjonstester mot ekte router-funksjoner (samme mønster som
|
|
test_tournament_hole_stats.py/test_concurrency_version_check.py) --
|
|
beviser selve bane-broen: samme fysiske (teeoff-)bane spilt både som
|
|
frittstående runde OG i en org-turnering i en ANNEN organisasjon enn
|
|
spilleren selv eier noe i, skal telles sammen. Custom-baner og
|
|
gjestespillere skal BEVISST ekskluderes (bruker-bekreftet scope).
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import date
|
|
|
|
from app.auth import CurrentUser
|
|
from app.hole_history import (
|
|
HoleInstance,
|
|
hole_history_for_user,
|
|
resolve_personal_round_course_key,
|
|
summarize_hole_history,
|
|
)
|
|
from app.routers.individual_tournaments import (
|
|
HoleUpdate as TournamentHoleUpdate,
|
|
update_hole as tournament_update_hole,
|
|
)
|
|
from app.routers.rounds import HoleUpdate as RoundHoleUpdate, get_hole_history, update_hole as round_update_hole
|
|
from app.routers.individual_tournaments import get_round_participant_hole_history
|
|
|
|
from tests.conftest import (
|
|
add_membership,
|
|
create_hole,
|
|
create_org,
|
|
create_org_hole,
|
|
create_course,
|
|
create_participant,
|
|
create_player,
|
|
create_round,
|
|
create_tee,
|
|
create_tournament,
|
|
create_tournament_participant,
|
|
create_tournament_round,
|
|
create_tournament_round_participant,
|
|
create_user,
|
|
)
|
|
|
|
import app.db as app_db
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. summarize_hole_history() -- ren funksjon, ingen db.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _instance(**overrides) -> HoleInstance:
|
|
defaults = dict(
|
|
source="personal",
|
|
played_at=date(2026, 1, 1),
|
|
label="Runde",
|
|
score=4,
|
|
par=4,
|
|
putts=None,
|
|
tee_shot_result=None,
|
|
approach_result=None,
|
|
chip_count=None,
|
|
bunker_shot_count=None,
|
|
penalty_strokes=None,
|
|
first_putt_distance_bucket=None,
|
|
)
|
|
defaults.update(overrides)
|
|
return HoleInstance(**defaults)
|
|
|
|
|
|
def test_summarize_empty_returns_none():
|
|
assert summarize_hole_history([]) is None
|
|
|
|
|
|
def test_summarize_counts_and_averages():
|
|
instances = [
|
|
_instance(source="personal", score=4, par=4),
|
|
_instance(source="tournament", score=6, par=4),
|
|
]
|
|
out = summarize_hole_history(instances)
|
|
assert out.times_played == 2
|
|
assert out.times_personal == 1
|
|
assert out.times_tournament == 1
|
|
assert out.average_score == 5.0
|
|
assert out.average_score_vs_par == 1.0
|
|
assert out.best_score == 4
|
|
assert out.worst_score == 6
|
|
|
|
|
|
def test_summarize_gir_percent_only_over_rows_with_putts():
|
|
instances = [
|
|
_instance(score=4, par=4, putts=2), # 4-2=2 <= 4-2=2 -> GIR
|
|
_instance(score=5, par=4, putts=3), # 5-3=2 <= 2 -> GIR
|
|
_instance(score=6, par=4, putts=None), # ingen putter registrert -- ekskludert fra GIR%
|
|
]
|
|
out = summarize_hole_history(instances)
|
|
assert out.gir_percent == 100.0
|
|
assert out.average_putts == 2.5 # snitt kun over de to MED putter registrert
|
|
|
|
|
|
def test_summarize_gir_percent_none_when_no_putts_data_anywhere():
|
|
instances = [_instance(score=4, par=4, putts=None)]
|
|
out = summarize_hole_history(instances)
|
|
assert out.gir_percent is None
|
|
assert out.average_putts is None
|
|
|
|
|
|
def test_summarize_fairway_percent_excludes_par_3_and_missing_data():
|
|
instances = [
|
|
_instance(par=4, tee_shot_result="fairway"),
|
|
_instance(par=4, tee_shot_result="left"),
|
|
_instance(par=3, tee_shot_result="fairway"), # par 3 -- ekskludert
|
|
_instance(par=5, tee_shot_result=None), # ingen utslagsdata -- ekskludert
|
|
]
|
|
out = summarize_hole_history(instances)
|
|
assert out.fairway_hit_percent == 50.0 # 1 av 2 par-4/5-rader MED data
|
|
|
|
|
|
def test_summarize_orders_most_recent_first_and_undated_last():
|
|
instances = [
|
|
_instance(played_at=date(2026, 1, 1), score=4),
|
|
_instance(played_at=date(2026, 6, 1), score=5),
|
|
_instance(played_at=None, score=6),
|
|
]
|
|
out = summarize_hole_history(instances)
|
|
assert [i.score for i in out.instances] == [5, 4, 6]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Integrasjon: bane-bro + eksklusjoner.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _score_personal_hole(round_id, participant_id, user_id, hole_number, score, putts=None):
|
|
user = CurrentUser(user_id=user_id)
|
|
await round_update_hole(
|
|
round_id, participant_id, hole_number,
|
|
RoundHoleUpdate(score=score, putts=putts, expected_version=None),
|
|
user=user,
|
|
)
|
|
|
|
|
|
async def _score_tournament_hole(tournament_id, round_id, rp_id, org_id, user_id, hole_number, score, putts=None):
|
|
user = CurrentUser(user_id=user_id)
|
|
await tournament_update_hole(
|
|
tournament_id, round_id, rp_id, hole_number,
|
|
TournamentHoleUpdate(gross_strokes=score, putts=putts, expected_version=None),
|
|
organization_id=org_id, user=user,
|
|
)
|
|
|
|
|
|
async def test_teeoff_bridge_combines_personal_and_tournament_history(pool):
|
|
"""Samme spiller, samme fysiske (teeoff-)bane -- én frittstående runde
|
|
OG én turneringsrunde i en organisasjon spilleren IKKE eier noe i selv
|
|
(kun har en player-rad koblet til app_user der). Historikken må vise
|
|
BEGGE, uansett hvilken side spørringen kommer fra."""
|
|
user_id = await create_user()
|
|
|
|
# Frittstående runde -- create_round() sin faste teeoff-bane-fixture
|
|
# (facility='test-facility', course='test-course').
|
|
async with app_db.plain_connection() as conn:
|
|
round_id = await create_round(conn, user_id)
|
|
participant_id = await create_participant(conn, round_id, user_id=user_id, is_owner=True)
|
|
await create_hole(conn, participant_id, hole_number=7, par=4, stroke_index=9)
|
|
await _score_personal_hole(round_id, participant_id, user_id, hole_number=7, score=4, putts=2)
|
|
|
|
# Org-turnering på en bane importert med SAMME teeoff-identitet.
|
|
org_id = await create_org()
|
|
await add_membership(org_id, user_id, role="member")
|
|
tournament_id = await create_tournament(org_id)
|
|
course_id = await create_course(org_id)
|
|
async with app_db.org_connection(org_id) as conn:
|
|
await conn.execute(
|
|
"UPDATE course SET source = 'official', external_course_ref = 'test-facility:test-course' WHERE id = $1",
|
|
course_id,
|
|
)
|
|
for n in range(1, 19):
|
|
await create_org_hole(org_id, course_id, hole_number=n, par=4 if n != 7 else 4, stroke_index=n)
|
|
tee_id = await create_tee(org_id, course_id)
|
|
tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
|
player_id = await create_player(org_id, user_id=user_id)
|
|
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
|
rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id)
|
|
await _score_tournament_hole(tournament_id, tournament_round_id, rp_id, org_id, user_id, hole_number=7, score=5, putts=1)
|
|
|
|
key = ("teeoff", "test-facility", "test-course")
|
|
combined = await hole_history_for_user(user_id, key, 7)
|
|
assert combined.times_played == 2
|
|
assert combined.times_personal == 1
|
|
assert combined.times_tournament == 1
|
|
assert sorted(i.score for i in combined.instances) == [4, 5]
|
|
|
|
# Begge endepunktene skal returnere SAMME kombinerte historikk.
|
|
from_personal_endpoint = await get_hole_history(
|
|
round_id, participant_id, 7, user=CurrentUser(user_id=user_id)
|
|
)
|
|
assert from_personal_endpoint.times_played == 2
|
|
|
|
from_tournament_endpoint = await get_round_participant_hole_history(
|
|
tournament_id, tournament_round_id, rp_id, 7, organization_id=org_id
|
|
)
|
|
assert from_tournament_endpoint.times_played == 2
|
|
|
|
|
|
async def test_custom_personal_course_is_excluded(pool):
|
|
"""Håndlaget bane (course_source='custom', ingen GolfAPI-kobling) --
|
|
resolve_personal_round_course_key skal returnere None, IKKE en snublende
|
|
match mot noe annet."""
|
|
user_id = await create_user()
|
|
async with app_db.plain_connection() as conn:
|
|
personal_course_id = str(uuid.uuid4())
|
|
await conn.execute(
|
|
"INSERT INTO personal_course (id, created_by_user_id, name) VALUES ($1, $2, 'Hjemmebanen')",
|
|
personal_course_id, user_id,
|
|
)
|
|
for n in range(1, 19):
|
|
await conn.execute(
|
|
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) "
|
|
"VALUES ($1, $2, 4, $3)",
|
|
personal_course_id, n, n,
|
|
)
|
|
round_id = str(uuid.uuid4())
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO round (id, owner_user_id, course_source, personal_course_id,
|
|
course_name_snapshot, tee_name_snapshot, played_at)
|
|
VALUES ($1, $2, 'custom', $3, 'Hjemmebanen', 'Gul', CURRENT_DATE)
|
|
""",
|
|
round_id, user_id, personal_course_id,
|
|
)
|
|
key = await resolve_personal_round_course_key(conn, round_id)
|
|
assert key is None
|
|
|
|
|
|
async def test_guest_tournament_participant_has_no_history(pool):
|
|
"""player.user_id IS NULL (gjest, aldri registrert/lenket) -- historikk-
|
|
endepunktet skal svare None (ikke feile), samme "skjul panelet"-
|
|
kontrakt som en custom bane."""
|
|
org_id = await create_org()
|
|
tournament_id = await create_tournament(org_id)
|
|
course_id = await create_course(org_id)
|
|
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)
|
|
tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id)
|
|
# user_id utelates bevisst -- create_player() sin default er NULL.
|
|
player_id = await create_player(org_id)
|
|
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
|
rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id)
|
|
|
|
out = await get_round_participant_hole_history(
|
|
tournament_id, tournament_round_id, rp_id, 1, organization_id=org_id
|
|
)
|
|
assert out is None
|