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 { 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 { 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); }); });