14 Commits
Author SHA1 Message Date
Dash 2896b54a69 docs: update the changelog
Lists the changes since 1.1.0.
2026-10-17 19:59:24 +02:00
Dash 9ceda9b873 ui/curses: wait for keys instead of polling
The main loop slept 100 ms whenever no key was pending, so keys took
up to a tick to register and every screen woke ten times a second.
Each view now declares how often it needs idle(): the game view keeps
its 100 ms tick, while menus wait for a key. getch() blocks for that
long, so keys register at once, and over 15 s in the roster menu the
process goes from about 300 wake-ups to 6.
2026-10-17 19:57:28 +02:00
Dash c9bce751ac build: ignore coverage data
Coverage runs leave a .coverage file in the working tree.
2026-10-17 19:54:09 +02:00
Dash 9689372e25 tests: run in parallel with pytest-xdist
pytest now runs with -n auto; pass -n0 to run tests one by one. The
pty harness starts pqcli through subprocess instead of pty.fork(),
which is unsafe in the threaded xdist workers, and the child claims
the pty as its controlling terminal so ^C still raises SIGINT.
2026-10-17 19:53:53 +02:00
Dash d1d7c2d9df tests: play shorter games when comparing
The upstream games ran 10000 tasks and the .pqw comparisons 3000 or
2000 more. Differences so far showed within the first few tasks, so
3000, 1000 and 500 keep the tests sharp at a fraction of the time.
Two .pqw save points that tested nothing new are dropped.
2026-10-17 19:53:51 +02:00
Dash c169102c32 pqcli: reject --load-save 0 and negative numbers
`if args.load_save:` treated 0 as unset, so the menu opened instead,
and a negative number indexed from the end of the roster, so -1
played the last character. Both now list the characters and exit
like any other number out of range.
2026-10-17 19:51:19 +02:00
Dash 60d2c54a88 tests: drive the curses UI in a pseudo-terminal
tests/pty_app.py starts pqcli with pty.fork at a fixed size, with
XDG_CONFIG_HOME in a temporary directory, and feeds its output to a
pyte screen. Tests send keys and poll the screen until it matches,
counting a match once the output pauses or the match has held for a
moment, so they never read a half-drawn frame and need no fixed
sleeps.

The tests cover the roster menu, creating a character (names, races,
classes, rolling and unrolling stats, and every way back), the game
view's panes, focus moves and page scrolling, deleting, importing and
exporting .pqw files, resizing the terminal, the cheat keys, Ctrl+C
and --load-save. They are skipped on Windows.

pyte does not draw the DEC line drawing characters, so the app runs
with NCURSES_NO_UTF8_ACS=1 to draw its boxes with Unicode lines.
2026-10-17 19:46:06 +02:00
Dash f8c370cc2e tests: cover the basic UI
Drive the line-based UI in-process: input() answers come from a list
and the output is captured. The tests walk the main menu through
creating, playing, querying, deleting, importing and exporting
characters, plus bad menu input and the Ctrl+C handler.

The play loop runs on a fake clock that advances a minute per call and
stops when time.sleep has been called a set number of times, so it
levels up and saves on schedule without waiting.

The test helpers move to tests/helpers.py, which now also holds the
input() feeder.
2026-10-17 19:46:05 +02:00
Dash 083dbe01c2 tests: cover the roster and the command line
The roster tests pin down the .old backup rotation and the five minute
periodic save, with a fake clock in place of datetime.

The command line tests call main() in-process with the save and data
paths in a temporary directory, so coverage counts them. They cover
listing, loading, importing and exporting saves, dumping and loading
word lists, and the first-run character creation. The curses UI is
swapped for a stub there so a stray code path fails instead of taking
over the terminal.

--load-save 0 falls through to the menu and --load-save -1 plays the
last character instead of being rejected; both are marked xfail.

A shared fixture restores pytest's SIGINT handler after the UIs
install their own.
2026-10-17 19:46:05 +02:00
Dash 92d4466f6d build: add pyte and measure subprocess coverage
pyte is a VT100 emulator; the curses tests will run pqcli in a
pseudo-terminal and read the rendered screen through it.

Coverage now also measures the pqcli processes the tests start, so the
curses UI and the existing CLI subprocess tests count towards the
report.
2026-10-17 19:46:04 +02:00
Dash dde49cf2dd build: run hooks and tests in GitHub Actions
The lint job runs the prek hooks (ruff and ty) on all files. The test
job runs pytest on Python 3.10 to 3.14 with node available, after
fetching the web version's code for the upstream comparison; the
download is cached, and if it fails those tests skip themselves.
2026-10-17 19:16:02 +02:00
Dash 8d523a4af8 build: update locked packages
xdg-base-dirs 6.0.3 and windows-curses 2.4.2. The development tools
and the ruff hook were already at their latest versions.
2026-10-17 19:15:33 +02:00
Dash 2fef41ca5e pqw: restore state through public methods
The importer assigned private attributes of QuestBook, Inventory and
SpellBook. Each now has a restore() method that sets its state
wholesale, and the importer uses those.
2026-10-17 19:14:46 +02:00
Dash 7573807808 build: drop the unused urwid dependencies
Nothing imports urwid or urwid-readline since the interfaces moved to
plain curses, yet every install pulled both in.
2026-10-17 19:13:32 +02:00
20 changed files with 2269 additions and 91 deletions

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync
- run: uv run prek run --all-files --show-diff-on-failure
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python }}
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/cache@v4
with:
path: ~/.cache/pqcli/pq-web
key: pq-web-${{ hashFiles('tests/upstream/fetch.py') }}
# The upstream comparison tests skip themselves if this fails, for
# example while progressquest.com is down.
- run: uv run python tests/upstream/fetch.py || true
- run: uv run pytest -q
+1
View File
@@ -2,3 +2,4 @@ dist/
build/
__pycache__
.coverage
+12
View File
@@ -2,6 +2,18 @@
## [Unreleased](https://github.com/rr-/pq-cli/compare/1.1.0...main) - ××××-××-××
**Interface**
- Improved the curses interface to react to keys at once, and to stop waking up ten times a second while in menus
- Fixed `--load-save 0` opening the menu and negative numbers playing characters counted from the end of the roster
**Installation**
- Removed the unused urwid and urwid-readline dependencies
**Development**
- Added tests for both interfaces, the roster and the command line
- Added continuous integration on GitHub Actions, running the hooks and the tests on Python 3.10 to 3.14
- Changed the tests to run in parallel
## [1.1.0](https://github.com/rr-/pq-cli/compare/1.0.4...1.1.0) - 2026-10-17
**Gameplay**
+4 -5
View File
@@ -112,7 +112,7 @@ def list_players(roster: Roster, file: TextIO | None = None) -> None:
def bootstrap_first_run(
roster: Roster, args: argparse.Namespace, data: GameData
) -> None:
if roster.players or args.list_saves or args.load_save:
if roster.players or args.list_saves or args.load_save is not None:
return
print("No saved characters found. Starting first-run character creation.")
@@ -193,13 +193,12 @@ def main() -> None:
exit(0)
player: Player | None = None
if args.load_save:
try:
player = list(roster.players)[args.load_save - 1]
except IndexError:
if args.load_save is not None:
if not 1 <= args.load_save <= len(roster.players):
print("Invalid player. Available players:", file=sys.stderr)
list_players(roster, file=sys.stderr)
exit(1)
player = roster.players[args.load_save - 1]
bootstrap_first_run(roster, args, data)
+17
View File
@@ -173,6 +173,11 @@ class QuestBook(SignalMixin):
return self._quests[-1]
return None
def restore(self, act: int, quests: list[str]) -> None:
"""Set the act and quest log wholesale, as when importing a save."""
self._act = act
self._quests = list(quests)
def increment_act(self) -> None:
self._act += 1
self.emit("start_act", self._act)
@@ -210,6 +215,14 @@ class Inventory(SignalMixin):
def __getitem__(self, idx: int) -> InventoryItem:
return self._items[idx]
def restore(self, gold: int, items: list[InventoryItem]) -> None:
"""Set the gold and items wholesale, as when importing a save.
The encumbrance bar is left alone, so callers can restore it too.
"""
self._gold = gold
self._items = list(items)
def add_gold(self, quantity: int) -> None:
logger.info(
"%s %s",
@@ -294,6 +307,10 @@ class SpellBook(SignalMixin):
def __len__(self) -> int:
return len(self._spells)
def restore(self, spells: list[Spell]) -> None:
"""Set the spells wholesale, as when importing a save."""
self._spells = list(spells)
def add(self, spell_name: str, level: int) -> None:
for spell in self._spells:
if spell.name == spell_name:
+8 -7
View File
@@ -241,8 +241,9 @@ def import_game(
player.quest_book.plot_bar.reset(*_bar(game, "PlotBar"))
player.quest_book.quest_bar.reset(*_bar(game, "QuestBar"))
player.quest_book._act = _int(game, "act")
player.quest_book._quests = [str(q) for q in _field(game, "Quests")]
player.quest_book.restore(
_int(game, "act"), [str(q) for q in _field(game, "Quests")]
)
player.quest_book.monster = _parse_monster(
str(game.get("questmonster", ""))
)
@@ -256,13 +257,13 @@ def import_game(
rows = _field(game, "Inventory")
if not rows or rows[0][0] != "Gold":
raise PqwError("field Inventory does not start with Gold")
player.inventory._gold = _int(game, "Inventory", 0, 1)
player.inventory._items = [
InventoryItem(str(row[0]), int(row[1])) for row in rows[1:]
]
player.inventory.restore(
_int(game, "Inventory", 0, 1),
[InventoryItem(str(row[0]), int(row[1])) for row in rows[1:]],
)
player.inventory.encum_bar.reset(*_bar(game, "EncumBar"))
player.spell_book._spells = _parse_spells(game)
player.spell_book.restore(_parse_spells(game))
player.task = _parse_task(game)
player.queue = [_parse_queue_entry(e) for e in _field(game, "queue")]
+9 -4
View File
@@ -2,7 +2,6 @@ import argparse
import curses
import os
import signal
import time
from pathlib import Path
from types import FrameType
@@ -54,7 +53,6 @@ class CursesUserInterface(BaseUserInterface):
super().__init__(roster, player, args, data)
os.environ.setdefault("ESCDELAY", "25")
self._screen = curses.initscr()
self._screen.nodelay(True)
self._screen.keypad(True)
curses.noecho()
curses.curs_set(0)
@@ -87,6 +85,7 @@ class CursesUserInterface(BaseUserInterface):
self._roster_view.on_quit += self._quit
self._view: BaseView = self._roster_view
self._view.start()
self._wait_for_keys()
def run(self) -> None:
try:
@@ -98,8 +97,7 @@ class CursesUserInterface(BaseUserInterface):
while True:
key = self._screen.getch()
if key == curses.ERR:
time.sleep(0.1)
if key == curses.ERR: # the view's idle interval passed
self._view.idle()
continue
@@ -250,6 +248,13 @@ class CursesUserInterface(BaseUserInterface):
self._view.stop()
self._view = view
self._view.start()
self._wait_for_keys()
def _wait_for_keys(self) -> None:
# getch() blocks until a key comes or the view's idle interval
# passes, so menus sit still and keys never wait for a tick.
interval = self._view.idle_interval
self._screen.timeout(-1 if interval is None else interval)
def _create_character(
self,
+3
View File
@@ -2,6 +2,9 @@ import curses
class BaseView:
# How often idle() runs, in milliseconds; None waits for a key.
idle_interval: int | None = None
def __init__(self, screen: curses.window) -> None:
self.screen = screen
@@ -42,6 +42,8 @@ class PageScrollable(Protocol):
class GameView(BaseView):
idle_interval = 100
def __init__(
self,
screen: curses.window,
+16 -3
View File
@@ -21,8 +21,6 @@ classifiers = [
dependencies = [
"xdg-base-dirs>=6.0.0,<7.0.0",
"urwid>=2.1.2,<3.0.0",
"urwid-readline>=0.13,<1.0.0",
]
[project.optional-dependencies]
@@ -39,7 +37,13 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = ["prek>=0.2", "pytest>=8.0", "ty>=0.0.89"]
dev = [
"prek>=0.2",
"pyte>=0.8",
"pytest>=8.0",
"pytest-xdist>=3.0",
"ty>=0.0.89",
]
[tool.ty.src]
# Only the package and its tests; agent worktrees under .claude/ would
@@ -54,3 +58,12 @@ select = ["E", "F", "W", "I", "UP"]
[tool.ruff.lint.isort]
known-first-party = ["pqcli"]
[tool.pytest.ini_options]
# Run tests in parallel; pass -n0 to run them one by one.
addopts = "-n auto"
[tool.coverage.run]
# Also measure the pqcli processes the tests start, such as the curses
# UI driven through a pseudo-terminal.
patch = ["subprocess"]
+12
View File
@@ -1,4 +1,16 @@
import signal
import sys
from collections.abc import Iterator
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent))
@pytest.fixture(autouse=True)
def _restore_sigint() -> Iterator[None]:
"""The UIs install their own SIGINT handler; keep pytest's."""
handler = signal.getsignal(signal.SIGINT)
yield
signal.signal(signal.SIGINT, handler)
+63
View File
@@ -0,0 +1,63 @@
"""Characters, saves and prompts shared by the UI and CLI tests."""
from collections.abc import Iterable
from pathlib import Path
import pytest
from test_pqw import played
from pqcli import pqw
from pqcli.game_data import load_game_data
from pqcli.mechanic import Player
from pqcli.roster import Roster
DATA = load_game_data()
def hero(name: str = "Exportée", ticks: int = 300) -> Player:
"""A character that has played for a while."""
player = played(ticks).player
player.name = name
return player
def save_path(config: Path) -> Path:
"""Where pqcli keeps its roster with XDG_CONFIG_HOME set to config."""
return config / "pqcli" / "save.dat"
def write_roster(config: Path, *players: Player) -> Path:
path = save_path(config)
Roster(path, list(players)).save()
return path
def read_names(config: Path) -> list[str]:
return [player.name for player in Roster.load(save_path(config)).players]
def write_pqw(path: Path, player: Player, *, online: bool = False) -> Path:
"""Write player as a .pqw; online adds fields the import warns about."""
game = pqw.export_game(player, DATA)
if online:
game["online"] = {"realm": "Alpaquil", "passkey": 1}
path.write_text(pqw.encode(game), encoding="ascii")
return path
def feed(monkeypatch: pytest.MonkeyPatch, answers: Iterable[str]) -> None:
"""Answer input() prompts in order; running out is a test failure.
Prompts are echoed so captured output reads like a session.
"""
it = iter(answers)
def fake_input(prompt: str = "") -> str:
try:
answer = next(it)
except StopIteration:
raise AssertionError(f"unexpected prompt {prompt!r}") from None
print(f"{prompt}{answer}")
return answer
monkeypatch.setattr("builtins.input", fake_input)
+244
View File
@@ -0,0 +1,244 @@
"""Run pqcli in a pseudo-terminal and read its screen through pyte.
Only works on POSIX: it needs a pty and the termios ioctls.
"""
import fcntl
import os
import select
import signal
import struct
import subprocess
import sys
import termios
import time
from collections.abc import Callable
from pathlib import Path
import pyte
ROWS = 40
COLS = 120
TIMEOUT = 10.0
# a pause in the output this long means a frame is complete
QUIET = 0.03
# or a match that keeps holding this long while output goes on
STABLE = 0.1
# What the keys send with TERM=xterm-256color once curses has switched
# the keypad to application mode.
ENTER = "\n"
ESC = "\x1b"
TAB = "\t"
BACKSPACE = "\x7f"
CTRL_W = "\x17"
CTRL_T = "\x14"
UP = "\x1bOA"
DOWN = "\x1bOB"
RIGHT = "\x1bOC"
LEFT = "\x1bOD"
PAGE_UP = "\x1b[5~"
PAGE_DOWN = "\x1b[6~"
F5 = "\x1b[15~"
F6 = "\x1b[17~"
F10 = "\x1b[21~"
def _set_size(fd: int, rows: int, cols: int) -> None:
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
class PtyApp:
"""A pqcli process attached to a pty, rendered into a pyte screen."""
def __init__(
self,
config: Path,
*args: str,
rows: int = ROWS,
cols: int = COLS,
cwd: Path | None = None,
) -> None:
self.screen = pyte.Screen(cols, rows)
self._stream = pyte.ByteStream(self.screen)
self.exit_status: int | None = None
self._lines: list[str] | None = None
env = {
**os.environ,
"XDG_CONFIG_HOME": str(config),
"TERM": "xterm-256color",
# draw boxes with Unicode lines; pyte ignores the DEC line
# drawing charset and would show them as letters
"NCURSES_NO_UTF8_ACS": "1",
}
# LINES/COLUMNS would override the pty size seen by curses.
env.pop("LINES", None)
env.pop("COLUMNS", None)
# The child makes the pty its controlling terminal, so that ^C
# raises SIGINT as in a real terminal, then becomes pqcli.
argv = [
sys.executable,
"-c",
"import fcntl, sys, termios, runpy; "
"fcntl.ioctl(0, termios.TIOCSCTTY, 0); "
"sys.argv[0] = 'pqcli'; "
"runpy.run_module('pqcli', run_name='__main__')",
*args,
]
# Popen forks and execs in C, which is safe in the threaded
# pytest-xdist workers, unlike pty.fork().
fd, child_fd = os.openpty()
_set_size(child_fd, rows, cols)
self._process = subprocess.Popen(
argv,
stdin=child_fd,
stdout=child_fd,
stderr=child_fd,
env=env,
cwd=cwd,
start_new_session=True,
)
os.close(child_fd)
self.pid = self._process.pid
self.fd = fd
# reading ---------------------------------------------------------
def _pump(self, timeout: float) -> bool | None:
"""Feed pending output to the screen.
Returns True if output arrived, False if there was none within
timeout, None once the pty is closed.
"""
if self.fd < 0:
return None
ready, _, _ = select.select([self.fd], [], [], timeout)
if not ready:
return False
try:
chunk = os.read(self.fd, 65536)
except OSError: # EIO: the child closed its end
chunk = b""
if not chunk:
os.close(self.fd)
self.fd = -1
return None
self._stream.feed(chunk)
self._lines = None
return True
@property
def lines(self) -> list[str]:
"""The screen's rows; cached, as pyte builds them slowly."""
if self._lines is None:
self._lines = self.screen.display
return self._lines
def screen_text(self) -> str:
return "\n".join(line.rstrip() for line in self.lines)
def wait_until(
self,
predicate: Callable[[str], bool],
timeout: float = TIMEOUT,
what: str = "condition",
) -> str:
"""Wait until predicate holds for a screen that is done drawing.
A frame arrives in several reads, so a match only counts once it
survives a pause of QUIET seconds in the output, or, while the
game view keeps drawing, once it has held for STABLE seconds.
"""
deadline = time.monotonic() + timeout
matched_since: float | None = None
while True:
now = time.monotonic()
if now >= deadline:
raise AssertionError(
f"timed out waiting for {what}; screen:\n"
+ self.screen_text()
)
if not predicate(self.screen_text()):
matched_since = None
elif matched_since is None:
matched_since = now
got = self._pump(
min(deadline - now, 0.05 if matched_since is None else QUIET)
)
text = self.screen_text()
if got is None:
if predicate(text):
return text
raise AssertionError(
f"pqcli exited while waiting for {what}; screen:\n" + text
)
if matched_since is not None and predicate(text):
if not got or time.monotonic() - matched_since >= STABLE:
return text
def wait_for(self, text: str, timeout: float = TIMEOUT) -> str:
return self.wait_until(lambda s: text in s, timeout, repr(text))
def wait_gone(self, text: str, timeout: float = TIMEOUT) -> str:
return self.wait_until(
lambda s: text not in s, timeout, f"{text!r} to go away"
)
def row_of(self, text: str) -> int:
for y, line in enumerate(self.lines):
if text in line:
return y
raise AssertionError(f"{text!r} not on screen:\n{self.screen_text()}")
def is_reverse(self, text: str) -> bool:
"""Whether the first cell of text on screen is drawn highlighted."""
y = self.row_of(text)
x = self.lines[y].index(text)
return bool(self.screen.buffer[y][x].reverse)
# writing ---------------------------------------------------------
def send(self, keys: str) -> None:
os.write(self.fd, keys.encode())
def resize(self, rows: int, cols: int) -> None:
self.screen.resize(rows, cols)
self._lines = None
_set_size(self.fd, rows, cols)
os.kill(self.pid, signal.SIGWINCH)
# lifetime --------------------------------------------------------
def wait_exit(self, timeout: float = TIMEOUT) -> int:
"""Wait for the process to end and return its exit code."""
deadline = time.monotonic() + timeout
while self.exit_status is None:
# keep draining so the child never blocks on a full pty
self._pump(0.02)
status = self._process.poll()
if status is not None:
self.exit_status = status
break
if time.monotonic() > deadline:
raise AssertionError(
f"pqcli did not exit; screen:\n{self.screen_text()}"
)
while self._pump(0) is not None:
pass
return self.exit_status
def close(self) -> None:
if self.exit_status is None:
# SIGINT runs the app's own handler, so it exits cleanly and
# coverage gets to write its data.
os.kill(self.pid, signal.SIGINT)
try:
self.wait_exit(5)
except AssertionError:
os.kill(self.pid, signal.SIGKILL)
self.wait_exit(5)
if self.fd >= 0:
os.close(self.fd)
self.fd = -1
+469
View File
@@ -0,0 +1,469 @@
"""The basic line-based UI, driven in-process through input()."""
import argparse
import signal
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from helpers import DATA, feed, hero, read_names, save_path, write_pqw
from test_pqw import played
from pqcli import pqw
from pqcli import roster as roster_module
from pqcli.config import PRIME_STATS
from pqcli.lingo import to_roman
from pqcli.mechanic import Player
from pqcli.roster import Roster
from pqcli.ui import basic
from pqcli.ui.basic import BasicUserInterface
def make_ui(
config: Path, *players: Player, use_saves: bool = True
) -> BasicUserInterface:
args = argparse.Namespace(use_saves=use_saves, cheats=False, colors=True)
roster = Roster(save_path(config), list(players))
return BasicUserInterface(roster, None, args, DATA)
@pytest.fixture
def no_play(monkeypatch: pytest.MonkeyPatch) -> list[Player]:
"""Make play() record the character instead of looping forever."""
played: list[Player] = []
monkeypatch.setattr(
BasicUserInterface, "play", lambda self, player: played.append(player)
)
return played
# main menu ------------------------------------------------------------
def test_quit_saves_the_roster(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
ui = make_ui(tmp_path, hero("Ann"))
feed(monkeypatch, ["7"])
ui.run()
out = capsys.readouterr().out
assert "| __/| | | (_) | (_| |" in out # the logo
for line in [
"1) Create a new character",
"2) Play as character",
"3) Query character info",
"4) Delete a character",
"5) Import a character (.pqw)",
"6) Export a character (.pqw)",
"7) Quit",
]:
assert line in out
assert read_names(tmp_path) == ["Ann"]
def test_quit_without_saving(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
ui = make_ui(tmp_path, hero("Ann"), use_saves=False)
feed(monkeypatch, ["7"])
ui.run()
assert not save_path(tmp_path).exists()
def test_run_with_a_character_goes_straight_to_play(
tmp_path: Path, no_play: list[Player]
) -> None:
player = hero("Ann")
ui = make_ui(tmp_path, player)
ui.player = player
ui.run() # no prompts: feed() is not set up, input() would fail
assert no_play == [player]
def test_menu_rejects_bad_input(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
ui = make_ui(tmp_path)
feed(monkeypatch, ["", "two", "0", "8", "-1", "7"])
ui.main_menu()
out = capsys.readouterr().out
assert out.count("Not a number") == 2
assert out.count("Expected a number between 1..7") == 3
def test_confirm_accepts_several_spellings(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
ui = make_ui(tmp_path)
answers = ["Y", "yes", "1", "N", "No", "0"]
feed(monkeypatch, ["?", *answers])
assert [ui.confirm("Sure?") for _ in answers] == [True] * 3 + [False] * 3
assert capsys.readouterr().out.count("Sure? [y/n] ") == 7
@pytest.mark.parametrize("choice", ["2", "3", "4", "6"])
def test_choosing_with_no_characters(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
choice: str,
) -> None:
ui = make_ui(tmp_path)
feed(monkeypatch, [choice, "7"])
ui.main_menu()
assert "No characters to choose from!" in capsys.readouterr().out
# creating -------------------------------------------------------------
def test_create_rerolls_until_accepted(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
no_play: list[Player],
) -> None:
ui = make_ui(tmp_path)
# create, name, race, class, reroll twice, accept, don't play, quit
feed(monkeypatch, ["1", "Rolf", "2", "3", "n", "n", "y", "n", "7"])
ui.main_menu()
out = capsys.readouterr().out
assert out.count("Total: ") == 3
for race in DATA.races:
assert f") {race.name}\n" in out
assert "Do you want to play as your new character? [y/n] n" in out
assert no_play == []
[player] = ui.roster.players
assert player.name == "Rolf"
assert player.race == DATA.races[1]
assert player.class_ == DATA.classes[2]
# the accepted stats are the last ones shown
last_shown = out.split("Is this okay?")[-2].splitlines()[-7:-1]
assert last_shown == [
f"{stat.value}: {player.stats[stat]}" for stat in PRIME_STATS
]
assert read_names(tmp_path) == ["Rolf"] # saved on quit
def test_create_then_play(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
no_play: list[Player],
) -> None:
ui = make_ui(tmp_path)
feed(monkeypatch, ["Rolf", "1", "1", "y", "y"])
player = ui.create_player()
assert no_play == [player]
assert ui.roster.players == [player]
def test_create_cancelled_by_empty_name(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
ui = make_ui(tmp_path)
feed(monkeypatch, [""])
assert ui.create_player() is None
assert "Cancelled." in capsys.readouterr().out
assert ui.roster.players == []
# playing --------------------------------------------------------------
def test_play_from_the_menu(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
no_play: list[Player],
) -> None:
ann, bob = hero("Ann"), hero("Bob")
ui = make_ui(tmp_path, ann, bob)
feed(monkeypatch, ["2", "x", "3", "2", "7"])
ui.main_menu()
assert no_play == [bob]
out = capsys.readouterr().out
assert "Choose your character:\n1) Ann\n2) Bob\n" in out
assert "Expected a number between 1..2" in out
class StopPlaying(Exception):
pass
class FastClock:
"""Stands in for datetime: every now() is a minute after the last."""
current = datetime(2024, 1, 1)
@classmethod
def now(cls) -> datetime:
cls.current += timedelta(minutes=1)
return cls.current
@pytest.fixture
def game_loop(monkeypatch: pytest.MonkeyPatch) -> list[float]:
"""Run play() on a fast clock, stopping it after 800 rounds.
Returns the requested sleeps.
"""
sleeps: list[float] = []
def sleep(seconds: float) -> None:
sleeps.append(seconds)
if len(sleeps) == 800:
raise StopPlaying
monkeypatch.setattr(FastClock, "current", datetime(2024, 1, 1))
monkeypatch.setattr(basic, "datetime", FastClock)
monkeypatch.setattr(roster_module, "datetime", FastClock)
monkeypatch.setattr(basic.time, "sleep", sleep)
return sleeps
def test_play_prints_the_sheet_on_level_up(
tmp_path: Path,
game_loop: list[float],
capsys: pytest.CaptureFixture[str],
) -> None:
# played(0) reaches level 2 within 800 minute-long ticks
player = played(0).player
ui = make_ui(tmp_path, player, use_saves=False)
with pytest.raises(StopPlaying):
ui.play(player)
out = capsys.readouterr().out
assert out.startswith("Playing as Exportée\n")
sheets = out.split("--- Character Sheet ---")[1:]
assert len(sheets) == 2
assert "Level: 1\n" in sheets[0]
assert "Level: 2\n" in sheets[1]
assert player.level == 2
# ticks are a minute late, so play() never waits
assert set(game_loop) == {0}
assert not save_path(tmp_path).exists()
def test_play_saves_every_five_minutes(
tmp_path: Path, game_loop: list[float], monkeypatch: pytest.MonkeyPatch
) -> None:
player = played(0).player
ui = make_ui(tmp_path, player)
saves: list[datetime] = []
real_save = ui.roster.save
def save() -> None:
saves.append(FastClock.current)
real_save()
monkeypatch.setattr(ui.roster, "save", save)
with pytest.raises(StopPlaying):
ui.play(player)
assert len(saves) > 100
gaps = {b - a for a, b in zip(saves, saves[1:], strict=False)}
assert min(gaps) >= timedelta(minutes=5)
assert read_names(tmp_path) == ["Exportée"]
def test_play_waits_out_the_rest_of_a_tick(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""With no time passing, each round sleeps a full 100 ms."""
sleeps: list[float] = []
class FrozenClock:
@staticmethod
def now() -> datetime:
return datetime(2024, 1, 1)
def sleep(seconds: float) -> None:
sleeps.append(seconds)
if len(sleeps) == 3:
raise StopPlaying
monkeypatch.setattr(basic, "datetime", FrozenClock)
monkeypatch.setattr(basic.time, "sleep", sleep)
player = played(0).player
with pytest.raises(StopPlaying):
make_ui(tmp_path, use_saves=False).play(player)
assert sleeps == [0.1, 0.1, 0.1]
# info -----------------------------------------------------------------
def test_info_for_a_seasoned_character(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
player = hero("Ann", ticks=20000)
assert player.spell_book.best is not None
ui = make_ui(tmp_path, player)
feed(monkeypatch, ["3", "1", "7"])
ui.main_menu()
out = capsys.readouterr().out
assert "Name: Ann\n" in out
assert f"Race: {player.race.name}\n" in out
assert f"Class: {player.class_.name}\n" in out
assert f"Level: {player.level}\n" in out
assert "No spells memorized yet." not in out
for spell in player.spell_book:
assert f"\n{spell.name} " in out
assert f"Gold: {player.inventory.gold}\n" in out
for item in player.inventory:
assert f"{item.name}: {item.quantity}\n" in out
assert f"Current quest: {player.quest_book.current_quest}\n" in out
assert player.task is not None
assert f"Current task: {player.task.description}\n" in out
def test_info_for_a_new_character(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
player = played(0, seed="new").player
player.task = None
make_ui(tmp_path).print_player_info(player)
out = capsys.readouterr().out
assert "No spells memorized yet." in out
assert f"Current act: {to_roman(player.quest_book.act)}\n" in out
assert "Current task: ?\n" in out
# deleting -------------------------------------------------------------
def test_delete_after_confirming(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
ann, bob = hero("Ann"), hero("Bob")
ui = make_ui(tmp_path, ann, bob)
feed(monkeypatch, ["4", "1", "n", "4", "1", "y", "7"])
ui.main_menu()
assert ui.roster.players == [bob]
assert capsys.readouterr().out.count("Ann") >= 2
assert read_names(tmp_path) == ["Bob"]
# importing and exporting ----------------------------------------------
def test_import(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
write_pqw(tmp_path / "cid.pqw", hero("Cid"), online=True)
ui = make_ui(tmp_path / "config", hero("Ann"))
feed(monkeypatch, ["5", " ~/cid.pqw ", "7"])
ui.main_menu()
out = capsys.readouterr().out
assert "Warning: dropped 'online': pqcli plays offline only\n" in out
assert "Imported Cid.\n" in out
assert [p.name for p in ui.roster.players] == ["Ann", "Cid"]
assert read_names(tmp_path / "config") == ["Ann", "Cid"]
def test_import_without_saving(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = write_pqw(tmp_path / "cid.pqw", hero("Cid"))
ui = make_ui(tmp_path, use_saves=False)
feed(monkeypatch, [str(source)])
ui.import_player()
assert [p.name for p in ui.roster.players] == ["Cid"]
assert not save_path(tmp_path).exists()
@pytest.mark.parametrize(
("answer", "message"),
[("", "Cancelled."), ("missing.pqw", "Cannot import: cannot read")],
)
def test_import_failures(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
answer: str,
message: str,
) -> None:
monkeypatch.chdir(tmp_path)
ui = make_ui(tmp_path)
feed(monkeypatch, [answer])
ui.import_player()
assert message in capsys.readouterr().out
assert ui.roster.players == []
assert not save_path(tmp_path).exists()
def test_export_to_the_default_file(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.chdir(tmp_path)
ui = make_ui(tmp_path, hero("Ann"), hero("Bob"))
feed(monkeypatch, ["6", "2", "", "7"])
ui.main_menu()
out = capsys.readouterr().out
assert "Export to .pqw file [Bob.pqw]: " in out
assert "Exported Bob to Bob.pqw.\n" in out
assert pqw.read_file(tmp_path / "Bob.pqw", DATA)[0].name == "Bob"
def test_export_to_a_chosen_file(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
ui = make_ui(tmp_path, hero("Ann"))
feed(monkeypatch, [" ~/out.pqw "])
ui.export_player(ui.roster.players[0])
assert "Exported Ann to ~/out.pqw.\n" in capsys.readouterr().out
assert pqw.read_file(tmp_path / "out.pqw", DATA)[0].name == "Ann"
def test_export_never_overwrites(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
target = tmp_path / "out.pqw"
target.write_text("mine")
ui = make_ui(tmp_path, hero("Ann"))
feed(monkeypatch, [str(target)])
ui.export_player(ui.roster.players[0])
assert f"Cannot export: {target} already exists" in capsys.readouterr().out
assert target.read_text() == "mine"
# Ctrl+C ---------------------------------------------------------------
@pytest.mark.parametrize("use_saves", [True, False])
def test_ctrl_c_saves_and_exits(tmp_path: Path, use_saves: bool) -> None:
ui = make_ui(tmp_path, hero("Ann"), use_saves=use_saves)
handler = signal.getsignal(signal.SIGINT)
assert callable(handler)
with pytest.raises(SystemExit) as ex:
handler(signal.SIGINT, None)
assert ex.value.code == 0
assert save_path(tmp_path).exists() == use_saves
assert ui.roster.players # still there
+788
View File
@@ -0,0 +1,788 @@
"""The curses UI, run for real in a pseudo-terminal."""
import re
import sys
from collections.abc import Callable, Iterator
from pathlib import Path
import pytest
from helpers import DATA, hero, read_names, save_path, write_pqw, write_roster
from pqcli import pqw
from pqcli.config import PRIME_STATS, EquipmentType
from pqcli.mechanic import Player
from pqcli.roster import Roster
if sys.platform != "win32":
from pty_app import (
BACKSPACE,
CTRL_T,
CTRL_W,
DOWN,
ENTER,
ESC,
F5,
F6,
F10,
LEFT,
PAGE_DOWN,
PAGE_UP,
RIGHT,
TAB,
UP,
PtyApp,
)
pytestmark = pytest.mark.skipif(
sys.platform == "win32", reason="needs a POSIX pseudo-terminal"
)
ROSTER_MENU = "[Q] Quit"
PANES = [
" Character Sheet ",
" Spell Book ",
" Equipment ",
" Inventory ",
" Plot Development ",
" Quests ",
]
STATS_RE = re.compile(r"\b(STR|CON|DEX|INT|WIS|CHA): (\d+)")
Start = Callable[..., "PtyApp"]
@pytest.fixture
def config(tmp_path: Path) -> Path:
return tmp_path / "config"
@pytest.fixture
def start(config: Path, tmp_path: Path) -> Iterator[Start]:
"""Start pqcli with XDG_CONFIG_HOME in tmp_path; stopped at teardown."""
apps: list[PtyApp] = []
def start(*args: str, **kwargs: int) -> PtyApp:
app = PtyApp(config, *args, cwd=tmp_path, **kwargs)
apps.append(app)
return app
yield start
for app in apps:
app.close()
def quit_app(app: "PtyApp") -> None:
app.wait_for(ROSTER_MENU)
app.send("q")
assert app.wait_exit() == 0
def shown_stats(app: "PtyApp") -> dict[str, int]:
return {
name: int(value) for name, value in STATS_RE.findall(app.screen_text())
}
def name_box(app: "PtyApp") -> str:
"""The text in the character name box."""
y = app.row_of("Choose character name:") + 3
return app.lines[y].strip().strip("│").strip()
def focused_panes(app: "PtyApp") -> list[str]:
return [pane for pane in PANES if app.is_reverse(pane)]
TASK_PROGRESS_RE = re.compile(r"^ *\d+\.\d\d% *$")
def task_row(app: "PtyApp") -> int | None:
"""The row of the task progress bar, the last one in the game view."""
for y, line in reversed(list(enumerate(app.lines))):
if TASK_PROGRESS_RE.match(line):
return y
return None
# roster ---------------------------------------------------------------
def test_roster_menu(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"))
app = start("--no-colors")
text = app.wait_for(ROSTER_MENU)
for entry in [
"[C] Create new character",
"[P] Play",
"[D] Delete character",
"[I] Import character (.pqw)",
"[E] Export character (.pqw)",
]:
assert entry in text
assert "█▄▄▀ █▀▀ ▄▀▀▄" in text # the logo
assert app.is_reverse("[C] Create")
app.send(DOWN)
app.wait_until(lambda _: app.is_reverse("[P] Play"), what="[P] focus")
assert not app.is_reverse("[C] Create")
app.send("j" + TAB)
app.wait_until(lambda _: app.is_reverse("[I] Import"), what="[I] focus")
app.send(UP + "k")
app.wait_until(lambda _: app.is_reverse("[P] Play"), what="[P] focus")
# Enter picks the highlighted entry
app.send(ENTER)
app.wait_for("Choose character to play:")
app.send("q")
quit_app(app)
assert read_names(config) == ["Ann"]
def test_roster_menu_wraps_with_tab(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"))
app = start("--no-colors")
app.wait_for(ROSTER_MENU)
app.send(UP) # already at the top: stays there
app.send(TAB * 5)
app.wait_until(lambda _: app.is_reverse("[Q] Quit"), what="[Q] focus")
app.send(DOWN) # already at the bottom: stays there
app.send(TAB)
app.wait_until(lambda _: app.is_reverse("[C] Create"), what="[C] focus")
app.send(ESC)
assert app.wait_exit() == 0
def test_ctrl_c_saves_and_exits(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"))
app = start()
app.wait_for(ROSTER_MENU)
app.send("\x03")
assert app.wait_exit() == 0
assert read_names(config) == ["Ann"]
assert (config / "pqcli" / "save.dat.old").exists()
# creating a character -------------------------------------------------
def test_create_character(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"))
app = start("--no-colors")
app.wait_for(ROSTER_MENU)
app.send("c")
app.wait_for("Choose character name:")
assert name_box(app) # starts with a random name
app.send(CTRL_W + "Zedd")
app.wait_until(lambda _: name_box(app) == "Zedd", what="Zedd")
app.send(BACKSPACE)
app.wait_until(lambda _: name_box(app) == "Zed", what="Zed")
app.send(F5)
app.wait_until(lambda _: name_box(app) != "Zed", what="a random name")
app.send(CTRL_W + "Zed" + ENTER)
text = app.wait_for("Choose character race:")
for i, race in enumerate(DATA.races[:9], 1):
assert f"[{i}] {race.name}" in text
app.send("2")
text = app.wait_for("Choose character class:")
for i, class_ in enumerate(DATA.classes[:9], 1):
assert f"[{i}] {class_.name}" in text
app.send("3")
app.wait_for("Roll character stats:")
first = shown_stats(app)
assert list(first) == ["STR", "CON", "DEX", "INT", "WIS", "CHA"]
assert all(3 <= value <= 18 for value in first.values())
assert app.is_reverse("[F5 ] Roll")
# Keys are handled in order, so once the focus has moved the stats
# are final: three rolls and four unrolls (one too many) give the
# first roll back.
app.send(F5 * 3 + F6 * 4 + DOWN)
app.wait_until(lambda _: app.is_reverse("[F6 ] Unroll"), what="focus")
assert shown_stats(app) == first
app.send(F5 + UP)
app.wait_until(lambda _: app.is_reverse("[F5 ] Roll"), what="focus")
rolled = shown_stats(app)
app.send(F10)
text = app.wait_for(" Character Sheet ")
assert re.search(r"Name +Zed\b", text)
assert re.search(rf"Race +{DATA.races[1].name}", text)
assert re.search(rf"Class +{DATA.classes[2].name}", text)
assert re.search(rf"STR +{rolled['STR']}\b", text)
# the new character is saved right away
[_, zed] = load_players(config)
assert zed.name == "Zed"
assert zed.race == DATA.races[1]
assert zed.class_ == DATA.classes[2]
assert {stat.value: zed.stats[stat] for stat in PRIME_STATS} == rolled
app.send("q")
quit_app(app)
def load_players(config: Path) -> list[Player]:
return Roster.load(save_path(config)).players
def test_name_view_buttons(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"))
app = start("--no-colors", "--no-save")
app.wait_for(ROSTER_MENU)
app.send("c")
app.wait_for("Choose character name:")
app.send(CTRL_W + "Zed")
app.wait_until(lambda _: name_box(app) == "Zed", what="Zed")
# Down leaves the text box for the buttons below it
app.send(DOWN)
app.wait_until(
lambda _: app.is_reverse("[F5 ] Generate"), what="F5 focus"
)
app.send(ENTER)
app.wait_until(lambda _: name_box(app) != "Zed", what="a random name")
app.send("j")
app.wait_until(lambda _: app.is_reverse("[F10 ] Continue"), what="F10")
app.send(DOWN + DOWN) # stops at the last button
app.wait_until(lambda _: app.is_reverse("[Esc ] Cancel"), what="Esc")
app.send(UP + "k" + "k") # back in the text box; no further up
app.wait_until(
lambda _: not app.is_reverse("[F5 ] Generate"), what="text box"
)
# typing works again in the text box
app.send(CTRL_W + "Bo" + TAB + TAB)
app.wait_until(lambda _: app.is_reverse("[F10 ] Continue"), what="F10")
assert name_box(app) == "Bo"
app.send(ENTER)
app.wait_for("Choose character race:")
# cancelling the race goes back to the name, which is kept
app.send("q")
app.wait_for("Choose character name:")
assert name_box(app) == "Bo"
# Tab wraps around to the text box
app.send(TAB * 4 + "b")
app.wait_until(lambda _: name_box(app) == "Bob", what="Bob")
app.send(F10)
app.wait_for("Choose character race:")
app.send(ESC)
app.wait_for("Choose character name:")
# the Cancel button goes back to the roster
app.send(TAB * 3)
app.wait_until(lambda _: app.is_reverse("[Esc ] Cancel"), what="Esc")
app.send(ENTER)
app.wait_for(ROSTER_MENU)
# so does q while a button has focus, and Esc in the text box
app.send("c")
app.wait_for("Choose character name:")
app.send(TAB + "q")
app.wait_for(ROSTER_MENU)
app.send("c")
app.wait_for("Choose character name:")
app.send(ESC)
app.wait_for(ROSTER_MENU)
# a blank name cancels too
app.send("c")
app.wait_for("Choose character name:")
app.send(CTRL_W + " " + ENTER)
app.wait_for(ROSTER_MENU)
# with --no-save a new character is not written
app.send("c")
app.wait_for("Choose character name:")
app.send(ENTER)
app.wait_for("Choose character race:")
app.send(ENTER)
app.wait_for("Choose character class:")
app.send(ENTER)
app.wait_for("Roll character stats:")
app.send(F10)
app.wait_for(" Character Sheet ")
app.send("q")
quit_app(app)
assert read_names(config) == ["Ann"]
def test_create_character_back_and_forth(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"))
app = start("--no-colors")
app.wait_for(ROSTER_MENU)
app.send("c")
app.wait_for("Choose character name:")
app.send(CTRL_W + "Cy" + ENTER)
race = DATA.races[2]
class_ = DATA.classes[1]
app.wait_for("Choose character race:")
app.send("3")
app.wait_for("Choose character class:")
app.send("q")
# back at the race, with the chosen one highlighted
app.wait_for("Choose character race:")
assert app.is_reverse(f"[3] {race.name}")
app.send(DOWN + UP + ENTER)
app.wait_for("Choose character class:")
app.send("2")
app.wait_for("Roll character stats:")
app.send(ESC)
app.wait_for("Choose character class:")
assert app.is_reverse(f"[2] {class_.name}")
app.send(ENTER)
# the buttons in the stats view
app.wait_for("Roll character stats:")
app.send(TAB + TAB)
app.wait_until(lambda _: app.is_reverse("[F10 ] Continue"), what="F10")
app.send(TAB + TAB) # wraps around
app.wait_until(lambda _: app.is_reverse("[F5 ] Roll"), what="F5")
first = shown_stats(app)
app.send(ENTER + "j" + ENTER + "k")
app.wait_until(lambda _: app.is_reverse("[F5 ] Roll"), what="F5")
assert shown_stats(app) == first # rolled, then unrolled
app.send(UP + DOWN * 4) # clamped at both ends
app.wait_until(lambda _: app.is_reverse("[Esc ] Cancel"), what="Esc")
app.send(ENTER)
app.wait_for("Choose character class:")
app.send(ENTER)
app.wait_for("Roll character stats:")
app.send(TAB + TAB)
app.wait_until(lambda _: app.is_reverse("[F10 ] Continue"), what="F10")
app.send(ENTER)
text = app.wait_for(" Character Sheet ")
assert re.search(r"Name +Cy\b", text)
assert re.search(rf"Race +{race.name}", text)
assert re.search(rf"Class +{class_.name}", text)
app.send("q")
quit_app(app)
assert read_names(config) == ["Ann", "Cy"]
# playing --------------------------------------------------------------
def test_game_view(start: Start, config: Path) -> None:
player = hero("Ann", ticks=20000)
write_roster(config, hero("Bob"), player)
app = start("--no-colors")
app.wait_for(ROSTER_MENU)
app.send("p")
text = app.wait_for("Choose character to play:")
assert "[1] Bob the " in text
assert f"[2] Ann the {player.race.name} (Act I)" in text
assert f"Level {player.level} {player.class_.name}" in text
app.send("2")
rows = len(app.lines)
text = app.wait_until(
lambda s: (
all(pane in s for pane in PANES) and task_row(app) == rows - 1
),
what="the game view",
)
assert re.search(r"Name +Ann\b", text)
assert re.search(rf"Level +{player.level}\b", text)
assert "Experience (" in text
assert "Encumbrance (" in text
assert "[X] Prologue" in text
assert "[ ] Act I" in text
assert re.search(rf"Gold +{player.inventory.gold}", text)
for spell in player.spell_book:
assert spell.name in text
for quest in player.quest_book.quests[-3:]:
assert quest[:20] in text
weapon = player.equipment[EquipmentType.weapon]
assert weapon
assert re.search(rf"Weapon +{weapon}", text)
# the task and its progress run along the bottom
assert app.lines[-2].rstrip().endswith("...")
app.send("q")
quit_app(app)
def test_game_view_focus(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"))
app = start("--no-colors", "--load-save", "1")
app.wait_until(lambda s: all(p in s for p in PANES), what="panes")
def expect(pane: str) -> None:
app.wait_until(
lambda _: focused_panes(app) == [pane], what=f"{pane} focus"
)
expect(" Character Sheet ")
for pane in PANES[1:] + PANES[:1]:
app.send(TAB)
expect(pane)
# The panes form a 3x2 grid: sheet, equipment and plot on top. This
# route takes every move between neighbours once.
moves = [
(UP + "l", " Equipment "), # nothing above the sheet
(RIGHT, " Plot Development "),
(DOWN, " Quests "),
(LEFT, " Inventory "),
("h", " Spell Book "),
(UP, " Character Sheet "),
(DOWN, " Spell Book "),
(DOWN + RIGHT, " Inventory "), # nothing below the spell book
(UP, " Equipment "),
("j", " Inventory "),
(RIGHT, " Quests "),
("k", " Plot Development "),
(RIGHT + LEFT, " Equipment "), # nothing right of the plot
(LEFT, " Character Sheet "),
]
for key, pane in moves:
app.send(key)
expect(pane)
# keys that mean nothing here are ignored
app.send("x")
app.send("t") # a cheat, but cheats are off
app.send(TAB)
expect(" Spell Book ")
app.send(ESC)
quit_app(app)
def test_game_view_scrolls_by_page(start: Start, config: Path) -> None:
player = hero("Ann", ticks=20000)
quests = player.quest_book.quests
assert len(quests) > 10
write_roster(config, player)
# small enough that the quests do not fit their pane
app = start("--no-colors", "--load-save", "1", rows=24)
app.wait_until(lambda s: all(p in s for p in PANES), what="panes")
first, last = quests[0][:20], quests[-1][:20]
text = app.wait_for(last)
assert first not in text
# PgUp/PgDn only scroll the focused pane
app.send(PAGE_UP)
app.send(TAB * 5)
app.wait_until(
lambda _: focused_panes(app) == [" Quests "], what="quests focus"
)
assert last in app.screen_text()
app.send(PAGE_UP * len(quests))
app.wait_for(first)
assert last not in app.screen_text()
app.send(PAGE_DOWN * len(quests))
app.wait_for(last)
assert first not in app.screen_text()
# the other panes take the keys too, or ignore them
for _ in range(5):
app.send(TAB)
app.send(PAGE_DOWN + PAGE_UP)
app.wait_until(
lambda _: focused_panes(app) == [" Plot Development "],
what="plot focus",
)
app.send("q")
quit_app(app)
def test_load_save_starts_in_the_game(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
app = start("--load-save", "2")
text = app.wait_for(" Character Sheet ")
assert re.search(r"Name +Bob\b", text)
# leaving the game shows the roster rather than quitting
app.send("q")
quit_app(app)
assert read_names(config) == ["Ann", "Bob"]
def test_many_characters(start: Start, config: Path) -> None:
players = [hero(f"Hero{i}") for i in range(1, 12)]
write_roster(config, *players)
app = start("--no-colors")
app.wait_for(ROSTER_MENU)
app.send("p")
text = app.wait_for("Choose character to play:")
assert "[1] Hero1 the " in text
assert "[0] Hero10 the " in text
assert "[-] Hero11 the " in text
# the eleventh has no key, but can be picked with the cursor
app.send(DOWN * 10)
app.wait_until(lambda _: app.is_reverse("[-] Hero11"), what="Hero11")
app.send(ENTER)
text = app.wait_for(" Character Sheet ")
assert re.search(r"Name +Hero11\b", text)
app.send("q")
quit_app(app)
def test_cheats(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"))
app = start("--cheats", "--no-save", "--load-save", "1")
app.wait_until(lambda s: all(p in s for p in PANES), what="panes")
def level() -> int:
found = re.search(r"Level +(\d+)", app.screen_text())
assert found
return int(found.group(1))
def xp_to_go() -> int:
found = re.search(r"Experience \((\d+) XP to go\)", app.screen_text())
assert found
return int(found.group(1))
assert level() == 1
start_xp = xp_to_go()
# t is one tick, T a hundred: 100 seconds of game time in all,
# far more than passes on its own before the wait times out
app.send("t" + "T" * 10)
app.wait_until(lambda _: xp_to_go() < start_xp, what="experience")
assert level() == 1
# ^T is 10000 ticks at once, about 17 minutes of game time; three
# of them reach level 2 with time to spare
app.send(CTRL_T * 3)
app.wait_until(lambda _: level() > 1, what="a level up")
app.send("q")
quit_app(app)
# resizing -------------------------------------------------------------
def menu_x(app: "PtyApp") -> int:
return app.lines[app.row_of("[P] Play")].index("[P] Play")
def test_resize(start: Start, config: Path) -> None:
write_roster(config, hero("Ann", ticks=3000))
app = start()
app.wait_for(ROSTER_MENU)
wide_x = menu_x(app)
app.resize(30, 80)
app.wait_until(
lambda s: "[P] Play" in s and menu_x(app) < wide_x,
what="the menu to move",
)
app.send("p")
app.wait_for("Choose character to play:")
app.send("1")
app.wait_until(lambda _: task_row(app) == 29, what="the game view")
assert all(pane in app.screen_text() for pane in PANES)
app.resize(40, 120)
app.wait_until(lambda _: task_row(app) == 39, what="the task to move")
text = app.screen_text()
assert all(pane in text for pane in PANES)
# far too small to fit: still runs and recovers
app.resize(8, 30)
app.send(TAB)
app.resize(40, 120)
app.wait_until(lambda _: task_row(app) == 39, what="the task to move")
app.send("q")
app.wait_for(ROSTER_MENU)
app.resize(12, 40)
app.resize(40, 120)
quit_app(app)
# deleting -------------------------------------------------------------
def test_delete_character(start: Start, config: Path) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
app = start()
app.wait_for(ROSTER_MENU)
# with colors, the highlight is a background color, not reverse
y = app.row_of("[C] Create")
x = app.lines[y].index("[C] Create")
focused = app.screen.buffer[y][x]
assert not focused.reverse
assert focused.bg != app.screen.buffer[y + 1][x].bg
app.send("d")
app.wait_for("Choose character to delete:")
app.send("1")
text = app.wait_for("[Y] Yes")
assert "Ann" in text
assert "[N] No" in text
# no goes back to the list
app.send("n")
app.wait_for("Choose character to delete:")
app.send("1")
app.wait_for("[Y] Yes")
app.send("y")
app.wait_for(ROSTER_MENU)
app.send("d")
text = app.wait_for("Choose character to delete:")
assert "Ann the" not in text
assert "[1] Bob the" in text
app.send(ESC)
quit_app(app)
assert read_names(config) == ["Bob"]
# importing and exporting ----------------------------------------------
def input_box(app: "PtyApp") -> str:
"""The text in the text input box."""
y = app.row_of("[Enter] Continue") - 2
return app.lines[y].strip().strip("│").strip()
def test_import(start: Start, config: Path, tmp_path: Path) -> None:
write_roster(config, hero("Ann"))
source = write_pqw(tmp_path / "cid.pqw", hero("Cid"), online=True)
app = start()
app.wait_for(ROSTER_MENU)
app.send("i")
app.wait_for("Import from .pqw file:")
assert input_box(app) == ""
app.send("cid.pqwx" + BACKSPACE)
app.wait_until(lambda _: input_box(app) == "cid.pqw", what="the path")
app.send(ENTER)
text = app.wait_for("Imported Cid.")
assert "dropped 'online': pqcli plays offline only" in text
app.send(ENTER)
app.wait_for(ROSTER_MENU)
assert read_names(config) == ["Ann", "Cid"]
quit_app(app)
assert source.exists()
def test_import_without_saving(
start: Start, config: Path, tmp_path: Path
) -> None:
write_roster(config, hero("Ann"))
source = write_pqw(tmp_path / "cid.pqw", hero("Cid"))
app = start("--no-save")
app.wait_for(ROSTER_MENU)
app.send("i")
app.wait_for("Import from .pqw file:")
app.send(str(source) + ENTER)
app.wait_for("Imported Cid.")
app.send("q")
app.wait_for(ROSTER_MENU)
app.send("p")
text = app.wait_for("Choose character to play:")
assert "[2] Cid the" in text
app.send("q")
quit_app(app)
assert read_names(config) == ["Ann"]
def test_import_failures(start: Start, config: Path, tmp_path: Path) -> None:
write_roster(config, hero("Ann"))
(tmp_path / "bad.pqw").write_text("garbage!")
app = start()
app.wait_for(ROSTER_MENU)
app.send("i")
app.wait_for("Import from .pqw file:")
app.send("bad.pqw" + ENTER)
app.wait_for("Cannot import: not a .pqw file")
app.send(ESC)
app.wait_for(ROSTER_MENU)
# a long path keeps its end in view
long_name = "x" * 100 + "/missing.pqw"
app.send("i")
app.wait_for("Import from .pqw file:")
app.send(long_name)
app.wait_until(
lambda _: input_box(app).endswith("/missing.pqw"), what="the path"
)
assert len(input_box(app)) < len(long_name)
app.send(ENTER)
text = app.wait_for("Cannot import: cannot read")
assert "[Enter] OK" in text
app.send("q")
app.wait_for(ROSTER_MENU)
# Esc and an empty path both cancel
app.send("i")
app.wait_for("Import from .pqw file:")
app.send(ESC)
app.wait_for(ROSTER_MENU)
app.send("i")
app.wait_for("Import from .pqw file:")
app.send("abc" + CTRL_W + " " + ENTER)
app.wait_for(ROSTER_MENU)
quit_app(app)
assert read_names(config) == ["Ann"]
def test_export(start: Start, config: Path, tmp_path: Path) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
app = start()
app.wait_for(ROSTER_MENU)
app.send("e")
app.wait_for("Choose character to export:")
app.send("2")
app.wait_for("Export Bob to .pqw file:")
assert input_box(app) == "Bob.pqw"
# Esc goes back to the character list
app.send(ESC)
app.wait_for("Choose character to export:")
app.send("2")
app.wait_for("Export Bob to .pqw file:")
app.send(ENTER)
app.wait_for("Exported Bob to Bob.pqw.")
assert pqw.read_file(tmp_path / "Bob.pqw", DATA)[0].name == "Bob"
app.send(ENTER)
app.wait_for(ROSTER_MENU)
# the same file again is refused
app.send("e")
app.wait_for("Choose character to export:")
app.send("1")
app.wait_for("Export Ann to .pqw file:")
app.send(CTRL_W + "Bob.pqw" + ENTER)
app.wait_for("Cannot export: Bob.pqw already exists")
app.send(ENTER)
app.wait_for(ROSTER_MENU)
app.send("e")
app.wait_for("Choose character to export:")
app.send("1")
app.wait_for("Export Ann to .pqw file:")
app.send(CTRL_W + str(tmp_path / "sub" / "ann.pqw") + ENTER)
app.wait_for("Cannot export: cannot write")
app.send(ENTER)
app.wait_for(ROSTER_MENU)
app.send("e")
text = app.wait_for("Choose character to export:")
assert "[Q] Cancel" in text
app.send(ESC)
quit_app(app)
assert pqw.read_file(tmp_path / "Bob.pqw", DATA)[0].name == "Bob"
assert not (tmp_path / "sub").exists()
+361
View File
@@ -0,0 +1,361 @@
"""pqcli's command line, run in-process so coverage sees it."""
import json
from collections.abc import Callable
from pathlib import Path
import pytest
from helpers import (
DATA,
feed,
hero,
read_names,
save_path,
write_pqw,
write_roster,
)
import pqcli.__main__ as cli
from pqcli import pqw
from pqcli.game_data import DATA_FILES
from pqcli.mechanic import Player
from pqcli.roster import Roster
from pqcli.ui.basic import BasicUserInterface
from pqcli.ui.curses import CursesUserInterface
Run = Callable[..., int]
@pytest.fixture
def config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point the save and data paths at tmp_path/config."""
config = tmp_path / "config"
monkeypatch.setattr(cli, "SAVE_PATH", save_path(config))
monkeypatch.setattr(cli, "DATA_PATH", config / "pqcli" / "data")
return config
class NoCurses:
"""Fails the test instead of taking over the terminal with curses."""
def __init__(self, *args: object) -> None:
raise AssertionError("the curses UI was started")
@pytest.fixture(autouse=True)
def _no_curses(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cli, "CursesUserInterface", NoCurses)
@pytest.fixture
def run(config: Path, monkeypatch: pytest.MonkeyPatch) -> Run:
"""Call main() with the given arguments and return its exit code."""
def run(*args: str) -> int:
monkeypatch.setattr("sys.argv", ["pqcli", *args])
try:
cli.main()
except SystemExit as ex:
assert isinstance(ex.code, int | None)
return ex.code or 0
return 0
return run
def test_defaults_to_the_curses_ui(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.undo()
monkeypatch.setattr("sys.argv", ["pqcli"])
args = cli.parse_args()
assert args.ui is CursesUserInterface
assert args.use_saves
assert args.colors
assert not args.cheats
def test_list_saves(
run: Run, config: Path, capsys: pytest.CaptureFixture[str]
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
assert run("--list-saves") == 0
assert capsys.readouterr().out == "1. Ann\n2. Bob\n"
def test_list_saves_when_there_are_none(
run: Run, config: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert run("--list-saves") == 0
assert capsys.readouterr().out == ""
# no first-run character creation, and nothing written
assert not config.exists()
@pytest.mark.parametrize(
"num",
[
"3",
"0",
"-1",
],
)
def test_load_save_rejects_unknown_character(
run: Run,
config: Path,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
num: str,
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
feed(monkeypatch, ["7"])
monkeypatch.setattr(BasicUserInterface, "play", lambda self, p: None)
assert run("--basic", "--load-save", num) == 1
err = capsys.readouterr().err
assert err == "Invalid player. Available players:\n1. Ann\n2. Bob\n"
def test_load_save_plays_the_character_and_saves(
run: Run, config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
played: list[Player] = []
def play(self: BasicUserInterface, player: Player) -> None:
played.append(player)
player.name = "Bobby"
monkeypatch.setattr(BasicUserInterface, "play", play)
assert run("--basic", "--load-save", "2") == 0
assert [p.name for p in played] == ["Bobby"]
assert read_names(config) == ["Ann", "Bobby"]
def test_no_save_leaves_the_roster_alone(
run: Run, config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
write_roster(config, hero("Ann"))
def play(self: BasicUserInterface, player: Player) -> None:
player.name = "Changed"
monkeypatch.setattr(BasicUserInterface, "play", play)
assert run("--basic", "--no-save", "--load-save", "1") == 0
assert read_names(config) == ["Ann"]
def test_saves_even_when_the_ui_crashes(
run: Run, config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
write_roster(config, hero("Ann"))
def play(self: BasicUserInterface, player: Player) -> None:
player.name = "Crashed"
raise RuntimeError("boom")
monkeypatch.setattr(BasicUserInterface, "play", play)
with pytest.raises(RuntimeError, match="boom"):
run("--basic", "--load-save", "1")
assert read_names(config) == ["Crashed"]
def test_dump_data(
run: Run, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
target = tmp_path / "words"
assert run("--dump-data", str(target)) == 0
assert capsys.readouterr().out == (
f"Wrote {len(DATA_FILES)} files to {target}\n"
)
assert sorted(p.stem for p in target.iterdir()) == sorted(DATA_FILES)
def test_dump_data_refuses_to_overwrite(
run: Run, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
target = tmp_path / "words"
target.mkdir()
existing = target / f"{next(iter(DATA_FILES))}.json"
existing.write_text("mine")
assert run("--dump-data", str(target)) == 1
assert capsys.readouterr().err.startswith("pqcli: refusing to overwrite")
assert existing.read_text() == "mine"
def test_data_dir_must_be_a_directory(
run: Run, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
not_dir = tmp_path / "file"
not_dir.write_text("")
assert run("--data-dir", str(not_dir), "--list-saves") == 1
assert capsys.readouterr().err == f"pqcli: {not_dir}: not a directory\n"
def test_data_dir_with_broken_list(
run: Run, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
(tmp_path / "races.json").write_text("{not json")
assert run("--data-dir", str(tmp_path), "--list-saves") == 1
err = capsys.readouterr().err
assert err.startswith("pqcli: ")
assert "races.json" in err
def test_default_data_dir_is_used(
run: Run, config: Path, capsys: pytest.CaptureFixture[str]
) -> None:
data_dir = config / "pqcli" / "data"
data_dir.mkdir(parents=True)
(data_dir / "races.json").write_text("[]")
assert run("--list-saves") == 1
assert "races.json" in capsys.readouterr().err
def test_import_saves(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
write_roster(config, hero("Ann"))
first = write_pqw(tmp_path / "a.pqw", hero("Bob"))
second = write_pqw(tmp_path / "b.pqw", hero("Cid"), online=True)
assert run("--import-save", str(first), "--import-save", str(second)) == 0
out, err = capsys.readouterr()
assert out == "Imported Bob\nImported Cid\n"
assert (
err == f"pqcli: {second}: dropped 'online': pqcli plays offline only\n"
)
assert read_names(config) == ["Ann", "Bob", "Cid"]
def test_import_saves_without_saving(
run: Run, config: Path, tmp_path: Path
) -> None:
source = write_pqw(tmp_path / "a.pqw", hero("Bob"))
assert run("--no-save", "--import-save", str(source)) == 0
assert not save_path(config).exists()
def test_import_stops_at_the_first_bad_file(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
good = write_pqw(tmp_path / "a.pqw", hero("Bob"))
missing = tmp_path / "missing.pqw"
assert run("--import-save", str(good), "--import-save", str(missing)) == 1
assert capsys.readouterr().err.startswith(f"pqcli: cannot read {missing}")
assert not save_path(config).exists()
def test_export_save(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
target = tmp_path / "bob.pqw"
assert run("--export-save", "2", str(target)) == 0
assert capsys.readouterr().out == f"Exported Bob to {target}\n"
assert pqw.read_file(target, DATA)[0].name == "Bob"
@pytest.mark.parametrize("num", ["0", "3", "x", "-1"])
def test_export_save_rejects_unknown_character(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
num: str,
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
target = tmp_path / "x.pqw"
assert run("--export-save", num, str(target)) == 1
assert capsys.readouterr().err == (
"Invalid player. Available players:\n1. Ann\n2. Bob\n"
)
assert not target.exists()
def test_export_save_never_overwrites(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
write_roster(config, hero("Ann"))
target = tmp_path / "x.pqw"
target.write_text("mine")
assert run("--export-save", "1", str(target)) == 1
assert capsys.readouterr().err == f"pqcli: {target} already exists\n"
assert target.read_text() == "mine"
# first run ------------------------------------------------------------
def test_first_run_creates_a_character_then_shows_the_menu(
run: Run,
config: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
race, class_ = DATA.races[2], DATA.classes[3]
# name, race, class, accept the stats, then quit from the main menu
feed(monkeypatch, ["Newbie", "3", "4", "y", "7"])
assert run("--basic") == 0
out = capsys.readouterr().out
assert out.startswith(
"No saved characters found. Starting first-run character creation."
)
# no "play now?" question on the first run
assert "Do you want to play" not in out
assert "1) Create a new character" in out
[player] = Roster.load(save_path(config)).players
assert player.name == "Newbie"
assert player.race == race
assert player.class_ == class_
def test_first_run_can_be_retried(
run: Run,
config: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
feed(monkeypatch, ["", "maybe", "y", "Second", "1", "1", "y", "7"])
assert run("--basic") == 0
assert read_names(config) == ["Second"]
def test_first_run_gives_up_without_a_character(
run: Run,
config: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
feed(monkeypatch, ["", "n"])
assert run("--basic") == 1
out, err = capsys.readouterr()
assert "Cancelled." in out
assert err == "A character is required for first run.\n"
assert not save_path(config).exists()
def test_first_run_is_skipped_with_characters(
run: Run, config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
write_roster(config, hero("Ann"))
feed(monkeypatch, ["7"])
assert run("--basic") == 0
assert read_names(config) == ["Ann"]
def test_dump_data_writes_valid_json(run: Run, tmp_path: Path) -> None:
target = tmp_path / "words"
run("--dump-data", str(target))
for path in target.iterdir():
json.loads(path.read_text(encoding="utf-8"))
+2 -4
View File
@@ -130,7 +130,7 @@ def test_export_of_unplayed_character() -> None:
assert player.task is None # exported a copy
@pytest.mark.parametrize("ticks", [0, 1, 25, 3000, 40000, 123457])
@pytest.mark.parametrize("ticks", [0, 25, 3000, 123457])
def test_round_trip_keeps_playing_identically(ticks: int) -> None:
original = played(ticks)
copy, _warnings = import_game(
@@ -138,9 +138,7 @@ def test_round_trip_keeps_playing_identically(ticks: int) -> None:
)
assert bridge.snapshot(copy) == bridge.snapshot(original.player)
resumed = Simulation(copy, DATA)
assert bridge.run(resumed, 10**8, 2000) == bridge.run(
original, 10**8, 2000
)
assert bridge.run(resumed, 10**8, 500) == bridge.run(original, 10**8, 500)
def test_files_round_trip(tmp_path: Path) -> None:
+131
View File
@@ -0,0 +1,131 @@
import pickle
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from helpers import hero
from pqcli import roster as roster_module
from pqcli.roster import Roster
START = datetime(2024, 1, 1, 12, 0, 0)
class Clock:
"""Stands in for datetime in pqcli.roster; only now() is used."""
current = START
@classmethod
def now(cls) -> datetime:
return cls.current
@classmethod
def advance(cls, seconds: float) -> None:
cls.current += timedelta(seconds=seconds)
@pytest.fixture
def clock(monkeypatch: pytest.MonkeyPatch) -> type[Clock]:
monkeypatch.setattr(Clock, "current", START)
monkeypatch.setattr(roster_module, "datetime", Clock)
return Clock
def names(path: Path) -> list[str]:
return [player.name for player in pickle.loads(path.read_bytes())]
def test_load_missing_file_gives_empty_roster(tmp_path: Path) -> None:
path = tmp_path / "nowhere" / "save.dat"
roster = Roster.load(path)
assert roster.players == []
assert roster.path == path
assert not path.parent.exists()
def test_load_accepts_a_string_path(tmp_path: Path) -> None:
path = tmp_path / "save.dat"
Roster(path, [hero("Ann")]).save()
assert Roster.load(str(path)).path == path
def test_save_then_load_round_trip(tmp_path: Path) -> None:
path = tmp_path / "deep" / "dir" / "save.dat"
player = hero("Ann", ticks=1000)
Roster(path, [player, hero("Bob")]).save()
loaded = Roster.load(path).players
assert [p.name for p in loaded] == ["Ann", "Bob"]
assert loaded[0].level == player.level
assert loaded[0].inventory.gold == player.inventory.gold
assert loaded[0].quest_book.quests == player.quest_book.quests
def test_save_keeps_the_previous_save_as_old(tmp_path: Path) -> None:
path = tmp_path / "save.dat"
old = tmp_path / "save.dat.old"
roster = Roster(path, [hero("Ann")])
roster.save()
assert names(path) == ["Ann"]
assert not old.exists()
roster.players.append(hero("Bob"))
roster.save()
assert names(path) == ["Ann", "Bob"]
assert names(old) == ["Ann"]
roster.players.pop(0)
roster.save()
assert names(path) == ["Bob"]
assert names(old) == ["Ann", "Bob"]
assert sorted(p.name for p in tmp_path.iterdir()) == [
"save.dat",
"save.dat.old",
]
def test_save_periodically_waits_five_minutes(
tmp_path: Path, clock: type[Clock]
) -> None:
path = tmp_path / "save.dat"
roster = Roster(path, [hero("Ann")])
clock.advance(299)
roster.save_periodically()
assert not path.exists()
clock.advance(1)
roster.save_periodically()
assert names(path) == ["Ann"]
# the timer starts over after a save
roster.players.append(hero("Bob"))
clock.advance(299)
roster.save_periodically()
assert names(path) == ["Ann"]
clock.advance(1)
roster.save_periodically()
assert names(path) == ["Ann", "Bob"]
def test_manual_save_restarts_the_periodic_timer(
tmp_path: Path, clock: type[Clock]
) -> None:
path = tmp_path / "save.dat"
roster = Roster(path, [hero("Ann")])
clock.advance(200)
roster.save()
roster.players.append(hero("Bob"))
clock.advance(200)
roster.save_periodically()
assert names(path) == ["Ann"]
clock.advance(100)
roster.save_periodically()
assert names(path) == ["Ann", "Bob"]
+6 -6
View File
@@ -128,7 +128,7 @@ def test_game_matches_upstream(seed: str, race: str, class_: str) -> None:
# Play both from the same character and generator state, 100 ms per
# tick, and compare the whole state, generator included, each time a
# task completes.
tasks = 10000
tasks = 3000
ticks = 100 * tasks * 1000
spec = bridge.make_spec(seed, race, class_)
(theirs,) = bridge.run_harness(
@@ -162,7 +162,7 @@ def assert_same_games(
# (tasks, ticks): stop right after a task, or in the middle of one
SAVE_POINTS = [(3000, 10**9), (10**9, 123457), (10**9, 1)]
SAVE_POINTS = [(1000, 10**9), (10**9, 123457), (10**9, 1)]
@pytest.mark.parametrize("tasks,ticks", SAVE_POINTS)
@@ -176,12 +176,12 @@ def test_import_from_upstream_plays_on_identically(
(text, theirs) = bridge.run_harness(
[
{"cmd": "encode", "game": game},
{"cmd": "resume", "game": game, "ticks": 10**9, "tasks": 3000},
{"cmd": "resume", "game": game, "ticks": 10**9, "tasks": 1000},
]
)
player, warnings = import_game(decode(text), DATA)
assert warnings == []
ours = bridge.run(Simulation(player, DATA), 10**9, 3000)
ours = bridge.run(Simulation(player, DATA), 10**9, 1000)
assert_same_games(ours, theirs)
@@ -195,7 +195,7 @@ def test_export_to_upstream_plays_on_identically(
text = encode(export_game(simulation.player, DATA))
(game,) = bridge.run_harness([{"cmd": "decode", "text": text}])
(theirs,) = bridge.run_harness(
[{"cmd": "resume", "game": game, "ticks": 10**9, "tasks": 3000}]
[{"cmd": "resume", "game": game, "ticks": 10**9, "tasks": 1000}]
)
ours = bridge.run(simulation, 10**9, 3000)
ours = bridge.run(simulation, 10**9, 1000)
assert_same_games(ours, theirs)
Generated
+83 -62
View File
@@ -1,10 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.10, <4.0"
resolution-markers = [
"python_full_version >= '3.11'",
"python_full_version < '3.11'",
]
[[package]]
name = "colorama"
@@ -20,13 +16,22 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "execnet"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
@@ -59,8 +64,6 @@ name = "pqcli"
version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "urwid" },
{ name = "urwid-readline" },
{ name = "xdg-base-dirs" },
]
@@ -72,14 +75,14 @@ windows = [
[package.dev-dependencies]
dev = [
{ name = "prek" },
{ name = "pyte" },
{ name = "pytest" },
{ name = "pytest-xdist" },
{ name = "ty" },
]
[package.metadata]
requires-dist = [
{ name = "urwid", specifier = ">=2.1.2,<3.0.0" },
{ name = "urwid-readline", specifier = ">=0.13,<1.0.0" },
{ name = "windows-curses", marker = "extra == 'windows'", specifier = ">=2.3.0,<3.0.0" },
{ name = "xdg-base-dirs", specifier = ">=6.0.0,<7.0.0" },
]
@@ -88,7 +91,9 @@ provides-extras = ["windows"]
[package.metadata.requires-dev]
dev = [
{ name = "prek", specifier = ">=0.2" },
{ name = "pyte", specifier = ">=0.8" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-xdist", specifier = ">=3.0" },
{ name = "ty", specifier = ">=0.0.89" },
]
@@ -118,6 +123,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyte"
version = "0.8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/ab/b599762933eba04de7dc5b31ae083112a6c9a9db15b01d3109ad797559d9/pyte-0.8.2.tar.gz", hash = "sha256:5af970e843fa96a97149d64e170c984721f20e52227a2f57f0a54207f08f083f", size = 92301, upload-time = "2023-11-12T09:33:43.217Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/d0/bb522283b90853afbf506cd5b71c650cf708829914efd0003d615cf426cd/pyte-0.8.2-py3-none-any.whl", hash = "sha256:85db42a35798a5aafa96ac4d8da78b090b2c933248819157fc0e6f78876a0135", size = 31627, upload-time = "2023-11-12T09:33:41.096Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
@@ -136,6 +153,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-xdist"
version = "3.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "execnet" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]
[[package]]
name = "tomli"
version = "2.5.0"
@@ -233,82 +263,73 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/09/93/af8f7250920823ca6d582bcf0abf803fb92c8b99415ba7f40f2304102c6f/ty-0.0.89-py3-none-win_arm64.whl", hash = "sha256:1d4967c8f7bfd67c9708115b41d7049197c5d20c76fb2e136fe78b0437ada05b", size = 13287101, upload-time = "2026-10-16T10:35:02.918Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.11'",
]
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "urwid"
version = "2.6.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/21/ad23c9e961b2d36d57c63686a6f86768dd945d406323fb58c84f09478530/urwid-2.6.16.tar.gz", hash = "sha256:93ad239939e44c385e64aa00027878b9e5c486d59e855ec8ab5b1e1adcdb32a2", size = 848179, upload-time = "2024-10-15T16:07:24.297Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/cb/271a4f5a1bf4208dbdc96d85b9eae744cf4e5e11ac73eda76dc98c8fd2d7/urwid-2.6.16-py3-none-any.whl", hash = "sha256:de14896c6df9eb759ed1fd93e0384a5279e51e0dde8f621e4083f7a8368c0797", size = 297196, upload-time = "2024-10-15T16:07:22.521Z" },
]
[[package]]
name = "urwid-readline"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urwid" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/70/be318554495555eba7d8ff6e489f6f74ddb225b24086ba4af62a82e723fd/urwid_readline-0.15.1.tar.gz", hash = "sha256:9301444b86d58f7d26388506b704f142cefd193888488b4070d3a0fdfcfc0f84", size = 9007, upload-time = "2024-09-22T17:51:55.144Z" }
[[package]]
name = "wcwidth"
version = "0.2.14"
version = "0.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz", hash = "sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b", size = 955039, upload-time = "2026-10-05T00:24:05.521Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
{ url = "https://files.pythonhosted.org/packages/59/1e/4532a81fb9dfbf4114a816775e0a36c3a64ee1d1f4bba2094e2da50be5dc/wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07", size = 605343, upload-time = "2026-10-05T00:23:22.649Z" },
{ url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17", size = 609960, upload-time = "2026-10-05T00:23:27.049Z" },
{ url = "https://files.pythonhosted.org/packages/a4/80/15ad05d40bfa99155639fb9e13b3d77083aa0fab893c816db2543d29005c/wcwidth-0.9.2-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79", size = 762566, upload-time = "2026-10-05T00:23:38.322Z" },
{ url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724", size = 771632, upload-time = "2026-10-05T00:23:32.448Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389", size = 769763, upload-time = "2026-10-05T00:23:40.175Z" },
{ url = "https://files.pythonhosted.org/packages/92/8f/285f862826f721964ec7c42f81dc53d23afbd723a0f4cd989651f8218e25/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7", size = 782134, upload-time = "2026-10-05T00:23:33.926Z" },
{ url = "https://files.pythonhosted.org/packages/c2/2d/64aa54882a5d556d3654c1f926d9118b797461033e23a158409941a37c8f/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2", size = 782851, upload-time = "2026-10-05T00:23:41.974Z" },
{ url = "https://files.pythonhosted.org/packages/59/39/52389f6de7fe2e9c14ceb8253dd99034bd86e1c87847ea3c100a97dded9a/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04", size = 784621, upload-time = "2026-10-05T00:23:43.449Z" },
{ url = "https://files.pythonhosted.org/packages/b3/8b/20225500a076ace27bbcc8a6fd7c55125133c57a618816c7b7b8b73070b1/wcwidth-0.9.2-cp310-abi3-win32.whl", hash = "sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4", size = 593123, upload-time = "2026-10-05T00:23:55.953Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl", hash = "sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec", size = 597827, upload-time = "2026-10-05T00:23:57.489Z" },
{ url = "https://files.pythonhosted.org/packages/e5/11/6ecf4e9e268ab1a4ec617ffcccc2ee4a71301625f5490912dbaba462fa9c/wcwidth-0.9.2-cp310-abi3-win_arm64.whl", hash = "sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa", size = 598415, upload-time = "2026-10-05T00:23:51.517Z" },
{ url = "https://files.pythonhosted.org/packages/4e/41/549eef1ab767032bdbdc1f0ab655d404b082b1e9a1dab1361dbba90f64ed/wcwidth-0.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7", size = 606555, upload-time = "2026-10-05T00:23:24.188Z" },
{ url = "https://files.pythonhosted.org/packages/9b/64/a875ed7ea71cacadc0ae11b5fd3fac3486efd58bb25e67a7344248dceadd/wcwidth-0.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec", size = 610829, upload-time = "2026-10-05T00:23:28.563Z" },
{ url = "https://files.pythonhosted.org/packages/c6/98/513095e484fe79b6f2613d6a72f855f5d56b65e15c215c2a6746fbc638f5/wcwidth-0.9.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76", size = 775819, upload-time = "2026-10-05T00:23:45.116Z" },
{ url = "https://files.pythonhosted.org/packages/22/fc/c02f3eec57224731e78f84b68e272250f784b6205acc7e0dcef6a7c23a0e/wcwidth-0.9.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892", size = 786915, upload-time = "2026-10-05T00:23:35.323Z" },
{ url = "https://files.pythonhosted.org/packages/3d/6f/b0529a79bac3fe8d94f32b4237a13dbc3f955508753f6a6f06c73d679dc2/wcwidth-0.9.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e", size = 784186, upload-time = "2026-10-05T00:23:46.366Z" },
{ url = "https://files.pythonhosted.org/packages/d5/bd/6357c84ca9a734bfc735b7c48dbe21336b3777fab8a4101d14976dfe49a7/wcwidth-0.9.2-cp314-cp314t-win32.whl", hash = "sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed", size = 600791, upload-time = "2026-10-05T00:23:59.398Z" },
{ url = "https://files.pythonhosted.org/packages/98/de/037591ca18d897cc2179559dde72e6efc6ce0c90e9cd1e6bca4e87c38b4b/wcwidth-0.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f", size = 605463, upload-time = "2026-10-05T00:24:01.049Z" },
{ url = "https://files.pythonhosted.org/packages/d0/07/c9d96e106d938d26f7ab639bc80b8199359a1645ba6e3498413313ab6f38/wcwidth-0.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14", size = 605936, upload-time = "2026-10-05T00:23:52.765Z" },
{ url = "https://files.pythonhosted.org/packages/82/8a/a28d61d910005ac93dfe48be3a0ebaa49352d88cebd25323e69e6ff2f4a8/wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724", size = 606575, upload-time = "2026-10-05T00:23:25.663Z" },
{ url = "https://files.pythonhosted.org/packages/01/c2/a3c66bd32766c8f4d6dc47d572532ba014fe5be30489f2576aff7cada363/wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2", size = 611045, upload-time = "2026-10-05T00:23:30.421Z" },
{ url = "https://files.pythonhosted.org/packages/ec/8a/d39964f8f8c019d7d439b9b501d3e7bb42fee69f00354040ba0b27b5824c/wcwidth-0.9.2-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c", size = 778143, upload-time = "2026-10-05T00:23:47.7Z" },
{ url = "https://files.pythonhosted.org/packages/2f/53/525da13e8f9ff7b5b4e74ec6f8d68bdee63905796972e086c6b1b96670d2/wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d", size = 786630, upload-time = "2026-10-05T00:23:36.967Z" },
{ url = "https://files.pythonhosted.org/packages/ef/9f/d6a0c6df354b9d93466548a65cbf4ffcb48c719bbd307504cf3e76740837/wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270", size = 784839, upload-time = "2026-10-05T00:23:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/bf/d7/3021feed1ed7926021ec134943ad3b24a2f7ea742cc9976461171482ed77/wcwidth-0.9.2-cp315-cp315t-win32.whl", hash = "sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b", size = 600845, upload-time = "2026-10-05T00:24:02.497Z" },
{ url = "https://files.pythonhosted.org/packages/63/80/6a03356d8ee38261e3a78cf89ee03d8e7f12c572d969237be00869e2dc73/wcwidth-0.9.2-cp315-cp315t-win_amd64.whl", hash = "sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9", size = 605441, upload-time = "2026-10-05T00:24:04.052Z" },
{ url = "https://files.pythonhosted.org/packages/0c/48/1a308a86a833fd12ff7a08d0d2491ff4a72c8a92d12f5ead8317630f771e/wcwidth-0.9.2-cp315-cp315t-win_arm64.whl", hash = "sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8", size = 605885, upload-time = "2026-10-05T00:23:54.274Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl", hash = "sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e", size = 301667, upload-time = "2026-10-05T00:23:21.097Z" },
]
[[package]]
name = "windows-curses"
version = "2.4.1"
version = "2.4.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/34/e70815e7ae6f157c00121180a607dbcdc82acfd949c70bba28c45d206dec/windows_curses-2.4.1-cp310-cp310-win32.whl", hash = "sha256:53d711e07194d0d3ff7ceff29e0955b35479bc01465d46c3041de67b8141db2f", size = 71136, upload-time = "2025-01-11T00:26:15.273Z" },
{ url = "https://files.pythonhosted.org/packages/a6/1d/1bb512c445af24fb0a3dccf9537c8727bba5920e69539227b87f5351f608/windows_curses-2.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:325439cd4f37897a1de8a9c068a5b4c432f9244bf9c855ee2fbeb3fa721a770c", size = 81418, upload-time = "2025-01-11T00:26:16.963Z" },
{ url = "https://files.pythonhosted.org/packages/74/b3/46a2508fff83b5affb5a311797c584c494b4b0c4c8796a1afa2c1b1e03ac/windows_curses-2.4.1-cp311-cp311-win32.whl", hash = "sha256:4fa1a176bfcf098d0c9bb7bc03dce6e83a4257fc0c66ad721f5745ebf0c00746", size = 71129, upload-time = "2025-01-11T00:26:19.137Z" },
{ url = "https://files.pythonhosted.org/packages/50/29/fd7c0c80177d8df55f841504a5f332b95b0002c80f82055913b6caac94e6/windows_curses-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fd7d7a9cf6c1758f46ed76b8c67f608bc5fcd5f0ca91f1580fd2d84cf41c7f4f", size = 81431, upload-time = "2025-01-11T00:26:20.359Z" },
{ url = "https://files.pythonhosted.org/packages/f4/99/60f34e51514c82631aa5c6d21eab88ce1701562de9844608411e39462a46/windows_curses-2.4.1-cp312-cp312-win32.whl", hash = "sha256:bdbe7d58747408aef8a9128b2654acf6fbd11c821b91224b9a046faba8c6b6ca", size = 71489, upload-time = "2025-01-11T00:26:22.726Z" },
{ url = "https://files.pythonhosted.org/packages/62/8f/d908bcab1954375b156a9300fa86ceccb110f39457cd6fd59c72777626ab/windows_curses-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c9c2635faf171a229caca80e1dd760ab00db078e2a285ba2f667bbfcc31777c", size = 81777, upload-time = "2025-01-11T00:26:25.273Z" },
{ url = "https://files.pythonhosted.org/packages/25/a0/e8d074f013117633f6b502ca123ecfc377fe0bd36818fe65e8935c91ca9c/windows_curses-2.4.1-cp313-cp313-win32.whl", hash = "sha256:05d1ca01e5199a435ccb6c8c2978df4a169cdff1ec99ab15f11ded9de8e5be26", size = 71390, upload-time = "2025-01-11T00:26:27.66Z" },
{ url = "https://files.pythonhosted.org/packages/2b/4b/2838a829b074a68c570d54ae0ae8539979657d3e619a4dc5a4b03eb69745/windows_curses-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8cf653f8928af19c103ae11cfed38124f418dcdd92643c4cd17239c0cec2f9da", size = 81636, upload-time = "2025-01-11T00:26:29.595Z" },
{ url = "https://files.pythonhosted.org/packages/d1/6c/21393045010dd74887c71f1738f2bb1e60758c04d74cc97cefc03731a6d6/windows_curses-2.4.2-cp310-cp310-win32.whl", hash = "sha256:fb55b40df1dd8633f431307f204293ca2a0b4f928927490509f1bf68beef3cfe", size = 71143, upload-time = "2026-03-31T12:44:33.2Z" },
{ url = "https://files.pythonhosted.org/packages/1c/aa/4a67a104ab4d6ac4d5d5f8f80363feec688d7846180c5ca69327f602196a/windows_curses-2.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:0ff2a0884cb78d48e15eea1e274f73c1f5a780a4b872357724c17b39de75c996", size = 81848, upload-time = "2026-03-31T12:44:34.869Z" },
{ url = "https://files.pythonhosted.org/packages/d1/58/5fd430fec0ebe1cef88ddccba345ca119f1b44e58f58d6998878a01c9973/windows_curses-2.4.2-cp311-cp311-win32.whl", hash = "sha256:ecf7af9c97002af661d3e47b9c5eb31cdb7629a032adcdab46530efea8ba1864", size = 71135, upload-time = "2026-03-31T12:44:35.863Z" },
{ url = "https://files.pythonhosted.org/packages/84/62/e2bae0ab9c5c80dca9a708a4cb9c8afaf0ef61c8d4459b57f96785040831/windows_curses-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:03c4ef0095ab8212ed964236d51b4d8c815f302c5a7de94831dae468f05ce333", size = 81863, upload-time = "2026-03-31T12:44:36.834Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9c/b2a3706c8edadae8ae88b327ba072d6056ad1b492f9996214cff027e9f01/windows_curses-2.4.2-cp312-cp312-win32.whl", hash = "sha256:7ec4629685add716d494e8fbdd0b83485de726e9deeda57d30e742c77a32a156", size = 71547, upload-time = "2026-03-31T12:44:37.797Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ee/8c5da36191524856fb2c675f8cae99211ccf77bcd4e4a9010477e08000ac/windows_curses-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:df782859176f9e8142618e4966d6cee7a53a8b1c819faeb6e606a60fa4f8972a", size = 82282, upload-time = "2026-03-31T12:44:39.106Z" },
{ url = "https://files.pythonhosted.org/packages/b1/54/195f2c4d0258ced34a401c8e3c9eefa2aa2221a9f0179ae64ad3cb039e64/windows_curses-2.4.2-cp313-cp313-win32.whl", hash = "sha256:248bf3f1d973e92ca193b1ea8a626ec7f81a66be6f6d6c5e31ff82056453678d", size = 71458, upload-time = "2026-03-31T12:44:40.184Z" },
{ url = "https://files.pythonhosted.org/packages/47/e8/38fd0fef95271280a77a4f3b65e0961d3c39716388b39c08b3040bfb4f2c/windows_curses-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:1bf3d0c902374b6c430a954adcb686a5ecd7a31fc4195d31136385b5c1bea925", size = 82200, upload-time = "2026-03-31T12:44:41.503Z" },
{ url = "https://files.pythonhosted.org/packages/d2/22/f90c7d1d83464eb63345802f33e8de41bd732521d9a60a69ad985c8faa90/windows_curses-2.4.2-cp314-cp314-win32.whl", hash = "sha256:a970bf067b3126251423b4da051ef8c1c77acf6294ec96a7d6de7f6b9e5c27a0", size = 77028, upload-time = "2026-03-31T12:44:42.611Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6a/1f8754de746474a049a841e139b443b250a96908d6913ccf2db9a4303584/windows_curses-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:6c22cb0310500dfbfe54c76354e37070e300d5473596d48ac4f9c65b2d578b6f", size = 86361, upload-time = "2026-03-31T12:44:43.893Z" },
]
[[package]]
name = "xdg-base-dirs"
version = "6.0.2"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/d0/bbe05a15347538aaf9fa5b51ac3b97075dfb834931fcb77d81fbdb69e8f6/xdg_base_dirs-6.0.2.tar.gz", hash = "sha256:950504e14d27cf3c9cb37744680a43bf0ac42efefc4ef4acf98dc736cab2bced", size = 4085, upload-time = "2024-10-19T14:35:08.114Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/3b/4dc3f77ec9c5073467d95b9c9ded466cfba9a8b71a889096a01ac94b9b4e/xdg_base_dirs-6.0.3.tar.gz", hash = "sha256:9e8274f6d2514b587d257bda21d7fcf758180343c4ad5a47be0b202548211ec2", size = 4076, upload-time = "2026-09-22T06:02:52.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/03/030b47fd46b60fc87af548e57ff59c2ca84b2a1dadbe721bb0ce33896b2e/xdg_base_dirs-6.0.2-py3-none-any.whl", hash = "sha256:3c01d1b758ed4ace150ac960ac0bd13ce4542b9e2cdf01312dcda5012cfebabe", size = 4747, upload-time = "2024-10-19T14:35:05.931Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7b/e1376eb2e5c3c0c7feca70687ba1def829d848b607a7e8d1811ea9a2c155/xdg_base_dirs-6.0.3-py3-none-any.whl", hash = "sha256:c80886a8f4e369d6148fdebd7abf182bf053ce6c744f7b7eec5853fec835ff44", size = 5431, upload-time = "2026-09-22T06:02:51.472Z" },
]