jest.mock('@angular/common/http', () => ({ HttpClient: class {}, HttpErrorResponse: class {}, })); import { PLAYER_COLOR_PALETTE, RankingRow, applyRankingSort, applySolveToGraph, assignUniquePlayerColors, dedupEventLogBySolveId, formatSolveDateTime, mutateMatrixFromSolve, parseSolveEventIntoRanking, stablePlayerColorIndex, } from '../../frontend/src/app/features/scoreboard/scoreboard.pure'; describe('stablePlayerColorIndex', () => { it('is deterministic and in range', () => { const a = stablePlayerColorIndex('player-abc'); expect(a).toBeGreaterThanOrEqual(0); expect(a).toBeLessThan(PLAYER_COLOR_PALETTE.length); expect(stablePlayerColorIndex('player-abc')).toBe(a); expect(stablePlayerColorIndex('player-xyz')).not.toBe(a); }); }); describe('assignUniquePlayerColors', () => { const ids = [ 'PlayerA', 'PlayerB', 'PlayerC', 'Player04', 'Player05', 'Player06', 'Player07', 'Player08', 'Player09', 'Player10', ]; it('gives the documented colliding id set 10 distinct palette colors', () => { const rows: RankingRow[] = ids.map((playerId) => ({ rank: 0, playerId, playerName: playerId, solvedCount: 1, points: 0, colorIndex: stablePlayerColorIndex(playerId), })); const unique = assignUniquePlayerColors(rows, (r) => r.colorIndex); expect(unique.length).toBe(ids.length); const paletteHits = new Set(unique.map((r) => PLAYER_COLOR_PALETTE[r.colorIndex])); expect(paletteHits.size).toBe(PLAYER_COLOR_PALETTE.length); unique.forEach((r) => { expect(r.colorIndex).toBeGreaterThanOrEqual(0); expect(r.colorIndex).toBeLessThan(PLAYER_COLOR_PALETTE.length); }); }); it('keeps the hashed index when there is no collision', () => { const rows: RankingRow[] = [ { rank: 0, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 10, colorIndex: stablePlayerColorIndex('p1') }, { rank: 0, playerId: 'p2', playerName: 'p2', solvedCount: 1, points: 5, colorIndex: stablePlayerColorIndex('p2') }, ]; const hashed1 = rows[0].colorIndex; const hashed2 = rows[1].colorIndex; const unique = assignUniquePlayerColors(rows, (r) => r.colorIndex); expect(unique[0].colorIndex).toBe(hashed1); expect(unique[1].colorIndex).toBe(hashed2); }); }); describe('applyRankingSort', () => { it('produces competition ranks with zero-pt rows at the bottom', () => { const rows: RankingRow[] = [ { rank: 0, playerId: 'p1', playerName: 'p1', solvedCount: 2, points: 0, colorIndex: 0 }, { rank: 0, playerId: 'p2', playerName: 'p2', solvedCount: 1, points: 100, colorIndex: 1 }, { rank: 0, playerId: 'p3', playerName: 'p3', solvedCount: 1, points: 50, colorIndex: 2 }, { rank: 0, playerId: 'p4', playerName: 'p4', solvedCount: 0, points: 0, colorIndex: 3 }, ]; const sorted = applyRankingSort(rows); expect(sorted.map((r) => r.playerId)).toEqual(['p2', 'p3', 'p1', 'p4']); expect(sorted.map((r) => r.rank)).toEqual([1, 2, 3, 3]); }); }); describe('parseSolveEventIntoRanking', () => { it('adds a new player and re-sorts', () => { const rows: RankingRow[] = [ { rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 2, points: 100, colorIndex: 0 }, ]; const next = parseSolveEventIntoRanking(rows, { challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', playerId: 'p2', playerName: 'p2', awardedPoints: 50, rankBonus: 0, awardedAtUtc: '2025-01-01T00:00:00.000Z', position: 4, }); expect(next.length).toBe(2); const p2 = next.find((r) => r.playerId === 'p2')!; expect(p2.points).toBe(50); expect(p2.solvedCount).toBe(1); expect(next[0].playerId).toBe('p1'); }); }); describe('dedupEventLogBySolveId', () => { it('prepends new rows and removes duplicates', () => { const existing = [ { solveId: 's1', playerId: 'p1', playerName: 'p1', challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', position: 1, awardedPoints: 100, awardedAtUtc: '2025-01-01T00:00:00.000Z' }, ]; const newRows = [ { solveId: 's1', playerId: 'p1', playerName: 'p1', challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', position: 1, awardedPoints: 100, awardedAtUtc: '2025-01-01T00:00:00.000Z' }, { solveId: 's2', playerId: 'p2', playerName: 'p2', challengeId: 'c2', challengeName: 'beta', categoryAbbreviation: 'WEB', position: 1, awardedPoints: 50, awardedAtUtc: '2025-01-01T00:01:00.000Z' }, ]; const out = dedupEventLogBySolveId(existing, newRows); expect(out.length).toBe(2); expect(out[0].solveId).toBe('s2'); expect(out[1].solveId).toBe('s1'); }); }); describe('applySolveToGraph', () => { it('appends a new point to the matching series and re-applies the top-10 cutoff', () => { const baseView = { startUtc: '2025-01-01T00:00:00.000Z', endUtc: '2025-01-02T00:00:00.000Z', serverNowUtc: '2025-01-01T12:00:00.000Z', state: 'running' as const, series: [ { playerId: 'p0', playerName: 'p0', colorIndex: 0, points: [{ tUtc: '2025-01-01T00:00:00.000Z', value: 0 }, { tUtc: '2025-01-02T00:00:00.000Z', value: 50 }] }, ], }; const next = applySolveToGraph( baseView, { challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 2 }, '2025-01-01T00:00:00.000Z', '2025-01-02T00:00:00.000Z', '2025-01-01T12:00:00.000Z', ); expect(next.series[0].points.length).toBe(3); expect(next.series[0].points[0]).toEqual({ tUtc: '2025-01-01T00:00:00.000Z', value: 0 }); expect(next.series[0].points[1].tUtc).toBe('2025-01-01T06:00:00.000Z'); expect(next.series[0].points[1].value).toBe(80); expect(next.series[0].points[2]).toEqual({ tUtc: '2025-01-02T00:00:00.000Z', value: 80 }); }); it('returns empty when event is not configured', () => { const next = applySolveToGraph( null, { challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 1 }, null, null, '2025-01-01T12:00:00.000Z', ); expect(next.series).toEqual([]); }); it('yields 10 unique polyline colors for the documented colliding id set', () => { const ids = [ 'PlayerA', 'PlayerB', 'PlayerC', 'Player04', 'Player05', 'Player06', 'Player07', 'Player08', 'Player09', 'Player10', ]; const start = '2025-01-01T00:00:00.000Z'; const end = '2025-01-02T00:00:00.000Z'; let view: any = null; ids.forEach((playerId, idx) => { const awarded = 100 - idx * 5; view = applySolveToGraph( view, { challengeId: `c${idx}`, challengeName: `ch${idx}`, categoryAbbreviation: 'CRY', playerId, playerName: playerId, awardedPoints: awarded, rankBonus: 0, awardedAtUtc: `2025-01-01T01:00:0${idx}.000Z`, position: idx + 1, }, start, end, '2025-01-01T12:00:00.000Z', ); }); expect(view.series.length).toBe(10); const paletteHits = new Set(view.series.map((s: any) => PLAYER_COLOR_PALETTE[s.colorIndex])); expect(paletteHits.size).toBe(10); }); }); describe('mutateMatrixFromSolve', () => { it('stores the rank when within top-3', () => { const matrix = { players: [{ rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 100, colorIndex: 0 }], challenges: [{ id: 'c1', name: 'alpha', solveCount: 1, categoryAbbreviation: 'CRY' }], cells: { p1: { c1: 1 as 1 } }, }; const next = mutateMatrixFromSolve(matrix, { challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', playerId: 'p2', playerName: 'p2', awardedPoints: 50, rankBonus: 0, awardedAtUtc: '2025-01-01T00:00:00.000Z', position: 5, }); expect(next!.cells.p2.c1).toBe('solved'); }); }); describe('formatSolveDateTime', () => { it('returns empty string for invalid input', () => { expect(formatSolveDateTime(null)).toBe(''); expect(formatSolveDateTime('not a date')).toBe(''); }); });