diff --git a/tests/test_config_drift_detector.py b/tests/test_config_drift_detector.py new file mode 100644 index 00000000..4024c702 --- /dev/null +++ b/tests/test_config_drift_detector.py @@ -0,0 +1,228 @@ +"""Tests for config drift detection.""" +import json +import sys +from pathlib import Path + +import pytest + +SCRIPT_DIR = Path(__file__).resolve().parent.parent / "scripts" +sys.path.insert(0, str(SCRIPT_DIR)) + +from config_drift_detector import ( + load_yaml, + _flatten, + diff_configs, + generate_sync_patch, + format_drift_markdown, + format_report, +) + + +# ── YAML Loading ───────────────────────────────────────────────────────────── + +class TestLoadYaml: + def test_valid_yaml(self): + result = load_yaml("model:\n default: hermes\n provider: nous") + assert result["model"]["default"] == "hermes" + + def test_empty_string(self): + assert load_yaml("") == {} + + def test_invalid_yaml(self): + assert load_yaml("{invalid yaml ][") == {} + + def test_non_dict_root(self): + assert load_yaml("- item1\n- item2") == {} + + +# ── Flatten ────────────────────────────────────────────────────────────────── + +class TestFlatten: + def test_flat_dict(self): + assert _flatten({"a": 1, "b": 2}) == {"a": 1, "b": 2} + + def test_nested_dict(self): + result = _flatten({"model": {"default": "hermes", "provider": "nous"}}) + assert result["model.default"] == "hermes" + assert result["model.provider"] == "nous" + + def test_deeply_nested(self): + result = _flatten({"a": {"b": {"c": 42}}}) + assert result["a.b.c"] == 42 + + def test_list_serialized_as_json(self): + result = _flatten({"toolsets": ["all", "terminal"]}) + assert result["toolsets"] == '["all", "terminal"]' + + def test_empty_dict(self): + assert _flatten({}) == {} + + +# ── Diff Configs ───────────────────────────────────────────────────────────── + +class TestDiffConfigs: + def test_identical_configs(self): + cfg = {"model": {"default": "hermes"}, "agent": {"max_turns": 30}} + assert diff_configs(cfg, cfg) == [] + + def test_changed_value(self): + canon = {"model": {"default": "hermes"}} + remote = {"model": {"default": "gpt-4"}} + drifts = diff_configs(canon, remote) + assert len(drifts) == 1 + assert drifts[0]["key"] == "model.default" + assert drifts[0]["drift_type"] == "changed" + assert drifts[0]["canonical_value"] == "hermes" + assert drifts[0]["remote_value"] == "gpt-4" + + def test_missing_remote_key(self): + canon = {"model": {"default": "hermes"}, "agent": {"max_turns": 30}} + remote = {"model": {"default": "hermes"}} + drifts = diff_configs(canon, remote) + assert len(drifts) == 1 + assert drifts[0]["key"] == "agent.max_turns" + assert drifts[0]["drift_type"] == "missing_remote" + + def test_extra_remote_key(self): + canon = {"model": {"default": "hermes"}} + remote = {"model": {"default": "hermes"}, "extra": {"key": 1}} + drifts = diff_configs(canon, remote) + assert len(drifts) == 1 + assert drifts[0]["drift_type"] == "extra_remote" + + def test_ignored_keys(self): + canon = {"terminal": {"cwd": "/a"}, "model": {"default": "hermes"}} + remote = {"terminal": {"cwd": "/b"}, "model": {"default": "hermes"}} + # terminal.cwd is in the default ignore list + drifts = diff_configs(canon, remote) + assert len(drifts) == 0 + + def test_api_keys_ignored(self): + canon = {"auxiliary": {"vision": {"api_key": "sk-old"}}} + remote = {"auxiliary": {"vision": {"api_key": "sk-new"}}} + drifts = diff_configs(canon, remote) + assert len(drifts) == 0 + + def test_custom_ignored_keys(self): + canon = {"a": 1, "b": 2} + remote = {"a": 99, "b": 99} + drifts = diff_configs(canon, remote, ignored_keys=["a"]) + assert len(drifts) == 1 + assert drifts[0]["key"] == "b" + + def test_multiple_drifts(self): + canon = {"model": {"default": "hermes", "provider": "nous"}, "agent": {"max_turns": 30}} + remote = {"model": {"default": "gpt-4", "provider": "openai"}, "agent": {"max_turns": 30}} + drifts = diff_configs(canon, remote) + assert len(drifts) == 2 + keys = {d["key"] for d in drifts} + assert "model.default" in keys + assert "model.provider" in keys + + def test_list_values_compared_by_content(self): + canon = {"toolsets": ["all"]} + remote = {"toolsets": ["terminal", "file"]} + drifts = diff_configs(canon, remote) + assert len(drifts) == 1 + assert drifts[0]["drift_type"] == "changed" + + +# ── Sync Patch ─────────────────────────────────────────────────────────────── + +class TestGenerateSyncPatch: + def test_empty_when_identical(self): + cfg = {"model": {"default": "hermes"}} + assert generate_sync_patch(cfg, cfg) == {} + + def test_patches_changed_values(self): + canon = {"model": {"default": "hermes"}} + remote = {"model": {"default": "gpt-4"}} + patch = generate_sync_patch(canon, remote) + assert patch["model.default"] == "hermes" + + def test_patches_missing_keys(self): + canon = {"model": {"default": "hermes"}, "agent": {"max_turns": 30}} + remote = {"model": {"default": "hermes"}} + patch = generate_sync_patch(canon, remote) + assert patch["agent.max_turns"] == 30 + + def test_does_not_delete_extra_keys(self): + canon = {"model": {"default": "hermes"}} + remote = {"model": {"default": "hermes"}, "extra": 1} + patch = generate_sync_patch(canon, remote) + assert "extra" not in patch + + +# ── Formatting ─────────────────────────────────────────────────────────────── + +class TestFormatting: + def test_drift_markdown_no_drift(self): + text = format_drift_markdown([]) + assert "no drift" in text.lower() + + def test_drift_markdown_with_drifts(self): + drifts = [ + {"key": "model.default", "canonical_value": "hermes", "remote_value": "gpt-4", "drift_type": "changed"}, + ] + text = format_drift_markdown(drifts) + assert "model.default" in text + assert "hermes" in text + assert "gpt-4" in text + assert "changed" in text + + def test_drift_markdown_caps_at_30(self): + drifts = [ + {"key": f"key.{i}", "canonical_value": "a", "remote_value": "b", "drift_type": "changed"} + for i in range(50) + ] + text = format_drift_markdown(drifts) + assert "and 20 more" in text + + def test_report_format_has_sections(self): + report = { + "timestamp": "2026-04-15T12:00:00+00:00", + "canonical_config": "config.yaml", + "total_nodes": 4, + "reachable": 3, + "unreachable": 1, + "drifted_nodes": 1, + "clean_nodes": 2, + "total_drifts": 3, + "nodes": { + "allegro": { + "host": "167.99.126.228", + "reachable": True, + "machine_type": "vps", + "config_keys": 25, + "has_drift": True, + "drift_count": 3, + "drifts": [ + {"key": "model.default", "canonical_value": "hermes", "remote_value": "gpt-4", "drift_type": "changed"}, + {"key": "agent.max_turns", "canonical_value": 30, "remote_value": 50, "drift_type": "changed"}, + {"key": "terminal.timeout", "canonical_value": 180, "remote_value": None, "drift_type": "missing_remote"}, + ], + }, + "timmy": { + "host": "localhost", + "reachable": True, + "machine_type": "mac", + "config_keys": 30, + "has_drift": False, + "drift_count": 0, + "drifts": [], + }, + "ezra": { + "host": "143.198.27.163", + "reachable": False, + "drift_count": -1, + "error": "Could not fetch config", + "drifts": [], + }, + }, + } + text = format_report(report) + assert "CONFIG DRIFT DETECTION" in text + assert "allegro" in text.lower() + assert "UNREACHABLE" in text + assert "model.default" in text + assert "no drift" in text.lower() # timmy