Files
HIPCTF2/tests/backend/scoreboard.service.spec.ts
T

237 lines
9.9 KiB
TypeScript

import { ScoreboardService } from '../../backend/src/modules/challenges/scoreboard.service';
import {
computeAwardedPoints,
PLAYER_COLOR_PALETTE,
stablePlayerColorIndex,
} from '../../backend/src/modules/challenges/scoring.util';
function buildMockManager(opts: {
solves?: any[];
challenges?: any[];
users?: any[];
}): any {
const solves = opts.solves ?? [];
const challenges = opts.challenges ?? [];
const users = opts.users ?? [];
function solveRepo() {
return {
createQueryBuilder: () => {
const b: any = {};
b.leftJoin = jest.fn().mockReturnThis();
b.select = jest.fn().mockReturnThis();
b.addSelect = jest.fn().mockReturnThis();
b.orderBy = jest.fn().mockReturnThis();
b.addOrderBy = jest.fn().mockReturnThis();
b.limit = jest.fn().mockReturnThis();
b.getRawMany = jest.fn().mockImplementation(async () => solves);
return b;
},
};
}
function userRepo() {
return {
createQueryBuilder: () => {
const b: any = {};
b.select = jest.fn().mockReturnThis();
b.addSelect = jest.fn().mockReturnThis();
b.getRawMany = jest.fn().mockImplementation(async () => users);
return b;
},
};
}
function challengeRepo() {
return {
createQueryBuilder: () => {
const b: any = {};
b.leftJoin = jest.fn().mockReturnThis();
b.select = jest.fn().mockReturnThis();
b.addSelect = jest.fn().mockReturnThis();
b.where = jest.fn().mockReturnThis();
b.getRawMany = jest.fn().mockImplementation(async () => challenges);
return b;
},
};
}
return {
getRepository: (entity: any) => {
const name = entity?.name ?? '';
if (name === 'SolveEntity') return solveRepo();
if (name === 'UserEntity') return userRepo();
if (name === 'ChallengeEntity') return challengeRepo();
return { createQueryBuilder: () => ({ getRawMany: async () => [] }) };
},
};
}
const fakeSettings: any = { get: jest.fn().mockResolvedValue('') };
const fakeEventStatus: any = {
getState: jest.fn().mockResolvedValue({
state: 'unconfigured',
serverNowUtc: '2025-01-01T00:00:00.000Z',
eventStartUtc: null,
eventEndUtc: null,
secondsToStart: null,
secondsToEnd: null,
}),
};
describe('scoring.util additions', () => {
it('computeAwardedPoints adds rank bonus for 1st/2nd/3rd only', () => {
expect(computeAwardedPoints(500, 100, 10, 0)).toBe(500 + 15);
expect(computeAwardedPoints(500, 100, 10, 1)).toBe(460 + 10);
expect(computeAwardedPoints(500, 100, 10, 2)).toBe(420 + 5);
expect(computeAwardedPoints(500, 100, 10, 3)).toBe(380);
expect(computeAwardedPoints(500, 100, 10, 9)).toBe(140);
});
it('stablePlayerColorIndex is deterministic and in range', () => {
const idx = stablePlayerColorIndex('player-abc');
expect(idx).toBeGreaterThanOrEqual(0);
expect(idx).toBeLessThan(PLAYER_COLOR_PALETTE.length);
expect(stablePlayerColorIndex('player-abc')).toBe(idx);
expect(stablePlayerColorIndex('player-xyz')).not.toBe(idx);
});
});
describe('ScoreboardService.getRanking', () => {
it('returns competition ranks with zero-pt players sorted to the bottom', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const mgr = buildMockManager({
solves: [
{ id: 's1', challengeId: 'c1', userId: 'u-alice', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 100, username: 'alice' },
{ id: 's2', challengeId: 'c2', userId: 'u-alice', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 50, username: 'alice' },
{ id: 's3', challengeId: 'c1', userId: 'u-bob', solvedAt: '2025-01-01T00:02:00.000Z', pointsAwarded: 80, username: 'bob' },
],
users: [
{ id: 'u-alice', username: 'alice' },
{ id: 'u-bob', username: 'bob' },
{ id: 'u-carol', username: 'carol' },
],
});
const ranking = await svc.getRanking(mgr);
expect(ranking).toHaveLength(3);
expect(ranking[0]).toMatchObject({ rank: 1, playerId: 'u-alice', points: 150, solvedCount: 2 });
expect(ranking[1]).toMatchObject({ rank: 2, playerId: 'u-bob', points: 80, solvedCount: 1 });
expect(ranking[2]).toMatchObject({ rank: 3, playerId: 'u-carol', points: 0, solvedCount: 0 });
for (const r of ranking) {
expect(r.colorIndex).toBeGreaterThanOrEqual(0);
expect(r.colorIndex).toBeLessThan(PLAYER_COLOR_PALETTE.length);
}
});
it('uses earliest solve as tiebreaker', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const mgr = buildMockManager({
solves: [
{ id: 's1', challengeId: 'c1', userId: 'u-bob', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 100, username: 'bob' },
{ id: 's2', challengeId: 'c1', userId: 'u-alice', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 100, username: 'alice' },
],
users: [
{ id: 'u-alice', username: 'alice' },
{ id: 'u-bob', username: 'bob' },
],
});
const ranking = await svc.getRanking(mgr);
expect(ranking[0].playerId).toBe('u-alice');
expect(ranking[1].playerId).toBe('u-bob');
});
});
describe('ScoreboardService.getMatrix', () => {
it('marks top-3 positions and sorts challenges by solve count DESC then name', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const mgr = buildMockManager({
solves: [
{ id: 's1', challengeId: 'c1', userId: 'u-a', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 50, username: 'a' },
{ id: 's2', challengeId: 'c1', userId: 'u-b', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 50, username: 'b' },
{ id: 's3', challengeId: 'c1', userId: 'u-c', solvedAt: '2025-01-01T00:02:00.000Z', pointsAwarded: 50, username: 'c' },
{ id: 's4', challengeId: 'c1', userId: 'u-d', solvedAt: '2025-01-01T00:03:00.000Z', pointsAwarded: 50, username: 'd' },
{ id: 's5', challengeId: 'c2', userId: 'u-a', solvedAt: '2025-01-01T00:04:00.000Z', pointsAwarded: 50, username: 'a' },
],
challenges: [
{ id: 'c1', name: 'alpha', initialPoints: 500, minimumPoints: 100, decaySolves: 10, categoryAbbreviation: 'CRY' },
{ id: 'c2', name: 'beta', initialPoints: 300, minimumPoints: 50, decaySolves: 5, categoryAbbreviation: 'WEB' },
],
users: [
{ id: 'u-a', username: 'a' },
{ id: 'u-b', username: 'b' },
{ id: 'u-c', username: 'c' },
{ id: 'u-d', username: 'd' },
],
});
const matrix = await svc.getMatrix(mgr);
expect(matrix.challenges[0].id).toBe('c1');
expect(matrix.challenges[1].id).toBe('c2');
expect(matrix.cells['u-a']?.['c1']).toBe(1);
expect(matrix.cells['u-b']?.['c1']).toBe(2);
expect(matrix.cells['u-c']?.['c1']).toBe(3);
expect(matrix.cells['u-d']?.['c1']).toBe('solved');
expect(matrix.cells['u-a']?.['c2']).toBe(1);
expect(matrix.cells['u-b']?.['c2']).toBeNull();
});
});
describe('ScoreboardService.getGraph', () => {
it('prepends start point and appends end point, limits to top 10', async () => {
const settings: any = {
get: jest.fn().mockImplementation(async (k: string) => {
if (k === 'eventStartUtc') return '2025-01-01T00:00:00.000Z';
if (k === 'eventEndUtc') return '2025-01-02T00:00:00.000Z';
return '';
}),
};
const eventStatus: any = {
getState: jest.fn().mockResolvedValue({
state: 'running',
serverNowUtc: '2025-01-01T12:00:00.000Z',
eventStartUtc: '2025-01-01T00:00:00.000Z',
eventEndUtc: '2025-01-02T00:00:00.000Z',
secondsToStart: null,
secondsToEnd: 43200,
}),
};
const svc = new ScoreboardService(settings, eventStatus);
const mgr = buildMockManager({
solves: [
{ id: 's1', challengeId: 'c1', userId: 'u-a', solvedAt: '2025-01-01T01:00:00.000Z', pointsAwarded: 115, username: 'a' },
{ id: 's2', challengeId: 'c2', userId: 'u-a', solvedAt: '2025-01-01T02:00:00.000Z', pointsAwarded: 60, username: 'a' },
],
challenges: [
{ id: 'c1', name: 'alpha', initialPoints: 500, minimumPoints: 100, decaySolves: 10, categoryAbbreviation: 'CRY' },
{ id: 'c2', name: 'beta', initialPoints: 300, minimumPoints: 50, decaySolves: 5, categoryAbbreviation: 'WEB' },
],
});
const graph = await svc.getGraph(mgr);
expect(graph.series).toHaveLength(1);
const s = graph.series[0];
expect(s.playerId).toBe('u-a');
expect(s.points[0]).toEqual({ tUtc: '2025-01-01T00:00:00.000Z', value: 0 });
expect(s.points[s.points.length - 1]).toEqual({ tUtc: '2025-01-02T00:00:00.000Z', value: 515 + 315 });
});
it('returns empty series when event not configured', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const graph = await svc.getGraph(buildMockManager({}));
expect(graph.series).toEqual([]);
expect(graph.startUtc).toBeNull();
expect(graph.endUtc).toBeNull();
});
});
describe('ScoreboardService.getEventLog', () => {
it('returns rows with computed position from same-challenge order', async () => {
const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
const solves = [
{ id: 's1', challengeId: 'c1', userId: 'u-a', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 115, basePoints: 100, rankBonus: 15, username: 'a', challengeName: 'alpha', categoryAbbreviation: 'CRY' },
{ id: 's2', challengeId: 'c1', userId: 'u-b', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 110, basePoints: 100, rankBonus: 10, username: 'b', challengeName: 'alpha', categoryAbbreviation: 'CRY' },
];
const mgr = buildMockManager({ solves });
const log = await svc.getEventLog(mgr, 10);
expect(log).toHaveLength(2);
const bySolveId = new Map(log.map((r: any) => [r.solveId, r]));
expect((bySolveId.get('s1') as any).position).toBe(1);
expect((bySolveId.get('s2') as any).position).toBe(2);
});
});