Scrypt / tests /test_ui.py
IMJONEZZ's picture
SCRYPT: initial commit — game, sandbox, Warden, Space web layer
9fca766
Raw
History Blame Contribute Delete
20.6 kB
"""Headless UI tests via Textual Pilot: full app, board, draft, run loop."""
from textual.app import App
from scrypt.app import ScryptApp
from scrypt.data import load_content
from scrypt.engine.combat import Phase
from scrypt.ui.board import BoardScreen
from scrypt.ui.choice import CardChoiceScreen, RunEndScreen
async def test_app_opens_on_first_battle():
app = ScryptApp(seed=42, skip_menu=True)
async with app.run_test() as pilot:
await pilot.pause()
assert isinstance(app.screen, BoardScreen)
assert app.screen.state.phase is Phase.DRAW
async def test_draw_play_bit_and_ring_bell():
app = ScryptApp(seed=42, skip_menu=True)
async with app.run_test() as pilot:
await pilot.pause()
board = app.screen
await pilot.press("s") # take a bit (appended last in hand)
assert board.state.phase is Phase.MAIN
await pilot.press("left") # wrap selection to the drawn bit
await pilot.press("enter") # free card -> straight to lane mode
await pilot.press("1")
assert board.state.player_row[0] is not None
assert board.state.player_row[0].spec.id == "bit"
await pilot.press("b") # ring the bell
assert board.state.turn == 1
async def test_sacrifice_flow():
app = ScryptApp(seed=42, skip_menu=True)
async with app.run_test() as pilot:
await pilot.pause()
board = app.screen
await pilot.press("s")
await pilot.press("left", "enter", "1") # bit into lane 1
await pilot.press("b")
await pilot.press("s") # next turn: another bit
await pilot.press("left", "enter", "2") # bit into lane 2
# Find a 1-mem card in hand and play it over a sacrificed bit.
idx = next(
i for i, c in enumerate(board.state.hand) if c.spec.cost.amount == 1
)
board.selected = idx
await pilot.press("enter") # sacrifice mode
await pilot.press("1") # mark the bit in lane 1
await pilot.press("enter") # confirm the kills
await pilot.press("1") # place into the freed lane
played = board.state.player_row[0]
assert played is not None and played.spec.cost.amount == 1
async def test_pacifist_run_burns_ttys_and_ends(tmp_path, monkeypatch):
"""Lose every fight by doing nothing: 2 TTYs burn, then the end screen."""
monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) # isolate legacy.json
app = ScryptApp(seed=7, skip_menu=True)
async with app.run_test() as pilot:
await pilot.pause()
from scrypt.ui.interview import ExitInterviewScreen
for _ in range(80):
screen = app.screen
if isinstance(screen, RunEndScreen):
break
if isinstance(screen, ExitInterviewScreen):
# Decline to comment; the record is kept anyway.
await pilot.press("enter", "space")
continue
assert isinstance(screen, BoardScreen)
state = screen.state
if state.phase is Phase.OVER:
await pilot.press("space") # leave the table
elif state.phase is Phase.DRAW:
await pilot.press("s" if state.can_draw_side else "d")
else:
await pilot.press("b")
assert isinstance(app.screen, RunEndScreen)
assert app.run_state.ttys == 0
assert not app.run_state.victorious
from scrypt.engine import legacy as legacy_store
filed = legacy_store.load()
assert filed["crashes"]
assert filed["crashes"][-1]["statement"] == "(they said nothing)"
assert filed["estate"] is None or filed["estate"]["cycles"] >= 0
assert filed["shards"] # the Warden remembers
await pilot.press("space") # exit
assert app.return_value is None
async def test_card_choice_screen_picks_a_card():
content = load_content()
options = [content.card("packet"), content.card("tarpit"), content.card("root")]
picked = []
class Host(App):
def on_mount(self):
self.push_screen(CardChoiceScreen(options, "test"), picked.append)
async with Host().run_test() as pilot:
await pilot.pause()
await pilot.press("right", "enter")
assert picked == [options[1]]
async def test_abandon_exits_app():
app = ScryptApp(seed=42, skip_menu=True)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("q")
assert app.return_value is None
async def test_full_victorious_run_through_all_nodes(tmp_path, monkeypatch):
"""Win two empty-script fights; pass through shell, altar, and draft,
then the victory lap: the root epilogue and the exfiltration pick."""
import scrypt.app as app_mod
from textual.widgets import Input
from scrypt.engine.cards import CardInstance, make_card
monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) # isolate legacy.json
monkeypatch.setattr(app_mod, "RUN_ENCOUNTERS", ["test_empty", "test_empty"])
app = ScryptApp(seed=5, skip_menu=True)
app.content.encounters["test_empty"] = {"name": "t", "script": [[]]}
titan = make_card("titan", power=9, health=9)
async with app.run_test() as pilot:
for _ in range(40):
await pilot.pause()
screen = app.screen
if isinstance(screen, RunEndScreen):
break
if isinstance(screen, BoardScreen):
state = screen.state
if state.phase is Phase.OVER:
await pilot.press("space")
elif state.phase is Phase.DRAW:
state.player_row[0] = CardInstance(spec=titan) # test shortcut
await pilot.press("s")
else:
await pilot.press("b")
elif type(screen).__name__ == "ShellScreen":
prompt = screen.query_one(Input)
prompt.value = "fight"
await pilot.press("enter")
elif type(screen).__name__ == "AltarScreen":
await pilot.press("a") # surrender the command
await pilot.press("space") # step away
elif isinstance(screen, CardChoiceScreen):
await pilot.press("enter")
assert isinstance(app.screen, RunEndScreen)
assert app.run_state.victorious
assert len(app.session.shell.revoked) == 1 # the altar took its due
assert len(app.run_state.deck) == 10 # 8 start + altar card + drafted card
from scrypt.engine import legacy as legacy_store
ledger = legacy_store.load()
assert ledger["wins"] == 1 and ledger["runs"] == 1
assert ledger["contraband"] is not None # the exfiltration pick
assert ledger["intel"] is not None # the diary password persists
await pilot.press("space")
assert app.return_value is None
async def test_altar_refusal_deletes_personal_files():
import random
from textual.app import App
from scrypt.data import load_content
from scrypt.sandbox.fabricate import fabricate_home
from scrypt.sandbox.shell import Shell
from scrypt.sandbox.vfs import VFS
from scrypt.ui.altar import AltarScreen
vfs = VFS()
fabricate_home(vfs, seed=2)
shell = Shell(vfs)
before = vfs.total_files()
content = load_content()
outcome = []
class Host(App):
def on_mount(self):
self.push_screen(
AltarScreen(shell, content.card("root"), random.Random(0)),
outcome.append,
)
async with Host().run_test() as pilot:
await pilot.pause()
await pilot.press("r") # refuse the deal
await pilot.press("space") # step away
assert outcome == [{"card": None, "paid": None}]
assert vfs.total_files() < before # the Warden took something anyway
assert not shell.revoked # but the command was kept
async def test_altar_speculates_and_speaks_on_resolution():
import random as random_mod
from textual.app import App
from scrypt.data import load_content
from scrypt.sandbox.fabricate import fabricate_home
from scrypt.sandbox.shell import Shell
from scrypt.sandbox.vfs import VFS
from scrypt.ui.altar import AltarScreen
from scrypt.warden.presence import WardenPresence
vfs = VFS()
fabricate_home(vfs, seed=3)
shell = Shell(vfs)
shell.run("ls") # give most_used() something to want
content = load_content()
presence = WardenPresence() # voiceless
prefetched: list[str] = []
submitted: list[str] = []
presence.prefetch = lambda key, moment, **kw: prefetched.append(key)
orig_submit = presence.submit
def spy(moment, **kw):
submitted.append(kw.get("key"))
orig_submit(moment, **kw)
presence.submit = spy
outcome = []
class Host(App):
def on_mount(self):
self.push_screen(
AltarScreen(shell, content.card("root"), random_mod.Random(0),
contraband=content.card("segfault"), presence=presence),
outcome.append,
)
async with Host().run_test() as pilot:
await pilot.pause()
# While the player hesitates, every ending is already cooking.
assert set(prefetched) == {"altar_accept", "altar_refuse", "altar_contraband"}
await pilot.press("t") # hand over the contraband
await pilot.press("space")
assert outcome == [{"card": "root", "paid": "contraband"}]
assert submitted == ["altar_contraband"]
presence.stop()
async def test_shell_is_haunted_and_answers_say():
from textual.app import App
from textual.widgets import Input
from scrypt.sandbox.fabricate import fabricate_home
from scrypt.sandbox.shell import Shell
from scrypt.sandbox.vfs import VFS
from scrypt.ui.shell import ShellScreen
from scrypt.warden import watcher
from scrypt.warden.presence import ANSWER, QUIP, WardenPresence
vfs = VFS()
fabricate_home(vfs, seed=4)
vfs.write("/var/log/warden.log", "audit: everything")
shell = Shell(vfs)
presence = WardenPresence() # voiceless: scripted fallbacks
submissions: list[tuple[str, int]] = []
orig_submit = presence.submit
def spy(moment, **kw):
submissions.append((moment, kw.get("priority")))
orig_submit(moment, **kw)
presence.submit = spy
screen = ShellScreen(shell, [], [], presence=presence)
class Host(App):
def on_mount(self):
self.push_screen(screen, lambda _: None)
async with Host().run_test() as pilot:
await pilot.pause()
prompt = screen.query_one(Input)
prompt.value = "say are you watching me"
await pilot.press("enter")
assert screen._say_spent == 1
assert submissions[-1][1] == ANSWER
assert "<player_input>" in submissions[-1][0] # wrapped inert
prompt.value = "cat /var/log/warden.log"
await pilot.press("enter")
assert submissions[-1][1] == QUIP
assert "audit log" in submissions[-1][0]
# Past the budget the Warden brushes you off without the model.
screen._say_spent = screen._say_budget
brushed = []
screen._broadcast = lambda line, pr: brushed.append(line)
prompt.value = "say hello again"
await pilot.press("enter")
assert brushed and brushed[0] in watcher.BRUSH_OFFS
presence.stop()
async def test_menu_flow_deck_select_and_howto(tmp_path, monkeypatch):
from scrypt.ui.menu import HowToPlayScreen, MenuScreen
from scrypt.ui.tutorial import OrientationScreen
monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) # a fresh installation
app = ScryptApp(seed=42) # menu NOT skipped
async with app.run_test() as pilot:
await pilot.pause()
assert isinstance(app.screen, MenuScreen)
await pilot.press("right") # cycle to second deck
await pilot.press("h")
await pilot.pause()
assert isinstance(app.screen, HowToPlayScreen)
await pilot.press("space") # back to menu
await pilot.pause()
await pilot.press("enter") # begin with selected deck
await pilot.pause()
# A first game ever passes through orientation before the table.
assert isinstance(app.screen, OrientationScreen)
await pilot.press("s") # the impatient may skip
await pilot.pause()
assert isinstance(app.screen, BoardScreen)
assert app.deck_id == "forkstorm"
assert app.screen.tutorial # first fight teaches
async def test_orientation_runs_once_per_installation(tmp_path, monkeypatch):
from scrypt.engine import legacy as legacy_store
from scrypt.ui.tutorial import PAGES, OrientationScreen
monkeypatch.setenv("SCRYPT_HOME", str(tmp_path))
app = ScryptApp(seed=1)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("enter") # begin
await pilot.pause()
assert isinstance(app.screen, OrientationScreen)
await pilot.press("backspace") # can't back off the first page
assert app.screen.page == 0
for _ in range(len(PAGES)): # read every page to the end
await pilot.press("enter")
await pilot.pause()
assert isinstance(app.screen, BoardScreen)
assert legacy_store.load()["initiated"] is True
# Same installation, new game: straight to the table.
again = ScryptApp(seed=2)
async with again.run_test() as pilot:
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert isinstance(again.screen, BoardScreen)
async def test_board_inspects_foes_and_opens_help():
from scrypt.ui.menu import HowToPlayScreen
from scrypt.engine.cards import CardInstance
app = ScryptApp(seed=42, skip_menu=True)
async with app.run_test() as pilot:
await pilot.pause()
board = app.screen
foe = CardInstance(spec=app.content.card("fork-bomb"))
board.state.foe_row[1] = foe # test shortcut: stage something to read
await pilot.press("tab")
assert board.inspect == 1
info = str(board._card_info())
assert "the warden's" in info and foe.name in info
await pilot.press("tab") # walk off the end of the row
assert board.inspect is None
await pilot.press("question_mark")
await pilot.pause()
assert isinstance(app.screen, HowToPlayScreen)
async def test_sigil_glossary_opens_from_board_and_draft():
from scrypt.ui.glossary import SigilGlossaryScreen
app = ScryptApp(seed=42, skip_menu=True)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("g")
await pilot.pause()
# Content completeness is guaranteed by construction: the page
# loops over SIGIL_EXPLAIN, which a render test keeps exhaustive.
assert isinstance(app.screen, SigilGlossaryScreen)
await pilot.press("space")
await pilot.pause()
assert isinstance(app.screen, BoardScreen)
def test_say_budget_rolls_between_two_and_seven():
import random as random_mod
from scrypt.sandbox.fabricate import fabricate_home
from scrypt.sandbox.shell import Shell
from scrypt.sandbox.vfs import VFS
from scrypt.ui.shell import ShellScreen
vfs = VFS()
fabricate_home(vfs, seed=1)
shell = Shell(vfs)
random_mod.seed(11)
budgets = {ShellScreen(shell, [], [])._say_budget for _ in range(60)}
assert budgets <= set(range(2, 8))
assert 2 in budgets and 7 in budgets # both ends actually reachable
async def test_shell_history_and_tab_completion():
from textual.app import App
from textual.widgets import Input
from scrypt.sandbox.shell import Shell
from scrypt.sandbox.vfs import VFS
from scrypt.ui.shell import ShellScreen
vfs = VFS()
vfs.write("/home/drifter/notes.txt", "hello")
vfs.write("/home/drifter/.secret", "shh")
vfs.mkdir("/home/drifter/notebooks")
shell = Shell(vfs)
screen = ShellScreen(shell, [], [])
class Host(App):
def on_mount(self):
self.push_screen(screen, lambda _: None)
async with Host().run_test() as pilot:
await pilot.pause()
prompt = screen.query_one(Input)
# ---- history: two commands, then walk back and forward
prompt.value = "pwd"
await pilot.press("enter")
prompt.value = "ls"
await pilot.press("enter")
await pilot.press("up")
assert prompt.value == "ls"
await pilot.press("up")
assert prompt.value == "pwd"
await pilot.press("down", "down")
assert prompt.value == "" # back at the live prompt
# ---- completion: command name, then a path
prompt.value = "pw"
prompt.cursor_position = 2
await pilot.press("tab")
assert prompt.value == "pwd "
prompt.value = "cat note"
prompt.cursor_position = len(prompt.value)
await pilot.press("tab") # notes.txt vs notebooks/ -> common prefix
assert prompt.value.endswith("note")
prompt.value = "cat notes"
prompt.cursor_position = len(prompt.value)
await pilot.press("tab")
assert prompt.value == "cat notes.txt "
prompt.value = "cat noteb"
prompt.cursor_position = len(prompt.value)
await pilot.press("tab")
assert prompt.value == "cat notebooks/" # dirs complete open-ended
# hidden files stay hidden until the prefix reaches for them
prompt.value = "cat .se"
prompt.cursor_position = len(prompt.value)
await pilot.press("tab")
assert prompt.value == "cat .secret "
# sold commands do not complete: they are gone
shell.revoke("grep", "sold")
prompt.value = "gre"
prompt.cursor_position = len(prompt.value)
await pilot.press("tab")
assert prompt.value == "gre"
async def test_menu_offers_carving_only_when_carvable():
from textual.app import App
from scrypt.ui.menu import MenuScreen
content = load_content()
picked = []
class Host(App):
def on_mount(self):
self.push_screen(
MenuScreen(content.starter_decks, "scripted", carvable=True),
picked.append,
)
async with Host().run_test() as pilot:
await pilot.pause()
await pilot.press("c")
assert picked == ["::carve::"]
picked.clear()
class Deaf(App):
def on_mount(self):
self.push_screen(
MenuScreen(content.starter_decks, "local", carvable=False),
picked.append,
)
async with Deaf().run_test() as pilot:
await pilot.pause()
await pilot.press("c") # ignored: nothing to carve
assert picked == []
async def test_menu_quit_exits():
from scrypt.ui.menu import MenuScreen
app = ScryptApp(seed=42)
async with app.run_test() as pilot:
await pilot.pause()
assert isinstance(app.screen, MenuScreen)
await pilot.press("q")
assert app.return_value is None
async def test_deck_viewer_opens_and_closes():
from scrypt.ui.deckview import DeckViewScreen
app = ScryptApp(seed=42, skip_menu=True)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("s") # leave draw phase
await pilot.press("v")
await pilot.pause()
assert isinstance(app.screen, DeckViewScreen)
await pilot.press("space")
await pilot.pause()
assert isinstance(app.screen, BoardScreen)
async def test_menu_surfaces_backend_failure(monkeypatch):
"""A broken local setup must explain itself, not silently go scripted."""
import scrypt.app as app_mod
from scrypt.ui.menu import MenuScreen
def explode():
raise RuntimeError("llama-server exited during startup: unknown architecture")
monkeypatch.setattr(app_mod, "build_backend", explode)
app = ScryptApp(seed=42)
app._backend = None # force the wake path
async with app.run_test() as pilot:
await pilot.pause()
screen = app.screen
assert isinstance(screen, MenuScreen)
assert app.backend_mode == "scripted"
assert "unknown architecture" in app.backend_error
assert not app.backend_waking
subtitle = str(screen.query_one("#subtitle").render())
assert "why:" in subtitle