oboeru 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oboeru-0.2.0/.gitignore +12 -0
- oboeru-0.2.0/PKG-INFO +58 -0
- oboeru-0.2.0/README.md +48 -0
- oboeru-0.2.0/pyproject.toml +23 -0
- oboeru-0.2.0/src/oboeru/__init__.py +3 -0
- oboeru-0.2.0/src/oboeru/__main__.py +3 -0
- oboeru-0.2.0/src/oboeru/app.py +667 -0
- oboeru-0.2.0/src/oboeru/kanjidic2.sqlite +0 -0
- oboeru-0.2.0/tests/test_quiz_app.py +158 -0
oboeru-0.2.0/.gitignore
ADDED
oboeru-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: oboeru
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Addictive terminal kanji quiz (TUI) on KANJIDIC2 data
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: pillow>=12
|
|
8
|
+
Requires-Dist: textual>=8.2.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# oboeru (覚える)
|
|
12
|
+
|
|
13
|
+
*Oboeru* is Japanese for "to memorize / learn by heart" — which is exactly what
|
|
14
|
+
this terminal kanji quiz helps you do. Fast, keyboard-driven, and just
|
|
15
|
+
addictive enough to keep you coming back.
|
|
16
|
+
|
|
17
|
+
Built with [textual](https://textual.textualize.io/) on
|
|
18
|
+
[KANJIDIC2](https://www.edrdg.org/wiki/index.php/KANJIDIC_Project) data.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- Rounds of 10 multiple-choice "kanji → meaning" questions per JLPT level (N5–N1)
|
|
23
|
+
- Big half-block kanji art (needs a system CJK font; falls back to plain text)
|
|
24
|
+
- Adaptive selection: unseen and previously missed kanji come back more often
|
|
25
|
+
(Leitner boxes), mastered kanji fade away
|
|
26
|
+
- Sessions are saved: per-kanji progress + session history in a small SQLite
|
|
27
|
+
file; the menu shows lifetime accuracy and mastered count
|
|
28
|
+
- Score with streak bonuses 🔥, animations, and a review list after each round
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install oboeru
|
|
34
|
+
# or run directly without installing:
|
|
35
|
+
uvx oboeru
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The KANJIDIC2 database (built by
|
|
39
|
+
[k2sqlite](https://github.com/trygu/k2sqlite)) is bundled with the package,
|
|
40
|
+
so no setup is needed. Pass `--db /path/to/kanjidic2.sqlite` if you have your
|
|
41
|
+
own copy.
|
|
42
|
+
|
|
43
|
+
## Run
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
oboeru
|
|
47
|
+
oboeru --help # all options (--db, --kanji-size, --progress)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Keys: `1-4` or `↑/↓` + `enter` answer, `enter` next question, `q` back/quit.
|
|
51
|
+
|
|
52
|
+
Progress is stored in `~/.oboeru/quiz_progress.sqlite` (or next to a custom
|
|
53
|
+
`--db` database) unless `--progress` says otherwise — your streaks survive
|
|
54
|
+
restarts.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
oboeru-0.2.0/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# oboeru (覚える)
|
|
2
|
+
|
|
3
|
+
*Oboeru* is Japanese for "to memorize / learn by heart" — which is exactly what
|
|
4
|
+
this terminal kanji quiz helps you do. Fast, keyboard-driven, and just
|
|
5
|
+
addictive enough to keep you coming back.
|
|
6
|
+
|
|
7
|
+
Built with [textual](https://textual.textualize.io/) on
|
|
8
|
+
[KANJIDIC2](https://www.edrdg.org/wiki/index.php/KANJIDIC_Project) data.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Rounds of 10 multiple-choice "kanji → meaning" questions per JLPT level (N5–N1)
|
|
13
|
+
- Big half-block kanji art (needs a system CJK font; falls back to plain text)
|
|
14
|
+
- Adaptive selection: unseen and previously missed kanji come back more often
|
|
15
|
+
(Leitner boxes), mastered kanji fade away
|
|
16
|
+
- Sessions are saved: per-kanji progress + session history in a small SQLite
|
|
17
|
+
file; the menu shows lifetime accuracy and mastered count
|
|
18
|
+
- Score with streak bonuses 🔥, animations, and a review list after each round
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install oboeru
|
|
24
|
+
# or run directly without installing:
|
|
25
|
+
uvx oboeru
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The KANJIDIC2 database (built by
|
|
29
|
+
[k2sqlite](https://github.com/trygu/k2sqlite)) is bundled with the package,
|
|
30
|
+
so no setup is needed. Pass `--db /path/to/kanjidic2.sqlite` if you have your
|
|
31
|
+
own copy.
|
|
32
|
+
|
|
33
|
+
## Run
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
oboeru
|
|
37
|
+
oboeru --help # all options (--db, --kanji-size, --progress)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Keys: `1-4` or `↑/↓` + `enter` answer, `enter` next question, `q` back/quit.
|
|
41
|
+
|
|
42
|
+
Progress is stored in `~/.oboeru/quiz_progress.sqlite` (or next to a custom
|
|
43
|
+
`--db` database) unless `--progress` says otherwise — your streaks survive
|
|
44
|
+
restarts.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oboeru"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Addictive terminal kanji quiz (TUI) on KANJIDIC2 data"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"textual>=8.2.8",
|
|
14
|
+
"pillow>=12",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
oboeru = "oboeru.app:main"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build]
|
|
21
|
+
# The KANJIDIC2 database is built into the package before `uv build`
|
|
22
|
+
# (see .github/workflows/oboeru-release.yml); bundle it as package data.
|
|
23
|
+
artifacts = ["src/oboeru/kanjidic2.sqlite"]
|
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""oboeru (覚える) — terminal kanji quiz on a k2sqlite database.
|
|
2
|
+
|
|
3
|
+
Uses the database "app contract": questions come from the kanji_seed view
|
|
4
|
+
(one row per kanji, by JLPT level) and distractors from distractor_pool.
|
|
5
|
+
|
|
6
|
+
Progress (per-kanji correct/wrong + session history) is stored in a separate
|
|
7
|
+
SQLite file, so the app remembers you between runs: unseen and previously
|
|
8
|
+
missed kanji come back more often (Leitner boxes).
|
|
9
|
+
|
|
10
|
+
Run: oboeru [--db /path/to/kanjidic2.sqlite] (defaults to the bundled DB)
|
|
11
|
+
Keys: 1-4 or ↑/↓ + enter answer, enter next, q back/quit.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import random
|
|
18
|
+
import sqlite3
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import ClassVar
|
|
24
|
+
|
|
25
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
from textual.app import App, ComposeResult
|
|
28
|
+
from textual.binding import Binding
|
|
29
|
+
from textual.containers import Horizontal, Vertical
|
|
30
|
+
from textual.screen import Screen
|
|
31
|
+
from textual.widgets import Button, ProgressBar, Static
|
|
32
|
+
|
|
33
|
+
QUESTIONS_PER_ROUND = 10
|
|
34
|
+
MAX_BOX = 4 # Leitner box cap; box >= 3 counts as "mastered"
|
|
35
|
+
LEVEL_NAMES = {5: "N5", 4: "N4", 3: "N3", 2: "N2", 1: "N1"}
|
|
36
|
+
|
|
37
|
+
# First match wins: macOS, Linux (Noto), Windows
|
|
38
|
+
CJK_FONT_PATHS = [
|
|
39
|
+
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
|
40
|
+
"/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
|
41
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
42
|
+
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
|
43
|
+
"C:/Windows/Fonts/msgothic.ttc",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@lru_cache(maxsize=4)
|
|
48
|
+
def _cjk_font(size: int) -> ImageFont.FreeTypeFont | None:
|
|
49
|
+
"""First available system CJK font at the given pixel size, or None."""
|
|
50
|
+
for path in CJK_FONT_PATHS:
|
|
51
|
+
if Path(path).exists():
|
|
52
|
+
return ImageFont.truetype(path, size)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def kanji_art(literal: str, pixels: int = 24, color: str = "#e0af68"):
|
|
57
|
+
"""Render a kanji glyph as big half-block art (▀ ▄ █), two image rows
|
|
58
|
+
per terminal row. Falls back to the plain literal when no CJK font is
|
|
59
|
+
available."""
|
|
60
|
+
font = _cjk_font(pixels * 2)
|
|
61
|
+
if font is None:
|
|
62
|
+
return literal
|
|
63
|
+
# Draw large, crop to the glyph, scale into a square canvas
|
|
64
|
+
img = Image.new("L", (pixels * 4, pixels * 4), 0)
|
|
65
|
+
ImageDraw.Draw(img).text(
|
|
66
|
+
(pixels * 2, pixels * 2), literal, font=font, fill=255, anchor="mm"
|
|
67
|
+
)
|
|
68
|
+
bbox = img.getbbox()
|
|
69
|
+
if bbox is None:
|
|
70
|
+
return literal
|
|
71
|
+
img = img.crop(bbox)
|
|
72
|
+
img.thumbnail((pixels, pixels), Image.LANCZOS)
|
|
73
|
+
canvas = Image.new("L", (pixels, pixels), 0)
|
|
74
|
+
canvas.paste(img, ((pixels - img.width) // 2, (pixels - img.height) // 2))
|
|
75
|
+
# Two vertical pixels per cell: top+bottom=█, top=▀, bottom=▄
|
|
76
|
+
art = Text()
|
|
77
|
+
px = canvas.load()
|
|
78
|
+
for y in range(0, pixels - 1, 2):
|
|
79
|
+
for x in range(pixels):
|
|
80
|
+
top, bottom = px[x, y] > 127, px[x, y + 1] > 127
|
|
81
|
+
if top:
|
|
82
|
+
art.append("█" if bottom else "▀", style=color)
|
|
83
|
+
elif bottom:
|
|
84
|
+
art.append("▄", style=color)
|
|
85
|
+
else:
|
|
86
|
+
art.append(" ")
|
|
87
|
+
if y + 2 < pixels:
|
|
88
|
+
art.append("\n")
|
|
89
|
+
return art
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def nudge(widget, frames: list[tuple[int, int]], step: float = 0.06) -> None:
|
|
93
|
+
"""Frame-based offset animation (slide/shake/bounce).
|
|
94
|
+
|
|
95
|
+
textual's styles.animate() can't blend the offset property, so we step
|
|
96
|
+
through (x, y) frames on a timer instead — simple and version-proof.
|
|
97
|
+
"""
|
|
98
|
+
frames_iter = iter(frames)
|
|
99
|
+
state = {"timer": None}
|
|
100
|
+
|
|
101
|
+
def tick() -> None:
|
|
102
|
+
if not widget.is_mounted:
|
|
103
|
+
state["timer"].stop()
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
widget.styles.offset = next(frames_iter)
|
|
107
|
+
except StopIteration:
|
|
108
|
+
state["timer"].stop()
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
widget.styles.offset = next(frames_iter)
|
|
112
|
+
except StopIteration:
|
|
113
|
+
return
|
|
114
|
+
state["timer"] = widget.set_interval(step, tick)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class Question:
|
|
119
|
+
literal: str
|
|
120
|
+
meaning: str
|
|
121
|
+
on_readings: str
|
|
122
|
+
kun_readings: str
|
|
123
|
+
choices: list[str]
|
|
124
|
+
correct_index: int
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class KanjiDB:
|
|
128
|
+
"""Read-only access to the k2sqlite database via its app contract views."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, path: Path):
|
|
131
|
+
self.conn = sqlite3.connect(path)
|
|
132
|
+
|
|
133
|
+
def levels(self) -> list[tuple[int, int]]:
|
|
134
|
+
"""Available JLPT levels with kanji counts, easiest first."""
|
|
135
|
+
return self.conn.execute(
|
|
136
|
+
"SELECT lvl, COUNT(*) FROM kanji_seed GROUP BY lvl ORDER BY lvl DESC"
|
|
137
|
+
).fetchall()
|
|
138
|
+
|
|
139
|
+
def round_questions(self, lvl: int, n: int, progress: dict) -> list[Question]:
|
|
140
|
+
"""Pick n kanji without replacement, weighted by learning need."""
|
|
141
|
+
rows = self.conn.execute(
|
|
142
|
+
"SELECT literal, main_meaning, on_prime, kun_prime"
|
|
143
|
+
" FROM kanji_seed WHERE lvl=?",
|
|
144
|
+
(lvl,),
|
|
145
|
+
).fetchall()
|
|
146
|
+
pool = [(self._weight(progress.get(row[0])), row) for row in rows]
|
|
147
|
+
picked = []
|
|
148
|
+
for _ in range(min(n, len(pool))):
|
|
149
|
+
hit = random.uniform(0, sum(w for w, _ in pool))
|
|
150
|
+
acc = 0.0
|
|
151
|
+
for i, (w, row) in enumerate(pool):
|
|
152
|
+
acc += w
|
|
153
|
+
if acc >= hit:
|
|
154
|
+
picked.append(row)
|
|
155
|
+
pool.pop(i)
|
|
156
|
+
break
|
|
157
|
+
return [self._make_question(lvl, row) for row in picked]
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _weight(p: tuple[int, int, int] | None) -> float:
|
|
161
|
+
"""Selection weight: unseen > often-wrong > mastered."""
|
|
162
|
+
if p is None:
|
|
163
|
+
return 6.0
|
|
164
|
+
_correct, wrong, box = p
|
|
165
|
+
return (1.0 + 2.0 * wrong) / (1.0 + box) + 0.1
|
|
166
|
+
|
|
167
|
+
def _make_question(self, lvl: int, row) -> Question:
|
|
168
|
+
literal, meaning, on_prime, kun_prime = row
|
|
169
|
+
distractors = [
|
|
170
|
+
d
|
|
171
|
+
for (d,) in self.conn.execute(
|
|
172
|
+
"""SELECT DISTINCT meaning FROM distractor_pool
|
|
173
|
+
WHERE lvl=? AND meaning<>?
|
|
174
|
+
AND meaning NOT IN
|
|
175
|
+
(SELECT meaning FROM kanji_meaning WHERE literal=?)
|
|
176
|
+
ORDER BY RANDOM() LIMIT 3""",
|
|
177
|
+
(lvl, meaning, literal),
|
|
178
|
+
)
|
|
179
|
+
]
|
|
180
|
+
choices = [meaning] + distractors
|
|
181
|
+
random.shuffle(choices)
|
|
182
|
+
return Question(
|
|
183
|
+
literal, meaning, on_prime, kun_prime, choices, choices.index(meaning)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ProgressDB:
|
|
188
|
+
"""Per-kanji progress + session history (separate from the kanji data)."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, path: Path):
|
|
191
|
+
self.conn = sqlite3.connect(path)
|
|
192
|
+
self.conn.executescript("""
|
|
193
|
+
CREATE TABLE IF NOT EXISTS progress(
|
|
194
|
+
literal TEXT PRIMARY KEY,
|
|
195
|
+
correct INTEGER NOT NULL DEFAULT 0,
|
|
196
|
+
wrong INTEGER NOT NULL DEFAULT 0,
|
|
197
|
+
box INTEGER NOT NULL DEFAULT 0,
|
|
198
|
+
last_seen TEXT NOT NULL
|
|
199
|
+
);
|
|
200
|
+
CREATE TABLE IF NOT EXISTS session(
|
|
201
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
202
|
+
played_at TEXT NOT NULL,
|
|
203
|
+
lvl INTEGER NOT NULL,
|
|
204
|
+
questions INTEGER NOT NULL,
|
|
205
|
+
correct INTEGER NOT NULL,
|
|
206
|
+
best_streak INTEGER NOT NULL
|
|
207
|
+
);
|
|
208
|
+
""")
|
|
209
|
+
|
|
210
|
+
def progress_map(self) -> dict[str, tuple[int, int, int]]:
|
|
211
|
+
"""literal -> (correct, wrong, box) for all seen kanji."""
|
|
212
|
+
return {
|
|
213
|
+
lit: (c, w, b)
|
|
214
|
+
for lit, c, w, b in self.conn.execute(
|
|
215
|
+
"SELECT literal, correct, wrong, box FROM progress"
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def record(self, literal: str, ok: bool) -> None:
|
|
220
|
+
row = self.conn.execute(
|
|
221
|
+
"SELECT correct, wrong, box FROM progress WHERE literal=?", (literal,)
|
|
222
|
+
).fetchone()
|
|
223
|
+
correct, wrong, box = row if row else (0, 0, 0)
|
|
224
|
+
if ok:
|
|
225
|
+
correct += 1
|
|
226
|
+
box = min(MAX_BOX, box + 1)
|
|
227
|
+
else:
|
|
228
|
+
wrong += 1
|
|
229
|
+
box = 0 # back to square one (Leitner)
|
|
230
|
+
self.conn.execute(
|
|
231
|
+
"INSERT OR REPLACE INTO progress"
|
|
232
|
+
"(literal, correct, wrong, box, last_seen) VALUES (?,?,?,?,?)",
|
|
233
|
+
(literal, correct, wrong, box, datetime.now(timezone.utc).isoformat()),
|
|
234
|
+
)
|
|
235
|
+
self.conn.commit()
|
|
236
|
+
|
|
237
|
+
def save_session(
|
|
238
|
+
self, lvl: int, questions: int, correct: int, best_streak: int
|
|
239
|
+
) -> None:
|
|
240
|
+
self.conn.execute(
|
|
241
|
+
"INSERT INTO session(played_at, lvl, questions, correct, best_streak)"
|
|
242
|
+
" VALUES (?,?,?,?,?)",
|
|
243
|
+
(
|
|
244
|
+
datetime.now(timezone.utc).isoformat(),
|
|
245
|
+
lvl,
|
|
246
|
+
questions,
|
|
247
|
+
correct,
|
|
248
|
+
best_streak,
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
self.conn.commit()
|
|
252
|
+
|
|
253
|
+
def lifetime(self) -> tuple[int, int, int, int]:
|
|
254
|
+
"""(total answers, total correct, mastered kanji, sessions played)."""
|
|
255
|
+
answers, correct = self.conn.execute(
|
|
256
|
+
"SELECT COALESCE(SUM(correct) + SUM(wrong), 0),"
|
|
257
|
+
" COALESCE(SUM(correct), 0) FROM progress"
|
|
258
|
+
).fetchone()
|
|
259
|
+
mastered = self.conn.execute(
|
|
260
|
+
"SELECT COUNT(*) FROM progress WHERE box >= 3"
|
|
261
|
+
).fetchone()[0]
|
|
262
|
+
sessions = self.conn.execute("SELECT COUNT(*) FROM session").fetchone()[0]
|
|
263
|
+
return answers, correct, mastered, sessions
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class StartScreen(Screen):
|
|
267
|
+
BINDINGS: ClassVar = [Binding("q", "quit_app", "Quit")]
|
|
268
|
+
|
|
269
|
+
TITLE = "K A N J I Q U I Z"
|
|
270
|
+
|
|
271
|
+
def compose(self) -> ComposeResult:
|
|
272
|
+
answers, correct, mastered, sessions = self.app.progress.lifetime()
|
|
273
|
+
acc = f"{100 * correct / answers:.0f}%" if answers else "—"
|
|
274
|
+
yield Vertical(
|
|
275
|
+
Static("漢 字", id="logo"),
|
|
276
|
+
Static("", id="title"),
|
|
277
|
+
Static(
|
|
278
|
+
f"answered {answers} ・ accuracy {acc}"
|
|
279
|
+
f" ・ mastered {mastered} ・ sessions {sessions}",
|
|
280
|
+
id="life",
|
|
281
|
+
),
|
|
282
|
+
Horizontal(
|
|
283
|
+
*[
|
|
284
|
+
Button(f"{LEVEL_NAMES[lvl]}\n{count} kanji", id=f"lvl-{lvl}")
|
|
285
|
+
for lvl, count in self.app.kanji.levels()
|
|
286
|
+
],
|
|
287
|
+
id="levels",
|
|
288
|
+
),
|
|
289
|
+
Static("pick a level — press q to quit", id="hint"),
|
|
290
|
+
id="start",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def on_mount(self) -> None:
|
|
294
|
+
# Typewriter title + gentle entrances
|
|
295
|
+
self._title_len = 0
|
|
296
|
+
self._title_timer = self.set_interval(0.06, self._type_title)
|
|
297
|
+
logo = self.query_one("#logo", Static)
|
|
298
|
+
logo.styles.opacity = 0.0
|
|
299
|
+
logo.styles.animate("opacity", 1.0, duration=0.8)
|
|
300
|
+
for i, btn in enumerate(self.query("#levels Button")):
|
|
301
|
+
btn.styles.opacity = 0.0
|
|
302
|
+
btn.styles.animate("opacity", 1.0, duration=0.3, delay=0.5 + 0.08 * i)
|
|
303
|
+
|
|
304
|
+
def _type_title(self) -> None:
|
|
305
|
+
if not self.is_mounted:
|
|
306
|
+
self._title_timer.stop()
|
|
307
|
+
return
|
|
308
|
+
self._title_len += 1
|
|
309
|
+
self.query_one("#title", Static).update(self.TITLE[: self._title_len])
|
|
310
|
+
if self._title_len >= len(self.TITLE):
|
|
311
|
+
self._title_timer.stop()
|
|
312
|
+
|
|
313
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
314
|
+
if event.button.id and event.button.id.startswith("lvl-"):
|
|
315
|
+
self.app.switch_screen(QuizScreen(int(event.button.id.split("-")[1])))
|
|
316
|
+
|
|
317
|
+
def action_quit_app(self) -> None:
|
|
318
|
+
self.app.exit()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class QuizScreen(Screen):
|
|
322
|
+
BINDINGS: ClassVar = [
|
|
323
|
+
Binding("1", "answer(0)", show=False),
|
|
324
|
+
Binding("2", "answer(1)", show=False),
|
|
325
|
+
Binding("3", "answer(2)", show=False),
|
|
326
|
+
Binding("4", "answer(3)", show=False),
|
|
327
|
+
Binding("up", "app.focus_previous", show=False),
|
|
328
|
+
Binding("down", "app.focus_next", show=False),
|
|
329
|
+
Binding("enter", "next", "Next"),
|
|
330
|
+
Binding("space", "next", show=False),
|
|
331
|
+
Binding("q", "to_levels", "Levels"),
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
def __init__(self, lvl: int):
|
|
335
|
+
super().__init__()
|
|
336
|
+
self.lvl = lvl
|
|
337
|
+
self.questions: list[Question] = []
|
|
338
|
+
self.index = 0
|
|
339
|
+
self.score = 0
|
|
340
|
+
self.streak = 0
|
|
341
|
+
self.best_streak = 0
|
|
342
|
+
self.correct_count = 0
|
|
343
|
+
self.missed: list[Question] = []
|
|
344
|
+
self.answered = False
|
|
345
|
+
|
|
346
|
+
def compose(self) -> ComposeResult:
|
|
347
|
+
yield Vertical(
|
|
348
|
+
Static(id="stats"),
|
|
349
|
+
ProgressBar(total=QUESTIONS_PER_ROUND, show_eta=False, id="bar"),
|
|
350
|
+
Horizontal(
|
|
351
|
+
Vertical(Static(id="kanji"), id="kanji-box"),
|
|
352
|
+
Vertical(
|
|
353
|
+
*[Button(id=f"ans-{i}", classes="answer") for i in range(4)],
|
|
354
|
+
Static(id="feedback", classes="hidden"),
|
|
355
|
+
Static(id="readings", classes="hidden"),
|
|
356
|
+
id="side",
|
|
357
|
+
),
|
|
358
|
+
id="main",
|
|
359
|
+
),
|
|
360
|
+
Static("1-4 / ↑↓ answer ・ enter next ・ q levels", id="quiz-hint"),
|
|
361
|
+
id="quiz",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def on_mount(self) -> None:
|
|
365
|
+
self.questions = self.app.kanji.round_questions(
|
|
366
|
+
self.lvl, QUESTIONS_PER_ROUND, self.app.progress.progress_map()
|
|
367
|
+
)
|
|
368
|
+
self.show_question()
|
|
369
|
+
|
|
370
|
+
def show_question(self) -> None:
|
|
371
|
+
q = self.questions[self.index]
|
|
372
|
+
self.answered = False
|
|
373
|
+
kanji = self.query_one("#kanji", Static)
|
|
374
|
+
kanji.update(kanji_art(q.literal, self.app.kanji_size))
|
|
375
|
+
# Fade + slide the kanji in
|
|
376
|
+
kanji.styles.opacity = 0.0
|
|
377
|
+
kanji.styles.animate("opacity", 1.0, duration=0.25)
|
|
378
|
+
nudge(kanji, [(0, -2), (0, -1), (0, 0)], step=0.05)
|
|
379
|
+
for widget_id in ("#feedback", "#readings"):
|
|
380
|
+
self.query_one(widget_id, Static).add_class("hidden")
|
|
381
|
+
for i, btn in enumerate(self.query(".answer")):
|
|
382
|
+
btn.label = q.choices[i] if i < len(q.choices) else "—"
|
|
383
|
+
btn.disabled = i >= len(q.choices)
|
|
384
|
+
btn.remove_class("correct", "wrong")
|
|
385
|
+
# Staggered entrance
|
|
386
|
+
btn.styles.opacity = 0.0
|
|
387
|
+
btn.styles.animate("opacity", 1.0, duration=0.2, delay=0.08 + 0.05 * i)
|
|
388
|
+
# Pre-focus the first option for keyboard play (↑/↓ + enter)
|
|
389
|
+
self.query_one("#ans-0", Button).focus()
|
|
390
|
+
self.query_one("#bar", ProgressBar).update(progress=self.index)
|
|
391
|
+
self.update_stats()
|
|
392
|
+
|
|
393
|
+
def update_stats(self) -> None:
|
|
394
|
+
flame = f" 🔥 streak {self.streak}" if self.streak >= 2 else ""
|
|
395
|
+
self.query_one("#stats", Static).update(
|
|
396
|
+
f"{LEVEL_NAMES[self.lvl]}"
|
|
397
|
+
f" {self.index + 1}/{len(self.questions)}"
|
|
398
|
+
f" score {self.score}{flame}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def action_answer(self, i: int) -> None:
|
|
402
|
+
if self.answered:
|
|
403
|
+
return
|
|
404
|
+
q = self.questions[self.index]
|
|
405
|
+
if i >= len(q.choices):
|
|
406
|
+
return
|
|
407
|
+
self.answered = True
|
|
408
|
+
ok = i == q.correct_index
|
|
409
|
+
buttons = list(self.query(".answer"))
|
|
410
|
+
for j, btn in enumerate(buttons):
|
|
411
|
+
btn.disabled = True
|
|
412
|
+
if j == q.correct_index:
|
|
413
|
+
btn.add_class("correct")
|
|
414
|
+
# Nothing left to select: drop focus so enter means "next"
|
|
415
|
+
self.app.set_focus(None)
|
|
416
|
+
feedback = self.query_one("#feedback", Static)
|
|
417
|
+
kanji = self.query_one("#kanji", Static)
|
|
418
|
+
if ok:
|
|
419
|
+
self.streak += 1
|
|
420
|
+
self.best_streak = max(self.best_streak, self.streak)
|
|
421
|
+
gained = 10 + 5 * (self.streak - 1)
|
|
422
|
+
self.score += gained
|
|
423
|
+
self.correct_count += 1
|
|
424
|
+
feedback.update(f"✓ Correct! +{gained}")
|
|
425
|
+
feedback.remove_class("bad")
|
|
426
|
+
feedback.add_class("ok")
|
|
427
|
+
if self.streak >= 2:
|
|
428
|
+
# Bounce the stats bar to celebrate the streak
|
|
429
|
+
nudge(
|
|
430
|
+
self.query_one("#stats", Static),
|
|
431
|
+
[(0, -1), (0, 0), (0, -1), (0, 0)],
|
|
432
|
+
step=0.07,
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
buttons[i].add_class("wrong")
|
|
436
|
+
self.streak = 0
|
|
437
|
+
self.missed.append(q)
|
|
438
|
+
feedback.update(f"✗ {q.literal} means “{q.meaning}”")
|
|
439
|
+
feedback.remove_class("ok")
|
|
440
|
+
feedback.add_class("bad")
|
|
441
|
+
# Shake the kanji panel
|
|
442
|
+
nudge(kanji, [(2, 0), (-2, 0), (1, 0), (-1, 0), (0, 0)], step=0.05)
|
|
443
|
+
# Fade the verdict in
|
|
444
|
+
feedback.styles.opacity = 0.0
|
|
445
|
+
feedback.styles.animate("opacity", 1.0, duration=0.2)
|
|
446
|
+
self.app.progress.record(q.literal, ok)
|
|
447
|
+
self.query_one("#readings", Static).update(
|
|
448
|
+
f"on: {q.on_readings or '—'} kun: {q.kun_readings or '—'}"
|
|
449
|
+
" — enter for next →"
|
|
450
|
+
)
|
|
451
|
+
feedback.remove_class("hidden")
|
|
452
|
+
self.query_one("#readings", Static).remove_class("hidden")
|
|
453
|
+
self.update_stats()
|
|
454
|
+
|
|
455
|
+
def action_next(self) -> None:
|
|
456
|
+
if not self.answered:
|
|
457
|
+
return
|
|
458
|
+
self.index += 1
|
|
459
|
+
if self.index >= len(self.questions):
|
|
460
|
+
self.app.progress.save_session(
|
|
461
|
+
self.lvl, len(self.questions), self.correct_count, self.best_streak
|
|
462
|
+
)
|
|
463
|
+
self.app.switch_screen(
|
|
464
|
+
SummaryScreen(
|
|
465
|
+
self.lvl,
|
|
466
|
+
self.correct_count,
|
|
467
|
+
len(self.questions),
|
|
468
|
+
self.best_streak,
|
|
469
|
+
self.score,
|
|
470
|
+
self.missed,
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
else:
|
|
474
|
+
self.show_question()
|
|
475
|
+
|
|
476
|
+
def action_to_levels(self) -> None:
|
|
477
|
+
self.app.switch_screen(StartScreen())
|
|
478
|
+
|
|
479
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
480
|
+
if event.button.id and event.button.id.startswith("ans-"):
|
|
481
|
+
self.action_answer(int(event.button.id.split("-")[1]))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class SummaryScreen(Screen):
|
|
485
|
+
BINDINGS: ClassVar = [
|
|
486
|
+
Binding("enter", "again", "Again"),
|
|
487
|
+
Binding("q", "levels", "Levels"),
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
def __init__(
|
|
491
|
+
self,
|
|
492
|
+
lvl: int,
|
|
493
|
+
correct: int,
|
|
494
|
+
total: int,
|
|
495
|
+
best_streak: int,
|
|
496
|
+
score: int,
|
|
497
|
+
missed: list[Question],
|
|
498
|
+
):
|
|
499
|
+
super().__init__()
|
|
500
|
+
self.lvl = lvl
|
|
501
|
+
self.correct = correct
|
|
502
|
+
self.total = total
|
|
503
|
+
self.best_streak = best_streak
|
|
504
|
+
self.score = score
|
|
505
|
+
self.missed = missed
|
|
506
|
+
|
|
507
|
+
def compose(self) -> ComposeResult:
|
|
508
|
+
rows = [
|
|
509
|
+
Static("R O U N D C O M P L E T E", id="sum-title"),
|
|
510
|
+
Static("", id="sum-stats"),
|
|
511
|
+
]
|
|
512
|
+
if self.missed:
|
|
513
|
+
lines = "\n".join(f" {q.literal} {q.meaning}" for q in self.missed)
|
|
514
|
+
rows.append(Static(f"review these:\n{lines}", id="sum-missed"))
|
|
515
|
+
rows.append(
|
|
516
|
+
Horizontal(
|
|
517
|
+
Button("Again (enter)", id="again"),
|
|
518
|
+
Button("Levels (q)", id="levels"),
|
|
519
|
+
Button("Quit", id="quit"),
|
|
520
|
+
id="sum-buttons",
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
yield Vertical(*rows, id="summary")
|
|
524
|
+
|
|
525
|
+
def on_mount(self) -> None:
|
|
526
|
+
# Score counts up from zero
|
|
527
|
+
self._shown_score = 0
|
|
528
|
+
self._render_stats()
|
|
529
|
+
if self.score:
|
|
530
|
+
self._score_timer = self.set_interval(0.04, self._tick_score)
|
|
531
|
+
|
|
532
|
+
def _render_stats(self) -> None:
|
|
533
|
+
acc = 100 * self.correct / self.total if self.total else 0
|
|
534
|
+
self.query_one("#sum-stats", Static).update(
|
|
535
|
+
f"{LEVEL_NAMES[self.lvl]} ・ score {self._shown_score} ・ "
|
|
536
|
+
f"✓ {self.correct}/{self.total} ({acc:.0f}%) ・ "
|
|
537
|
+
f"🔥 best streak {self.best_streak}"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
def _tick_score(self) -> None:
|
|
541
|
+
if not self.is_mounted:
|
|
542
|
+
self._score_timer.stop()
|
|
543
|
+
return
|
|
544
|
+
self._shown_score = min(
|
|
545
|
+
self.score, self._shown_score + max(1, self.score // 25)
|
|
546
|
+
)
|
|
547
|
+
self._render_stats()
|
|
548
|
+
if self._shown_score >= self.score:
|
|
549
|
+
self._score_timer.stop()
|
|
550
|
+
|
|
551
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
552
|
+
if event.button.id == "again":
|
|
553
|
+
self.action_again()
|
|
554
|
+
elif event.button.id == "levels":
|
|
555
|
+
self.action_levels()
|
|
556
|
+
elif event.button.id == "quit":
|
|
557
|
+
self.app.exit()
|
|
558
|
+
|
|
559
|
+
def action_again(self) -> None:
|
|
560
|
+
self.app.switch_screen(QuizScreen(self.lvl))
|
|
561
|
+
|
|
562
|
+
def action_levels(self) -> None:
|
|
563
|
+
self.app.switch_screen(StartScreen())
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class KanjiQuizApp(App):
|
|
567
|
+
CSS = """
|
|
568
|
+
Screen { background: #1a1b26; color: #c0caf5; }
|
|
569
|
+
#start, #quiz, #summary { align: center middle; }
|
|
570
|
+
|
|
571
|
+
#logo { text-align: center; color: #e0af68; text-style: bold; }
|
|
572
|
+
#title { text-align: center; color: #7aa2f7; text-style: bold; }
|
|
573
|
+
#life { text-align: center; color: #9ece6a; margin: 1 0; }
|
|
574
|
+
#levels { height: auto; align: center middle; }
|
|
575
|
+
#levels Button { width: 16; margin: 0 1; }
|
|
576
|
+
#hint { text-align: center; color: #565f89; margin-top: 2; }
|
|
577
|
+
|
|
578
|
+
#stats { text-align: center; color: #e0af68; text-style: bold; }
|
|
579
|
+
#bar { width: 60; margin: 0 0 1 0; }
|
|
580
|
+
#main { height: auto; align: center middle; }
|
|
581
|
+
#kanji-box { height: auto; margin: 0 1; }
|
|
582
|
+
#kanji {
|
|
583
|
+
border: heavy #7aa2f7;
|
|
584
|
+
background: #24283b;
|
|
585
|
+
padding: 1 3;
|
|
586
|
+
text-align: center;
|
|
587
|
+
text-style: bold;
|
|
588
|
+
}
|
|
589
|
+
#side { height: auto; margin: 0 1; }
|
|
590
|
+
.answer { width: 44; margin-bottom: 1; }
|
|
591
|
+
.answer:focus { background: #414868; text-style: bold; }
|
|
592
|
+
.answer.correct { background: #9ece6a; color: #1a1b26; text-style: bold; }
|
|
593
|
+
.answer.wrong { background: #f7768e; color: #1a1b26; text-style: bold; }
|
|
594
|
+
#quiz-hint { text-align: center; color: #565f89; margin-top: 1; }
|
|
595
|
+
#feedback { text-style: bold; margin-top: 1; }
|
|
596
|
+
#feedback.ok { color: #9ece6a; }
|
|
597
|
+
#feedback.bad { color: #f7768e; }
|
|
598
|
+
#readings { color: #bb9af7; }
|
|
599
|
+
.hidden { display: none; }
|
|
600
|
+
|
|
601
|
+
#sum-title { text-align: center; color: #7aa2f7; text-style: bold; }
|
|
602
|
+
#sum-stats { text-align: center; color: #e0af68; margin: 1 0; }
|
|
603
|
+
#sum-missed {
|
|
604
|
+
text-align: center;
|
|
605
|
+
color: #f7768e;
|
|
606
|
+
border: round #f7768e;
|
|
607
|
+
padding: 1 3;
|
|
608
|
+
margin: 1 0;
|
|
609
|
+
}
|
|
610
|
+
#sum-buttons { height: auto; align: center middle; margin-top: 1; }
|
|
611
|
+
#sum-buttons Button { margin: 0 1; }
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
def __init__(self, db_path: Path, progress_path: Path, kanji_size: int = 24):
|
|
615
|
+
super().__init__()
|
|
616
|
+
self.kanji = KanjiDB(db_path)
|
|
617
|
+
self.progress = ProgressDB(progress_path)
|
|
618
|
+
self.kanji_size = kanji_size
|
|
619
|
+
|
|
620
|
+
def on_mount(self) -> None:
|
|
621
|
+
self.push_screen(StartScreen())
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def main() -> None:
|
|
625
|
+
parser = argparse.ArgumentParser(
|
|
626
|
+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
627
|
+
)
|
|
628
|
+
parser.add_argument(
|
|
629
|
+
"--db",
|
|
630
|
+
type=Path,
|
|
631
|
+
default=None,
|
|
632
|
+
help="path to kanjidic2.sqlite (default: the bundled database)",
|
|
633
|
+
)
|
|
634
|
+
parser.add_argument(
|
|
635
|
+
"--progress",
|
|
636
|
+
type=Path,
|
|
637
|
+
default=None,
|
|
638
|
+
help="progress database (default: alongside --db)",
|
|
639
|
+
)
|
|
640
|
+
parser.add_argument(
|
|
641
|
+
"--kanji-size",
|
|
642
|
+
type=int,
|
|
643
|
+
default=24,
|
|
644
|
+
help="kanji art size in pixels (terminal rows = size/2; default: 24)",
|
|
645
|
+
)
|
|
646
|
+
args = parser.parse_args()
|
|
647
|
+
bundled = Path(__file__).with_name("kanjidic2.sqlite")
|
|
648
|
+
db = args.db or (bundled if bundled.exists() else Path("output/kanjidic2.sqlite"))
|
|
649
|
+
if not db.exists():
|
|
650
|
+
parser.error(
|
|
651
|
+
f"{db} not found — pass --db (get kanjidic2.sqlite from "
|
|
652
|
+
"github.com/trygu/k2sqlite/releases)"
|
|
653
|
+
)
|
|
654
|
+
if args.progress:
|
|
655
|
+
progress = args.progress
|
|
656
|
+
elif db == bundled:
|
|
657
|
+
# The bundled DB lives in site-packages (read-only), so keep progress
|
|
658
|
+
# in the user's home directory.
|
|
659
|
+
progress = Path.home() / ".oboeru" / "quiz_progress.sqlite"
|
|
660
|
+
progress.parent.mkdir(parents=True, exist_ok=True)
|
|
661
|
+
else:
|
|
662
|
+
progress = db.with_name("quiz_progress.sqlite")
|
|
663
|
+
KanjiQuizApp(db, progress, kanji_size=args.kanji_size).run()
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
if __name__ == "__main__":
|
|
667
|
+
main()
|
|
Binary file
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Tests for the oboeru quiz app.
|
|
2
|
+
|
|
3
|
+
The quiz only depends on the k2sqlite database *contract* (the kanji_seed and
|
|
4
|
+
distractor_pool views), so tests run against a tiny hand-written fixture DB —
|
|
5
|
+
no k2sqlite import needed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sqlite3
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from oboeru import app as quiz
|
|
12
|
+
|
|
13
|
+
FIXTURE_SQL = """
|
|
14
|
+
CREATE TABLE kanji_seed(literal, lvl, freq, grade, main_meaning, on_prime, kun_prime);
|
|
15
|
+
CREATE TABLE distractor_pool(lvl, meaning);
|
|
16
|
+
CREATE TABLE kanji_meaning(literal, lang, meaning);
|
|
17
|
+
INSERT INTO kanji_seed VALUES
|
|
18
|
+
('水', 5, 60, 1, 'water', 'スイ', 'みず'),
|
|
19
|
+
('日', 5, 1, 1, 'day', 'ニチ・ジツ', 'ひ'),
|
|
20
|
+
('韓', 2, 445, 8, 'Korea', 'カン', '');
|
|
21
|
+
INSERT INTO distractor_pool VALUES
|
|
22
|
+
(5, 'water'), (5, 'day'), (5, 'sun'), (5, 'Japan'), (2, 'Korea');
|
|
23
|
+
INSERT INTO kanji_meaning VALUES
|
|
24
|
+
('水', 'en', 'water'),
|
|
25
|
+
('日', 'en', 'day'), ('日', 'en', 'sun'), ('日', 'en', 'Japan'),
|
|
26
|
+
('韓', 'en', 'Korea');
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture()
|
|
31
|
+
def quiz_env(tmp_path):
|
|
32
|
+
db = tmp_path / "kanji.sqlite"
|
|
33
|
+
conn = sqlite3.connect(db)
|
|
34
|
+
conn.executescript(FIXTURE_SQL)
|
|
35
|
+
conn.close()
|
|
36
|
+
return db, tmp_path / "progress.sqlite"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_selection_weights(quiz_env):
|
|
40
|
+
# Unseen kanji beat seen ones; wrong answers beat mastered boxes
|
|
41
|
+
assert quiz.KanjiDB._weight(None) > quiz.KanjiDB._weight((5, 0, 3))
|
|
42
|
+
assert quiz.KanjiDB._weight((0, 3, 0)) > quiz.KanjiDB._weight((3, 0, 3))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_quiz_flow(quiz_env):
|
|
47
|
+
db, progress_path = quiz_env
|
|
48
|
+
app = quiz.KanjiQuizApp(db, progress_path)
|
|
49
|
+
async with app.run_test() as pilot:
|
|
50
|
+
await pilot.pause()
|
|
51
|
+
assert type(app.screen).__name__ == "StartScreen"
|
|
52
|
+
|
|
53
|
+
# Pick N5 (fixture has 水 and 日 at lvl 5)
|
|
54
|
+
await pilot.click("#lvl-5")
|
|
55
|
+
await pilot.pause()
|
|
56
|
+
screen = app.screen
|
|
57
|
+
assert type(screen).__name__ == "QuizScreen"
|
|
58
|
+
assert len(screen.questions) == 2 # min(10, pool size)
|
|
59
|
+
assert len(list(screen.query(".answer"))) == 4
|
|
60
|
+
|
|
61
|
+
# Answer the first question correctly via its button
|
|
62
|
+
q = screen.questions[screen.index]
|
|
63
|
+
await pilot.click(f"#ans-{q.correct_index}")
|
|
64
|
+
await pilot.pause()
|
|
65
|
+
assert screen.correct_count == 1
|
|
66
|
+
assert screen.streak == 1
|
|
67
|
+
assert not screen.query_one("#feedback").has_class("hidden")
|
|
68
|
+
|
|
69
|
+
# Progress was persisted: one correct, zero wrong for that kanji
|
|
70
|
+
row = (
|
|
71
|
+
sqlite3.connect(progress_path)
|
|
72
|
+
.execute(
|
|
73
|
+
"SELECT correct, wrong, box FROM progress WHERE literal=?", (q.literal,)
|
|
74
|
+
)
|
|
75
|
+
.fetchone()
|
|
76
|
+
)
|
|
77
|
+
assert row == (1, 0, 1)
|
|
78
|
+
|
|
79
|
+
# Advance to the next question
|
|
80
|
+
await pilot.press("enter")
|
|
81
|
+
await pilot.pause()
|
|
82
|
+
assert screen.index == 1
|
|
83
|
+
|
|
84
|
+
# Finish the round -> summary screen + session row saved
|
|
85
|
+
q2 = screen.questions[screen.index]
|
|
86
|
+
wrong_index = next(i for i in range(len(q2.choices)) if i != q2.correct_index)
|
|
87
|
+
screen.action_answer(wrong_index)
|
|
88
|
+
await pilot.pause()
|
|
89
|
+
screen.action_next()
|
|
90
|
+
await pilot.pause()
|
|
91
|
+
assert type(app.screen).__name__ == "SummaryScreen"
|
|
92
|
+
sessions = (
|
|
93
|
+
sqlite3.connect(progress_path)
|
|
94
|
+
.execute("SELECT lvl, questions, correct, best_streak FROM session")
|
|
95
|
+
.fetchall()
|
|
96
|
+
)
|
|
97
|
+
assert sessions == [(5, 2, 1, 1)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_arrow_key_navigation(quiz_env):
|
|
102
|
+
db, progress_path = quiz_env
|
|
103
|
+
app = quiz.KanjiQuizApp(db, progress_path)
|
|
104
|
+
async with app.run_test() as pilot:
|
|
105
|
+
await pilot.pause()
|
|
106
|
+
await pilot.click("#lvl-5")
|
|
107
|
+
await pilot.pause()
|
|
108
|
+
screen = app.screen
|
|
109
|
+
|
|
110
|
+
# First option is pre-focused; arrows move the highlight.
|
|
111
|
+
# (disabled buttons are skipped; fixture's 日 question has only 2 choices)
|
|
112
|
+
enabled = [b for b in screen.query(".answer") if not b.disabled]
|
|
113
|
+
assert app.focused is enabled[0]
|
|
114
|
+
await pilot.press("down")
|
|
115
|
+
assert app.focused is enabled[1 % len(enabled)]
|
|
116
|
+
await pilot.press("up")
|
|
117
|
+
assert app.focused is enabled[0]
|
|
118
|
+
# ...and it wraps around
|
|
119
|
+
await pilot.press("up")
|
|
120
|
+
assert app.focused is enabled[-1]
|
|
121
|
+
|
|
122
|
+
# Enter answers the highlighted choice (and does not skip ahead)
|
|
123
|
+
await pilot.press("enter")
|
|
124
|
+
await pilot.pause()
|
|
125
|
+
assert screen.answered
|
|
126
|
+
assert screen.index == 0
|
|
127
|
+
|
|
128
|
+
# After answering, focus is dropped: enter advances to next question
|
|
129
|
+
assert app.focused is None
|
|
130
|
+
await pilot.press("enter")
|
|
131
|
+
await pilot.pause()
|
|
132
|
+
assert screen.index == 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.asyncio
|
|
136
|
+
async def test_round_questions_have_unique_valid_choices(quiz_env):
|
|
137
|
+
db, _ = quiz_env
|
|
138
|
+
kanji = quiz.KanjiDB(db)
|
|
139
|
+
questions = kanji.round_questions(5, 10, {})
|
|
140
|
+
assert len({q.literal for q in questions}) == len(questions) # no repeats
|
|
141
|
+
for q in questions:
|
|
142
|
+
assert q.choices[q.correct_index] == q.meaning
|
|
143
|
+
assert len(set(q.choices)) == len(q.choices) # no duplicate choices
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_kanji_art(quiz_env):
|
|
147
|
+
if quiz._cjk_font(48) is None:
|
|
148
|
+
pytest.skip("no CJK font on this machine")
|
|
149
|
+
art = quiz.kanji_art("水", pixels=24)
|
|
150
|
+
# Big half-block rendering: 12 terminal rows, ink present
|
|
151
|
+
assert len(art.plain.splitlines()) == 12
|
|
152
|
+
assert any(c in art.plain for c in "█▀▄")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_kanji_art_fallback_without_font(quiz_env, monkeypatch):
|
|
156
|
+
monkeypatch.setattr(quiz, "CJK_FONT_PATHS", [])
|
|
157
|
+
quiz._cjk_font.cache_clear()
|
|
158
|
+
assert quiz.kanji_art("水") == "水"
|