pridanie rebricka (leaderboard)

Nova polozka menu Rebricek a stranka /leaderboard: top 10 podla najlepsej hry (hry len s ludmi / s aspon jednym botom) a podla hodin v hre (hry do 3 h). Backend api/leaderboard.py + socket udalost get_leaderboard, testy v tests/test_history.py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YzEAACE9VgnLtXPuWGjwy
This commit is contained in:
timandClaude Sonnet 5.5 committed 2026-10-26 22:06:50 +01:00
1 parent 4000da0454
commit 04846aee26
11 files changed
+614 -12

No files matched your search

+174 -9
View File
@@ -10,6 +10,7 @@ import os
import tempfile
import unittest
import uuid
from datetime import datetime, timedelta
from types import SimpleNamespace
# Nastav DB/ENCRYPTION_KEY PRED importom db/api modulov -- engine sa vytvara pri importe.
@@ -23,10 +24,12 @@ os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
import pyotp # noqa: E402
from sqlalchemy import select # noqa: E402
from api import auth, history # noqa: E402
from api import auth, history, leaderboard # noqa: E402
from api import bots as bots_module # noqa: E402
from bridzik import ROUNDS_PER_SERIES # noqa: E402
from db import crypto # noqa: E402
from db.db import async_session, init_db # noqa: E402
from db.models import Player # noqa: E402
from db.models import Game, Guess, Player # noqa: E402
def run(coro):
@@ -47,19 +50,24 @@ def make_core(completed=True):
return SimpleNamespace(series=[series], is_completed=lambda: completed)
def make_players(n=4):
"""Zalozi `n` potvrdenych uctov, vrati ich player_id."""
ids = []
for _ in range(n):
username = "u_" + uuid.uuid4().hex[:8]
data = run(auth.register_account(username))
ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
ids.append(ident["player_id"])
return ids
class HistoryCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
run(init_db())
def _make_players(self, n=4):
ids = []
for _ in range(n):
username = "u_" + uuid.uuid4().hex[:8]
data = run(auth.register_account(username))
ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
ids.append(ident["player_id"])
return ids
return make_players(n)
@staticmethod
def _next_step_code(secret):
@@ -279,6 +287,163 @@ class HistoryCase(unittest.TestCase):
self.assertEqual(stored, crypto.hash_token(ident["token"]))
class LeaderboardCase(unittest.TestCase):
"""Pravidla rebrickov (api/leaderboard.py): radenie nad syntetickymi datami,
plus jeden integracny test cez DB."""
UN = {1: "ann", 2: "bob", 3: "cyd", 4: "dee", 5: "bot:heuristic-1", 6: "bot:heuristic-2"}
@staticmethod
def game(created_h, ended_h, has_bot=False, day=1):
base = datetime(2026, 10, day)
return {
"created_at": base + timedelta(hours=created_h),
"ended_at": base + timedelta(hours=ended_h),
"has_bot": has_bot,
}
def board(self, results, games, me=1):
return leaderboard.build_leaderboard(results, games, self.UN, me)
def test_points_split_humans_and_bots_and_hide_bots(self):
games = {"h": self.game(10, 11), "b": self.game(12, 13, has_bot=True)}
results = [
("h", 1, 200), ("h", 2, 150), ("h", 3, 100), ("h", 4, 50),
("b", 1, 300), ("b", 2, 120), ("b", 3, 90), ("b", 5, 400),
]
lb = self.board(results, games)
humans = lb["points"]["humans"]["top"]
self.assertEqual([(e["username"], e["points"]) for e in humans],
[("ann", 200), ("bob", 150), ("cyd", 100), ("dee", 50)])
bots = lb["points"]["bots"]["top"]
# bot (400) sa v rebricku nezobrazi, hrac ann ma v botej hre svojich 300
self.assertEqual([(e["username"], e["points"]) for e in bots],
[("ann", 300), ("bob", 120), ("cyd", 90)])
self.assertEqual([e["rank"] for e in bots], [1, 2, 3])
def test_points_one_row_per_player_best_game(self):
games = {"a": self.game(10, 11), "b": self.game(12, 13, day=2)}
results = [("a", 1, 100), ("b", 1, 180), ("a", 2, 150)]
top = self.board(results, games)["points"]["humans"]["top"]
self.assertEqual([(e["username"], e["points"]) for e in top],
[("ann", 180), ("bob", 150)])
def test_points_tie_goes_to_earlier_game(self):
games = {"early": self.game(10, 11, day=1), "late": self.game(10, 11, day=2)}
results = [("late", 1, 150), ("early", 2, 150)]
top = self.board(results, games)["points"]["humans"]["top"]
self.assertEqual([e["username"] for e in top], ["bob", "ann"])
def test_me_outside_top_n_gets_real_rank(self):
games = {"g": self.game(10, 11)}
usernames = {i: f"p{i}" for i in range(1, 16)}
results = [("g", i, 1000 - i) for i in range(1, 16)]
lb = leaderboard.build_leaderboard(results, games, usernames, 12)
board = lb["points"]["humans"]
self.assertEqual(len(board["top"]), leaderboard.TOP_N)
self.assertEqual(board["me"]["rank"], 12)
self.assertEqual(board["me"]["points"], 988)
self.assertNotIn(12, [e["player_id"] for e in board["top"]])
def test_me_is_none_without_a_game(self):
games = {"g": self.game(10, 11)}
lb = self.board([("g", 2, 100)], games, me=1)
self.assertIsNone(lb["points"]["humans"]["me"])
self.assertIsNone(lb["points"]["bots"]["me"])
self.assertIsNone(lb["hours"]["me"])
def test_hours_sum_includes_bot_games_and_counts_games(self):
games = {
"a": self.game(10, 11), # 1 h
"b": self.game(12, 12.5, has_bot=True), # 30 min, s botom sa pocita
}
results = [("a", 1, 10), ("b", 1, 10), ("b", 5, 10), ("a", 2, 10)]
hours = self.board(results, games)["hours"]
self.assertEqual([e["username"] for e in hours["top"]], ["ann", "bob"])
self.assertEqual(hours["top"][0]["seconds"], 5400)
self.assertEqual(hours["top"][0]["games"], 2)
self.assertEqual(hours["top"][1]["seconds"], 3600)
def test_hours_game_across_midnight_counts_but_long_game_does_not(self):
games = {
"night": { # 23:30 -> 00:15 nasledujuceho dna = 45 min
"created_at": datetime(2026, 10, 1, 23, 30),
"ended_at": datetime(2026, 10, 2, 0, 15),
"has_bot": False,
},
"resumed": { # obnovena o den neskor -> pauza by skreslila cas
"created_at": datetime(2026, 10, 1, 20, 0),
"ended_at": datetime(2026, 10, 2, 20, 30),
"has_bot": False,
},
}
results = [("night", 1, 10), ("resumed", 1, 10), ("resumed", 2, 10)]
hours = self.board(results, games)["hours"]
self.assertEqual([(e["username"], e["seconds"]) for e in hours["top"]],
[("ann", 2700)])
def test_hours_game_at_the_limit_counts(self):
games = {"g": self.game(10, 13)}
hours = self.board([("g", 1, 10)], games)["hours"]
self.assertEqual(hours["top"][0]["seconds"], 3 * 3600)
def test_bots_never_in_hours(self):
games = {"g": self.game(10, 11, has_bot=True)}
hours = self.board([("g", 1, 10), ("g", 5, 10), ("g", 6, 10)], games)["hours"]
self.assertEqual([e["username"] for e in hours["top"]], ["ann"])
# --- integracia s DB -----------------------------------------------------
@classmethod
def setUpClass(cls):
run(init_db())
@staticmethod
async def _add_game(gid, ids, totals, created, ended, rounds=history.FULL_GAME_ROUNDS):
"""Hra so `rounds` dohratymi kolami; cely sucet bodov hraca je v kole 0."""
async with async_session() as session:
session.add(Game(
id=gid, name="LB", player0_id=ids[0], player1_id=ids[1],
player2_id=ids[2], player3_id=ids[3], created_at=created, ended_at=ended,
))
for n in range(rounds):
for seat in range(4):
session.add(Guess(
game_id=gid, player_id=ids[seat], series_number=n // ROUNDS_PER_SERIES,
round_number=n % ROUNDS_PER_SERIES, guess=0, points=totals[seat] if n == 0 else 0,
))
await session.commit()
def test_get_leaderboard_from_db(self):
humans = make_players(4)
bot = run(bots_module.ensure_bot_account("heuristic", set()))
mixed = humans[:3] + [bot["player_id"]]
start = datetime(2026, 11, 1, 10, 0)
# hry s obrovskymi bodmi, nech su urcite na vrchu aj popri inych testoch
run(self._add_game(str(uuid.uuid4()), humans, [9001, 9002, 9003, 9004],
start, start + timedelta(hours=1)))
run(self._add_game(str(uuid.uuid4()), mixed, [9101, 9102, 9103, 99999],
start, start + timedelta(hours=2)))
# nedohrana hra (len 1 kolo) sa nepocita
run(self._add_game(str(uuid.uuid4()), humans, [99990, 0, 0, 0],
start, start + timedelta(hours=1), rounds=1))
lb = run(leaderboard.get_leaderboard(humans[0]))
hum = lb["points"]["humans"]["top"]
self.assertEqual([e["player_id"] for e in hum[:4]],
[humans[3], humans[2], humans[1], humans[0]])
self.assertEqual(hum[0]["points"], 9004)
bts = lb["points"]["bots"]["top"]
self.assertEqual([e["player_id"] for e in bts[:3]],
[humans[2], humans[1], humans[0]])
self.assertNotIn(bot["player_id"], [e["player_id"] for e in bts])
self.assertEqual(lb["points"]["humans"]["me"]["points"], 9001)
# hodiny: humans[0] odohral 1 h + 2 h, humans[3] len 1 h
me_hours = lb["hours"]["me"]
self.assertEqual(me_hours["seconds"], 3 * 3600)
self.assertEqual(me_hours["games"], 2)
class CryptoCase(unittest.TestCase):
def test_encrypt_decrypt_roundtrip(self):
secret = "JBSWY3DPEHPK3PXP"