"""Tests for linux-schedule-parity (v1.1 task 6).""" import importlib.util import json import platform import sys from pathlib import Path from unittest.mock import patch, MagicMock spec = importlib.util.spec_from_file_location( "st", str(Path(__file__).resolve().parent.parent / "scripts" / "status.py") ) st = importlib.util.module_from_spec(spec) spec.loader.exec_module(st) LOOP_TICK_SCRIPT_SH = st.LOOP_TICK_SCRIPT_SH class TestInstallCronBlock: def test_writes_fresh_block(self, tmp_path): with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 mock_run.return_value.stdout = "" rc = st._install_cron_block("my-loop", tmp_path, 3600) assert rc == 0 # Second call to crontab - contains the block input_call = mock_run.call_args_list[-1] stdin = input_call.kwargs["input"] assert "# automaton-loop:my-loop" in stdin assert "# end automaton-loop:my-loop" in stdin assert "*/60 * * * *" in stdin def test_strips_prior_block(self, tmp_path): existing = ( "# automaton-loop:my-loop\n" "*/60 * * * * /old/stub\n" "# end automaton-loop:my-loop\n" ) with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 mock_run.return_value.stdout = existing rc = st._install_cron_block("my-loop", tmp_path, 3600) assert rc == 0 input_call = mock_run.call_args_list[-1] stdin = input_call.kwargs["input"] blocks = [l for l in stdin.splitlines() if l.strip().startswith("# automaton-loop:my-loop")] assert len(blocks) == 1, "should have exactly one block" def test_returns_2_on_write_error(self, tmp_path): from subprocess import SubprocessError with patch("subprocess.run") as mock_run: mock_run.side_effect = [ MagicMock(returncode=0, stdout=""), SubprocessError("crontab write failed"), ] rc = st._install_cron_block("my-loop", tmp_path, 3600) assert rc == 2 def test_rounds_interval_to_minutes(self, tmp_path): with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 mock_run.return_value.stdout = "" st._install_cron_block("my-loop", tmp_path, 90) input_call = mock_run.call_args_list[-1] stdin = input_call.kwargs["input"] assert "*/1 * * * *" in stdin def test_minimum_interval(self, tmp_path): with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 mock_run.return_value.stdout = "" st._install_cron_block("my-loop", tmp_path, 30) input_call = mock_run.call_args_list[-1] stdin = input_call.kwargs["input"] assert "*/1 * * * *" in stdin class TestEnableScheduleLinux: def _loop_path(self, tmp_path, name="my-loop"): """Build a fake loop dir structure that _enable_schedule expects.""" loop_dir = tmp_path / ".automaton" / "loops" / name loop_dir.mkdir(parents=True, exist_ok=True) return loop_dir def test_reinstalls_cron_when_stub_exists(self, tmp_path): loop_dir = self._loop_path(tmp_path) stub = loop_dir / LOOP_TICK_SCRIPT_SH stub.write_text("#!/bin/bash\necho tick\n") cfg = {"schedule": {"interval_seconds": 7200}} (loop_dir / "loop.json").write_text(json.dumps(cfg)) with patch("platform.system", return_value="Linux"): with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 mock_run.return_value.stdout = "" st._enable_schedule("my-loop", tmp_path) has_crontab_write = False for c in mock_run.call_args_list: _, kwargs = c if kwargs.get("input") and "*/120 * * * *" in kwargs["input"]: has_crontab_write = True break assert has_crontab_write, "expected crontab write with */120" def test_warns_when_stub_missing(self, tmp_path, capsys): self._loop_path(tmp_path) with patch("platform.system", return_value="Linux"): with patch("subprocess.run") as mock_run: st._enable_schedule("my-loop", tmp_path) mock_run.assert_not_called() captured = capsys.readouterr() assert "WARNING" in captured.err assert "no tick stub" in captured.err def test_reads_interval_from_cfg(self, tmp_path): loop_dir = self._loop_path(tmp_path) stub = loop_dir / LOOP_TICK_SCRIPT_SH stub.write_text("#!/bin/bash\n") (loop_dir / "loop.json").write_text( json.dumps({"schedule": {"interval_seconds": 180}})) with patch("platform.system", return_value="Linux"): with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 mock_run.return_value.stdout = "" st._enable_schedule("my-loop", tmp_path) found = False for c in mock_run.call_args_list: _, kwargs = c inp = kwargs.get("input", "") if "*/3 * * * *" in inp: found = True break assert found, "expected */3 interval" def test_fallback_interval_when_garbage(self, tmp_path): loop_dir = self._loop_path(tmp_path) stub = loop_dir / LOOP_TICK_SCRIPT_SH stub.write_text("#!/bin/bash\n") (loop_dir / "loop.json").write_text( json.dumps({"schedule": {"interval_seconds": "twenty"}})) with patch("platform.system", return_value="Linux"): with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 mock_run.return_value.stdout = "" st._enable_schedule("my-loop", tmp_path) found = False for c in mock_run.call_args_list: _, kwargs = c inp = kwargs.get("input", "") if "*/60 * * * *" in inp: found = True break assert found, "expected */60 fallback interval" def test_idempotent_two_calls(self, tmp_path): loop_dir = self._loop_path(tmp_path) stub = loop_dir / LOOP_TICK_SCRIPT_SH stub.write_text("#!/bin/bash\n") (loop_dir / "loop.json").write_text( json.dumps({"schedule": {"interval_seconds": 3600}})) existing = "# automaton-loop:my-loop\n*/60 * * * * /stub\n# end automaton-loop:my-loop\n" with patch("platform.system", return_value="Linux"): with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 mock_run.return_value.stdout = existing st._enable_schedule("my-loop", tmp_path) st._enable_schedule("my-loop", tmp_path) last_input = "" for c in mock_run.call_args_list: _, kwargs = c if kwargs.get("input"): last_input = kwargs["input"] blocks = [l for l in last_input.splitlines() if l.strip().startswith("# automaton-loop:")] assert len(blocks) == 1, "should have exactly one block after two calls" def test_darwin_branch_unchanged(self, tmp_path): self._loop_path(tmp_path) plist = Path.home() / "Library" / "LaunchAgents" / f"com.automaton.loop.my-loop.plist" disabled = plist.with_suffix(".plist.disabled") try: disabled.parent.mkdir(parents=True, exist_ok=True) disabled.write_text("fake") with patch("platform.system", return_value="Darwin"): st._enable_schedule("my-loop", tmp_path) assert plist.exists(), "Darwin should rename .disabled back" assert not disabled.exists() finally: plist.unlink(missing_ok=True) disabled.unlink(missing_ok=True) def test_windows_branch_unchanged(self, tmp_path): self._loop_path(tmp_path) with patch("platform.system", return_value="Windows"): with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 st._enable_schedule("my-loop", tmp_path) mock_run.assert_called_once() class TestDisableScheduleLinux: def test_still_strips_after_extraction(self, tmp_path): loop_dir = tmp_path / ".automaton" / "loops" / "my-loop" loop_dir.mkdir(parents=True, exist_ok=True) stub = loop_dir / LOOP_TICK_SCRIPT_SH stub.write_text("#!/bin/bash\n") existing = ( "# automaton-loop:my-loop\n" "*/60 * * * * /stub\n" "# end automaton-loop:my-loop\n" "# unrelated\n" ) with patch("platform.system", return_value="Linux"): with patch("subprocess.run") as mock_run: mock_run.return_value.returncode = 0 mock_run.return_value.stdout = existing st._disable_schedule("my-loop", tmp_path) inputs = [] for c in mock_run.call_args_list: _, kwargs = c inp = kwargs.get("input") if inp: inputs.append(inp) assert len(inputs) == 1, "expected one crontab - write" assert "# automaton-loop:my-loop" not in inputs[0], "block should be stripped" assert "# unrelated" in inputs[0], "unrelated lines preserved"