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,
|
|
|
|
|
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-07-08 14:14:50 +02:00
|
|
|
# ─── Run all tests ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
pytest.main([__file__, "-v", "--tb=short"])
|