Files
aiturk-hermes-ide/tests/hermes_cli/test_cron_dispatch_visibility.py
T

147 lines
4.7 KiB
Python

"""CLI rendering of missed-run dispatch visibility (#99879).
`hermes cron list` shows a Dispatch line (scheduled vs actual, lateness,
disposition) and `hermes cron status` calls out jobs whose last dispatch
was a late/missed-fire catch-up.
"""
from datetime import datetime, timedelta, timezone
import pytest
from cron.jobs import create_job, load_jobs, save_jobs
from hermes_cli.cron import (
_dispatch_display,
_format_lateness,
_print_active_jobs_summary,
cron_list,
)
@pytest.fixture()
def tmp_cron_dir(tmp_path, monkeypatch):
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
return tmp_path
def _stamp_last_dispatch(job_id, stamp):
jobs = load_jobs()
for job in jobs:
if job["id"] == job_id:
job["last_dispatch"] = stamp
save_jobs(jobs)
def _catch_up_stamp(late_seconds=1860.0, kind="catch_up"):
scheduled = datetime(2026, 9, 1, 9, 0, tzinfo=timezone.utc)
return {
"scheduled_at": scheduled.isoformat(),
"dispatched_at": (scheduled + timedelta(seconds=late_seconds)).isoformat(),
"lateness_seconds": late_seconds,
"kind": kind,
}
class TestCronListDispatchLine:
def test_catch_up_dispatch_rendered(self, tmp_cron_dir, capsys, monkeypatch):
monkeypatch.setattr(
"hermes_cli.cron._warn_if_gateway_not_running", lambda: None
)
job = create_job(prompt="daily report", schedule="0 9 * * *")
_stamp_last_dispatch(job["id"], _catch_up_stamp())
cron_list()
out = capsys.readouterr().out
assert "Dispatch:" in out
assert "catch-up after missed fire" in out
assert "2026-09-01T09:00:00+00:00" in out # scheduled instant
assert "31m late" in out
def test_on_time_dispatch_rendered_quietly(self, tmp_cron_dir, capsys, monkeypatch):
monkeypatch.setattr(
"hermes_cli.cron._warn_if_gateway_not_running", lambda: None
)
job = create_job(prompt="daily report", schedule="0 9 * * *")
_stamp_last_dispatch(job["id"], _catch_up_stamp(30.0, kind="on_time"))
cron_list()
out = capsys.readouterr().out
assert "Dispatch:" in out
assert "on time" in out
assert "catch-up" not in out
def test_no_stamp_no_dispatch_line(self, tmp_cron_dir, capsys, monkeypatch):
monkeypatch.setattr(
"hermes_cli.cron._warn_if_gateway_not_running", lambda: None
)
create_job(prompt="daily report", schedule="0 9 * * *")
cron_list()
assert "Dispatch:" not in capsys.readouterr().out
class TestStatusLateJobsCallout:
def test_late_jobs_called_out(self, capsys):
jobs = [
{
"id": "abc123",
"name": "daily 9am",
"next_run_at": "2026-09-02T09:00:00+00:00",
"last_dispatch": _catch_up_stamp(),
},
{
"id": "def456",
"name": "hourly ping",
"next_run_at": "2026-09-01T10:00:00+00:00",
"last_dispatch": _catch_up_stamp(10.0, kind="on_time"),
},
]
_print_active_jobs_summary(jobs)
out = capsys.readouterr().out
assert "1 job(s) last fired late (missed-fire catch-up)" in out
assert "abc123" in out
assert "31m late" in out
# On-time job is not in the callout.
assert "def456" not in out
def test_no_late_jobs_no_callout(self, capsys):
jobs = [
{
"id": "def456",
"name": "hourly ping",
"next_run_at": "2026-09-01T10:00:00+00:00",
"last_dispatch": _catch_up_stamp(10.0, kind="on_time"),
}
]
_print_active_jobs_summary(jobs)
assert "fired late" not in capsys.readouterr().out
class TestDisplayHelpers:
def test_format_lateness(self):
assert _format_lateness(45) == "45s"
assert _format_lateness(1860) == "31m"
assert _format_lateness(9000) == "2h 30m"
assert _format_lateness(90000) == "1d 1h"
assert _format_lateness("bogus") == "?"
def test_dispatch_display_malformed_returns_none(self):
assert _dispatch_display(None) is None
assert _dispatch_display("late") is None
assert _dispatch_display({}) is None
assert _dispatch_display({"scheduled_at": "x"}) is None
def test_dispatch_display_late_kind(self):
line = _dispatch_display(_catch_up_stamp(600.0, kind="late"))
assert "late" in line
assert "10m" in line