+
Scoreboard
- Live scoreboard will appear here.
+
+
+ Loading…
+ {{ e }}
+
+
+
+
+
+
+
+
`,
})
-export class ScoreboardPage {}
+export class ScoreboardPage implements OnInit, OnDestroy {
+ readonly store = inject(ScoreboardStore);
+ private readonly auth = inject(AuthService);
+ private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService);
+ private readonly destroyRef = inject(DestroyRef);
+
+ private sseWired = false;
+
+ ngOnInit(): void {
+ void this.store.loadAll();
+ if (!this.sseWired) {
+ this.sseWired = true;
+ this.store.wireSse(
+ () => this.authenticatedEventSource.open('/api/v1/events'),
+ );
+ }
+ }
+
+ ngOnDestroy(): void {
+ this.store.stop();
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/features/scoreboard/scoreboard.pure.ts b/frontend/src/app/features/scoreboard/scoreboard.pure.ts
new file mode 100644
index 0000000..cd110fb
--- /dev/null
+++ b/frontend/src/app/features/scoreboard/scoreboard.pure.ts
@@ -0,0 +1,261 @@
+export type ScoreboardTab = 'ranking' | 'matrix' | 'event-log' | 'graph';
+
+export const SCOREBOARD_TABS: ScoreboardTab[] = ['ranking', 'matrix', 'event-log', 'graph'];
+
+export interface RankingRow {
+ rank: number;
+ playerId: string;
+ playerName: string;
+ solvedCount: number;
+ points: number;
+ colorIndex: number;
+}
+
+export interface MatrixChallengeHeader {
+ id: string;
+ name: string;
+ solveCount: number;
+ categoryAbbreviation: string;
+}
+
+export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null;
+
+export interface MatrixView {
+ players: RankingRow[];
+ challenges: MatrixChallengeHeader[];
+ cells: Record>;
+}
+
+export interface EventLogRow {
+ solveId: string;
+ playerId: string;
+ playerName: string;
+ challengeId: string;
+ challengeName: string;
+ categoryAbbreviation: string;
+ position: number;
+ awardedPoints: number;
+ awardedAtUtc: string;
+}
+
+export interface GraphPoint {
+ tUtc: string;
+ value: number;
+}
+
+export interface GraphSeries {
+ playerId: string;
+ playerName: string;
+ colorIndex: number;
+ points: GraphPoint[];
+}
+
+export interface GraphView {
+ startUtc: string | null;
+ endUtc: string | null;
+ serverNowUtc: string;
+ state: 'running' | 'countdown' | 'stopped' | 'unconfigured';
+ series: GraphSeries[];
+}
+
+export interface SolveLivePayload {
+ challengeId: string;
+ playerId: string;
+ playerName: string;
+ awardedPoints: number;
+ rankBonus: number;
+ awardedAtUtc: string;
+ position: number;
+}
+
+export const PLAYER_COLOR_PALETTE: ReadonlyArray = [
+ '#f59e0b',
+ '#3b82f6',
+ '#10b981',
+ '#ef4444',
+ '#8b5cf6',
+ '#ec4899',
+ '#14b8a6',
+ '#f97316',
+ '#6366f1',
+ '#84cc16',
+];
+
+export function stablePlayerColorIndex(playerId: string): number {
+ if (!playerId) return 0;
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < playerId.length; i += 1) {
+ hash ^= playerId.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ const n = PLAYER_COLOR_PALETTE.length;
+ return ((hash % n) + n) % n;
+}
+
+export function applyRankingSort(rows: readonly RankingRow[]): RankingRow[] {
+ const list = rows.map((r) => ({ ...r }));
+ list.sort((a, b) => {
+ if (a.points !== b.points) return b.points - a.points;
+ return 0;
+ });
+ let displayedRank = 0;
+ let lastPoints = Number.NaN;
+ list.forEach((row, idx) => {
+ if (row.points !== lastPoints) {
+ displayedRank = idx + 1;
+ lastPoints = row.points;
+ }
+ row.rank = displayedRank;
+ });
+ return list;
+}
+
+export function parseSolveEventIntoRanking(
+ rows: readonly RankingRow[],
+ payload: SolveLivePayload,
+): RankingRow[] {
+ const out = rows.map((r) => ({ ...r }));
+ let found = false;
+ for (const r of out) {
+ if (r.playerId === payload.playerId) {
+ r.points += payload.awardedPoints;
+ r.solvedCount += 1;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ out.push({
+ rank: 0,
+ playerId: payload.playerId,
+ playerName: payload.playerName,
+ solvedCount: 1,
+ points: payload.awardedPoints,
+ colorIndex: stablePlayerColorIndex(payload.playerId),
+ });
+ }
+ return applyRankingSort(out);
+}
+
+export function dedupEventLogBySolveId(
+ existing: readonly EventLogRow[],
+ newRows: readonly EventLogRow[],
+): EventLogRow[] {
+ if (newRows.length === 0) return existing.slice();
+ const seen = new Set();
+ const merged: EventLogRow[] = [];
+ for (let i = newRows.length - 1; i >= 0; i -= 1) {
+ const r = newRows[i];
+ const key = r.solveId || `${r.playerId}|${r.challengeId}|${r.awardedAtUtc}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ merged.push(r);
+ }
+ for (const r of existing) {
+ const key = r.solveId || `${r.playerId}|${r.challengeId}|${r.awardedAtUtc}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ merged.push(r);
+ }
+ return merged;
+}
+
+export function applySolveToGraph(
+ existing: GraphView | null,
+ payload: SolveLivePayload,
+ eventStartUtc: string | null,
+ eventEndUtc: string | null,
+ serverNowUtc: string,
+): GraphView {
+ const start = eventStartUtc ?? existing?.startUtc ?? null;
+ const end = eventEndUtc ?? existing?.endUtc ?? null;
+ const state: GraphView['state'] = existing?.state ?? 'running';
+
+ if (!start || !end) {
+ return {
+ startUtc: start,
+ endUtc: end,
+ serverNowUtc,
+ state,
+ series: existing?.series ?? [],
+ };
+ }
+
+ const seriesList = (existing?.series ?? []).map((s) => ({ ...s, points: [...s.points] }));
+ let series = seriesList.find((s) => s.playerId === payload.playerId);
+ if (!series) {
+ series = {
+ playerId: payload.playerId,
+ playerName: payload.playerName,
+ colorIndex: stablePlayerColorIndex(payload.playerId),
+ points: [{ tUtc: start, value: 0 }],
+ };
+ seriesList.push(series);
+ }
+ const head = series.points[0];
+ if (!head || head.tUtc !== start) {
+ series.points = [{ tUtc: start, value: 0 }, ...series.points];
+ }
+ const endIdx = series.points.findIndex((p) => p.tUtc === end);
+ const prevValue = endIdx >= 0
+ ? series.points[endIdx].value
+ : (series.points[series.points.length - 1]?.value ?? 0);
+ const newValue = prevValue + payload.awardedPoints;
+ if (endIdx >= 0) {
+ const body = series.points.slice(1, endIdx);
+ series.points = [
+ { tUtc: start, value: 0 },
+ ...body,
+ { tUtc: payload.awardedAtUtc, value: newValue },
+ { tUtc: end, value: newValue },
+ ];
+ } else {
+ series.points = [
+ ...series.points,
+ { tUtc: payload.awardedAtUtc, value: newValue },
+ ];
+ }
+
+ const finalValue = (s: GraphSeries) => s.points[s.points.length - 1]?.value ?? 0;
+ seriesList.sort((a, b) => finalValue(b) - finalValue(a));
+ const top = seriesList.slice(0, 10);
+
+ return { startUtc: start, endUtc: end, serverNowUtc, state, series: top };
+}
+
+export function mutateMatrixFromSolve(
+ matrix: MatrixView | null,
+ payload: SolveLivePayload,
+): MatrixView | null {
+ if (!matrix) return matrix;
+ const players = matrix.players.map((p) => ({ ...p }));
+ let player = players.find((p) => p.playerId === payload.playerId);
+ if (!player) {
+ player = {
+ rank: 0,
+ playerId: payload.playerId,
+ playerName: payload.playerName,
+ solvedCount: 1,
+ points: payload.awardedPoints,
+ colorIndex: stablePlayerColorIndex(payload.playerId),
+ };
+ players.push(player);
+ }
+ const cells = { ...matrix.cells };
+ const cellRow = { ...(cells[payload.playerId] ?? {}) };
+ if (payload.position >= 1 && payload.position <= 3) {
+ cellRow[payload.challengeId] = payload.position as 1 | 2 | 3;
+ } else if (payload.position >= 4) {
+ cellRow[payload.challengeId] = 'solved';
+ }
+ cells[payload.playerId] = cellRow;
+ return { players, challenges: matrix.challenges, cells };
+}
+
+export function formatSolveDateTime(utc: string | null | undefined): string {
+ if (!utc) return '';
+ const d = new Date(utc);
+ if (Number.isNaN(d.getTime())) return '';
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
\ No newline at end of file
diff --git a/frontend/src/app/features/scoreboard/scoreboard.service.ts b/frontend/src/app/features/scoreboard/scoreboard.service.ts
new file mode 100644
index 0000000..ce757b0
--- /dev/null
+++ b/frontend/src/app/features/scoreboard/scoreboard.service.ts
@@ -0,0 +1,60 @@
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable, catchError, throwError } from 'rxjs';
+import {
+ EventLogRow,
+ GraphView,
+ MatrixView,
+ RankingRow,
+} from './scoreboard.pure';
+
+export interface ApiErrorEnvelope {
+ status: number;
+ code?: string;
+ message?: string;
+ details?: unknown;
+}
+
+function toEnvelope(err: unknown): ApiErrorEnvelope {
+ if (err instanceof HttpErrorResponse) {
+ return {
+ status: err.status,
+ code: err.error?.code,
+ message: err.error?.message,
+ details: err.error?.details,
+ };
+ }
+ return { status: 0, code: 'NETWORK', message: (err as Error)?.message };
+}
+
+@Injectable({ providedIn: 'root' })
+export class ScoreboardApiService {
+ private readonly http = inject(HttpClient);
+
+ getRanking(): Observable {
+ return this.http
+ .get('/api/v1/scoreboard/ranking', { withCredentials: true })
+ .pipe(catchError((err) => throwError(() => toEnvelope(err))));
+ }
+
+ getMatrix(): Observable {
+ return this.http
+ .get('/api/v1/scoreboard/matrix', { withCredentials: true })
+ .pipe(catchError((err) => throwError(() => toEnvelope(err))));
+ }
+
+ getEventLog(limit = 50): Observable {
+ return this.http
+ .get('/api/v1/scoreboard/event-log', {
+ withCredentials: true,
+ params: { limit: String(limit) },
+ })
+ .pipe(catchError((err) => throwError(() => toEnvelope(err))));
+ }
+
+ getGraph(): Observable {
+ return this.http
+ .get('/api/v1/scoreboard/graph', { withCredentials: true })
+ .pipe(catchError((err) => throwError(() => toEnvelope(err))));
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/features/scoreboard/scoreboard.store.ts b/frontend/src/app/features/scoreboard/scoreboard.store.ts
new file mode 100644
index 0000000..ad0bbcf
--- /dev/null
+++ b/frontend/src/app/features/scoreboard/scoreboard.store.ts
@@ -0,0 +1,210 @@
+import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
+import { ScoreboardApiService } from './scoreboard.service';
+import {
+ EventLogRow,
+ GraphView,
+ MatrixView,
+ RankingRow,
+ SCOREBOARD_TABS,
+ ScoreboardTab,
+ SolveLivePayload,
+ applySolveToGraph,
+ dedupEventLogBySolveId,
+ mutateMatrixFromSolve,
+ parseSolveEventIntoRanking,
+} from './scoreboard.pure';
+import { EventSourceLike } from '../../core/services/event-status.pure';
+
+@Injectable({ providedIn: 'root' })
+export class ScoreboardStore {
+ private readonly api: ScoreboardApiService;
+ private readonly destroyRef: DestroyRef;
+
+ private readonly _ranking = signal(null);
+ private readonly _matrix = signal(null);
+ private readonly _eventLog = signal(null);
+ private readonly _graph = signal(null);
+ private readonly _loading = signal(false);
+ private readonly _error = signal(null);
+ private readonly _activeTab = signal('ranking');
+
+ private sse: EventSourceLike | null = null;
+ private reconnectTimer: ReturnType | null = null;
+ private reconnectDelayMs = 1000;
+ private createSource: (() => EventSourceLike) | null = null;
+ private loadAllInFlight: Promise | null = null;
+
+ constructor(api?: ScoreboardApiService, destroyRef?: DestroyRef) {
+ this.api = api ?? inject(ScoreboardApiService);
+ this.destroyRef = destroyRef ?? inject(DestroyRef);
+ this.destroyRef.onDestroy(() => this.stop());
+ }
+
+ readonly ranking = this._ranking.asReadonly();
+ readonly matrix = this._matrix.asReadonly();
+ readonly eventLog = this._eventLog.asReadonly();
+ readonly graph = this._graph.asReadonly();
+ readonly loading = this._loading.asReadonly();
+ readonly error = this._error.asReadonly();
+ readonly activeTab = this._activeTab.asReadonly();
+
+ readonly tabs = computed(() => [...SCOREBOARD_TABS]);
+
+ setActiveTab(tab: ScoreboardTab): void {
+ this._activeTab.set(tab);
+ }
+
+ async loadAll(): Promise {
+ if (this.loadAllInFlight) return this.loadAllInFlight;
+ if (this._ranking() && this._matrix() && this._eventLog() && this._graph()) {
+ return;
+ }
+ this._loading.set(true);
+ this._error.set(null);
+ this.loadAllInFlight = (async () => {
+ try {
+ const ranking = await this.toPromise(() => this.api.getRanking());
+ const matrix = await this.toPromise(() => this.api.getMatrix());
+ const eventLog = await this.toPromise(() => this.api.getEventLog(50));
+ const graph = await this.toPromise(() => this.api.getGraph());
+ this._ranking.set(ranking);
+ this._matrix.set(matrix);
+ this._eventLog.set(eventLog);
+ this._graph.set(graph);
+ } catch (err: any) {
+ this._error.set(err?.message ?? 'Failed to load scoreboard');
+ } finally {
+ this._loading.set(false);
+ this.loadAllInFlight = null;
+ }
+ })();
+ return this.loadAllInFlight;
+ }
+
+ wireSse(createSource: () => EventSourceLike, onUnauthorized?: () => void): void {
+ this.stop();
+ this.createSource = createSource;
+ this.openSource(onUnauthorized);
+ }
+
+ handleSseFrame(ev: MessageEvent | Event): void {
+ const me = ev as MessageEvent;
+ if (!me || me.type !== 'solve') return;
+ try {
+ const raw = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
+ if (!raw) return;
+ const payload: SolveLivePayload = {
+ challengeId: raw?.challenge_id ?? raw?.challengeId ?? '',
+ playerId: raw?.player_id ?? raw?.playerId ?? raw?.userId ?? '',
+ playerName: raw?.player_name ?? raw?.playerName ?? '',
+ awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0),
+ rankBonus: Number(raw?.rank_bonus ?? raw?.rankBonus ?? 0),
+ awardedAtUtc: raw?.awarded_at_utc ?? raw?.awardedAtUtc ?? raw?.solvedAt ?? '',
+ position: Number(raw?.position ?? 0),
+ };
+ this.applySolveEvent(payload);
+ } catch {
+ // ignore malformed frame
+ }
+ }
+
+ applySolveEvent(payload: SolveLivePayload): void {
+ const ranking = this._ranking();
+ if (ranking) {
+ this._ranking.set(parseSolveEventIntoRanking(ranking, payload));
+ }
+ const eventLog = this._eventLog();
+ if (eventLog) {
+ const newRow: EventLogRow = {
+ solveId: '',
+ playerId: payload.playerId,
+ playerName: payload.playerName,
+ challengeId: payload.challengeId,
+ challengeName: '',
+ categoryAbbreviation: '',
+ position: payload.position,
+ awardedPoints: payload.awardedPoints,
+ awardedAtUtc: payload.awardedAtUtc,
+ };
+ this._eventLog.set(dedupEventLogBySolveId(eventLog, [newRow]).slice(0, 200));
+ }
+ const graph = this._graph();
+ if (graph) {
+ const next = applySolveToGraph(graph, payload, graph.startUtc, graph.endUtc, graph.serverNowUtc);
+ this._graph.set(next);
+ }
+ const matrix = this._matrix();
+ if (matrix) {
+ this._matrix.set(mutateMatrixFromSolve(matrix, payload));
+ }
+ }
+
+ reset(): void {
+ this.stop();
+ this._ranking.set(null);
+ this._matrix.set(null);
+ this._eventLog.set(null);
+ this._graph.set(null);
+ this._error.set(null);
+ this._loading.set(false);
+ this._activeTab.set('ranking');
+ }
+
+ stop(): void {
+ if (this.sse) {
+ try {
+ this.sse.close();
+ } catch {
+ // ignore
+ }
+ this.sse = null;
+ }
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ this.createSource = null;
+ this.reconnectDelayMs = 1000;
+ }
+
+ private openSource(onUnauthorized?: () => void): void {
+ if (!this.createSource) return;
+ const src = this.createSource();
+ this.sse = src;
+ src.addEventListener('solve', (ev) => this.handleSseFrame(ev));
+ src.addEventListener('error', () => {
+ if (this.sse === src) {
+ try { src.close(); } catch { /* ignore */ }
+ this.sse = null;
+ this.scheduleReconnect(onUnauthorized);
+ }
+ });
+ if (onUnauthorized) {
+ src.addEventListener('unauthorized', () => {
+ try {
+ onUnauthorized();
+ } catch {
+ // ignore
+ }
+ });
+ }
+ }
+
+ private scheduleReconnect(onUnauthorized?: () => void): void {
+ if (!this.createSource) return;
+ if (this.reconnectTimer) return;
+ const delay = Math.min(this.reconnectDelayMs, 30_000);
+ this.reconnectTimer = setTimeout(() => {
+ this.reconnectTimer = null;
+ if (!this.createSource) return;
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 30_000);
+ this.openSource(onUnauthorized);
+ }, delay);
+ }
+
+ private toPromise(fn: () => any): Promise {
+ return new Promise((resolve, reject) => {
+ fn().subscribe({ next: (v: T) => resolve(v), error: reject });
+ });
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/features/scoreboard/tabs.component.ts b/frontend/src/app/features/scoreboard/tabs.component.ts
new file mode 100644
index 0000000..d11f2c3
--- /dev/null
+++ b/frontend/src/app/features/scoreboard/tabs.component.ts
@@ -0,0 +1,63 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { SCOREBOARD_TABS, ScoreboardTab } from './scoreboard.pure';
+
+interface TabSpec {
+ id: ScoreboardTab;
+ label: string;
+}
+
+const TAB_LABELS: Readonly> = {
+ ranking: 'Ranking',
+ matrix: 'Matrix',
+ 'event-log': 'Event Log',
+ graph: 'Score Graph',
+};
+
+@Component({
+ selector: 'app-scoreboard-tabs',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [CommonModule],
+ styles: [`
+ :host { display: block; }
+ nav {
+ display: flex; gap: 6px;
+ border-bottom: 1px solid var(--color-border, #2a2f3a);
+ padding-bottom: 6px;
+ margin-bottom: 12px;
+ overflow-x: auto;
+ }
+ button {
+ padding: 6px 12px;
+ border-radius: var(--radius-sm, 4px);
+ background: var(--color-surface, #1c1f26);
+ color: inherit;
+ border: 1px solid var(--color-border, #2a2f3a);
+ cursor: pointer;
+ font: inherit;
+ }
+ button.active {
+ background: var(--color-primary, #3b82f6);
+ color: var(--color-primary-contrast, #fff);
+ border-color: var(--color-primary, #3b82f6);
+ }
+ `],
+ template: `
+
+ {{ t.label }}
+
+ `,
+})
+export class ScoreboardTabsComponent {
+ @Input({ required: true }) active!: ScoreboardTab;
+ @Output() select = new EventEmitter();
+
+ readonly tabs: TabSpec[] = SCOREBOARD_TABS.map((id) => ({ id, label: TAB_LABELS[id] }));
+}
\ No newline at end of file
diff --git a/tests/backend/scoreboard-controller.spec.ts b/tests/backend/scoreboard-controller.spec.ts
new file mode 100644
index 0000000..dee7aac
--- /dev/null
+++ b/tests/backend/scoreboard-controller.spec.ts
@@ -0,0 +1,218 @@
+process.env.DATABASE_PATH = ':memory:';
+process.env.THEMES_DIR = './themes';
+process.env.FRONTEND_DIST = './frontend/dist';
+process.env.UPLOAD_DIR = '/tmp/hipctf-scoreboard-controller-test';
+
+import { Test } from '@nestjs/testing';
+import { INestApplication, ValidationPipe } from '@nestjs/common';
+import { HttpAdapterHost } from '@nestjs/core';
+import cookieParser from 'cookie-parser';
+import * as express from 'express';
+import request from 'supertest';
+import { getRepositoryToken } from '@nestjs/typeorm';
+import { v4 as uuid } from 'uuid';
+import * as argon2 from 'argon2';
+import { AppModule } from '../../backend/src/app.module';
+import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
+import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
+import { ConfigService } from '@nestjs/config';
+import { initDb } from './db-helper';
+import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
+import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
+import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
+import { UserEntity } from '../../backend/src/database/entities/user.entity';
+import { SettingsService } from '../../backend/src/modules/settings/settings.module';
+import { SETTINGS_KEYS } from '../../backend/src/config/env.schema';
+
+describe('GET /api/v1/scoreboard/*', () => {
+ let app: INestApplication;
+ let adminToken: string;
+ let solveRepo: any;
+ let userRepo: any;
+ let challengeRepo: any;
+ let categoryRepo: any;
+ let settings: SettingsService;
+ let challengeA: ChallengeEntity;
+ let challengeB: ChallengeEntity;
+ let playerA: string;
+ let playerB: string;
+
+ async function setRunningWindow(): Promise {
+ await settings.set(SETTINGS_KEYS.EVENT_START_UTC, new Date(Date.now() - 3600_000).toISOString());
+ await settings.set(SETTINGS_KEYS.EVENT_END_UTC, new Date(Date.now() + 3600_000).toISOString());
+ }
+
+ beforeAll(async () => {
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
+ app = moduleRef.createNestApplication();
+ app.use(cookieParser());
+ app.use(express.json({ limit: '1mb' }));
+ const csrfMw = new CsrfMiddleware(app.get(ConfigService));
+ app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
+ app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
+ const httpAdapterHost = app.get(HttpAdapterHost);
+ app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
+ await app.init();
+ await initDb(app);
+
+ settings = app.get(SettingsService);
+ solveRepo = app.get(getRepositoryToken(SolveEntity));
+ userRepo = app.get(getRepositoryToken(UserEntity));
+ challengeRepo = app.get(getRepositoryToken(ChallengeEntity));
+ categoryRepo = app.get(getRepositoryToken(CategoryEntity));
+
+ await setRunningWindow();
+
+ const server = app.getHttpServer();
+ await request(server).post('/api/v1/auth/register-first-admin')
+ .send({ username: 'admin', password: 'Sup3rSecret!Pass' })
+ .expect(201);
+ const csrfPrime = await request(server).get('/api/v1/auth/csrf');
+ const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
+ const csrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
+ const login = await request(server).post('/api/v1/auth/login')
+ .set('Cookie', `csrf=${csrf}`)
+ .set('X-CSRF-Token', csrf)
+ .send({ username: 'admin', password: 'Sup3rSecret!Pass' })
+ .expect(201);
+ adminToken = login.body.accessToken;
+
+ const cry = await categoryRepo.findOne({ where: { systemKey: 'CRY' } });
+ if (!cry) throw new Error('expected CRY category');
+
+ challengeA = await challengeRepo.save(challengeRepo.create({
+ id: uuid(),
+ name: 'chal-a',
+ descriptionMd: '',
+ categoryId: cry.id,
+ difficulty: 'LOW',
+ initialPoints: 500,
+ minimumPoints: 100,
+ decaySolves: 2,
+ flag: 'flag{a}',
+ protocol: 'WEB',
+ port: null,
+ ipAddress: '',
+ enabled: true,
+ }));
+ challengeB = await challengeRepo.save(challengeRepo.create({
+ id: uuid(),
+ name: 'chal-b',
+ descriptionMd: '',
+ categoryId: cry.id,
+ difficulty: 'LOW',
+ initialPoints: 200,
+ minimumPoints: 50,
+ decaySolves: 5,
+ flag: 'flag{b}',
+ protocol: 'WEB',
+ port: null,
+ ipAddress: '',
+ enabled: true,
+ }));
+
+ const passwordHash = await argon2.hash('PlayerPass!1Ab', { type: argon2.argon2id });
+ const a = await userRepo.save(userRepo.create({ id: uuid(), username: 'alice', passwordHash, role: 'player', status: 'enabled' }));
+ const b = await userRepo.save(userRepo.create({ id: uuid(), username: 'bob', passwordHash, role: 'player', status: 'enabled' }));
+ const c = await userRepo.save(userRepo.create({ id: uuid(), username: 'carol', passwordHash, role: 'player', status: 'enabled' }));
+ playerA = a.id;
+ playerB = b.id;
+ void c;
+
+ await solveRepo.save(solveRepo.create({
+ id: uuid(),
+ challengeId: challengeA.id,
+ userId: playerA,
+ solvedAt: new Date(Date.now() - 1000).toISOString(),
+ pointsAwarded: 515,
+ basePoints: 500,
+ rankBonus: 15,
+ isFirst: 1,
+ isSecond: 0,
+ isThird: 0,
+ }));
+ await solveRepo.save(solveRepo.create({
+ id: uuid(),
+ challengeId: challengeB.id,
+ userId: playerA,
+ solvedAt: new Date(Date.now() - 500).toISOString(),
+ pointsAwarded: 210,
+ basePoints: 200,
+ rankBonus: 10,
+ isFirst: 1,
+ isSecond: 0,
+ isThird: 0,
+ }));
+ await solveRepo.save(solveRepo.create({
+ id: uuid(),
+ challengeId: challengeA.id,
+ userId: playerB,
+ solvedAt: new Date(Date.now() - 100).toISOString(),
+ pointsAwarded: 110,
+ basePoints: 100,
+ rankBonus: 10,
+ isFirst: 0,
+ isSecond: 1,
+ isThird: 0,
+ }));
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ function authed(): any {
+ const r: any = request(app.getHttpServer());
+ r.set = ((h: string, v: string) => { r.headers = r.headers || {}; r.headers[h] = v; return r; });
+ return r;
+ }
+
+ it('rejects unauthenticated ranking with 401', async () => {
+ await request(app.getHttpServer()).get('/api/v1/scoreboard/ranking').expect(401);
+ });
+
+ it('returns competition-ranked ranking including zero-pt player', async () => {
+ const res = await authed().get('/api/v1/scoreboard/ranking').set('Authorization', `Bearer ${adminToken}`).expect(200);
+ expect(Array.isArray(res.body)).toBe(true);
+ expect(res.body.length).toBe(4);
+ expect(res.body[0]).toMatchObject({ rank: 1, playerName: 'alice' });
+ expect(res.body[0].points).toBe(725);
+ expect(res.body[1].rank).toBe(2);
+ const zeroPointRows = res.body.filter((r: any) => r.points === 0);
+ expect(zeroPointRows.length).toBe(2);
+ expect(zeroPointRows[0].rank).toBe(3);
+ });
+
+ it('returns matrix with players and challenges', async () => {
+ const res = await authed().get('/api/v1/scoreboard/matrix').set('Authorization', `Bearer ${adminToken}`).expect(200);
+ expect(res.body.players.length).toBe(4);
+ expect(res.body.challenges.length).toBeGreaterThanOrEqual(2);
+ const challengeIds = res.body.challenges.map((c: any) => c.id);
+ expect(challengeIds).toContain(challengeA.id);
+ expect(challengeIds).toContain(challengeB.id);
+ const aliceCells = res.body.cells[playerA];
+ expect(aliceCells[challengeA.id]).toBe(1);
+ expect(aliceCells[challengeB.id]).toBe(1);
+ const bobCells = res.body.cells[playerB];
+ expect(bobCells[challengeA.id]).toBe(2);
+ expect(bobCells[challengeB.id]).toBeNull();
+ });
+
+ it('returns event-log in DESC order with limit', async () => {
+ const res = await authed().get('/api/v1/scoreboard/event-log?limit=2').set('Authorization', `Bearer ${adminToken}`).expect(200);
+ expect(res.body.length).toBe(2);
+ const ts = res.body.map((r: any) => r.awardedAtUtc);
+ expect(ts[0] >= ts[1]).toBe(true);
+ });
+
+ it('returns graph with top-10 series and start/end anchors', async () => {
+ const res = await authed().get('/api/v1/scoreboard/graph').set('Authorization', `Bearer ${adminToken}`).expect(200);
+ expect(res.body.state).toBe('running');
+ expect(res.body.startUtc).toBeTruthy();
+ expect(res.body.endUtc).toBeTruthy();
+ expect(res.body.series.length).toBeGreaterThan(0);
+ const top = res.body.series[0];
+ expect(top.points[0]).toMatchObject({ value: 0 });
+ expect(top.points[top.points.length - 1].tUtc).toBe(res.body.endUtc);
+ });
+});
\ No newline at end of file
diff --git a/tests/backend/scoreboard.service.spec.ts b/tests/backend/scoreboard.service.spec.ts
new file mode 100644
index 0000000..c8942a0
--- /dev/null
+++ b/tests/backend/scoreboard.service.spec.ts
@@ -0,0 +1,237 @@
+import { ScoreboardService } from '../../backend/src/modules/challenges/scoreboard.service';
+import {
+ computeAwardedPoints,
+ PLAYER_COLOR_PALETTE,
+ stablePlayerColorIndex,
+} from '../../backend/src/modules/challenges/scoring.util';
+
+function buildMockManager(opts: {
+ solves?: any[];
+ challenges?: any[];
+ users?: any[];
+}): any {
+ const solves = opts.solves ?? [];
+ const challenges = opts.challenges ?? [];
+ const users = opts.users ?? [];
+
+ function solveRepo() {
+ return {
+ createQueryBuilder: () => {
+ const b: any = {};
+ b.leftJoin = jest.fn().mockReturnThis();
+ b.select = jest.fn().mockReturnThis();
+ b.addSelect = jest.fn().mockReturnThis();
+ b.orderBy = jest.fn().mockReturnThis();
+ b.addOrderBy = jest.fn().mockReturnThis();
+ b.limit = jest.fn().mockReturnThis();
+ b.getRawMany = jest.fn().mockImplementation(async () => solves);
+ return b;
+ },
+ };
+ }
+ function userRepo() {
+ return {
+ createQueryBuilder: () => {
+ const b: any = {};
+ b.select = jest.fn().mockReturnThis();
+ b.addSelect = jest.fn().mockReturnThis();
+ b.getRawMany = jest.fn().mockImplementation(async () => users);
+ return b;
+ },
+ };
+ }
+ function challengeRepo() {
+ return {
+ createQueryBuilder: () => {
+ const b: any = {};
+ b.leftJoin = jest.fn().mockReturnThis();
+ b.select = jest.fn().mockReturnThis();
+ b.addSelect = jest.fn().mockReturnThis();
+ b.where = jest.fn().mockReturnThis();
+ b.getRawMany = jest.fn().mockImplementation(async () => challenges);
+ return b;
+ },
+ };
+ }
+
+ return {
+ getRepository: (entity: any) => {
+ const name = entity?.name ?? '';
+ if (name === 'SolveEntity') return solveRepo();
+ if (name === 'UserEntity') return userRepo();
+ if (name === 'ChallengeEntity') return challengeRepo();
+ return { createQueryBuilder: () => ({ getRawMany: async () => [] }) };
+ },
+ };
+}
+
+const fakeSettings: any = { get: jest.fn().mockResolvedValue('') };
+const fakeEventStatus: any = {
+ getState: jest.fn().mockResolvedValue({
+ state: 'unconfigured',
+ serverNowUtc: '2025-01-01T00:00:00.000Z',
+ eventStartUtc: null,
+ eventEndUtc: null,
+ secondsToStart: null,
+ secondsToEnd: null,
+ }),
+};
+
+describe('scoring.util additions', () => {
+ it('computeAwardedPoints adds rank bonus for 1st/2nd/3rd only', () => {
+ expect(computeAwardedPoints(500, 100, 10, 0)).toBe(500 + 15);
+ expect(computeAwardedPoints(500, 100, 10, 1)).toBe(460 + 10);
+ expect(computeAwardedPoints(500, 100, 10, 2)).toBe(420 + 5);
+ expect(computeAwardedPoints(500, 100, 10, 3)).toBe(380);
+ expect(computeAwardedPoints(500, 100, 10, 9)).toBe(140);
+ });
+
+ it('stablePlayerColorIndex is deterministic and in range', () => {
+ const idx = stablePlayerColorIndex('player-abc');
+ expect(idx).toBeGreaterThanOrEqual(0);
+ expect(idx).toBeLessThan(PLAYER_COLOR_PALETTE.length);
+ expect(stablePlayerColorIndex('player-abc')).toBe(idx);
+ expect(stablePlayerColorIndex('player-xyz')).not.toBe(idx);
+ });
+});
+
+describe('ScoreboardService.getRanking', () => {
+ it('returns competition ranks with zero-pt players sorted to the bottom', async () => {
+ const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
+ const mgr = buildMockManager({
+ solves: [
+ { id: 's1', challengeId: 'c1', userId: 'u-alice', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 100, username: 'alice' },
+ { id: 's2', challengeId: 'c2', userId: 'u-alice', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 50, username: 'alice' },
+ { id: 's3', challengeId: 'c1', userId: 'u-bob', solvedAt: '2025-01-01T00:02:00.000Z', pointsAwarded: 80, username: 'bob' },
+ ],
+ users: [
+ { id: 'u-alice', username: 'alice' },
+ { id: 'u-bob', username: 'bob' },
+ { id: 'u-carol', username: 'carol' },
+ ],
+ });
+ const ranking = await svc.getRanking(mgr);
+ expect(ranking).toHaveLength(3);
+ expect(ranking[0]).toMatchObject({ rank: 1, playerId: 'u-alice', points: 150, solvedCount: 2 });
+ expect(ranking[1]).toMatchObject({ rank: 2, playerId: 'u-bob', points: 80, solvedCount: 1 });
+ expect(ranking[2]).toMatchObject({ rank: 3, playerId: 'u-carol', points: 0, solvedCount: 0 });
+ for (const r of ranking) {
+ expect(r.colorIndex).toBeGreaterThanOrEqual(0);
+ expect(r.colorIndex).toBeLessThan(PLAYER_COLOR_PALETTE.length);
+ }
+ });
+
+ it('uses earliest solve as tiebreaker', async () => {
+ const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
+ const mgr = buildMockManager({
+ solves: [
+ { id: 's1', challengeId: 'c1', userId: 'u-bob', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 100, username: 'bob' },
+ { id: 's2', challengeId: 'c1', userId: 'u-alice', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 100, username: 'alice' },
+ ],
+ users: [
+ { id: 'u-alice', username: 'alice' },
+ { id: 'u-bob', username: 'bob' },
+ ],
+ });
+ const ranking = await svc.getRanking(mgr);
+ expect(ranking[0].playerId).toBe('u-alice');
+ expect(ranking[1].playerId).toBe('u-bob');
+ });
+});
+
+describe('ScoreboardService.getMatrix', () => {
+ it('marks top-3 positions and sorts challenges by solve count DESC then name', async () => {
+ const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
+ const mgr = buildMockManager({
+ solves: [
+ { id: 's1', challengeId: 'c1', userId: 'u-a', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 50, username: 'a' },
+ { id: 's2', challengeId: 'c1', userId: 'u-b', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 50, username: 'b' },
+ { id: 's3', challengeId: 'c1', userId: 'u-c', solvedAt: '2025-01-01T00:02:00.000Z', pointsAwarded: 50, username: 'c' },
+ { id: 's4', challengeId: 'c1', userId: 'u-d', solvedAt: '2025-01-01T00:03:00.000Z', pointsAwarded: 50, username: 'd' },
+ { id: 's5', challengeId: 'c2', userId: 'u-a', solvedAt: '2025-01-01T00:04:00.000Z', pointsAwarded: 50, username: 'a' },
+ ],
+ challenges: [
+ { id: 'c1', name: 'alpha', initialPoints: 500, minimumPoints: 100, decaySolves: 10, categoryAbbreviation: 'CRY' },
+ { id: 'c2', name: 'beta', initialPoints: 300, minimumPoints: 50, decaySolves: 5, categoryAbbreviation: 'WEB' },
+ ],
+ users: [
+ { id: 'u-a', username: 'a' },
+ { id: 'u-b', username: 'b' },
+ { id: 'u-c', username: 'c' },
+ { id: 'u-d', username: 'd' },
+ ],
+ });
+ const matrix = await svc.getMatrix(mgr);
+ expect(matrix.challenges[0].id).toBe('c1');
+ expect(matrix.challenges[1].id).toBe('c2');
+ expect(matrix.cells['u-a']?.['c1']).toBe(1);
+ expect(matrix.cells['u-b']?.['c1']).toBe(2);
+ expect(matrix.cells['u-c']?.['c1']).toBe(3);
+ expect(matrix.cells['u-d']?.['c1']).toBe('solved');
+ expect(matrix.cells['u-a']?.['c2']).toBe(1);
+ expect(matrix.cells['u-b']?.['c2']).toBeNull();
+ });
+});
+
+describe('ScoreboardService.getGraph', () => {
+ it('prepends start point and appends end point, limits to top 10', async () => {
+ const settings: any = {
+ get: jest.fn().mockImplementation(async (k: string) => {
+ if (k === 'eventStartUtc') return '2025-01-01T00:00:00.000Z';
+ if (k === 'eventEndUtc') return '2025-01-02T00:00:00.000Z';
+ return '';
+ }),
+ };
+ const eventStatus: any = {
+ getState: jest.fn().mockResolvedValue({
+ state: 'running',
+ serverNowUtc: '2025-01-01T12:00:00.000Z',
+ eventStartUtc: '2025-01-01T00:00:00.000Z',
+ eventEndUtc: '2025-01-02T00:00:00.000Z',
+ secondsToStart: null,
+ secondsToEnd: 43200,
+ }),
+ };
+ const svc = new ScoreboardService(settings, eventStatus);
+ const mgr = buildMockManager({
+ solves: [
+ { id: 's1', challengeId: 'c1', userId: 'u-a', solvedAt: '2025-01-01T01:00:00.000Z', pointsAwarded: 115, username: 'a' },
+ { id: 's2', challengeId: 'c2', userId: 'u-a', solvedAt: '2025-01-01T02:00:00.000Z', pointsAwarded: 60, username: 'a' },
+ ],
+ challenges: [
+ { id: 'c1', name: 'alpha', initialPoints: 500, minimumPoints: 100, decaySolves: 10, categoryAbbreviation: 'CRY' },
+ { id: 'c2', name: 'beta', initialPoints: 300, minimumPoints: 50, decaySolves: 5, categoryAbbreviation: 'WEB' },
+ ],
+ });
+ const graph = await svc.getGraph(mgr);
+ expect(graph.series).toHaveLength(1);
+ const s = graph.series[0];
+ expect(s.playerId).toBe('u-a');
+ expect(s.points[0]).toEqual({ tUtc: '2025-01-01T00:00:00.000Z', value: 0 });
+ expect(s.points[s.points.length - 1]).toEqual({ tUtc: '2025-01-02T00:00:00.000Z', value: 515 + 315 });
+ });
+
+ it('returns empty series when event not configured', async () => {
+ const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
+ const graph = await svc.getGraph(buildMockManager({}));
+ expect(graph.series).toEqual([]);
+ expect(graph.startUtc).toBeNull();
+ expect(graph.endUtc).toBeNull();
+ });
+});
+
+describe('ScoreboardService.getEventLog', () => {
+ it('returns rows with computed position from same-challenge order', async () => {
+ const svc = new ScoreboardService(fakeSettings, fakeEventStatus);
+ const solves = [
+ { id: 's1', challengeId: 'c1', userId: 'u-a', solvedAt: '2025-01-01T00:00:00.000Z', pointsAwarded: 115, basePoints: 100, rankBonus: 15, username: 'a', challengeName: 'alpha', categoryAbbreviation: 'CRY' },
+ { id: 's2', challengeId: 'c1', userId: 'u-b', solvedAt: '2025-01-01T00:01:00.000Z', pointsAwarded: 110, basePoints: 100, rankBonus: 10, username: 'b', challengeName: 'alpha', categoryAbbreviation: 'CRY' },
+ ];
+ const mgr = buildMockManager({ solves });
+ const log = await svc.getEventLog(mgr, 10);
+ expect(log).toHaveLength(2);
+ const bySolveId = new Map(log.map((r: any) => [r.solveId, r]));
+ expect((bySolveId.get('s1') as any).position).toBe(1);
+ expect((bySolveId.get('s2') as any).position).toBe(2);
+ });
+});
\ No newline at end of file
diff --git a/tests/frontend/scoreboard.pure.spec.ts b/tests/frontend/scoreboard.pure.spec.ts
new file mode 100644
index 0000000..73b6ec6
--- /dev/null
+++ b/tests/frontend/scoreboard.pure.spec.ts
@@ -0,0 +1,142 @@
+jest.mock('@angular/common/http', () => ({
+ HttpClient: class {},
+ HttpErrorResponse: class {},
+}));
+
+import {
+ PLAYER_COLOR_PALETTE,
+ RankingRow,
+ applyRankingSort,
+ applySolveToGraph,
+ dedupEventLogBySolveId,
+ formatSolveDateTime,
+ mutateMatrixFromSolve,
+ parseSolveEventIntoRanking,
+ stablePlayerColorIndex,
+} from '../../frontend/src/app/features/scoreboard/scoreboard.pure';
+
+describe('stablePlayerColorIndex', () => {
+ it('is deterministic and in range', () => {
+ const a = stablePlayerColorIndex('player-abc');
+ expect(a).toBeGreaterThanOrEqual(0);
+ expect(a).toBeLessThan(PLAYER_COLOR_PALETTE.length);
+ expect(stablePlayerColorIndex('player-abc')).toBe(a);
+ expect(stablePlayerColorIndex('player-xyz')).not.toBe(a);
+ });
+});
+
+describe('applyRankingSort', () => {
+ it('produces competition ranks with zero-pt rows at the bottom', () => {
+ const rows: RankingRow[] = [
+ { rank: 0, playerId: 'p1', playerName: 'p1', solvedCount: 2, points: 0, colorIndex: 0 },
+ { rank: 0, playerId: 'p2', playerName: 'p2', solvedCount: 1, points: 100, colorIndex: 1 },
+ { rank: 0, playerId: 'p3', playerName: 'p3', solvedCount: 1, points: 50, colorIndex: 2 },
+ { rank: 0, playerId: 'p4', playerName: 'p4', solvedCount: 0, points: 0, colorIndex: 3 },
+ ];
+ const sorted = applyRankingSort(rows);
+ expect(sorted.map((r) => r.playerId)).toEqual(['p2', 'p3', 'p1', 'p4']);
+ expect(sorted.map((r) => r.rank)).toEqual([1, 2, 3, 3]);
+ });
+});
+
+describe('parseSolveEventIntoRanking', () => {
+ it('adds a new player and re-sorts', () => {
+ const rows: RankingRow[] = [
+ { rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 2, points: 100, colorIndex: 0 },
+ ];
+ const next = parseSolveEventIntoRanking(rows, {
+ challengeId: 'c1',
+ playerId: 'p2',
+ playerName: 'p2',
+ awardedPoints: 50,
+ rankBonus: 0,
+ awardedAtUtc: '2025-01-01T00:00:00.000Z',
+ position: 4,
+ });
+ expect(next.length).toBe(2);
+ const p2 = next.find((r) => r.playerId === 'p2')!;
+ expect(p2.points).toBe(50);
+ expect(p2.solvedCount).toBe(1);
+ expect(next[0].playerId).toBe('p1');
+ });
+});
+
+describe('dedupEventLogBySolveId', () => {
+ it('prepends new rows and removes duplicates', () => {
+ const existing = [
+ { solveId: 's1', playerId: 'p1', playerName: 'p1', challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', position: 1, awardedPoints: 100, awardedAtUtc: '2025-01-01T00:00:00.000Z' },
+ ];
+ const newRows = [
+ { solveId: 's1', playerId: 'p1', playerName: 'p1', challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', position: 1, awardedPoints: 100, awardedAtUtc: '2025-01-01T00:00:00.000Z' },
+ { solveId: 's2', playerId: 'p2', playerName: 'p2', challengeId: 'c2', challengeName: 'beta', categoryAbbreviation: 'WEB', position: 1, awardedPoints: 50, awardedAtUtc: '2025-01-01T00:01:00.000Z' },
+ ];
+ const out = dedupEventLogBySolveId(existing, newRows);
+ expect(out.length).toBe(2);
+ expect(out[0].solveId).toBe('s2');
+ expect(out[1].solveId).toBe('s1');
+ });
+});
+
+describe('applySolveToGraph', () => {
+ it('appends a new point to the matching series and re-applies the top-10 cutoff', () => {
+ const baseView = {
+ startUtc: '2025-01-01T00:00:00.000Z',
+ endUtc: '2025-01-02T00:00:00.000Z',
+ serverNowUtc: '2025-01-01T12:00:00.000Z',
+ state: 'running' as const,
+ series: [
+ { playerId: 'p0', playerName: 'p0', colorIndex: 0, points: [{ tUtc: '2025-01-01T00:00:00.000Z', value: 0 }, { tUtc: '2025-01-02T00:00:00.000Z', value: 50 }] },
+ ],
+ };
+ const next = applySolveToGraph(
+ baseView,
+ { challengeId: 'c1', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 2 },
+ '2025-01-01T00:00:00.000Z',
+ '2025-01-02T00:00:00.000Z',
+ '2025-01-01T12:00:00.000Z',
+ );
+ expect(next.series[0].points.length).toBe(3);
+ expect(next.series[0].points[0]).toEqual({ tUtc: '2025-01-01T00:00:00.000Z', value: 0 });
+ expect(next.series[0].points[1].tUtc).toBe('2025-01-01T06:00:00.000Z');
+ expect(next.series[0].points[1].value).toBe(80);
+ expect(next.series[0].points[2]).toEqual({ tUtc: '2025-01-02T00:00:00.000Z', value: 80 });
+ });
+
+ it('returns empty when event is not configured', () => {
+ const next = applySolveToGraph(
+ null,
+ { challengeId: 'c1', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 1 },
+ null,
+ null,
+ '2025-01-01T12:00:00.000Z',
+ );
+ expect(next.series).toEqual([]);
+ });
+});
+
+describe('mutateMatrixFromSolve', () => {
+ it('stores the rank when within top-3', () => {
+ const matrix = {
+ players: [{ rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 100, colorIndex: 0 }],
+ challenges: [{ id: 'c1', name: 'alpha', solveCount: 1, categoryAbbreviation: 'CRY' }],
+ cells: { p1: { c1: 1 as 1 } },
+ };
+ const next = mutateMatrixFromSolve(matrix, {
+ challengeId: 'c1',
+ playerId: 'p2',
+ playerName: 'p2',
+ awardedPoints: 50,
+ rankBonus: 0,
+ awardedAtUtc: '2025-01-01T00:00:00.000Z',
+ position: 5,
+ });
+ expect(next!.cells.p2.c1).toBe('solved');
+ });
+});
+
+describe('formatSolveDateTime', () => {
+ it('returns empty string for invalid input', () => {
+ expect(formatSolveDateTime(null)).toBe('');
+ expect(formatSolveDateTime('not a date')).toBe('');
+ });
+});
\ No newline at end of file
diff --git a/tests/frontend/scoreboard.store.spec.ts b/tests/frontend/scoreboard.store.spec.ts
new file mode 100644
index 0000000..7ab125b
--- /dev/null
+++ b/tests/frontend/scoreboard.store.spec.ts
@@ -0,0 +1,147 @@
+jest.mock('@angular/common/http', () => ({
+ HttpClient: class {},
+ HttpErrorResponse: class {},
+}));
+
+import { ScoreboardStore } from '../../frontend/src/app/features/scoreboard/scoreboard.store';
+import {
+ EventLogRow,
+ GraphView,
+ MatrixView,
+ RankingRow,
+} from '../../frontend/src/app/features/scoreboard/scoreboard.pure';
+import { Observable, of } from 'rxjs';
+
+class FakeApi {
+ rankingCallCount = 0;
+ matrixCallCount = 0;
+ eventLogCallCount = 0;
+ graphCallCount = 0;
+
+ getRanking(): Observable {
+ this.rankingCallCount += 1;
+ return of([
+ { rank: 1, playerId: 'p1', playerName: 'p1', solvedCount: 1, points: 100, colorIndex: 0 },
+ ]);
+ }
+ getMatrix(): Observable {
+ this.matrixCallCount += 1;
+ return of({ players: [], challenges: [], cells: {} });
+ }
+ getEventLog(): Observable {
+ this.eventLogCallCount += 1;
+ return of([
+ { solveId: 's1', playerId: 'p1', playerName: 'p1', challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', position: 1, awardedPoints: 100, awardedAtUtc: '2025-01-01T00:00:00.000Z' },
+ ]);
+ }
+ getGraph(): Observable {
+ this.graphCallCount += 1;
+ return of({
+ startUtc: '2025-01-01T00:00:00.000Z',
+ endUtc: '2025-01-02T00:00:00.000Z',
+ serverNowUtc: '2025-01-01T00:00:00.000Z',
+ state: 'running',
+ series: [],
+ });
+ }
+}
+
+function makeFakeSse(handler: (type: string, payload: any) => void): any {
+ return {
+ addEventListener(type: string, fn: (ev: any) => void) {
+ handler(type, fn);
+ },
+ close() {},
+ };
+}
+
+describe('ScoreboardStore', () => {
+ it('loadAll fetches all four projections once', async () => {
+ const api = new FakeApi();
+ const destroyRef: any = { onDestroy: () => {} };
+ const store = new ScoreboardStore(api as any, destroyRef);
+ await store.loadAll();
+ expect(api.rankingCallCount).toBe(1);
+ expect(api.matrixCallCount).toBe(1);
+ expect(api.eventLogCallCount).toBe(1);
+ expect(api.graphCallCount).toBe(1);
+ await store.loadAll();
+ expect(api.rankingCallCount).toBe(1);
+ });
+
+ it('applySolveEvent mutates ranking, eventLog, graph and matrix', () => {
+ const api = new FakeApi();
+ const destroyRef: any = { onDestroy: () => {} };
+ const store = new ScoreboardStore(api as any, destroyRef);
+ void store.loadAll();
+ return new Promise((resolve) => {
+ const source = makeFakeSse((type, fn) => {
+ if (type === 'solve') {
+ setTimeout(() => {
+ fn(new MessageEvent('solve', { data: JSON.stringify({
+ challenge_id: 'c2',
+ player_id: 'p1',
+ player_name: 'p1',
+ awarded_points: 25,
+ rank_bonus: 0,
+ awarded_at_utc: '2025-01-01T01:00:00.000Z',
+ position: 4,
+ }) }));
+ setTimeout(() => {
+ expect(store.ranking()?.[0]?.points).toBe(125);
+ expect(store.eventLog()?.[0]?.awardedPoints).toBe(25);
+ expect(store.graph()?.series[0]?.playerId).toBe('p1');
+ resolve();
+ }, 0);
+ }, 0);
+ }
+ });
+ store.wireSse(() => source);
+ });
+ });
+
+ it('does not duplicate event-log rows on replayed solve frame', () => {
+ const api = new FakeApi();
+ const destroyRef: any = { onDestroy: () => {} };
+ const store = new ScoreboardStore(api as any, destroyRef);
+ void store.loadAll();
+ return new Promise((resolve) => {
+ let fire: ((ev: any) => void) | null = null;
+ const source = makeFakeSse((type, fn) => {
+ if (type === 'solve') fire = fn;
+ });
+ store.wireSse(() => source);
+ const payload = {
+ challenge_id: 'c2',
+ player_id: 'p1',
+ player_name: 'p1',
+ awarded_points: 25,
+ rank_bonus: 0,
+ awarded_at_utc: '2025-01-01T01:00:00.000Z',
+ position: 4,
+ };
+ setTimeout(() => {
+ fire!(new MessageEvent('solve', { data: JSON.stringify(payload) }));
+ fire!(new MessageEvent('solve', { data: JSON.stringify(payload) }));
+ setTimeout(() => {
+ const log = store.eventLog() ?? [];
+ const p1c2 = log.filter((r) => r.playerId === 'p1' && r.challengeId === 'c2');
+ expect(p1c2.length).toBe(1);
+ resolve();
+ }, 0);
+ }, 0);
+ });
+ });
+
+ it('reset clears all signals and stops SSE', () => {
+ const api = new FakeApi();
+ const destroyRef: any = { onDestroy: () => {} };
+ const store = new ScoreboardStore(api as any, destroyRef);
+ void store.loadAll();
+ store.reset();
+ expect(store.ranking()).toBeNull();
+ expect(store.matrix()).toBeNull();
+ expect(store.eventLog()).toBeNull();
+ expect(store.graph()).toBeNull();
+ });
+});
\ No newline at end of file