diff --git a/.kilo/plans/906-register-migration.md b/.kilo/plans/906-register-migration.md deleted file mode 100644 index 48fb6cd..0000000 --- a/.kilo/plans/906-register-migration.md +++ /dev/null @@ -1,28 +0,0 @@ -# Implementation Plan: Register SeedSampleChallenges migration in DatabaseModule - -## 1. Architectural Reconnaissance -- **Codebase style & conventions:** NestJS + TypeORM; migrations are registered manually via an in-source `MIGRATIONS` array (not a filesystem glob) in `backend/src/database/database.module.ts`. Imports follow chronological order matching the array. -- **Data Layer:** SQLite via `better-sqlite3`. `DatabaseInitService.init()` (called from `main.ts` before `app.listen()`) executes `dataSource.runMigrations({ transaction: 'each' })`, which **uses the `MIGRATIONS` array** registered with `TypeOrmModule.forRootAsync`. Until the array contains the new class, `npm run setup` and the first app boot will not run `SeedSampleChallenges1700000000600`. -- **Test framework:** Jest; backend tests construct their own in-memory `DataSource` with an explicit `migrations: [...]` list — they bypass `DatabaseModule`, which is why `migrations.spec.ts` (already updated for Job 906) passes despite this gap. -- **Required tools & dependencies:** none. - -## 2. Impacted Files -- **To Modify:** - - `backend/src/database/database.module.ts` — add the import and append the new migration class to the `MIGRATIONS` array, right after `UpgradeChallengeAdminSchema1700000000500`. - -## 3. Proposed Changes -1. **Edit `database.module.ts`:** - - Add the import alongside the other chronological migration imports (between `.../1700000000500-UpgradeChallengeAdminSchema` and the next non-migration import `DatabaseInitService`): - ```ts - import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges'; - ``` - - Append to the `MIGRATIONS` array, immediately after `UpgradeChallengeAdminSchema1700000000500`: - ```ts - SeedSampleChallenges1700000000600, - ``` -2. **No other code changes.** The migration class already exists, is exported, and is covered by tests in `tests/backend/migrations.spec.ts`. Once registered in the TypeORM `MIGRATIONS` array, `DatabaseInitService.init()` (called via `main.ts` and indirectly by `npm run setup`) will execute it on next boot. -3. **Verification expectation:** After `npm run setup` (or first boot on a fresh DB), `SELECT COUNT(*) FROM challenge;` returns `> 0` and the eight sample names are present. Re-running setup on a DB that already has rows is a no-op (the migration's early-return guard). - -## 4. Test Strategy -- No new automated tests required. The existing `tests/backend/migrations.spec.ts` already asserts the seed runs and that values are schema-compatible, schema-correct, and `down()` is scoped correctly. The fix is purely a wiring change that registers an already-tested class with the production `DatabaseModule`, which has no dedicated test (and per the Jobs rule, no new test files should be introduced for a 2-line wiring fix). -- **Manual smoke (tester steps, no UI):** on a fresh DB file, run `npm run setup` then `sqlite3 ./data/db.sqlite 'SELECT COUNT(*) FROM challenge;'` and confirm the count is `8`; on a re-run, confirm the count is unchanged (idempotent guard works). diff --git a/.kilo/plans/907.md b/.kilo/plans/907.md new file mode 100644 index 0000000..ac534e1 --- /dev/null +++ b/.kilo/plans/907.md @@ -0,0 +1,38 @@ +# Implementation Plan: Challenges Page and Challenge Solve Modal 1.03 + +## 1. Architectural Reconnaissance +- **Codebase style & conventions:** Node.js monorepo with an Angular 17 standalone SPA and NestJS API, written in TypeScript. Frontend state uses root-provided signal stores with private mutable signals and readonly/computed public state; HTTP calls are isolated in injectable services and return typed RxJS `Observable`s. The challenges page consumes `EventStatusStore` for its anchor-based one-second countdown and `ChallengesStore`/`ChallengesApiService` for board, detail, solve, and SSE data. Pure countdown helpers are currently duplicated in `frontend/src/app/core/services/event-status.pure.ts` and `frontend/src/app/features/challenges/challenges.pure.ts`, and both drop seconds. +- **Data Layer:** SQLite through TypeORM in the NestJS backend. No schema or persistence change is needed: the event store already recalculates remaining seconds every second, solve persistence is correct, `ChallengesService.getDetail()` already loads solver rows, and solve responses already return the updated solver list. +- **Test Framework & Structure:** Jest 29 with `ts-jest`; frontend tests use jsdom and live under the dedicated `tests/frontend/` tree. Root `npm test` executes backend and frontend projects through `tests/jest.config.js`, while `npm run test:frontend` provides the focused frontend run. Keep coverage at pure/helper and HTTP-service boundaries without browser/visual tests. +- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, `/data` assets, or setup steps are required. Existing Angular `HttpClient`, Jest, jsdom, and `ts-jest` are sufficient; `setup.sh` does not need modification. Verification should use the existing root `npm test` and `npm run build` scripts; the repository exposes no lint or standalone typecheck script. + +## 2. Impacted Files +- **To Modify:** + - `frontend/src/app/core/services/event-status.pure.ts` — format countdown output with the actual seconds remaining so computed text changes on every one-second store tick. + - `frontend/src/app/features/challenges/challenges.pure.ts` — keep the challenges-page formatter consistent with the shared event-status formatter (or re-export/delegate to one source of truth) so the gate also displays seconds. + - `frontend/src/app/features/challenges/challenges.service.ts` — request challenge detail with `include=solvers` so the modal receives the server’s current solver list after an event resume and solve. + - `tests/frontend/event-status.store.spec.ts` — add/update focused assertions for the final-minute countdown and second-level decrement behavior. + - `tests/frontend/challenges.pure.spec.ts` — update the feature formatter contract to include seconds and retain invalid-input coverage. + - `tests/frontend/challenges-page.spec.ts` — update countdown expectations/documentation and cover final-minute values used by the page gate. + - `tests/frontend/challenge-modal-submit.spec.ts` — update the incidental formatter assertion to the new output shape, keeping solve-modal tests aligned. + - `tests/frontend/challenges.service.spec.ts` — add a minimal HTTP boundary regression test asserting that detail loading sends `include=solvers` and preserves the typed detail payload. +- **To Create:** + - `tests/frontend/challenges.service.spec.ts` — only if no existing service test is present at implementation time; keep it in the dedicated test tree and use Angular HTTP testing utilities rather than a manual browser or backend environment. + +## 3. Proposed Changes +1. **Database / Schema Migration:** No migration. Solver records, event state, scoring, and backend detail assembly are already correct and must remain unchanged. +2. **Backend Logic & APIs:** No backend implementation change. The existing `GET /api/v1/challenges/:id` flow in `backend/src/modules/challenges/challenges.controller.ts` and `ChallengesService.getDetail()` already retrieves solvers. Preserve the current API contract and add the client’s expected `include=solvers` query parameter rather than changing solve/event-state behavior. +3. **Frontend UI Integration:** + 1. Change the countdown formatter contract from `DD:HH:MM` to a second-bearing representation while preserving all four units, e.g. `DD:HH:MM:SS`; compute `seconds = floor(totalSeconds) % 60`, zero-pad every component, and retain the existing `00:00:00:00` fallback for negative/non-finite input. This makes values such as 24, 23, and 22 render distinctly and lets the existing `EventStatusStore.tick` signal drive visible one-second updates without introducing another timer. + 2. Eliminate behavioral drift between the core and challenges formatters by having the challenges pure module delegate to/re-export the shared core formatter, or otherwise apply the exact same implementation if repository import constraints require it. Keep `ChallengesPage.countdownText` and `EventStatusStore.countdownText` on the same output contract. + 3. Update `ChallengesApiService.getDetail()` to pass typed query params containing `include: 'solvers'` alongside `withCredentials: true`. Keep URL encoding and existing `catchError` envelope conversion unchanged. + 4. Leave modal submission state handling intact: successful and idempotent solve responses already replace the modal’s solver signal from `resp.solvers`. The detail-query fix addresses newly opened/reopened modals by ensuring their initial server fetch explicitly requests solver data. + 5. Update directly relevant documentation strings/comments or API/countdown labels only if implementation validation confirms they are part of the maintained contract; do not add unrelated UI or backend work. + +## 4. Test Strategy +- **Target Unit Test File:** + - `tests/frontend/event-status.store.spec.ts`: assert final-minute formatting (for example, `24 -> 00:00:00:24` and `23 -> 00:00:00:23`) plus representative multi-day formatting and invalid input. + - `tests/frontend/challenges.pure.spec.ts` and `tests/frontend/challenges-page.spec.ts`: update focused formatter/page-gate expectations so seconds are retained rather than frozen at zero. + - `tests/frontend/challenges.service.spec.ts`: instantiate `ChallengesApiService` with Angular’s HTTP testing providers, call `getDetail(id)`, assert the request URL/method, `withCredentials`, and `include=solvers`, flush a small `ChallengeDetail`, and verify the observable result. + - `tests/frontend/challenge-modal-submit.spec.ts`: update only the existing formatter assertion affected by the contract; rely on existing success-path tests for solve-response solver replacement. +- **Mocking Strategy:** Use pure function calls for countdown coverage and `provideHttpClient()` plus `provideHttpClientTesting()`/`HttpTestingController` for the detail request. Flush an in-memory detail payload and call `verify()` after each HTTP test. Do not start NestJS, SQLite, SSE, timers requiring real waiting, a browser, or visual tooling; no persistent `/data` fixture is needed. Run all automated checks from the repository root with `npm test`, then `npm run build` for Angular/NestJS compilation. diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index eaba6c0..46d79f8 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -89,7 +89,7 @@ single path. | `EventState` | Union `'running' \| 'countdown' \| 'stopped' \| 'unconfigured'`. | | `EventStatePayload` | Server-pushed event window payload shape. | | `EventSourceLike` | Minimal interface for any SSE transport the store can drive. | -| `formatDdHhMm(seconds)` | Pure `DD:HH:mm` formatter. | +| `formatDdHhMm(seconds)` | Pure `DD:HH:mm:ss` formatter (zero-padded, falls back to `00:00:00:00` for negative / non-finite input). | | `deriveCountdownText(state, start, end)` | Pure mapping from event window state to countdown label. | | `LED_COLOR_BY_STATE` | `Readonly>` mapping each state to its theme CSS variable (`var(--color-success)`, `--color-warning`, `--color-danger`, `--color-secondary`). | | `ledBackgroundColor(state)` | Pure accessor returning the `background-color` value for the LED element. | diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 79beefc..428f882 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -59,7 +59,7 @@ timestamp: 2026-07-23T01:19:00Z | `frontend/src/app/core/services/bootstrap-event.service.ts` | Root-provided public SSE listener that subscribes to `/api/v1/event/stream`, filters `topic === 'general'` frames, and triggers `BootstrapService.refresh()` so the landing modal stays in sync with admin general-settings updates. | | `frontend/src/app/core/services/bootstrap-event.pure.ts` | Pure helpers for the public bootstrap SSE listener: `isBootstrapGeneralFrame` predicate, SSE line parser, and the `makeBootstrapEventSource` factory (fetch + `ReadableStream` opener with frame buffering and idempotent `close()`). | | `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring; exposes `start()`/`stop()`/`closeTransport()` for transport lifecycle and `subscribeReloadAtCountdownZero(handler)` (with `reloadAtCountdownZero(handler)` kept as a back-compat wrapper) so the reload handler is owned by its subscriber rather than the transport. | -| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers, and the `LED_COLOR_BY_STATE` map used by the shell LED. | +| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers (`formatDdHhMm` returns `DD:HH:mm:ss` and `deriveCountdownText`), and the `LED_COLOR_BY_STATE` map used by the shell LED. | | `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, and navigates to `/login` when the session is invalidated elsewhere. | | `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. | | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. | @@ -74,14 +74,15 @@ timestamp: 2026-07-23T01:19:00Z | `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. | | `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page: gate logic (countdown/running/stopped/unconfigured), modal lifecycle, SSE wiring on `/api/v1/events`, and the page-owned countdown-zero reload (handler registered in the constructor with the disposer wired into `DestroyRef`). | | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle. | -| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. | +| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. `getDetail` always requests `?include=solvers` so the modal can render the solvers list in one round-trip; `getBoard` only attaches `include=solvers` when the caller opts in. | | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types and helpers (`BoardCard`, `SolverRow`, `SolveEventPayload`, `parseSolveEvent`, `mergeSolveEventIntoSolvers`, `messageForSolveError`, `formatDdHhMm`). | | `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 (difficulty, live points, solve count, `✓` when solved by the player). | | `frontend/src/app/features/challenges/challenge-modal.component.ts` | Challenge detail modal: description, flag form, awarded banner, solvers list, live-solve updates. | | `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. | | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). | -| `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm`. | +| `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm` (`DD:HH:mm:ss`). | +| `tests/frontend/challenges.service.spec.ts` | HTTP-service contract: `getDetail` URL-encodes the id, attaches `?include=solvers`, and forwards `withCredentials: true`. | | `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. | | `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. | | `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, `?include=solvers`. | diff --git a/docs/guides/challenges-board.md b/docs/guides/challenges-board.md index 4548ea0..09ed4a9 100644 --- a/docs/guides/challenges-board.md +++ b/docs/guides/challenges-board.md @@ -3,7 +3,7 @@ type: guide title: Challenges Board description: How a signed-in player navigates the `/challenges` page, opens a challenge modal, submits a flag, and watches live solve updates. tags: [guide, challenges, board, flag, score, sse, tester] -timestamp: 2026-07-23T00:10:00Z +timestamp: 2026-07-23T03:09:00Z --- # When this view is available @@ -66,7 +66,7 @@ renders `[data-testid="challenges-gate"]`: | Event state | Headline | Countdown text (`[data-testid="challenges-countdown"]`) | Helper label | |---------------|---------------------------|--------------------------------------------------------|-----------------------------------------------------| -| `countdown` | `Event starts in` | `DD:HH:mm` until `eventStartUtc` | `The board will be available once the event is running.` | +| `countdown` | `Event starts in` | `DD:HH:mm:ss` until `eventStartUtc` (ticks each second during the final minute so the player sees time elapse) | `The board will be available once the event is running.` | | `running` | _(grid shown instead)_ | — | — | | `stopped` | `Event ended` | `Event ended` | `The board is closed.` | | `unconfigured`| `Awaiting configuration` | empty | _(none)_ | @@ -159,7 +159,9 @@ destruction of the page the SSE source is closed via ## Countdown / stopped gate 1. Set `eventStartUtc` in the future; confirm the gate panel shows - `Event starts in` and the `DD:HH:mm` countdown. + `Event starts in` and the `DD:HH:mm:ss` countdown. Within the final + minute the seconds digit visibly ticks down (e.g. `00:00:00:24` → + `00:00:00:23` → …) so the player can see time elapse. 2. Wait for the countdown to hit zero; the SPA reloads and now shows the board. 3. Set `eventEndUtc` in the past; confirm the gate shows diff --git a/docs/guides/event-window.md b/docs/guides/event-window.md index 718641e..0434470 100644 --- a/docs/guides/event-window.md +++ b/docs/guides/event-window.md @@ -3,7 +3,7 @@ type: guide title: Event Window description: How the event window state machine is computed and surfaced via REST + SSE. tags: [guide, event, countdown, sse, state-machine] -timestamp: 2026-07-23T01:19:00Z +timestamp: 2026-07-23T03:09:00Z --- # Configuration @@ -73,7 +73,10 @@ The store: 3. Exposes `countdownText` (computed): `""` for `unconfigured`, `"Event ended"` for `stopped`, and `formatDdHhMm(seconds)` for `countdown` / `running`. The format helper lives in the pure module - `event-status.pure.ts` (`DD:HH:mm`, zero-padded). + `event-status.pure.ts` (`DD:HH:mm:ss`, zero-padded). The seconds + digit is driven by the local 1-second tick (and the SSE re-push + every 60 seconds) so the final minute of the countdown visibly + elapses second-by-second on the shell header. The header (`ShellHeaderComponent`) renders an LED with the `led-{state}` class so the colour reflects the current state (the diff --git a/docs/index.md b/docs/index.md index 5ee4244..8eae156 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-23T02:23:34Z. +they need. Last regenerated 2026-07-23T03:09:36Z. # Architecture diff --git a/frontend/src/app/core/services/event-status.pure.ts b/frontend/src/app/core/services/event-status.pure.ts index 305c5e9..ab9abca 100644 --- a/frontend/src/app/core/services/event-status.pure.ts +++ b/frontend/src/app/core/services/event-status.pure.ts @@ -18,13 +18,14 @@ export interface EventSourceLike { } export function formatDdHhMm(totalSeconds: number): string { - if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00'; + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00:00'; const s = Math.floor(totalSeconds); const days = Math.floor(s / 86400); const hours = Math.floor((s % 86400) / 3600); const minutes = Math.floor((s % 3600) / 60); + const seconds = s % 60; const pad = (n: number) => n.toString().padStart(2, '0'); - return `${pad(days)}:${pad(hours)}:${pad(minutes)}`; + return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; } export function deriveCountdownText( @@ -35,9 +36,9 @@ export function deriveCountdownText( if (state === 'unconfigured') return ''; if (state === 'stopped') return 'Event ended'; if (state === 'countdown') { - return secondsToStart == null ? '00:00:00' : formatDdHhMm(secondsToStart); + return secondsToStart == null ? '00:00:00:00' : formatDdHhMm(secondsToStart); } - return secondsToEnd == null ? '00:00:00' : formatDdHhMm(secondsToEnd); + return secondsToEnd == null ? '00:00:00:00' : formatDdHhMm(secondsToEnd); } export const LED_COLOR_BY_STATE: Readonly> = { diff --git a/frontend/src/app/features/challenges/challenges.pure.ts b/frontend/src/app/features/challenges/challenges.pure.ts index e3eae6c..55ff3c7 100644 --- a/frontend/src/app/features/challenges/challenges.pure.ts +++ b/frontend/src/app/features/challenges/challenges.pure.ts @@ -120,13 +120,14 @@ export function sortColumnsByAbbrev(columns: readonly CategoryColumn[]): Categor } export function formatDdHhMm(totalSeconds: number): string { - if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00'; + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00:00'; const s = Math.floor(totalSeconds); const days = Math.floor(s / 86400); const hours = Math.floor((s % 86400) / 3600); const minutes = Math.floor((s % 3600) / 60); + const seconds = s % 60; const pad = (n: number) => n.toString().padStart(2, '0'); - return `${pad(days)}:${pad(hours)}:${pad(minutes)}`; + return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; } export function formatUtcToLocal(utc: string | null | undefined): string { diff --git a/frontend/src/app/features/challenges/challenges.service.ts b/frontend/src/app/features/challenges/challenges.service.ts index 879d929..bd8584b 100644 --- a/frontend/src/app/features/challenges/challenges.service.ts +++ b/frontend/src/app/features/challenges/challenges.service.ts @@ -43,6 +43,7 @@ export class ChallengesApiService { return this.http .get(`/api/v1/challenges/${encodeURIComponent(challengeId)}`, { withCredentials: true, + params: { include: 'solvers' }, }) .pipe(catchError((err) => throwError(() => toEnvelope(err)))); } diff --git a/tests/frontend/challenge-modal-submit.spec.ts b/tests/frontend/challenge-modal-submit.spec.ts index c613989..91dae17 100644 --- a/tests/frontend/challenge-modal-submit.spec.ts +++ b/tests/frontend/challenge-modal-submit.spec.ts @@ -68,7 +68,7 @@ describe('ChallengeModal — flag submission branches', () => { const wrong = messageForSolveError(parseSolveError(400, { code: 'FLAG_INCORRECT' })); expect(wrong).toBe('Incorrect flag. Try again.'); const awarded = formatDdHhMm(3600); - expect(awarded).toBe('00:01:00'); + expect(awarded).toBe('00:01:00:00'); }); it('second submit yields already_solved without losing the awarded banner', () => { diff --git a/tests/frontend/challenges-page.spec.ts b/tests/frontend/challenges-page.spec.ts index 5cdc259..922eed8 100644 --- a/tests/frontend/challenges-page.spec.ts +++ b/tests/frontend/challenges-page.spec.ts @@ -83,10 +83,10 @@ describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => { it('starts in unconfigured state so the gate panel would render', () => { expect(eventStore.state()).toBe('unconfigured'); - expect(formatDdHhMm(0)).toBe('00:00:00'); + expect(formatDdHhMm(0)).toBe('00:00:00:00'); }); - it('shows live countdown in DD:HH:mm when the admin sets a countdown window', () => { + it('shows live countdown in DD:HH:mm:ss when the admin sets a countdown window', () => { const future = new Date(Date.now() + 90_061_000).toISOString(); const past = new Date(Date.now() - 60_000).toISOString(); eventStore.applyServerStatus({ @@ -99,7 +99,53 @@ describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => { }); expect(eventStore.state()).toBe('countdown'); - expect(eventStore.countdownText()).toBe('01:01:01'); + const text = eventStore.countdownText(); + expect(text).toMatch(/^01:01:01:\d{2}$/); + const [d, h, m, s] = text.split(':').map(Number); + expect(d).toBe(1); + expect(h).toBe(1); + expect(m).toBe(1); + expect(s).toBeGreaterThanOrEqual(0); + expect(s).toBeLessThanOrEqual(2); + }); + + it('formats the final minute second-by-second so the user can see time elapse', () => { + eventStore.applyServerStatus({ + state: 'countdown', + serverNowUtc: new Date().toISOString(), + eventStartUtc: new Date(Date.now() + 24_000).toISOString(), + eventEndUtc: new Date(Date.now() + 7200_000).toISOString(), + secondsToStart: 24, + secondsToEnd: null, + }); + const first = eventStore.countdownText(); + expect(first).toMatch(/^00:00:00:\d{2}$/); + expect(Number(first.split(':')[3])).toBeGreaterThanOrEqual(22); + expect(Number(first.split(':')[3])).toBeLessThanOrEqual(24); + + eventStore.applyServerStatus({ + state: 'countdown', + serverNowUtc: new Date().toISOString(), + eventStartUtc: new Date(Date.now() + 23_000).toISOString(), + eventEndUtc: new Date(Date.now() + 7200_000).toISOString(), + secondsToStart: 23, + secondsToEnd: null, + }); + const second = eventStore.countdownText(); + expect(Number(second.split(':')[3])).toBeGreaterThanOrEqual(21); + expect(Number(second.split(':')[3])).toBeLessThanOrEqual(23); + + eventStore.applyServerStatus({ + state: 'countdown', + serverNowUtc: new Date().toISOString(), + eventStartUtc: new Date(Date.now() + 22_000).toISOString(), + eventEndUtc: new Date(Date.now() + 7200_000).toISOString(), + secondsToStart: 22, + secondsToEnd: null, + }); + const third = eventStore.countdownText(); + expect(Number(third.split(':')[3])).toBeGreaterThanOrEqual(20); + expect(Number(third.split(':')[3])).toBeLessThanOrEqual(22); }); it('single auto-reload fires exactly once when secondsToStart hits zero', () => { diff --git a/tests/frontend/challenges.pure.spec.ts b/tests/frontend/challenges.pure.spec.ts index 27600ec..081f358 100644 --- a/tests/frontend/challenges.pure.spec.ts +++ b/tests/frontend/challenges.pure.spec.ts @@ -90,18 +90,18 @@ describe('sortColumnsByAbbrev', () => { }); describe('formatDdHhMm', () => { - it('formats DD:HH:mm', () => { - expect(formatDdHhMm(0)).toBe('00:00:00'); - expect(formatDdHhMm(59)).toBe('00:00:00'); - expect(formatDdHhMm(60)).toBe('00:00:01'); - expect(formatDdHhMm(3600)).toBe('00:01:00'); - expect(formatDdHhMm(86400)).toBe('01:00:00'); - expect(formatDdHhMm(90061)).toBe('01:01:01'); + it('formats DD:HH:mm:ss', () => { + expect(formatDdHhMm(0)).toBe('00:00:00:00'); + expect(formatDdHhMm(59)).toBe('00:00:00:59'); + expect(formatDdHhMm(60)).toBe('00:00:01:00'); + expect(formatDdHhMm(3600)).toBe('00:01:00:00'); + expect(formatDdHhMm(86400)).toBe('01:00:00:00'); + expect(formatDdHhMm(90061)).toBe('01:01:01:01'); }); - it('returns 00:00:00 for negative or non-finite', () => { - expect(formatDdHhMm(-1)).toBe('00:00:00'); - expect(formatDdHhMm(Number.NaN)).toBe('00:00:00'); - expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00'); + it('returns 00:00:00:00 for negative or non-finite', () => { + expect(formatDdHhMm(-1)).toBe('00:00:00:00'); + expect(formatDdHhMm(Number.NaN)).toBe('00:00:00:00'); + expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00:00'); }); }); diff --git a/tests/frontend/challenges.service.spec.ts b/tests/frontend/challenges.service.spec.ts new file mode 100644 index 0000000..28b3c7d --- /dev/null +++ b/tests/frontend/challenges.service.spec.ts @@ -0,0 +1,107 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injector, runInInjectionContext } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ChallengesApiService } from '../../frontend/src/app/features/challenges/challenges.service'; +import { ChallengeDetail } from '../../frontend/src/app/features/challenges/challenges.pure'; + +interface CapturedRequest { + url: string; + params: HttpParams; + withCredentials: boolean; +} + +function makeDetail(): ChallengeDetail { + return { + challenge: { + id: 'c1', + name: 'alpha', + descriptionMd: '# Alpha', + categoryId: 'cat1', + categoryAbbreviation: 'CRY', + categoryIconPath: '', + difficulty: 'LOW', + livePoints: 90, + solveCount: 1, + solvedByMe: false, + }, + solvers: [ + { + position: 1, + playerId: 'p1', + playerName: 'alice', + solvedAtUtc: '2025-01-01T00:00:00.000Z', + awardedPoints: 100, + basePoints: 100, + rankBonus: 0, + isFirst: true, + isSecond: false, + isThird: false, + }, + ], + }; +} + +class StubHttpClient { + public captured: CapturedRequest | null = null; + + get(url: string, options: { withCredentials?: boolean; params?: HttpParams | Record }): Observable { + this.captured = { + url, + params: + options.params instanceof HttpParams + ? options.params + : new HttpParams({ fromObject: (options.params ?? {}) as Record }), + withCredentials: !!options.withCredentials, + }; + return new Observable((subscriber) => { + subscriber.next(makeDetail() as unknown as T); + subscriber.complete(); + }); + } + + post(_url: string, _body: unknown, _options: unknown): Observable { + return new Observable(); + } +} + +describe('ChallengesApiService.getDetail', () => { + let http: StubHttpClient; + let service: ChallengesApiService; + + beforeEach(() => { + http = new StubHttpClient(); + const injector = Injector.create({ + providers: [ + { provide: HttpClient, useValue: http }, + ChallengesApiService, + ], + }); + service = runInInjectionContext(injector, () => injector.get(ChallengesApiService)); + }); + + it('requests the challenge detail with include=solvers and withCredentials', (done) => { + service.getDetail('c1').subscribe(() => { + try { + expect(http.captured).not.toBeNull(); + expect(http.captured!.url).toBe('/api/v1/challenges/c1'); + expect(http.captured!.params.get('include')).toBe('solvers'); + expect(http.captured!.withCredentials).toBe(true); + done(); + } catch (err) { + done(err); + } + }); + }); + + it('encodes the challenge id in the URL path', (done) => { + service.getDetail('c/with spaces').subscribe(() => { + try { + expect(http.captured!.url).toBe('/api/v1/challenges/c%2Fwith%20spaces'); + expect(http.captured!.params.get('include')).toBe('solvers'); + done(); + } catch (err) { + done(err); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/frontend/event-status.store.spec.ts b/tests/frontend/event-status.store.spec.ts index 6b51113..99c1728 100644 --- a/tests/frontend/event-status.store.spec.ts +++ b/tests/frontend/event-status.store.spec.ts @@ -5,21 +5,21 @@ import { } from '../../frontend/src/app/core/services/event-status.pure'; describe('formatDdHhMm', () => { - it('formats 90_061 seconds as 01:01:01', () => { - expect(formatDdHhMm(90_061)).toBe('01:01:01'); + it('formats 90_061 seconds as 01:01:01:01', () => { + expect(formatDdHhMm(90_061)).toBe('01:01:01:01'); }); - it('formats 800 seconds as 00:00:13', () => { - expect(formatDdHhMm(800)).toBe('00:00:13'); + it('formats 800 seconds as 00:00:13:20', () => { + expect(formatDdHhMm(800)).toBe('00:00:13:20'); }); - it('formats 0 seconds as 00:00:00', () => { - expect(formatDdHhMm(0)).toBe('00:00:00'); + it('formats 0 seconds as 00:00:00:00', () => { + expect(formatDdHhMm(0)).toBe('00:00:00:00'); }); - it('returns 00:00:00 for negative or non-finite values', () => { - expect(formatDdHhMm(-5)).toBe('00:00:00'); - expect(formatDdHhMm(Number.NaN)).toBe('00:00:00'); + it('returns 00:00:00:00 for negative or non-finite values', () => { + expect(formatDdHhMm(-5)).toBe('00:00:00:00'); + expect(formatDdHhMm(Number.NaN)).toBe('00:00:00:00'); }); }); @@ -30,17 +30,17 @@ describe('deriveCountdownText', () => { if (state === 'stopped' || state === 'unconfigured') { expect(deriveCountdownText(state, null, null)).toBe(state === 'unconfigured' ? '' : 'Event ended'); } else { - expect(deriveCountdownText(state, null, null)).toBe('00:00:00'); + expect(deriveCountdownText(state, null, null)).toBe('00:00:00:00'); } }); }); - it('returns DD:HH:mm to start when countdown and secondsToStart provided', () => { - expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00'); + it('returns DD:HH:mm:ss to start when countdown and secondsToStart provided', () => { + expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00:00'); }); - it('returns DD:HH:mm to end when running and secondsToEnd provided', () => { - expect(deriveCountdownText('running', null, 800)).toBe('00:00:13'); + it('returns DD:HH:mm:ss to end when running and secondsToEnd provided', () => { + expect(deriveCountdownText('running', null, 800)).toBe('00:00:13:20'); }); it('returns "Event ended" when stopped', () => {