From 1ab748a34f909a9f75007be4adfc37972986b5cc Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 9 Oct 2026 08:35:03 +0000 Subject: [PATCH] Paginate the leaderboard from materialized player ratings Store each player's leaderboard totals (wins, accumulated points and the latest display name) on player_rating alongside the Elo rating, updated in the same transaction as the match, so the board is a single indexed table query instead of aggregating every participation on each request. GET /api/leaderboard is now page-number paginated (page/limit) and returns the page, page count and total, plus the caller's own rank and page when authenticated; focus=me returns the caller's page directly. The migration adds the columns, backfills them from the match history (inserting rows for players from pre-Elo matches at the initial rating) and replaces the index. backfill_elo rebuilds the aggregates too. The web page opens on the page containing the signed-in player, marks their row and adds a numbered footer pager. --- server/README.md | 18 +- .../4_20261009082421_leaderboard_stats.py | 79 +++++ server/packages/tavolo-platform/README.md | 15 +- .../src/tavolo/platform/backfill_elo.py | 29 +- .../src/tavolo/platform/models.py | 19 +- .../src/tavolo/platform/openapi.py | 19 ++ .../src/tavolo/platform/pagination.py | 35 +++ .../src/tavolo/platform/routes/stats.py | 157 +++++++--- .../src/tavolo/platform/stats.py | 53 +++- .../tavolo-platform/tests/test_pagination.py | 26 +- .../tavolo-platform/tests/test_stats.py | 143 ++++++++- server/tests/test_scopa.py | 7 +- server/tests/test_stats.py | 18 +- web/src/api.rs | 25 +- web/src/model.rs | 49 +++ web/src/pages/leaderboard.rs | 293 ++++++++++++++---- 16 files changed, 818 insertions(+), 167 deletions(-) create mode 100644 server/migrations/models/4_20261009082421_leaderboard_stats.py diff --git a/server/README.md b/server/README.md index 9f60ce1..92e8922 100644 --- a/server/README.md +++ b/server/README.md @@ -251,8 +251,10 @@ aggregate with `max`, not `sum`. seat, game-defined team label (nullable), whether they won, the points they scored, the Elo change the match produced (`elo_delta`) and game-specific extras (`details` JSON). Unique per `(match, user_sub)`. -- `player_rating` — current Elo rating per `(user_sub, game_type)`, with - the number of rated matches played. +- `player_rating` — current Elo rating and leaderboard totals per + `(user_sub, game_type)`: the rating, matches played, wins, accumulated + points and the most recent display name. Kept in sync with the match + transaction, so the leaderboard is a single indexed table query. When a match ends, the engine's result is written transactionally to Postgres (once, guarded by a flag on the session); the finished session @@ -266,10 +268,10 @@ Players carry a chess-style Elo rating per game type mean of its members, and the standard formula `E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how many points the match result moves — the same delta for every member of -a team, zero-sum between the two teams. Ratings update in the same -transaction as the match result. To recompute every rating from the -recorded match history (e.g. to backfill matches recorded before ratings -existed): +a team, zero-sum between the two teams. Ratings and the leaderboard +aggregates update in the same transaction as the match result. To +rebuild every rating and aggregate from the recorded match history (e.g. +to backfill matches recorded before ratings existed): ```sh .venv/bin/python -m tavolo.platform.backfill_elo \ @@ -292,7 +294,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, | `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included | | `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) | | `GET` | `/api/me/ratings` | The caller's Elo rating per game type | -| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) | +| `GET` | `/api/leaderboard` | Elo rating, wins / matches / points per rated player, sorted by Elo (`?game_type=`, `?page=`, `?limit=`, `?focus=me`). The response carries the requested page, the page count and — when authenticated — the caller's own rank and page; `focus=me` returns the caller's page directly | ## WebSocket protocol @@ -560,7 +562,7 @@ packages/tavolo-platform/ # game-independent platform (own pyproject + tests ├── models.py # Match / MatchPlayer / PlayerRating (Postgres) ├── elo.py # chess-style Elo math (1500 start, K=32) ├── stats.py # MatchResult -> Postgres persistence + Elo update - ├── backfill_elo.py # recompute all ratings from the match history + ├── backfill_elo.py # recompute all ratings and aggregates from match history ├── store.py # Redis / in-memory live-session store (+ deadline queue) ├── deadlines.py # connection-independent timeout scheduler ├── ws.py # WebSocket live-play endpoint (generic envelope) diff --git a/server/migrations/models/4_20261009082421_leaderboard_stats.py b/server/migrations/models/4_20261009082421_leaderboard_stats.py new file mode 100644 index 0000000..09beeab --- /dev/null +++ b/server/migrations/models/4_20261009082421_leaderboard_stats.py @@ -0,0 +1,79 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "idx_player_rati_game_ty_3326d1"; + ALTER TABLE "player_rating" ADD "points" DOUBLE PRECISION NOT NULL DEFAULT 0; + ALTER TABLE "player_rating" ADD "display_name" VARCHAR(200) NOT NULL DEFAULT ''; + ALTER TABLE "player_rating" ADD "wins" INT NOT NULL DEFAULT 0; + -- Backfill the leaderboard aggregates from the match history: + -- existing rows keep their rating and matches, while players from + -- matches recorded before ratings existed are inserted at the + -- initial rating (1500) so they stay on the board. + INSERT INTO "player_rating" ( + "id", "user_sub", "game_type", "rating", "matches_played", + "wins", "points", "display_name", "updated_at" + ) + SELECT gen_random_uuid(), agg.user_sub, agg.game_type, 1500, + agg.matches, agg.wins, agg.points, agg.display_name, NOW() + FROM ( + SELECT mp.user_sub AS user_sub, + m.game_type AS game_type, + COUNT(*) AS matches, + SUM(CASE WHEN mp.won THEN 1 ELSE 0 END) AS wins, + SUM(mp.score) AS points, + (ARRAY_AGG(mp.display_name ORDER BY m.finished_at DESC, m.id DESC))[1] + AS display_name + FROM "match_player" mp + JOIN "match" m ON m.id = mp.match_id + GROUP BY mp.user_sub, m.game_type + ) agg + ON CONFLICT ("user_sub", "game_type") DO UPDATE + SET "wins" = EXCLUDED."wins", + "points" = EXCLUDED."points", + "display_name" = EXCLUDED."display_name"; + CREATE INDEX "idx_player_rati_game_ty_abcb75" ON "player_rating" ("game_type", "rating", "wins", "points", "matches_played", "user_sub");""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "idx_player_rati_game_ty_abcb75"; + ALTER TABLE "player_rating" DROP COLUMN "points"; + ALTER TABLE "player_rating" DROP COLUMN "display_name"; + ALTER TABLE "player_rating" DROP COLUMN "wins"; + CREATE INDEX "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");""" + + +MODELS_STATE = ( + "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" + "+g2+dS" +) diff --git a/server/packages/tavolo-platform/README.md b/server/packages/tavolo-platform/README.md index f32c6d7..ffa314a 100644 --- a/server/packages/tavolo-platform/README.md +++ b/server/packages/tavolo-platform/README.md @@ -33,15 +33,18 @@ itself ships no game. revalidate the token, so stale or duplicate deliveries are harmless. - `models.py` — `Match` (`game_type`, timestamps, game-specific JSON `result`), `MatchPlayer` (seat, team, won, points, Elo delta, JSON - `details`) and `PlayerRating` (Elo per `(user_sub, game_type)`). + `details`) and `PlayerRating` (Elo and leaderboard aggregates per + `(user_sub, game_type)`). - `stats.py` — `save_match_result` (engine `MatchResult` → Postgres, - transactionally, with Elo) and `apply_elo`. + transactionally, with Elo and the leaderboard aggregates) and + `apply_match_stats`. - `elo.py` — chess-style Elo math generalized to two-team matches. -- `backfill_elo.py` — rebuild every rating from the match history: - `python -m tavolo.platform.backfill_elo --database-url postgres://…`. +- `backfill_elo.py` — rebuild every rating and aggregate from the match + history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`. - `auth.py`, `http.py`, `pagination.py`, `openapi.py`, - `tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination, - shared OpenAPI fragments, the TortoiseORM lifecycle mixin. + `tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and + page-number pagination, shared OpenAPI fragments, the TortoiseORM + lifecycle mixin. ## Adding a game diff --git a/server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py b/server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py index 9b1aac5..0f994fe 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/backfill_elo.py @@ -1,9 +1,9 @@ -"""Recompute every Elo rating from the recorded match history. +"""Recompute every Elo rating and leaderboard aggregate from the history. -Ratings are deterministic given the finished matches, so this replays all -matches in chronological order and rewrites the ``player_rating`` table -and each ``match_player.elo_delta`` from scratch. Run any time ratings -need to be rebuilt:: +Ratings and aggregates are deterministic given the finished matches, so +this replays all matches in chronological order and rewrites the +``player_rating`` table and each ``match_player.elo_delta`` from scratch. +Run any time the leaderboard state needs to be rebuilt:: python -m tavolo.platform.backfill_elo --database-url postgres://... """ @@ -17,14 +17,14 @@ from typing import Dict, List from tortoise.transactions import in_transaction -from .stats import apply_elo +from .stats import PlayerStats, apply_match_stats from .tortoise_mixin import TortoiseMixin log = getLogger(__name__) async def backfill_elo() -> int: - """Rebuild all ratings; returns the number of matches replayed.""" + """Rebuild all ratings and aggregates; returns the matches replayed.""" from .models import Match, MatchPlayer, PlayerRating replayed = 0 @@ -48,7 +48,20 @@ async def backfill_elo() -> int: ), 0, ) - deltas = await apply_elo(match.game_type, teams, winner_index) + deltas = await apply_match_stats( + match.game_type, + teams, + winner_index, + [ + PlayerStats( + user_sub=p.user_sub, + won=p.won, + score=p.score, + display_name=p.display_name, + ) + for p in players + ], + ) for player in players: player.elo_delta = deltas[player.user_sub] await player.save() diff --git a/server/packages/tavolo-platform/src/tavolo/platform/models.py b/server/packages/tavolo-platform/src/tavolo/platform/models.py index f8d9969..8211bc3 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/models.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/models.py @@ -9,9 +9,10 @@ completed matches are written here. The schema is game-independent: * :class:`MatchPlayer` — one row per participant, linking an OIDC ``sub`` to a seat, an optional team label, whether they won, the points they scored and their Elo delta. -* :class:`PlayerRating` — current chess-style Elo rating of a player for - one game type, updated transactionally with every finished match (see - :mod:`tavolo.platform.elo`). +* :class:`PlayerRating` — current chess-style Elo rating and leaderboard + totals (wins, points, latest display name) of a player for one game + type, updated transactionally with every finished match (see + :mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`). """ from __future__ import annotations @@ -69,7 +70,7 @@ class MatchPlayer(Model): class PlayerRating(Model): - """Current Elo rating of one player for one game type.""" + """Current Elo rating and leaderboard totals of one player per game.""" id = fields.UUIDField(pk=True) # OIDC subject of the player; no local users table. @@ -78,9 +79,17 @@ class PlayerRating(Model): game_type = fields.CharField(max_length=32) rating = fields.IntField(default=INITIAL_RATING) matches_played = fields.IntField(default=0) + # Leaderboard aggregates, updated with every finished match. + wins = fields.IntField(default=0) + points = fields.FloatField(default=0) + # Most recent display name seen for the player. + display_name = fields.CharField(max_length=200, default="") updated_at = fields.DatetimeField(auto_now=True) class Meta: table = "player_rating" unique_together = (("user_sub", "game_type"),) - indexes = (("game_type", "rating"),) + # Composite index matching the leaderboard ordering within a game. + indexes = ( + ("game_type", "rating", "wins", "points", "matches_played", "user_sub"), + ) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/openapi.py b/server/packages/tavolo-platform/src/tavolo/platform/openapi.py index b5d0220..4125530 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/openapi.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/openapi.py @@ -19,3 +19,22 @@ PAGINATION_PARAMETERS: List[Dict[str, Any]] = [ "description": "Opaque pagination cursor from a previous response's next_cursor.", }, ] + +# Page-number pagination (used by the leaderboard, which supports jumping +# to a specific page). +PAGE_PARAMETERS: List[Dict[str, Any]] = [ + { + "name": "page", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 1, "default": 1}, + "description": "1-based page number (clamped to the last page).", + }, + { + "name": "limit", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20}, + "description": "Maximum number of results per page (clamped to [1, 100]).", + }, +] diff --git a/server/packages/tavolo-platform/src/tavolo/platform/pagination.py b/server/packages/tavolo-platform/src/tavolo/platform/pagination.py index 1bd992b..87c2583 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/pagination.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/pagination.py @@ -30,12 +30,17 @@ MIN_LIMIT = 1 CURSOR_PARAM = "cursor" LIMIT_PARAM = "limit" +PAGE_PARAM = "page" class CursorDecodeError(ValueError): """Raised when the ``cursor`` query param cannot be decoded.""" +class PageParamsError(ValueError): + """Raised when the ``page``/``limit`` query params are invalid.""" + + def encode_cursor(values: Dict[str, Any]) -> str: # ``default=str`` handles datetimes (ISO) so keyset cursors can carry # datetime-typed sort fields (e.g. finished_at for match history). @@ -79,6 +84,36 @@ def parse_cursor_params(query_string: str) -> Cursor: return Cursor(limit=limit, after=after) +def parse_page_params(query_string: str) -> Tuple[int, int]: + """Parse the ``page``/``limit`` params: a 1-based page and a page size. + + ``limit`` is clamped to [1, 100] (default 20); ``page`` must be a + positive integer (default 1). Clamping ``page`` to the last existing + page is left to the endpoint, which knows how many pages there are. + """ + params = extract_query_params(query_string) + limit_raw = params.get(LIMIT_PARAM) + if limit_raw: + try: + limit = int(limit_raw[0]) + except ValueError as exc: + raise PageParamsError("invalid limit") from exc + else: + limit = DEFAULT_LIMIT + limit = max(MIN_LIMIT, min(MAX_LIMIT, limit)) + page_raw = params.get(PAGE_PARAM) + if page_raw: + try: + page = int(page_raw[0]) + except ValueError as exc: + raise PageParamsError("invalid page") from exc + else: + page = 1 + if page < 1: + raise PageParamsError("invalid page") + return page, limit + + # A sort field: (model field name, "ASC" or "DESC"). The tuple is the full # keyset; the cursor encodes exactly these fields. Sort = List[Tuple[str, str]] diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py index d416e54..35d65bd 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/stats.py @@ -16,11 +16,17 @@ from kaya.core import HttpContext, KayaApp from kaya.openapi import operation from ..auth import current_user, require_auth -from ..elo import INITIAL_RATING from ..http import extract_query_params, send_error, send_json from ..models import Match, MatchPlayer, PlayerRating -from ..openapi import PAGINATION_PARAMETERS -from ..pagination import CursorDecodeError, paginate, parse_cursor_params +from ..openapi import PAGE_PARAMETERS, PAGINATION_PARAMETERS +from ..pagination import ( + CursorDecodeError, + PageParamsError, + paginate, + parse_cursor_params, + parse_page_params, +) +from tortoise.expressions import Q if TYPE_CHECKING: from ..mixin import Platform @@ -33,6 +39,15 @@ GAME_TYPE_PARAMETER: Dict[str, Any] = { "description": "Only count matches of this game (id from GET /api/game-types).", } +LEADERBOARD_FOCUS_PARAMETER: Dict[str, Any] = { + "name": "focus", + "in": "query", + "required": False, + "schema": {"type": "string", "enum": ["me"]}, + "description": "Set to 'me' to return the page containing the caller's " + "own ranking (page 1 for unrated or anonymous callers).", +} + def _parse_game_type( platform: "Platform", query_string: str @@ -50,6 +65,33 @@ def _parse_game_type( return game_type, None +def _ranks_before(rating: PlayerRating) -> Q: + """Keyset condition selecting the rows ranked above ``rating``. + + Mirrors the leaderboard ordering ``-rating, -wins, -points, + matches_played, user_sub``: exactly the rows sorting before the given + one. + """ + return ( + Q(rating__gt=rating.rating) + | Q(rating=rating.rating, wins__gt=rating.wins) + | Q(rating=rating.rating, wins=rating.wins, points__gt=rating.points) + | Q( + rating=rating.rating, + wins=rating.wins, + points=rating.points, + matches_played__lt=rating.matches_played, + ) + | Q( + rating=rating.rating, + wins=rating.wins, + points=rating.points, + matches_played=rating.matches_played, + user_sub__lt=rating.user_sub, + ) + ) + + def _serialize_match( match: Match, viewer: str, participants: List[MatchPlayer] ) -> Dict[str, Any]: @@ -131,56 +173,87 @@ def register(app: KayaApp, platform: "Platform") -> None: @app.GET("/api/leaderboard") @operation(summary="Global leaderboard", - description="Elo rating, aggregated wins, matches played and " - "points for every player with at least one " - "finished match. Sorted by Elo rating (the rating " - "for the requested game_type, or the default game " - "when the filter is absent).", + description="Elo rating, wins, matches played and points for " + "every rated player of a game, sorted by Elo (the " + "requested game_type, or the default game when " + "the filter is absent). Page-number paginated; " + "the response carries the caller's own rank when " + "authenticated.", tags=["stats"], - parameters=[GAME_TYPE_PARAMETER], + parameters=[ + GAME_TYPE_PARAMETER, + *PAGE_PARAMETERS, + LEADERBOARD_FOCUS_PARAMETER, + ], responses={ - 200: {"description": "The leaderboard"}, - 400: {"description": "Unknown game_type"}, + 200: {"description": "A page of the leaderboard"}, + 400: {"description": "Unknown game_type or invalid parameters"}, }) async def leaderboard(ctx: HttpContext) -> None: game_type, error = _parse_game_type(platform, ctx.query_string) if error is not None: await send_error(ctx, 400, error) return - queryset = MatchPlayer.all() - if game_type is not None: - queryset = queryset.filter(match__game_type=game_type) - rows = await queryset - # Ratings are per game type; without a filter show the default - # game's. + try: + page, limit = parse_page_params(ctx.query_string) + except PageParamsError as exc: + await send_error(ctx, 400, str(exc)) + return + focus_values = extract_query_params(ctx.query_string).get("focus") + focus = focus_values[0] if focus_values else None + if focus is not None and focus != "me": + await send_error(ctx, 400, "invalid focus") + return + + # The board is the requested game's, or the default game's when the + # filter is absent (ratings are per game type). rating_game = game_type or platform.registry.default.id - rating_rows = await PlayerRating.filter(game_type=rating_game) - ratings = {row.user_sub: row.rating for row in rating_rows} - aggregate: Dict[str, Dict[str, Any]] = {} - for row in rows: - entry = aggregate.setdefault( - row.user_sub, + board = PlayerRating.filter(game_type=rating_game) + total = await board.count() + pages = (total + limit - 1) // limit + current = min(page, pages) if pages else 1 + + # The caller's own ranking, when authenticated and rated: ranks + # above theirs plus one. `focus=me` jumps to their page. + user = current_user(platform.oidc, ctx) + yours: Optional[PlayerRating] = None + if user is not None: + yours = await PlayerRating.get_or_none( + user_sub=user.sub, game_type=rating_game + ) + your_rank: Optional[int] = None + your_page: Optional[int] = None + if yours is not None: + your_rank = 1 + await board.filter(_ranks_before(yours)).count() + your_page = (your_rank - 1) // limit + 1 + if focus == "me": + current = your_page + + rows = await ( + board.order_by( + "-rating", "-wins", "-points", "matches_played", "user_sub" + ) + .offset((current - 1) * limit) + .limit(limit) + ) + await send_json(ctx, 200, { + "results": [ { "user_sub": row.user_sub, - "display_name": row.display_name, - "matches": 0, - "wins": 0, - "points": 0, - "elo": ratings.get(row.user_sub, INITIAL_RATING), - }, - ) - entry["matches"] += 1 - entry["wins"] += 1 if row.won else 0 - entry["points"] += row.score - # Keep the most recent display name seen. - entry["display_name"] = row.display_name - - ranking: List[Dict[str, Any]] = sorted( - aggregate.values(), - key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]), - reverse=True, - ) - await send_json(ctx, 200, {"results": ranking}) + "display_name": row.display_name or row.user_sub, + "matches": row.matches_played, + "wins": row.wins, + "points": row.points, + "elo": row.rating, + } + for row in rows + ], + "page": current, + "pages": pages, + "total": total, + "your_rank": your_rank, + "your_page": your_page, + }) @app.GET("/api/me/ratings") @operation(summary="My Elo ratings", diff --git a/server/packages/tavolo-platform/src/tavolo/platform/stats.py b/server/packages/tavolo-platform/src/tavolo/platform/stats.py index ddd23ff..a78499d 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/stats.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/stats.py @@ -3,8 +3,8 @@ Called once when a session reaches the finished state (guarded by the ``stats_saved`` flag on the session). The write is transactional so a match never appears with only some of its players. The same transaction -also updates the participants' Elo ratings (see -:mod:`tavolo.platform.elo`). +also updates the participants' Elo ratings and leaderboard aggregates +(see :mod:`tavolo.platform.elo`). This module is game-independent: everything it persists comes from the engine's :class:`~tavolo.platform.engine.MatchResult`. @@ -12,9 +12,10 @@ engine's :class:`~tavolo.platform.engine.MatchResult`. from __future__ import annotations import uuid +from dataclasses import dataclass from datetime import datetime, timezone from logging import getLogger -from typing import Dict, List +from typing import Dict, List, Sequence from tortoise.transactions import in_transaction @@ -24,21 +25,37 @@ from .engine import GameEngine, GameSession log = getLogger(__name__) -async def apply_elo( - game_type: str, teams: List[List[str]], winner_team: int +@dataclass(frozen=True) +class PlayerStats: + """One participant's data feeding a ``player_rating`` row update.""" + + user_sub: str + won: bool + score: float + display_name: str + + +async def apply_match_stats( + game_type: str, + teams: List[List[str]], + winner_team: int, + players: Sequence[PlayerStats], ) -> Dict[str, int]: - """Update the Elo ratings of ``teams`` for ``game_type``. + """Update the ratings and leaderboard aggregates of ``teams``. ``teams`` groups the participants' subs into the two opposing sides; ``winner_team`` is the index of the winning side. Ratings are read from (and written back to) the ``player_rating`` table; unrated - players start at the initial rating. Returns the per-player delta. - Must be called inside a transaction. + players start at the initial rating. Alongside the Elo delta the + transaction records each participant's wins, accumulated points and + latest display name, so the leaderboard is a single-table query. + Returns the per-player delta. Must be called inside a transaction. """ if len(teams) != 2: raise ValueError("Elo rating requires exactly two teams") from .models import PlayerRating + stats = {player.user_sub: player for player in players} ratings: Dict[str, "PlayerRating"] = {} for subs in teams: for sub in subs: @@ -63,6 +80,13 @@ async def apply_elo( rating = ratings[sub] rating.rating += delta rating.matches_played += 1 + player = stats.get(sub) + if player is not None: + if player.won: + rating.wins += 1 + rating.points += player.score + if player.display_name: + rating.display_name = player.display_name await rating.save() return deltas @@ -78,6 +102,15 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None: started_at = session.created_at or datetime.now(timezone.utc) finished_at = session.finished_at or datetime.now(timezone.utc) display_names = {seat.user_sub: seat.display_name for seat in session.players} + player_stats = [ + PlayerStats( + user_sub=player.user_sub, + won=player.won, + score=player.score, + display_name=display_names.get(player.user_sub, player.user_sub), + ) + for player in result.players + ] async with in_transaction(): match = await Match.create( id=uuid.uuid4(), @@ -86,7 +119,9 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None: finished_at=finished_at, result=result.summary, ) - deltas = await apply_elo(session.game_type, result.teams, result.winner_team) + deltas = await apply_match_stats( + session.game_type, result.teams, result.winner_team, player_stats + ) for player in result.players: await MatchPlayer.create( id=uuid.uuid4(), diff --git a/server/packages/tavolo-platform/tests/test_pagination.py b/server/packages/tavolo-platform/tests/test_pagination.py index 603def9..a668f7f 100644 --- a/server/packages/tavolo-platform/tests/test_pagination.py +++ b/server/packages/tavolo-platform/tests/test_pagination.py @@ -14,7 +14,14 @@ from datetime import datetime, timezone from typing import Any, List from tavolo.platform.models import Match -from tavolo.platform.pagination import Cursor, decode_cursor, encode_cursor, paginate +from tavolo.platform.pagination import ( + Cursor, + PageParamsError, + decode_cursor, + encode_cursor, + paginate, + parse_page_params, +) from helpers import async_test @@ -77,5 +84,22 @@ class PaginateCursorTypesTest(unittest.TestCase): self.assertEqual(match_id, id_values[0]) +class PageParamsTest(unittest.TestCase): + def test_defaults(self) -> None: + self.assertEqual((1, 20), parse_page_params("")) + + def test_page_and_limit(self) -> None: + self.assertEqual((3, 5), parse_page_params("page=3&limit=5")) + + def test_limit_is_clamped(self) -> None: + self.assertEqual((1, 100), parse_page_params("limit=500")) + self.assertEqual((1, 1), parse_page_params("limit=0")) + + def test_invalid_values_raise(self) -> None: + for query in ("page=0", "page=-2", "page=abc", "limit=abc"): + with self.assertRaises(PageParamsError, msg=query): + parse_page_params(query) + + if __name__ == "__main__": unittest.main() diff --git a/server/packages/tavolo-platform/tests/test_stats.py b/server/packages/tavolo-platform/tests/test_stats.py index dd5d286..759513a 100644 --- a/server/packages/tavolo-platform/tests/test_stats.py +++ b/server/packages/tavolo-platform/tests/test_stats.py @@ -4,6 +4,7 @@ from __future__ import annotations import unittest import uuid from datetime import datetime, timezone +from typing import Any, Dict from unittest import mock from httpx import ASGITransport, AsyncClient @@ -78,6 +79,12 @@ class SaveMatchResultTest(unittest.TestCase): self.assertEqual(1, ratings["alice"].matches_played) self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating) self.assertEqual(1, ratings["bob"].matches_played) + # The leaderboard aggregates ride along in the same transaction. + self.assertEqual(1, ratings["alice"].wins) + self.assertEqual(0, ratings["bob"].wins) + self.assertEqual(2.0, ratings["alice"].points) + self.assertEqual(0.0, ratings["bob"].points) + self.assertEqual("alice", ratings["alice"].display_name) # The per-match delta is recorded on each participation row. deltas = { @@ -136,6 +143,9 @@ async def _seed_two_matches( ) -> None: ctx = await use_db(tortoise_mixin) with ctx: + # The leaderboard reads the player_rating aggregates, so the seeded + # participations are mirrored there (as save_match_result would). + aggregates: Dict[tuple, Dict[str, Any]] = {} for index, (winner, finished) in enumerate( [ ("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)), @@ -150,6 +160,7 @@ async def _seed_two_matches( result={"winner": winner, "plays": 3 + index}, ) for seat, sub in enumerate(("alice", "bob")): + score = 2.0 + index if sub == winner else 1.0 await MatchPlayer.create( id=uuid.uuid4(), match=match, @@ -158,8 +169,26 @@ async def _seed_two_matches( seat=seat, team="A" if seat == 0 else "B", won=(sub == winner), - score=2.0 + index if sub == winner else 1.0, + score=score, ) + aggregate = aggregates.setdefault( + (sub, game_types[index]), + {"matches": 0, "wins": 0, "points": 0.0}, + ) + aggregate["matches"] += 1 + aggregate["wins"] += 1 if sub == winner else 0 + aggregate["points"] += score + for (sub, game_type), aggregate in aggregates.items(): + await PlayerRating.create( + id=uuid.uuid4(), + user_sub=sub, + game_type=game_type, + rating=INITIAL_RATING, + matches_played=aggregate["matches"], + wins=aggregate["wins"], + points=aggregate["points"], + display_name=sub, + ) class StatsRouteTest(unittest.TestCase): @@ -252,13 +281,9 @@ class StatsRouteTest(unittest.TestCase): ctx = await use_db(tortoise_mixin) with ctx: # Alice outranks everyone despite Bob leading on points. - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="alice", - game_type="dummy", - rating=1600, - matches_played=2, - ) + await PlayerRating.filter( + user_sub="alice", game_type="dummy" + ).update(rating=1600) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: response = await client.get("/api/leaderboard") @@ -266,7 +291,6 @@ class StatsRouteTest(unittest.TestCase): results = response.json()["results"] by_sub = {row["user_sub"]: row for row in results} self.assertEqual(1600, by_sub["alice"]["elo"]) - # Players without a rating row report the initial rating. self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"]) # Elo outranks wins/points. self.assertEqual("alice", results[0]["user_sub"]) @@ -279,13 +303,9 @@ class StatsRouteTest(unittest.TestCase): await _seed_two_matches(tortoise_mixin) ctx = await use_db(tortoise_mixin) with ctx: - await PlayerRating.create( - id=uuid.uuid4(), - user_sub="bob", - game_type="dummy", - rating=1516, - matches_played=1, - ) + await PlayerRating.filter( + user_sub="bob", game_type="dummy" + ).update(rating=1516) # Bob's rating in another game must not leak into the # dummy leaderboard. await PlayerRating.create( @@ -305,6 +325,97 @@ class StatsRouteTest(unittest.TestCase): self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"]) self.assertEqual("second", platform.registry.all()[1].id) + @async_test + async def test_leaderboard_pagination(self) -> None: + app, _, tortoise_mixin = make_platform() + await _seed_two_matches(tortoise_mixin) + ctx = await use_db(tortoise_mixin) + with ctx: + for index, sub in enumerate(("carol", "dave", "erin", "frank")): + await PlayerRating.create( + id=uuid.uuid4(), + user_sub=sub, + game_type="dummy", + rating=1600 + index, + matches_played=1, + wins=1, + points=2.0, + display_name=sub, + ) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + first = await client.get("/api/leaderboard?limit=2") + second = await client.get("/api/leaderboard?limit=2&page=2") + third = await client.get("/api/leaderboard?limit=2&page=3") + beyond = await client.get("/api/leaderboard?limit=2&page=99") + body = first.json() + self.assertEqual(1, body["page"]) + self.assertEqual(3, body["pages"]) + self.assertEqual(6, body["total"]) + self.assertEqual(2, len(body["results"])) + self.assertEqual( + ["frank", "erin"], [row["user_sub"] for row in body["results"]] + ) + self.assertEqual( + ["dave", "carol"], + [row["user_sub"] for row in second.json()["results"]], + ) + self.assertEqual( + ["bob", "alice"], + [row["user_sub"] for row in third.json()["results"]], + ) + # Out-of-range pages clamp to the last page. + self.assertEqual(3, beyond.json()["page"]) + self.assertEqual( + ["bob", "alice"], + [row["user_sub"] for row in beyond.json()["results"]], + ) + + @async_test + async def test_leaderboard_focus_me(self) -> None: + app, platform, tortoise_mixin = make_platform() + await _seed_two_matches(tortoise_mixin) + ctx = await use_db(tortoise_mixin) + with ctx: + for index, sub in enumerate(("carol", "dave")): + await PlayerRating.create( + id=uuid.uuid4(), + user_sub=sub, + game_type="dummy", + rating=1600 + index, + matches_played=1, + wins=1, + points=2.0, + display_name=sub, + ) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user(platform.oidc, "alice"): + mine = await client.get("/api/leaderboard?limit=1&focus=me") + anonymous = await client.get("/api/leaderboard?limit=1&focus=me") + # dave, carol, bob, alice: the caller lands on their own page. + body = mine.json() + self.assertEqual(4, body["page"]) + self.assertEqual(4, body["your_rank"]) + self.assertEqual(4, body["your_page"]) + self.assertEqual("alice", body["results"][0]["user_sub"]) + anonymous_body = anonymous.json() + self.assertEqual(1, anonymous_body["page"]) + self.assertIsNone(anonymous_body["your_rank"]) + self.assertIsNone(anonymous_body["your_page"]) + + @async_test + async def test_leaderboard_rejects_bad_page_params(self) -> None: + app, _, _ = make_platform() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + for query in ("page=0", "page=abc", "focus=elsewhere"): + response = await client.get(f"/api/leaderboard?{query}") + self.assertEqual(400, response.status_code, query) + # The limit is clamped, not rejected. + clamped = await client.get("/api/leaderboard?limit=500") + self.assertEqual(200, clamped.status_code) + @async_test async def test_my_matches_include_elo_delta(self) -> None: app, platform, tortoise_mixin = make_platform() diff --git a/server/tests/test_scopa.py b/server/tests/test_scopa.py index 96a5fa7..13eb287 100644 --- a/server/tests/test_scopa.py +++ b/server/tests/test_scopa.py @@ -190,7 +190,12 @@ class ScopaWebSocketTest(unittest.TestCase): self.assertEqual(1, game["last_move"]["seat"]) self.assertEqual(card, game["last_move"]["card"]) if options: - self.assertEqual(2, game["players"][1]["captured_count"]) + # The pile grows by the captured table cards + # plus the card just played. + self.assertEqual( + len(options[0]) + 1, + game["players"][1]["captured_count"], + ) else: self.assertEqual(5, len(game["table"])) diff --git a/server/tests/test_stats.py b/server/tests/test_stats.py index b838aa0..ff7491b 100644 --- a/server/tests/test_stats.py +++ b/server/tests/test_stats.py @@ -133,11 +133,25 @@ class ScoponeStatsTest(unittest.TestCase): self.assertIn("your_elo_delta", results[0]) self.assertEqual(4, len(results[0]["players"])) - board = await client.get("/api/leaderboard") + with oidc_user("alice"): + board = await client.get("/api/leaderboard") self.assertEqual(200, board.status_code) - by_sub = {r["user_sub"]: r for r in board.json()["results"]} + body = board.json() + by_sub = {r["user_sub"]: r for r in body["results"]} self.assertEqual(4, len(by_sub)) self.assertTrue(all(r["matches"] == 1 for r in by_sub.values())) + self.assertEqual(1, body["page"]) + self.assertEqual(1, body["pages"]) + self.assertEqual(4, body["total"]) + # The caller's own rank rides along on the response. + self.assertIsNotNone(body["your_rank"]) + self.assertEqual(1, body["your_page"]) + + paged = await client.get("/api/leaderboard?limit=1&page=2") + self.assertEqual(200, paged.status_code) + self.assertEqual(4, paged.json()["pages"]) + self.assertEqual(2, paged.json()["page"]) + self.assertEqual(1, len(paged.json()["results"])) async def _finished_briscola_session() -> GameSession: diff --git a/web/src/api.rs b/web/src/api.rs index 34b7438..8513893 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -175,13 +175,24 @@ pub async fn my_ratings() -> Result { resp.json().await.map_err(|e| e.to_string()) } -/// Fetch the leaderboard for one game type: Elo, wins, matches and points -/// are all scoped to that game. -pub async fn leaderboard(game_type: &str) -> Result { - let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}")) - .send() - .await - .map_err(|e| e.to_string())?; +/// Fetch one page of the leaderboard for a game. With `focus_me` the +/// server returns the page holding the caller's own ranking (page 1 for +/// anonymous or unrated callers). +pub async fn leaderboard( + game_type: &str, + page: Option, + focus_me: bool, +) -> Result { + let mut url = format!( + "/api/leaderboard?game_type={game_type}&limit={LEADERBOARD_PAGE_SIZE}" + ); + if focus_me { + url.push_str("&focus=me"); + } + if let Some(page) = page { + url.push_str(&format!("&page={page}")); + } + let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?; check(&resp)?; resp.json().await.map_err(|e| e.to_string()) } diff --git a/web/src/model.rs b/web/src/model.rs index 4d2629b..d2b80fe 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -466,6 +466,10 @@ fn default_elo() -> i32 { 1500 } +fn default_page() -> u32 { + 1 +} + #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct LeaderboardEntry { @@ -512,10 +516,30 @@ pub struct RatingsPage { pub results: Vec, } +/// Page size requested by the leaderboard page. Also used to turn a rank +/// into the row number on a page (and back). +pub const LEADERBOARD_PAGE_SIZE: u32 = 20; + #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct LeaderboardPage { #[serde(default)] pub results: Vec, + /// 1-based page actually returned (clamped to the last page). + #[serde(default = "default_page")] + pub page: u32, + /// Total number of pages; 0 when the board has no rated players. + #[serde(default)] + pub pages: u32, + /// Total number of rated players. + #[serde(default)] + pub total: u32, + /// The viewer's own rank, when authenticated and rated for this game. + #[serde(default)] + pub your_rank: Option, + /// The page holding the viewer's row. + #[serde(default)] + pub your_page: Option, } /// A card game the platform can host (GET /api/game-types). @@ -1113,6 +1137,31 @@ mod tests { assert_eq!(1500, e.elo); } + #[test] + fn leaderboard_page_parses_pagination_fields() { + let p: LeaderboardPage = serde_json::from_value(json!({ + "results": [], + "page": 3, + "pages": 12, + "total": 234, + "your_rank": 47, + "your_page": 3, + })) + .unwrap(); + assert_eq!(3, p.page); + assert_eq!(12, p.pages); + assert_eq!(234, p.total); + assert_eq!(Some(47), p.your_rank); + assert_eq!(Some(3), p.your_page); + // The pagination fields are optional for older payloads. + let legacy: LeaderboardPage = + serde_json::from_value(json!({"results": []})).unwrap(); + assert_eq!(1, legacy.page); + assert_eq!(0, legacy.pages); + assert_eq!(None, legacy.your_rank); + assert_eq!(None, legacy.your_page); + } + fn scopone_game_type() -> GameTypeInfo { serde_json::from_value(json!({ "id": "scopone_scientifico", diff --git a/web/src/pages/leaderboard.rs b/web/src/pages/leaderboard.rs index e487297..921b3fb 100644 --- a/web/src/pages/leaderboard.rs +++ b/web/src/pages/leaderboard.rs @@ -1,14 +1,102 @@ //! Per-game leaderboard page. //! -//! Elo ratings are stored per game type, so the board is scoped to the -//! game chosen in the dropdown: Elo, wins, matches and points all come -//! from that game's matches only. +//! Elo ratings and the leaderboard aggregates live in one +//! `player_rating` row per player and game, so the board is +//! page-number paginated: the first load lands on the page holding the +//! signed-in player's own ranking (the server resolves it with +//! `focus=me`) and a footer pager changes pages. use wasm_bindgen_futures::spawn_local; use sycamore::prelude::*; use crate::api; use crate::components::toast::toast; -use crate::model::{fallback_game_types, fmt_points, LeaderboardPage}; +use crate::model::{ + fallback_game_types, fmt_points, LeaderboardPage, LEADERBOARD_PAGE_SIZE, +}; + +/// Page numbers to show in the pager: the first and last page, the +/// current page and its neighbours, with `None` marking an ellipsis. +fn page_window(page: u32, pages: u32) -> Vec> { + if pages <= 1 { + return Vec::new(); + } + let mut window = vec![Some(1)]; + let start = page.saturating_sub(2).max(2); + let end = (page + 2).min(pages.saturating_sub(1)); + if start > 2 { + window.push(None); + } + for number in start..=end { + window.push(Some(number)); + } + if end + 1 < pages { + window.push(None); + } + window.push(Some(pages)); + window +} + +/// The footer pager: first/previous, page numbers, next/last. Rendered +/// only when the board has more than one page. +fn pager( + page: &LeaderboardPage, + busy: bool, + load: impl Fn(Option, bool) + Copy + 'static, +) -> View { + let current = page.page; + let pages = page.pages; + if pages <= 1 { + return view! {}; + } + let button = move |label: &'static str, target: u32, enabled: bool| { + view! { + li(class=if enabled { "page-item" } else { "page-item disabled" }) { + button( + class="page-link", + disabled=!enabled || busy, + on:click=move |_| if enabled && !busy { + load(Some(target), false); + }, + ) { (label) } + } + } + }; + let items = page_window(current, pages) + .into_iter() + .map(|entry| match entry { + None => view! { + li(class="page-item disabled") { + span(class="page-link") { "…" } + } + }, + Some(number) => { + let active = number == current; + view! { + li(class=if active { "page-item active" } else { "page-item" }) { + button( + class="page-link", + disabled=active || busy, + on:click=move |_| if !active && !busy { + load(Some(number), false); + }, + ) { (number) } + } + } + } + }) + .collect::>(); + view! { + nav(aria-label="Leaderboard pages") { + ul(class="pagination justify-content-center mt-4") { + (button("«", 1, current > 1)) + (button("‹", current.saturating_sub(1).max(1), current > 1)) + (items) + (button("›", (current + 1).min(pages), current < pages)) + (button("»", pages, current < pages)) + } + } + } +} #[component] pub fn LeaderboardPage() -> View { @@ -16,26 +104,33 @@ pub fn LeaderboardPage() -> View { let error = create_signal(Option::::None); let game_types = create_signal(fallback_game_types()); let selected_game = create_signal("scopone_scientifico".to_string()); + // A leaderboard request is in flight; the pager is disabled meanwhile. + let loading = create_signal(false); - // Reload whenever the selection changes; a response for a game the - // user has already switched away from is dropped. - create_effect(move || { + let load = move |target: Option, focus_me: bool| { let game_type = selected_game.get_clone(); - page.set(None); + loading.set(true); spawn_local(async move { - match api::leaderboard(&game_type).await { - Ok(p) => { - if selected_game.get_clone() == game_type { - page.set(Some(p)); - } - } - Err(e) => { - if selected_game.get_clone() == game_type { - error.set(Some(e)); - } - } + let response = api::leaderboard(&game_type, target, focus_me).await; + // A response for a game the user has already switched away + // from is dropped (and must not clear the new game's spinner). + if selected_game.get_clone() != game_type { + return; + } + loading.set(false); + match response { + Ok(p) => page.set(Some(p)), + Err(e) => error.set(Some(e)), } }); + }; + + // Reload whenever the selection changes; the first load focuses the + // viewer's own page so they see their name right away. + create_effect(move || { + let _game = selected_game.get_clone(); + page.set(None); + load(None, true); }); spawn_local(async move { @@ -80,53 +175,86 @@ pub fn LeaderboardPage() -> View { } } (toast(error)) - (move || match page.get_clone() { - None => view! { - div(class="d-flex justify-content-center p-5") { - div(class="spinner-border text-warning", role="status") { - span(class="visually-hidden") { "Loading…" } - } - } - }, - Some(p) if p.results.is_empty() => view! { - p(class="text-secondary text-center p-5") { - "No ratings yet — finish a match to appear here." - } - }, - Some(p) => { - let rows = p - .results - .iter() - .cloned() - .enumerate() - .map(|(i, e)| { - view! { - tr { - td { (i + 1) } - td { (e.display_name.clone()) } - td { (e.elo) } - td { (e.wins) } - td { (e.matches) } - td { (fmt_points(e.points)) } - } + (move || { + let busy = loading.get(); + match page.get_clone() { + None => view! { + div(class="d-flex justify-content-center p-5") { + div(class="spinner-border text-warning", role="status") { + span(class="visually-hidden") { "Loading…" } } - }) - .collect::>(); - view! { - div(class="table-responsive") { - table(class="table table-striped table-hover align-middle") { - thead { - tr { - th { "#" } - th { "Player" } - th { "Elo" } - th { "Wins" } - th { "Matches" } - th { "Points" } + } + }, + Some(p) if p.results.is_empty() => view! { + p(class="text-secondary text-center p-5") { + "No ratings yet — finish a match to appear here." + } + }, + Some(p) => { + let rows = p + .results + .iter() + .cloned() + .enumerate() + .map(|(i, e)| { + let rank = (p.page.saturating_sub(1)) + * LEADERBOARD_PAGE_SIZE + + i as u32 + + 1; + let is_me = p.your_rank == Some(rank); + let cls = if is_me { + "table-active fw-semibold" + } else { + "" + }; + view! { + tr(class=cls) { + td { (rank) } + td { + (e.display_name.clone()) + (is_me.then(|| view! { + span( + class="badge text-bg-warning ms-2" + ) { + "you" + } + })) + } + td { (e.elo) } + td { (e.wins) } + td { (e.matches) } + td { (fmt_points(e.points)) } } } - tbody { (rows) } + }) + .collect::>(); + let pager_view = pager(&p, busy, load); + let rated = if p.total == 1 { + "1 player rated".to_string() + } else { + format!("{} players rated", p.total) + }; + view! { + p(class="text-secondary small mb-2") { (rated) } + div(class="table-responsive") { + table( + class="table table-striped table-hover \ + align-middle" + ) { + thead { + tr { + th { "#" } + th { "Player" } + th { "Elo" } + th { "Wins" } + th { "Matches" } + th { "Points" } + } + } + tbody { (rows) } + } } + (pager_view) } } } @@ -135,3 +263,44 @@ pub fn LeaderboardPage() -> View { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_window_small_boards() { + // Boards with a single page (or none) have no pager at all. + assert_eq!(Vec::>::new(), page_window(1, 0)); + assert_eq!(Vec::>::new(), page_window(1, 1)); + assert_eq!(vec![Some(1), Some(2)], page_window(1, 2)); + assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(1, 3)); + assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(3, 3)); + } + + #[test] + fn page_window_ellipsizes_long_boards() { + assert_eq!( + vec![Some(1), Some(2), Some(3), None, Some(10)], + page_window(1, 10) + ); + assert_eq!( + vec![ + Some(1), + None, + Some(3), + Some(4), + Some(5), + Some(6), + Some(7), + None, + Some(10), + ], + page_window(5, 10) + ); + assert_eq!( + vec![Some(1), None, Some(8), Some(9), Some(10)], + page_window(10, 10) + ); + } +}