feat: Challenges Page and Challenge Solve Modal

This commit is contained in:
OpenVelo Agent
2026-07-23 00:13:57 +00:00
parent 77d31e0ee1
commit cb88b21462
32 changed files with 4134 additions and 119 deletions
+301
View File
@@ -0,0 +1,301 @@
import {
BoardCard,
CategoryColumn,
SolverRow,
compareDifficulty,
formatDdHhMm,
formatUtcToLocal,
mergeSolveEventIntoSolvers,
messageForSolveError,
parseSolveError,
parseSolveEvent,
parseStatusPayload,
sortCardsInColumn,
sortColumnsByAbbrev,
} from '../../frontend/src/app/features/challenges/challenges.pure';
function makeCard(partial: Partial<BoardCard> = {}): BoardCard {
return {
id: 'c1',
name: 'Sample',
descriptionMd: '',
categoryId: 'cat1',
categoryAbbreviation: 'CRY',
categoryIconPath: '',
difficulty: 'MEDIUM',
livePoints: 100,
solveCount: 0,
solvedByMe: false,
...partial,
};
}
function makeCol(partial: Partial<CategoryColumn> = {}): CategoryColumn {
return {
id: 'cat1',
abbreviation: 'CRY',
name: 'Cryptography',
iconPath: '',
cards: [],
...partial,
};
}
describe('compareDifficulty', () => {
it('returns negative when a is easier', () => {
expect(compareDifficulty('LOW', 'MEDIUM')).toBeLessThan(0);
expect(compareDifficulty('LOW', 'HIGH')).toBeLessThan(0);
expect(compareDifficulty('MEDIUM', 'HIGH')).toBeLessThan(0);
});
it('returns 0 for equal difficulties', () => {
expect(compareDifficulty('LOW', 'LOW')).toBe(0);
});
it('returns positive when a is harder', () => {
expect(compareDifficulty('HIGH', 'LOW')).toBeGreaterThan(0);
});
});
describe('sortCardsInColumn', () => {
it('sorts cards by difficulty ascending then name alphabetically', () => {
const cards = [
makeCard({ id: '1', name: 'Zeta', difficulty: 'HIGH' }),
makeCard({ id: '2', name: 'alpha', difficulty: 'LOW' }),
makeCard({ id: '3', name: 'Beta', difficulty: 'LOW' }),
makeCard({ id: '4', name: 'mid', difficulty: 'MEDIUM' }),
];
const sorted = sortCardsInColumn(cards);
expect(sorted.map((c) => c.id)).toEqual(['2', '3', '4', '1']);
});
it('does not mutate the input array', () => {
const cards = [
makeCard({ id: '1', name: 'b', difficulty: 'LOW' }),
makeCard({ id: '2', name: 'a', difficulty: 'HIGH' }),
];
const before = cards.map((c) => c.id);
sortCardsInColumn(cards);
expect(cards.map((c) => c.id)).toEqual(before);
});
});
describe('sortColumnsByAbbrev', () => {
it('sorts columns alphabetically by abbreviation', () => {
const cols = [
makeCol({ id: '1', abbreviation: 'WEB' }),
makeCol({ id: '2', abbreviation: 'CRY' }),
makeCol({ id: '3', abbreviation: 'pwn' }),
];
const sorted = sortColumnsByAbbrev(cols);
expect(sorted.map((c) => c.id)).toEqual(['2', '3', '1']);
});
});
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('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');
});
});
describe('formatUtcToLocal', () => {
it('returns empty string for null/empty', () => {
expect(formatUtcToLocal(null)).toBe('');
expect(formatUtcToLocal(undefined)).toBe('');
expect(formatUtcToLocal('')).toBe('');
});
it('returns empty string for invalid date', () => {
expect(formatUtcToLocal('not-a-date')).toBe('');
});
it('returns a non-empty string for a valid UTC date', () => {
const s = formatUtcToLocal('2025-01-01T00:00:00.000Z');
expect(typeof s).toBe('string');
expect(s.length).toBeGreaterThan(0);
});
});
describe('parseSolveError + messageForSolveError', () => {
it('maps EVENT_NOT_RUNNING to not_running', () => {
expect(parseSolveError(409, { code: 'EVENT_NOT_RUNNING' })).toBe('not_running');
expect(messageForSolveError('not_running')).toMatch(/running/i);
});
it('maps FLAG_INCORRECT to incorrect', () => {
expect(parseSolveError(400, { code: 'FLAG_INCORRECT' })).toBe('incorrect');
});
it('maps 404 / NOT_FOUND to not_found', () => {
expect(parseSolveError(404, { code: 'NOT_FOUND' })).toBe('not_found');
expect(parseSolveError(404, {})).toBe('not_found');
});
it('maps 401/403 to unauthorized/forbidden', () => {
expect(parseSolveError(401, {})).toBe('unauthorized');
expect(parseSolveError(403, {})).toBe('forbidden');
});
it('maps 0 to network', () => {
expect(parseSolveError(0, null)).toBe('network');
});
it('maps unknown to unknown', () => {
expect(parseSolveError(500, { code: 'BOOM' })).toBe('unknown');
});
});
describe('mergeSolveEventIntoSolvers', () => {
const baseRow: SolverRow = {
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,
};
it('appends a new solver and recomputes positions', () => {
const merged = mergeSolveEventIntoSolvers([baseRow], {
challengeId: 'c1',
playerId: 'p2',
playerName: 'bob',
awardedPoints: 50,
rankBonus: 0,
awardedAtUtc: '2025-01-02T00:00:00.000Z',
position: 2,
});
expect(merged).toHaveLength(2);
expect(merged[0].playerId).toBe('p1');
expect(merged[0].position).toBe(1);
expect(merged[1].playerId).toBe('p2');
expect(merged[1].position).toBe(2);
});
it('dedupes by playerId+solvedAtUtc (no duplicate insert)', () => {
const merged = mergeSolveEventIntoSolvers([baseRow], {
challengeId: 'c1',
playerId: 'p1',
playerName: 'alice',
awardedPoints: 100,
rankBonus: 0,
awardedAtUtc: baseRow.solvedAtUtc,
position: 1,
});
expect(merged).toHaveLength(1);
});
it('sorts by solvedAtUtc ascending regardless of input order', () => {
const merged = mergeSolveEventIntoSolvers([baseRow], {
challengeId: 'c1',
playerId: 'p2',
playerName: 'bob',
awardedPoints: 50,
rankBonus: 0,
awardedAtUtc: '2024-12-31T00:00:00.000Z', // earlier
position: 1,
});
expect(merged[0].playerId).toBe('p2');
expect(merged[1].playerId).toBe('p1');
expect(merged[0].position).toBe(1);
expect(merged[1].position).toBe(2);
});
});
describe('parseStatusPayload', () => {
it('maps snake_case keys to camelCase', () => {
const payload = parseStatusPayload({
state: 'running',
server_time_utc: '2025-01-01T00:00:00.000Z',
event_start_utc: '2025-01-01T00:00:00.000Z',
event_end_utc: '2025-01-02T00:00:00.000Z',
seconds_to_start: 0,
seconds_to_end: 3600,
});
expect(payload).toEqual({
state: 'running',
serverNowUtc: '2025-01-01T00:00:00.000Z',
eventStartUtc: '2025-01-01T00:00:00.000Z',
eventEndUtc: '2025-01-02T00:00:00.000Z',
secondsToStart: 0,
secondsToEnd: 3600,
});
});
it('falls back to camelCase keys for legacy payloads', () => {
const payload = parseStatusPayload({
state: 'countdown',
serverNowUtc: '2025-01-01T00:00:00.000Z',
eventStartUtc: '2025-01-02T00:00:00.000Z',
eventEndUtc: '2025-01-03T00:00:00.000Z',
secondsToStart: 60,
secondsToEnd: null,
});
expect(payload.state).toBe('countdown');
expect(payload.secondsToStart).toBe(60);
});
it('returns unconfigured defaults when payload is empty/null', () => {
expect(parseStatusPayload(null).state).toBe('unconfigured');
expect(parseStatusPayload({}).state).toBe('unconfigured');
expect(parseStatusPayload({}).serverNowUtc).toBeTruthy();
});
});
describe('parseSolveEvent', () => {
it('maps snake_case keys to camelCase', () => {
const p = parseSolveEvent({
challenge_id: 'c1',
player_id: 'p1',
player_name: 'alice',
awarded_points: 100,
rank_bonus: 15,
awarded_at_utc: '2025-01-01T00:00:00.000Z',
position: 1,
live_points: 80,
solve_count: 1,
initial_points: 100,
minimum_points: 50,
decay_solves: 5,
});
expect(p).toEqual({
challengeId: 'c1',
playerId: 'p1',
playerName: 'alice',
awardedPoints: 100,
rankBonus: 15,
awardedAtUtc: '2025-01-01T00:00:00.000Z',
position: 1,
livePoints: 80,
solveCount: 1,
initialPoints: 100,
minimumPoints: 50,
decaySolves: 5,
});
});
it('falls back to camelCase keys for legacy payloads', () => {
const p = parseSolveEvent({
challengeId: 'c1',
playerId: 'p1',
awardedPoints: 100,
rankBonus: 0,
awardedAtUtc: '2025-01-01T00:00:00.000Z',
position: 1,
});
expect(p.challengeId).toBe('c1');
expect(p.playerName).toBe('');
expect(p.livePoints).toBe(0);
});
it('handles null payload with safe defaults', () => {
const p = parseSolveEvent(null);
expect(p.challengeId).toBe('');
expect(p.playerId).toBe('');
expect(p.livePoints).toBe(0);
});
});