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:
1 parent
4000da0454
commit
04846aee26
11 files changed
+614
-12
No files matched your search
+174
-9
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user