From 1576d663d3275ccb0d31df834a0458dba72db827 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Thu, 23 Jul 2026 05:13:20 +0000 Subject: [PATCH 1/2] docs: update documentation to OKF v0.1 format --- docs/api/challenges.md | 172 +++++++++++++++- docs/architecture/frontend-structure.md | 13 +- docs/architecture/key-files.md | 20 +- docs/guides/scoreboard-page.md | 262 ++++++++++++++++++++++++ docs/guides/scoreboard-stream.md | 89 ++++++-- docs/index.md | 8 +- 6 files changed, 536 insertions(+), 28 deletions(-) create mode 100644 docs/guides/scoreboard-page.md diff --git a/docs/api/challenges.md b/docs/api/challenges.md index f904bf8..14ef80d 100644 --- a/docs/api/challenges.md +++ b/docs/api/challenges.md @@ -2,8 +2,8 @@ type: api title: Challenges Endpoints description: Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames. -tags: [api, challenges, board, score, sse, submit] -timestamp: 2026-07-23T00:10:00Z +tags: [api, challenges, board, score, scoreboard, sse, submit] +timestamp: 2026-07-23T05:10:00Z --- # Endpoints @@ -21,6 +21,10 @@ are not `@Public()`). The DTOs and zod schemas live in | `GET` | `/api/v1/challenges/:id` | Authenticated | Same. | | `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. | | `SSE` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` | +| `GET` | `/api/v1/scoreboard/ranking` | Authenticated | `backend/src/modules/challenges/scoreboard.controller.ts` | +| `GET` | `/api/v1/scoreboard/matrix` | Authenticated | Same. | +| `GET` | `/api/v1/scoreboard/event-log` | Authenticated | Same. | +| `GET` | `/api/v1/scoreboard/graph` | Authenticated | Same. | > The combined `/api/v1/events` SSE is **in addition to** the existing > `/api/v1/events/status` status-only stream. The two share the same @@ -202,6 +206,169 @@ downstream consumer can read either shape (for example `challenge_id` or `challengeId`, `player_id` or `userId`, `awarded_points` or `pointsAwarded`). +# Scoreboard endpoints + +The `ScoreboardController` mounts the four authenticated read-only +projections that drive the `/scoreboard` page (Ranking, Matrix, +Event Log, Score Graph tabs). All four live under +`/api/v1/scoreboard/*` and are served by +`backend/src/modules/challenges/scoreboard.controller.ts` via +`ScoreboardService` +(`backend/src/modules/challenges/scoreboard.service.ts`). The DTO +contracts live in +`backend/src/modules/challenges/dto/scoreboard.dto.ts` (zod +`EventLogQuerySchema` plus TypeScript interfaces for the response +shapes). + +All four endpoints: + +* Require a valid JWT (the global `JwtAuthGuard` is active; no + `@Public()` is used on the scoreboard controller). +* Read exclusively — no mutation — and never expose the `challenge.flag` + field or any admin-only data. +* Compute `awardedPoints` per row using + `computeAwardedPoints(initial, minimum, decaySolves, solveCountBefore)` + from `backend/src/modules/challenges/scoring.util.ts`, which is the + same `basePoints + rankBonus` arithmetic that `submitFlag` performs + when persisting the `solve` row. +* Color each player via `stablePlayerColorIndex(playerId)` (FNV-1a + hash → 10-entry `PLAYER_COLOR_PALETTE`) so the Ranking, Matrix, and + Score Graph share a deterministic swatch per `playerId`. + +## `GET /api/v1/scoreboard/ranking` + +Returns every user (including zero-solve players) ranked by total +points, with competition-rank numbering (`1, 2, 2, 4, …` — players with +equal `points` share a rank). + +Response (`RankingRowDto[]`): + +```json +[ + { + "rank": 1, + "playerId": "", + "playerName": "alice", + "solvedCount": 7, + "points": 3120, + "colorIndex": 4 + } +] +``` + +`colorIndex` is the same value the frontend uses to tint the +per-player swatch in the Ranking, Matrix, and Score Graph tabs so a +player keeps their color across all three views. + +## `GET /api/v1/scoreboard/matrix` + +Returns a players × challenges grid for the live scoreboard view. + +Response (`MatrixViewDto`): + +```json +{ + "players": [ /* RankingRowDto[] in ranking order */ ], + "challenges": [ + { + "id": "", + "name": "RSA Rollers", + "solveCount": 4, + "categoryAbbreviation": "CRY" + } + ], + "cells": { + "": { + "": 1, + "": 2, + "": 3, + "": "solved", + "": null + } + } +} +``` + +`cells[playerId][challengeId]` is one of `1` (gold ★, 1st solver), +`2` (silver ★, 2nd solver), `3` (bronze ★, 3rd solver), `'solved'` +(✓, 4th+ solver), or `null` (not solved). Challenges are sorted by +`solveCount` DESC, then by `name` ASC. The service walks the `solve` +rows in `solved_at ASC` order per challenge to assign each cell its +first solver rank. + +## `GET /api/v1/scoreboard/event-log` + +Recent solves, newest first, joined with the user, challenge, and +category so the frontend never needs a second round-trip to render +labels. + +Query string (`EventLogQuerySchema`, validated by `ZodValidationPipe`): + +| Param | Type | Default | Description | +|---------|------|---------|----------------------------------------------| +| `limit` | int | `50` | `1 <= limit <= 200`, clamped server-side too. | + +Response (`EventLogRowDto[]`): + +```json +[ + { + "solveId": "", + "playerId": "", + "playerName": "bob", + "challengeId": "", + "challengeName": "Forensic Trail", + "categoryAbbreviation": "FOR", + "position": 1, + "awardedPoints": 515, + "awardedAtUtc": "2026-07-23T05:01:30.000Z" + } +] +``` + +## `GET /api/v1/scoreboard/graph` + +Time-series of cumulative points for the top 10 players over the +configured event window, used to drive the Score Graph tab. + +Response (`GraphViewDto`): + +```json +{ + "startUtc": "2026-07-23T05:00:00.000Z", + "endUtc": "2026-07-23T09:00:00.000Z", + "serverNowUtc": "2026-07-23T05:10:00.000Z", + "state": "running", + "series": [ + { + "playerId": "", + "playerName": "alice", + "colorIndex": 4, + "points": [ + { "tUtc": "2026-07-23T05:00:00.000Z", "value": 0 }, + { "tUtc": "2026-07-23T05:01:30.000Z", "value": 515 }, + { "tUtc": "2026-07-23T05:09:50.000Z", "value": 980 }, + { "tUtc": "2026-07-23T09:00:00.000Z", "value": 980 } + ] + } + ] +} +``` + +`state` mirrors the 4-state event window (`running` / `countdown` / +`stopped` / `unconfigured`) and `startUtc` / `endUtc` come from +`SettingsService` via the admin general-settings pipeline. When the +event has not yet started (`countdown`) or has no window configured +(`unconfigured`), the endpoint still returns `200` with empty +`series` and `null` boundaries so the frontend can render the +"Awaiting event configuration" / "Not started" empty states. + +Series are sorted by their final cumulative value DESC and trimmed to +the top 10. Each series starts with a `{tUtc: startUtc, value: 0}` +anchor and (when `endUtc` is known) ends with a +`{tUtc: endUtc, value: finalValue}` step so the line plot shows a +flat plateau between the last solve and the event end. + # Errors The challenges endpoints use three additional error codes from @@ -221,5 +388,6 @@ The full error envelope and middleware are documented in - [Challenge Tables](/database/challenges.md) - [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`) - [Scoreboard Stream](/guides/scoreboard-stream.md) +- [Scoreboard Page Guide](/guides/scoreboard-page.md) - [Challenges Board Guide](/guides/challenges-board.md) - [Backend Module Map](/architecture/backend-modules.md) \ No newline at end of file diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index a7b6f4e..898a870 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -2,8 +2,8 @@ type: architecture title: Frontend Structure description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport. -tags: [architecture, frontend, angular, shell, sse, challenges, notifications] -timestamp: 2026-07-23T00:10:00Z +tags: [architecture, frontend, angular, shell, sse, challenges, scoreboard, notifications] +timestamp: 2026-07-23T05:10:00Z --- # Routes @@ -16,7 +16,7 @@ Routes live in `frontend/src/app/app.routes.ts`: | `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. | | `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. | | `/challenges` | `ChallengesPage` | inherited | Full challenges board (category columns, modal, flag submit, live SSE). | -| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. | +| `/scoreboard` | `ScoreboardPage` | inherited | Live scoreboard page with 4 tabs (Ranking / Matrix / Event Log / Score Graph), live SSE `solve` updates. | | `/blog` | `BlogPage` | inherited | Placeholder blog page. | | `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. | | `/admin/challenges` | `AdminChallengesComponent` | inherited | Sortable/searchable admin Challenges list, Add/Edit modal, import/export. | @@ -37,6 +37,12 @@ Routes live in `frontend/src/app/app.routes.ts`: | `ChallengesApiService` | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP wrapper around `/api/v1/challenges/{board,status,:id,:id/solves}` returning `Observable<...>` and translating `HttpErrorResponse` into a typed `ApiErrorEnvelope`. | | `challenges.pure.ts` | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types + helpers for the challenges feature (`BoardCard`, `SolverRow`, `SolveEventPayload`, sorting, parsers, friendly error mapping, `formatDdHhMm`). | | `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. | +| `ScoreboardPage` | `frontend/src/app/features/scoreboard/scoreboard.page.ts` | Smart page for `/scoreboard`: holds a `ScoreboardStore`, calls `store.loadAll()` + `store.wireSse(...)` on init, tears them down on destroy, and switches between the 4 tab components via `*ngIf` on `store.activeTab()`. | +| `ScoreboardStore` | `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Root-provided signal store backing `/scoreboard`: holds the four projections, active tab, loading + error state, and the SSE lifecycle (`wireSse` / `stop` / exponential reconnect on transport error). Mutates all four tabs from each `solve` frame via pure helpers (`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`, `applySolveToGraph`, `dedupEventLogBySolveId`). | +| `ScoreboardApiService` | `frontend/src/app/features/scoreboard/scoreboard.service.ts` | HTTP wrapper around `/api/v1/scoreboard/{ranking,matrix,event-log,graph}` returning typed `ApiErrorEnvelope`s. | +| `scoreboard.pure.ts` | `frontend/src/app/features/scoreboard/scoreboard.pure.ts` | Pure types + helpers shared between the store and the four tab components (`RankingRow`, `MatrixView`, `EventLogRow`, `GraphView`, `SolveLivePayload`, `PLAYER_COLOR_PALETTE`, `stablePlayerColorIndex`, `applyRankingSort`, `parseSolveEventIntoRanking`, `dedupEventLogBySolveId`, `applySolveToGraph`, `mutateMatrixFromSolve`, `formatSolveDateTime`). | +| `ScoreboardTabsComponent` | `frontend/src/app/features/scoreboard/tabs.component.ts` | Horizontal tab strip bound to `store.activeTab()`; emits `(select)` to switch tabs. | +| `RankingComponent` / `MatrixComponent` / `EventLogComponent` / `ScoreGraphComponent` | `frontend/src/app/features/scoreboard/` | Presentational components for the 4 tabs; each binds a single `@Input()` from `ScoreboardStore` and uses `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` to color each player consistently. | | `NotificationService` | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. | | `errorNotificationInterceptor` | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Functional HTTP interceptor that pushes a friendly message into `NotificationService` for every `HttpErrorResponse`; suppresses duplicate toasts for endpoints with their own error UI (login form, `/challenges/status` snapshot). | @@ -121,5 +127,6 @@ for the full contract and tester matrix. - [Authenticated Shell](/guides/authenticated-shell.md) - [Event Window](/guides/event-window.md) - [Challenges Board](/guides/challenges-board.md) +- [Scoreboard Page](/guides/scoreboard-page.md) - [Notifications](/guides/notifications.md) - [System Overview](/architecture/overview.md) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 72df473..1378131 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -35,8 +35,11 @@ timestamp: 2026-07-23T04:05:00Z | `backend/src/modules/challenges/challenges.controller.ts` | Registers `/api/v1/challenges/{board,status,:id,:id/solves}` (all JWT-protected). | | `backend/src/modules/challenges/events.controller.ts` | Authenticated SSE `/api/v1/events` merging status ticks + hub `general` pushes + `scoreboard$` solve frames. | | `backend/src/modules/challenges/challenges.service.ts` | `getBoard` / `getDetail` / `submitFlag` + `dataSource.transaction` for race-safe idempotent solves; publishes `solve` SSE frames on success. | +| `backend/src/modules/challenges/scoreboard.controller.ts` | Registers the four authenticated scoreboard read-only endpoints under `/api/v1/scoreboard/{ranking,matrix,event-log,graph}`. | +| `backend/src/modules/challenges/scoreboard.service.ts` | Builds the `RankingRowDto[]`, `MatrixViewDto`, `EventLogRowDto[]`, and `GraphViewDto` projections used by the `/scoreboard` page; consumes `SettingsService` for the event window and `EventStatusService` for `state`. | +| `backend/src/modules/challenges/dto/scoreboard.dto.ts` | `EventLogQuerySchema` (zod) + TypeScript DTO interfaces (`RankingRowDto`, `MatrixViewDto`, `EventLogRowDto`, `GraphViewDto`, `GraphSeriesDto`, `GraphPointDto`) for the four scoreboard endpoints. | | `backend/src/modules/challenges/dto/challenges.dto.ts` | zod contracts (`SolveSubmitBodySchema`, `BoardQuerySchema`, `ChallengeIdParamSchema`) + DTO types for the player-facing board. | -| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, and constant-time `safeEqualString` for flag comparison. | +| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, `computeAwardedPoints` (base + rank bonus), `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` (FNV-1a → palette index), and constant-time `safeEqualString` for flag comparison. | | `backend/src/common/errors/error-codes.ts` | Canonical error code map (now includes `EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`). | # Frontend @@ -79,6 +82,16 @@ timestamp: 2026-07-23T04:05:00Z | `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. | | `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders a single challenge card on the board as a semantic ` + + `, +}) +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 -- 2.52.0