From 427366221169d4f8f80b79f87bbb3d415169e511 Mon Sep 17 00:00:00 2001 From: m0rph3us1987 Date: Thu, 23 Jul 2026 05:42:17 +0000 Subject: [PATCH] AI Implementation feature(910): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00 (#53) --- .kilo/plans/865-matrix-solved-sentinel.md | 162 ------------------ .kilo/plans/910.md | 39 +++++ .../modules/challenges/challenges.service.ts | 6 + .../modules/challenges/dto/challenges.dto.ts | 2 + .../modules/challenges/events.controller.ts | 7 +- docs/api/challenges.md | 13 +- docs/guides/scoreboard-page.md | 9 +- docs/guides/scoreboard-stream.md | 14 +- docs/index.md | 2 +- .../features/scoreboard/scoreboard.pure.ts | 2 + .../features/scoreboard/scoreboard.store.ts | 7 +- tests/backend/challenges-submit-flag.spec.ts | 2 + tests/frontend/scoreboard.pure.spec.ts | 8 +- tests/frontend/scoreboard.store.spec.ts | 35 ++++ 14 files changed, 129 insertions(+), 179 deletions(-) delete mode 100644 .kilo/plans/865-matrix-solved-sentinel.md create mode 100644 .kilo/plans/910.md diff --git a/.kilo/plans/865-matrix-solved-sentinel.md b/.kilo/plans/865-matrix-solved-sentinel.md deleted file mode 100644 index 1439495..0000000 --- a/.kilo/plans/865-matrix-solved-sentinel.md +++ /dev/null @@ -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 - - - - - - - -``` -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/910.md b/.kilo/plans/910.md new file mode 100644 index 0000000..943b4a1 --- /dev/null +++ b/.kilo/plans/910.md @@ -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 store’s 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`. diff --git a/backend/src/modules/challenges/challenges.service.ts b/backend/src/modules/challenges/challenges.service.ts index df75cbd..d2fa1da 100644 --- a/backend/src/modules/challenges/challenges.service.ts +++ b/backend/src/modules/challenges/challenges.service.ts @@ -373,6 +373,8 @@ export class ChallengesService { this.publishSolveEvent({ challengeId, + challengeName: challenge.name, + categoryAbbreviation: challenge.abbreviation ?? '', userId: currentUserId, username: solvers.find((s) => s.userId === currentUserId)?.username ?? '', awardedPoints, @@ -405,6 +407,8 @@ export class ChallengesService { private publishSolveEvent(input: { challengeId: string; + challengeName: string; + categoryAbbreviation: string; userId: string; username: string; awardedPoints: number; @@ -422,6 +426,8 @@ export class ChallengesService { this.hub.emitScoreboard({ topic: 'solve', challengeId: input.challengeId, + challengeName: input.challengeName, + categoryAbbreviation: input.categoryAbbreviation, userId: input.userId, playerId: input.userId, playerName: input.username, diff --git a/backend/src/modules/challenges/dto/challenges.dto.ts b/backend/src/modules/challenges/dto/challenges.dto.ts index 100e088..83263a2 100644 --- a/backend/src/modules/challenges/dto/challenges.dto.ts +++ b/backend/src/modules/challenges/dto/challenges.dto.ts @@ -81,6 +81,8 @@ export interface ChallengeDetailDto { export interface SolveEventPayload { challengeId: string; + challengeName: string; + categoryAbbreviation: string; playerId: string; playerName: string; awardedPoints: number; diff --git a/backend/src/modules/challenges/events.controller.ts b/backend/src/modules/challenges/events.controller.ts index 7ab1875..43429fa 100644 --- a/backend/src/modules/challenges/events.controller.ts +++ b/backend/src/modules/challenges/events.controller.ts @@ -28,8 +28,9 @@ export class ChallengesEventsController { * Emits NestJS MessageEvent objects with the proper `type` field so the * wire format is `event: \ndata: \n\n`: * - 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, - * position, live_points, solve_count, initial_points, minimum_points, decay_solves } + * - event: solve, payload = { 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 } * Source of truth: every transition of the event window is broadcast by * the admin general service through SseHubService.event$(). Solves are * published by ChallengesService through SseHubService.scoreboard$(). @@ -78,6 +79,8 @@ export class ChallengesEventsController { type: 'solve', data: { challenge_id: s?.challengeId, + challenge_name: s?.challengeName ?? '', + category_abbreviation: s?.categoryAbbreviation ?? '', player_id: s?.playerId ?? s?.userId, player_name: s?.playerName ?? '', awarded_points: s?.awardedPoints ?? s?.pointsAwarded, diff --git a/docs/api/challenges.md b/docs/api/challenges.md index 14ef80d..a6a80fb 100644 --- a/docs/api/challenges.md +++ b/docs/api/challenges.md @@ -3,7 +3,7 @@ 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, scoreboard, sse, submit] -timestamp: 2026-07-23T05:10:00Z +timestamp: 2026-07-23T05:41:00Z --- # Endpoints @@ -181,7 +181,7 @@ sources and emits `MessageEvent` frames whose `type` is one of: | `event:` | Payload | |----------|-------------------------------------------------------------------------------------------------| | `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: @@ -203,8 +203,13 @@ appended as-is. `backend/src/modules/challenges/dto/challenges.dto.ts`). The controller maps both camelCase and snake_case aliases so a downstream consumer can read either shape (for example -`challenge_id` or `challengeId`, `player_id` or `userId`, -`awarded_points` or `pointsAwarded`). +`challenge_id` or `challengeId`, `challenge_name` or `challengeName`, +`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 diff --git a/docs/guides/scoreboard-page.md b/docs/guides/scoreboard-page.md index 282c157..692b58f 100644 --- a/docs/guides/scoreboard-page.md +++ b/docs/guides/scoreboard-page.md @@ -3,7 +3,7 @@ type: guide 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. 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 @@ -157,7 +157,12 @@ The same `solve` frame that lights up the New players are appended to the matrix row list. * **Event Log**: a new `EventLogRow` is prepended to the list (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 `{tUtc: awardedAtUtc, value: prevValue + awardedPoints}` point, the series list is re-sorted by final value DESC and trimmed to the diff --git a/docs/guides/scoreboard-stream.md b/docs/guides/scoreboard-stream.md index 20c7af3..1045ecb 100644 --- a/docs/guides/scoreboard-stream.md +++ b/docs/guides/scoreboard-stream.md @@ -3,7 +3,7 @@ type: guide 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. tags: [guide, scoreboard, sse, stream, public, authenticated] -timestamp: 2026-07-23T05:10:00Z +timestamp: 2026-07-23T05:41:00Z --- # Overview @@ -43,9 +43,15 @@ is published to the scoreboard hub. * `/api/v1/scoreboard/stream` emits a flattened shape (`challengeId`, `userId`, `pointsAwarded`, `rankBonus`, `solvedAt`) for anonymous spectators. - * `/api/v1/events` emits a richer `SolveEventPayload` (with both - snake_case and camelCase aliases, plus `position`, - `live_points`, `solve_count`, etc.) for the authenticated SPA. + * `/api/v1/events` emits a richer `SolveEventPayload` (with both + snake_case and camelCase aliases, plus `challenge_name`, + `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 pub/sub to fan out across pods (not in scope for this repo). diff --git a/docs/index.md b/docs/index.md index 54b355a..1866336 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin controls. 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 diff --git a/frontend/src/app/features/scoreboard/scoreboard.pure.ts b/frontend/src/app/features/scoreboard/scoreboard.pure.ts index cd110fb..96e64c4 100644 --- a/frontend/src/app/features/scoreboard/scoreboard.pure.ts +++ b/frontend/src/app/features/scoreboard/scoreboard.pure.ts @@ -60,6 +60,8 @@ export interface GraphView { export interface SolveLivePayload { challengeId: string; + challengeName: string; + categoryAbbreviation: string; playerId: string; playerName: string; awardedPoints: number; diff --git a/frontend/src/app/features/scoreboard/scoreboard.store.ts b/frontend/src/app/features/scoreboard/scoreboard.store.ts index ad0bbcf..1456f92 100644 --- a/frontend/src/app/features/scoreboard/scoreboard.store.ts +++ b/frontend/src/app/features/scoreboard/scoreboard.store.ts @@ -95,6 +95,9 @@ export class ScoreboardStore { if (!raw) return; const payload: SolveLivePayload = { 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 ?? '', playerName: raw?.player_name ?? raw?.playerName ?? '', awardedPoints: Number(raw?.awarded_points ?? raw?.awardedPoints ?? raw?.pointsAwarded ?? 0), @@ -120,8 +123,8 @@ export class ScoreboardStore { playerId: payload.playerId, playerName: payload.playerName, challengeId: payload.challengeId, - challengeName: '', - categoryAbbreviation: '', + challengeName: payload.challengeName, + categoryAbbreviation: payload.categoryAbbreviation, position: payload.position, awardedPoints: payload.awardedPoints, awardedAtUtc: payload.awardedAtUtc, diff --git a/tests/backend/challenges-submit-flag.spec.ts b/tests/backend/challenges-submit-flag.spec.ts index 688aea7..c39e128 100644 --- a/tests/backend/challenges-submit-flag.spec.ts +++ b/tests/backend/challenges-submit-flag.spec.ts @@ -364,6 +364,8 @@ describe('POST /api/v1/challenges/:id/solves', () => { expect(latest).toMatchObject({ topic: 'solve', challengeId: c.id, + challengeName: 'sse-emit', + categoryAbbreviation: 'CRY', playerId: playerId, position: 1, awardedPoints: 400 + 15, diff --git a/tests/frontend/scoreboard.pure.spec.ts b/tests/frontend/scoreboard.pure.spec.ts index 73b6ec6..d78deff 100644 --- a/tests/frontend/scoreboard.pure.spec.ts +++ b/tests/frontend/scoreboard.pure.spec.ts @@ -46,6 +46,8 @@ describe('parseSolveEventIntoRanking', () => { ]; const next = parseSolveEventIntoRanking(rows, { challengeId: 'c1', + challengeName: 'alpha', + categoryAbbreviation: 'CRY', playerId: 'p2', playerName: 'p2', awardedPoints: 50, @@ -90,7 +92,7 @@ describe('applySolveToGraph', () => { }; const next = applySolveToGraph( 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-02T00:00:00.000Z', '2025-01-01T12:00:00.000Z', @@ -105,7 +107,7 @@ describe('applySolveToGraph', () => { 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 }, + { 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, '2025-01-01T12:00:00.000Z', @@ -123,6 +125,8 @@ describe('mutateMatrixFromSolve', () => { }; const next = mutateMatrixFromSolve(matrix, { challengeId: 'c1', + challengeName: 'alpha', + categoryAbbreviation: 'CRY', playerId: 'p2', playerName: 'p2', awardedPoints: 50, diff --git a/tests/frontend/scoreboard.store.spec.ts b/tests/frontend/scoreboard.store.spec.ts index 7ab125b..4a74a0c 100644 --- a/tests/frontend/scoreboard.store.spec.ts +++ b/tests/frontend/scoreboard.store.spec.ts @@ -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((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', () => { const api = new FakeApi(); const destroyRef: any = { onDestroy: () => {} };