237 lines
9.9 KiB
TypeScript
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);
|
|
});
|
|
}); |