"""Tests for Automation Blueprints — the parameterized automation blueprint system. Covers the core catalog/slot schema/renderers/fill (cron/blueprint_catalog.py), the shared /blueprint command handler (hermes_cli/blueprint_cmd.py), and the docs generator. Uses an isolated HERMES_HOME for anything that touches the cron job store. """ import importlib import json from pathlib import Path from unittest.mock import patch import pytest from cron.blueprint_catalog import ( CATALOG, BlueprintFillError, BlueprintSlot, fill_blueprint, get_blueprint, blueprint_catalog_entry, blueprint_deeplink, blueprint_form_schema, blueprint_slash_command, ) class TestCatalog: def test_catalog_nonempty_and_keyed(self): assert len(CATALOG) >= 1 for r in CATALOG: assert get_blueprint(r.key) is r def test_every_slot_has_known_type(self): for r in CATALOG: for s in r.slots: assert s.type in {"time", "enum", "text", "weekdays"} def test_bad_slot_type_rejected(self): with pytest.raises(ValueError): BlueprintSlot(name="x", type="bogus", label="X") class TestScheduleResolution: def test_time_to_cron(self): spec = fill_blueprint(get_blueprint("morning-brief"), {"time": "08:30"}) assert spec["schedule"] == "30 8 * * *" def test_interval_schedule(self): spec = fill_blueprint( get_blueprint("important-mail"), {"interval_min": "15", "criteria": "x", "deliver": "origin"}, ) assert spec["schedule"] == "*/15 * * * *" def test_day_to_dow(self): spec = fill_blueprint( get_blueprint("weekly-review"), {"time": "18:00", "day": "sunday", "deliver": "origin"}, ) assert spec["schedule"] == "0 18 * * 0" def test_weekday_preset_to_dow(self): spec = fill_blueprint( get_blueprint("custom-reminder"), {"what": "stretch", "time": "14:00", "recurrence": "weekdays", "deliver": "origin"}, ) assert spec["schedule"] == "0 14 * * 1-5" def test_defaults_fill_when_omitted(self): spec = fill_blueprint(get_blueprint("morning-brief"), {}) assert spec["schedule"] == "0 8 * * *" class TestValidation: def test_invalid_time_rejected(self): with pytest.raises(BlueprintFillError, match="invalid time"): fill_blueprint(get_blueprint("morning-brief"), {"time": "25:99"}) def test_bad_enum_rejected_and_names_slot(self): with pytest.raises(BlueprintFillError, match="not allowed"): fill_blueprint(get_blueprint("news-digest"), {"count": "42"}) def test_deliver_slot_accepts_any_platform(self): # deliver is a non-strict enum: its options are suggestions, the real # set of valid platforms depends on the user's configured gateways and # is validated downstream by the cron scheduler. spec = fill_blueprint(get_blueprint("morning-brief"), {"time": "08:00", "deliver": "slack"}) assert spec["deliver"] == "slack" def test_unknown_slot_name_rejected(self): # A typo'd slot must NOT silently create a job with the default value. with pytest.raises(BlueprintFillError, match="unknown slot"): fill_blueprint(get_blueprint("morning-brief"), {"tiem": "07:15"}) def test_hydration_hourly_step_actually_fires_at_chosen_cadence(self): # Regression: a minute-field step (*/90) silently wraps to hourly. # The hour-field step form must produce the cadence the user picked. croniter = pytest.importorskip("croniter").croniter from datetime import datetime spec = fill_blueprint(get_blueprint("hydration-move"), {"interval_hours": "2"}) it = croniter(spec["schedule"], datetime(2026, 6, 10, 8, 0)) first_three = [it.get_next(datetime) for _ in range(3)] gaps = { (b - a).total_seconds() for a, b in zip(first_three, first_three[1:]) } assert gaps == {7200.0}, f"expected 2h gaps, got {spec['schedule']} -> {first_three}" def test_text_slot_renders_into_prompt(self): spec = fill_blueprint( get_blueprint("important-mail"), {"interval_min": "30", "criteria": "from my CEO", "deliver": "origin"}, ) assert "from my CEO" in spec["prompt"] def test_origin_threads_through(self): spec = fill_blueprint( get_blueprint("morning-brief"), {"time": "08:00"}, origin={"platform": "telegram", "chat_id": "9"} ) assert spec["origin"] == {"platform": "telegram", "chat_id": "9"} class TestRenderers: def test_form_schema_fields(self): schema = blueprint_form_schema(get_blueprint("morning-brief")) names = [f["name"] for f in schema["fields"]] assert names == ["time", "deliver"] assert schema["key"] == "morning-brief" def test_slash_command_defaults(self): cmd = blueprint_slash_command(get_blueprint("morning-brief")) assert cmd.startswith("/blueprint morning-brief") assert "time=08:00" in cmd def test_slash_command_quotes_freetext(self): cmd = blueprint_slash_command( get_blueprint("custom-reminder"), {"what": "drink water", "time": "10:00"} ) assert '"drink water"' in cmd def test_deeplink_shape(self): url = blueprint_deeplink(get_blueprint("morning-brief"), {"time": "07:15"}) assert url.startswith("hermes://blueprint/morning-brief?") assert "time=07" in url def test_catalog_entry_has_all_surfaces(self): entry = blueprint_catalog_entry(get_blueprint("morning-brief")) assert entry["command"].startswith("/blueprint") assert entry["appUrl"].startswith("hermes://") assert entry["scheduleHuman"] assert "fields" in entry @pytest.fixture def isolated_home(tmp_path, monkeypatch): home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) import hermes_constants importlib.reload(hermes_constants) import cron.jobs as jobs importlib.reload(jobs) return jobs class TestCommandHandler: def test_bare_lists_catalog(self, isolated_home): from hermes_cli.blueprint_cmd import handle_blueprint_command res = handle_blueprint_command("") assert "morning-brief" in res.text and "Automation Blueprints" in res.text assert res.agent_seed is None def test_name_seeds_agent(self, isolated_home): from hermes_cli.blueprint_cmd import handle_blueprint_command # `/blueprint ` (no inline slots) now seeds the agent to ask # the user for each value conversationally instead of dumping fields. res = handle_blueprint_command("morning-brief") assert res.agent_seed is not None assert "morning-brief" in res.agent_seed assert "cronjob tool" in res.agent_seed # the schedule template is handed to the agent to build the cron expr assert "* * *" in res.agent_seed def test_name_match_is_forgiving(self, isolated_home): from hermes_cli.blueprint_cmd import handle_blueprint_command, match_blueprint # prefix match r, cands = match_blueprint("morning") assert r is not None and r.key == "morning-brief" # fuzzy / typo r2, _ = match_blueprint("mornning-brief") assert r2 is not None and r2.key == "morning-brief" # a forgiving name still seeds the agent res = handle_blueprint_command("morning") assert res.agent_seed is not None def test_fill_creates_job(self, isolated_home): from hermes_cli.blueprint_cmd import handle_blueprint_command res = handle_blueprint_command("morning-brief time=07:30 deliver=telegram") assert "Scheduled" in res.text assert res.agent_seed is None jobs = isolated_home.load_jobs() assert len(jobs) == 1 assert (jobs[0].get("schedule_display") or jobs[0].get("schedule")) == "30 7 * * *" assert jobs[0].get("deliver") == "telegram" def test_unknown_blueprint(self, isolated_home): from hermes_cli.blueprint_cmd import handle_blueprint_command res = handle_blueprint_command("zzz-nope-nothing") assert "No automation blueprint" in res.text assert res.agent_seed is None def test_bad_value_names_slot(self, isolated_home): from hermes_cli.blueprint_cmd import handle_blueprint_command res = handle_blueprint_command("morning-brief time=99:99") assert "Can't set up" in res.text and "time" in res.text assert res.agent_seed is None class TestDocsGenerator: def test_generator_emits_valid_index(self, tmp_path): # The generator imports the catalog and writes a flat JSON array. import importlib.util script = ( Path(__file__).resolve().parents[2] / "website" / "scripts" / "extract-automation-blueprints.py" ) spec = importlib.util.spec_from_file_location("extract_cron_blueprints", script) assert spec is not None and spec.loader is not None mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) index = mod.build_index() assert isinstance(index, list) and len(index) == len(CATALOG) # Each entry must round-trip through json and carry the surfaces. json.dumps(index) assert all("command" in e and "appUrl" in e for e in index)