feat: Challenges Page and Challenge Solve Modal 1.03
This commit is contained in:
@@ -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).
|
|
||||||
@@ -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.
|
||||||
@@ -18,13 +18,14 @@ export interface EventSourceLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatDdHhMm(totalSeconds: number): string {
|
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 s = Math.floor(totalSeconds);
|
||||||
const days = Math.floor(s / 86400);
|
const days = Math.floor(s / 86400);
|
||||||
const hours = Math.floor((s % 86400) / 3600);
|
const hours = Math.floor((s % 86400) / 3600);
|
||||||
const minutes = Math.floor((s % 3600) / 60);
|
const minutes = Math.floor((s % 3600) / 60);
|
||||||
|
const seconds = s % 60;
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
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(
|
export function deriveCountdownText(
|
||||||
@@ -35,9 +36,9 @@ export function deriveCountdownText(
|
|||||||
if (state === 'unconfigured') return '';
|
if (state === 'unconfigured') return '';
|
||||||
if (state === 'stopped') return 'Event ended';
|
if (state === 'stopped') return 'Event ended';
|
||||||
if (state === 'countdown') {
|
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<Record<EventState, string>> = {
|
export const LED_COLOR_BY_STATE: Readonly<Record<EventState, string>> = {
|
||||||
|
|||||||
@@ -120,13 +120,14 @@ export function sortColumnsByAbbrev(columns: readonly CategoryColumn[]): Categor
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatDdHhMm(totalSeconds: number): string {
|
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 s = Math.floor(totalSeconds);
|
||||||
const days = Math.floor(s / 86400);
|
const days = Math.floor(s / 86400);
|
||||||
const hours = Math.floor((s % 86400) / 3600);
|
const hours = Math.floor((s % 86400) / 3600);
|
||||||
const minutes = Math.floor((s % 3600) / 60);
|
const minutes = Math.floor((s % 3600) / 60);
|
||||||
|
const seconds = s % 60;
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
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 {
|
export function formatUtcToLocal(utc: string | null | undefined): string {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export class ChallengesApiService {
|
|||||||
return this.http
|
return this.http
|
||||||
.get<ChallengeDetail>(`/api/v1/challenges/${encodeURIComponent(challengeId)}`, {
|
.get<ChallengeDetail>(`/api/v1/challenges/${encodeURIComponent(challengeId)}`, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
params: { include: 'solvers' },
|
||||||
})
|
})
|
||||||
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
.pipe(catchError((err) => throwError(() => toEnvelope(err))));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe('ChallengeModal — flag submission branches', () => {
|
|||||||
const wrong = messageForSolveError(parseSolveError(400, { code: 'FLAG_INCORRECT' }));
|
const wrong = messageForSolveError(parseSolveError(400, { code: 'FLAG_INCORRECT' }));
|
||||||
expect(wrong).toBe('Incorrect flag. Try again.');
|
expect(wrong).toBe('Incorrect flag. Try again.');
|
||||||
const awarded = formatDdHhMm(3600);
|
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', () => {
|
it('second submit yields already_solved without losing the awarded banner', () => {
|
||||||
|
|||||||
@@ -83,10 +83,10 @@ describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => {
|
|||||||
|
|
||||||
it('starts in unconfigured state so the gate panel would render', () => {
|
it('starts in unconfigured state so the gate panel would render', () => {
|
||||||
expect(eventStore.state()).toBe('unconfigured');
|
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 future = new Date(Date.now() + 90_061_000).toISOString();
|
||||||
const past = new Date(Date.now() - 60_000).toISOString();
|
const past = new Date(Date.now() - 60_000).toISOString();
|
||||||
eventStore.applyServerStatus({
|
eventStore.applyServerStatus({
|
||||||
@@ -99,7 +99,53 @@ describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(eventStore.state()).toBe('countdown');
|
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', () => {
|
it('single auto-reload fires exactly once when secondsToStart hits zero', () => {
|
||||||
|
|||||||
@@ -90,18 +90,18 @@ describe('sortColumnsByAbbrev', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('formatDdHhMm', () => {
|
describe('formatDdHhMm', () => {
|
||||||
it('formats DD:HH:mm', () => {
|
it('formats DD:HH:mm:ss', () => {
|
||||||
expect(formatDdHhMm(0)).toBe('00:00:00');
|
expect(formatDdHhMm(0)).toBe('00:00:00:00');
|
||||||
expect(formatDdHhMm(59)).toBe('00:00:00');
|
expect(formatDdHhMm(59)).toBe('00:00:00:59');
|
||||||
expect(formatDdHhMm(60)).toBe('00:00:01');
|
expect(formatDdHhMm(60)).toBe('00:00:01:00');
|
||||||
expect(formatDdHhMm(3600)).toBe('00:01:00');
|
expect(formatDdHhMm(3600)).toBe('00:01:00:00');
|
||||||
expect(formatDdHhMm(86400)).toBe('01:00:00');
|
expect(formatDdHhMm(86400)).toBe('01:00:00:00');
|
||||||
expect(formatDdHhMm(90061)).toBe('01:01:01');
|
expect(formatDdHhMm(90061)).toBe('01:01:01:01');
|
||||||
});
|
});
|
||||||
it('returns 00:00:00 for negative or non-finite', () => {
|
it('returns 00:00:00:00 for negative or non-finite', () => {
|
||||||
expect(formatDdHhMm(-1)).toBe('00:00:00');
|
expect(formatDdHhMm(-1)).toBe('00:00:00:00');
|
||||||
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00');
|
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00:00');
|
||||||
expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00');
|
expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00:00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<T>(url: string, options: { withCredentials?: boolean; params?: HttpParams | Record<string, string> }): Observable<T> {
|
||||||
|
this.captured = {
|
||||||
|
url,
|
||||||
|
params:
|
||||||
|
options.params instanceof HttpParams
|
||||||
|
? options.params
|
||||||
|
: new HttpParams({ fromObject: (options.params ?? {}) as Record<string, string> }),
|
||||||
|
withCredentials: !!options.withCredentials,
|
||||||
|
};
|
||||||
|
return new Observable<T>((subscriber) => {
|
||||||
|
subscriber.next(makeDetail() as unknown as T);
|
||||||
|
subscriber.complete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
post<T>(_url: string, _body: unknown, _options: unknown): Observable<T> {
|
||||||
|
return new Observable<T>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,21 +5,21 @@ import {
|
|||||||
} from '../../frontend/src/app/core/services/event-status.pure';
|
} from '../../frontend/src/app/core/services/event-status.pure';
|
||||||
|
|
||||||
describe('formatDdHhMm', () => {
|
describe('formatDdHhMm', () => {
|
||||||
it('formats 90_061 seconds as 01:01:01', () => {
|
it('formats 90_061 seconds as 01:01:01:01', () => {
|
||||||
expect(formatDdHhMm(90_061)).toBe('01:01:01');
|
expect(formatDdHhMm(90_061)).toBe('01:01:01:01');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats 800 seconds as 00:00:13', () => {
|
it('formats 800 seconds as 00:00:13:20', () => {
|
||||||
expect(formatDdHhMm(800)).toBe('00:00:13');
|
expect(formatDdHhMm(800)).toBe('00:00:13:20');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats 0 seconds as 00:00:00', () => {
|
it('formats 0 seconds as 00:00:00:00', () => {
|
||||||
expect(formatDdHhMm(0)).toBe('00:00:00');
|
expect(formatDdHhMm(0)).toBe('00:00:00:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 00:00:00 for negative or non-finite values', () => {
|
it('returns 00:00:00:00 for negative or non-finite values', () => {
|
||||||
expect(formatDdHhMm(-5)).toBe('00:00:00');
|
expect(formatDdHhMm(-5)).toBe('00:00:00:00');
|
||||||
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00');
|
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00:00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,17 +30,17 @@ describe('deriveCountdownText', () => {
|
|||||||
if (state === 'stopped' || state === 'unconfigured') {
|
if (state === 'stopped' || state === 'unconfigured') {
|
||||||
expect(deriveCountdownText(state, null, null)).toBe(state === 'unconfigured' ? '' : 'Event ended');
|
expect(deriveCountdownText(state, null, null)).toBe(state === 'unconfigured' ? '' : 'Event ended');
|
||||||
} else {
|
} 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', () => {
|
it('returns DD:HH:mm:ss to start when countdown and secondsToStart provided', () => {
|
||||||
expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00');
|
expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns DD:HH:mm to end when running and secondsToEnd provided', () => {
|
it('returns DD:HH:mm:ss to end when running and secondsToEnd provided', () => {
|
||||||
expect(deriveCountdownText('running', null, 800)).toBe('00:00:13');
|
expect(deriveCountdownText('running', null, 800)).toBe('00:00:13:20');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns "Event ended" when stopped', () => {
|
it('returns "Event ended" when stopped', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user