meanwhile-cli 0.1.1__tar.gz → 0.1.2__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.
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/.claude-plugin/marketplace.json +1 -1
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/.claude-plugin/plugin.json +1 -1
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/PKG-INFO +9 -2
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/README.md +8 -1
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/plugin.json +1 -1
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/pyproject.toml +1 -1
- meanwhile_cli-0.1.2/src/meanwhile/__init__.py +1 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/app.py +12 -3
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/build_prompt.md +20 -2
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/game.py +21 -4
- meanwhile_cli-0.1.2/src/meanwhile/scores.py +75 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_app.py +16 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_game.py +12 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_plugin_files.py +11 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_scores.py +32 -0
- meanwhile_cli-0.1.1/src/meanwhile/__init__.py +0 -1
- meanwhile_cli-0.1.1/src/meanwhile/scores.py +0 -44
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/.agents/plugins/marketplace.json +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/.gitignore +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/LICENSE +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/commands/meanwhile-build.md +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/hooks/codex-hooks.json +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/hooks/hooks.json +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/hooks/meanwhile-state.sh +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/agents.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/app.tcss +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/cli.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/hooks_cli.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/meanwhile-state.sh +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/pool.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/repo.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/state.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/src/meanwhile/strings.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_agents.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_cli.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_hooks_cli.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_pool.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_repo.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_state.py +0 -0
- {meanwhile_cli-0.1.1 → meanwhile_cli-0.1.2}/tests/test_strings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: meanwhile-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Play a quiz about your own repository while your coding agent works
|
|
5
5
|
Project-URL: Homepage, https://github.com/ismailbayram/meanwhile
|
|
6
6
|
Project-URL: Repository, https://github.com/ismailbayram/meanwhile
|
|
@@ -136,10 +136,17 @@ looks like this:
|
|
|
136
136
|
- **Language:** `--lang` takes a language code, not a name. `tr` and `en` are
|
|
137
137
|
the two the interface speaks; any other code (`de`, `fr`, …) writes the
|
|
138
138
|
questions in that language and keeps the interface in English.
|
|
139
|
+
- **Adding questions:** run the build step again. When a pool already
|
|
140
|
+
exists it keeps every question and adds 30 new ones. Ask your agent for
|
|
141
|
+
"a fresh pool" if you want to replace it instead.
|
|
139
142
|
- **Refreshing:** when the repository is about 50 commits past the pool, the
|
|
140
143
|
pane shows a banner. Run the build step again and commit the new file.
|
|
141
144
|
Nothing is regenerated automatically.
|
|
142
|
-
-
|
|
145
|
+
- **Repeats:** within a round no question repeats. Across rounds, questions
|
|
146
|
+
you have not answered yet come first; once you have answered them all, it
|
|
147
|
+
starts over.
|
|
148
|
+
- Scores and the answered-question list are kept in
|
|
149
|
+
`.meanwhile/scores.json`, which is gitignored.
|
|
143
150
|
|
|
144
151
|
## Commands
|
|
145
152
|
|
|
@@ -121,10 +121,17 @@ looks like this:
|
|
|
121
121
|
- **Language:** `--lang` takes a language code, not a name. `tr` and `en` are
|
|
122
122
|
the two the interface speaks; any other code (`de`, `fr`, …) writes the
|
|
123
123
|
questions in that language and keeps the interface in English.
|
|
124
|
+
- **Adding questions:** run the build step again. When a pool already
|
|
125
|
+
exists it keeps every question and adds 30 new ones. Ask your agent for
|
|
126
|
+
"a fresh pool" if you want to replace it instead.
|
|
124
127
|
- **Refreshing:** when the repository is about 50 commits past the pool, the
|
|
125
128
|
pane shows a banner. Run the build step again and commit the new file.
|
|
126
129
|
Nothing is regenerated automatically.
|
|
127
|
-
-
|
|
130
|
+
- **Repeats:** within a round no question repeats. Across rounds, questions
|
|
131
|
+
you have not answered yet come first; once you have answered them all, it
|
|
132
|
+
starts over.
|
|
133
|
+
- Scores and the answered-question list are kept in
|
|
134
|
+
`.meanwhile/scores.json`, which is gitignored.
|
|
128
135
|
|
|
129
136
|
## Commands
|
|
130
137
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "meanwhile",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"description": "Play a quiz about your own repository while Claude Code is working, so your context stays loaded for the diff review that follows.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Ismail Bayram"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
|
@@ -52,11 +52,16 @@ class MeanwhileApp(App):
|
|
|
52
52
|
self.poll_seconds = poll_seconds
|
|
53
53
|
self.language = language
|
|
54
54
|
self.agent_source = agent_source
|
|
55
|
-
self.session =
|
|
55
|
+
self.session = self._deal()
|
|
56
56
|
self.phase = PLAYING
|
|
57
57
|
self._last_status = "idle"
|
|
58
58
|
self._answered = False
|
|
59
59
|
|
|
60
|
+
def _deal(self) -> game.Session:
|
|
61
|
+
"""A fresh round, questions this player has not answered yet first."""
|
|
62
|
+
seen = scores.load_seen(self.repo_dir) if self.repo_dir is not None else set()
|
|
63
|
+
return game.start_session(self.pool, seen=seen)
|
|
64
|
+
|
|
60
65
|
def _agent_name(self) -> str:
|
|
61
66
|
return agents.label_for(self.agent_source()) or strings.text(self.language, "agent")
|
|
62
67
|
|
|
@@ -120,7 +125,7 @@ class MeanwhileApp(App):
|
|
|
120
125
|
# card on screen rather than reshuffling under the player.
|
|
121
126
|
return
|
|
122
127
|
self.phase = PLAYING
|
|
123
|
-
self.session =
|
|
128
|
+
self.session = self._deal() # re-shuffled: the cards come back
|
|
124
129
|
self._answered = False
|
|
125
130
|
self.query_one("#status").update("")
|
|
126
131
|
self.query_one("#feedback").update("")
|
|
@@ -158,6 +163,10 @@ class MeanwhileApp(App):
|
|
|
158
163
|
if not 0 <= index < len(item.choices):
|
|
159
164
|
return
|
|
160
165
|
self._resolve(game.answer(self.session, index))
|
|
166
|
+
if self.repo_dir is not None:
|
|
167
|
+
scores.mark_seen(
|
|
168
|
+
self.repo_dir, game.item_key(item), {game.item_key(i) for i in self.pool.items}
|
|
169
|
+
)
|
|
161
170
|
|
|
162
171
|
def action_next(self) -> None:
|
|
163
172
|
if self.phase != PLAYING:
|
|
@@ -166,7 +175,7 @@ class MeanwhileApp(App):
|
|
|
166
175
|
# Out of cards. Without hooks no busy edge will ever come to deal
|
|
167
176
|
# another round, so `space` does it: bank this one, start afresh.
|
|
168
177
|
self._record()
|
|
169
|
-
self.session =
|
|
178
|
+
self.session = self._deal()
|
|
170
179
|
self._answered = False
|
|
171
180
|
self.query_one("#feedback").update("")
|
|
172
181
|
self._render()
|
|
@@ -20,9 +20,26 @@ pool's `language` field records exactly that code. `tr` and `en` are the two
|
|
|
20
20
|
the playing interface itself speaks; any other code is still fine — the cards
|
|
21
21
|
get written in that language and the interface stays English.
|
|
22
22
|
|
|
23
|
+
## Extending an existing pool
|
|
24
|
+
|
|
25
|
+
If `.meanwhile/pool.json` already exists, extend it rather than replacing it,
|
|
26
|
+
unless the user asked for a fresh pool:
|
|
27
|
+
|
|
28
|
+
- Keep every existing item exactly as it is — players' progress is keyed on
|
|
29
|
+
the question text, so a reworded question counts as a new one. Only drop
|
|
30
|
+
an item whose `source` file no longer exists.
|
|
31
|
+
- Add 30 new items. None may ask what an existing item already asks; read
|
|
32
|
+
the existing questions first, and look for parts of the repository they
|
|
33
|
+
do not cover yet.
|
|
34
|
+
- Keep the pool's own `language` — it wins over the language named above,
|
|
35
|
+
which is only a default — unless the user asked for another one.
|
|
36
|
+
- Set `builtAt` and `headSha` to today and the current commit.
|
|
37
|
+
|
|
23
38
|
## What to write
|
|
24
39
|
|
|
25
|
-
|
|
40
|
+
For a new pool, write `.meanwhile/pool.json` with 30-50 `quiz` items; when
|
|
41
|
+
extending, keep the same envelope and append to `items`. Use exactly this
|
|
42
|
+
shape:
|
|
26
43
|
|
|
27
44
|
{
|
|
28
45
|
"builtAt": "<today, YYYY-MM-DD>",
|
|
@@ -60,7 +77,8 @@ Rules:
|
|
|
60
77
|
2. Run `uvx meanwhile-cli validate --repo .` (during development, from a
|
|
61
78
|
checkout of this plugin: `uv run meanwhile validate --repo .`) and fix
|
|
62
79
|
anything it reports.
|
|
63
|
-
3. Tell the user the item count
|
|
80
|
+
3. Tell the user the item count — when extending, how many were added and
|
|
81
|
+
how many there are now — and that `.meanwhile/pool.json` is ready to
|
|
64
82
|
commit so their team shares the pool.
|
|
65
83
|
4. Tell the user how to play, in {language} — building the pool starts
|
|
66
84
|
nothing, and no other step says how. Give them the command itself, in a
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
5
6
|
import random
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
|
|
@@ -31,10 +32,26 @@ class Session:
|
|
|
31
32
|
_answered_index: int = field(default=-1, init=False, repr=False, compare=False)
|
|
32
33
|
|
|
33
34
|
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
def item_key(item) -> str:
|
|
36
|
+
"""A stable name for a question, for remembering it was answered.
|
|
37
|
+
|
|
38
|
+
The question text, not the item's position: a rebuilt or extended pool
|
|
39
|
+
reorders items, and a question that survives a rebuild word for word is
|
|
40
|
+
the same question to the player.
|
|
41
|
+
"""
|
|
42
|
+
return hashlib.sha256(item.q.encode("utf-8")).hexdigest()[:16]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def start_session(
|
|
46
|
+
pool: Pool, rng: random.Random | None = None, seen: frozenset[str] | set[str] = frozenset()
|
|
47
|
+
) -> Session:
|
|
48
|
+
"""A shuffled round over the whole pool, questions not in `seen` first."""
|
|
49
|
+
rng = rng or random.Random()
|
|
50
|
+
fresh = [item for item in pool.items if item_key(item) not in seen]
|
|
51
|
+
repeats = [item for item in pool.items if item_key(item) in seen]
|
|
52
|
+
rng.shuffle(fresh)
|
|
53
|
+
rng.shuffle(repeats)
|
|
54
|
+
return Session(items=fresh + repeats)
|
|
38
55
|
|
|
39
56
|
|
|
40
57
|
def finished(session: Session) -> bool:
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Per-developer running totals, and which questions this developer has
|
|
2
|
+
answered. Gitignored: both are local, never shared."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .game import Session
|
|
11
|
+
|
|
12
|
+
SCORES_RELPATH = ".meanwhile/scores.json"
|
|
13
|
+
_ZERO = {"answered": 0, "correct": 0, "best_streak": 0}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _path(repo_dir: str | Path) -> Path:
|
|
17
|
+
return Path(repo_dir) / SCORES_RELPATH
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _read(repo_dir: str | Path) -> dict:
|
|
21
|
+
try:
|
|
22
|
+
data = json.loads(_path(repo_dir).read_text(encoding="utf-8"))
|
|
23
|
+
except (OSError, json.JSONDecodeError):
|
|
24
|
+
return {}
|
|
25
|
+
return data if isinstance(data, dict) else {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write(repo_dir: str | Path, totals: dict, seen: set[str]) -> None:
|
|
29
|
+
path = _path(repo_dir)
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
|
|
32
|
+
tmp.write_text(json.dumps({**totals, "seen": sorted(seen)}, indent=2), encoding="utf-8")
|
|
33
|
+
tmp.replace(path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_scores(repo_dir: str | Path) -> dict:
|
|
37
|
+
data = _read(repo_dir)
|
|
38
|
+
out = dict(_ZERO)
|
|
39
|
+
for key in out:
|
|
40
|
+
value = data.get(key, 0)
|
|
41
|
+
out[key] = value if isinstance(value, int) and value >= 0 else 0
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def record_session(repo_dir: str | Path, session: Session) -> dict:
|
|
46
|
+
totals = load_scores(repo_dir)
|
|
47
|
+
totals["answered"] += session.answered
|
|
48
|
+
totals["correct"] += session.correct
|
|
49
|
+
totals["best_streak"] = max(totals["best_streak"], session.best_streak)
|
|
50
|
+
_write(repo_dir, totals, load_seen(repo_dir))
|
|
51
|
+
return totals
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_seen(repo_dir: str | Path) -> set[str]:
|
|
55
|
+
"""The keys (see `game.item_key`) of the questions answered so far."""
|
|
56
|
+
seen = _read(repo_dir).get("seen", [])
|
|
57
|
+
if not isinstance(seen, list):
|
|
58
|
+
return set()
|
|
59
|
+
return {key for key in seen if isinstance(key, str)}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def mark_seen(repo_dir: str | Path, key: str, pool_keys: set[str]) -> set[str]:
|
|
63
|
+
"""Remember one answered question, and return what is remembered now.
|
|
64
|
+
|
|
65
|
+
Written on every answer rather than at the end of a round: a pane closed
|
|
66
|
+
with its terminal never reaches the end of a round. Once every question in
|
|
67
|
+
the pool has been answered the list starts over, so the next rounds go
|
|
68
|
+
back to a plain shuffle instead of ordering by a list that covers it all.
|
|
69
|
+
Keys of questions no longer in the pool are dropped on the way.
|
|
70
|
+
"""
|
|
71
|
+
seen = (load_seen(repo_dir) & pool_keys) | {key}
|
|
72
|
+
if pool_keys <= seen:
|
|
73
|
+
seen = set()
|
|
74
|
+
_write(repo_dir, load_scores(repo_dir), seen)
|
|
75
|
+
return seen
|
|
@@ -325,6 +325,22 @@ async def test_space_on_an_exhausted_pool_deals_a_fresh_round(tmp_path):
|
|
|
325
325
|
assert saved["answered"] == 1 # the finished round was banked, and only once
|
|
326
326
|
|
|
327
327
|
|
|
328
|
+
async def test_an_answered_question_is_dealt_last_next_time(tmp_path):
|
|
329
|
+
"""Remembered across panes, not just within one: a second pane opened on
|
|
330
|
+
the same repository leads with what the first one never got to."""
|
|
331
|
+
status = {"value": "idle"}
|
|
332
|
+
first = lifecycle_app([QUIZ, NARROW], status, tmp_path)
|
|
333
|
+
async with first.run_test() as pilot:
|
|
334
|
+
await pilot.pause()
|
|
335
|
+
answered = game.current(first.session)
|
|
336
|
+
first.action_choose(1)
|
|
337
|
+
await pilot.pause()
|
|
338
|
+
|
|
339
|
+
for _ in range(10): # the shuffle must never put it first again
|
|
340
|
+
second = lifecycle_app([QUIZ, NARROW], status, tmp_path)
|
|
341
|
+
assert second.session.items[-1] is answered
|
|
342
|
+
|
|
343
|
+
|
|
328
344
|
async def test_quitting_mid_session_banks_it(tmp_path):
|
|
329
345
|
"""Without hooks there is never an idle edge, so `q` is where a session ends."""
|
|
330
346
|
status = {"value": "idle"}
|
|
@@ -84,3 +84,15 @@ def test_answering_a_finished_session_is_rejected():
|
|
|
84
84
|
assert game.finished(session) is True
|
|
85
85
|
with pytest.raises(game.GameError):
|
|
86
86
|
game.answer(session, 1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_unseen_questions_come_before_seen_ones():
|
|
90
|
+
seen = {game.item_key(QUIZ)}
|
|
91
|
+
for seed in range(20): # whatever the shuffle, the seen one is last
|
|
92
|
+
session = game.start_session(make_pool([QUIZ, QUIZ_2]), rng=random.Random(seed), seen=seen)
|
|
93
|
+
assert session.items == [QUIZ_2, QUIZ]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_the_item_key_follows_the_question_text_not_the_position():
|
|
97
|
+
assert game.item_key(QUIZ) == game.item_key(QuizItem(**{**QUIZ.__dict__, "source": "moved.py"}))
|
|
98
|
+
assert game.item_key(QUIZ) != game.item_key(QUIZ_2)
|
|
@@ -102,6 +102,17 @@ def test_the_build_prompt_ends_by_telling_the_user_how_to_play():
|
|
|
102
102
|
assert "same directory" in finish
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def test_the_build_prompt_extends_an_existing_pool_instead_of_replacing_it():
|
|
106
|
+
"""Running the build again is how a player gets more questions. Replacing
|
|
107
|
+
the pool would throw away the questions the team already has, and with
|
|
108
|
+
them every player's record of which ones they answered."""
|
|
109
|
+
text = PROMPT_SOURCE.read_text(encoding="utf-8")
|
|
110
|
+
extend = text.split("## Extending an existing pool")[1].split("##")[0]
|
|
111
|
+
assert "Keep every existing item exactly as it is" in extend
|
|
112
|
+
assert "fresh pool" in extend
|
|
113
|
+
assert "`language`" in extend
|
|
114
|
+
|
|
115
|
+
|
|
105
116
|
def test_the_slash_command_delegates_rather_than_duplicating():
|
|
106
117
|
body = BUILD_COMMAND.read_text(encoding="utf-8")
|
|
107
118
|
assert "build_prompt.md" in body
|
|
@@ -30,3 +30,35 @@ def test_an_unplayed_session_writes_nothing_new(tmp_path):
|
|
|
30
30
|
scores.record_session(tmp_path, Session(items=[], answered=2, correct=1, best_streak=1))
|
|
31
31
|
unchanged = scores.record_session(tmp_path, Session(items=[]))
|
|
32
32
|
assert unchanged == {"answered": 2, "correct": 1, "best_streak": 1}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_seen_questions_are_remembered_across_loads(tmp_path):
|
|
36
|
+
scores.mark_seen(tmp_path, "a", {"a", "b", "c"})
|
|
37
|
+
scores.mark_seen(tmp_path, "b", {"a", "b", "c"})
|
|
38
|
+
assert scores.load_seen(tmp_path) == {"a", "b"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_seen_starts_over_once_the_whole_pool_is_answered(tmp_path):
|
|
42
|
+
scores.mark_seen(tmp_path, "a", {"a", "b"})
|
|
43
|
+
assert scores.mark_seen(tmp_path, "b", {"a", "b"}) == set()
|
|
44
|
+
assert scores.load_seen(tmp_path) == set()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_seen_drops_questions_that_left_the_pool(tmp_path):
|
|
48
|
+
scores.mark_seen(tmp_path, "gone", {"gone", "x", "y"})
|
|
49
|
+
assert scores.mark_seen(tmp_path, "x", {"x", "y"}) == {"x"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_totals_and_seen_do_not_overwrite_each_other(tmp_path):
|
|
53
|
+
scores.mark_seen(tmp_path, "a", {"a", "b"})
|
|
54
|
+
scores.record_session(tmp_path, Session(items=[], answered=1, correct=1, best_streak=1))
|
|
55
|
+
assert scores.load_seen(tmp_path) == {"a"}
|
|
56
|
+
scores.mark_seen(tmp_path, "b", {"a", "b", "c"})
|
|
57
|
+
assert scores.load_scores(tmp_path) == {"answered": 1, "correct": 1, "best_streak": 1}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_a_malformed_seen_list_reads_as_empty(tmp_path):
|
|
61
|
+
(tmp_path / ".meanwhile").mkdir()
|
|
62
|
+
(tmp_path / scores.SCORES_RELPATH).write_text('{"answered": 1, "seen": "nope"}')
|
|
63
|
+
assert scores.load_seen(tmp_path) == set()
|
|
64
|
+
assert scores.load_scores(tmp_path)["answered"] == 1
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.1"
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
"""Per-developer running totals. Gitignored: scores are local, never shared."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from .game import Session
|
|
10
|
-
|
|
11
|
-
SCORES_RELPATH = ".meanwhile/scores.json"
|
|
12
|
-
_ZERO = {"answered": 0, "correct": 0, "best_streak": 0}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _path(repo_dir: str | Path) -> Path:
|
|
16
|
-
return Path(repo_dir) / SCORES_RELPATH
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def load_scores(repo_dir: str | Path) -> dict:
|
|
20
|
-
try:
|
|
21
|
-
data = json.loads(_path(repo_dir).read_text(encoding="utf-8"))
|
|
22
|
-
except (OSError, json.JSONDecodeError):
|
|
23
|
-
return dict(_ZERO)
|
|
24
|
-
if not isinstance(data, dict):
|
|
25
|
-
return dict(_ZERO)
|
|
26
|
-
out = dict(_ZERO)
|
|
27
|
-
for key in out:
|
|
28
|
-
value = data.get(key, 0)
|
|
29
|
-
out[key] = value if isinstance(value, int) and value >= 0 else 0
|
|
30
|
-
return out
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def record_session(repo_dir: str | Path, session: Session) -> dict:
|
|
34
|
-
totals = load_scores(repo_dir)
|
|
35
|
-
totals["answered"] += session.answered
|
|
36
|
-
totals["correct"] += session.correct
|
|
37
|
-
totals["best_streak"] = max(totals["best_streak"], session.best_streak)
|
|
38
|
-
|
|
39
|
-
path = _path(repo_dir)
|
|
40
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
-
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
|
|
42
|
-
tmp.write_text(json.dumps(totals, indent=2), encoding="utf-8")
|
|
43
|
-
tmp.replace(path)
|
|
44
|
-
return totals
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|