This commit is contained in:
@@ -28,6 +28,27 @@ beforeEach(async () => {
|
||||
});
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
const emptyProfile = {
|
||||
stats: {
|
||||
highestRound: 0,
|
||||
kills: 0,
|
||||
roundsSurvived: 0,
|
||||
headshots: 0,
|
||||
revives: 0,
|
||||
downs: 0,
|
||||
gamesPlayed: 0,
|
||||
gamesCompleted: 0,
|
||||
bestKills: 0,
|
||||
bestScore: 0
|
||||
},
|
||||
records: null,
|
||||
maps: [],
|
||||
matches: [],
|
||||
matchesTotal: 0,
|
||||
offset: 0,
|
||||
limit: 25
|
||||
};
|
||||
|
||||
async function report(
|
||||
event: GameEvent['event'],
|
||||
round = 0,
|
||||
@@ -94,6 +115,54 @@ it('keeps report revisions stable during a match and invalidates completed data
|
||||
expect(revision()).toBeGreaterThan(reopened);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['t5', server, 'zombie_theater'],
|
||||
['t6', 't6_zombie_town_1', 'zm_transit']
|
||||
])(
|
||||
'loads a known %s player before qualification and refreshes cached profiles after game end',
|
||||
async (game, source, map) => {
|
||||
const { load } =
|
||||
await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
const request = {
|
||||
params: { guid: player().guid },
|
||||
url: new URL(
|
||||
`http://localhost/leaderboard/players/${player().guid}?game=${game}&offset=999`
|
||||
)
|
||||
};
|
||||
const send = (event: GameEvent['event'], round = 0) =>
|
||||
report(event, round, [player(undefined, { kills: 20 })], source, map);
|
||||
|
||||
await expect(load(request)).rejects.toMatchObject({ status: 404 });
|
||||
await send('game_start');
|
||||
const started = await load(request);
|
||||
expect(started).toMatchObject({
|
||||
profile: { ...emptyProfile, guid: player().guid, name: player().name },
|
||||
scope: { entry: null }
|
||||
});
|
||||
expect(started.profile.firstSeen).toBe('2026-09-18T20:00:00.000Z');
|
||||
expect(started.profile.lastSeen).toBe(started.profile.firstSeen);
|
||||
await send('game_end', 1);
|
||||
expect((await load(request)).profile).toMatchObject(emptyProfile);
|
||||
await send('game_start');
|
||||
await send('heartbeat', 2);
|
||||
expect((await load(request)).profile).toMatchObject(emptyProfile);
|
||||
await send('game_end', 2);
|
||||
const completed = await load(request);
|
||||
expect(completed).toMatchObject({
|
||||
profile: {
|
||||
stats: { highestRound: 2, kills: 20, gamesPlayed: 1 },
|
||||
records: { highestRound: { value: 2 } },
|
||||
matchesTotal: 1,
|
||||
offset: 0
|
||||
},
|
||||
scope: { entry: { rank: 1, kills: 20 } }
|
||||
});
|
||||
await send('game_start');
|
||||
await send('heartbeat', 10);
|
||||
expect((await load(request)).profile.stats).toEqual(completed.profile.stats);
|
||||
}
|
||||
);
|
||||
|
||||
it('publishes round 2 or higher games only after game end, preserving early stats and excluding round 1 departures', async () => {
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
@@ -107,7 +176,7 @@ it('publishes round 2 or higher games only after game end, preserving early stat
|
||||
total: 0,
|
||||
summary: { players: 0, matches: 0, kills: 0 }
|
||||
});
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
expect(getPlayerProfile(player().guid)).toMatchObject(emptyProfile);
|
||||
expect(serverMatchHistory()).toEqual({ total: 0, matches: [], maps: [], page: 1, limit: 25 });
|
||||
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
|
||||
expect(getLeaderboard()).toMatchObject({
|
||||
@@ -116,9 +185,9 @@ it('publishes round 2 or higher games only after game end, preserving early stat
|
||||
total: 0,
|
||||
summary: { players: 0, matches: 0, kills: 0 }
|
||||
});
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
expect(getPlayerProfile(player().guid)).toMatchObject(emptyProfile);
|
||||
expect(serverMatchHistory()).toMatchObject({ total: 1, matches: [{ status: 'active' }] });
|
||||
expect(getPlayerProfile(departed.guid)).toBeNull();
|
||||
expect(getPlayerProfile(departed.guid)).toMatchObject(emptyProfile);
|
||||
await report('game_end', 2, [player(undefined, { kills: 10 })]);
|
||||
const board = getLeaderboard();
|
||||
const profile = getPlayerProfile(player().guid);
|
||||
@@ -137,7 +206,7 @@ it('publishes round 2 or higher games only after game end, preserving early stat
|
||||
],
|
||||
summary: { players: 1, matches: 1, kills: 10 }
|
||||
});
|
||||
expect(getPlayerProfile(departed.guid)).toBeNull();
|
||||
expect(getPlayerProfile(departed.guid)).toMatchObject(emptyProfile);
|
||||
expect(history).toMatchObject({
|
||||
total: 1,
|
||||
matches: [{ startPlayers: 2, uniquePlayers: 2, durationMs: 3000, finalRound: 2 }]
|
||||
@@ -478,7 +547,7 @@ it('keeps disconnected player records while completing only the final roster', a
|
||||
await report('game_start', 0, [player(), other]);
|
||||
await report('round_change', 4, [player(undefined, { kills: 80 }), other]);
|
||||
expect(getLeaderboard()).toMatchObject({ entries: [], total: 0 });
|
||||
expect(getPlayerProfile(other.guid)).toBeNull();
|
||||
expect(getPlayerProfile(other.guid)).toMatchObject(emptyProfile);
|
||||
await report('game_end', 5, [player(undefined, { kills: 100 })]);
|
||||
expect(await records(other.guid)).toEqual([
|
||||
expect.objectContaining({ kills: 40, highest_round: 4, rounds_survived: 3, completed: 0 })
|
||||
@@ -682,7 +751,7 @@ it('keeps deleted match identities and starts a separate match when the server n
|
||||
server_deleted_at: null,
|
||||
status: 'active'
|
||||
});
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
expect(getPlayerProfile(player().guid)).toMatchObject(emptyProfile);
|
||||
await report('game_end', 8, [player(undefined, { kills: 120 })]);
|
||||
const profile = getPlayerProfile(player().guid)!;
|
||||
expect(profile.stats).toMatchObject({ gamesPlayed: 1, kills: 120 });
|
||||
|
||||
@@ -117,7 +117,14 @@ describe('persisted leaderboard queries', () => {
|
||||
)
|
||||
.run(id, guid);
|
||||
}
|
||||
expect(getPlayerProfile(status)).toBeNull();
|
||||
expect(getPlayerProfile(status)).toMatchObject({
|
||||
name: status,
|
||||
stats: { gamesPlayed: 0, kills: 0, highestRound: 0 },
|
||||
records: null,
|
||||
maps: [],
|
||||
matches: [],
|
||||
matchesTotal: 0
|
||||
});
|
||||
expect(getLeaderboard({ map: `t5:${status}` })).toMatchObject({
|
||||
entries: [],
|
||||
total: 0,
|
||||
@@ -594,7 +601,14 @@ describe('player profiles', () => {
|
||||
maps: [expect.objectContaining({ game: 't6' })],
|
||||
matches: [expect.objectContaining({ game: 't6' })]
|
||||
});
|
||||
expect(getPlayerProfile('404')).toBeNull();
|
||||
expect(getPlayerProfile('404')).toMatchObject({
|
||||
name: 'No stats',
|
||||
stats: { gamesPlayed: 0, kills: 0, highestRound: 0 },
|
||||
records: null,
|
||||
maps: [],
|
||||
matches: [],
|
||||
matchesTotal: 0
|
||||
});
|
||||
expect(getPlayerProfile("' OR 1=1 --")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ it('resolves tied bests by earliest completion, receipt time for unknown endings
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
for (const records of [getLeaderboard().entries[0].records, getPlayerProfile('101')!.records]) {
|
||||
for (const record of Object.values(records))
|
||||
for (const record of Object.values(records!))
|
||||
expect(record).toMatchObject({ matchId: 3, endedAt: null });
|
||||
}
|
||||
});
|
||||
@@ -245,19 +245,19 @@ it('records first observed rounds for start participants and late arrivals witho
|
||||
report('game_end', 52, [player('101', 200), player('202', 40), player('303')], 4);
|
||||
closeDb();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
expect(getPlayerProfile('101')!.records.highestRound).toMatchObject({
|
||||
expect(getPlayerProfile('101')!.records?.highestRound).toMatchObject({
|
||||
value: 52,
|
||||
startObserved: true,
|
||||
firstObservedRound: 0,
|
||||
presentAtStart: true
|
||||
});
|
||||
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({
|
||||
expect(getPlayerProfile('202')!.records?.highestRound).toMatchObject({
|
||||
value: 52,
|
||||
startObserved: true,
|
||||
firstObservedRound: 50,
|
||||
presentAtStart: false
|
||||
});
|
||||
expect(getPlayerProfile('303')!.records.highestRound).toMatchObject({
|
||||
expect(getPlayerProfile('303')!.records?.highestRound).toMatchObject({
|
||||
value: 52,
|
||||
startObserved: true,
|
||||
firstObservedRound: 0,
|
||||
@@ -315,13 +315,13 @@ it('migrates legacy participation as unknown before snapshot backfill and preser
|
||||
});
|
||||
closeDb();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
expect(getPlayerProfile('101')!.records.bestKills).toMatchObject({
|
||||
expect(getPlayerProfile('101')!.records?.bestKills).toMatchObject({
|
||||
matchId: 1,
|
||||
value: 150,
|
||||
firstObservedRound: null,
|
||||
presentAtStart: null
|
||||
});
|
||||
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({
|
||||
expect(getPlayerProfile('202')!.records?.highestRound).toMatchObject({
|
||||
firstObservedRound: 8,
|
||||
startObserved: false,
|
||||
presentAtStart: false
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('completed match details', () => {
|
||||
name: 'Player 303',
|
||||
hasStats: false,
|
||||
presentAtEnd: false,
|
||||
canViewProfile: false,
|
||||
canViewProfile: true,
|
||||
firstSeenAt: timestamp(0),
|
||||
lastSeenAt: timestamp(0),
|
||||
highestRound: null,
|
||||
@@ -232,28 +232,29 @@ describe('completed match details', () => {
|
||||
expect(participants.find((entry) => entry.guid === '202')).toMatchObject({
|
||||
hasStats: true,
|
||||
highestRound: 1,
|
||||
canViewProfile: false
|
||||
canViewProfile: true
|
||||
});
|
||||
expect(participants.find((entry) => entry.guid === '303')).toMatchObject({
|
||||
hasStats: false,
|
||||
canViewProfile: false
|
||||
canViewProfile: true
|
||||
});
|
||||
for (const participant of participants)
|
||||
expect(participant.canViewProfile).toBe(getPlayerProfile(participant.guid) !== null);
|
||||
|
||||
// A start-only participant can still have a valid profile from another match.
|
||||
// A start-only participant has an empty profile until another match qualifies.
|
||||
await report('game_start', 0, [player('303')]);
|
||||
await report('heartbeat', 3, [player('303')]);
|
||||
expect(getMatchDetail(1)!.players.find((entry) => entry.guid === '303')).toMatchObject({
|
||||
canViewProfile: false
|
||||
canViewProfile: true
|
||||
});
|
||||
expect(getPlayerProfile('303')).toBeNull();
|
||||
expect(getPlayerProfile('303')).toMatchObject({ records: null, matches: [] });
|
||||
await report('game_end', 3, [player('303')]);
|
||||
participants = getMatchDetail(1)!.players;
|
||||
expect(participants.find((entry) => entry.guid === '303')).toMatchObject({
|
||||
hasStats: false,
|
||||
canViewProfile: true
|
||||
});
|
||||
expect(getPlayerProfile('303')).toMatchObject({ stats: { gamesPlayed: 1 } });
|
||||
|
||||
// Missing identities must not discard a saved roster row or produce a dead link.
|
||||
handle.prepare("DELETE FROM leaderboard_players WHERE guid = '101'").run();
|
||||
|
||||
@@ -102,4 +102,60 @@ it('shows the same ranks in the desktop profile with lifetime map scope explaine
|
||||
'Lifetime personal bests across all squad sizes, ranked against other players on each map.'
|
||||
);
|
||||
expect(body).toContain('Tied records share a rank.');
|
||||
expect(body).not.toContain('No qualifying match history yet');
|
||||
expect(body).toContain('Show all completed matches');
|
||||
});
|
||||
|
||||
it('renders a known player without qualifying history with identity and unavailable personal bests', () => {
|
||||
const profile: PlayerProfile = {
|
||||
guid: '101',
|
||||
name: 'New player',
|
||||
firstSeen: evidence.startedAt,
|
||||
lastSeen: evidence.startedAt,
|
||||
stats: {
|
||||
highestRound: 0,
|
||||
bestKills: 0,
|
||||
bestScore: 0,
|
||||
kills: 0,
|
||||
roundsSurvived: 0,
|
||||
headshots: 0,
|
||||
revives: 0,
|
||||
downs: 0,
|
||||
gamesPlayed: 0,
|
||||
gamesCompleted: 0
|
||||
},
|
||||
records: null,
|
||||
maps: [],
|
||||
matches: [],
|
||||
matchesTotal: 0,
|
||||
limit: 25,
|
||||
offset: 0
|
||||
};
|
||||
const body = render(PlayerProfilePage, {
|
||||
props: {
|
||||
data: {
|
||||
profile,
|
||||
returnTo: '/leaderboard',
|
||||
scope: { game: '', map: '', players: '', metric: 'highestRound', entry: null }
|
||||
}
|
||||
}
|
||||
}).body;
|
||||
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
|
||||
expect(text).toContain('New player');
|
||||
expect(text).toContain('No qualifying match history yet');
|
||||
expect(text).toContain('Stats appear after a recorded game ends');
|
||||
expect(text).toContain('observed at round 2 or later');
|
||||
expect(text).toContain('Lifetime best round —');
|
||||
expect(text).toContain('Best game kills —');
|
||||
expect(text).toContain('Highest reported score —');
|
||||
expect(text).toContain('Lifetime games played 0');
|
||||
expect(text).toContain('No map records yet');
|
||||
expect(text).toContain('No qualifying matches recorded');
|
||||
expect(text).toContain('First seen');
|
||||
expect(text).toContain('Last seen');
|
||||
expect(body).toContain('Player GUID');
|
||||
expect(body).not.toContain('/matches/');
|
||||
expect(body).not.toContain('Show all completed matches');
|
||||
expect(body).not.toContain('<table');
|
||||
expect(body).not.toContain('NaN');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user