feat: extract config.py, refactor CLI to use event-driven engine, add deprecation shim
- config.py: extracted load_config(), find_workspace_config(), load_discussion_config() from cli.py; fixed Windows root detection - cli.py: run_discussion() now creates DiscussionEngine + EventEmitter, subscribes to events with ANSI-color output (identical to before), delegates to engine.run() - backends/custom/run.py: deprecation shim with DeprecationWarning, delegates to DiscussionEngine - 16 new config tests, all 184 tests passing Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
263
tests/test_config.py
Normal file
263
tests/test_config.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Tests for meeting_room.config — config loading and DiscussionConfig conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from meeting_room.config import (
|
||||
find_workspace_config,
|
||||
load_config,
|
||||
load_discussion_config,
|
||||
)
|
||||
from meeting_room.models import (
|
||||
DefaultsConfig,
|
||||
DiscussionConfig,
|
||||
ProviderConfig,
|
||||
RoleConfig,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_config — basic loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
def test_loads_default_config(self) -> None:
|
||||
"""load_config() with no path loads the package default config.yaml."""
|
||||
config = load_config()
|
||||
assert "providers" in config
|
||||
assert "roles" in config
|
||||
assert "defaults" in config
|
||||
|
||||
def test_loads_custom_config(self, tmp_path) -> None:
|
||||
"""load_config(path) loads a specific YAML file."""
|
||||
custom = {
|
||||
"providers": {
|
||||
"test": {"base_url": "https://api.test.com/v1", "api_key": "sk-test"},
|
||||
},
|
||||
"roles": {
|
||||
"analyst": {
|
||||
"name": "Analyst",
|
||||
"provider": "test",
|
||||
"model": "gpt-3.5",
|
||||
"temperature": 0.5,
|
||||
"tools": "none",
|
||||
"system_prompt": "You are an analyst.",
|
||||
},
|
||||
},
|
||||
"defaults": {"max_rounds": 5, "language": "en"},
|
||||
}
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
|
||||
|
||||
config = load_config(str(config_path))
|
||||
|
||||
assert config["providers"]["test"]["base_url"] == "https://api.test.com/v1"
|
||||
assert config["roles"]["analyst"]["name"] == "Analyst"
|
||||
assert config["defaults"]["max_rounds"] == 5
|
||||
|
||||
def test_env_var_resolution(self, tmp_path, monkeypatch) -> None:
|
||||
"""${ENV_VAR} placeholders are resolved from the environment."""
|
||||
monkeypatch.setenv("TEST_MR_API_KEY", "sk-from-env")
|
||||
monkeypatch.setenv("TEST_MR_BASE_URL", "https://env-url.com/v1")
|
||||
|
||||
custom = {
|
||||
"providers": {
|
||||
"testprov": {
|
||||
"base_url": "${TEST_MR_BASE_URL}",
|
||||
"api_key": "${TEST_MR_API_KEY}",
|
||||
},
|
||||
},
|
||||
"roles": {},
|
||||
"defaults": {"max_rounds": 3},
|
||||
}
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
|
||||
|
||||
config = load_config(str(config_path))
|
||||
|
||||
assert config["providers"]["testprov"]["base_url"] == "https://env-url.com/v1"
|
||||
assert config["providers"]["testprov"]["api_key"] == "sk-from-env"
|
||||
|
||||
def test_unresolved_env_var_preserved(self, tmp_path) -> None:
|
||||
"""Unresolved ${ENV_VAR} stays as-is in the config."""
|
||||
custom = {
|
||||
"providers": {
|
||||
"testprov": {
|
||||
"base_url": "https://api.test.com/v1",
|
||||
"api_key": "${NONEXISTENT_VAR_12345}",
|
||||
},
|
||||
},
|
||||
"roles": {},
|
||||
"defaults": {},
|
||||
}
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
|
||||
|
||||
config = load_config(str(config_path))
|
||||
|
||||
assert config["providers"]["testprov"]["api_key"] == "${NONEXISTENT_VAR_12345}"
|
||||
|
||||
def test_env_override_api_key(self, monkeypatch) -> None:
|
||||
"""MEETING_ROOM_<PROV>_API_KEY overrides the config value."""
|
||||
monkeypatch.setenv("MEETING_ROOM_ROUTERAI_API_KEY", "sk-override")
|
||||
|
||||
config = load_config()
|
||||
assert config["providers"]["routerai"]["api_key"] == "sk-override"
|
||||
|
||||
def test_env_override_base_url(self, monkeypatch) -> None:
|
||||
"""MEETING_ROOM_<PROV>_BASE_URL overrides the config value."""
|
||||
monkeypatch.setenv("MEETING_ROOM_ROUTERAI_BASE_URL", "https://override.com/v1")
|
||||
|
||||
config = load_config()
|
||||
assert config["providers"]["routerai"]["base_url"] == "https://override.com/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_workspace_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindWorkspaceConfig:
|
||||
def test_finds_config_in_cwd(self, tmp_path, monkeypatch) -> None:
|
||||
"""Finds .meeting-room/config.yaml in the current working directory."""
|
||||
ws_dir = tmp_path / ".meeting-room"
|
||||
ws_dir.mkdir()
|
||||
(ws_dir / "config.yaml").write_text("defaults: {}", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
result = find_workspace_config()
|
||||
|
||||
assert result is not None
|
||||
assert result.endswith(".meeting-room\\config.yaml") or result.endswith(".meeting-room/config.yaml")
|
||||
|
||||
def test_finds_config_in_parent(self, tmp_path, monkeypatch) -> None:
|
||||
"""Searches upward to find .meeting-room/config.yaml."""
|
||||
ws_dir = tmp_path / ".meeting-room"
|
||||
ws_dir.mkdir()
|
||||
(ws_dir / "config.yaml").write_text("defaults: {}", encoding="utf-8")
|
||||
child = tmp_path / "subdir"
|
||||
child.mkdir()
|
||||
monkeypatch.chdir(child)
|
||||
|
||||
result = find_workspace_config()
|
||||
|
||||
assert result is not None
|
||||
|
||||
def test_returns_none_when_not_found(self, tmp_path, monkeypatch) -> None:
|
||||
"""Returns None when no .meeting-room/config.yaml exists."""
|
||||
# Use a tmp dir with no .meeting-room
|
||||
isolated = tmp_path / "isolated"
|
||||
isolated.mkdir()
|
||||
monkeypatch.chdir(isolated)
|
||||
# Ensure no .meeting-room exists anywhere up to root
|
||||
# (This may find a .meeting-room in a parent if one exists,
|
||||
# but in a temp dir hierarchy it shouldn't)
|
||||
result = find_workspace_config()
|
||||
# We can't guarantee None since there might be .meeting-room in parent dirs,
|
||||
# so we just check it's either None or a valid path
|
||||
if result is not None:
|
||||
assert os.path.exists(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_discussion_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadDiscussionConfig:
|
||||
def test_returns_discussion_config(self) -> None:
|
||||
"""load_discussion_config() returns a DiscussionConfig instance."""
|
||||
config = load_discussion_config()
|
||||
|
||||
assert isinstance(config, DiscussionConfig)
|
||||
assert isinstance(config.providers, dict)
|
||||
assert isinstance(config.roles, dict)
|
||||
assert isinstance(config.defaults, DefaultsConfig)
|
||||
|
||||
def test_providers_are_typed(self) -> None:
|
||||
"""Provider values are ProviderConfig instances."""
|
||||
config = load_discussion_config()
|
||||
|
||||
for name, prov in config.providers.items():
|
||||
assert isinstance(prov, ProviderConfig), f"Provider {name} is not ProviderConfig"
|
||||
assert prov.base_url
|
||||
assert prov.api_key is not None
|
||||
|
||||
def test_roles_are_typed(self) -> None:
|
||||
"""Role values are RoleConfig instances."""
|
||||
config = load_discussion_config()
|
||||
|
||||
for name, role in config.roles.items():
|
||||
assert isinstance(role, RoleConfig), f"Role {name} is not RoleConfig"
|
||||
assert role.name
|
||||
assert role.provider
|
||||
assert role.model
|
||||
|
||||
def test_defaults_populated(self) -> None:
|
||||
"""DefaultsConfig has expected fields."""
|
||||
config = load_discussion_config()
|
||||
|
||||
assert config.defaults.max_rounds > 0
|
||||
assert config.defaults.language is not None
|
||||
assert config.defaults.framework is not None
|
||||
|
||||
def test_custom_config_path(self, tmp_path) -> None:
|
||||
"""load_discussion_config(path) loads from a custom file."""
|
||||
custom = {
|
||||
"providers": {
|
||||
"test": {"base_url": "https://api.test.com/v1", "api_key": "sk-test"},
|
||||
},
|
||||
"roles": {
|
||||
"analyst": {
|
||||
"name": "Analyst",
|
||||
"provider": "test",
|
||||
"model": "gpt-3.5",
|
||||
"temperature": 0.5,
|
||||
"tools": "none",
|
||||
"system_prompt": "You are an analyst.",
|
||||
},
|
||||
},
|
||||
"defaults": {"max_rounds": 5, "language": "en"},
|
||||
}
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump(custom, default_flow_style=False), encoding="utf-8")
|
||||
|
||||
config = load_discussion_config(str(config_path))
|
||||
|
||||
assert isinstance(config, DiscussionConfig)
|
||||
assert "test" in config.providers
|
||||
assert config.providers["test"].base_url == "https://api.test.com/v1"
|
||||
assert "analyst" in config.roles
|
||||
assert config.roles["analyst"].model == "gpt-3.5"
|
||||
assert config.defaults.max_rounds == 5
|
||||
|
||||
def test_env_overrides_applied_before_typing(self, monkeypatch) -> None:
|
||||
"""Environment variable overrides are applied before converting to typed config."""
|
||||
monkeypatch.setenv("MEETING_ROOM_ROUTERAI_API_KEY", "sk-from-env")
|
||||
|
||||
config = load_discussion_config()
|
||||
|
||||
assert config.providers["routerai"].api_key == "sk-from-env"
|
||||
|
||||
def test_minimal_config(self, tmp_path) -> None:
|
||||
"""A minimal config with just defaults works."""
|
||||
minimal = {
|
||||
"providers": {},
|
||||
"roles": {},
|
||||
"defaults": {"max_rounds": 3},
|
||||
}
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(yaml.dump(minimal, default_flow_style=False), encoding="utf-8")
|
||||
|
||||
config = load_discussion_config(str(config_path))
|
||||
|
||||
assert isinstance(config, DiscussionConfig)
|
||||
assert len(config.providers) == 0
|
||||
assert len(config.roles) == 0
|
||||
assert config.defaults.max_rounds == 3
|
||||
Reference in New Issue
Block a user