feat: Challenges Page and Challenge Solve Modal

This commit is contained in:
OpenVelo Agent
2026-07-23 00:13:57 +00:00
parent 77d31e0ee1
commit cb88b21462
32 changed files with 4134 additions and 119 deletions
+319
View File
@@ -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');
});
});