feat(pi_wake_agent): --at HHMM one-off self-cleaning wake mode
This commit is contained in:
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user