feat: Challenges Page and Challenge Solve Modal 1.03

This commit is contained in:
OpenVelo Agent
2026-07-23 03:11:16 +00:00
parent c434e5d3a2
commit 1c64f057f9
10 changed files with 229 additions and 63 deletions
@@ -68,7 +68,7 @@ describe('ChallengeModal — flag submission branches', () => {
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');
expect(awarded).toBe('00:01:00:00');
});
it('second submit yields already_solved without losing the awarded banner', () => {
+49 -3
View File
@@ -83,10 +83,10 @@ describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => {
it('starts in unconfigured state so the gate panel would render', () => {
expect(eventStore.state()).toBe('unconfigured');
expect(formatDdHhMm(0)).toBe('00:00:00');
expect(formatDdHhMm(0)).toBe('00:00:00:00');
});
it('shows live countdown in DD:HH:mm when the admin sets a countdown window', () => {
it('shows live countdown in DD:HH:mm:ss 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({
@@ -99,7 +99,53 @@ describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => {
});
expect(eventStore.state()).toBe('countdown');
expect(eventStore.countdownText()).toBe('01:01:01');
const text = eventStore.countdownText();
expect(text).toMatch(/^01:01:01:\d{2}$/);
const [d, h, m, s] = text.split(':').map(Number);
expect(d).toBe(1);
expect(h).toBe(1);
expect(m).toBe(1);
expect(s).toBeGreaterThanOrEqual(0);
expect(s).toBeLessThanOrEqual(2);
});
it('formats the final minute second-by-second so the user can see time elapse', () => {
eventStore.applyServerStatus({
state: 'countdown',
serverNowUtc: new Date().toISOString(),
eventStartUtc: new Date(Date.now() + 24_000).toISOString(),
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
secondsToStart: 24,
secondsToEnd: null,
});
const first = eventStore.countdownText();
expect(first).toMatch(/^00:00:00:\d{2}$/);
expect(Number(first.split(':')[3])).toBeGreaterThanOrEqual(22);
expect(Number(first.split(':')[3])).toBeLessThanOrEqual(24);
eventStore.applyServerStatus({
state: 'countdown',
serverNowUtc: new Date().toISOString(),
eventStartUtc: new Date(Date.now() + 23_000).toISOString(),
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
secondsToStart: 23,
secondsToEnd: null,
});
const second = eventStore.countdownText();
expect(Number(second.split(':')[3])).toBeGreaterThanOrEqual(21);
expect(Number(second.split(':')[3])).toBeLessThanOrEqual(23);
eventStore.applyServerStatus({
state: 'countdown',
serverNowUtc: new Date().toISOString(),
eventStartUtc: new Date(Date.now() + 22_000).toISOString(),
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
secondsToStart: 22,
secondsToEnd: null,
});
const third = eventStore.countdownText();
expect(Number(third.split(':')[3])).toBeGreaterThanOrEqual(20);
expect(Number(third.split(':')[3])).toBeLessThanOrEqual(22);
});
it('single auto-reload fires exactly once when secondsToStart hits zero', () => {
+11 -11
View File
@@ -90,18 +90,18 @@ describe('sortColumnsByAbbrev', () => {
});
describe('formatDdHhMm', () => {
it('formats DD:HH:mm', () => {
expect(formatDdHhMm(0)).toBe('00:00:00');
expect(formatDdHhMm(59)).toBe('00:00:00');
expect(formatDdHhMm(60)).toBe('00:00:01');
expect(formatDdHhMm(3600)).toBe('00:01:00');
expect(formatDdHhMm(86400)).toBe('01:00:00');
expect(formatDdHhMm(90061)).toBe('01:01:01');
it('formats DD:HH:mm:ss', () => {
expect(formatDdHhMm(0)).toBe('00:00:00:00');
expect(formatDdHhMm(59)).toBe('00:00:00:59');
expect(formatDdHhMm(60)).toBe('00:00:01:00');
expect(formatDdHhMm(3600)).toBe('00:01:00:00');
expect(formatDdHhMm(86400)).toBe('01:00:00:00');
expect(formatDdHhMm(90061)).toBe('01:01:01:01');
});
it('returns 00:00:00 for negative or non-finite', () => {
expect(formatDdHhMm(-1)).toBe('00:00:00');
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00');
expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00');
it('returns 00:00:00:00 for negative or non-finite', () => {
expect(formatDdHhMm(-1)).toBe('00:00:00:00');
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00:00');
expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00:00');
});
});
+107
View File
@@ -0,0 +1,107 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injector, runInInjectionContext } from '@angular/core';
import { Observable } from 'rxjs';
import { ChallengesApiService } from '../../frontend/src/app/features/challenges/challenges.service';
import { ChallengeDetail } from '../../frontend/src/app/features/challenges/challenges.pure';
interface CapturedRequest {
url: string;
params: HttpParams;
withCredentials: boolean;
}
function makeDetail(): ChallengeDetail {
return {
challenge: {
id: 'c1',
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,
},
],
};
}
class StubHttpClient {
public captured: CapturedRequest | null = null;
get<T>(url: string, options: { withCredentials?: boolean; params?: HttpParams | Record<string, string> }): Observable<T> {
this.captured = {
url,
params:
options.params instanceof HttpParams
? options.params
: new HttpParams({ fromObject: (options.params ?? {}) as Record<string, string> }),
withCredentials: !!options.withCredentials,
};
return new Observable<T>((subscriber) => {
subscriber.next(makeDetail() as unknown as T);
subscriber.complete();
});
}
post<T>(_url: string, _body: unknown, _options: unknown): Observable<T> {
return new Observable<T>();
}
}
describe('ChallengesApiService.getDetail', () => {
let http: StubHttpClient;
let service: ChallengesApiService;
beforeEach(() => {
http = new StubHttpClient();
const injector = Injector.create({
providers: [
{ provide: HttpClient, useValue: http },
ChallengesApiService,
],
});
service = runInInjectionContext(injector, () => injector.get(ChallengesApiService));
});
it('requests the challenge detail with include=solvers and withCredentials', (done) => {
service.getDetail('c1').subscribe(() => {
try {
expect(http.captured).not.toBeNull();
expect(http.captured!.url).toBe('/api/v1/challenges/c1');
expect(http.captured!.params.get('include')).toBe('solvers');
expect(http.captured!.withCredentials).toBe(true);
done();
} catch (err) {
done(err);
}
});
});
it('encodes the challenge id in the URL path', (done) => {
service.getDetail('c/with spaces').subscribe(() => {
try {
expect(http.captured!.url).toBe('/api/v1/challenges/c%2Fwith%20spaces');
expect(http.captured!.params.get('include')).toBe('solvers');
done();
} catch (err) {
done(err);
}
});
});
});
+14 -14
View File
@@ -5,21 +5,21 @@ import {
} from '../../frontend/src/app/core/services/event-status.pure';
describe('formatDdHhMm', () => {
it('formats 90_061 seconds as 01:01:01', () => {
expect(formatDdHhMm(90_061)).toBe('01:01:01');
it('formats 90_061 seconds as 01:01:01:01', () => {
expect(formatDdHhMm(90_061)).toBe('01:01:01:01');
});
it('formats 800 seconds as 00:00:13', () => {
expect(formatDdHhMm(800)).toBe('00:00:13');
it('formats 800 seconds as 00:00:13:20', () => {
expect(formatDdHhMm(800)).toBe('00:00:13:20');
});
it('formats 0 seconds as 00:00:00', () => {
expect(formatDdHhMm(0)).toBe('00:00:00');
it('formats 0 seconds as 00:00:00:00', () => {
expect(formatDdHhMm(0)).toBe('00:00:00:00');
});
it('returns 00:00:00 for negative or non-finite values', () => {
expect(formatDdHhMm(-5)).toBe('00:00:00');
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00');
it('returns 00:00:00:00 for negative or non-finite values', () => {
expect(formatDdHhMm(-5)).toBe('00:00:00:00');
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00:00');
});
});
@@ -30,17 +30,17 @@ describe('deriveCountdownText', () => {
if (state === 'stopped' || state === 'unconfigured') {
expect(deriveCountdownText(state, null, null)).toBe(state === 'unconfigured' ? '' : 'Event ended');
} else {
expect(deriveCountdownText(state, null, null)).toBe('00:00:00');
expect(deriveCountdownText(state, null, null)).toBe('00:00:00:00');
}
});
});
it('returns DD:HH:mm to start when countdown and secondsToStart provided', () => {
expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00');
it('returns DD:HH:mm:ss to start when countdown and secondsToStart provided', () => {
expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00:00');
});
it('returns DD:HH:mm to end when running and secondsToEnd provided', () => {
expect(deriveCountdownText('running', null, 800)).toBe('00:00:13');
it('returns DD:HH:mm:ss to end when running and secondsToEnd provided', () => {
expect(deriveCountdownText('running', null, 800)).toBe('00:00:13:20');
});
it('returns "Event ended" when stopped', () => {