feat: Challenges Page and Challenge Solve Modal 1.04
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
jest.mock('@angular/common/http', () => ({
|
||||
HttpClient: class {},
|
||||
HttpErrorResponse: class {},
|
||||
}));
|
||||
|
||||
import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store';
|
||||
import {
|
||||
BoardResponse,
|
||||
SolveResponse,
|
||||
} from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
function makeBoard(solvedByMe: boolean): BoardResponse {
|
||||
return {
|
||||
columns: [
|
||||
{
|
||||
id: 'cat1',
|
||||
abbreviation: 'CRY',
|
||||
name: 'Cryptography',
|
||||
iconPath: '',
|
||||
cards: [
|
||||
{
|
||||
id: 'alpha',
|
||||
name: 'Alpha Cipher',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 120,
|
||||
solveCount: 4,
|
||||
solvedByMe,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
solvedChallengeIds: solvedByMe ? ['alpha'] : [],
|
||||
perChallengeLivePoints: { alpha: 120 },
|
||||
};
|
||||
}
|
||||
|
||||
function createStore(): {
|
||||
store: ChallengesStore;
|
||||
api: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock };
|
||||
} {
|
||||
const api = {
|
||||
getBoard: jest.fn(),
|
||||
submit: jest.fn(),
|
||||
getDetail: jest.fn(),
|
||||
};
|
||||
const store = new ChallengesStore(api as any, { onDestroy: () => undefined } as any);
|
||||
return { store, api };
|
||||
}
|
||||
|
||||
describe('ChallengesStore — per-user solvedByMe after logout/login', () => {
|
||||
it('reset() clears board, selectedDetail, myUserId and stops any SSE', () => {
|
||||
const { store, api } = createStore();
|
||||
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
|
||||
return store.load().then(() => {
|
||||
expect(store.board()).not.toBeNull();
|
||||
// wire a fake SSE source and ensure reset closes it
|
||||
const closed = { v: false };
|
||||
const fakeSrc: any = {
|
||||
addEventListener: () => undefined,
|
||||
close: () => { closed.v = true; },
|
||||
};
|
||||
store.wireSse(() => fakeSrc as any);
|
||||
store.reset();
|
||||
expect(store.board()).toBeNull();
|
||||
expect(store.selectedChallengeDetail()).toBeNull();
|
||||
expect(closed.v).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('setMyUserId with a different id flushes the previous board', async () => {
|
||||
const { store, api } = createStore();
|
||||
// Record PlayerA first, then load so the cached board is owned by PlayerA.
|
||||
store.setMyUserId('playerA');
|
||||
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
|
||||
await store.load();
|
||||
expect(store.findCard('alpha')?.solvedByMe).toBe(true);
|
||||
|
||||
// Switching to PlayerB flushes the cached PlayerA board.
|
||||
api.getBoard.mockReturnValueOnce(of(makeBoard(false)));
|
||||
const prev = store.setMyUserId('playerB');
|
||||
expect(prev).toBe('playerA');
|
||||
expect(store.board()).toBeNull();
|
||||
|
||||
await store.load();
|
||||
expect(store.findCard('alpha')?.solvedByMe).toBe(false);
|
||||
});
|
||||
|
||||
it('preloaded board with no recorded user id is cleared when PlayerB id is set', async () => {
|
||||
const { store, api } = createStore();
|
||||
// Simulate the bug scenario: a board was loaded into the singleton
|
||||
// (e.g. by an earlier PlayerA session or by an SSR/refresh path that
|
||||
// fetched before setMyUserId ran) but no user id has been recorded yet.
|
||||
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
|
||||
await store.load();
|
||||
expect(store.findCard('alpha')).not.toBeNull();
|
||||
expect(store.findCard('alpha')!.solvedByMe).toBe(true);
|
||||
|
||||
// First-ever setMyUserId for a new user must flush the orphan board.
|
||||
const prev = store.setMyUserId('playerB');
|
||||
expect(prev).toBeNull();
|
||||
expect(store.board()).toBeNull();
|
||||
expect(store.selectedChallengeDetail()).toBeNull();
|
||||
expect(store.findCard('alpha')).toBeNull();
|
||||
|
||||
api.getBoard.mockReturnValueOnce(of(makeBoard(false)));
|
||||
await store.load();
|
||||
expect(store.findCard('alpha')!.solvedByMe).toBe(false);
|
||||
});
|
||||
|
||||
it('applySolveEvent for another user does not flip solvedByMe from false to true', async () => {
|
||||
const { store, api } = createStore();
|
||||
store.setMyUserId('me');
|
||||
api.getBoard.mockReturnValueOnce(of(makeBoard(false)));
|
||||
await store.load();
|
||||
|
||||
store.applySolveEvent({
|
||||
challengeId: 'alpha',
|
||||
playerId: 'someone-else',
|
||||
playerName: 'other',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2026-07-23T00:00:00.000Z',
|
||||
position: 5,
|
||||
livePoints: 110,
|
||||
solveCount: 5,
|
||||
initialPoints: 200,
|
||||
minimumPoints: 50,
|
||||
decaySolves: 10,
|
||||
});
|
||||
|
||||
expect(store.findCard('alpha')!.solvedByMe).toBe(false);
|
||||
});
|
||||
|
||||
it('applySubmitResponse always takes the server authoritative solvedByMe (no stale OR)', async () => {
|
||||
const { store, api } = createStore();
|
||||
store.setMyUserId('me');
|
||||
api.getBoard.mockReturnValueOnce(of(makeBoard(true)));
|
||||
await store.load();
|
||||
expect(store.findCard('alpha')!.solvedByMe).toBe(true);
|
||||
|
||||
const resp: SolveResponse = {
|
||||
status: 'solved',
|
||||
challenge: {
|
||||
id: 'alpha',
|
||||
name: 'Alpha Cipher',
|
||||
descriptionMd: '',
|
||||
categoryId: 'cat1',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '',
|
||||
difficulty: 'LOW',
|
||||
livePoints: 100,
|
||||
solveCount: 5,
|
||||
solvedByMe: false,
|
||||
},
|
||||
awarded: { basePoints: 100, rankBonus: 0, awardedPoints: 100, awardedAtUtc: '', position: 6 },
|
||||
solvers: [],
|
||||
};
|
||||
api.submit.mockReturnValueOnce(of(resp));
|
||||
await store.submit('alpha', 'flag{x}');
|
||||
expect(store.findCard('alpha')!.solvedByMe).toBe(false);
|
||||
expect(store.findCard('alpha')!.livePoints).toBe(100);
|
||||
expect(store.findCard('alpha')!.solveCount).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -119,9 +119,9 @@ describe('ChallengesStore', () => {
|
||||
});
|
||||
|
||||
it('applySolveEvent marks solvedByMe and increments solveCount when payload is mine', async () => {
|
||||
store.setMyUserId('me-1');
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
store.setMyUserId('me-1');
|
||||
store.applySolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'me-1',
|
||||
@@ -145,9 +145,9 @@ describe('ChallengesStore', () => {
|
||||
});
|
||||
|
||||
it('applySolveEvent does not double-count when payload is not mine', async () => {
|
||||
store.setMyUserId('me-1');
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await store.load();
|
||||
store.setMyUserId('me-1');
|
||||
store.applySolveEvent({
|
||||
challengeId: 'c1',
|
||||
playerId: 'someone-else',
|
||||
|
||||
Reference in New Issue
Block a user