AI Implementation feature(910): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00 (#53)

This commit was merged in pull request #53.
This commit is contained in:
2026-07-23 05:42:17 +00:00
parent 7c4843d5cc
commit 4273662211
14 changed files with 129 additions and 179 deletions
-162
View File
@@ -1,162 +0,0 @@
# 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
<ng-container [ngSwitch]="view.cells[p.playerId]?.[ch.id]">
<span *ngSwitchCase="1" class="cell-gold" title="1st solver"></span>
<span *ngSwitchCase="2" class="cell-silver" title="2nd solver"></span>
<span *ngSwitchCase="3" class="cell-bronze" title="3rd solver"></span>
<span *ngSwitchCase="'solved'" class="cell-check" title="solved"></span>
<span *ngSwitchDefault></span>
</ng-container>
```
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 `<ng-template #blank>`). 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`.
+39
View File
@@ -0,0 +1,39 @@
# Implementation Plan: Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Node.js npm-workspace monorepo with a NestJS 10 backend and Angular 17 standalone frontend, both written in TypeScript. Backend request/business logic is service-oriented; live updates use RxJS-backed NestJS SSE. Frontend feature state uses private Angular signals exposed as read-only signals, with SSE frames normalized in `ScoreboardStore` and then applied immutably. The Event Log template already renders `categoryAbbreviation` and `challengeName`; the defect originates upstream because the solve publish path and authenticated SSE mapper omit those fields, and the frontend currently replaces them with empty strings.
- **Data Layer:** TypeORM with `better-sqlite3`. `ChallengesService.submitFlag` already joins `challenge` to `category`, so the successful transaction has `challenge.name` and `challenge.abbreviation` available without an additional query. The reloaded Event Log projection independently joins `solve`, `user`, `challenge`, and `category`, which explains why labels appear after refresh. No schema or migration is required.
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; backend tests are under `tests/backend/` using Nest testing utilities/Supertest and frontend tests are under `tests/frontend/` using jsdom. All tests run from the repository root with `npm test`; focused suites can use `npm run test:backend` and `npm run test:frontend`. Keep regression coverage in the existing dedicated test folders, not beside source files.
- **Required Tools & Dependencies:** No new package, system tool, global CLI, persistent `/data` fixture, or `setup.sh` change is required. Existing Node/npm, TypeScript, Jest, NestJS, Angular, RxJS, TypeORM, and SQLite dependencies cover the implementation. Verification should use `npm test` and `npm run build`; the repository exposes no lint script.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/challenges/dto/challenges.dto.ts` — extend the typed live solve contract with `challengeName` and `categoryAbbreviation`.
- `backend/src/modules/challenges/challenges.service.ts` — include challenge/category labels in the post-insert hub payload using metadata already loaded by `submitFlag`.
- `backend/src/modules/challenges/events.controller.ts` — forward the two labels in authenticated solve SSE frames, preserving the established snake_case wire format and accepting current camelCase hub fields.
- `frontend/src/app/features/scoreboard/scoreboard.pure.ts` — extend `SolveLivePayload` so the normalized live event carries the Event Log labels.
- `frontend/src/app/features/scoreboard/scoreboard.store.ts` — parse label fields from SSE aliases and populate the newly prepended `EventLogRow` instead of writing empty strings.
- `tests/backend/challenges-submit-flag.spec.ts` — strengthen the existing hub-publish success-path test to assert challenge name and category abbreviation.
- `tests/frontend/scoreboard.store.spec.ts` — add focused regression assertions proving an SSE-pushed Event Log row retains both labels.
- `docs/api/challenges.md` — update the documented authenticated solve-frame contract and example fields.
- `docs/guides/scoreboard-stream.md` — document that live solve frames carry display metadata needed by the Event Log.
- **To Create:** None.
## 3. Proposed Changes
1. **Database / Schema Migration:** Make no database changes. Reuse the challenge/category metadata already selected in `ChallengesService.submitFlag`; keep the existing reload query in `ScoreboardService.getEventLog` unchanged.
2. **Backend Logic & APIs:**
1. Add required `challengeName` and `categoryAbbreviation` properties to `SolveEventPayload` so omissions are caught at the backend contract boundary.
2. Extend the private `publishSolveEvent` input and its `SseHubService.emitScoreboard` payload with `challenge.name` and `challenge.abbreviation`, passing them from the successful fresh-solve branch only. Preserve current idempotency: `already_solved`, failed validation, and uniqueness-race fallback paths must not publish a new event.
3. Extend `ChallengesEventsController.solveFrame` to emit `challenge_name` and `category_abbreviation` alongside `challenge_id`, player, scoring, and timing data. Do not add a lookup or business logic to the controller; it should remain a pure transport mapping over the enriched hub payload.
4. Keep all existing fields and aliases intact so Ranking, Matrix, Graph, Challenges Board, and any current stream consumers remain backward-compatible.
3. **Frontend UI Integration:**
1. Extend `SolveLivePayload` with `challengeName` and `categoryAbbreviation`.
2. In `ScoreboardStore.handleSseFrame`, normalize both snake_case fields (`challenge_name`, `category_abbreviation`) and camelCase fallbacks (`challengeName`, `categoryAbbreviation`) in the same style as existing solve properties.
3. In `applySolveEvent`, copy those normalized values into the new `EventLogRow`. Leave `EventLogComponent` unchanged because its `.what` span already renders the abbreviation followed by challenge name, and leave the existing deduplication/cap behavior unchanged.
4. Update API/stream documentation so the public contract matches the corrected wire payload and explains that both initial REST rows and live SSE rows contain identical display metadata.
## 4. Test Strategy
- **Target Unit Test File:**
- `tests/backend/challenges-submit-flag.spec.ts`: enhance the existing “emits an SSE solve payload” test to assert the emitted hub event contains the seeded challenge name and `CRY` abbreviation. This directly verifies the root publish boundary without adding a large SSE/network fixture.
- `tests/frontend/scoreboard.store.spec.ts`: extend the existing live solve-frame test, or add one minimal test, with `challenge_name` and `category_abbreviation`, then assert the prepended Event Log row contains both exact values while retaining awarded points. This covers the user-visible regression through the stores SSE normalization path without UI/visual testing.
- **Mocking Strategy:** Reuse the existing in-memory SQLite Nest application and real `SseHubService` subscription in the backend test; no external service mocks or persistent files are needed. Reuse `FakeApi`, the fake `DestroyRef`, and the lightweight fake `EventSourceLike` in the frontend test; feed a synthetic `MessageEvent` and inspect the public read-only `eventLog` signal. Do not add Playwright, browser, visual, filesystem, network, or `/data` fixtures. Implement test-first: make the focused assertions fail against the current omission, then apply the smallest payload-contract changes and run the root `npm test` plus `npm run build`.
@@ -373,6 +373,8 @@ export class ChallengesService {
this.publishSolveEvent({ this.publishSolveEvent({
challengeId, challengeId,
challengeName: challenge.name,
categoryAbbreviation: challenge.abbreviation ?? '',
userId: currentUserId, userId: currentUserId,
username: solvers.find((s) => s.userId === currentUserId)?.username ?? '', username: solvers.find((s) => s.userId === currentUserId)?.username ?? '',
awardedPoints, awardedPoints,
@@ -405,6 +407,8 @@ export class ChallengesService {
private publishSolveEvent(input: { private publishSolveEvent(input: {
challengeId: string; challengeId: string;
challengeName: string;
categoryAbbreviation: string;
userId: string; userId: string;
username: string; username: string;
awardedPoints: number; awardedPoints: number;
@@ -422,6 +426,8 @@ export class ChallengesService {
this.hub.emitScoreboard({ this.hub.emitScoreboard({
topic: 'solve', topic: 'solve',
challengeId: input.challengeId, challengeId: input.challengeId,
challengeName: input.challengeName,
categoryAbbreviation: input.categoryAbbreviation,
userId: input.userId, userId: input.userId,
playerId: input.userId, playerId: input.userId,
playerName: input.username, playerName: input.username,
@@ -81,6 +81,8 @@ export interface ChallengeDetailDto {
export interface SolveEventPayload { export interface SolveEventPayload {
challengeId: string; challengeId: string;
challengeName: string;
categoryAbbreviation: string;
playerId: string; playerId: string;
playerName: string; playerName: string;
awardedPoints: number; awardedPoints: number;
@@ -28,8 +28,9 @@ export class ChallengesEventsController {
* Emits NestJS MessageEvent objects with the proper `type` field so the * Emits NestJS MessageEvent objects with the proper `type` field so the
* wire format is `event: <type>\ndata: <JSON>\n\n`: * wire format is `event: <type>\ndata: <JSON>\n\n`:
* - event: status, payload = { state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end } * - event: status, payload = { state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }
* - event: solve, payload = { challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc, * - event: solve, payload = { challenge_id, challenge_name, category_abbreviation, player_id, player_name,
* position, live_points, solve_count, initial_points, minimum_points, decay_solves } * awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count,
* initial_points, minimum_points, decay_solves }
* Source of truth: every transition of the event window is broadcast by * Source of truth: every transition of the event window is broadcast by
* the admin general service through SseHubService.event$(). Solves are * the admin general service through SseHubService.event$(). Solves are
* published by ChallengesService through SseHubService.scoreboard$(). * published by ChallengesService through SseHubService.scoreboard$().
@@ -78,6 +79,8 @@ export class ChallengesEventsController {
type: 'solve', type: 'solve',
data: { data: {
challenge_id: s?.challengeId, challenge_id: s?.challengeId,
challenge_name: s?.challengeName ?? '',
category_abbreviation: s?.categoryAbbreviation ?? '',
player_id: s?.playerId ?? s?.userId, player_id: s?.playerId ?? s?.userId,
player_name: s?.playerName ?? '', player_name: s?.playerName ?? '',
awarded_points: s?.awardedPoints ?? s?.pointsAwarded, awarded_points: s?.awardedPoints ?? s?.pointsAwarded,
+9 -4
View File
@@ -3,7 +3,7 @@ type: api
title: Challenges Endpoints 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. 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, scoreboard, sse, submit] tags: [api, challenges, board, score, scoreboard, sse, submit]
timestamp: 2026-07-23T05:10:00Z timestamp: 2026-07-23T05:41:00Z
--- ---
# Endpoints # Endpoints
@@ -181,7 +181,7 @@ sources and emits `MessageEvent` frames whose `type` is one of:
| `event:` | Payload | | `event:` | Payload |
|----------|-------------------------------------------------------------------------------------------------| |----------|-------------------------------------------------------------------------------------------------|
| `status` | `{ state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }` | | `status` | `{ state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }` |
| `solve` | `{ challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count, initial_points, minimum_points, decay_solves }` | | `solve` | `{ challenge_id, challenge_name, category_abbreviation, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count, initial_points, minimum_points, decay_solves }` |
Sources: Sources:
@@ -203,8 +203,13 @@ appended as-is.
`backend/src/modules/challenges/dto/challenges.dto.ts`). The `backend/src/modules/challenges/dto/challenges.dto.ts`). The
controller maps both camelCase and snake_case aliases so a controller maps both camelCase and snake_case aliases so a
downstream consumer can read either shape (for example downstream consumer can read either shape (for example
`challenge_id` or `challengeId`, `player_id` or `userId`, `challenge_id` or `challengeId`, `challenge_name` or `challengeName`,
`awarded_points` or `pointsAwarded`). `category_abbreviation` or `categoryAbbreviation`, `player_id` or
`userId`, `awarded_points` or `pointsAwarded`). `challenge_name`
and `category_abbreviation` carry the same display metadata that
the `/api/v1/scoreboard/event-log` projection joins in, so the
Scoreboard Event Log tab can render a freshly pushed row without
performing a second lookup.
# Scoreboard endpoints # Scoreboard endpoints
+7 -2
View File
@@ -3,7 +3,7 @@ type: guide
title: Scoreboard Page title: Scoreboard Page
description: How a signed-in player navigates /scoreboard, uses the four tabs (Ranking, Matrix, Event Log, Score Graph), and watches live solve updates arriving over the authenticated SSE stream. description: How a signed-in player navigates /scoreboard, uses the four tabs (Ranking, Matrix, Event Log, Score Graph), and watches live solve updates arriving over the authenticated SSE stream.
tags: [guide, scoreboard, sse, live, ranking, matrix, event-log, score-graph, tester] tags: [guide, scoreboard, sse, live, ranking, matrix, event-log, score-graph, tester]
timestamp: 2026-07-23T05:10:00Z timestamp: 2026-07-23T05:41:00Z
--- ---
# Overview # Overview
@@ -157,7 +157,12 @@ The same `solve` frame that lights up the
New players are appended to the matrix row list. New players are appended to the matrix row list.
* **Event Log**: a new `EventLogRow` is prepended to the list * **Event Log**: a new `EventLogRow` is prepended to the list
(deduplicated by `solveId` so reconnects don't duplicate) and the (deduplicated by `solveId` so reconnects don't duplicate) and the
list is capped at 200. list is capped at 200. The SSE solve frame carries
`challenge_name` / `category_abbreviation` (sourced from the same
`challenge` + `category` join that backs the
`/api/v1/scoreboard/event-log` REST projection), so the prepended
row's category abbreviation and challenge name are populated
immediately — no refresh is required.
* **Score Graph**: the matching player's series receives a new * **Score Graph**: the matching player's series receives a new
`{tUtc: awardedAtUtc, value: prevValue + awardedPoints}` point, `{tUtc: awardedAtUtc, value: prevValue + awardedPoints}` point,
the series list is re-sorted by final value DESC and trimmed to the the series list is re-sorted by final value DESC and trimmed to the
+10 -4
View File
@@ -3,7 +3,7 @@ type: guide
title: Scoreboard Stream title: Scoreboard Stream
description: How fresh solves are broadcast to clients — both the public unauthenticated scoreboard SSE stream and the authenticated combined /api/v1/events stream that the /scoreboard page consumes. description: How fresh solves are broadcast to clients — both the public unauthenticated scoreboard SSE stream and the authenticated combined /api/v1/events stream that the /scoreboard page consumes.
tags: [guide, scoreboard, sse, stream, public, authenticated] tags: [guide, scoreboard, sse, stream, public, authenticated]
timestamp: 2026-07-23T05:10:00Z timestamp: 2026-07-23T05:41:00Z
--- ---
# Overview # Overview
@@ -43,9 +43,15 @@ is published to the scoreboard hub.
* `/api/v1/scoreboard/stream` emits a flattened shape * `/api/v1/scoreboard/stream` emits a flattened shape
(`challengeId`, `userId`, `pointsAwarded`, `rankBonus`, (`challengeId`, `userId`, `pointsAwarded`, `rankBonus`,
`solvedAt`) for anonymous spectators. `solvedAt`) for anonymous spectators.
* `/api/v1/events` emits a richer `SolveEventPayload` (with both * `/api/v1/events` emits a richer `SolveEventPayload` (with both
snake_case and camelCase aliases, plus `position`, snake_case and camelCase aliases, plus `challenge_name`,
`live_points`, `solve_count`, etc.) for the authenticated SPA. `category_abbreviation`, `position`, `live_points`,
`solve_count`, etc.) for the authenticated SPA. The display
metadata fields are sourced from the same `challenge` +
`category` join that backs the `/api/v1/scoreboard/event-log`
REST projection, so the Event Log tab can render an SSE-pushed
row with the challenge name and category abbreviation without a
second lookup.
The hub is in-process; multi-replica deployments need a shared The hub is in-process; multi-replica deployments need a shared
pub/sub to fan out across pods (not in scope for this repo). pub/sub to fan out across pods (not in scope for this repo).
+1 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-23T05:10:00Z. they need. Last regenerated 2026-07-23T05:41:00Z.
# Architecture # Architecture
@@ -60,6 +60,8 @@ export interface GraphView {
export interface SolveLivePayload { export interface SolveLivePayload {
challengeId: string; challengeId: string;
challengeName: string;
categoryAbbreviation: string;
playerId: string; playerId: string;
playerName: string; playerName: string;
awardedPoints: number; awardedPoints: number;
@@ -95,6 +95,9 @@ export class ScoreboardStore {
if (!raw) return; if (!raw) return;
const payload: SolveLivePayload = { const payload: SolveLivePayload = {
challengeId: raw?.challenge_id ?? raw?.challengeId ?? '', challengeId: raw?.challenge_id ?? raw?.challengeId ?? '',
challengeName: raw?.challenge_name ?? raw?.challengeName ?? '',
categoryAbbreviation:
raw?.category_abbreviation ?? raw?.categoryAbbreviation ?? '',
playerId: raw?.player_id ?? raw?.playerId ?? raw?.userId ?? '', playerId: raw?.player_id ?? raw?.playerId ?? raw?.userId ?? '',
playerName: raw?.player_name ?? raw?.playerName ?? '', playerName: raw?.player_name ?? raw?.playerName ?? '',
awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0), awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0),
@@ -120,8 +123,8 @@ export class ScoreboardStore {
playerId: payload.playerId, playerId: payload.playerId,
playerName: payload.playerName, playerName: payload.playerName,
challengeId: payload.challengeId, challengeId: payload.challengeId,
challengeName: '', challengeName: payload.challengeName,
categoryAbbreviation: '', categoryAbbreviation: payload.categoryAbbreviation,
position: payload.position, position: payload.position,
awardedPoints: payload.awardedPoints, awardedPoints: payload.awardedPoints,
awardedAtUtc: payload.awardedAtUtc, awardedAtUtc: payload.awardedAtUtc,
@@ -364,6 +364,8 @@ describe('POST /api/v1/challenges/:id/solves', () => {
expect(latest).toMatchObject({ expect(latest).toMatchObject({
topic: 'solve', topic: 'solve',
challengeId: c.id, challengeId: c.id,
challengeName: 'sse-emit',
categoryAbbreviation: 'CRY',
playerId: playerId, playerId: playerId,
position: 1, position: 1,
awardedPoints: 400 + 15, awardedPoints: 400 + 15,
+6 -2
View File
@@ -46,6 +46,8 @@ describe('parseSolveEventIntoRanking', () => {
]; ];
const next = parseSolveEventIntoRanking(rows, { const next = parseSolveEventIntoRanking(rows, {
challengeId: 'c1', challengeId: 'c1',
challengeName: 'alpha',
categoryAbbreviation: 'CRY',
playerId: 'p2', playerId: 'p2',
playerName: 'p2', playerName: 'p2',
awardedPoints: 50, awardedPoints: 50,
@@ -90,7 +92,7 @@ describe('applySolveToGraph', () => {
}; };
const next = applySolveToGraph( const next = applySolveToGraph(
baseView, baseView,
{ challengeId: 'c1', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 2 }, { challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', 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-01T00:00:00.000Z',
'2025-01-02T00:00:00.000Z', '2025-01-02T00:00:00.000Z',
'2025-01-01T12:00:00.000Z', '2025-01-01T12:00:00.000Z',
@@ -105,7 +107,7 @@ describe('applySolveToGraph', () => {
it('returns empty when event is not configured', () => { it('returns empty when event is not configured', () => {
const next = applySolveToGraph( const next = applySolveToGraph(
null, null,
{ challengeId: 'c1', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 1 }, { challengeId: 'c1', challengeName: 'alpha', categoryAbbreviation: 'CRY', playerId: 'p0', playerName: 'p0', awardedPoints: 30, rankBonus: 0, awardedAtUtc: '2025-01-01T06:00:00.000Z', position: 1 },
null, null,
null, null,
'2025-01-01T12:00:00.000Z', '2025-01-01T12:00:00.000Z',
@@ -123,6 +125,8 @@ describe('mutateMatrixFromSolve', () => {
}; };
const next = mutateMatrixFromSolve(matrix, { const next = mutateMatrixFromSolve(matrix, {
challengeId: 'c1', challengeId: 'c1',
challengeName: 'alpha',
categoryAbbreviation: 'CRY',
playerId: 'p2', playerId: 'p2',
playerName: 'p2', playerName: 'p2',
awardedPoints: 50, awardedPoints: 50,
+35
View File
@@ -133,6 +133,41 @@ describe('ScoreboardStore', () => {
}); });
}); });
it('propagates challenge name and category abbreviation from SSE solve frame to the Event Log row', () => {
const api = new FakeApi();
const destroyRef: any = { onDestroy: () => {} };
const store = new ScoreboardStore(api as any, destroyRef);
void store.loadAll();
return new Promise<void>((resolve) => {
const source = makeFakeSse((type, fn) => {
if (type === 'solve') {
setTimeout(() => {
fn(new MessageEvent('solve', { data: JSON.stringify({
challenge_id: 'c2',
challenge_name: 'Hardware Hello',
category_abbreviation: 'HW',
player_id: 'p3',
player_name: 'PlayerC',
awarded_points: 115,
rank_bonus: 15,
awarded_at_utc: '2026-07-23T05:25:37.309Z',
position: 1,
}) }));
setTimeout(() => {
const top = store.eventLog()?.[0];
expect(top?.challengeName).toBe('Hardware Hello');
expect(top?.categoryAbbreviation).toBe('HW');
expect(top?.playerName).toBe('PlayerC');
expect(top?.awardedPoints).toBe(115);
resolve();
}, 0);
}, 0);
}
});
store.wireSse(() => source);
});
});
it('reset clears all signals and stops SSE', () => { it('reset clears all signals and stops SSE', () => {
const api = new FakeApi(); const api = new FakeApi();
const destroyRef: any = { onDestroy: () => {} }; const destroyRef: any = { onDestroy: () => {} };