jest.mock('@angular/common/http', () => ({ HttpClient: class {}, HttpErrorResponse: class {}, })); import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store'; import { BoardResponse, ChallengeDetail, SolveResponse, } from '../../frontend/src/app/features/challenges/challenges.pure'; import { of } from 'rxjs'; function makeBoard(): BoardResponse { return { columns: [ { id: 'cat1', abbreviation: 'CRY', name: 'Cryptography', iconPath: '', cards: [ { id: 'c1', name: 'alpha', descriptionMd: '', categoryId: 'cat1', categoryAbbreviation: 'CRY', categoryIconPath: '', difficulty: 'LOW', livePoints: 100, solveCount: 0, solvedByMe: false, }, { id: 'c2', name: 'Zeta', descriptionMd: '', categoryId: 'cat1', categoryAbbreviation: 'CRY', categoryIconPath: '', difficulty: 'HIGH', livePoints: 300, solveCount: 0, solvedByMe: false, }, ], }, ], solvedChallengeIds: [], perChallengeLivePoints: { c1: 100, c2: 300 }, }; } function makeDetail(id = 'c1'): ChallengeDetail { return { challenge: { id, name: 'alpha', descriptionMd: '# Alpha', categoryId: 'cat1', categoryAbbreviation: 'CRY', categoryIconPath: '', difficulty: 'LOW', livePoints: 90, solveCount: 1, solvedByMe: false, }, solvers: [ { position: 1, playerId: 'p1', playerName: 'alice', solvedAtUtc: '2025-01-01T00:00:00.000Z', awardedPoints: 100, basePoints: 100, rankBonus: 0, isFirst: true, isSecond: false, isThird: false, }, ], }; } function createStore(api: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock }): ChallengesStore { const store = new ChallengesStore(api as any, { onDestroy: () => undefined } as any); return store; } describe('ChallengesStore', () => { let store: ChallengesStore; let apiSpy: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock }; beforeEach(() => { apiSpy = { getBoard: jest.fn(), submit: jest.fn(), getDetail: jest.fn(), }; store = createStore(apiSpy); }); it('loads the board via the API', async () => { const board = makeBoard(); apiSpy.getBoard.mockReturnValueOnce(of(board)); await store.load(); expect(store.board()).toEqual(board); expect(store.error()).toBeNull(); expect(store.loading()).toBe(false); }); it('sortedColumns sorts columns by abbreviation and cards by difficulty then name', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await store.load(); const cols = store.sortedColumns(); expect(cols[0].abbreviation).toBe('CRY'); expect(cols[0].cards.map((c) => c.id)).toEqual(['c1', 'c2']); }); it('applySolveEvent marks solvedByMe and increments solveCount when payload is mine', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await store.load(); store.setMyUserId('me-1'); store.applySolveEvent({ challengeId: 'c1', playerId: 'me-1', playerName: 'me', awardedPoints: 50, rankBonus: 0, awardedAtUtc: '2025-01-01T00:00:00.000Z', position: 1, livePoints: 80, solveCount: 1, initialPoints: 100, minimumPoints: 50, decaySolves: 5, }); const card = store.findCard('c1')!; expect(card.solvedByMe).toBe(true); expect(card.solveCount).toBe(1); expect(card.livePoints).toBe(80); expect(store.board()?.solvedChallengeIds).toContain('c1'); expect(store.board()?.perChallengeLivePoints['c1']).toBe(80); }); it('applySolveEvent does not double-count when payload is not mine', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await store.load(); store.setMyUserId('me-1'); store.applySolveEvent({ challengeId: 'c1', playerId: 'someone-else', playerName: 'other', awardedPoints: 50, rankBonus: 0, awardedAtUtc: '2025-01-01T00:00:00.000Z', position: 1, livePoints: 80, solveCount: 1, initialPoints: 100, minimumPoints: 50, decaySolves: 5, }); const card = store.findCard('c1')!; expect(card.solvedByMe).toBe(false); expect(card.solveCount).toBe(1); expect(card.livePoints).toBe(80); expect(store.board()?.solvedChallengeIds).not.toContain('c1'); }); it('applySubmitResponse updates the card with new livePoints + solveCount + solvedByMe', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await store.load(); const resp: SolveResponse = { status: 'solved', challenge: { id: 'c1', name: 'alpha', descriptionMd: '', categoryId: 'cat1', categoryAbbreviation: 'CRY', categoryIconPath: '', difficulty: 'LOW', livePoints: 80, solveCount: 3, solvedByMe: true, }, awarded: { basePoints: 80, rankBonus: 0, awardedPoints: 80, awardedAtUtc: '', position: 4 }, solvers: [], }; apiSpy.submit.mockReturnValueOnce(of(resp)); await store.submit('c1', 'flag{x}'); const card = store.findCard('c1')!; expect(card.livePoints).toBe(80); expect(card.solveCount).toBe(3); expect(card.solvedByMe).toBe(true); expect(store.board()?.perChallengeLivePoints['c1']).toBe(80); }); it('applyStatusPayload reflects new event state and updates isRunning', () => { store.applyStatusPayload({ state: 'running', serverNowUtc: new Date().toISOString(), eventStartUtc: null, eventEndUtc: null, secondsToStart: null, secondsToEnd: 3600, }); expect(store.eventState()).toBe('running'); expect(store.isRunning()).toBe(true); }); it('findCard returns null for unknown id', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await store.load(); expect(store.findCard('nope')).toBeNull(); }); it('loadDetail fetches and stores ChallengeDetail', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); apiSpy.getDetail.mockReturnValueOnce(of(makeDetail('c1'))); await store.load(); const detail = await store.loadDetail('c1'); expect(detail).toEqual(makeDetail('c1')); expect(store.selectedChallengeDetail()).toEqual(makeDetail('c1')); }); it('loadDetail returns null and clears selectedDetail on error', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await store.load(); apiSpy.getDetail.mockReturnValueOnce(of({} as any).pipe()); // simulate error apiSpy.getDetail.mockImplementation(() => ({ subscribe: ({ error }: any) => error(new Error('boom')), })); const detail = await store.loadDetail('c1'); expect(detail).toBeNull(); expect(store.selectedChallengeDetail()).toBeNull(); }); it('registerSolveListener fires for the matching challenge and unsubscribes', () => { const seen: string[] = []; const unsub = store.registerSolveListener('c1', (p) => seen.push(p.challengeId)); // invoke the internal dispatch path manually const ev = new MessageEvent('solve', { data: JSON.stringify({ challenge_id: 'c1', player_id: 'p1', player_name: 'alice', awarded_points: 50, rank_bonus: 0, awarded_at_utc: '2025-01-02T00:00:00.000Z', position: 2, live_points: 80, solve_count: 1, initial_points: 100, minimum_points: 50, decay_solves: 5, }), }); (store as any).handleSseFrame(ev); expect(seen).toEqual(['c1']); unsub(); (store as any).handleSseFrame(ev); expect(seen).toEqual(['c1']); }); it('registerSolveListener ignores solves for other challenges', () => { const seen: string[] = []; store.registerSolveListener('c1', (p) => seen.push(p.challengeId)); const ev = new MessageEvent('solve', { data: JSON.stringify({ challenge_id: 'c2', player_id: 'p1', awarded_points: 0, rank_bonus: 0, awarded_at_utc: '', position: 1, live_points: 0, solve_count: 1, initial_points: 0, minimum_points: 0, decay_solves: 0, }), }); (store as any).handleSseFrame(ev); expect(seen).toEqual([]); }); it('applySolveEvent updates the selectedDetail livePoints + merges solvers', async () => { apiSpy.getBoard.mockReturnValueOnce(of(makeBoard())); await store.load(); apiSpy.getDetail.mockReturnValueOnce(of(makeDetail('c1'))); await store.loadDetail('c1'); store.applySolveEvent({ challengeId: 'c1', playerId: 'p2', playerName: 'bob', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-02T00:00:00.000Z', position: 2, livePoints: 70, solveCount: 2, initialPoints: 100, minimumPoints: 50, decaySolves: 5, }); const detail = store.selectedChallengeDetail()!; expect(detail.challenge.livePoints).toBe(70); expect(detail.challenge.solveCount).toBe(2); expect(detail.solvers).toHaveLength(2); expect(detail.solvers[0].playerName).toBe('alice'); expect(detail.solvers[1].playerName).toBe('bob'); }); });