Add text-to-dialogue, self-restart capability, and misc updates
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
"""`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) == []
|
||||
Reference in New Issue
Block a user