feat: Challenges Page and Challenge Solve Modal
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user