| """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") |
| assert board.state.phase is Phase.MAIN |
| await pilot.press("left") |
| await pilot.press("enter") |
| 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") |
| 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") |
| await pilot.press("b") |
| await pilot.press("s") |
| await pilot.press("left", "enter", "2") |
| |
| idx = next( |
| i for i, c in enumerate(board.state.hand) if c.spec.cost.amount == 1 |
| ) |
| board.selected = idx |
| await pilot.press("enter") |
| await pilot.press("1") |
| await pilot.press("enter") |
| await pilot.press("1") |
| 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)) |
| 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): |
| |
| await pilot.press("enter", "space") |
| continue |
| assert isinstance(screen, BoardScreen) |
| state = screen.state |
| if state.phase is Phase.OVER: |
| await pilot.press("space") |
| 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"] |
| await pilot.press("space") |
| 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_settings_screen_saves_byo_api(monkeypatch, tmp_path): |
| """A player picks 'My own API endpoint', fills it in, Save persists it.""" |
| from textual.widgets import Button, Input, RadioSet |
|
|
| from scrypt.inference import config |
| from scrypt.ui.settings import SettingsScreen |
|
|
| monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) |
| result = [] |
|
|
| class Host(App): |
| def on_mount(self): |
| self.push_screen(SettingsScreen(), result.append) |
|
|
| async with Host().run_test(size=(100, 44)) as pilot: |
| await pilot.pause() |
| scr = pilot.app.screen |
| scr.query_one("#mode", RadioSet).focus() |
| await pilot.click("#mode-api") |
| scr.query_one("#base", Input).value = "http://localhost:8080/v1" |
| scr.query_one("#key", Input).value = "sk-test" |
| scr.query_one("#model", Input).value = "warden" |
| await pilot.click("#save") |
| await pilot.pause() |
|
|
| assert result == [True] |
| saved = config.load() |
| assert saved["backend"] == "api" and saved["api_model"] == "warden" |
|
|
|
|
| async def test_settings_screen_cancel_changes_nothing(monkeypatch, tmp_path): |
| from textual.app import App as _App |
|
|
| from scrypt.inference import config |
| from scrypt.ui.settings import SettingsScreen |
|
|
| monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) |
| result = [] |
|
|
| class Host(_App): |
| def on_mount(self): |
| self.push_screen(SettingsScreen(), result.append) |
|
|
| async with Host().run_test(size=(100, 44)) as pilot: |
| await pilot.pause() |
| await pilot.click("#cancel") |
| await pilot.pause() |
|
|
| assert result == [False] |
| assert not (tmp_path / "settings.json").exists() |
|
|
|
|
| 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)) |
| 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) |
| 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") |
| await pilot.press("space") |
| 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 |
| assert len(app.run_state.deck) == 10 |
| 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 |
| assert ledger["intel"] is not None |
| 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") |
| await pilot.press("space") |
| assert outcome == [{"card": None, "paid": None}] |
| assert vfs.total_files() < before |
| assert not shell.revoked |
|
|
|
|
| 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") |
| content = load_content() |
| presence = WardenPresence() |
| 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() |
| |
| assert set(prefetched) == {"altar_accept", "altar_refuse", "altar_contraband"} |
| await pilot.press("t") |
| 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() |
| 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] |
|
|
| prompt.value = "cat /var/log/warden.log" |
| await pilot.press("enter") |
| assert submissions[-1][1] == QUIP |
| assert "audit log" in submissions[-1][0] |
|
|
| |
| 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 |
|
|
| monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) |
| app = ScryptApp(seed=42) |
| async with app.run_test() as pilot: |
| await pilot.pause() |
| assert isinstance(app.screen, MenuScreen) |
| await pilot.press("right") |
| await pilot.press("h") |
| await pilot.pause() |
| assert isinstance(app.screen, HowToPlayScreen) |
| await pilot.press("space") |
| await pilot.pause() |
| await pilot.press("enter") |
| await pilot.pause() |
| |
| assert isinstance(app.screen, BoardScreen) |
| assert app.screen.guided is not None |
| |
| await pilot.press("b") |
| await pilot.pause() |
| assert app.screen._lesson_pos == 0 |
| await pilot.press("escape") |
| await pilot.pause() |
| |
| assert isinstance(app.screen, BoardScreen) |
| assert app.screen.guided is None |
| assert app.deck_id == "forkstorm" |
| assert app.screen.tutorial |
|
|
|
|
| async def test_tutorial_plays_through_once_per_installation(tmp_path, monkeypatch): |
| from scrypt.engine import legacy as legacy_store |
| from scrypt.ui.shell import ShellScreen |
| from scrypt.ui.tutorial import LESSON |
|
|
| monkeypatch.setenv("SCRYPT_HOME", str(tmp_path)) |
| monkeypatch.setenv("SCRYPT_REDUCED_MOTION", "1") |
| app = ScryptApp(seed=1) |
| async with app.run_test() as pilot: |
| await pilot.pause() |
| await pilot.press("enter") |
| await pilot.pause() |
| assert isinstance(app.screen, BoardScreen) and app.screen.guided is not None |
| for key, _hint in LESSON: |
| await pilot.press(key) |
| await pilot.pause() |
| await pilot.press("enter") |
| await pilot.pause() |
| |
| assert isinstance(app.screen, ShellScreen) |
| assert sorted(app.screen.shell.available()) == ["help", "ls", "pwd"] |
| app.screen.query_one("#shell-prompt").value = "fight" |
| await pilot.press("enter") |
| await pilot.pause() |
| |
| assert isinstance(app.screen, BoardScreen) and app.screen.guided is None |
| assert legacy_store.load()["initiated"] is True |
|
|
| |
| 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) and again.screen.guided is None |
|
|
|
|
| def test_tutorial_lesson_takes_damage_then_wins(): |
| """The hard-railed lesson, played on the engine, must (a) put the player |
| behind so they see damage, then (b) reach a win via sacrifice — so the |
| rails can never strand a player in an unwinnable fight.""" |
| from scrypt.data import load_content |
| from scrypt.engine.combat import Phase, Result |
| from scrypt.ui import tutorial as tut |
|
|
| state = tut.build_fight(load_content()) |
| |
| state.draw("main") |
| state.play(0, 1) |
| state.ring_bell() |
| |
| state.draw("side") |
| state.play(0, 2) |
| state.ring_bell() |
| assert state.scale < 0 |
| |
| state.draw("main") |
| state.play(0, 3, sacrifices=(1, 2)) |
| assert state.dumps == 2 |
| state.ring_bell() |
| assert state.phase is Phase.OVER and state.result is Result.PLAYER_WIN |
|
|
|
|
| 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 |
| 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") |
| 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() |
| |
| |
| 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 |
|
|
|
|
| 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) |
|
|
| |
| 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 == "" |
|
|
| |
| 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") |
| 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/" |
|
|
| |
| prompt.value = "cat .se" |
| prompt.cursor_position = len(prompt.value) |
| await pilot.press("tab") |
| assert prompt.value == "cat .secret " |
|
|
| |
| 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") |
| 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") |
| 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 |
| 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 |
|
|