""" Regression test suite for config/state hardening (Steps 1-10). Run inside Docker: docker compose run --rm miku-bot python tests/test_config_state.py Each test is an independent function. Tests use a temporary directory for all file I/O so they never touch the real config/memory files. """ import json import os import sys import shutil import tempfile import traceback from pathlib import Path # ── Bootstrap ── sys.path.insert(0, "/app") os.chdir("/app") os.environ.setdefault("DISCORD_BOT_TOKEN", "test_token") # ── Imports (after path setup) ── import globals as g from config import CONFIG from config_manager import ConfigManager from server_manager import ServerManager, ServerConfig # ═══════════════════════════════════════════════════ # Test Runner # ═══════════════════════════════════════════════════ _results: list[tuple[str, bool, str]] = [] # (name, passed, detail) def run_test(func): """Decorator-free runner: call run_test(fn) to execute and record.""" name = func.__name__ try: func() _results.append((name, True, "")) print(f" ✓ {name}") except AssertionError as e: _results.append((name, False, str(e))) print(f" ✗ {name}: {e}") except Exception as e: _results.append((name, False, f"{type(e).__name__}: {e}")) print(f" ✗ {name}: {type(e).__name__}: {e}") traceback.print_exc() def _make_tmp_dir(): """Create a fresh temp directory and return its Path.""" d = tempfile.mkdtemp(prefix="miku_test_") return Path(d) # ═══════════════════════════════════════════════════ # A. Config Loading & Persistence (Steps 1-2) # ═══════════════════════════════════════════════════ def test_runtime_config_persists_in_memory_dir(): """Step 1: config_runtime.yaml lives inside memory/ (volume-mounted).""" from config_manager import config_manager runtime_path = config_manager.runtime_config_path assert "memory" in str(runtime_path), \ f"runtime_config_path should be in memory/: {runtime_path}" assert runtime_path.parent.name == "memory", \ f"Parent dir should be 'memory', got: {runtime_path.parent.name}" def test_config_yaml_loads_into_pydantic(): """Step 2: config.yaml parses cleanly into AppConfig.""" assert CONFIG is not None, "CONFIG is None" assert hasattr(CONFIG, 'discord'), "CONFIG missing 'discord' section" assert hasattr(CONFIG, 'autonomous'), "CONFIG missing 'autonomous' section" assert hasattr(CONFIG, 'voice'), "CONFIG missing 'voice' section" assert hasattr(CONFIG, 'gpu'), "CONFIG missing 'gpu' section" assert hasattr(CONFIG, 'cheshire_cat'), "CONFIG missing 'cheshire_cat' section" assert hasattr(CONFIG, 'services'), "CONFIG missing 'services' section" def test_runtime_overrides_merge(): """Runtime overrides take precedence over static config.""" tmp = _make_tmp_dir() try: # Write a minimal static config static = tmp / "config.yaml" static.write_text("discord:\n language_mode: english\n") # Write runtime override mem = tmp / "memory" mem.mkdir() (mem / "config_runtime.yaml").write_text( "discord:\n language_mode: japanese\n" ) cm = ConfigManager(config_path=str(static)) # Patch memory_dir to our temp location cm.memory_dir = mem cm.runtime_config_path = mem / "config_runtime.yaml" cm.runtime_config = cm._load_runtime_config() val = cm.get("discord.language_mode") assert val == "japanese", f"Expected 'japanese', got {val!r}" finally: shutil.rmtree(tmp) # ═══════════════════════════════════════════════════ # B. Runtime State (Steps 4-6) # ═══════════════════════════════════════════════════ def test_runtime_state_reads_live_globals(): """Step 4: runtime_state property reads current globals, not stale cache.""" from config_manager import config_manager old = g.DM_MOOD try: g.DM_MOOD = "test_sentinel_mood" state = config_manager.runtime_state assert state["dm_mood"] == "test_sentinel_mood", \ f"runtime_state returned {state['dm_mood']!r}, expected 'test_sentinel_mood'" finally: g.DM_MOOD = old def test_config_set_syncs_all_simple_globals(): """Step 6: the _GLOBALS_SYNC map covers all 5 simple settings.""" _GLOBALS_SYNC = { "discord.language_mode": ("LANGUAGE_MODE", str), "autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool), "voice.debug_mode": ("VOICE_DEBUG_MODE", bool), "memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool), "gpu.prefer_amd": ("PREFER_AMD_GPU", bool), } # Save originals (use getattr with default since some may be dynamically created) originals = {attr: getattr(g, attr, None) for _, (attr, _) in _GLOBALS_SYNC.items()} try: # Set each to a non-default value test_values = { "discord.language_mode": "japanese", "autonomous.debug_mode": True, "voice.debug_mode": True, "memory.use_cheshire_cat": True, "gpu.prefer_amd": True, } for key_path, value in test_values.items(): attr, converter = _GLOBALS_SYNC[key_path] setattr(g, attr, converter(value)) for key_path, value in test_values.items(): attr, _ = _GLOBALS_SYNC[key_path] actual = getattr(g, attr) assert actual == value, f"globals.{attr}: expected {value!r}, got {actual!r}" finally: # Restore (delete attr if it didn't exist before) for attr, orig in originals.items(): if orig is None and not hasattr(g, attr): continue elif orig is None: delattr(g, attr) else: setattr(g, attr, orig) def test_config_set_syncs_dm_mood(): """Step 6: DM mood sync updates both DM_MOOD and DM_MOOD_DESCRIPTION.""" old_mood = g.DM_MOOD old_desc = g.DM_MOOD_DESCRIPTION try: if g.AVAILABLE_MOODS: test_mood = g.AVAILABLE_MOODS[0] g.DM_MOOD = test_mood g.DM_MOOD_DESCRIPTION = f"I'm feeling {test_mood} today." assert g.DM_MOOD == test_mood, f"DM_MOOD: expected {test_mood!r}" assert test_mood in g.DM_MOOD_DESCRIPTION, \ f"DM_MOOD_DESCRIPTION should contain {test_mood!r}" else: # No available moods to test with; just verify the list exists assert isinstance(g.AVAILABLE_MOODS, list), "AVAILABLE_MOODS should be a list" finally: g.DM_MOOD = old_mood g.DM_MOOD_DESCRIPTION = old_desc def test_restore_runtime_settings(): """Step 4: restore_runtime_settings() pushes persisted values into globals.""" tmp = _make_tmp_dir() try: mem = tmp / "memory" mem.mkdir() # Write a runtime config with overrides (mem / "config_runtime.yaml").write_text( "discord:\n language_mode: japanese\n" "autonomous:\n debug_mode: true\n" ) static = tmp / "config.yaml" static.write_text("discord:\n language_mode: english\n") cm = ConfigManager(config_path=str(static)) cm.memory_dir = mem cm.runtime_config_path = mem / "config_runtime.yaml" cm.runtime_config = cm._load_runtime_config() # Save originals old_lang = g.LANGUAGE_MODE old_debug = g.AUTONOMOUS_DEBUG try: g.LANGUAGE_MODE = "english" g.AUTONOMOUS_DEBUG = False cm.restore_runtime_settings() assert g.LANGUAGE_MODE == "japanese", \ f"Expected 'japanese', got {g.LANGUAGE_MODE!r}" assert g.AUTONOMOUS_DEBUG is True, \ f"Expected True, got {g.AUTONOMOUS_DEBUG!r}" finally: g.LANGUAGE_MODE = old_lang g.AUTONOMOUS_DEBUG = old_debug finally: shutil.rmtree(tmp) # ═══════════════════════════════════════════════════ # C. Reset (Step 5) # ═══════════════════════════════════════════════════ def test_reset_to_defaults_resets_all_globals(): """Step 5: full reset restores all globals to CONFIG defaults.""" from config_manager import config_manager # Save originals (use getattr with default since PREFER_AMD_GPU may be dynamically created) attrs_to_save = ["LANGUAGE_MODE", "AUTONOMOUS_DEBUG", "VOICE_DEBUG_MODE", "USE_CHESHIRE_CAT", "PREFER_AMD_GPU", "DM_MOOD"] saved = {attr: getattr(g, attr, None) for attr in attrs_to_save} try: # Mutate globals away from defaults g.LANGUAGE_MODE = "japanese" g.AUTONOMOUS_DEBUG = True g.VOICE_DEBUG_MODE = True g.USE_CHESHIRE_CAT = True g.PREFER_AMD_GPU = True g.DM_MOOD = "chaotic_test_mood" config_manager.reset_to_defaults() assert g.LANGUAGE_MODE == CONFIG.discord.language_mode, \ f"LANGUAGE_MODE: {g.LANGUAGE_MODE!r}" assert g.AUTONOMOUS_DEBUG == CONFIG.autonomous.debug_mode, \ f"AUTONOMOUS_DEBUG: {g.AUTONOMOUS_DEBUG!r}" assert g.VOICE_DEBUG_MODE == CONFIG.voice.debug_mode, \ f"VOICE_DEBUG_MODE: {g.VOICE_DEBUG_MODE!r}" assert g.USE_CHESHIRE_CAT == CONFIG.cheshire_cat.enabled, \ f"USE_CHESHIRE_CAT: {g.USE_CHESHIRE_CAT!r}" assert getattr(g, 'PREFER_AMD_GPU', CONFIG.gpu.prefer_amd) == CONFIG.gpu.prefer_amd, \ f"PREFER_AMD_GPU: {getattr(g, 'PREFER_AMD_GPU', None)!r}" assert g.DM_MOOD == "neutral", f"DM_MOOD: {g.DM_MOOD!r}" finally: for attr, val in saved.items(): if val is None and hasattr(g, attr): delattr(g, attr) elif val is not None: setattr(g, attr, val) def test_reset_single_key(): """Step 5: single-key reset only affects that one global.""" from config_manager import config_manager old_lang = g.LANGUAGE_MODE old_debug = g.AUTONOMOUS_DEBUG try: g.LANGUAGE_MODE = "japanese" g.AUTONOMOUS_DEBUG = True config_manager.reset_to_defaults("discord.language_mode") assert g.LANGUAGE_MODE == CONFIG.discord.language_mode, \ f"LANGUAGE_MODE should be default, got {g.LANGUAGE_MODE!r}" # Other globals should NOT have been reset assert g.AUTONOMOUS_DEBUG is True, \ "AUTONOMOUS_DEBUG should still be True (not reset)" finally: g.LANGUAGE_MODE = old_lang g.AUTONOMOUS_DEBUG = old_debug # ═══════════════════════════════════════════════════ # D. Server Manager (Steps 7-9) # ═══════════════════════════════════════════════════ def test_missing_config_gives_zero_servers(): """Step 9: no servers_config.json → empty servers dict.""" sm = ServerManager(config_file="/tmp/_nonexistent_miku_test_.json") assert len(sm.servers) == 0, f"Expected 0 servers, got {len(sm.servers)}" def test_corrupt_config_gives_zero_servers(): """Step 9: corrupt JSON → zero servers (no hardcoded default).""" path = "/tmp/_corrupt_miku_test_.json" try: with open(path, "w") as f: f.write("{{{invalid json!") sm = ServerManager(config_file=path) assert len(sm.servers) == 0, f"Expected 0 servers, got {len(sm.servers)}" finally: os.remove(path) def test_valid_config_loads(): """Step 9: valid JSON loads servers correctly.""" path = "/tmp/_valid_miku_test_.json" try: data = { "12345": { "guild_id": 12345, "guild_name": "Test Guild", "autonomous_channel_id": 67890, "autonomous_channel_name": "test-chat", "bedtime_channel_ids": [67890], "enabled_features": ["autonomous"], } } with open(path, "w") as f: json.dump(data, f) sm = ServerManager(config_file=path) assert len(sm.servers) == 1, f"Expected 1 server, got {len(sm.servers)}" assert 12345 in sm.servers assert sm.servers[12345].guild_name == "Test Guild" finally: if os.path.exists(path): os.remove(path) def test_add_remove_server_roundtrip(): """Steps 8-9: add/remove on empty state works cleanly.""" sm = ServerManager(config_file="/tmp/_roundtrip_miku_test_.json") try: assert len(sm.servers) == 0 ok = sm.add_server(99999, "Roundtrip Guild", 11111, "rt-chat") assert ok is True, "add_server should return True" assert 99999 in sm.servers assert sm.servers[99999].guild_name == "Roundtrip Guild" ok = sm.remove_server(99999) assert ok is True, "remove_server should return True" assert 99999 not in sm.servers finally: if os.path.exists("/tmp/_roundtrip_miku_test_.json"): os.remove("/tmp/_roundtrip_miku_test_.json") def test_no_server_memories_attribute(): """Step 8: server_memories dict and methods were removed.""" assert not hasattr(ServerManager, 'get_server_memory'), \ "get_server_memory method still exists" assert not hasattr(ServerManager, 'set_server_memory'), \ "set_server_memory method still exists" sm = ServerManager(config_file="/tmp/_nomem_miku_test_.json") assert not hasattr(sm, 'server_memories'), \ "server_memories attribute still exists on instance" def test_no_create_default_config(): """Step 9: _create_default_config was removed.""" assert not hasattr(ServerManager, '_create_default_config'), \ "_create_default_config method still exists" # ═══════════════════════════════════════════════════ # E. GPU Deduplication (Step 10) # ═══════════════════════════════════════════════════ def test_gpu_url_helper_delegates(): """Step 10: get_current_gpu_url() uses config_manager, not direct file read.""" import inspect from routes.chat import get_current_gpu_url src = inspect.getsource(get_current_gpu_url) assert "gpu_state.json" not in src, \ "get_current_gpu_url still reads gpu_state.json directly" # After Phase B split, chat.get_current_gpu_url reads globals.PREFER_AMD_GPU assert "PREFER_AMD_GPU" in src or "config_manager" in src, \ "get_current_gpu_url should use globals.PREFER_AMD_GPU or config_manager" def test_gpu_status_endpoint_delegates(): """Step 10: /gpu-status endpoint uses config_manager, not direct file read.""" import inspect from routes.gpu import get_gpu_status src = inspect.getsource(get_gpu_status) assert "gpu_state.json" not in src, \ "get_gpu_status still reads gpu_state.json directly" assert "config_manager" in src, \ "get_gpu_status should delegate to config_manager" def test_gpu_url_returns_correct_url(): """Step 10: URL switches correctly between nvidia/amd.""" from routes.chat import get_current_gpu_url old_val = g.PREFER_AMD_GPU try: g.PREFER_AMD_GPU = False assert get_current_gpu_url() == g.LLAMA_URL g.PREFER_AMD_GPU = True assert get_current_gpu_url() == g.LLAMA_AMD_URL finally: g.PREFER_AMD_GPU = old_val # ═══════════════════════════════════════════════════ # F. Clean Imports (Steps 3, 10) # ═══════════════════════════════════════════════════ def test_config_py_no_os_import(): """Step 10: config.py does not import os (unused).""" src = Path("/app/config.py").read_text() # Check for standalone 'import os' line (not in comments) for line in src.splitlines(): stripped = line.strip() if stripped == "import os" or stripped.startswith("import os "): assert False, f"config.py still has: {stripped}" def test_config_manager_no_dead_imports(): """Step 10: config_manager.py has no unused 'os' or 'Union' imports.""" src = Path("/app/config_manager.py").read_text() for line in src.splitlines(): stripped = line.strip() if stripped == "import os" or stripped.startswith("import os "): assert False, f"config_manager.py still has: {stripped}" assert "Union" not in src, "config_manager.py still imports Union" def test_globals_no_guild_settings(): """Step 10: globals.py has no GUILD_SETTINGS.""" assert not hasattr(g, 'GUILD_SETTINGS'), \ "GUILD_SETTINGS still exists in globals" # ═══════════════════════════════════════════════════ # Main # ═══════════════════════════════════════════════════ if __name__ == "__main__": print("\n══════════════════════════════════════════") print(" Config / State Regression Tests") print("══════════════════════════════════════════\n") tests = [ # A. Config Loading & Persistence test_runtime_config_persists_in_memory_dir, test_config_yaml_loads_into_pydantic, test_runtime_overrides_merge, # B. Runtime State test_runtime_state_reads_live_globals, test_config_set_syncs_all_simple_globals, test_config_set_syncs_dm_mood, test_restore_runtime_settings, # C. Reset test_reset_to_defaults_resets_all_globals, test_reset_single_key, # D. Server Manager test_missing_config_gives_zero_servers, test_corrupt_config_gives_zero_servers, test_valid_config_loads, test_add_remove_server_roundtrip, test_no_server_memories_attribute, test_no_create_default_config, # E. GPU Deduplication test_gpu_url_helper_delegates, test_gpu_status_endpoint_delegates, test_gpu_url_returns_correct_url, # F. Clean Imports test_config_py_no_os_import, test_config_manager_no_dead_imports, test_globals_no_guild_settings, ] for t in tests: run_test(t) # Summary passed = sum(1 for _, ok, _ in _results if ok) failed = sum(1 for _, ok, _ in _results if not ok) total = len(_results) print(f"\n──────────────────────────────────────────") print(f" {passed}/{total} passed, {failed} failed") print(f"──────────────────────────────────────────\n") if failed: print("FAILED tests:") for name, ok, detail in _results: if not ok: print(f" ✗ {name}: {detail}") sys.exit(1) else: print("ALL PASSED ✓") sys.exit(0)