2026-07-08 14:14:50 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Comprehensive test suite for pi_wake_agent.py
|
|
|
|
|
Tests all modes, edge cases, and the new succession feature.
|
2026-09-14 21:31:37 +02:00
|
|
|
|
|
|
|
|
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).
|
2026-07-08 14:14:50 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import subprocess
|
|
|
|
|
import time
|
|
|
|
|
import tempfile
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
2026-09-14 21:31:37 +02:00
|
|
|
import logging
|
2026-07-08 14:14:50 +02:00
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import patch, MagicMock, call
|
|
|
|
|
|
|
|
|
|
# Add the script directory to path
|
|
|
|
|
sys.path.insert(0, "/mnt/dolphinng5_predict")
|
|
|
|
|
|
2026-09-14 21:31:37 +02:00
|
|
|
import pi_wake_agent
|
2026-07-08 14:14:50 +02:00
|
|
|
from pi_wake_agent import (
|
|
|
|
|
parse_interval,
|
|
|
|
|
interval_to_cron,
|
|
|
|
|
interval_to_human,
|
|
|
|
|
cron_comment,
|
|
|
|
|
parse_sessions,
|
2026-09-14 21:31:37 +02:00
|
|
|
wake_command_args,
|
2026-09-15 11:17:36 +02:00
|
|
|
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,
|
2026-09-14 21:31:37 +02:00
|
|
|
use_cronicle,
|
|
|
|
|
cronicle_available,
|
|
|
|
|
cronicle_daemon_running,
|
|
|
|
|
run_wake,
|
2026-07-08 14:14:50 +02:00
|
|
|
SCRIPT_PATH,
|
|
|
|
|
LOG_FILE,
|
|
|
|
|
LOG_MAX_SIZE,
|
|
|
|
|
LOG_MAX_FILES,
|
|
|
|
|
CRON_COMMENT_PREFIX,
|
|
|
|
|
AGENT_NICK,
|
|
|
|
|
H5I_AGENT,
|
|
|
|
|
H5I_BUS_ROOT,
|
|
|
|
|
DEFAULT_INTERVAL,
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-14 21:31:37 +02:00
|
|
|
|
|
|
|
|
@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"])
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
# ─── Test parse_interval ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestParseInterval:
|
|
|
|
|
def test_hours(self):
|
|
|
|
|
assert parse_interval("1h") == 3600
|
|
|
|
|
assert parse_interval("2h") == 7200
|
|
|
|
|
assert parse_interval("24h") == 86400
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_minutes(self):
|
|
|
|
|
assert parse_interval("30m") == 1800
|
|
|
|
|
assert parse_interval("1m") == 60
|
|
|
|
|
assert parse_interval("90m") == 5400
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_seconds(self):
|
|
|
|
|
assert parse_interval("30s") == 30
|
|
|
|
|
assert parse_interval("1s") == 1
|
|
|
|
|
assert parse_interval("10s") == 10
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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 * * *"
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_minutes(self):
|
|
|
|
|
assert interval_to_cron("1m") == "*/1 * * * *"
|
|
|
|
|
assert interval_to_cron("30m") == "*/30 * * * *"
|
|
|
|
|
assert interval_to_cron("45m") == "*/45 * * * *"
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_invalid_minutes(self):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
interval_to_cron("60m")
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
interval_to_cron("90m")
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_invalid_seconds(self):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
interval_to_cron("30s")
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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)"
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_minutes(self):
|
|
|
|
|
assert interval_to_human("30m") == "30 minute(s)"
|
|
|
|
|
assert interval_to_human("1m") == "1 minute(s)"
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_seconds(self):
|
|
|
|
|
assert interval_to_human("30s") == "30 second(s)"
|
|
|
|
|
assert interval_to_human("1s") == "1 second(s)"
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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"
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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"
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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"]
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_single_session(self):
|
|
|
|
|
result = parse_sessions("cc_UV_dev0_Fb")
|
|
|
|
|
assert result == ["cc_UV_dev0_Fb"]
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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"]
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_empty(self):
|
|
|
|
|
assert parse_sessions("") == []
|
|
|
|
|
assert parse_sessions(None) == []
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_list_input(self):
|
|
|
|
|
result = parse_sessions(["a", "b", "c"])
|
|
|
|
|
assert result == ["a", "b", "c"]
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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"
|
|
|
|
|
|
2026-09-14 23:05:47 +02:00
|
|
|
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 <N>: 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),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 21:31:37 +02:00
|
|
|
def run_cmd(args, env=None, timeout=30):
|
2026-09-14 23:05:47 +02:00
|
|
|
"""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()
|
2026-07-08 14:14:50 +02:00
|
|
|
result = subprocess.run(
|
|
|
|
|
[sys.executable, SCRIPT] + args,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
2026-09-14 21:31:37 +02:00
|
|
|
timeout=timeout,
|
|
|
|
|
env=env,
|
2026-07-08 14:14:50 +02:00
|
|
|
)
|
|
|
|
|
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
|
2026-09-14 21:31:37 +02:00
|
|
|
assert "--pane" in out # new pane-targeting flag is documented
|
2026-07-08 14:14:50 +02:00
|
|
|
|
|
|
|
|
class TestList:
|
2026-09-14 21:31:37 +02:00
|
|
|
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()
|
2026-09-14 23:05:47 +02:00
|
|
|
# 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.
|
2026-09-14 21:31:37 +02:00
|
|
|
|
|
|
|
|
def test_list_no_cron(self):
|
2026-07-08 14:14:50 +02:00
|
|
|
rc, out, err = run_cmd(["--list"])
|
|
|
|
|
assert rc == 0
|
2026-09-14 21:31:37 +02:00
|
|
|
assert "(none)" in out or "pi_wake_agent cron entries" in out
|
2026-07-08 14:14:50 +02:00
|
|
|
|
|
|
|
|
class TestInstallRemove:
|
|
|
|
|
def setup_method(self):
|
2026-09-14 21:31:37 +02:00
|
|
|
# Clear cronicle config (per-test tmp path)
|
|
|
|
|
config_path = cronicle_hcl_path()
|
|
|
|
|
if config_path.exists():
|
|
|
|
|
config_path.unlink()
|
2026-09-14 23:05:47 +02:00
|
|
|
# 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.
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def teardown_method(self):
|
2026-09-14 21:31:37 +02:00
|
|
|
config_path = cronicle_hcl_path()
|
|
|
|
|
if config_path.exists():
|
|
|
|
|
config_path.unlink()
|
2026-09-14 23:05:47 +02:00
|
|
|
# Doctrine: no real crontab mutation (cronicle tmp-hcl backend pinned).
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_install_and_list(self):
|
|
|
|
|
rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "test_session", "--msg", "test"])
|
2026-09-14 21:31:37 +02:00
|
|
|
assert rc == 0, f"install failed: {err}"
|
|
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
rc, out, err = run_cmd(["--list"])
|
|
|
|
|
assert rc == 0
|
|
|
|
|
assert "test_session" in out
|
|
|
|
|
assert "1h" in out
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
# Cleanup
|
|
|
|
|
run_cmd(["--remove", "--session", "test_session", "--interval", "1h"])
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
def test_install_multi_session(self):
|
|
|
|
|
rc, out, err = run_cmd(["--install", "--interval", "30m", "--sessions", "s1,s2", "--msg", "multi"])
|
2026-09-14 21:31:37 +02:00
|
|
|
assert rc == 0, f"install failed: {err}"
|
|
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
rc, out, err = run_cmd(["--list"])
|
|
|
|
|
assert rc == 0
|
2026-09-14 21:31:37 +02:00
|
|
|
assert "s1_s2" in out # cronicle format: pi_wake_s1_s2_30m
|
|
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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
|
2026-09-14 21:31:37 +02:00
|
|
|
# New status fields
|
|
|
|
|
assert "Backend:" in out
|
|
|
|
|
assert "cronicle binary=" in out
|
2026-07-08 14:14:50 +02:00
|
|
|
|
|
|
|
|
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
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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()
|
2026-09-14 21:31:37 +02:00
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
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"])
|
|
|
|
|
|
2026-09-14 21:31:37 +02:00
|
|
|
# ─── 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
|
|
|
|
|
|
2026-09-14 23:05:47 +02:00
|
|
|
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") == ""
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 21:31:37 +02:00
|
|
|
# ─── 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."""
|
|
|
|
|
|
2026-09-14 23:05:47 +02:00
|
|
|
def _run(self, pane, message="Please carefully continue your work", dump_screen="pi_test\n"):
|
2026-09-14 21:31:37 +02:00
|
|
|
# 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
|
2026-09-14 23:05:47 +02:00
|
|
|
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
|
2026-09-14 21:31:37 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-09-14 23:05:47 +02:00
|
|
|
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
|
|
|
|
|
|
2026-09-14 21:31:37 +02:00
|
|
|
# ─── 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
|
|
|
|
|
|
2026-09-14 23:05:47 +02:00
|
|
|
|
|
|
|
|
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 "<unreadable>"
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-09-15 11:17:36 +02:00
|
|
|
# ─── 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
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 14:14:50 +02:00
|
|
|
# ─── Run all tests ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
pytest.main([__file__, "-v", "--tb=short"])
|