using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using RadicadeServer.DB; using RadicadeServer.Leaderboard; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Leaderboards; using Xunit; namespace RadicadeServer.Tests; /// /// The room scoreboard / watch leaderboard path: the client posts a stat with /// CheckAndSetStat (SetStatRequestDTO: StatChannel + RoomId + StatValue, with identity /// coming from the auth token rather than the body), then reads it back with GetRanks, /// GetNearbyScores or GetPlayerRank. These tests drive the store end to end, because the /// conformance suite only ever exercised an empty board. /// public class LeaderboardTests { private const long RoomA = 25; private const long RoomB = 32; private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions options; public TestDbContextFactory() { var root = new InMemoryDatabaseRoot(); options = TestDbOptions.Create() .UseInMemoryDatabase(Guid.NewGuid().ToString(), root) .Options; } public RecDBContext CreateDbContext() => new(options); } private static async Task SeedProfilesAsync(IDbContextFactory factory, params long[] ids) { await using var db = factory.CreateDbContext(); foreach (var id in ids) db.Profiles.Add(new Profile { accountId = id, profileImage = "default.png", username = $"user{id}", displayName = $"User {id}", }); await db.SaveChangesAsync(); } private static string Stat(long roomId, int channel, int value) => $"{{\"StatChannel\":{channel},\"RoomId\":{roomId},\"StatValue\":{value}}}"; [Fact] public async Task SubmitScore_RanksPlayersAndKeepsRoomsSeparate() { var factory = new TestDbContextFactory(); await SeedProfilesAsync(factory, 2, 3); await LeaderboardStore.SubmitScoreAsync(factory, Stat(RoomA, 1, 10), 2, 0); await LeaderboardStore.SubmitScoreAsync(factory, Stat(RoomA, 1, 40), 3, 0); // A different room's board on the same channel number must not leak into RoomA. await LeaderboardStore.SubmitScoreAsync(factory, Stat(RoomB, 1, 999), 2, 0); var board = await LeaderboardStore.GetRanksAsync(factory, new GetRanksRequest { RoomId = RoomA, StatChannel = 1, RankStart = 1, RankEnd = 10, }); Assert.Equal(2, board.Total); Assert.Equal(2, board.Rows.Count); Assert.Equal(3, board.Rows[0].PlayerId); Assert.Equal(40, board.Rows[0].Score); Assert.Equal(1, board.Rows[0].Rank); Assert.Equal(2, board.Rows[1].PlayerId); Assert.Equal(2, board.Rows[1].Rank); Assert.Equal("User 3", board.Rows[0].PlayerName); } [Fact] public async Task SubmitScore_KeepsEachPlayersBestRatherThanTheirLast() { var factory = new TestDbContextFactory(); await SeedProfilesAsync(factory, 2); await LeaderboardStore.SubmitScoreAsync(factory, Stat(RoomA, 1, 50), 2, 0); await LeaderboardStore.SubmitScoreAsync(factory, Stat(RoomA, 1, 10), 2, 0); var board = await LeaderboardStore.GetRanksAsync(factory, new GetRanksRequest { RoomId = RoomA, StatChannel = 1, RankStart = 1, RankEnd = 10, }); Assert.Equal(1, board.Total); Assert.Equal(50, board.Rows[0].Score); } [Fact] public async Task SubmitScore_AscendingBoardRanksTheLowerTimeFirst() { var factory = new TestDbContextFactory(); await SeedProfilesAsync(factory, 2, 3); // A race board: lower is better, and the client marks it with SortAscending. await LeaderboardStore.SubmitScoreAsync(factory, "{\"StatChannel\":2,\"RoomId\":25,\"Time\":9000}", 2, 0); await LeaderboardStore.SubmitScoreAsync(factory, "{\"StatChannel\":2,\"RoomId\":25,\"Time\":4000}", 3, 0); var board = await LeaderboardStore.GetRanksAsync(factory, new GetRanksRequest { RoomId = RoomA, StatChannel = 2, SortAscending = true, RankStart = 1, RankEnd = 10, }); Assert.Equal(2, board.Total); Assert.Equal(3, board.Rows[0].PlayerId); Assert.Equal(1, board.Rows[0].Rank); } [Fact] public async Task SubmitScore_FallsBackToTheAuthenticatedPlayerWhenTheBodyHasNoPlayerId() { // The client's SetStatRequestDTO has no player field at all; identity is the token. var factory = new TestDbContextFactory(); await SeedProfilesAsync(factory, 2); var result = await LeaderboardStore.SubmitScoreAsync(factory, Stat(RoomA, 3, 7), 2, 0); Assert.NotNull(result); await using var db = factory.CreateDbContext(); var stored = await db.LeaderboardScores.SingleAsync(); Assert.Equal(2, stored.PlayerId); Assert.Equal(7, stored.Score); } [Fact] public async Task GetNearbyScores_CentresTheWindowOnTheAskingPlayer() { var factory = new TestDbContextFactory(); await SeedProfilesAsync(factory, 2, 3, 4, 5, 6); for (var i = 0; i < 5; i++) await LeaderboardStore.SubmitScoreAsync(factory, Stat(RoomA, 1, 10 * (i + 1)), 2 + i, 0); // Player 4 scored 30, so they sit third of five. var nearby = await LeaderboardStore.GetNearbyScoresAsync(factory, new GetNearbyScoresRequest { RoomId = RoomA, StatChannel = 1, PlayerId = 4, WindowSize = 3, }); Assert.Equal(5, nearby.Total); Assert.Equal(3, nearby.MyRank); Assert.Contains(nearby.Rows, r => r.PlayerId == 4 && r.Rank == 3); } [Fact] public async Task EmptyBoard_StillNamesTheAskingPlayer() { // The scoreboard renders the row the server hands back, so the synthetic row for // a player who has not scored yet must still carry their name rather than an // empty string. Names were loaded only for players already on the board, which on // an empty board is nobody. var factory = new TestDbContextFactory(); await SeedProfilesAsync(factory, 2); var ranks = await LeaderboardStore.GetRanksAsync(factory, new GetRanksRequest { RoomId = RoomA, StatChannel = 1, PlayerId = 2, RankStart = 1, RankEnd = 10, }); Assert.Equal("User 2", ranks.Rows[0].PlayerName); var nearby = await LeaderboardStore.GetNearbyScoresAsync(factory, new GetNearbyScoresRequest { RoomId = RoomA, StatChannel = 1, PlayerId = 2, WindowSize = 10, }); Assert.Equal("User 2", nearby.Rows[0].PlayerName); } [Fact] public async Task GetPlayerRank_NamesTheAskingPlayerWhenTheyHaveNotScored() { var factory = new TestDbContextFactory(); await SeedProfilesAsync(factory, 2); var rank = await LeaderboardStore.GetPlayerRankAsync(factory, new GetRankRequest { RoomId = RoomA, StatChannel = 1, PlayerId = 2, }); var name = rank.GetType().GetProperty("PlayerName")?.GetValue(rank) as string; Assert.Equal("User 2", name); } [Fact] public async Task GetPlayerRank_ReportsZeroForAPlayerWhoHasNotScored() { var factory = new TestDbContextFactory(); await SeedProfilesAsync(factory, 2, 3); await LeaderboardStore.SubmitScoreAsync(factory, Stat(RoomA, 1, 10), 3, 0); var rank = await LeaderboardStore.GetPlayerRankAsync(factory, new GetRankRequest { RoomId = RoomA, StatChannel = 1, PlayerId = 2, }); Assert.NotNull(rank); } }