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); }); });