* refactor: re-architect tests to mirror the codebase
* Update tests.yml
* fix: add missing tool_error imports after registry refactor
* fix(tests): replace patch.dict with monkeypatch to prevent env var leaks under xdist
patch.dict(os.environ) can leak TERMINAL_ENV across xdist workers,
causing test_code_execution tests to hit the Modal remote path.
* fix(tests): fix update_check and telegram xdist failures
- test_update_check: replace patch("hermes_cli.banner.os.getenv") with
monkeypatch.setenv("HERMES_HOME") — banner.py no longer imports os
directly, it uses get_hermes_home() from hermes_constants.
- test_telegram_conflict/approval_buttons: provide real exception classes
for telegram.error mock (NetworkError, TimedOut, BadRequest) so the
except clause in connect() doesn't fail with "catching classes that do
not inherit from BaseException" when xdist pollutes sys.modules.
* fix(tests): accept unavailable_models kwarg in _prompt_model_selection mock
81 lines
3.1 KiB
Python
81 lines
3.1 KiB
Python
"""Tests for save_config_value() in cli.py — atomic write behavior."""
|
|
|
|
import os
|
|
import yaml
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
class TestSaveConfigValueAtomic:
|
|
"""save_config_value() must use atomic_yaml_write to avoid data loss."""
|
|
|
|
@pytest.fixture
|
|
def config_env(self, tmp_path, monkeypatch):
|
|
"""Isolated config environment with a writable config.yaml."""
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
config_path = hermes_home / "config.yaml"
|
|
config_path.write_text(yaml.dump({
|
|
"model": {"default": "test-model", "provider": "openrouter"},
|
|
"display": {"skin": "default"},
|
|
}))
|
|
monkeypatch.setattr("cli._hermes_home", hermes_home)
|
|
return config_path
|
|
|
|
def test_calls_atomic_yaml_write(self, config_env, monkeypatch):
|
|
"""save_config_value must route through atomic_yaml_write, not bare open()."""
|
|
mock_atomic = MagicMock()
|
|
monkeypatch.setattr("utils.atomic_yaml_write", mock_atomic)
|
|
|
|
from cli import save_config_value
|
|
save_config_value("display.skin", "mono")
|
|
|
|
mock_atomic.assert_called_once()
|
|
written_path, written_data = mock_atomic.call_args[0]
|
|
assert Path(written_path) == config_env
|
|
assert written_data["display"]["skin"] == "mono"
|
|
|
|
def test_preserves_existing_keys(self, config_env):
|
|
"""Writing a new key must not clobber existing config entries."""
|
|
from cli import save_config_value
|
|
save_config_value("agent.max_turns", 50)
|
|
|
|
result = yaml.safe_load(config_env.read_text())
|
|
assert result["model"]["default"] == "test-model"
|
|
assert result["model"]["provider"] == "openrouter"
|
|
assert result["display"]["skin"] == "default"
|
|
assert result["agent"]["max_turns"] == 50
|
|
|
|
def test_creates_nested_keys(self, config_env):
|
|
"""Dot-separated paths create intermediate dicts as needed."""
|
|
from cli import save_config_value
|
|
save_config_value("compression.summary_model", "google/gemini-3-flash-preview")
|
|
|
|
result = yaml.safe_load(config_env.read_text())
|
|
assert result["compression"]["summary_model"] == "google/gemini-3-flash-preview"
|
|
|
|
def test_overwrites_existing_value(self, config_env):
|
|
"""Updating an existing key replaces the value."""
|
|
from cli import save_config_value
|
|
save_config_value("display.skin", "ares")
|
|
|
|
result = yaml.safe_load(config_env.read_text())
|
|
assert result["display"]["skin"] == "ares"
|
|
|
|
def test_file_not_truncated_on_error(self, config_env, monkeypatch):
|
|
"""If atomic_yaml_write raises, the original file is untouched."""
|
|
original_content = config_env.read_text()
|
|
|
|
def exploding_write(*args, **kwargs):
|
|
raise OSError("disk full")
|
|
|
|
monkeypatch.setattr("utils.atomic_yaml_write", exploding_write)
|
|
|
|
from cli import save_config_value
|
|
result = save_config_value("display.skin", "broken")
|
|
|
|
assert result is False
|
|
assert config_env.read_text() == original_content
|