From a69c0a4dc49114bc0f117a28b65366b2d328b8fc Mon Sep 17 00:00:00 2001 From: Stepan Pressl Date: Thu, 24 Sep 2026 23:03:35 +0200 Subject: [PATCH] feat: add `levels` and `resume` commands The levels command lists all available levels. The resume command lets user resume from a specified level. --- README.md | 17 ++ docs/spec.md | 3 + src/shellgame/cli/boot.py | 4 +- src/shellgame/cli/commands.py | 79 ++++++- src/shellgame/cli/subshell.py | 38 +++- .../cli/templates/bash_integration.template | 18 +- .../cli/templates/fish_integration.template | 13 +- src/shellgame/core/session.py | 65 +++++- src/shellgame/ui/display.py | 64 ++++++ tests/test_levels_listing.py | 213 ++++++++++++++++++ tests/test_pending_command_forwarding.py | 185 +++++++++++++++ tests/test_resume.py | 147 ++++++++++++ 12 files changed, 828 insertions(+), 18 deletions(-) create mode 100644 tests/test_levels_listing.py create mode 100644 tests/test_pending_command_forwarding.py create mode 100644 tests/test_resume.py diff --git a/README.md b/README.md index 6bf1029..105d23f 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,23 @@ move you back to the level's start directory): shellgame reset ``` +List all available levels (number and name): + +```sh +shellgame levels +``` + +Resume from a specific level (becomes your saved position, so quitting and +launching `shellgame` again continues from there): + +```sh +shellgame resume
. +``` + +Example: `shellgame resume 1.4` + +Running `shellgame resume` without a level lists the available levels. + Repeat assignment (without resetting): ```sh diff --git a/docs/spec.md b/docs/spec.md index 4b12361..753bc8f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -29,6 +29,9 @@ ShellGame is a local, single-binary, Linux terminal learning experience for firs | `shellgame status` | Show progress, time per level, hints used | | `shellgame hint --repeat` | Reprint already revealed hints without consuming a new one | | `shellgame skip` | Advance past a bonus (optional or extension) level | +| `shellgame levels` | List every level with its number and name | +| `shellgame resume LEVEL` | Jump to `LEVEL` (e.g. `1.4`) and save it as the current position | +| `shellgame resume` | With no level, lists the levels to choose from | | `shellgame repeat` | Re-show a level or a section intro | | `shellgame show --level` / `--section` | Re-show the current level or the current section intro | | `shellgame exit` | Leave the ShellGame subshell | diff --git a/src/shellgame/cli/boot.py b/src/shellgame/cli/boot.py index fc403b1..0814118 100644 --- a/src/shellgame/cli/boot.py +++ b/src/shellgame/cli/boot.py @@ -17,7 +17,7 @@ class BootResult: exit_code: int = 0 -def boot_if_needed(*, wrapped: bool, devmode: bool) -> BootResult: +def boot_if_needed(*, wrapped: bool, devmode: bool, pending_command: list[str] | None = None) -> BootResult: if wrapped: return BootResult(should_exit=False) @@ -28,5 +28,5 @@ def boot_if_needed(*, wrapped: bool, devmode: bool) -> BootResult: detected = detect_interactive_shell() target_shell = detected if detected in ("fish", "bash") else "bash" - launch_subshell(target_shell, devmode) + launch_subshell(target_shell, devmode, pending_command=pending_command) return BootResult(should_exit=True, exit_code=0) diff --git a/src/shellgame/cli/commands.py b/src/shellgame/cli/commands.py index 8286ba5..798d90d 100644 --- a/src/shellgame/cli/commands.py +++ b/src/shellgame/cli/commands.py @@ -19,7 +19,7 @@ from shellgame.cli.boot import boot_if_needed from shellgame.core.services import GameServices from shellgame.core.session import GameSession -from shellgame.levels.registry import LevelRegistry +from shellgame.levels.registry import LevelRegistry, parse_level_id from shellgame.messages import Messages from shellgame.paths import current_directory from shellgame.shell.client import ShellClient @@ -100,6 +100,61 @@ def invoke(self, ctx: click.Context) -> object: ctx.exit(1) +def _pending_command(ctx: click.Context) -> list[str]: + """The subcommand this invocation asked for, as argv tokens. + + Running `shellgame ` from an ordinary terminal has to launch the + game subshell first, which ends this process before the subcommand ever + runs. The tokens are handed to the subshell so it can run them once it is + up, instead of silently dropping what the player typed. + """ + if ctx.invoked_subcommand is None: + return [] + + argv = sys.argv[1:] + try: + start = argv.index(ctx.invoked_subcommand) + except ValueError: + # The name reaching the group differs from what was typed; forwarding a + # guess would be worse than the plain start the player already gets. + return [] + return argv[start:] + + +#: Commands that only print something. They need no workspace, no `cd` hooks +#: and no game shell, so launching one would trap the player in a subshell they +#: never asked for and have to `exit` out of. +_NO_GAME_SHELL_COMMANDS = frozenset({"levels"}) + + +def _resume_targets_a_real_level(args: list[str]) -> bool: + """Whether `resume` will actually move the player into a level. + + Anything else — no ID, a malformed one, an unknown one, `--help` — only + prints, so it must not drag a subshell up behind it. + """ + if len(args) != 1: + return False + + level_id = args[0] + if parse_level_id(level_id) is None: + return False + return cast(LevelRegistry, _module_attr("level_registry")).get(level_id) is not None + + +def _runs_without_game_shell(ctx: click.Context) -> bool: + command = _pending_command(ctx) + if not command: + return False + + name, *args = command + if name in _NO_GAME_SHELL_COMMANDS: + return True + if name == "resume": + return not _resume_targets_a_real_level(args) + return False + + @click.group(cls=CzechGroup, invoke_without_command=True) @click.option("--devmode", is_flag=True, hidden=True, help="Enable developer mode") @click.option( @@ -120,8 +175,13 @@ def cli(ctx: click.Context, devmode: bool, forced_shell: str | None) -> None: if forced_shell: os.environ["SHELLGAME_FORCE_SHELL"] = forced_shell.lower() + if not wrapped and _runs_without_game_shell(ctx): + # Runs here and the process ends, instead of booting a shell the player + # would then have to leave by hand. + return + try: - boot = boot_if_needed(wrapped=wrapped, devmode=devmode) + boot = boot_if_needed(wrapped=wrapped, devmode=devmode, pending_command=_pending_command(ctx)) except Exception as e: cast(Console, _module_attr("console")).print( f"[bold red]CHYBA: Nepodařilo se spustit herní shell ({e})[/bold red]" @@ -181,6 +241,21 @@ def reset() -> None: _get_session().reset() +@cli.command() +def levels() -> None: + """Vypsat všechny dostupné levely.""" + _get_session().levels() + + +@cli.command() +@click.argument("level_id", required=False) +@click.pass_context +def resume(ctx: click.Context, level_id: str | None) -> None: + """Pokračovat ve hře od zvoleného levelu (např. 1.4).""" + if not _get_session().resume(level_id): + ctx.exit(1) + + @cli.command() @click.option( "--section", diff --git a/src/shellgame/cli/subshell.py b/src/shellgame/cli/subshell.py index 4b6b7d0..84aba76 100644 --- a/src/shellgame/cli/subshell.py +++ b/src/shellgame/cli/subshell.py @@ -49,17 +49,37 @@ def _load_template(relative_path: str) -> str: return resources.files(package).joinpath(relative_path).read_text(encoding="utf-8") +def _quote_for_fish(argument: str) -> str: + """Single-quote an argument for fish. + + fish's single quotes are literal apart from `\\` and `'`, which is a + different rule from POSIX shells, so `shlex.quote` cannot be reused here. + """ + escaped = argument.replace("\\", "\\\\").replace("'", "\\'") + return f"'{escaped}'" + + +def _autostart_command(shell_name: str, pending_command: list[str] | None) -> str: + """Render `pending_command` so the integration's autostart can run it.""" + if not pending_command: + return "" + quote = _quote_for_fish if shell_name == "fish" else shlex.quote + return " ".join(quote(token) for token in pending_command) + + def _render_template( template_text: str, *, binary_path: str, dev_shortcuts: str, cd_hook: str, + autostart_args: str = "", ) -> str: return Template(template_text).safe_substitute( binary_path=binary_path, dev_shortcuts=dev_shortcuts, cd_hook=cd_hook, + autostart_args=autostart_args, ) @@ -128,7 +148,7 @@ def get_parent_shell() -> str: return "unknown" -def get_fish_integration(binary_path: str, devmode: bool = False) -> str: +def get_fish_integration(binary_path: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> str: template_text = _load_template("fish_integration.template") dev_shortcuts = "" @@ -142,10 +162,11 @@ def get_fish_integration(binary_path: str, devmode: bool = False) -> str: binary_path=binary_path, dev_shortcuts=dev_shortcuts, cd_hook=cd_hook, + autostart_args=_autostart_command("fish", pending_command), ) -def get_bash_integration(binary_path: str, devmode: bool = False) -> str: +def get_bash_integration(binary_path: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> str: template_text = _load_template("bash_integration.template") dev_shortcuts = "" @@ -159,6 +180,7 @@ def get_bash_integration(binary_path: str, devmode: bool = False) -> str: binary_path=binary_path, dev_shortcuts=dev_shortcuts, cd_hook=cd_hook, + autostart_args=_autostart_command("bash", pending_command), ) @@ -181,12 +203,14 @@ def _get_launcher_argv(devmode: bool) -> list[str]: return launcher_argv -def _create_integration_script(shell_name: str, binary_path: str, devmode: bool) -> str: +def _create_integration_script( + shell_name: str, binary_path: str, devmode: bool, pending_command: list[str] | None = None +) -> str: with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=f".{shell_name}") as f: if shell_name == "fish": - f.write(get_fish_integration(binary_path, devmode)) + f.write(get_fish_integration(binary_path, devmode, pending_command=pending_command)) else: - f.write(get_bash_integration(binary_path, devmode)) + f.write(get_bash_integration(binary_path, devmode, pending_command=pending_command)) return f.name @@ -220,14 +244,14 @@ def _run_subshell(argv: list[str], env: dict[str, str], debug: bool, label: str) raise RuntimeError(f"{label.capitalize()} subshell exited with code {proc.returncode}") -def launch_subshell(shell_name: str, devmode: bool = False) -> None: +def launch_subshell(shell_name: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> None: launcher_argv = _get_launcher_argv(devmode) binary_path = " ".join(shlex.quote(p) for p in launcher_argv) debug = (os.environ.get("SHELLGAME_SUBSHELL_DEBUG") or "").strip() == "1" - script_path = _create_integration_script(shell_name, binary_path, devmode) + script_path = _create_integration_script(shell_name, binary_path, devmode, pending_command) fd_hook_path: str | None = None try: diff --git a/src/shellgame/cli/templates/bash_integration.template b/src/shellgame/cli/templates/bash_integration.template index 6e32035..2f41b51 100644 --- a/src/shellgame/cli/templates/bash_integration.template +++ b/src/shellgame/cli/templates/bash_integration.template @@ -12,6 +12,13 @@ shopt -s globasciiranges 2>/dev/null || true declare -a SHELLGAME_BINARY_ARR eval "SHELLGAME_BINARY_ARR=($binary_path)" +# Command forwarded from outside the game shell; empty for a plain launch. +# Python injects already-quoted tokens, which are valid bash source here, so +# this must NOT go through eval: the quoting would be parsed a second time and +# arguments containing quotes would lose them. +declare -a SHELLGAME_AUTOSTART_ARR +SHELLGAME_AUTOSTART_ARR=($autostart_args) + # Ensure SHELLGAME_FD_HOOK exists so scripts (e.g. buggy.sh) can invoke fd-hook directly if [ -z "${SHELLGAME_FD_HOOK:-}" ] || [ ! -x "${SHELLGAME_FD_HOOK:-}" ]; then _sg_fd_hook="$(mktemp "${TMPDIR:-/tmp}/sg-fd-hook-XXXXXX.sh" 2>/dev/null || mktemp /tmp/sg-fd-hook-XXXXXX.sh)" @@ -162,7 +169,7 @@ _shellgame_completions() COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" - local commands="init hint submit skip status reset repeat show remove exit" + local commands="init hint submit skip status reset resume levels repeat show remove exit" if [ "$COMP_CWORD" -eq 1 ]; then if [[ "${cur}" == -* ]]; then @@ -205,12 +212,15 @@ export -f shellgame # This must be at the end so the shellgame function is already defined. if [ -z "${SHELLGAME_AUTO_STARTED:-}" ]; then export SHELLGAME_AUTO_STARTED=1 + # A command typed outside the game shell (e.g. `shellgame resume 6.2`) + # cannot run before this shell exists, so Python injects it here as argv + # and it is replayed once the shell is up. Empty for a plain launch. if [ "${SHELLGAME_SUBSHELL_DEBUG:-}" = "1" ]; then - echo "[shellgame][debug][bash] autostarting shellgame..." >&2 + echo "[shellgame][debug][bash] autostarting shellgame ${SHELLGAME_AUTOSTART_ARR[*]:-}..." >&2 set -x - shellgame + shellgame ${SHELLGAME_AUTOSTART_ARR[@]+"${SHELLGAME_AUTOSTART_ARR[@]}"} { set +x; } 2>/dev/null || true else - shellgame + shellgame ${SHELLGAME_AUTOSTART_ARR[@]+"${SHELLGAME_AUTOSTART_ARR[@]}"} fi fi diff --git a/src/shellgame/cli/templates/fish_integration.template b/src/shellgame/cli/templates/fish_integration.template index db30361..2246875 100644 --- a/src/shellgame/cli/templates/fish_integration.template +++ b/src/shellgame/cli/templates/fish_integration.template @@ -6,6 +6,9 @@ # We set it without quotes so Fish parses it as a list. set -gx SHELLGAME_BINARY $binary_path +# Command forwarded from outside the game shell; empty for a plain launch. +set -g SHELLGAME_AUTOSTART_LIST $autostart_args + # Ensure SHELLGAME_FD_HOOK exists so scripts (e.g. buggy.sh) can invoke fd-hook directly if not set -q SHELLGAME_FD_HOOK; or not test -x "$SHELLGAME_FD_HOOK" set -g _sg_fd_hook (mktemp -t sg-fd-hook-XXXXXX.sh 2>/dev/null; or mktemp /tmp/sg-fd-hook-XXXXXX.sh) @@ -164,13 +167,16 @@ end complete -c shellgame -f # Root commands -complete -c shellgame -n "__fish_shellgame_needs_command" -a "init hint submit skip status reset repeat show remove exit" +complete -c shellgame -n "__fish_shellgame_needs_command" -a "init hint submit skip status reset resume levels repeat show remove exit" complete -c shellgame -s h -l help -d "Zobrazit nápovědu" # Options for hint complete -c shellgame -n "__fish_seen_subcommand_from hint" -s r -l repeat -d "Zobrazit znovu všechny již zobrazené nápovědy" complete -c shellgame -n "__fish_seen_subcommand_from hint" -s h -l help -d "Zobrazit nápovědu" +# Options for resume +complete -c shellgame -n "__fish_seen_subcommand_from resume" -s h -l help -d "Zobrazit nápovědu" + # Options for repeat complete -c shellgame -n "__fish_seen_subcommand_from repeat" -l section -d "Číslo sekce ke zopakování" complete -c shellgame -n "__fish_seen_subcommand_from repeat" -l level -d "ID levelu ke zopakování" @@ -195,5 +201,8 @@ complete -c g -w shellgame # This must be at the end so the shellgame function is already defined. if test "$SHELLGAME_AUTO_STARTED" = "0" set -gx SHELLGAME_AUTO_STARTED 1 - shellgame + # A command typed outside the game shell (e.g. `shellgame resume 6.2`) + # cannot run before this shell exists, so Python injects it here as a list + # and it is replayed once the shell is up. Empty for a plain launch. + shellgame $SHELLGAME_AUTOSTART_LIST end diff --git a/src/shellgame/core/session.py b/src/shellgame/core/session.py index 3c182bd..e5701eb 100644 --- a/src/shellgame/core/session.py +++ b/src/shellgame/core/session.py @@ -17,7 +17,7 @@ from shellgame.core.navigation import NavigationManager from shellgame.core.progress import ProgressTracker from shellgame.levels.base import Level -from shellgame.levels.registry import LevelRegistry, UnknownLevelError +from shellgame.levels.registry import LevelRegistry, UnknownLevelError, parse_level_id from shellgame.paths import ContainedPathError from shellgame.shell.client import ShellClient from shellgame.state.manager import GameState, LevelCompletion, StateLoadError, StateManager @@ -255,6 +255,69 @@ def reset(self) -> None: self._export_shell_context(workspace=state.workspace, level_id=level.id) self._navigation_manager.ensure_user_in_reasonable_place(state) + def levels(self) -> None: + """List every level a player can resume into.""" + registered = self._level_registry.list_levels() + if not registered: + self._display.show_no_levels_available() + return + + state = self._state_manager.load() + self._display.show_levels( + registered, + current_level=state.current_level if state else None, + completed=set(state.levels_complete) if state else set(), + ) + + def resume(self, level_id: str | None) -> bool: + """Move the player to `level_id` and make it their saved position. + + Unlike `repeat`, which only reprints an assignment, this rewrites + `current_level`, so quitting and launching ShellGame again starts here. + + Returns whether the player was actually moved, so the CLI can exit + non-zero when they were not. + """ + # Missing ID is a usage error, but the IDs are the one thing a player + # cannot guess, so the listing comes with it rather than just a refusal. + if level_id is None: + self._display.show_missing_level_id() + self.levels() + return False + + state = self._state_manager.load() + if not state: + self._display.show_not_initialized() + return False + + # Resolved against the request, never against the saved level: an + # explicit target must win even when the save points at a level that + # this version renumbered away. + if parse_level_id(level_id) is None: + self._display.show_invalid_level_id(level_id) + return False + + if self._level_registry.get(level_id) is None: + self._display.show_unknown_level(level_id) + return False + + self._restore_workspace_if_missing(state) + + if not self._prepare_level(level_id, state): + return False + + state.current_level = level_id + # A finished run makes every gameplay command print the completion + # screen instead. Resuming into a level reopens the run, otherwise the + # player would land on the level but be unable to play it. + state.completed_at = None + self._progress_tracker.ensure_level_started(state, level_id=level_id) + self._state_manager.save(state) + + self._display.show_resumed(level_id) + self.show_current_level() + return True + def repeat(self, *, section_num: int | None, level_id: str | None) -> None: state = self._state_manager.load() if not state: diff --git a/src/shellgame/ui/display.py b/src/shellgame/ui/display.py index d404dbe..55995ce 100644 --- a/src/shellgame/ui/display.py +++ b/src/shellgame/ui/display.py @@ -294,6 +294,70 @@ def show_removed(self) -> None: def show_reset(self, level_id: str) -> None: self.console.print(f"[green]✓ Level {level_id} byl resetován.[/green]\n") + def show_resumed(self, level_id: str) -> None: + self.console.print(f"[green]✓ Pokračujete od levelu {level_id}.[/green]\n") + + _LEVEL_LIST_TIP = "[dim]Seznam všech levelů: [violet]shellgame levels[/violet][/dim]" + + def show_invalid_level_id(self, level_id: str) -> None: + self.console.print(f"[red]Chyba: Neplatné ID levelu: {level_id}[/red]") + self.note( + "[dim]Použijte formát [violet]sekce.level[/violet], například: [violet]shellgame resume 1.4[/violet][/dim]" + ) + self.note(self._LEVEL_LIST_TIP) + self.console.print() + + def show_missing_level_id(self) -> None: + self.console.print("[red]Chyba: Chybí ID levelu.[/red]") + self.note( + "[dim]Použití: [violet]shellgame resume [/violet], " + "například: [violet]shellgame resume 1.4[/violet][/dim]" + ) + self.console.print() + + def show_unknown_level(self, level_id: str) -> None: + self.console.print(f"[red]Chyba: Level {level_id} neexistuje.[/red]") + self.note(self._LEVEL_LIST_TIP) + self.console.print() + + def show_levels( + self, + levels: list[Any], + *, + current_level: str | None = None, + completed: set[str] | None = None, + ) -> None: + """List every level, grouped by section, with the player's position. + + The section intro (`X.0`) carries the section's name, so it titles the + group instead of becoming a row of its own. + """ + done = completed or set() + + table = Table(title="Dostupné levely") + table.add_column("Level", style="cyan", no_wrap=True) + table.add_column("Název") + table.add_column("", no_wrap=True) + + for level in sorted(levels, key=lambda item: _level_sort_key((item.id, item))): + marker = "" + if level.id == current_level: + marker = "[bold yellow]▸ zde[/bold yellow]" + elif level.id in done: + marker = "[green]✓[/green]" + + name = level.title + if getattr(level, "is_intro", False): + name = f"[bold]{name}[/bold]" + elif getattr(level, "is_bonus", False): + name = f"{name} [dim](nepovinný)[/dim]" + + table.add_row(level.id, name, marker) + + self.console.print(table) + self.note("[dim]Pokračovat od zvoleného levelu: [violet]shellgame resume 1.4[/violet][/dim]") + self.console.print() + def show_current_directory(self, path: str | Path) -> None: self.console.print(f"[dim]Aktuální adresář: {path}[/dim]") diff --git a/tests/test_levels_listing.py b/tests/test_levels_listing.py new file mode 100644 index 0000000..444db4b --- /dev/null +++ b/tests/test_levels_listing.py @@ -0,0 +1,213 @@ +"""Tests for `shellgame levels` and the choices `resume` offers. + +A player who does not know the level IDs has no way to guess them, so the +listing is the entry point to `resume` and must work before the game shell +exists — and without dragging one up behind it. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from shellgame.cli import commands +from shellgame.core.session import GameSession +from shellgame.state.manager import GameState, LevelCompletion +from tests.fakes import ( + _FakeConsole, + _FakeDisplay, + _FakeLevel, + _FakeRegistry, + _FakeShell, + _FakeStateManager, + _FakeWorkspace, +) + + +def _make_state(tmp_path: Path, current_level: str = "1.1") -> GameState: + return GameState( + username="student", + current_level=current_level, + workspace=tmp_path / "ws", + start_time=datetime.now(), + ) + + +def _make_session( + tmp_path: Path, levels: list[_FakeLevel], state: GameState | None +) -> tuple[GameSession, _FakeDisplay]: + workspace_root = tmp_path / "ws" + workspace_root.mkdir(parents=True, exist_ok=True) + display = _FakeDisplay() + session = GameSession( + console=_FakeConsole(), + display=display, # type: ignore[arg-type] + state_manager=_FakeStateManager(state), # type: ignore[arg-type] + level_registry=_FakeRegistry(levels), # type: ignore[arg-type] + teleport_notice=lambda _destination: None, + shell_client=_FakeShell(), # type: ignore[arg-type] + workspace_factory=lambda _workspace: _FakeWorkspace(workspace_root), # type: ignore[arg-type] + ) + return session, display + + +def _levels() -> list[_FakeLevel]: + return [_FakeLevel("1.1"), _FakeLevel("1.2"), _FakeLevel("2.0"), _FakeLevel("2.1")] + + +def test_levels_lists_every_registered_level(tmp_path: Path) -> None: + session, display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) + + session.levels() + + args, _kwargs = display.call("show_levels") + assert [level.id for level in args[0]] == ["1.1", "1.2", "2.0", "2.1"] + + +def test_levels_marks_where_the_player_is(tmp_path: Path) -> None: + state = _make_state(tmp_path, current_level="2.0") + state.levels_complete["1.1"] = LevelCompletion(time_sec=1, hints=0, attempts=0, completed_at=datetime.now()) + session, display = _make_session(tmp_path, _levels(), state) + + session.levels() + + _args, kwargs = display.call("show_levels") + assert kwargs["current_level"] == "2.0" + assert kwargs["completed"] == {"1.1"} + + +def test_levels_works_before_the_game_is_initialized(tmp_path: Path) -> None: + """The listing is how a player finds a level, so it cannot require a save.""" + session, display = _make_session(tmp_path, _levels(), None) + + session.levels() + + _args, kwargs = display.call("show_levels") + assert kwargs["current_level"] is None + assert kwargs["completed"] == set() + assert "show_not_initialized" not in display.names() + + +def test_resume_without_a_level_reports_the_usage_and_the_listing(tmp_path: Path) -> None: + """Missing ID is a usage error, but the IDs cannot be guessed either.""" + session, display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) + + session.resume(None) + + assert "show_missing_level_id" in display.names() + assert "show_levels" in display.names() + + +def test_resume_without_a_level_changes_nothing(tmp_path: Path) -> None: + state = _make_state(tmp_path, current_level="1.1") + session, _display = _make_session(tmp_path, _levels(), state) + + session.resume(None) + + assert state.current_level == "1.1" + + +@pytest.mark.parametrize("level_id", [None, "nonsense", "9.9"]) +def test_resume_reports_failure_to_the_caller(tmp_path: Path, level_id: str | None) -> None: + """The CLI turns this into a non-zero exit status.""" + session, _display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) + + assert session.resume(level_id) is False + + +def test_resume_reports_success_to_the_caller(tmp_path: Path) -> None: + session, _display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) + + assert session.resume("2.1") is True + + +def test_cli_resume_without_a_level_exits_non_zero(monkeypatch: Any) -> None: + runner = CliRunner() + + class _StubSession: + def resume(self, level_id: str | None) -> bool: + assert level_id is None + return False + + monkeypatch.setattr(commands, "_get_session", lambda: _StubSession()) + + result = runner.invoke(commands.cli, ["resume"], env={"SHELLGAME_WRAPPER": "1"}) + + assert result.exit_code == 1 + + +def test_cli_resume_with_a_level_exits_zero(monkeypatch: Any) -> None: + runner = CliRunner() + + class _StubSession: + def resume(self, level_id: str | None) -> bool: + return True + + monkeypatch.setattr(commands, "_get_session", lambda: _StubSession()) + + result = runner.invoke(commands.cli, ["resume", "2.1"], env={"SHELLGAME_WRAPPER": "1"}) + + assert result.exit_code == 0 + + +def test_unknown_level_points_at_the_listing(tmp_path: Path) -> None: + session, display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) + + session.resume("9.9") + + assert "show_unknown_level" in display.names() + + +class _FakeContext: + def __init__(self, invoked_subcommand: str | None) -> None: + self.invoked_subcommand = invoked_subcommand + + +@pytest.fixture +def _registry(monkeypatch: Any) -> None: + monkeypatch.setattr(commands, "level_registry", _FakeRegistry(_levels())) + + +def _skips_shell(argv: list[str], invoked: str, monkeypatch: Any) -> bool: + monkeypatch.setattr(commands.sys, "argv", ["shellgame", *argv]) + return commands._runs_without_game_shell(_FakeContext(invoked)) # type: ignore[arg-type] + + +@pytest.mark.usefixtures("_registry") +@pytest.mark.parametrize( + ("argv", "invoked"), + [ + (["levels"], "levels"), + (["resume"], "resume"), + (["resume", "9.9"], "resume"), + (["resume", "nonsense"], "resume"), + (["resume", "--help"], "resume"), + (["resume", "1.1", "2.1"], "resume"), + ], +) +def test_printing_only_invocations_never_launch_a_subshell(argv: list[str], invoked: str, monkeypatch: Any) -> None: + """These only print, so trapping the player in a subshell would be wrong.""" + assert _skips_shell(argv, invoked, monkeypatch) is True + + +@pytest.mark.usefixtures("_registry") +@pytest.mark.parametrize( + ("argv", "invoked"), + [ + (["resume", "2.1"], "resume"), + (["status"], "status"), + (["hint"], "hint"), + (["submit", "answer"], "submit"), + ], +) +def test_gameplay_invocations_still_launch_the_game_shell(argv: list[str], invoked: str, monkeypatch: Any) -> None: + assert _skips_shell(argv, invoked, monkeypatch) is False + + +def test_bare_launch_still_starts_the_game(monkeypatch: Any) -> None: + monkeypatch.setattr(commands.sys, "argv", ["shellgame"]) + assert commands._runs_without_game_shell(_FakeContext(None)) is False # type: ignore[arg-type] diff --git a/tests/test_pending_command_forwarding.py b/tests/test_pending_command_forwarding.py new file mode 100644 index 0000000..458a63d --- /dev/null +++ b/tests/test_pending_command_forwarding.py @@ -0,0 +1,185 @@ +"""A command typed outside the game shell must survive the subshell launch. + +`shellgame resume 6.2` in an ordinary terminal has to start the game subshell +first, which ends the original process before Click ever reaches the +subcommand. Without forwarding, what the player typed is silently dropped and +they land on their old level instead. +""" + +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +from shellgame.cli import boot, commands +from shellgame.cli.subshell import _autostart_command, get_bash_integration, get_fish_integration + + +class _FakeContext: + def __init__(self, invoked_subcommand: str | None) -> None: + self.invoked_subcommand = invoked_subcommand + + +def _pending(argv: list[str], invoked: str | None, monkeypatch: Any) -> list[str]: + monkeypatch.setattr(sys, "argv", ["shellgame", *argv]) + return commands._pending_command(_FakeContext(invoked)) # type: ignore[arg-type] + + +def test_pending_command_captures_subcommand_and_arguments(monkeypatch: Any) -> None: + assert _pending(["resume", "6.2"], "resume", monkeypatch) == ["resume", "6.2"] + + +def test_pending_command_skips_group_level_options(monkeypatch: Any) -> None: + assert _pending(["--shell", "bash", "resume", "6.2"], "resume", monkeypatch) == ["resume", "6.2"] + + +def test_pending_command_is_empty_for_a_bare_launch(monkeypatch: Any) -> None: + assert _pending([], None, monkeypatch) == [] + + +def test_pending_command_gives_up_rather_than_guessing(monkeypatch: Any) -> None: + """A resolved name that was never typed must not be forwarded as a guess.""" + assert _pending(["--shell", "bash"], "resume", monkeypatch) == [] + + +@pytest.mark.parametrize( + ("shell_name", "expected"), + [ + ("bash", "resume 6.2"), + ("fish", "'resume' '6.2'"), + ], +) +def test_autostart_command_quotes_per_shell(shell_name: str, expected: str) -> None: + assert _autostart_command(shell_name, ["resume", "6.2"]) == expected + + +def test_autostart_command_is_empty_without_a_pending_command() -> None: + assert _autostart_command("bash", None) == "" + assert _autostart_command("bash", []) == "" + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not installed") +def test_bash_quoting_survives_spaces_and_quotes() -> None: + """The rendered string must re-split into the exact original arguments.""" + argv = ["submit", "a b 'c'", "d\\e"] + + rendered = _autostart_command("bash", argv) + + assert shlex.split(rendered) == argv + + +@pytest.mark.skipif(shutil.which("fish") is None, reason="fish not installed") +def test_fish_quoting_survives_spaces_and_quotes(tmp_path: Path) -> None: + """fish escapes differently from POSIX, so real fish has to confirm it.""" + argv = ["submit", "a b 'c'", "d\\e"] + recorder = tmp_path / "args.txt" + + rendered = _autostart_command("fish", argv) + script = ( + f"function shellgame; printf '%s\\n' $argv > {shlex.quote(str(recorder))}; end; eval \"shellgame {rendered}\"" + ) + subprocess.run(["fish", "-c", script], check=True, capture_output=True, text=True) + + assert recorder.read_text(encoding="utf-8").splitlines() == argv + + +def test_boot_forwards_the_pending_command_to_the_subshell(monkeypatch: Any) -> None: + launched: dict[str, Any] = {} + + def fake_launch(shell_name: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> None: + launched["shell"] = shell_name + launched["pending"] = pending_command + + monkeypatch.setattr(boot, "launch_subshell", fake_launch) + monkeypatch.setenv("SHELLGAME_FORCE_SHELL", "bash") + + result = boot.boot_if_needed(wrapped=False, devmode=False, pending_command=["resume", "6.2"]) + + assert result.should_exit is True + assert launched["pending"] == ["resume", "6.2"] + + +def test_boot_inside_the_game_shell_does_not_relaunch(monkeypatch: Any) -> None: + def fail_launch(*args: Any, **kwargs: Any) -> None: + raise AssertionError("must not launch a subshell when already wrapped") + + monkeypatch.setattr(boot, "launch_subshell", fail_launch) + + result = boot.boot_if_needed(wrapped=True, devmode=False, pending_command=["resume", "6.2"]) + + assert result.should_exit is False + + +def _recording_binary(tmp_path: Path, recorder: Path) -> str: + stub = tmp_path / "stub.py" + stub.write_text( + "import sys, pathlib\n" + f"pathlib.Path({str(recorder)!r}).write_text(chr(10).join(sys.argv[1:]), encoding='utf-8')\n", + encoding="utf-8", + ) + return " ".join(shlex.quote(part) for part in [sys.executable, str(stub)]) + + +def _source_in_bash(script: Path) -> None: + subprocess.run( + ["bash", "--noprofile", "--norc", "-c", f"source {shlex.quote(str(script))}"], + check=False, + capture_output=True, + text=True, + env=os.environ.copy(), + ) + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not installed") +@pytest.mark.parametrize("argv", [["resume", "6.2"], ["submit", "a b 'c'"], ["levels"]]) +def test_bash_autostart_runs_the_forwarded_command(tmp_path: Path, argv: list[str]) -> None: + """End to end in real bash: the forwarded args reach the game binary intact.""" + recorder = tmp_path / "args.txt" + script = tmp_path / "integration.bash" + script.write_text( + get_bash_integration(_recording_binary(tmp_path, recorder), pending_command=argv), + encoding="utf-8", + ) + + _source_in_bash(script) + + assert recorder.read_text(encoding="utf-8").splitlines() == argv + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not installed") +def test_bash_autostart_without_forwarding_starts_the_game_plainly(tmp_path: Path) -> None: + recorder = tmp_path / "args.txt" + script = tmp_path / "integration.bash" + script.write_text(get_bash_integration(_recording_binary(tmp_path, recorder)), encoding="utf-8") + + _source_in_bash(script) + + assert recorder.read_text(encoding="utf-8") == "" + + +@pytest.mark.skipif(shutil.which("fish") is None, reason="fish not installed") +@pytest.mark.parametrize("argv", [["resume", "6.2"], ["submit", "a b 'c'"]]) +def test_fish_autostart_runs_the_forwarded_command(tmp_path: Path, argv: list[str]) -> None: + recorder = tmp_path / "args.txt" + script = tmp_path / "integration.fish" + script.write_text( + get_fish_integration(_recording_binary(tmp_path, recorder), pending_command=argv), + encoding="utf-8", + ) + + subprocess.run( + ["fish", "-c", f"source {shlex.quote(str(script))}"], + check=False, + capture_output=True, + text=True, + env=os.environ.copy(), + ) + + assert recorder.read_text(encoding="utf-8").splitlines() == argv diff --git a/tests/test_resume.py b/tests/test_resume.py new file mode 100644 index 0000000..04398dc --- /dev/null +++ b/tests/test_resume.py @@ -0,0 +1,147 @@ +"""Tests for `shellgame resume LEVEL`. + +The contract a player relies on: resuming moves them to the requested level +*and* records it, so quitting and launching ShellGame again continues there. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +import pytest + +from shellgame.core.session import GameSession +from shellgame.state.manager import GameState +from tests.fakes import ( + _FakeConsole, + _FakeDisplay, + _FakeLevel, + _FakeRegistry, + _FakeShell, + _FakeStateManager, + _FakeWorkspace, +) + + +def _make_state(tmp_path: Path, current_level: str = "1.1") -> GameState: + return GameState( + username="student", + current_level=current_level, + workspace=tmp_path / "ws", + start_time=datetime.now(), + ) + + +def _make_session( + tmp_path: Path, levels: list[_FakeLevel], state: GameState | None +) -> tuple[GameSession, _FakeDisplay, _FakeStateManager]: + display = _FakeDisplay() + state_manager = _FakeStateManager(state) + workspace_root = tmp_path / "ws" + workspace_root.mkdir(parents=True, exist_ok=True) + session = GameSession( + console=_FakeConsole(), + display=display, # type: ignore[arg-type] + state_manager=state_manager, # type: ignore[arg-type] + level_registry=_FakeRegistry(levels), # type: ignore[arg-type] + teleport_notice=lambda _destination: None, + shell_client=_FakeShell(), # type: ignore[arg-type] + workspace_factory=lambda _workspace: _FakeWorkspace(workspace_root), # type: ignore[arg-type] + ) + return session, display, state_manager + + +def _levels() -> list[_FakeLevel]: + return [_FakeLevel("1.1"), _FakeLevel("1.2"), _FakeLevel("2.0"), _FakeLevel("2.1")] + + +def test_resume_moves_to_requested_level_and_persists_it(tmp_path: Path) -> None: + state = _make_state(tmp_path, current_level="1.1") + session, display, state_manager = _make_session(tmp_path, _levels(), state) + + session.resume("2.1") + + assert state.current_level == "2.1" + assert state_manager.state is not None + assert state_manager.state.current_level == "2.1" + assert state_manager.saves >= 1 + assert "show_resumed" in display.names() + + +def test_relaunch_continues_from_resumed_level(tmp_path: Path) -> None: + """The point of the feature: the next launch starts where resume left off.""" + state = _make_state(tmp_path, current_level="1.1") + session, _, state_manager = _make_session(tmp_path, _levels(), state) + + session.resume("2.0") + + # A fresh process: new session, same persisted state. + relaunched, display, _ = _make_session(tmp_path, _levels(), state_manager.state) + relaunched.show_current_level() + + args, _kwargs = display.call("show_instructions") + assert args[0].id == "2.0" + + +def test_resume_prepares_the_target_level(tmp_path: Path) -> None: + levels = _levels() + target = next(level for level in levels if level.id == "2.1") + session, _, _ = _make_session(tmp_path, levels, _make_state(tmp_path)) + + session.resume("2.1") + + assert target.prepared >= 1 + + +def test_resume_reopens_a_finished_run(tmp_path: Path) -> None: + """Without clearing `completed_at`, every command prints the end screen.""" + state = _make_state(tmp_path, current_level="2.1") + state.completed_at = datetime.now() + session, display, _ = _make_session(tmp_path, _levels(), state) + + session.resume("1.2") + + assert state.completed_at is None + assert state.current_level == "1.2" + assert "show_game_complete" not in display.names() + + +def test_resume_starts_the_clock_for_an_unvisited_level(tmp_path: Path) -> None: + state = _make_state(tmp_path, current_level="1.1") + session, _, _ = _make_session(tmp_path, _levels(), state) + + session.resume("2.1") + + assert "2.1" in state.level_started_at + + +def test_resume_keeps_the_original_start_time_when_revisiting(tmp_path: Path) -> None: + state = _make_state(tmp_path, current_level="2.1") + first_seen = datetime(2020, 1, 1, 12, 0, 0) + state.level_started_at["1.2"] = first_seen + session, _, _ = _make_session(tmp_path, _levels(), state) + + session.resume("1.2") + + assert state.level_started_at["1.2"] == first_seen + + +@pytest.mark.parametrize("level_id", ["9.9", "nope", "1", "1.x", ""]) +def test_resume_rejects_unusable_level_ids_without_touching_state(tmp_path: Path, level_id: str) -> None: + state = _make_state(tmp_path, current_level="1.1") + session, _, state_manager = _make_session(tmp_path, _levels(), state) + + session.resume(level_id) + + assert state.current_level == "1.1" + assert state_manager.saves == 0 + + +def test_resume_without_a_save_reports_not_initialized(tmp_path: Path) -> None: + session, display, state_manager = _make_session(tmp_path, _levels(), None) + + session.resume("1.2") + + assert "show_not_initialized" in display.names() + assert state_manager.saves == 0