Spaces:
Running on Zero
Running on Zero
| """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 | |