feat: Scoreboard: Ranking, Matrix, Event Log and Score Graph
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
jest.mock('@angular/common/http', () => ({
|
||||
HttpClient: class {},
|
||||
HttpErrorResponse: class {},
|
||||
}));
|
||||
|
||||
import {
|
||||
PLAYER_COLOR_PALETTE,
|
||||
RankingRow,
|
||||
applyRankingSort,
|
||||
applySolveToGraph,
|
||||
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('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',
|
||||
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', 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', 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
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('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
jest.mock('@angular/common/http', () => ({
|
||||
HttpClient: class {},
|
||||
HttpErrorResponse: class {},
|
||||
}));
|
||||
|
||||
import { ScoreboardStore } from '../../frontend/src/app/features/scoreboard/scoreboard.store';
|
||||
import {
|
||||
EventLogRow,
|
||||
GraphView,
|
||||
MatrixView,
|
||||
RankingRow,
|
||||
} from '../../frontend/src/app/features/scoreboard/scoreboard.pure';
|
||||
import { Observable, of } from 'rxjs';
|
||||
|
||||
class FakeApi {
|
||||
rankingCallCount = 0;
|
||||
matrixCallCount = 0;
|
||||
eventLogCallCount = 0;
|
||||
graphCallCount = 0;
|
||||
|
||||
getRanking(): Observable<RankingRow[]> {
|
||||
this.rankingCallCount += 1;
|
||||
return of<RankingRow[]>([
|
||||
{ rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 100, colorIndex: 0 },
|
||||
]);
|
||||
}
|
||||
getMatrix(): Observable<MatrixView> {
|
||||
this.matrixCallCount += 1;
|
||||
return of<MatrixView>({ players: [], challenges: [], cells: {} });
|
||||
}
|
||||
getEventLog(): Observable<EventLogRow[]> {
|
||||
this.eventLogCallCount += 1;
|
||||
return of<EventLogRow[]>([
|
||||
{ solveId: 's1', playerId: 'p1', playerName: 'p1', challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', position: 1, awardedPoints: 100, awardedAtUtc: '2025-01-01T00:00:00.000Z' },
|
||||
]);
|
||||
}
|
||||
getGraph(): Observable<GraphView> {
|
||||
this.graphCallCount += 1;
|
||||
return of<GraphView>({
|
||||
startUtc: '2025-01-01T00:00:00.000Z',
|
||||
endUtc: '2025-01-02T00:00:00.000Z',
|
||||
serverNowUtc: '2025-01-01T00:00:00.000Z',
|
||||
state: 'running',
|
||||
series: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function makeFakeSse(handler: (type: string, payload: any) => void): any {
|
||||
return {
|
||||
addEventListener(type: string, fn: (ev: any) => void) {
|
||||
handler(type, fn);
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ScoreboardStore', () => {
|
||||
it('loadAll fetches all four projections once', async () => {
|
||||
const api = new FakeApi();
|
||||
const destroyRef: any = { onDestroy: () => {} };
|
||||
const store = new ScoreboardStore(api as any, destroyRef);
|
||||
await store.loadAll();
|
||||
expect(api.rankingCallCount).toBe(1);
|
||||
expect(api.matrixCallCount).toBe(1);
|
||||
expect(api.eventLogCallCount).toBe(1);
|
||||
expect(api.graphCallCount).toBe(1);
|
||||
await store.loadAll();
|
||||
expect(api.rankingCallCount).toBe(1);
|
||||
});
|
||||
|
||||
it('applySolveEvent mutates ranking, eventLog, graph and matrix', () => {
|
||||
const api = new FakeApi();
|
||||
const destroyRef: any = { onDestroy: () => {} };
|
||||
const store = new ScoreboardStore(api as any, destroyRef);
|
||||
void store.loadAll();
|
||||
return new Promise<void>((resolve) => {
|
||||
const source = makeFakeSse((type, fn) => {
|
||||
if (type === 'solve') {
|
||||
setTimeout(() => {
|
||||
fn(new MessageEvent('solve', { data: JSON.stringify({
|
||||
challenge_id: 'c2',
|
||||
player_id: 'p1',
|
||||
player_name: 'p1',
|
||||
awarded_points: 25,
|
||||
rank_bonus: 0,
|
||||
awarded_at_utc: '2025-01-01T01:00:00.000Z',
|
||||
position: 4,
|
||||
}) }));
|
||||
setTimeout(() => {
|
||||
expect(store.ranking()?.[0]?.points).toBe(125);
|
||||
expect(store.eventLog()?.[0]?.awardedPoints).toBe(25);
|
||||
expect(store.graph()?.series[0]?.playerId).toBe('p1');
|
||||
resolve();
|
||||
}, 0);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
store.wireSse(() => source);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate event-log rows on replayed solve frame', () => {
|
||||
const api = new FakeApi();
|
||||
const destroyRef: any = { onDestroy: () => {} };
|
||||
const store = new ScoreboardStore(api as any, destroyRef);
|
||||
void store.loadAll();
|
||||
return new Promise<void>((resolve) => {
|
||||
let fire: ((ev: any) => void) | null = null;
|
||||
const source = makeFakeSse((type, fn) => {
|
||||
if (type === 'solve') fire = fn;
|
||||
});
|
||||
store.wireSse(() => source);
|
||||
const payload = {
|
||||
challenge_id: 'c2',
|
||||
player_id: 'p1',
|
||||
player_name: 'p1',
|
||||
awarded_points: 25,
|
||||
rank_bonus: 0,
|
||||
awarded_at_utc: '2025-01-01T01:00:00.000Z',
|
||||
position: 4,
|
||||
};
|
||||
setTimeout(() => {
|
||||
fire!(new MessageEvent('solve', { data: JSON.stringify(payload) }));
|
||||
fire!(new MessageEvent('solve', { data: JSON.stringify(payload) }));
|
||||
setTimeout(() => {
|
||||
const log = store.eventLog() ?? [];
|
||||
const p1c2 = log.filter((r) => r.playerId === 'p1' && r.challengeId === 'c2');
|
||||
expect(p1c2.length).toBe(1);
|
||||
resolve();
|
||||
}, 0);
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('reset clears all signals and stops SSE', () => {
|
||||
const api = new FakeApi();
|
||||
const destroyRef: any = { onDestroy: () => {} };
|
||||
const store = new ScoreboardStore(api as any, destroyRef);
|
||||
void store.loadAll();
|
||||
store.reset();
|
||||
expect(store.ranking()).toBeNull();
|
||||
expect(store.matrix()).toBeNull();
|
||||
expect(store.eventLog()).toBeNull();
|
||||
expect(store.graph()).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user