"""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 "" 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