mirror of
https://github.com/rr-/pq-cli.git
synced 2026-11-12 22:40:36 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2896b54a69 | ||
|
|
9ceda9b873 | ||
|
|
c9bce751ac | ||
|
|
9689372e25 | ||
|
|
d1d7c2d9df | ||
|
|
c169102c32 | ||
|
|
60d2c54a88 | ||
|
|
f8c370cc2e | ||
|
|
083dbe01c2 | ||
|
|
92d4466f6d | ||
|
|
dde49cf2dd | ||
|
|
8d523a4af8 | ||
|
|
2fef41ca5e | ||
|
|
7573807808 |
No files matched your search
@@ -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
|
||||||
@@ -2,3 +2,4 @@ dist/
|
|||||||
build/
|
build/
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|
||||||
|
.coverage
|
||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
## [Unreleased](https://github.com/rr-/pq-cli/compare/1.1.0...main) - ××××-××-××
|
## [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
|
## [1.1.0](https://github.com/rr-/pq-cli/compare/1.0.4...1.1.0) - 2026-10-17
|
||||||
|
|
||||||
**Gameplay**
|
**Gameplay**
|
||||||
|
|||||||
+4
-5
@@ -112,7 +112,7 @@ def list_players(roster: Roster, file: TextIO | None = None) -> None:
|
|||||||
def bootstrap_first_run(
|
def bootstrap_first_run(
|
||||||
roster: Roster, args: argparse.Namespace, data: GameData
|
roster: Roster, args: argparse.Namespace, data: GameData
|
||||||
) -> None:
|
) -> 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
|
return
|
||||||
|
|
||||||
print("No saved characters found. Starting first-run character creation.")
|
print("No saved characters found. Starting first-run character creation.")
|
||||||
@@ -193,13 +193,12 @@ def main() -> None:
|
|||||||
exit(0)
|
exit(0)
|
||||||
|
|
||||||
player: Player | None = None
|
player: Player | None = None
|
||||||
if args.load_save:
|
if args.load_save is not None:
|
||||||
try:
|
if not 1 <= args.load_save <= len(roster.players):
|
||||||
player = list(roster.players)[args.load_save - 1]
|
|
||||||
except IndexError:
|
|
||||||
print("Invalid player. Available players:", file=sys.stderr)
|
print("Invalid player. Available players:", file=sys.stderr)
|
||||||
list_players(roster, file=sys.stderr)
|
list_players(roster, file=sys.stderr)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
player = roster.players[args.load_save - 1]
|
||||||
|
|
||||||
bootstrap_first_run(roster, args, data)
|
bootstrap_first_run(roster, args, data)
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ class QuestBook(SignalMixin):
|
|||||||
return self._quests[-1]
|
return self._quests[-1]
|
||||||
return None
|
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:
|
def increment_act(self) -> None:
|
||||||
self._act += 1
|
self._act += 1
|
||||||
self.emit("start_act", self._act)
|
self.emit("start_act", self._act)
|
||||||
@@ -210,6 +215,14 @@ class Inventory(SignalMixin):
|
|||||||
def __getitem__(self, idx: int) -> InventoryItem:
|
def __getitem__(self, idx: int) -> InventoryItem:
|
||||||
return self._items[idx]
|
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:
|
def add_gold(self, quantity: int) -> None:
|
||||||
logger.info(
|
logger.info(
|
||||||
"%s %s",
|
"%s %s",
|
||||||
@@ -294,6 +307,10 @@ class SpellBook(SignalMixin):
|
|||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self._spells)
|
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:
|
def add(self, spell_name: str, level: int) -> None:
|
||||||
for spell in self._spells:
|
for spell in self._spells:
|
||||||
if spell.name == spell_name:
|
if spell.name == spell_name:
|
||||||
|
|||||||
+8
-7
@@ -241,8 +241,9 @@ def import_game(
|
|||||||
player.quest_book.plot_bar.reset(*_bar(game, "PlotBar"))
|
player.quest_book.plot_bar.reset(*_bar(game, "PlotBar"))
|
||||||
player.quest_book.quest_bar.reset(*_bar(game, "QuestBar"))
|
player.quest_book.quest_bar.reset(*_bar(game, "QuestBar"))
|
||||||
|
|
||||||
player.quest_book._act = _int(game, "act")
|
player.quest_book.restore(
|
||||||
player.quest_book._quests = [str(q) for q in _field(game, "Quests")]
|
_int(game, "act"), [str(q) for q in _field(game, "Quests")]
|
||||||
|
)
|
||||||
player.quest_book.monster = _parse_monster(
|
player.quest_book.monster = _parse_monster(
|
||||||
str(game.get("questmonster", ""))
|
str(game.get("questmonster", ""))
|
||||||
)
|
)
|
||||||
@@ -256,13 +257,13 @@ def import_game(
|
|||||||
rows = _field(game, "Inventory")
|
rows = _field(game, "Inventory")
|
||||||
if not rows or rows[0][0] != "Gold":
|
if not rows or rows[0][0] != "Gold":
|
||||||
raise PqwError("field Inventory does not start with Gold")
|
raise PqwError("field Inventory does not start with Gold")
|
||||||
player.inventory._gold = _int(game, "Inventory", 0, 1)
|
player.inventory.restore(
|
||||||
player.inventory._items = [
|
_int(game, "Inventory", 0, 1),
|
||||||
InventoryItem(str(row[0]), int(row[1])) for row in rows[1:]
|
[InventoryItem(str(row[0]), int(row[1])) for row in rows[1:]],
|
||||||
]
|
)
|
||||||
player.inventory.encum_bar.reset(*_bar(game, "EncumBar"))
|
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.task = _parse_task(game)
|
||||||
player.queue = [_parse_queue_entry(e) for e in _field(game, "queue")]
|
player.queue = [_parse_queue_entry(e) for e in _field(game, "queue")]
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import argparse
|
|||||||
import curses
|
import curses
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
|
|
||||||
@@ -54,7 +53,6 @@ class CursesUserInterface(BaseUserInterface):
|
|||||||
super().__init__(roster, player, args, data)
|
super().__init__(roster, player, args, data)
|
||||||
os.environ.setdefault("ESCDELAY", "25")
|
os.environ.setdefault("ESCDELAY", "25")
|
||||||
self._screen = curses.initscr()
|
self._screen = curses.initscr()
|
||||||
self._screen.nodelay(True)
|
|
||||||
self._screen.keypad(True)
|
self._screen.keypad(True)
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
@@ -87,6 +85,7 @@ class CursesUserInterface(BaseUserInterface):
|
|||||||
self._roster_view.on_quit += self._quit
|
self._roster_view.on_quit += self._quit
|
||||||
self._view: BaseView = self._roster_view
|
self._view: BaseView = self._roster_view
|
||||||
self._view.start()
|
self._view.start()
|
||||||
|
self._wait_for_keys()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -98,8 +97,7 @@ class CursesUserInterface(BaseUserInterface):
|
|||||||
while True:
|
while True:
|
||||||
key = self._screen.getch()
|
key = self._screen.getch()
|
||||||
|
|
||||||
if key == curses.ERR:
|
if key == curses.ERR: # the view's idle interval passed
|
||||||
time.sleep(0.1)
|
|
||||||
self._view.idle()
|
self._view.idle()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -250,6 +248,13 @@ class CursesUserInterface(BaseUserInterface):
|
|||||||
self._view.stop()
|
self._view.stop()
|
||||||
self._view = view
|
self._view = view
|
||||||
self._view.start()
|
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(
|
def _create_character(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import curses
|
|||||||
|
|
||||||
|
|
||||||
class BaseView:
|
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:
|
def __init__(self, screen: curses.window) -> None:
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class PageScrollable(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class GameView(BaseView):
|
class GameView(BaseView):
|
||||||
|
idle_interval = 100
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
screen: curses.window,
|
screen: curses.window,
|
||||||
|
|||||||
+16
-3
@@ -21,8 +21,6 @@ classifiers = [
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"xdg-base-dirs>=6.0.0,<7.0.0",
|
"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]
|
[project.optional-dependencies]
|
||||||
@@ -39,7 +37,13 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[dependency-groups]
|
[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]
|
[tool.ty.src]
|
||||||
# Only the package and its tests; agent worktrees under .claude/ would
|
# 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]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["pqcli"]
|
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"]
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -130,7 +130,7 @@ def test_export_of_unplayed_character() -> None:
|
|||||||
assert player.task is None # exported a copy
|
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:
|
def test_round_trip_keeps_playing_identically(ticks: int) -> None:
|
||||||
original = played(ticks)
|
original = played(ticks)
|
||||||
copy, _warnings = import_game(
|
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)
|
assert bridge.snapshot(copy) == bridge.snapshot(original.player)
|
||||||
resumed = Simulation(copy, DATA)
|
resumed = Simulation(copy, DATA)
|
||||||
assert bridge.run(resumed, 10**8, 2000) == bridge.run(
|
assert bridge.run(resumed, 10**8, 500) == bridge.run(original, 10**8, 500)
|
||||||
original, 10**8, 2000
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_files_round_trip(tmp_path: Path) -> None:
|
def test_files_round_trip(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
# Play both from the same character and generator state, 100 ms per
|
||||||
# tick, and compare the whole state, generator included, each time a
|
# tick, and compare the whole state, generator included, each time a
|
||||||
# task completes.
|
# task completes.
|
||||||
tasks = 10000
|
tasks = 3000
|
||||||
ticks = 100 * tasks * 1000
|
ticks = 100 * tasks * 1000
|
||||||
spec = bridge.make_spec(seed, race, class_)
|
spec = bridge.make_spec(seed, race, class_)
|
||||||
(theirs,) = bridge.run_harness(
|
(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
|
# (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)
|
@pytest.mark.parametrize("tasks,ticks", SAVE_POINTS)
|
||||||
@@ -176,12 +176,12 @@ def test_import_from_upstream_plays_on_identically(
|
|||||||
(text, theirs) = bridge.run_harness(
|
(text, theirs) = bridge.run_harness(
|
||||||
[
|
[
|
||||||
{"cmd": "encode", "game": game},
|
{"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)
|
player, warnings = import_game(decode(text), DATA)
|
||||||
assert warnings == []
|
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)
|
assert_same_games(ours, theirs)
|
||||||
|
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ def test_export_to_upstream_plays_on_identically(
|
|||||||
text = encode(export_game(simulation.player, DATA))
|
text = encode(export_game(simulation.player, DATA))
|
||||||
(game,) = bridge.run_harness([{"cmd": "decode", "text": text}])
|
(game,) = bridge.run_harness([{"cmd": "decode", "text": text}])
|
||||||
(theirs,) = bridge.run_harness(
|
(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)
|
assert_same_games(ours, theirs)
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.10, <4.0"
|
requires-python = ">=3.10, <4.0"
|
||||||
resolution-markers = [
|
|
||||||
"python_full_version >= '3.11'",
|
|
||||||
"python_full_version < '3.11'",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorama"
|
name = "colorama"
|
||||||
@@ -20,13 +16,22 @@ name = "exceptiongroup"
|
|||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
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" }
|
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 = [
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
@@ -59,8 +64,6 @@ name = "pqcli"
|
|||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "urwid" },
|
|
||||||
{ name = "urwid-readline" },
|
|
||||||
{ name = "xdg-base-dirs" },
|
{ name = "xdg-base-dirs" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -72,14 +75,14 @@ windows = [
|
|||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "prek" },
|
{ name = "prek" },
|
||||||
|
{ name = "pyte" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-xdist" },
|
||||||
{ name = "ty" },
|
{ name = "ty" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
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 = "windows-curses", marker = "extra == 'windows'", specifier = ">=2.3.0,<3.0.0" },
|
||||||
{ name = "xdg-base-dirs", specifier = ">=6.0.0,<7.0.0" },
|
{ name = "xdg-base-dirs", specifier = ">=6.0.0,<7.0.0" },
|
||||||
]
|
]
|
||||||
@@ -88,7 +91,9 @@ provides-extras = ["windows"]
|
|||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "prek", specifier = ">=0.2" },
|
{ name = "prek", specifier = ">=0.2" },
|
||||||
|
{ name = "pyte", specifier = ">=0.8" },
|
||||||
{ name = "pytest", specifier = ">=8.0" },
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-xdist", specifier = ">=3.0" },
|
||||||
{ name = "ty", specifier = ">=0.0.89" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.1.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "tomli"
|
name = "tomli"
|
||||||
version = "2.5.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.16.0"
|
version = "4.16.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "wcwidth"
|
name = "wcwidth"
|
||||||
version = "0.2.14"
|
version = "0.9.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
name = "windows-curses"
|
name = "windows-curses"
|
||||||
version = "2.4.1"
|
version = "2.4.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
wheels = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
name = "xdg-base-dirs"
|
name = "xdg-base-dirs"
|
||||||
version = "6.0.2"
|
version = "6.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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" },
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user