nilshoehing commited on
Commit
4d7b4ed
·
verified ·
1 Parent(s): 180f0c3

Add explicit first-50 puzzle chooser

Browse files
frontend/src/index.css CHANGED
@@ -131,6 +131,59 @@ a {
131
  color: #13231d;
132
  }
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  .primary-button,
135
  .secondary-button,
136
  .ghost-button {
 
131
  color: #13231d;
132
  }
133
 
134
+ .puzzle-picker {
135
+ display: grid;
136
+ gap: 10px;
137
+ max-height: 360px;
138
+ overflow: auto;
139
+ padding-right: 4px;
140
+ }
141
+
142
+ .puzzle-option {
143
+ display: grid;
144
+ grid-template-columns: auto 44px minmax(0, 1fr);
145
+ gap: 12px;
146
+ align-items: center;
147
+ padding: 14px 16px;
148
+ border: 1px solid rgba(28, 55, 45, 0.12);
149
+ border-radius: 18px;
150
+ background: rgba(255, 255, 255, 0.88);
151
+ transition:
152
+ border-color 140ms ease,
153
+ background 140ms ease,
154
+ transform 140ms ease;
155
+ }
156
+
157
+ .puzzle-option:hover {
158
+ transform: translateY(-1px);
159
+ border-color: rgba(28, 55, 45, 0.22);
160
+ }
161
+
162
+ .puzzle-option.selected {
163
+ border-color: rgba(21, 79, 58, 0.42);
164
+ background: rgba(220, 239, 229, 0.72);
165
+ }
166
+
167
+ .puzzle-option input {
168
+ margin: 0;
169
+ }
170
+
171
+ .puzzle-option-seq {
172
+ font-size: 0.88rem;
173
+ font-weight: 700;
174
+ color: #42685a;
175
+ }
176
+
177
+ .puzzle-option-text {
178
+ display: grid;
179
+ gap: 2px;
180
+ }
181
+
182
+ .puzzle-option-text span {
183
+ color: #46695d;
184
+ font-size: 0.88rem;
185
+ }
186
+
187
  .primary-button,
188
  .secondary-button,
189
  .ghost-button {
frontend/src/lib/api.ts CHANGED
@@ -23,6 +23,15 @@ export type SessionResponse = {
23
  };
24
  };
25
 
 
 
 
 
 
 
 
 
 
26
  export type SubmitResponse = {
27
  solved: boolean;
28
  elapsed_ms: number | null;
@@ -48,6 +57,7 @@ export async function createSession(input: {
48
  player_name: string;
49
  puzzle_type: PuzzleType;
50
  difficulty: Difficulty;
 
51
  }): Promise<SessionResponse> {
52
  return handle<SessionResponse>(
53
  await fetch("/api/sessions", {
@@ -58,6 +68,17 @@ export async function createSession(input: {
58
  );
59
  }
60
 
 
 
 
 
 
 
 
 
 
 
 
61
  export async function fetchSession(sessionId: string): Promise<SessionResponse> {
62
  return handle<SessionResponse>(await fetch(`/api/sessions/${sessionId}`));
63
  }
 
23
  };
24
  };
25
 
26
+ export type PuzzleOption = {
27
+ puzzle_id: string;
28
+ title: string;
29
+ sequence: number;
30
+ puzzle_type: PuzzleType;
31
+ difficulty: Difficulty;
32
+ args: string;
33
+ };
34
+
35
  export type SubmitResponse = {
36
  solved: boolean;
37
  elapsed_ms: number | null;
 
57
  player_name: string;
58
  puzzle_type: PuzzleType;
59
  difficulty: Difficulty;
60
+ puzzle_id: string;
61
  }): Promise<SessionResponse> {
62
  return handle<SessionResponse>(
63
  await fetch("/api/sessions", {
 
68
  );
69
  }
70
 
71
+ export async function fetchPuzzleOptions(
72
+ puzzleType: PuzzleType,
73
+ difficulty: Difficulty,
74
+ ): Promise<PuzzleOption[]> {
75
+ const params = new URLSearchParams({
76
+ puzzle_type: puzzleType,
77
+ difficulty,
78
+ });
79
+ return handle<PuzzleOption[]>(await fetch(`/api/puzzles?${params.toString()}`));
80
+ }
81
+
82
  export async function fetchSession(sessionId: string): Promise<SessionResponse> {
83
  return handle<SessionResponse>(await fetch(`/api/sessions/${sessionId}`));
84
  }
frontend/src/routes/DonePage.tsx CHANGED
@@ -35,6 +35,7 @@ export function DonePage() {
35
  playerName: state?.playerName ?? "",
36
  puzzleType: state?.session?.puzzle_type,
37
  difficulty: state?.session?.difficulty,
 
38
  },
39
  });
40
  }
@@ -51,7 +52,7 @@ export function DonePage() {
51
  </p>
52
  <div className="button-row">
53
  <button className="primary-button" type="button" onClick={playAgain}>
54
- Start Another Random Puzzle
55
  </button>
56
  <button className="ghost-button" type="button" onClick={() => navigate("/")}>
57
  Back Home
 
35
  playerName: state?.playerName ?? "",
36
  puzzleType: state?.session?.puzzle_type,
37
  difficulty: state?.session?.difficulty,
38
+ puzzleId: state?.session?.puzzle_id,
39
  },
40
  });
41
  }
 
52
  </p>
53
  <div className="button-row">
54
  <button className="primary-button" type="button" onClick={playAgain}>
55
+ Choose Another Puzzle
56
  </button>
57
  <button className="ghost-button" type="button" onClick={() => navigate("/")}>
58
  Back Home
frontend/src/routes/HomePage.tsx CHANGED
@@ -1,8 +1,14 @@
1
- import { useState } from "react";
2
  import type { FormEvent } from "react";
3
  import { useLocation, useNavigate } from "react-router-dom";
4
 
5
- import { createSession, type Difficulty, type PuzzleType } from "../lib/api";
 
 
 
 
 
 
6
 
7
  const PUZZLE_OPTIONS: Array<{ value: PuzzleType; label: string; blurb: string }> = [
8
  { value: "bridges", label: "Bridges", blurb: "Connect islands with single or double links." },
@@ -17,14 +23,56 @@ export function HomePage() {
17
  const navigate = useNavigate();
18
  const location = useLocation();
19
  const defaults = (location.state as
20
- | { playerName?: string; puzzleType?: PuzzleType; difficulty?: Difficulty }
21
  | null) ?? { playerName: "", puzzleType: "bridges", difficulty: "easy" };
22
  const [playerName, setPlayerName] = useState(defaults.playerName ?? "");
23
  const [puzzleType, setPuzzleType] = useState(defaults.puzzleType ?? "bridges");
24
  const [difficulty, setDifficulty] = useState(defaults.difficulty ?? "easy");
 
 
 
25
  const [error, setError] = useState<string | null>(null);
26
  const [submitting, setSubmitting] = useState(false);
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
29
  event.preventDefault();
30
  setSubmitting(true);
@@ -34,8 +82,11 @@ export function HomePage() {
34
  player_name: playerName,
35
  puzzle_type: puzzleType,
36
  difficulty,
 
 
 
 
37
  });
38
- navigate(`/play/${session.session_id}`, { state: { session, playerName } });
39
  } catch (caught) {
40
  setError(caught instanceof Error ? caught.message : "Could not create a session.");
41
  } finally {
@@ -50,9 +101,10 @@ export function HomePage() {
50
  <div className="eyebrow">TopoBench Space</div>
51
  <h1>Play the benchmark, not just the report.</h1>
52
  <p>
53
- This Space pulls real TopoBench puzzles from Hugging Face, times each solve
54
- on the server, and verifies your submission against the same native logic
55
- used by the benchmark repo.
 
56
  </p>
57
  </section>
58
 
@@ -99,11 +151,44 @@ export function HomePage() {
99
  </select>
100
  </div>
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  {error ? <div className="status-box error">{error}</div> : null}
103
 
104
  <div className="button-row">
105
- <button className="primary-button" type="submit" disabled={submitting}>
106
- {submitting ? "Starting..." : "Start Puzzle"}
 
 
 
 
107
  </button>
108
  </div>
109
  </form>
 
1
+ import { useEffect, useState } from "react";
2
  import type { FormEvent } from "react";
3
  import { useLocation, useNavigate } from "react-router-dom";
4
 
5
+ import {
6
+ createSession,
7
+ fetchPuzzleOptions,
8
+ type Difficulty,
9
+ type PuzzleOption,
10
+ type PuzzleType,
11
+ } from "../lib/api";
12
 
13
  const PUZZLE_OPTIONS: Array<{ value: PuzzleType; label: string; blurb: string }> = [
14
  { value: "bridges", label: "Bridges", blurb: "Connect islands with single or double links." },
 
23
  const navigate = useNavigate();
24
  const location = useLocation();
25
  const defaults = (location.state as
26
+ | { playerName?: string; puzzleType?: PuzzleType; difficulty?: Difficulty; puzzleId?: string }
27
  | null) ?? { playerName: "", puzzleType: "bridges", difficulty: "easy" };
28
  const [playerName, setPlayerName] = useState(defaults.playerName ?? "");
29
  const [puzzleType, setPuzzleType] = useState(defaults.puzzleType ?? "bridges");
30
  const [difficulty, setDifficulty] = useState(defaults.difficulty ?? "easy");
31
+ const [puzzleOptions, setPuzzleOptions] = useState<PuzzleOption[]>([]);
32
+ const [selectedPuzzleId, setSelectedPuzzleId] = useState(defaults.puzzleId ?? "");
33
+ const [loadingPuzzles, setLoadingPuzzles] = useState(true);
34
  const [error, setError] = useState<string | null>(null);
35
  const [submitting, setSubmitting] = useState(false);
36
 
37
+ useEffect(() => {
38
+ let cancelled = false;
39
+ async function loadPuzzles() {
40
+ setLoadingPuzzles(true);
41
+ setError(null);
42
+ try {
43
+ const options = await fetchPuzzleOptions(puzzleType, difficulty);
44
+ if (cancelled) {
45
+ return;
46
+ }
47
+ setPuzzleOptions(options);
48
+ setSelectedPuzzleId((current) => {
49
+ if (current && options.some((option) => option.puzzle_id === current)) {
50
+ return current;
51
+ }
52
+ const preferred = defaults.puzzleId;
53
+ if (preferred && options.some((option) => option.puzzle_id === preferred)) {
54
+ return preferred;
55
+ }
56
+ return options[0]?.puzzle_id ?? "";
57
+ });
58
+ } catch (caught) {
59
+ if (!cancelled) {
60
+ setPuzzleOptions([]);
61
+ setSelectedPuzzleId("");
62
+ setError(caught instanceof Error ? caught.message : "Could not load puzzle choices.");
63
+ }
64
+ } finally {
65
+ if (!cancelled) {
66
+ setLoadingPuzzles(false);
67
+ }
68
+ }
69
+ }
70
+ void loadPuzzles();
71
+ return () => {
72
+ cancelled = true;
73
+ };
74
+ }, [puzzleType, difficulty, defaults.puzzleId]);
75
+
76
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
77
  event.preventDefault();
78
  setSubmitting(true);
 
82
  player_name: playerName,
83
  puzzle_type: puzzleType,
84
  difficulty,
85
+ puzzle_id: selectedPuzzleId,
86
+ });
87
+ navigate(`/play/${session.session_id}`, {
88
+ state: { session, playerName, puzzleId: selectedPuzzleId },
89
  });
 
90
  } catch (caught) {
91
  setError(caught instanceof Error ? caught.message : "Could not create a session.");
92
  } finally {
 
101
  <div className="eyebrow">TopoBench Space</div>
102
  <h1>Play the benchmark, not just the report.</h1>
103
  <p>
104
+ This Space pulls real TopoBench puzzles from Hugging Face, lets you choose
105
+ intentionally from the first 50 boards in each set, times each solve on the
106
+ server, and verifies your submission against the same native logic used by
107
+ the benchmark repo.
108
  </p>
109
  </section>
110
 
 
151
  </select>
152
  </div>
153
 
154
+ <div className="field">
155
+ <label>Pick one of the first 50 puzzles</label>
156
+ <div className="puzzle-picker">
157
+ {loadingPuzzles ? (
158
+ <div className="status-box">Loading puzzle choices...</div>
159
+ ) : (
160
+ puzzleOptions.map((option) => (
161
+ <label
162
+ key={option.puzzle_id}
163
+ className={`puzzle-option ${selectedPuzzleId === option.puzzle_id ? "selected" : ""}`}
164
+ >
165
+ <input
166
+ type="radio"
167
+ name="puzzleId"
168
+ value={option.puzzle_id}
169
+ checked={selectedPuzzleId === option.puzzle_id}
170
+ onChange={() => setSelectedPuzzleId(option.puzzle_id)}
171
+ />
172
+ <span className="puzzle-option-seq">{option.sequence.toString().padStart(2, "0")}</span>
173
+ <span className="puzzle-option-text">
174
+ <strong>{option.title}</strong>
175
+ <span>{option.args}</span>
176
+ </span>
177
+ </label>
178
+ ))
179
+ )}
180
+ </div>
181
+ </div>
182
+
183
  {error ? <div className="status-box error">{error}</div> : null}
184
 
185
  <div className="button-row">
186
+ <button
187
+ className="primary-button"
188
+ type="submit"
189
+ disabled={submitting || loadingPuzzles || !selectedPuzzleId}
190
+ >
191
+ {submitting ? "Starting..." : "Start Chosen Puzzle"}
192
  </button>
193
  </div>
194
  </form>
frontend/src/routes/PlayPage.tsx CHANGED
@@ -28,6 +28,7 @@ function formatElapsed(ms: number) {
28
  type LocationState = {
29
  session?: SessionResponse;
30
  playerName?: string;
 
31
  };
32
 
33
  export function PlayPage() {
@@ -117,12 +118,13 @@ export function PlayPage() {
117
  }
118
  }
119
 
120
- function newRandom() {
121
  navigate("/", {
122
  state: {
123
  playerName: state?.playerName ?? "",
124
  puzzleType: session?.puzzle_type,
125
  difficulty: session?.difficulty,
 
126
  },
127
  });
128
  }
@@ -184,8 +186,8 @@ export function PlayPage() {
184
  <button className="ghost-button" type="button" onClick={resetBoard}>
185
  Reset Board
186
  </button>
187
- <button className="secondary-button" type="button" onClick={newRandom}>
188
- New Random Puzzle
189
  </button>
190
  </div>
191
 
 
28
  type LocationState = {
29
  session?: SessionResponse;
30
  playerName?: string;
31
+ puzzleId?: string;
32
  };
33
 
34
  export function PlayPage() {
 
118
  }
119
  }
120
 
121
+ function chooseAnother() {
122
  navigate("/", {
123
  state: {
124
  playerName: state?.playerName ?? "",
125
  puzzleType: session?.puzzle_type,
126
  difficulty: session?.difficulty,
127
+ puzzleId: session?.puzzle_id,
128
  },
129
  });
130
  }
 
186
  <button className="ghost-button" type="button" onClick={resetBoard}>
187
  Reset Board
188
  </button>
189
+ <button className="secondary-button" type="button" onClick={chooseAnother}>
190
+ Choose Another Puzzle
191
  </button>
192
  </div>
193
 
space_app/dataset.py CHANGED
@@ -1,6 +1,5 @@
1
  from __future__ import annotations
2
 
3
- import random
4
  from collections import defaultdict
5
  from typing import Iterable
6
 
@@ -11,7 +10,6 @@ from .models import ALLOWED_DIFFICULTIES, ALLOWED_PUZZLES, DatasetRow
11
 
12
  class DatasetStore:
13
  def __init__(self, rows: Iterable[DatasetRow], *, seed: int = 0) -> None:
14
- self._rng = random.Random(seed)
15
  self._index: dict[tuple[str, str], list[DatasetRow]] = defaultdict(list)
16
  self._rows_by_filename: dict[str, DatasetRow] = {}
17
  for row in rows:
@@ -32,20 +30,17 @@ class DatasetStore:
32
  rows.append(DatasetRow.from_payload(row))
33
  return cls(rows)
34
 
35
- def choose_row(
36
  self,
37
  *,
38
  puzzle_type: str,
39
  difficulty: str,
40
- solved_filenames: set[str],
41
- ) -> DatasetRow:
42
  candidates = list(self._index[(puzzle_type, difficulty)])
43
  if not candidates:
44
  raise ValueError(f"No puzzles available for {puzzle_type}:{difficulty}")
45
- unsolved = [row for row in candidates if row.filename not in solved_filenames]
46
- pool = unsolved or candidates
47
- return self._rng.choice(pool)
48
 
49
  def get_row(self, filename: str) -> DatasetRow:
50
  return self._rows_by_filename[filename]
51
-
 
1
  from __future__ import annotations
2
 
 
3
  from collections import defaultdict
4
  from typing import Iterable
5
 
 
10
 
11
  class DatasetStore:
12
  def __init__(self, rows: Iterable[DatasetRow], *, seed: int = 0) -> None:
 
13
  self._index: dict[tuple[str, str], list[DatasetRow]] = defaultdict(list)
14
  self._rows_by_filename: dict[str, DatasetRow] = {}
15
  for row in rows:
 
30
  rows.append(DatasetRow.from_payload(row))
31
  return cls(rows)
32
 
33
+ def list_rows(
34
  self,
35
  *,
36
  puzzle_type: str,
37
  difficulty: str,
38
+ limit: int = 50,
39
+ ) -> list[DatasetRow]:
40
  candidates = list(self._index[(puzzle_type, difficulty)])
41
  if not candidates:
42
  raise ValueError(f"No puzzles available for {puzzle_type}:{difficulty}")
43
+ return candidates[:limit]
 
 
44
 
45
  def get_row(self, filename: str) -> DatasetRow:
46
  return self._rows_by_filename[filename]
 
space_app/main.py CHANGED
@@ -15,6 +15,7 @@ from .db import SessionStore
15
  from .models import engine_for_puzzle, normalize_player_name
16
  from .schemas import (
17
  CreateSessionRequest,
 
18
  SessionPayload,
19
  SessionResponse,
20
  SubmitResponse,
@@ -90,6 +91,32 @@ def create_app(
90
  def health() -> dict[str, str]:
91
  return {"status": "ok"}
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  @app.post("/api/sessions", response_model=SessionResponse)
94
  def create_session(
95
  request: CreateSessionRequest,
@@ -100,16 +127,15 @@ def create_app(
100
  player_name_norm = normalize_player_name(player_name_raw)
101
  if not player_name_norm:
102
  raise HTTPException(status_code=400, detail="Player name is required.")
103
- solved = current_session_store.get_solved_filenames(
104
- player_name_norm=player_name_norm,
105
- puzzle_type=request.puzzle_type,
106
- difficulty=request.difficulty,
107
- )
108
- row = current_dataset_store.choose_row(
109
- puzzle_type=request.puzzle_type,
110
- difficulty=request.difficulty,
111
- solved_filenames=solved,
112
- )
113
  session = current_session_store.create_session(
114
  player_name_raw=player_name_raw,
115
  player_name_norm=player_name_norm,
 
15
  from .models import engine_for_puzzle, normalize_player_name
16
  from .schemas import (
17
  CreateSessionRequest,
18
+ PuzzleOptionResponse,
19
  SessionPayload,
20
  SessionResponse,
21
  SubmitResponse,
 
91
  def health() -> dict[str, str]:
92
  return {"status": "ok"}
93
 
94
+ @app.get("/api/puzzles", response_model=list[PuzzleOptionResponse])
95
+ def list_puzzles(
96
+ puzzle_type: str = Query(...),
97
+ difficulty: str = Query(...),
98
+ current_dataset_store: DatasetStore = Depends(get_dataset_store),
99
+ ) -> list[PuzzleOptionResponse]:
100
+ try:
101
+ rows = current_dataset_store.list_rows(
102
+ puzzle_type=puzzle_type,
103
+ difficulty=difficulty,
104
+ limit=50,
105
+ )
106
+ except ValueError as exc:
107
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
108
+ return [
109
+ PuzzleOptionResponse(
110
+ puzzle_id=row.filename,
111
+ title=row.filename,
112
+ sequence=index,
113
+ puzzle_type=row.puzzlename,
114
+ difficulty=row.difficulty,
115
+ args=row.args,
116
+ )
117
+ for index, row in enumerate(rows, start=1)
118
+ ]
119
+
120
  @app.post("/api/sessions", response_model=SessionResponse)
121
  def create_session(
122
  request: CreateSessionRequest,
 
127
  player_name_norm = normalize_player_name(player_name_raw)
128
  if not player_name_norm:
129
  raise HTTPException(status_code=400, detail="Player name is required.")
130
+ try:
131
+ row = current_dataset_store.get_row(request.puzzle_id)
132
+ except KeyError:
133
+ raise HTTPException(status_code=404, detail="Puzzle not found.") from None
134
+ if row.puzzlename != request.puzzle_type or row.difficulty != request.difficulty:
135
+ raise HTTPException(
136
+ status_code=400,
137
+ detail="Selected puzzle does not match the chosen family and difficulty.",
138
+ )
 
139
  session = current_session_store.create_session(
140
  player_name_raw=player_name_raw,
141
  player_name_norm=player_name_norm,
space_app/schemas.py CHANGED
@@ -11,6 +11,7 @@ class CreateSessionRequest(BaseModel):
11
  player_name: str = Field(min_length=1, max_length=80)
12
  puzzle_type: str
13
  difficulty: str
 
14
 
15
  @field_validator("puzzle_type")
16
  @classmethod
@@ -37,6 +38,15 @@ class SessionPayload(BaseModel):
37
  image_base64: str | None = None
38
 
39
 
 
 
 
 
 
 
 
 
 
40
  class SessionResponse(BaseModel):
41
  session_id: str
42
  engine: str
 
11
  player_name: str = Field(min_length=1, max_length=80)
12
  puzzle_type: str
13
  difficulty: str
14
+ puzzle_id: str = Field(min_length=1)
15
 
16
  @field_validator("puzzle_type")
17
  @classmethod
 
38
  image_base64: str | None = None
39
 
40
 
41
+ class PuzzleOptionResponse(BaseModel):
42
+ puzzle_id: str
43
+ title: str
44
+ sequence: int
45
+ puzzle_type: str
46
+ difficulty: str
47
+ args: str
48
+
49
+
50
  class SessionResponse(BaseModel):
51
  session_id: str
52
  engine: str
tests/test_api.py CHANGED
@@ -64,12 +64,22 @@ class ApiTests(unittest.TestCase):
64
  self.tempdir.cleanup()
65
 
66
  def test_session_lifecycle(self) -> None:
 
 
 
 
 
 
 
 
 
67
  create_response = self.client.post(
68
  "/api/sessions",
69
  json={
70
  "player_name": "Ada Lovelace",
71
  "puzzle_type": "bridges",
72
  "difficulty": "easy",
 
73
  },
74
  )
75
  self.assertEqual(create_response.status_code, 200)
@@ -77,6 +87,29 @@ class ApiTests(unittest.TestCase):
77
  self.assertEqual(session["puzzle_type"], "bridges")
78
  self.assertEqual(session["payload"]["problem_ascii"], "1.1\n...\n1.1")
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  ready_response = self.client.post(f"/api/sessions/{session['session_id']}/ready")
81
  self.assertEqual(ready_response.status_code, 200)
82
  self.assertIsNotNone(ready_response.json()["started_at"])
@@ -99,4 +132,3 @@ class ApiTests(unittest.TestCase):
99
  headers={"Authorization": "Bearer secret-token"},
100
  )
101
  self.assertEqual(authorized.status_code, 200)
102
-
 
64
  self.tempdir.cleanup()
65
 
66
  def test_session_lifecycle(self) -> None:
67
+ list_response = self.client.get(
68
+ "/api/puzzles",
69
+ params={"puzzle_type": "bridges", "difficulty": "easy"},
70
+ )
71
+ self.assertEqual(list_response.status_code, 200)
72
+ options = list_response.json()
73
+ self.assertEqual(len(options), 1)
74
+ self.assertEqual(options[0]["puzzle_id"], "bridges_1.txt")
75
+
76
  create_response = self.client.post(
77
  "/api/sessions",
78
  json={
79
  "player_name": "Ada Lovelace",
80
  "puzzle_type": "bridges",
81
  "difficulty": "easy",
82
+ "puzzle_id": "bridges_1.txt",
83
  },
84
  )
85
  self.assertEqual(create_response.status_code, 200)
 
87
  self.assertEqual(session["puzzle_type"], "bridges")
88
  self.assertEqual(session["payload"]["problem_ascii"], "1.1\n...\n1.1")
89
 
90
+ def test_session_requires_matching_selected_puzzle(self) -> None:
91
+ create_response = self.client.post(
92
+ "/api/sessions",
93
+ json={
94
+ "player_name": "Ada Lovelace",
95
+ "puzzle_type": "flow_free",
96
+ "difficulty": "easy",
97
+ "puzzle_id": "bridges_1.txt",
98
+ },
99
+ )
100
+ self.assertEqual(create_response.status_code, 400)
101
+
102
+ def test_ready_and_submit_lifecycle(self) -> None:
103
+ create_response = self.client.post(
104
+ "/api/sessions",
105
+ json={
106
+ "player_name": "Ada Lovelace",
107
+ "puzzle_type": "bridges",
108
+ "difficulty": "easy",
109
+ "puzzle_id": "bridges_1.txt",
110
+ },
111
+ )
112
+ session = create_response.json()
113
  ready_response = self.client.post(f"/api/sessions/{session['session_id']}/ready")
114
  self.assertEqual(ready_response.status_code, 200)
115
  self.assertIsNotNone(ready_response.json()["started_at"])
 
132
  headers={"Authorization": "Bearer secret-token"},
133
  )
134
  self.assertEqual(authorized.status_code, 200)
 
tests/test_core.py CHANGED
@@ -14,32 +14,26 @@ class CoreTests(unittest.TestCase):
14
  def test_normalize_player_name(self) -> None:
15
  self.assertEqual(normalize_player_name(" Ada Lovelace "), "ada lovelace")
16
 
17
- def test_dataset_store_skips_solved_rows_until_exhausted(self) -> None:
18
  rows = [
19
  DatasetRow(
20
- filename="a.txt",
21
  puzzlename="bridges",
22
  args="5x5de",
23
- problem="1",
24
- solution="1",
25
  difficulty="easy",
26
- ),
27
- DatasetRow(
28
- filename="b.txt",
29
- puzzlename="bridges",
30
- args="5x5de",
31
- problem="2",
32
- solution="2",
33
- difficulty="easy",
34
- ),
35
  ]
36
  store = DatasetStore(rows, seed=0)
37
- chosen = store.choose_row(
38
  puzzle_type="bridges",
39
  difficulty="easy",
40
- solved_filenames={"a.txt"},
41
  )
42
- self.assertEqual(chosen.filename, "b.txt")
 
 
43
 
44
  def test_session_store_records_submission(self) -> None:
45
  with tempfile.TemporaryDirectory() as tempdir:
 
14
  def test_normalize_player_name(self) -> None:
15
  self.assertEqual(normalize_player_name(" Ada Lovelace "), "ada lovelace")
16
 
17
+ def test_dataset_store_lists_first_fifty_in_dataset_order(self) -> None:
18
  rows = [
19
  DatasetRow(
20
+ filename=f"puzzle_{index:02d}.txt",
21
  puzzlename="bridges",
22
  args="5x5de",
23
+ problem=str(index),
24
+ solution=str(index),
25
  difficulty="easy",
26
+ )
27
+ for index in range(60)
 
 
 
 
 
 
 
28
  ]
29
  store = DatasetStore(rows, seed=0)
30
+ listed = store.list_rows(
31
  puzzle_type="bridges",
32
  difficulty="easy",
 
33
  )
34
+ self.assertEqual(len(listed), 50)
35
+ self.assertEqual(listed[0].filename, "puzzle_00.txt")
36
+ self.assertEqual(listed[-1].filename, "puzzle_49.txt")
37
 
38
  def test_session_store_records_submission(self) -> None:
39
  with tempfile.TemporaryDirectory() as tempdir: