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 { this.rankingCallCount += 1; return of([ { rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 100, colorIndex: 0 }, ]); } getMatrix(): Observable { this.matrixCallCount += 1; return of({ players: [], challenges: [], cells: {} }); } getEventLog(): Observable { this.eventLogCallCount += 1; return of([ { 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 { this.graphCallCount += 1; return of({ 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((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((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(); }); });