- 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>
263 lines
9.7 KiB
Python
263 lines
9.7 KiB
Python
"""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 |