feat: Challenges Page and Challenge Solve Modal 1.03
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user