fix: change GAME_ROUNDS from strings to numbers
This commit is contained in:
parent
7d3c456efe
commit
02ccc88d24
8 changed files with 181 additions and 14 deletions
|
|
@ -60,7 +60,7 @@ const validBody = {
|
||||||
target_language: "it",
|
target_language: "it",
|
||||||
pos: "noun",
|
pos: "noun",
|
||||||
difficulty: "easy",
|
difficulty: "easy",
|
||||||
rounds: "3",
|
rounds: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fakeTerms = [
|
const fakeTerms = [
|
||||||
|
|
@ -177,4 +177,22 @@ describe("POST /api/v1/game/answer", () => {
|
||||||
expect(body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
expect(body.error).toContain("Question not found");
|
expect(body.error).toContain("Question not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 400 when a field has an invalid value", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/v1/game/start")
|
||||||
|
.send({ ...validBody, difficulty: "impossible" });
|
||||||
|
const body = res.body as ErrorResponse;
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when rounds has an invalid value", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/v1/game/start")
|
||||||
|
.send({ ...validBody, rounds: "invalid" });
|
||||||
|
const body = res.body as ErrorResponse;
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(body.success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const validRequest: GameRequest = {
|
||||||
target_language: "it",
|
target_language: "it",
|
||||||
pos: "noun",
|
pos: "noun",
|
||||||
difficulty: "easy",
|
difficulty: "easy",
|
||||||
rounds: "3",
|
rounds: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fakeTerms = [
|
const fakeTerms = [
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export const createGameSession = async (
|
||||||
request.target_language,
|
request.target_language,
|
||||||
request.pos,
|
request.pos,
|
||||||
request.difficulty,
|
request.difficulty,
|
||||||
Number(request.rounds),
|
request.rounds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const answerKey = new Map<string, number>();
|
const answerKey = new Map<string, number>();
|
||||||
|
|
|
||||||
|
|
@ -24,19 +24,19 @@ const LABELS: Record<string, string> = {
|
||||||
|
|
||||||
type GameSetupProps = { onStart: (settings: GameRequest) => void };
|
type GameSetupProps = { onStart: (settings: GameRequest) => void };
|
||||||
|
|
||||||
type SettingGroupProps = {
|
type SettingGroupProps<T extends string | number> = {
|
||||||
label: string;
|
label: string;
|
||||||
options: readonly string[];
|
options: readonly T[];
|
||||||
selected: string;
|
selected: T;
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: T) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingGroup = ({
|
const SettingGroup = <T extends string | number>({
|
||||||
label,
|
label,
|
||||||
options,
|
options,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: SettingGroupProps) => (
|
}: SettingGroupProps<T>) => (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-2">
|
<p className="text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-2">
|
||||||
{label}
|
{label}
|
||||||
|
|
@ -52,7 +52,7 @@ const SettingGroup = ({
|
||||||
: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0"
|
: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{LABELS[option] ?? option}
|
{LABELS[String(option)] ?? option}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -68,7 +68,7 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
|
||||||
);
|
);
|
||||||
const [pos, setPos] = useState<string>(SUPPORTED_POS[0]);
|
const [pos, setPos] = useState<string>(SUPPORTED_POS[0]);
|
||||||
const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]);
|
const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]);
|
||||||
const [rounds, setRounds] = useState<string>(GAME_ROUNDS[0]);
|
const [rounds, setRounds] = useState<number>(GAME_ROUNDS[0]);
|
||||||
|
|
||||||
const handleSourceLanguage = (value: string) => {
|
const handleSourceLanguage = (value: string) => {
|
||||||
if (value === targetLanguage) {
|
if (value === targetLanguage) {
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ export const evaluateAnswer = async (...) => { ... };
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. `rounds` is typed as a string
|
|
||||||
|
|
||||||
**Problem**
|
**Problem**
|
||||||
|
|
||||||
|
|
|
||||||
149
documentation/tickets/t00002.md
Normal file
149
documentation/tickets/t00002.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# ADR: Change GAME_ROUNDS from strings to numbers
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-04-28
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`GAME_ROUNDS` in `packages/shared/src/constants.ts` was typed as `["3", "10"] as const`, making `GameRounds` a string union (`"3" | "10"`). This meant `gameService.ts` had to cast the value with `Number(request.rounds)` deep in business logic — a type conversion happening far from the boundary where data enters the system. The type system was lying: `rounds` was described as a string everywhere but used as a number where it mattered.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Change `GAME_ROUNDS` to `[3, 10] as const` and update the Zod schema to use `z.literal(GAME_ROUNDS)` instead of `z.enum(GAME_ROUNDS)`. The single source of truth remains `constants.ts` — adding a new round count (e.g. `20`) requires only editing that file.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
### Option A — Numbers everywhere ✅
|
||||||
|
|
||||||
|
Change `GAME_ROUNDS` to `[3, 10] as const`. Use `z.literal(GAME_ROUNDS)` in the schema. Update the frontend component state and `SettingGroup` props. Drop `Number()` cast in the service.
|
||||||
|
|
||||||
|
Chosen because: JSON carries numbers natively, both ends of the wire are owned by this codebase, and type conversions belong at the boundary — not inside business logic.
|
||||||
|
|
||||||
|
### Option B — Keep strings, accept the cast
|
||||||
|
|
||||||
|
Leave `GAME_ROUNDS` as `["3", "10"]`. The `Number()` cast stays in `gameService.ts`.
|
||||||
|
|
||||||
|
Rejected because: it pushes type conversion into business logic and makes the inferred `GameRequest` type misleading. The cast has to live somewhere — the schema boundary is the right place.
|
||||||
|
|
||||||
|
### Option C — Coerce at the schema boundary
|
||||||
|
|
||||||
|
Keep `GAME_ROUNDS` as numbers but use `z.coerce.number().pipe(z.literal(GAME_ROUNDS))` so the frontend can keep sending strings.
|
||||||
|
|
||||||
|
Rejected because: coercion is for untrusted or uncontrolled inputs (form fields, query params, third-party clients). We control both ends of the wire. Coercing a self-inflicted type mismatch is treating a wound we gave ourselves.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `GameRounds` is now `3 | 10` instead of `"3" | "10"`
|
||||||
|
- `Number(request.rounds)` cast removed from `gameService.ts`
|
||||||
|
- `SettingGroup` in `GameSetup.tsx` now accepts `string | number` options
|
||||||
|
- `useState<string>` for rounds changed to `useState<number>`
|
||||||
|
- Adding a new round count requires only editing `GAME_ROUNDS` in `constants.ts`
|
||||||
|
- `z.enum` cannot be used for number literals — `z.literal` must be used instead (this is a Zod constraint, not a project convention)
|
||||||
|
|
||||||
|
## Affected files
|
||||||
|
|
||||||
|
- `packages/shared/src/constants.ts`
|
||||||
|
- `packages/shared/src/schemas/game.ts`
|
||||||
|
- `apps/api/src/services/gameService.ts`
|
||||||
|
- `apps/api/src/services/gameService.test.ts`
|
||||||
|
- `apps/api/src/controllers/gameController.test.ts`
|
||||||
|
- `apps/web/src/components/game/GameSetup.tsx`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Zod literals](https://zod.dev/?id=literals)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup guide / implementation notes
|
||||||
|
|
||||||
|
1. In `packages/shared/src/constants.ts`, change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const GAME_ROUNDS = ["3", "10"] as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const GAME_ROUNDS = [3, 10] as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In `packages/shared/src/schemas/game.ts`, change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rounds: z.enum(GAME_ROUNDS),
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rounds: z.literal(GAME_ROUNDS),
|
||||||
|
```
|
||||||
|
|
||||||
|
3. In `apps/api/src/services/gameService.ts`, change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
Number(request.rounds),
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
request.rounds,
|
||||||
|
```
|
||||||
|
|
||||||
|
4. In `apps/api/src/services/gameService.test.ts`, change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rounds: "3",
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rounds: 3,
|
||||||
|
```
|
||||||
|
|
||||||
|
5. In `apps/api/src/controllers/gameController.test.ts`, change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rounds: "3",
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rounds: 3,
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add a pinning test before the refactor:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("returns 400 when rounds has an invalid value", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/v1/game/start")
|
||||||
|
.send({ ...validBody, rounds: "invalid" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
6. In `apps/web/src/components/game/GameSetup.tsx`:
|
||||||
|
- Update `SettingGroup` props to accept `string | number`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SettingGroupProps = {
|
||||||
|
options: readonly (string | number)[];
|
||||||
|
selected: string | number;
|
||||||
|
onSelect: (value: string | number) => void;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- Update `LABELS` lookup to `LABELS[String(option)]`
|
||||||
|
- Change rounds state from `useState<string>` to `useState<number>`
|
||||||
|
|
@ -4,7 +4,7 @@ export type SupportedLanguageCode = (typeof SUPPORTED_LANGUAGE_CODES)[number];
|
||||||
export const SUPPORTED_POS = ["noun", "verb", "adjective", "adverb"] as const;
|
export const SUPPORTED_POS = ["noun", "verb", "adjective", "adverb"] as const;
|
||||||
export type SupportedPos = (typeof SUPPORTED_POS)[number];
|
export type SupportedPos = (typeof SUPPORTED_POS)[number];
|
||||||
|
|
||||||
export const GAME_ROUNDS = ["3", "10"] as const;
|
export const GAME_ROUNDS = [3, 10] as const;
|
||||||
export type GameRounds = (typeof GAME_ROUNDS)[number];
|
export type GameRounds = (typeof GAME_ROUNDS)[number];
|
||||||
|
|
||||||
export const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
export const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export const GameRequestSchema = z.object({
|
||||||
target_language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
target_language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
pos: z.enum(SUPPORTED_POS),
|
pos: z.enum(SUPPORTED_POS),
|
||||||
difficulty: z.enum(DIFFICULTY_LEVELS),
|
difficulty: z.enum(DIFFICULTY_LEVELS),
|
||||||
rounds: z.enum(GAME_ROUNDS),
|
rounds: z.literal(GAME_ROUNDS),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GameRequest = z.infer<typeof GameRequestSchema>;
|
export type GameRequest = z.infer<typeof GameRequestSchema>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue