320 lines
9.4 KiB
TypeScript
320 lines
9.4 KiB
TypeScript
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');
|
|
});
|
|
});
|