Files
Radicade/ServerTests/LeaderboardTests.cs
T
2026-10-16 23:33:13 -07:00

232 lines
7.9 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public class LeaderboardTests
{
private const long RoomA = 25;
private const long RoomB = 32;
private sealed class TestDbContextFactory : IDbContextFactory<RecDBContext>
{
private readonly DbContextOptions<RecDBContext> 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<RecDBContext> 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);
}
}