"""`petctl self_restart` — the pet restarting itself and remembering why. Everything here runs against a temp context file and a fake subprocess runner, so the tests exercise the arming/preflight/report logic without any process actually dying. """ import subprocess import sys from pathlib import Path from types import SimpleNamespace import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from bolt_pet import pet_actions, self_restart @pytest.fixture def state(tmp_path): return tmp_path / "restart_context.json" def _ok_run(*args, **kwargs): return SimpleNamespace(returncode=0, stdout="", stderr="") def _broken_run(*args, **kwargs): return SimpleNamespace( returncode=1, stdout="", stderr=' File "bolt_pet/controller.py", line 42\n def _speak(\nSyntaxError: invalid syntax', ) # ── parsing ───────────────────────────────────────────────────────────────── def test_self_restart_parses_with_a_free_text_reason(): action = pet_actions.parse("petctl self_restart check the new walk cycle loads") assert action == { "action": "self_restart", "reason": "check the new walk cycle loads", } def test_self_restart_needs_no_reason_and_accepts_aliases(): assert pet_actions.parse("petctl self_restart")["reason"] == "" assert pet_actions.parse("petctl restart")["action"] == "self_restart" assert pet_actions.parse("petctl reboot")["action"] == "self_restart" def test_self_restart_is_listed_in_the_help(): assert "self_restart" in pet_actions.HELP # ── preflight ─────────────────────────────────────────────────────────────── def test_preflight_passes_when_the_code_imports(): self_restart.preflight(run=_ok_run) # no exception def test_preflight_hands_back_the_traceback_instead_of_dying(state): """The whole point: a syntax error Bolt just introduced comes back as something he can read and fix, in the same turn, with the pet still up.""" with pytest.raises(self_restart.RestartError) as excinfo: self_restart.preflight(run=_broken_run) message = str(excinfo.value) assert "does not import" in message assert "SyntaxError" in message and "controller.py" in message def test_preflight_runs_the_import_in_a_subprocess_not_here(): """This process holds the *old* modules, so an in-process import would pass on a file that no longer parses.""" seen = {} def capture(cmd, **kwargs): seen["cmd"], seen["kwargs"] = cmd, kwargs return SimpleNamespace(returncode=0, stdout="", stderr="") self_restart.preflight(run=capture) assert seen["cmd"][0] == sys.executable assert "import bolt_pet" in seen["cmd"][2] assert seen["kwargs"]["env"]["QT_QPA_PLATFORM"] == "offscreen" # imports need no display def test_a_subprocess_that_cannot_even_run_is_reported(monkeypatch): def explode(*args, **kwargs): raise OSError("no python here") with pytest.raises(self_restart.RestartError, match="couldn't run the preflight"): self_restart.preflight(run=explode) # ── context across the restart ────────────────────────────────────────────── def test_arming_persists_the_reason_for_the_next_process(state): self_restart.arm("check the sprite frames load", version="0.2.3", session="pet-desktop", recent=["you: reload the sprites"], path=state, now=1000.0) revived = self_restart.load(state) assert revived.reason == "check the sprite frames load" assert revived.version == "0.2.3" assert revived.recent == ["you: reload the sprites"] assert revived.restarts == [1000.0] def test_no_context_means_a_normal_start(state): assert self_restart.load(state) is None def test_a_corrupt_context_file_is_ignored_not_fatal(state): state.write_text("{not json at all", encoding="utf-8") assert self_restart.load(state) is None def test_clearing_the_context_stops_it_being_re_announced(state): self_restart.arm("once", path=state, now=1000.0) self_restart.clear(state) assert self_restart.load(state) is None self_restart.clear(state) # clearing twice is not an error def test_the_report_says_what_happened_and_what_to_check(state): context = self_restart.arm( "verify the dialogue command works", verify="verify the dialogue command works", version="0.2.3", recent=["you: try a scene"], path=state, now=1000.0, ) text = self_restart.report(context, version="0.2.4", now=1004.5) assert "I restarted myself" in text assert "verify the dialogue command works" in text assert "4.5s" in text assert "0.2.4" in text and "was 0.2.3" in text assert "you: try a scene" in text # ── loop guard ────────────────────────────────────────────────────────────── def test_restart_history_accumulates_across_restarts(state): self_restart.arm("one", path=state, now=1000.0) self_restart.arm("two", path=state, now=1100.0) assert self_restart.load(state).restarts == [1000.0, 1100.0] def test_too_many_restarts_in_the_window_is_refused(state): now = 1000.0 for index in range(self_restart.MAX_RESTARTS): self_restart.arm(f"attempt {index}", path=state, now=now + index) with pytest.raises(self_restart.RestartError, match="looping"): self_restart.check_loop_guard(self_restart.load(state), now=now + 10) def test_old_restarts_fall_out_of_the_window(state): now = 1000.0 for index in range(self_restart.MAX_RESTARTS): self_restart.arm(f"attempt {index}", path=state, now=now + index) later = now + self_restart.WINDOW_SECONDS + 60 self_restart.check_loop_guard(self_restart.load(state), now=later) # no exception assert self_restart.recent_restarts(self_restart.load(state), now=later) == []