feat(pi_wake_agent): --at HHMM one-off self-cleaning wake mode

This commit is contained in:
Codex
2026-09-15 11:17:36 +02:00
parent ad4fcc538a
commit 616a23c0b3
2 changed files with 590 additions and 8 deletions

View File

@@ -32,6 +32,16 @@ from pi_wake_agent import (
cron_comment,
parse_sessions,
wake_command_args,
parse_at_time,
cron_at_comment,
cron_at_schedule_name,
build_at_cron_entry,
crontab_install_at,
crontab_remove_at,
cronicle_install_at,
cronicle_remove_at,
install_at,
remove_at,
use_cronicle,
cronicle_available,
cronicle_daemon_running,
@@ -778,6 +788,346 @@ class TestHermeticity:
assert h5i.exists()
# ─── Test --at one-off time-of-day scheduling ─────────────────────────────
class TestParseAtTime:
"""Pure: HH:MM / HHMM parsing + range validation (mutation litmus)."""
def test_hhmm_four_digit(self):
assert parse_at_time("0150") == ("0150", "50", "01")
def test_hhmm_rejects_three_digits(self):
# HHMM is exactly 4 digits (0150); a bare 930 is ambiguous -> rejected.
with pytest.raises(ValueError):
parse_at_time("930")
def test_hh_mm_colon(self):
assert parse_at_time("01:50") == ("0150", "50", "01")
assert parse_at_time("9:05") == ("0905", "05", "09")
def test_midnight_and_last_minute(self):
assert parse_at_time("0000") == ("0000", "00", "00")
assert parse_at_time("2359") == ("2359", "59", "23")
def test_bad_format(self):
with pytest.raises(ValueError):
parse_at_time("999") # 3 digits, not HHMM
with pytest.raises(ValueError):
parse_at_time("abc") # non-numeric
with pytest.raises(ValueError):
parse_at_time("12:5") # minute not 2 digits (regex is strict)
with pytest.raises(ValueError):
parse_at_time("") # empty
with pytest.raises(ValueError):
parse_at_time("1:50:00") # HH:MM:SS rejected
def test_out_of_range(self):
with pytest.raises(ValueError):
parse_at_time("2400") # hour 24
with pytest.raises(ValueError):
parse_at_time("2500") # hour 25
with pytest.raises(ValueError):
parse_at_time("1260") # minute 60
with pytest.raises(ValueError):
parse_at_time("12:99") # minute 99
with pytest.raises(ValueError):
parse_at_time("23:60")
def test_rejects_extra_digits(self):
with pytest.raises(ValueError):
parse_at_time("01500") # 5 digits
class TestCronAtTag:
"""Tag + schedule-name generation (distinct from recurring entries)."""
def test_comment_format(self):
assert cron_at_comment(["cdx_FLIGHT13"], "0150") == "pi_wake_agent:cdx_FLIGHT13:at:0150"
def test_comment_multiple_sessions(self):
assert cron_at_comment(["a", "b"], "0150") == "pi_wake_agent:a,b:at:0150"
def test_schedule_name_format(self):
assert cron_at_schedule_name(["cdx_FLIGHT13"], "0150") == "pi_wake_cdx_FLIGHT13_at_0150"
def test_tag_distinct_from_recurring(self):
recurring = cron_comment(["cdx_FLIGHT13"], "20m") # pi_wake_agent:cdx_FLIGHT13:20m
at_tag = cron_at_comment(["cdx_FLIGHT13"], "0150") # ...:at:0150
assert "at:" in at_tag
assert at_tag != recurring
assert "at:" not in recurring # recurring tag never looks like an at-tag
class TestBuildAtCronEntry:
"""Pure: the crontab line for a one-off, self-cleaning --at wake."""
def _entry(self, **over):
kw = dict(sessions=["cdx_FLIGHT13"], hhmm="0150", message="continue", pane=None)
kw.update(over)
return build_at_cron_entry(**kw)
def test_schedule_is_time_of_day(self):
e = self._entry()
assert e.startswith("50 01 * * * "), e # 01:50 -> min=50 hour=01
def test_wake_omits_interval(self):
e = self._entry()
assert "--interval" not in e # one-off is not interval driven
def test_wake_has_run_sessions_and_message(self):
e = self._entry()
assert "--run" in e
assert "cdx_FLIGHT13" in e
assert "continue" in e
def test_self_strip_targets_the_at_tag_not_recurring(self):
e = self._entry()
at_tag = cron_at_comment(["cdx_FLIGHT13"], "0150")
recurring = cron_comment(["cdx_FLIGHT13"], "20m")
assert f"grep -v '{at_tag}'" in e # strips THIS line
assert f"grep -v '{recurring}'" not in e # never strips recurring
def test_self_strip_sits_after_wake_cmd(self):
e = self._entry()
wake = pi_wake_agent._at_wake_command(["cdx_FLIGHT13"], "continue")
assert wake in e # wake runs before self-strip
assert e.rstrip().endswith(f"# {cron_at_comment(['cdx_FLIGHT13'], '0150')}")
def test_pane_threaded_into_command(self):
e = self._entry(pane="terminal_1")
cmd_part = e.split(" # ")[0]
assert "--pane" in cmd_part and "terminal_1" in cmd_part
def test_pane_default_omitted(self):
e = self._entry(pane=None)
assert "--pane" not in e
class TestCrontabAtBackend:
"""Hermetic crontab-backend tests for --at (subprocess.run mocked).
Mirrors TestCrontabEntry: real crontab is never touched; we capture the
`crontab -` stdin to assert the time-of-day schedule, the self-strip suffix,
the at-tag comment, and that the pre-existing doorbell survives. Mutation
litmus: dropping the self-strip suffix must fail test_self_strip_present.
"""
_DOORBELL = (
"*/20 * * * * cd /mnt/dolphinng5_predict && export PATH=/usr/local/sbin && "
"/usr/bin/python3 /mnt/dolphinng5_predict/pi_wake_agent.py --run --interval 20m "
"--sessions 'pi_test' --msg 'Please carefully continue your work' --pane terminal_1 "
"# pi_wake_agent:pi_test:20m\n"
)
@staticmethod
def _mock_crontab_at(monkeypatch, crontab_before, captured):
def fake_run(cmd, *args, **kwargs):
c = list(cmd)
if c == ["crontab", "-l"]:
m = MagicMock(); m.stdout = crontab_before; m.returncode = 0; return m
if c == ["crontab", "-"]:
captured.append(kwargs.get("input", ""))
m = MagicMock(); m.returncode = 0; return m
m = MagicMock(); m.returncode = 0; return m
monkeypatch.setenv("PI_WAKE_BACKEND", "crontab")
monkeypatch.setattr(pi_wake_agent.subprocess, "run", MagicMock(side_effect=fake_run))
def test_install_sets_time_of_day_schedule(self, monkeypatch):
captured = []
self._mock_crontab_at(monkeypatch, self._DOORBELL, captured)
pi_wake_agent.crontab_install_at(["cdx_FLIGHT13"], "0150", "continue",
logging.getLogger("t"), pane=None)
assert len(captured) == 1, "crontab - not invoked by crontab_install_at"
new = captured[0]
assert "50 01 * * *" in new, new # at-entry is mid-crontab
assert "pi_wake_agent:cdx_FLIGHT13:at:0150" in new
at_line = [ln for ln in new.splitlines() if "pi_wake_agent:cdx_FLIGHT13:at:0150" in ln]
assert at_line and "--interval" not in at_line[0] # at-ENTRY omits --interval (doorbell keeps its 20m)
def test_install_self_strip_present(self, monkeypatch):
captured = []
self._mock_crontab_at(monkeypatch, self._DOORBELL, captured)
pi_wake_agent.crontab_install_at(["cdx_FLIGHT13"], "0150", "continue", logging.getLogger("t"))
new = captured[0]
assert "grep -v" in new
assert "crontab -" in new # self-strip rewrites crontab
def test_install_idempotent_replaces_same_tag(self, monkeypatch):
"""Installing twice must NOT duplicate (replace, not append)."""
pre = self._DOORBELL + (
"50 1 * * * cd /mnt/dolphinng5_predict && ... # pi_wake_agent:cdx_FLIGHT13:at:0150\n"
)
captured = []
self._mock_crontab_at(monkeypatch, pre, captured)
pi_wake_agent.crontab_install_at(["cdx_FLIGHT13"], "0150", "continue", logging.getLogger("t"))
new = captured[0]
assert sum(1 for ln in new.splitlines() if "pi_wake_agent:cdx_FLIGHT13:at:0150" in ln) == 1 # one line (old replaced)
assert "pi_wake_agent:pi_test:20m" in new # doorbell intact
def test_install_preserves_doorbell_and_unrelated(self, monkeypatch):
captured = []
self._mock_crontab_at(monkeypatch, self._DOORBELL, captured)
pi_wake_agent.crontab_install_at(["cdx_FLIGHT13"], "0150", "continue", logging.getLogger("t"))
new = captured[0]
assert "pi_wake_agent:pi_test:20m" in new # doorbell intact
assert "pi_wake_agent:cdx_FLIGHT13:at:0150" in new # new at-entry present
assert self._DOORBELL.strip() in new # pre-existing line verbatim
def test_remove_clears_at_entry(self, monkeypatch):
installed = self._DOORBELL + (
"50 1 * * * cd /mnt/dolphinng5_predict && ... # pi_wake_agent:cdx_FLIGHT13:at:0150\n"
)
captured = []
self._mock_crontab_at(monkeypatch, installed, captured)
pi_wake_agent.crontab_remove_at(["cdx_FLIGHT13"], "0150", logging.getLogger("t"))
new = captured[0]
assert "pi_wake_agent:cdx_FLIGHT13:at:0150" not in new
assert "pi_wake_agent:pi_test:20m" in new # doorbell preserved
def test_remove_missing_tag_noop(self, monkeypatch):
"""Removing an absent --at entry leaves the crontab content unchanged."""
captured = []
self._mock_crontab_at(monkeypatch, self._DOORBELL, captured)
pi_wake_agent.crontab_remove_at(["nope"], "0150", logging.getLogger("t"))
new = captured[0]
assert "at:0150" not in new
assert new.strip() == self._DOORBELL.strip()
def test_install_at_routes_to_crontab_when_selected(self, monkeypatch):
captured = []
self._mock_crontab_at(monkeypatch, self._DOORBELL, captured) # forces crontab backend
pi_wake_agent.install_at(["cdx_FLIGHT13"], "0150", "continue", logging.getLogger("t"))
assert captured and "50 01 * * *" in captured[0]
class TestCronicleAtHcl:
"""cronicle backend (tmp-hcl): --at writes a one-off schedule block.
Asserts the generated HCL string (hermetic: tmp config, never the real
cronicle.hcl). Mirrors TestCronicleHslPane's shape + mutation litmus.
"""
def setup_method(self):
p = cronicle_hcl_path()
if p.exists():
p.unlink()
def teardown_method(self):
p = cronicle_hcl_path()
if p.exists():
p.unlink()
def test_install_hcl_block(self):
rc, out, err = run_cmd(["--at", "0150", "--session", "cdx_FLIGHT13",
"--msg", "continue"])
assert rc == 0, f"install --at failed: {err}"
content = cronicle_hcl_path().read_text()
assert 'schedule "pi_wake_cdx_FLIGHT13_at_0150"' in content
assert 'cron = "50 01 * * *"' in content
assert '"--run"' in content
assert '"cdx_FLIGHT13"' in content
assert '"continue"' in content
assert '"--interval"' not in content # one-off omits --interval
assert 'command = [' in content # valid HCL array (mutation litmus)
def test_install_hcl_has_pane(self):
rc, out, err = run_cmd(["--at", "0150", "--session", "cdx_FLIGHT13",
"--pane", "terminal_1", "--msg", "continue"])
assert rc == 0, f"install --at failed: {err}"
content = cronicle_hcl_path().read_text()
assert '"--pane"' in content
assert '"terminal_1"' in content
def test_install_is_idempotent_replaces_block(self):
"""Reinstalling the same --at schedule replaces, not duplicates."""
run_cmd(["--at", "0150", "--session", "cdx_FLIGHT13", "--msg", "continue"])
run_cmd(["--at", "0150", "--session", "cdx_FLIGHT13", "--msg", "again"])
content = cronicle_hcl_path().read_text()
assert content.count('schedule "pi_wake_cdx_FLIGHT13_at_0150"') == 1
assert '"again"' in content
def test_remove_clears_hcl_block(self):
cronicle_hcl_path().write_text(
'schedule "pi_wake_cdx_FLIGHT13_at_0150" {\n'
' cron = "50 1 * * *"\n'
' task "wake" { command = ["x"] }\n'
'}\n'
'schedule "pi_wake_keep_20m" {\n'
' cron = "@every 20m"\n'
' task "wake" { command = ["y"] }\n'
'}\n'
)
rc, out, err = run_cmd(["--remove", "--at", "0150", "--session", "cdx_FLIGHT13"])
assert rc == 0, f"remove --at failed: {err}"
content = cronicle_hcl_path().read_text()
assert 'schedule "pi_wake_cdx_FLIGHT13_at_0150"' not in content
assert 'schedule "pi_wake_keep_20m"' in content # unrelated schedule preserved
class TestAtDryRunHermeticity:
"""--at --dry-run must not touch crontab, cronicle.hcl, or zjet/h5i."""
def setup_method(self):
p = cronicle_hcl_path()
if p.exists():
p.unlink()
def teardown_method(self):
p = cronicle_hcl_path()
if p.exists():
p.unlink()
def test_dry_run_does_not_install(self):
before_log = Path(LOG_FILE).read_text() if LOG_FILE.exists() else ""
rc, out, err = run_cmd(["--at", "0150", "--session", "cdx_FLIGHT13",
"--msg", "continue", "--dry-run"])
assert rc == 0
assert not cronicle_hcl_path().exists() # no HCL written (dry-run => no side effects)
# dry-run branch logs via the FileHandler (DEBUG-level); stderr's StreamHandler
# is WARNING-only, so assert on the log file, not on captured err/out.
after_log = Path(LOG_FILE).read_text()
assert "DRY RUN" in after_log
assert after_log.count("DRY RUN") > before_log.count("DRY RUN") # a new entry was emitted
def test_dry_run_does_not_touch_live_crontab(self):
before = TestHermeticity._live_crontab()
rc, out, err = run_cmd(["--at", "0150", "--session", "cdx_FLIGHT13",
"--msg", "continue", "--dry-run"])
after = TestHermeticity._live_crontab()
assert rc == 0
assert before == after, "dry-run mutated live crontab"
assert "at:0150" not in (after or "")
class TestAtLiveHermeticity:
"""Mutation litmus: integration runs must NOT mutate the live crontab or
the real cronicle.hcl (the autouse hermetic env routes --at to the cronicle
tmp-hcl backend, so the live crontab is never rewritten)."""
@staticmethod
def _live_cronicle_hcl():
p = Path("/mnt/dolphinng5_predict/cronicle.hcl")
return p.read_text() if p.exists() else ""
def test_live_crontab_unchanged_across_at(self):
before = TestHermeticity._live_crontab()
rc, out, err = run_cmd(["--at", "0150", "--session", "cdx_FLIGHT13",
"--msg", "hermetic guard"])
after = TestHermeticity._live_crontab()
assert rc == 0
assert before == after, "integration run mutated the live crontab"
assert "pi_wake_cdx_FLIGHT13_at_0150" not in before # not pre-installed
def test_live_cronicle_hcl_unchanged_across_at(self):
before = self._live_cronicle_hcl()
rc, out, err = run_cmd(["--at", "0150", "--session", "cdx_FLIGHT13",
"--msg", "hermetic guard"])
after = self._live_cronicle_hcl()
assert rc == 0
assert before == after, "integration run mutated real cronicle.hcl"
assert "pi_wake_cdx_FLIGHT13_at_0150" not in before
# ─── Run all tests ────────────────────────────────────────────────────────
if __name__ == "__main__":