Files
HIPCTF2/tests/frontend/scoreboard.store.spec.ts
T

182 lines
6.0 KiB
TypeScript

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('propagates challenge name and category abbreviation from SSE solve frame to the Event Log row', () => {
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',
challenge_name: 'Hardware Hello',
category_abbreviation: 'HW',
player_id: 'p3',
player_name: 'PlayerC',
awarded_points: 115,
rank_bonus: 15,
awarded_at_utc: '2026-07-23T05:25:37.309Z',
position: 1,
}) }));
setTimeout(() => {
const top = store.eventLog()?.[0];
expect(top?.challengeName).toBe('Hardware Hello');
expect(top?.categoryAbbreviation).toBe('HW');
expect(top?.playerName).toBe('PlayerC');
expect(top?.awardedPoints).toBe(115);
resolve();
}, 0);
}, 0);
}
});
store.wireSse(() => source);
});
});
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();
});
});