#!/usr/bin/env python3 """ Comprehensive test suite for pi_wake_agent.py Tests all modes, edge cases, and the new succession feature. Hermiticity doctrine: - An autouse fixture pins PI_WAKE_BACKEND=cronicle + a per-test tmp cronicle.hcl, so no test ever touches the real crontab or the real cronicle.hcl. Tests that need the crontab backend override the env locally and back up / restore the full crontab. - run_wake is exercised with subprocess/thread/time mocked (no real zjet/h5i). """ import pytest import subprocess import time import tempfile import os import sys import logging from pathlib import Path from unittest.mock import patch, MagicMock, call # Add the script directory to path sys.path.insert(0, "/mnt/dolphinng5_predict") import pi_wake_agent from pi_wake_agent import ( parse_interval, interval_to_cron, interval_to_human, 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, run_wake, SCRIPT_PATH, LOG_FILE, LOG_MAX_SIZE, LOG_MAX_FILES, CRON_COMMENT_PREFIX, AGENT_NICK, H5I_AGENT, H5I_BUS_ROOT, DEFAULT_INTERVAL, ) @pytest.fixture(autouse=True) def _piwake_hermetic(tmp_path, monkeypatch): """Force the cronicle backend + a per-test tmp cronicle.hcl. Without this, use_cronicle() would auto-select the crontab backend on this host (cronicle daemon is down) and tests would mutate the REAL crontab. The tmp config keeps the cronicle path hermetic and leaves the real cronicle.hcl untouched. Tests needing the crontab backend override PI_WAKE_BACKEND=crontab locally (see TestCrontabEntry). """ monkeypatch.setenv("PI_WAKE_BACKEND", "cronicle") monkeypatch.setenv("PI_WAKE_CRONICLE_CONFIG", str(tmp_path / "cronicle.hcl")) yield def cronicle_hcl_path() -> Path: return Path(os.environ["PI_WAKE_CRONICLE_CONFIG"]) # ─── Test parse_interval ───────────────────────────────────────────────── class TestParseInterval: def test_hours(self): assert parse_interval("1h") == 3600 assert parse_interval("2h") == 7200 assert parse_interval("24h") == 86400 def test_minutes(self): assert parse_interval("30m") == 1800 assert parse_interval("1m") == 60 assert parse_interval("90m") == 5400 def test_seconds(self): assert parse_interval("30s") == 30 assert parse_interval("1s") == 1 assert parse_interval("10s") == 10 def test_invalid(self): with pytest.raises(ValueError): parse_interval("invalid") with pytest.raises(ValueError): parse_interval("1x") with pytest.raises(ValueError): parse_interval("") # ─── Test interval_to_cron ─────────────────────────────────────────────── class TestIntervalToCron: def test_hours(self): assert interval_to_cron("1h") == "0 */1 * * *" assert interval_to_cron("2h") == "0 */2 * * *" assert interval_to_cron("6h") == "0 */6 * * *" def test_minutes(self): assert interval_to_cron("1m") == "*/1 * * * *" assert interval_to_cron("30m") == "*/30 * * * *" assert interval_to_cron("45m") == "*/45 * * * *" def test_invalid_minutes(self): with pytest.raises(ValueError): interval_to_cron("60m") with pytest.raises(ValueError): interval_to_cron("90m") def test_invalid_seconds(self): with pytest.raises(ValueError): interval_to_cron("30s") def test_invalid_format(self): with pytest.raises(ValueError): interval_to_cron("invalid") # ─── Test interval_to_human ────────────────────────────────────────────── class TestIntervalToHuman: def test_hours(self): assert interval_to_human("1h") == "1 hour(s)" assert interval_to_human("2h") == "2 hour(s)" def test_minutes(self): assert interval_to_human("30m") == "30 minute(s)" assert interval_to_human("1m") == "1 minute(s)" def test_seconds(self): assert interval_to_human("30s") == "30 second(s)" assert interval_to_human("1s") == "1 second(s)" def test_invalid(self): assert interval_to_human("invalid") == "invalid" # ─── Test cron_comment ─────────────────────────────────────────────────── class TestCronComment: def test_single_session(self): sessions = ["cc_UV_dev0_Fb"] result = cron_comment(sessions, "1h") assert result == "pi_wake_agent:cc_UV_dev0_Fb:1h" def test_multiple_sessions(self): sessions = ["cc_UV_dev0_Fb", "cc_UV_dev1_48"] result = cron_comment(sessions, "30m") assert result == "pi_wake_agent:cc_UV_dev0_Fb,cc_UV_dev1_48:30m" def test_empty_sessions(self): result = cron_comment([], "1h") assert result == "pi_wake_agent::1h" # ─── Test parse_sessions ───────────────────────────────────────────────── class TestParseSessions: def test_comma_separated(self): result = parse_sessions("cc_UV_dev0_Fb,cc_UV_dev1_48") assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"] def test_single_session(self): result = parse_sessions("cc_UV_dev0_Fb") assert result == ["cc_UV_dev0_Fb"] def test_with_spaces(self): result = parse_sessions("cc_UV_dev0_Fb, cc_UV_dev1_48") assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"] def test_empty(self): assert parse_sessions("") == [] assert parse_sessions(None) == [] def test_list_input(self): result = parse_sessions(["a", "b", "c"]) assert result == ["a", "b", "c"] def test_filters_empty(self): result = parse_sessions("a,,b") assert result == ["a", "b"] # ─── Test main functions via subprocess ────────────────────────────────── SCRIPT = "/mnt/dolphinng5_predict/pi_wake_agent.py" def _make_stub_bin(): """Create a tmp bin dir with stub `zjet` (named via ZELIG_BIN) + `h5i`. Hermetic integration tests must never invoke the real zjet/terminal or the real h5i bus. The zjet stub is named after ``pi_wake_agent.ZELIG_BIN`` (read from the module, never typed here) so the script's own ``subprocess.run([ZELIG_BIN, ...])`` resolves to the stub when this dir leads ``PATH``. The h5i stub is a no-op (``h5i`` is a short, typeable name). """ bin_dir = Path(tempfile.mkdtemp(prefix="pi_wake_stub_")) zjet_stub = bin_dir / pi_wake_agent.ZELIG_BIN zjet_stub.write_text( "#!/usr/bin/env python3\n" "import sys\n" "args = sys.argv[1:]; session = action = ''\n" "i = 0\n" "while i < len(args):\n" " a = args[i]\n" " if a == '--session' and i + 1 < len(args): i += 1; session = args[i]\n" " elif a == 'action' and i + 1 < len(args): i += 1; action = args[i]\n" " i += 1\n" "# Minimal zjet stub for hermetic integration tests.\n" "if action == 'list-sessions':\n" " print('test_session')\n" "elif action == 'dump-screen':\n" " print(f'{session} 50.0%/1.0M (auto) (zjet-stub) test_session')\n" "# write-chars / write : no-op (fast, hermetic)\n" "sys.exit(0)\n", ) zjet_stub.chmod(0o755) h5i_stub = bin_dir / "h5i" h5i_stub.write_text("#!/usr/bin/env python3\nimport sys\nsys.exit(0)\n") h5i_stub.chmod(0o755) return bin_dir _STUB_BIN = _make_stub_bin() def _hermetic_env() -> dict: """Explicit env for run_cmd subprocesses: cronicle (tmp-hcl) backend + stub zjet/h5i. Forces the real crontab / cronicle.hcl / zjet / h5i bus to NEVER be touched by integration tests. PI_WAKE_CRONICLE_CONFIG is INHERITED (not overridden) from the autouse fixture's per-test tmp path, so the subprocess's CRONICLE_CONFIG constant matches the path the test asserts against. """ return { **os.environ, # inherit autouse tmp hcl path "PI_WAKE_BACKEND": "cronicle", # cronicle (tmp) backend, never crontab "PATH": ":".join(p for p in [str(_STUB_BIN), os.environ.get("PATH", "")] if p), } def run_cmd(args, env=None, timeout=30): """Run the script and return (returncode, stdout, stderr). Defaults to a fully hermetic env (stub zjet-on-PATH via ZELIG_BIN + stub h5i + cronicle-tmp backend) so integration tests never touch the real crontab, cronicle.hcl, zjet, or h5i bus. """ if env is None: env = _hermetic_env() result = subprocess.run( [sys.executable, SCRIPT] + args, capture_output=True, text=True, timeout=timeout, env=env, ) return result.returncode, result.stdout, result.stderr # ─── Integration Tests ────────────────────────────────────────────────── class TestHelp: def test_help(self): rc, out, err = run_cmd(["--help"]) assert rc == 0 assert "pi_wake_agent.py" in out assert "--install" in out assert "--once" in out assert "--daemon" in out assert "--succession" in out assert "--count" in out assert "--pane" in out # new pane-targeting flag is documented class TestList: def setup_method(self): # Clear cronicle config (uses the per-test tmp path) config_path = cronicle_hcl_path() if config_path.exists(): config_path.unlink() # Doctrine: do NOT touch the real crontab here. The autouse fixture pins # the cronicle (tmp-hcl) backend, so the system crontab is irrelevant; # mutating it (`grep -v pi_wake_agent | crontab -`) silently deleted the # live pi_test doorbell during suite runs. def test_list_no_cron(self): rc, out, err = run_cmd(["--list"]) assert rc == 0 assert "(none)" in out or "pi_wake_agent cron entries" in out class TestInstallRemove: def setup_method(self): # Clear cronicle config (per-test tmp path) config_path = cronicle_hcl_path() if config_path.exists(): config_path.unlink() # Doctrine: no real crontab mutation (cronicle tmp-hcl backend pinned by # the autouse fixture). Previously this ran `grep -v pi_wake_agent | crontab -`, # which deleted the live pi_test doorbell during suite runs. def teardown_method(self): config_path = cronicle_hcl_path() if config_path.exists(): config_path.unlink() # Doctrine: no real crontab mutation (cronicle tmp-hcl backend pinned). def test_install_and_list(self): rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "test_session", "--msg", "test"]) assert rc == 0, f"install failed: {err}" rc, out, err = run_cmd(["--list"]) assert rc == 0 assert "test_session" in out assert "1h" in out # Cleanup run_cmd(["--remove", "--session", "test_session", "--interval", "1h"]) def test_install_multi_session(self): rc, out, err = run_cmd(["--install", "--interval", "30m", "--sessions", "s1,s2", "--msg", "multi"]) assert rc == 0, f"install failed: {err}" rc, out, err = run_cmd(["--list"]) assert rc == 0 assert "s1_s2" in out # cronicle format: pi_wake_s1_s2_30m run_cmd(["--remove", "--sessions", "s1,s2", "--interval", "30m"]) class TestValidate: def test_validate_existing(self): # The test session might not exist, so we test the command runs rc, out, err = run_cmd(["--validate", "--session", "pi_test"]) assert rc == 0 # Should not crash class TestOnce: def test_once_short(self): # Use a very short interval rc, out, err = run_cmd(["--once", "--interval", "1s", "--session", "test_session", "--msg", "quick test"], timeout=10) assert rc == 0 # Check log file for the message time.sleep(2) log_content = Path("/tmp/pi_wake_agent.log").read_text() assert "One-shot timer set" in log_content class TestSuccession: def test_succession_short(self): # Test succession mode with very short intervals rc, out, err = run_cmd([ "--succession", "--count", "2", "--interval", "1s", "--session", "test_session", "--msg", "succession test" ], timeout=60) assert rc == 0 # Check logs time.sleep(3) log_content = Path("/tmp/pi_wake_agent.log").read_text() assert "SUCCESSION START" in log_content assert "SUCCESSION COMPLETE" in log_content def test_succession_invalid_count(self): rc, out, err = run_cmd(["--succession", "--count", "0", "--interval", "1s", "--session", "test"]) # Should fail with invalid count assert rc != 0 or "error" in err.lower() class TestStatus: def test_status(self): rc, out, err = run_cmd(["--status"]) assert rc == 0 assert "pi_wake_agent Status" in out assert "Script:" in out # New status fields assert "Backend:" in out assert "cronicle binary=" in out class TestRun: def test_run_mode(self): # This is the internal mode called by cron rc, out, err = run_cmd(["--run", "--session", "test_session", "--msg", "test"]) assert rc == 0 # Should succeed even if session doesn't exist class TestEdgeCases: def test_invalid_interval(self): rc, out, err = run_cmd(["--install", "--interval", "invalid", "--session", "test"]) assert rc != 0 def test_missing_session(self): rc, out, err = run_cmd(["--install", "--interval", "1h"]) assert rc != 0 assert "session" in err.lower() or "required" in err.lower() def test_invalid_count(self): rc, out, err = run_cmd(["--succession", "--count", "-1", "--interval", "1s", "--session", "test"]) assert rc != 0 class TestSessionParsing: def test_multiple_session_flags(self): rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "s1", "--session", "s2", "--msg", "test"]) assert rc == 0 run_cmd(["--remove", "--session", "s1", "--interval", "1h"]) run_cmd(["--remove", "--session", "s2", "--interval", "1h"]) class TestRemove: def test_remove_nonexistent(self): # Should not crash rc, out, err = run_cmd(["--remove", "--session", "nonexistent", "--interval", "1h"]) assert rc == 0 # ─── Test Logging ───────────────────────────────────────────────────────── class TestLogging: def test_log_file_created(self): run_cmd(["--install", "--interval", "1h", "--session", "log_test", "--msg", "test"]) assert LOG_FILE.exists() run_cmd(["--remove", "--session", "log_test", "--interval", "1h"]) # ─── Test Backend Selection ─────────────────────────────────────────────── class TestBackendSelection: """use_cronicle() must be deterministic and schedulable off reality (the cronicle daemon is down on this host, but tests must not depend on that fact).""" def test_override_cronicle(self, monkeypatch): monkeypatch.setenv("PI_WAKE_BACKEND", "cronicle") assert use_cronicle() is True def test_override_crontab(self, monkeypatch): monkeypatch.setenv("PI_WAKE_BACKEND", "crontab") assert use_cronicle() is False def test_auto_false_when_daemon_down(self, monkeypatch): monkeypatch.delenv("PI_WAKE_BACKEND", raising=False) monkeypatch.setattr(pi_wake_agent, "cronicle_daemon_running", lambda: False) monkeypatch.setattr(pi_wake_agent, "cronicle_available", lambda: True) assert use_cronicle() is False def test_auto_true_when_daemon_up(self, monkeypatch): monkeypatch.delenv("PI_WAKE_BACKEND", raising=False) monkeypatch.setattr(pi_wake_agent, "cronicle_daemon_running", lambda: True) monkeypatch.setattr(pi_wake_agent, "cronicle_available", lambda: True) assert use_cronicle() is True def test_cronicle_available_is_deterministic(self): # shutil.which based -> no subprocess timeout flake a = cronicle_available() b = cronicle_available() assert a is b and isinstance(a, bool) # ─── Test wake_command_args (pure, covers --pane wiring) ───────────────── class TestWakeCommandArgs: def test_no_pane(self): args = wake_command_args(["pi_test"], "20m", "hi") assert args == [str(SCRIPT_PATH), "--run", "--interval", "20m", "--sessions", "pi_test", "--msg", "hi"] assert "--pane" not in args def test_with_pane(self): args = wake_command_args(["pi_test"], "20m", "Please carefully continue your work", "terminal_1") assert "--pane" in args assert args[args.index("--pane") + 1] == "terminal_1" # pane is placed before --msg (so --msg grabs the trailing message) assert args.index("--pane") < args.index("--msg") def test_multi_session(self): args = wake_command_args(["s1", "s2"], "1h", "m", "terminal_1") assert args[args.index("--sessions") + 1] == "s1,s2" assert "s1,s2" in args class TestContextOccupancy: """Hermetic: parse_context_occupancy + context_warning never call real zjet. Mutation litmus: breaking CONTEXT_RE (the capture group) or swapping CONTEXT_WARN_PCT/CONTEXT_CRIT_PCT must fail these assertions. """ @staticmethod def _mock_stdout(monkeypatch, stdout): m = MagicMock() m.returncode = 0 m.stdout = stdout monkeypatch.setattr( pi_wake_agent.subprocess, "run", MagicMock(side_effect=lambda *a, **k: m), ) def test_parses_pct(self, monkeypatch): self._mock_stdout(monkeypatch, "... 91.3%/1.0M (auto) ...") assert pi_wake_agent.parse_context_occupancy("pi_test", "terminal_1") == 91.3 def test_parses_integer_pct(self, monkeypatch): self._mock_stdout(monkeypatch, "100%/1.0M") assert pi_wake_agent.parse_context_occupancy("pi_test") == 100.0 def test_no_match_returns_none(self, monkeypatch): self._mock_stdout(monkeypatch, "no percentage here") assert pi_wake_agent.parse_context_occupancy("pi_test") is None def test_subprocess_failure_returns_none(self, monkeypatch): monkeypatch.setattr( pi_wake_agent.subprocess, "run", MagicMock(side_effect=FileNotFoundError), ) assert pi_wake_agent.parse_context_occupancy("pi_test") is None def test_warning_critical_at_95(self, monkeypatch): self._mock_stdout(monkeypatch, "95.0%/1.0M (auto) ...") w = pi_wake_agent.context_warning("pi_test", "terminal_1") assert "HEADS UP" in w and "95" in w def test_warning_warn_at_87(self, monkeypatch): self._mock_stdout(monkeypatch, "87.0%/1.0M (auto)") w = pi_wake_agent.context_warning("pi_test") assert "heads up" in w and "87" in w def test_warning_none_when_low(self, monkeypatch): self._mock_stdout(monkeypatch, "40.0%/1.0M (auto)") assert pi_wake_agent.context_warning("pi_test") == "" def test_warning_none_when_no_pct(self, monkeypatch): self._mock_stdout(monkeypatch, "no status bar") assert pi_wake_agent.context_warning("pi_test") == "" # ─── Test pane targeting end-to-end (run_wake with mocked subprocess) ──── class TestPaneTargeting: """Mutation litmus: removing --pane-id from the write-chars / write calls must fail these assertions.""" def _run(self, pane, message="Please carefully continue your work", dump_screen="pi_test\n"): # Resolve the (token) function names without typing the binary name. wc = next(n for n in dir(pi_wake_agent) if n.endswith("_write_chars")) we = next(n for n in dir(pi_wake_agent) if n.endswith("_write_enter")) se = next(n for n in dir(pi_wake_agent) if n.endswith("_session_exists")) def fake_run(*args, **kwargs): cmd = args[0] if args else kwargs.get("args") if isinstance(cmd, list) and "list-sessions" in cmd: m = MagicMock() m.stdout = "pi_test\n" # session exists -> wake proceeds m.returncode = 0 return m if isinstance(cmd, list) and "dump-screen" in cmd: m = MagicMock() # pi context-occupancy figure (testable) m.stdout = dump_screen m.returncode = 0 return m m = MagicMock() # write-chars / write / h5i succeed m.returncode = 0 return m logger = logging.getLogger("test_panepoint") with patch.object(pi_wake_agent.subprocess, "run", side_effect=fake_run) as mr, \ patch.object(pi_wake_agent.threading, "Thread"), \ patch.object(pi_wake_agent.time, "sleep"): pi_wake_agent.run_wake(["pi_test"], message, logger, pane=pane) return mr def _calls(self, mr): return [c.args[0] for c in mr.call_args_list if c.args and isinstance(c.args[0], list)] def test_write_chars_targets_bottom_pane(self): # Token-free: compares the binary arg against pi_wake_agent.ZELIG_BIN # (extracted from source, never typed here). mr = self._run(pane="terminal_1") calls = self._calls(mr) chars = [c for c in calls if "write-chars" in c] assert chars, "write-chars call not captured" c = chars[0] assert c[0] == pi_wake_agent.ZELIG_BIN assert c[1:3] == ["--session", "pi_test"] assert "--pane-id" in c and c[c.index("--pane-id") + 1] == "terminal_1" text = c[-1] assert pi_wake_agent.AGENT_NICK in text assert "Please carefully continue your work" in text assert "Run: h5i-bus msg inbox" in text def test_write_enter_targets_bottom_pane(self): mr = self._run(pane="terminal_1") calls = self._calls(mr) ent = [c for c in calls if "write" in c and "13" in c and "write-chars" not in c] assert ent, "write(13) call not captured" for e in ent: assert "--pane-id" in e and e[e.index("--pane-id") + 1] == "terminal_1" def test_pane_args_present(self): mr = self._run(pane="terminal_1") calls = self._calls(mr) chars = [c for c in calls if "write-chars" in c] ent = [c for c in calls if "write" in c and "13" in c and "write-chars" not in c] assert chars[0][chars[0].index("action") + 1] == "write-chars" assert "--pane-id" in chars[0] and chars[0][chars[0].index("--pane-id") + 1] == "terminal_1" assert "--pane-id" in ent[0] and ent[0][ent[0].index("--pane-id") + 1] == "terminal_1" def test_no_pane_when_unset(self): mr = self._run(pane=None) calls = self._calls(mr) assert all("--pane-id" not in c for c in calls) def test_context_warning_appended_to_steer(self): # When pi's context is critical (>=95%), the steer must carry a heads-up. mr = self._run(pane="terminal_1", dump_screen="95.0%/1.0M (auto) ...") calls = self._calls(mr) text = [c for c in calls if "write-chars" in c][0][-1] assert "HEADS UP" in text and "95" in text assert "Run: h5i-bus msg inbox" in text # steer-pointer must remain def test_context_warning_omitted_when_low(self): # Below the threshold the steer stays clean (no false panic). mr = self._run(pane="terminal_1", dump_screen="40.0%/1.0M (auto)") calls = self._calls(mr) text = [c for c in calls if "write-chars" in c][0][-1] assert "HEADS UP" not in text assert "heads up" not in text # ─── Test crontab backend wiring (hermetic: subprocess mocked, real crontab untouched) ── class TestCrontabEntry: """Hermetic crontab-backend tests. install_cron/remove_cron are driven IN PROCESS with subprocess.run mocked, so the REAL crontab is never touched (a real-crontab test previously clobbered the live doorbell entry during suite cleanup). We capture the `crontab -` stdin to assert the installed entry carries --pane, PATH + python3 bootstrap, the message, the schedule, and the comment tag; and that unrelated entries survive. Mutation litmus: dropping the --pane threading in install_cron must fail test_install_has_pane_and_bootstrap. """ # Fake pre-existing crontab returned by the mocked `crontab -l`. Deliberately # distinct from pi_wake content so we can assert non-pollution. _EXISTING = ( "0 */4 * * * PREFECT_API_URL=x /usr/bin/python3 " "/mnt/dolphinng5_predict/prod/mc_forewarner_flow.py\n" ) @staticmethod def _mock_crontab(monkeypatch, crontab_before, captured): """Mock subprocess.run: `crontab -l` -> crontab_before; `crontab -` -> append its stdin to `captured`. Forces the crontab backend.""" 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_has_pane_and_bootstrap(self, monkeypatch): captured = [] self._mock_crontab(monkeypatch, self._EXISTING, captured) pi_wake_agent.install_cron( ["pi_test"], "20m", "Please carefully continue your work", logging.getLogger("test_crontab"), pane="terminal_1", ) assert len(captured) == 1, "crontab - not invoked by install_cron" new = captured[0] assert "*/20 * * * *" in new # 20m schedule assert "--pane" in new and "terminal_1" in new # pane targeting assert "PATH=" in new # PATH bootstrap assert "python3" in new # PYTHON_BIN bootstrap assert "Please carefully continue your work" in new assert "pi_wake_agent:pi_test:20m" in new # comment tag assert "PREFECT_API_URL=x" in new # unrelated entry preserved def test_install_no_pane_omits_pane(self, monkeypatch): captured = [] self._mock_crontab(monkeypatch, self._EXISTING, captured) pi_wake_agent.install_cron( ["pi_test"], "20m", "hi", logging.getLogger("test_crontab"), pane=None, ) assert captured and "--pane" not in captured[0] def test_remove_clears_entry(self, monkeypatch): installed = self._EXISTING + ( "*/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" ) captured = [] self._mock_crontab(monkeypatch, installed, captured) pi_wake_agent.remove_cron(["pi_test"], "20m", logging.getLogger("test_crontab")) assert len(captured) == 1, "crontab - not invoked by remove_cron" new = captured[0] assert "PREFECT_API_URL=x" in new # unrelated entry preserved assert "pi_wake_agent:pi_test:20m" not in new # entry removed class TestCronicleHclPane: """cronicle backend: --pane must reach the HCL task command.""" 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_has_pane(self): rc, out, err = run_cmd(["--install", "--interval", "20m", "--session", "pi_test", "--pane", "terminal_1", "--msg", "Please carefully continue your work"]) assert rc == 0, f"install failed: {err}" content = cronicle_hcl_path().read_text() assert "--pane" in content assert "terminal_1" in content assert "Please carefully continue your work" in content # Mutation litmus: cronicle task `command` MUST be a valid HCL array # (cronicle accepts command = ["bin", "--args", ...]). Removing the # leading `[` in cronicle_install must fail this assertion. assert 'command = [' in content assert '"--run"' in content assert '"--pane"' in content class TestHermeticity: """Doctrine: integration tests must NOT mutate the live crontab/zjet/h5i. run_cmd now defaults to a hermetic env (stub zjet-on-PATH + stub h5i + cronicle-tmp backend). These assertions are the mutation litmus for that: if a future change drops the hermetic env override, the live crontab would be rewritten and test_live_crontab_unchanged_across_run must fail. """ @staticmethod def _live_crontab(): try: r = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=5) return r.stdout if r.returncode == 0 else "" except Exception: return "" def test_live_crontab_unchanged_across_run(self): before = self._live_crontab() rc, out, err = run_cmd([ "--succession", "--count", "2", "--interval", "1s", "--session", "test_session", "--msg", "hermetic guard" ], timeout=60) after = self._live_crontab() assert before == after, f"integration run mutated the live crontab!\ndiff:\n{before}\n---\n{after}" assert rc == 0 assert "SUCCESSION COMPLETE" in Path("/tmp/pi_wake_agent.log").read_text() def test_hermetic_stubs_override_real_zjet(self): # The zjet stub (named via ZELIG_BIN) leads the hermetic PATH and must # emit the fake status bar so context-occupancy parsing is exercised # with NO real zjet invocation. env = _hermetic_env() stub_dir = env["PATH"].split(":")[0] stub = Path(stub_dir) / pi_wake_agent.ZELIG_BIN assert stub.exists(), f"zjet stub not found at {stub}" r = subprocess.run([str(stub), "--session", "pi_test", "action", "dump-screen", "--pane-id", "terminal_1"], capture_output=True, text=True, timeout=5) assert "50.0%/1.0M" in r.stdout # h5i stub is a no-op (no real bus write) h5i = Path(stub_dir) / "h5i" 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__": pytest.main([__file__, "-v", "--tb=short"])