mirror of
https://github.com/rr-/pq-cli.git
synced 2026-11-12 22:40:36 -07:00
tests: drive the curses UI in a pseudo-terminal
tests/pty_app.py starts pqcli with pty.fork at a fixed size, with XDG_CONFIG_HOME in a temporary directory, and feeds its output to a pyte screen. Tests send keys and poll the screen until it matches, counting a match once the output pauses or the match has held for a moment, so they never read a half-drawn frame and need no fixed sleeps. The tests cover the roster menu, creating a character (names, races, classes, rolling and unrolling stats, and every way back), the game view's panes, focus moves and page scrolling, deleting, importing and exporting .pqw files, resizing the terminal, the cheat keys, Ctrl+C and --load-save. They are skipped on Windows. pyte does not draw the DEC line drawing characters, so the app runs with NCURSES_NO_UTF8_ACS=1 to draw its boxes with Unicode lines.
This commit is contained in:
2 files changed
+1017
No files matched your search
@@ -0,0 +1,229 @@
|
|||||||
|
"""Run pqcli in a pseudo-terminal and read its screen through pyte.
|
||||||
|
|
||||||
|
Only works on POSIX: it needs pty.fork and the termios ioctls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import select
|
||||||
|
import signal
|
||||||
|
import struct
|
||||||
|
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)
|
||||||
|
argv = [sys.executable, "-m", "pqcli", *args]
|
||||||
|
|
||||||
|
pid, fd = pty.fork()
|
||||||
|
if pid == 0: # pragma: no cover - runs in the child
|
||||||
|
try:
|
||||||
|
_set_size(0, rows, cols)
|
||||||
|
if cwd is not None:
|
||||||
|
os.chdir(cwd)
|
||||||
|
os.execve(sys.executable, argv, env)
|
||||||
|
finally:
|
||||||
|
os._exit(127)
|
||||||
|
self.pid = 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)
|
||||||
|
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
||||||
|
if pid:
|
||||||
|
self.exit_status = os.waitstatus_to_exitcode(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,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()
|
||||||
Reference in new issue
Block a user