feat: add realtime Discord leaderboards
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildVoiceLeaderboard,
|
||||
buildXpLeaderboard,
|
||||
formatInteger,
|
||||
formatVoiceDuration,
|
||||
normalizeRequestedPage,
|
||||
shortenDiscordId,
|
||||
type VoiceSourceRow,
|
||||
type XpSourceRow,
|
||||
} from "@/lib/leaderboard/ranking";
|
||||
|
||||
function xpRow(
|
||||
userId: string,
|
||||
totalXp: string,
|
||||
level = 1,
|
||||
displayName = userId,
|
||||
): XpSourceRow {
|
||||
return {
|
||||
userId,
|
||||
username: `${userId}-username`,
|
||||
displayName,
|
||||
level,
|
||||
totalXp,
|
||||
};
|
||||
}
|
||||
|
||||
function voiceRow(
|
||||
userId: string,
|
||||
channelId: string,
|
||||
totalSeconds: string,
|
||||
displayName: string | null = userId,
|
||||
): VoiceSourceRow {
|
||||
return {
|
||||
userId,
|
||||
channelId,
|
||||
totalSeconds,
|
||||
displayName,
|
||||
username: displayName ? `${userId}-username` : null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("XP leaderboard", () => {
|
||||
test("filters level zero and assigns shared competition ranks", () => {
|
||||
const result = buildXpLeaderboard([
|
||||
xpRow("b", "900", 2, "Beta"),
|
||||
xpRow("a", "1000", 3, "Alpha"),
|
||||
xpRow("c", "900", 2, "Charlie"),
|
||||
xpRow("hidden", "9999", 0),
|
||||
xpRow("d", "800", 1, "Delta"),
|
||||
], 1);
|
||||
|
||||
expect(result.rows.map(({ userId, rank }) => [userId, rank])).toEqual([
|
||||
["a", 1],
|
||||
["b", 2],
|
||||
["c", 2],
|
||||
["d", 4],
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns ten rows and normalizes pages beyond the result", () => {
|
||||
const rows = Array.from({ length: 101 }, (_, index) =>
|
||||
xpRow(String(index).padStart(3, "0"), String(1_000 - index)));
|
||||
|
||||
const middle = buildXpLeaderboard(rows, 2);
|
||||
expect(middle.rows).toHaveLength(10);
|
||||
expect(middle.rows[0]?.rank).toBe(11);
|
||||
expect(middle.totalPages).toBe(11);
|
||||
|
||||
const last = buildXpLeaderboard(rows, 99);
|
||||
expect(last.page).toBe(11);
|
||||
expect(last.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("uses stable name then user ID ordering for ties", () => {
|
||||
const result = buildXpLeaderboard([
|
||||
xpRow("3", "100", 1, "Zulu"),
|
||||
xpRow("2", "100", 1, "Alpha"),
|
||||
xpRow("1", "100", 1, "Alpha"),
|
||||
], 1);
|
||||
|
||||
expect(result.rows.map((row) => row.userId)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice leaderboard", () => {
|
||||
const rows = [
|
||||
voiceRow("a", "voice-1", "100"),
|
||||
voiceRow("a", "voice-2", "200"),
|
||||
voiceRow("a", "deleted-channel", "9_999"),
|
||||
voiceRow("b", "voice-1", "300"),
|
||||
voiceRow("missing", "voice-2", "60", null),
|
||||
];
|
||||
|
||||
test("aggregates current channels and excludes historical channels", () => {
|
||||
const result = buildVoiceLeaderboard(rows, ["voice-1", "voice-2"], 1);
|
||||
|
||||
expect(result.rows.map(({ userId, totalSeconds, rank }) => ({
|
||||
userId,
|
||||
totalSeconds,
|
||||
rank,
|
||||
}))).toEqual([
|
||||
{ userId: "a", totalSeconds: "300", rank: 1 },
|
||||
{ userId: "b", totalSeconds: "300", rank: 1 },
|
||||
{ userId: "missing", totalSeconds: "60", rank: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("filters to a selected current channel", () => {
|
||||
const result = buildVoiceLeaderboard(
|
||||
rows,
|
||||
["voice-1", "voice-2"],
|
||||
1,
|
||||
"voice-1",
|
||||
);
|
||||
|
||||
expect(result.rows.map(({ userId, totalSeconds }) => [
|
||||
userId,
|
||||
totalSeconds,
|
||||
])).toEqual([
|
||||
["b", "300"],
|
||||
["a", "100"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps unresolved identities and formats their shortened ID", () => {
|
||||
const result = buildVoiceLeaderboard(rows, ["voice-2"], 1);
|
||||
const unresolved = result.rows.find((row) => row.userId === "missing");
|
||||
|
||||
expect(unresolved?.displayName).toBeNull();
|
||||
expect(unresolved?.username).toBeNull();
|
||||
expect(shortenDiscordId("123456789012345678")).toBe("12345…5678");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaderboard formatting", () => {
|
||||
test("normalizes invalid page values", () => {
|
||||
expect(normalizeRequestedPage(undefined)).toBe(1);
|
||||
expect(normalizeRequestedPage("0")).toBe(1);
|
||||
expect(normalizeRequestedPage("2.5")).toBe(1);
|
||||
expect(normalizeRequestedPage(["2", "3"])).toBe(1);
|
||||
expect(normalizeRequestedPage("3")).toBe(3);
|
||||
});
|
||||
|
||||
test("formats large integer and voice duration values", () => {
|
||||
expect(formatInteger("1234567")).toBe("1,234,567");
|
||||
expect(formatVoiceDuration("90061")).toBe("1 วัน 1 ชม. 1 นาที");
|
||||
expect(formatVoiceDuration("30")).toBe("น้อยกว่า 1 นาที");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user