"""Tests for meeting_room.models — Pydantic v2 data models.""" import copy from datetime import datetime, timezone import pytest import yaml from meeting_room.models import ( DefaultsConfig, DiscussionConfig, Message, ProviderConfig, RoleConfig, ScenarioConfig, Session, ToolCallRecord, ) # --------------------------------------------------------------------------- # Fixtures — sample data matching config.yaml structure # --------------------------------------------------------------------------- SAMPLE_YAML = """\ providers: routerai: base_url: "https://routerai.ru/api/v1" api_key: "sk-test-key" ollama_cloud: api_key: "02a8f6e9f9744088885982ac645f1ce1.HB1MFh4AlTpyic9oJGxQjNuA" base_url: "https://ollama.com/v1" roles: moderator: name: "Moderator" provider: ollama_cloud model: "gpt-4o-mini" temperature: 0.7 tools: "none" system_prompt: | You are the moderator of a discussion. skeptic: name: "Skeptic" provider: ollama_cloud model: "gpt-4o-mini" temperature: 0.9 tools: "readonly" system_prompt: | You are a skeptic and critic. defaults: max_rounds: 8 language: "ru" framework: "custom" workdir: "." """ @pytest.fixture def yaml_config() -> dict: """Parse SAMPLE_YAML the same way cli.py does.""" return yaml.safe_load(SAMPLE_YAML) # =========================================================================== # ProviderConfig # =========================================================================== class TestProviderConfig: def test_basic(self): p = ProviderConfig(base_url="https://api.example.com/v1", api_key="sk-123") assert p.base_url == "https://api.example.com/v1" assert p.api_key == "sk-123" def test_extra_fields_forbidden(self): with pytest.raises(Exception): ProviderConfig(base_url="https://x", api_key="k", unknown_field="oops") def test_missing_required(self): with pytest.raises(Exception): ProviderConfig(base_url="https://x") # missing api_key # =========================================================================== # RoleConfig # =========================================================================== class TestRoleConfig: def test_basic(self): r = RoleConfig( name="Moderator", provider="ollama_cloud", model="gpt-4o-mini", temperature=0.7, tools="none", system_prompt="You moderate.", ) assert r.name == "Moderator" assert r.temperature == 0.7 assert r.tools == "none" def test_defaults(self): r = RoleConfig(name="Bot", provider="p", model="m") assert r.temperature == 0.7 assert r.tools == "none" assert r.system_prompt == "" def test_tools_string_values(self): for val in ("all", "none", "readonly", "web", "full", "files"): r = RoleConfig(name="X", provider="p", model="m", tools=val) assert r.tools == val def test_tools_list(self): r = RoleConfig(name="X", provider="p", model="m", tools=["read_file", "web_search"]) assert r.tools == ["read_file", "web_search"] def test_tools_coercion_list_items_to_str(self): r = RoleConfig(name="X", provider="p", model="m", tools=["read_file"]) assert isinstance(r.tools[0], str) def test_tools_invalid_type(self): with pytest.raises(Exception): RoleConfig(name="X", provider="p", model="m", tools=42) def test_extra_fields_forbidden(self): with pytest.raises(Exception): RoleConfig(name="X", provider="p", model="m", rogue_field=True) # =========================================================================== # DefaultsConfig # =========================================================================== class TestDefaultsConfig: def test_defaults(self): d = DefaultsConfig() assert d.max_rounds == 8 assert d.language == "ru" assert d.framework == "custom" assert d.workdir == "." def test_custom(self): d = DefaultsConfig(max_rounds=12, language="en", framework="autogen", workdir="/tmp") assert d.max_rounds == 12 def test_extra_ignored(self): # DefaultsConfig uses extra="ignore" — unknown keys are silently dropped d = DefaultsConfig(max_rounds=5, unknown_key="oops") assert d.max_rounds == 5 assert not hasattr(d, "unknown_key") # =========================================================================== # DiscussionConfig — the critical one # =========================================================================== class TestDiscussionConfig: def test_from_yaml_config(self, yaml_config: dict): """DiscussionConfig(**yaml_config) must work without changes.""" cfg = DiscussionConfig(**yaml_config) assert "routerai" in cfg.providers assert "ollama_cloud" in cfg.providers assert cfg.providers["routerai"].base_url == "https://routerai.ru/api/v1" assert "moderator" in cfg.roles assert "skeptic" in cfg.roles assert cfg.roles["moderator"].name == "Moderator" assert cfg.roles["skeptic"].tools == "readonly" assert cfg.defaults.max_rounds == 8 def test_roundtrip_serialization(self, yaml_config: dict): """Model can be built from yaml, exported, and rebuilt.""" cfg = DiscussionConfig(**yaml_config) exported = cfg.model_dump() rebuilt = DiscussionConfig(**exported) assert rebuilt.providers.keys() == cfg.providers.keys() assert rebuilt.roles.keys() == cfg.roles.keys() def test_empty_config(self): cfg = DiscussionConfig() assert cfg.providers == {} assert cfg.roles == {} assert cfg.defaults.max_rounds == 8 def test_extra_keys_ignored(self): # DiscussionConfig uses extra="ignore" cfg = DiscussionConfig( providers={}, roles={}, defaults={}, unknown_top_level="should be dropped", ) assert not hasattr(cfg, "unknown_top_level") def test_nested_provider_is_typed(self, yaml_config: dict): cfg = DiscussionConfig(**yaml_config) prov = cfg.providers["routerai"] assert isinstance(prov, ProviderConfig) def test_nested_role_is_typed(self, yaml_config: dict): cfg = DiscussionConfig(**yaml_config) role = cfg.roles["moderator"] assert isinstance(role, RoleConfig) # =========================================================================== # ScenarioConfig # =========================================================================== class TestScenarioConfig: def test_defaults(self): s = ScenarioConfig() assert s.name == "Untitled" assert s.participants == [] assert s.max_rounds == 8 assert s.problem == "" def test_from_frontmatter(self): data = { "name": "SaaS Pricing", "participants": ["moderator", "skeptic"], "max_rounds": 6, "problem": "How should we price our SaaS?", } s = ScenarioConfig(**data) assert s.name == "SaaS Pricing" assert s.participants == ["moderator", "skeptic"] assert s.max_rounds == 6 def test_extra_ignored(self): s = ScenarioConfig(name="X", surprise="drop me") assert s.name == "X" assert not hasattr(s, "surprise") # =========================================================================== # Message # =========================================================================== class TestMessage: def test_basic(self): m = Message(role_id="moderator", name="Moderator", content="Hello everyone!") assert m.role_id == "moderator" assert m.name == "Moderator" assert m.content == "Hello everyone!" def test_extra_forbidden(self): with pytest.raises(Exception): Message(role_id="mod", name="Mod", content="hi", extra=True) def test_missing_required(self): with pytest.raises(Exception): Message(role_id="mod", name="Mod") # missing content # =========================================================================== # ToolCallRecord # =========================================================================== class TestToolCallRecord: def test_basic(self): t = ToolCallRecord(id="call_abc", name="read_file", arguments='{"path": "/tmp/x"}', result="file contents") assert t.id == "call_abc" assert t.name == "read_file" assert t.result == "file contents" def test_default_result(self): t = ToolCallRecord(id="call_1", name="web_search", arguments='{"q": "test"}') assert t.result == "" def test_extra_forbidden(self): with pytest.raises(Exception): ToolCallRecord(id="1", name="n", arguments="{}", extra="bad") # =========================================================================== # Session # =========================================================================== class TestSession: def test_defaults(self): s = Session() assert s.scenario.name == "Untitled" assert s.messages == [] assert s.tool_calls == [] assert isinstance(s.created_at, datetime) assert isinstance(s.updated_at, datetime) def test_with_messages(self): msg = Message(role_id="moderator", name="Moderator", content="Let's begin.") s = Session(messages=[msg]) assert len(s.messages) == 1 assert s.messages[0].content == "Let's begin." def test_timestamps_auto_set(self): before = datetime.now(timezone.utc) s = Session() after = datetime.now(timezone.utc) assert before <= s.created_at <= after assert before <= s.updated_at <= after def test_explicit_timestamps(self): t = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) s = Session(created_at=t, updated_at=t) assert s.created_at.year == 2025 def test_serialization_roundtrip(self): msg = Message(role_id="mod", name="Mod", content="Hi") tc = ToolCallRecord(id="c1", name="search", arguments='{}', result="ok") s = Session( scenario=ScenarioConfig(name="Test"), messages=[msg], tool_calls=[tc], ) data = s.model_dump() rebuilt = Session(**data) assert rebuilt.messages[0].content == "Hi" assert rebuilt.tool_calls[0].name == "search" assert rebuilt.scenario.name == "Test" def test_extra_ignored(self): s = Session(unknown_field="drop me") assert not hasattr(s, "unknown_field") # =========================================================================== # Edge cases with real config.yaml structure # =========================================================================== class TestConfigYamlCompatibility: """Verify that the models accept exactly what yaml.safe_load produces from the actual config.yaml in the package. """ def test_full_config_yaml_structure(self): """Build DiscussionConfig from the full config.yaml structure (minus env-var resolution) and verify every field.""" raw = yaml.safe_load(SAMPLE_YAML) cfg = DiscussionConfig(**raw) # Providers assert len(cfg.providers) == 2 assert cfg.providers["routerai"].base_url == "https://routerai.ru/api/v1" assert cfg.providers["routerai"].api_key == "sk-test-key" assert cfg.providers["ollama_cloud"].base_url == "https://ollama.com/v1" # Roles assert len(cfg.roles) == 2 mod = cfg.roles["moderator"] assert mod.name == "Moderator" assert mod.provider == "ollama_cloud" assert mod.model == "gpt-4o-mini" assert mod.temperature == 0.7 assert mod.tools == "none" assert "moderator" in mod.system_prompt.lower() or "moderator" in mod.system_prompt sk = cfg.roles["skeptic"] assert sk.name == "Skeptic" assert sk.temperature == 0.9 assert sk.tools == "readonly" # Defaults assert cfg.defaults.max_rounds == 8 assert cfg.defaults.language == "ru" def test_tools_as_list_in_role(self): """Roles can specify tools as a list of tool names.""" cfg = DiscussionConfig( roles={ "analyst": RoleConfig( name="Analyst", provider="ollama_cloud", model="gpt-4o-mini", tools=["read_file", "web_search", "list_directory"], ), }, ) assert cfg.roles["analyst"].tools == ["read_file", "web_search", "list_directory"] def test_minimal_config(self): """Minimal valid config with just one provider and one role.""" cfg = DiscussionConfig( providers={"local": ProviderConfig(base_url="http://localhost:11434/v1", api_key="ollama")}, roles={"bot": RoleConfig(name="Bot", provider="local", model="llama3")}, ) assert cfg.providers["local"].api_key == "ollama" assert cfg.roles["bot"].model == "llama3" assert cfg.defaults.max_rounds == 8 # default