hermes-hub/tests/test_model_choice_and_discovery.py
ochenstarik-ui 2b89372b0f feat(config): A41 чистая конфигурация при первой установке, безопасность учетных данных и сброс
1. P0-1: get_default_router_config() возвращает чистую конфигурацию (0 профилей,
   13 канонических ролей с пустыми цепочками). Миграция не внедряет фиктивные профили.
2. P0-2: Учетные данные (~/.hermes/*_profiles/, hub_settings.json) изолированы и
   никогда не затрагиваются при сбросе или установке.
3. P0-3: Профили создаются динамически при подключении аккаунтов (ag-1, codex-1, etc.).
   Пустые цепочки ролей являются нормальным рабочим состоянием.
4. P0-4: Добавлен экшен reset_router_config и кнопка «Начать настройку заново»
   в настройках с подтверждением и созданием бэкапа router_profiles.yaml.bak_<timestamp>.
5. P0-5: scripts/verify_multi_provider_router.py адаптирован и проходит 10/10 PASS
   как на пустой конфигурации, так и на заполненной.
6. tests/test_a41_clean_install.py: 6 тестов, 490 passed, ruff чисто.
2026-08-31 02:27:42 +07:00

141 lines
4.9 KiB
Python

"""
Hermes Hub — Model Choice & Discovery Persistence Tests.
Verifies P0-1 and P0-2 requirements of Task A18.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
import pytest
from antigravity_provider.router.action_handler import ActionExecutor, do_set_model
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.router_config import load_router_config, save_router_config
@pytest.fixture
def temp_models_cache(tmp_path, monkeypatch):
"""Provide isolated models_cache.json."""
cache_file = tmp_path / "models_cache.json"
cache_file.write_text(
json.dumps({
"antigravity": {
"models": [
"gemini-3.7-flash-high",
"gemini-3.5-flash-high",
"gemini-3.1-pro-high",
"claude-sonnet-4-6",
],
"discovered_at": time.time(),
},
"opencode-go": {
"models": ["deepseek-r1", "deepseek-v4-pro", "qwen3.8-max"],
"discovered_at": time.time(),
},
}, ensure_ascii=False),
encoding="utf-8"
)
svc = ModelDiscoveryService(cache_path=cache_file)
monkeypatch.setattr(ModelDiscoveryService, "get", classmethod(lambda cls: svc))
return svc, cache_file
def test_set_model_success_and_config_persistence(temp_models_cache):
"""Verify setting valid discovered model succeeds and persists in router_config."""
svc, _ = temp_models_cache
profile_id = "ag-w1"
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
config = load_router_config()
assert profile_id in config.profiles
# Pick valid discovered model
target_model = "gemini-3.1-pro-high"
res = ActionExecutor.execute("set_model", {
"profile_id": profile_id,
"model": target_model,
"role_id": "developer-1",
})
assert res["ok"] is True
assert "успешно" in res["message"]
# Verify persistence on disk
reloaded = load_router_config()
assert reloaded.profiles[profile_id].preferred_models[0] == target_model
assert reloaded.roles["developer-1"].default_model == target_model
def test_set_model_rejects_nonexistent_model(temp_models_cache):
"""Verify nonexistent model is strictly rejected without modifying configuration."""
svc, _ = temp_models_cache
profile_id = "ag-w1"
AutoAssigner.ensure_profile_definition("antigravity", profile_id)
config = load_router_config()
orig_models = list(config.profiles[profile_id].preferred_models)
# Attempt to set invalid/hallucinated model
res = ActionExecutor.execute("set_model", {
"profile_id": profile_id,
"model": "gemini-nonexistent-model-99",
})
assert res["ok"] is False
assert "отсутствует в списке" in res["message"] or "не поддерживается" in res["message"]
# Verify config was not corrupted
reloaded = load_router_config()
assert reloaded.profiles[profile_id].preferred_models == orig_models
def test_models_cache_disk_persistence_and_reload(tmp_path):
"""Verify models cache survives service recreation and disk reload."""
cache_file = tmp_path / "models_cache.json"
cache_file.write_text(
json.dumps({
"claude": {
"models": ["claude-3-7-sonnet", "claude-sonnet-4-6"],
"discovered_at": time.time(),
}
}, ensure_ascii=False),
encoding="utf-8"
)
svc1 = ModelDiscoveryService(cache_path=cache_file)
assert svc1.get_models("claude") == ["claude-3-7-sonnet", "claude-sonnet-4-6"]
# Simulate new process/service instance
svc2 = ModelDiscoveryService(cache_path=cache_file)
assert svc2.get_models("claude") == ["claude-3-7-sonnet", "claude-sonnet-4-6"]
def test_models_discovery_timeout_retains_existing_cache(tmp_path, monkeypatch):
"""Verify that timeout/probe failure does not erase previously cached models."""
cache_file = tmp_path / "models_cache.json"
cache_file.write_text(
json.dumps({
"grok": {
"models": ["grok-2-latest", "grok-beta"],
"discovered_at": time.time(),
}
}, ensure_ascii=False),
encoding="utf-8"
)
svc = ModelDiscoveryService(cache_path=cache_file)
# Mock _probe_provider to simulate a hang/timeout
def mock_hang_probe(provider):
time.sleep(2.0)
return ["invented-model"]
monkeypatch.setattr(svc, "_probe_provider", mock_hang_probe)
# Synchronous probe with 0.1s timeout
result = svc.discover_models_sync("grok", timeout=0.1)
# Result should retain existing cached models
assert result == ["grok-2-latest", "grok-beta"]
assert svc.get_models("grok") == ["grok-2-latest", "grok-beta"]