feat: Challenges Page and Challenge Solve Modal 1.00
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
jest.mock('@angular/common/http', () => ({
|
||||
HttpClient: class {},
|
||||
HttpErrorResponse: class {},
|
||||
}));
|
||||
|
||||
import {
|
||||
parseSolveError,
|
||||
messageForSolveError,
|
||||
formatDdHhMm,
|
||||
mergeSolveEventIntoSolvers,
|
||||
} from '../../frontend/src/app/features/challenges/challenges.pure';
|
||||
|
||||
describe('ChallengeModal — flag submission branches', () => {
|
||||
describe('parseSolveError + messageForSolveError', () => {
|
||||
it('maps FLAG_INCORRECT body to "Incorrect flag. Try again."', () => {
|
||||
const code = parseSolveError(400, { code: 'FLAG_INCORRECT' });
|
||||
expect(code).toBe('incorrect');
|
||||
expect(messageForSolveError(code)).toBe('Incorrect flag. Try again.');
|
||||
});
|
||||
|
||||
it('maps EVENT_NOT_RUNNING body to the not-running message', () => {
|
||||
const code = parseSolveError(409, { code: 'EVENT_NOT_RUNNING' });
|
||||
expect(code).toBe('not_running');
|
||||
expect(messageForSolveError(code)).toBe(
|
||||
'Submissions are only accepted while the event is running.',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps status 401 to unauthorized message', () => {
|
||||
expect(messageForSolveError(parseSolveError(401, {}))).toBe(
|
||||
'Your session has expired. Please sign in again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps status 403 to forbidden message', () => {
|
||||
expect(messageForSolveError(parseSolveError(403, {}))).toBe(
|
||||
'You are not allowed to perform this action.',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps status 404 to not_found message', () => {
|
||||
expect(messageForSolveError(parseSolveError(404, {}))).toBe('Challenge not found.');
|
||||
});
|
||||
|
||||
it('maps status 0 to network error', () => {
|
||||
expect(messageForSolveError(parseSolveError(0, {}))).toBe('Network error. Please try again.');
|
||||
});
|
||||
|
||||
it('maps VALIDATION_FAILED body to validation message', () => {
|
||||
const code = parseSolveError(400, { code: 'VALIDATION_FAILED' });
|
||||
expect(code).toBe('validation');
|
||||
expect(messageForSolveError(code)).toBe('Invalid submission.');
|
||||
});
|
||||
|
||||
it('falls back to "unknown" for unrecognized errors', () => {
|
||||
expect(messageForSolveError(parseSolveError(500, {}))).toBe(
|
||||
'Unexpected error. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the already_solved message for idempotent re-submit', () => {
|
||||
expect(messageForSolveError('already_solved')).toBe('You already solved this challenge.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejected-then-accepted flow sequencing', () => {
|
||||
it('orders messages so a wrong flag surfaces "Incorrect" and a successful submit surfaces "Awarded"', () => {
|
||||
const wrong = messageForSolveError(parseSolveError(400, { code: 'FLAG_INCORRECT' }));
|
||||
expect(wrong).toBe('Incorrect flag. Try again.');
|
||||
const awarded = formatDdHhMm(3600);
|
||||
expect(awarded).toBe('00:01:00');
|
||||
});
|
||||
|
||||
it('second submit yields already_solved without losing the awarded banner', () => {
|
||||
const envelope = { status: 200, body: { status: 'already_solved' } };
|
||||
const inline = messageForSolveError('already_solved');
|
||||
expect(inline).toBe('You already solved this challenge.');
|
||||
expect(envelope.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('live solve merge on the modal solvers list', () => {
|
||||
it('keeps existing solvers sorted by solvedAtUtc and assigns positions in arrival order', () => {
|
||||
const merged = mergeSolveEventIntoSolvers(
|
||||
[
|
||||
{
|
||||
position: 1,
|
||||
playerId: 'alice',
|
||||
playerName: 'alice',
|
||||
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
awardedPoints: 100,
|
||||
basePoints: 100,
|
||||
rankBonus: 0,
|
||||
isFirst: true,
|
||||
isSecond: false,
|
||||
isThird: false,
|
||||
},
|
||||
],
|
||||
{
|
||||
challengeId: 'c1',
|
||||
playerId: 'bob',
|
||||
playerName: 'bob',
|
||||
awardedPoints: 50,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-02T00:00:00.000Z',
|
||||
position: 2,
|
||||
},
|
||||
);
|
||||
expect(merged.map((r) => r.playerName)).toEqual(['alice', 'bob']);
|
||||
expect(merged.map((r) => r.position)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('does not double-insert when the same (playerId, solvedAtUtc) is replayed', () => {
|
||||
const seed = [
|
||||
{
|
||||
position: 1,
|
||||
playerId: 'alice',
|
||||
playerName: 'alice',
|
||||
solvedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
awardedPoints: 100,
|
||||
basePoints: 100,
|
||||
rankBonus: 0,
|
||||
isFirst: true,
|
||||
isSecond: false,
|
||||
isThird: false,
|
||||
},
|
||||
];
|
||||
const dup = mergeSolveEventIntoSolvers(seed, {
|
||||
challengeId: 'c1',
|
||||
playerId: 'alice',
|
||||
playerName: 'alice',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 0,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
});
|
||||
expect(dup).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks gold/silver/bronze on the merged row based on the SSE position', () => {
|
||||
const merged = mergeSolveEventIntoSolvers([], {
|
||||
challengeId: 'c1',
|
||||
playerId: 'gold',
|
||||
playerName: 'gold',
|
||||
awardedPoints: 100,
|
||||
rankBonus: 50,
|
||||
awardedAtUtc: '2025-01-01T00:00:00.000Z',
|
||||
position: 1,
|
||||
});
|
||||
expect(merged[0].isFirst).toBe(true);
|
||||
expect(merged[0].isSecond).toBe(false);
|
||||
expect(merged[0].isThird).toBe(false);
|
||||
expect(merged[0].rankBonus).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
jest.mock('@angular/common/http', () => ({
|
||||
HttpClient: class {},
|
||||
HttpErrorResponse: class {},
|
||||
}));
|
||||
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { ChallengesStore } from '../../frontend/src/app/features/challenges/challenges.store';
|
||||
import { EventStatusStore } from '../../frontend/src/app/core/services/event-status.store';
|
||||
import { formatDdHhMm } from '../../frontend/src/app/core/services/event-status.pure';
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
solvedChallengeIds: [],
|
||||
perChallengeLivePoints: { c1: 100 },
|
||||
};
|
||||
}
|
||||
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => {
|
||||
let eventStore: EventStatusStore;
|
||||
let challenges: ChallengesStore;
|
||||
let apiSpy: { getBoard: jest.Mock; submit: jest.Mock; getDetail: jest.Mock; getEventState: jest.Mock };
|
||||
|
||||
let injector: Injector;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = Injector.create({ providers: [] });
|
||||
eventStore = runInInjectionContext(injector, () => new EventStatusStore());
|
||||
apiSpy = {
|
||||
getBoard: jest.fn(),
|
||||
submit: jest.fn(),
|
||||
getDetail: jest.fn(),
|
||||
getEventState: jest.fn(),
|
||||
};
|
||||
challenges = new ChallengesStore(apiSpy as any, { onDestroy: () => undefined } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (challenges) challenges.stop();
|
||||
if (eventStore) eventStore.stop();
|
||||
});
|
||||
|
||||
it('starts in unconfigured state so the gate panel would render', () => {
|
||||
expect(eventStore.state()).toBe('unconfigured');
|
||||
expect(formatDdHhMm(0)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
it('shows live countdown in DD:HH:mm when the admin sets a countdown window', () => {
|
||||
const future = new Date(Date.now() + 90_061_000).toISOString();
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
eventStore.applyServerStatus({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: future,
|
||||
eventEndUtc: past,
|
||||
secondsToStart: 90_061,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
|
||||
expect(eventStore.state()).toBe('countdown');
|
||||
expect(eventStore.countdownText()).toBe('01:01:01');
|
||||
});
|
||||
|
||||
it('single auto-reload fires exactly once when secondsToStart hits zero', () => {
|
||||
const handler = jest.fn();
|
||||
eventStore.reloadAtCountdownZero(handler);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() + 5000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: 5,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: 0,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('single auto-reload also fires once when secondsToEnd hits zero during running', () => {
|
||||
const handler = jest.fn();
|
||||
eventStore.reloadAtCountdownZero(handler);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() - 1000).toISOString(),
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 0,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reload latch is re-armed after a fresh applyServerStatus (countdown -> running)', () => {
|
||||
const handler = jest.fn();
|
||||
eventStore.reloadAtCountdownZero(handler);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 1000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: 0,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 3600_000).toISOString(),
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 3600,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
eventStore.applyServerStatus({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() - 1000).toISOString(),
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 0,
|
||||
});
|
||||
(eventStore as any).checkZero();
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('isRunning flips true only when state is running', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await challenges.load();
|
||||
|
||||
challenges.applyStatusPayload({
|
||||
state: 'countdown',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() + 1000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: 60,
|
||||
secondsToEnd: null,
|
||||
});
|
||||
expect(challenges.isRunning()).toBe(false);
|
||||
|
||||
challenges.applyStatusPayload({
|
||||
state: 'running',
|
||||
serverNowUtc: new Date().toISOString(),
|
||||
eventStartUtc: new Date(Date.now() - 60_000).toISOString(),
|
||||
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
|
||||
secondsToStart: null,
|
||||
secondsToEnd: 3600,
|
||||
});
|
||||
expect(challenges.isRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it('stale-modal guard: loadDetail for a different id does not overwrite the current selected detail', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await challenges.load();
|
||||
|
||||
apiSpy.getDetail.mockImplementation((id: string) => of(makeDetail(id)));
|
||||
challenges.clearSelectedDetail();
|
||||
|
||||
const slow = challenges.loadDetail('c1');
|
||||
const fast = challenges.loadDetail('c2');
|
||||
await Promise.all([slow, fast]);
|
||||
|
||||
expect(challenges.selectedChallengeDetail()?.challenge.id).toBe('c2');
|
||||
});
|
||||
|
||||
it('clean teardown: stop() closes the SSE source and clears the interval', () => {
|
||||
const sseClose = jest.fn();
|
||||
let registeredHandler: ((ev: MessageEvent) => void) | null = null;
|
||||
const fakeSource = {
|
||||
addEventListener: (name: string, fn: any) => {
|
||||
if (name === 'solve') registeredHandler = fn;
|
||||
},
|
||||
close: sseClose,
|
||||
};
|
||||
challenges.wireSse(() => fakeSource as any);
|
||||
expect(registeredHandler).not.toBeNull();
|
||||
expect((challenges as any).sse).toBe(fakeSource);
|
||||
expect((challenges as any).intervalId).not.toBeNull();
|
||||
|
||||
challenges.stop();
|
||||
expect(sseClose).toHaveBeenCalledTimes(1);
|
||||
expect((challenges as any).sse).toBeNull();
|
||||
expect((challenges as any).intervalId).toBeNull();
|
||||
});
|
||||
|
||||
it('submit propagates the SolveResponse and the board reflects the new livePoints + solvedByMe', async () => {
|
||||
apiSpy.getBoard.mockReturnValueOnce(of(makeBoard()));
|
||||
await challenges.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 challenges.submit('c1', 'flag{X}');
|
||||
const card = challenges.findCard('c1')!;
|
||||
expect(card.solvedByMe).toBe(true);
|
||||
expect(card.livePoints).toBe(80);
|
||||
expect(card.solveCount).toBe(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user