diff --git a/.kilo/plans/865-matrix-solved-sentinel.md b/.kilo/plans/865-matrix-solved-sentinel.md new file mode 100644 index 0000000..1439495 --- /dev/null +++ b/.kilo/plans/865-matrix-solved-sentinel.md @@ -0,0 +1,162 @@ +# Plan: Scoreboard Matrix — Distinguish "solved (not top-3)" from "not solved" + +## Goal +In the Matrix view, a cell must render a green checkmark for *any* solve +(positions 1, 2, 3, **and** 4+) and stay empty only when the player +genuinely has not solved the challenge. Today the backend stores +positions > 3 as `null` (skipped at +`backend/src/modules/challenges/scoreboard.service.ts:87`), so the +frontend can't tell "solved but not top-3" apart from "never solved". +We introduce a `'solved'` sentinel so the frontend can always render +✓ for a real solve. + +## 1. Type changes + +### 1.1 Backend +`backend/src/modules/challenges/dto/scoreboard.dto.ts:21`: +```ts +export type MatrixCellRank = 1 | 2 | 3 | null; +``` +→ +```ts +export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null; +``` + +### 1.2 Frontend +`frontend/src/app/features/scoreboard/scoreboard.pure.ts:27`: +```ts +export type MatrixCellRank = 1 | 2 | 3 | null; +``` +→ identical widening to `1 | 2 | 3 | 'solved' | null`. + +Both types must stay in lock-step so the controller JSON, the store, +and the matrix component all agree on the new sentinel. + +## 2. Backend projection + +`backend/src/modules/challenges/scoreboard.service.ts:83-96` — the +per-challenge solver walk currently does: +```ts +for (let i = 0; i < list.length; i += 1) { + const position = i + 1; + if (position > 3) continue; // <- drops positions > 3 + ... + perPlayer.set(challengeId, position as MatrixCellRank); +} +``` +Replace the `if (position > 3) continue;` with: +```ts +const value: MatrixCellRank = position <= 3 ? (position as 1|2|3) : 'solved'; +perPlayer.set(challengeId, value); +``` +Everything else in this loop (player-row creation, map writes) stays +unchanged. The cells map therefore now contains a row for every +solver of every challenge, with `1|2|3|'solved'|null`. + +## 3. Frontend live mutation + +`frontend/src/app/features/scoreboard/scoreboard.pure.ts:246-248` +(`mutateMatrixFromSolve`) — currently only writes for positions 1-3: +```ts +if (payload.position >= 1 && payload.position <= 3) { + cellRow[payload.challengeId] = payload.position as MatrixCellRank; +} +``` +Replace with: +```ts +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'; +} +``` +(no else branch — for `position <= 0` we leave the cell alone, same +as today.) With this, a 4th-or-later live solve correctly flips a +blank cell to the green ✓. + +## 4. Frontend rendering + +`frontend/src/app/features/scoreboard/matrix.component.ts:86-95` — +currently the template uses `*ngSwitchDefault` for non-top-3 cells +and re-derives "is this player solved" via a helper method. Now the +sentinel tells us directly: +```html + + + + + + + +``` +Changes vs today: +- Add an explicit `*ngSwitchCase="'solved'"` branch that renders ✓ + with `class="cell-check"` (the green color already defined at + `matrix.component.ts:32`). +- Remove the `*ngSwitchDefault` body (which used `cellSolved()` and a + nested ``). With the sentinel there is no + ambiguity — `null` means empty, `'solved'` means checkmark. +- The `cellSolved(playerId, challengeId)` helper method on + `MatrixComponent` becomes dead code (its only caller is the + template branch we removed) → delete it. + +## 5. Test updates + +### 5.1 `tests/backend/scoreboard.service.spec.ts` +Update the matrix assertion at the "marks top-3 positions" case — +currently: +```ts +expect(matrix.cells['u-d']?.['c1']).toBeNull(); +``` +Change to: +```ts +expect(matrix.cells['u-d']?.['c1']).toBe('solved'); +``` +(u-d is the 4th solver of c1 in that test, so this validates the +new sentinel.) u-b and u-c stay as `2` and `3` respectively. The +matrix cell-shape test for the controller +(`tests/backend/scoreboard-controller.spec.ts`) does not assert on +the >3 sentinel and needs no change. + +### 5.2 `tests/frontend/scoreboard.pure.spec.ts` +Update `mutateMatrixFromSolve` test: +```ts +const next = mutateMatrixFromSolve(matrix, { + ... + position: 5, +}); +expect(next!.cells.p2.c1).toBe('solved'); +``` +(Was `position: 2` expecting `2`; now uses `position: 5` to prove the +new branch fires.) + +## 6. Files touched + +- `backend/src/modules/challenges/dto/scoreboard.dto.ts` (type only) +- `backend/src/modules/challenges/scoreboard.service.ts` (one block, ~3 lines) +- `frontend/src/app/features/scoreboard/scoreboard.pure.ts` (type + `mutateMatrixFromSolve`) +- `frontend/src/app/features/scoreboard/matrix.component.ts` (template + remove `cellSolved` helper) +- `tests/backend/scoreboard.service.spec.ts` (one assertion) +- `tests/frontend/scoreboard.pure.spec.ts` (one assertion) + +## 7. Implementation order + +1. Widen `MatrixCellRank` in backend DTO and frontend pure types. +2. Update backend `getMatrix` to store `'solved'` for positions > 3. +3. Update `mutateMatrixFromSolve` to write `'solved'` for live 4+ solves. +4. Update `matrix.component.ts` template (add `'solved'` case, drop default helper branch) and delete `cellSolved()`. +5. Update the two test assertions. +6. Run `npm test` (all 590+ tests must pass) and `npm run build`. + +## 8. Compatibility & risk + +- Public API contract: `cells` JSON gains a new string literal value + `'solved'`. Older clients that already only branch on `1|2|3|null` + would render an unknown cell as empty (because of `ngSwitchDefault` + falling back). To keep current behaviour while migrating, the + template change MUST land in the same commit as the backend change + (single-tenant SPA, no external consumers — safe). +- No DB or migration change; the sentinel is purely a projection + change over the existing `solve` table. +- No impact on Ranking, Event Log, or Score Graph — they do not use + `MatrixCellRank`. \ No newline at end of file diff --git a/.kilo/plans/909.md b/.kilo/plans/909.md deleted file mode 100644 index 8319da1..0000000 --- a/.kilo/plans/909.md +++ /dev/null @@ -1,26 +0,0 @@ -# Implementation Plan: Challenges Page and Challenge Solve Modal 1.05 - -## 1. Architectural Reconnaissance -- **Codebase style & conventions:** TypeScript monorepo with a NestJS backend and Angular 17 standalone frontend. The challenges feature uses OnPush presentational components with inline templates/styles, a root-provided signal store, RxJS HTTP services, and authenticated SSE updates. The backend already returns authoritative per-user `solvedByMe` values, and the store already preserves that isolation across load, submit, SSE, logout, and user-switch boundaries. The remaining defect is accessibility semantics: the visible `✓` is nested inside a click-only `
` card and does not reliably appear as an independently exposed solved state in the card's accessibility tree. -- **Data Layer:** SQLite through TypeORM. No schema or migration change is required; `solve` rows and the board API already compute player-specific solve state correctly. -- **Test Framework & Structure:** Root Jest 29 configuration with dedicated suites under `tests/frontend` and `tests/backend`; `npm test` runs all projects and `npm run test:frontend` runs frontend tests. Add one focused, CLI-only source-contract regression test under `tests/frontend` rather than a browser or visual test. The test should verify both solved and unsolved accessibility bindings are derived directly from `card.solvedByMe` and that the solved glyph remains conditionally rendered. -- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, persistent `/data` assets, or `setup.sh` changes are needed. Existing Angular, TypeScript, Jest, ts-jest, and jsdom dependencies are sufficient. - -## 2. Impacted Files -- **To Modify:** - - `frontend/src/app/features/challenges/challenge-card.component.ts` — make each card a semantic keyboard-operable control and expose its player-specific solved status on the card accessibility node while retaining the visible conditional checkmark and solved border. -- **To Create:** - - `tests/frontend/challenge-card-accessibility.spec.ts` — minimal regression coverage for the card's accessible solved/unsolved state and conditional checkmark wiring. - -## 3. Proposed Changes -1. **Database / Schema Migration:** No change. Preserve the existing `solve` uniqueness rules and backend `solvedByMe` calculation; the reported scoring, concurrency, and cross-player API behavior already pass. -2. **Backend Logic & APIs:** No change. Continue consuming `BoardCard.solvedByMe` from `GET /api/v1/challenges/board`, challenge detail, submit responses, and player-matched SSE updates as the sole source of player-specific state. -3. **Frontend UI Integration:** - 1. Update the challenge card root in `ChallengeCardComponent` from a generic click target to a native ` + + `, +}) +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