"""Regression tests for 3 pre-existing bugs in AppController. Bug 1: _handle_reset_session zeroes mma_tier_usage to empty dicts; the downstream _flush_to_project crashes with KeyError: 'model'. (Commits fe240db4 introduced.) Bug 2: __init__ does not set self.context_preset_manager; save_context_preset and load_context_preset crash. (Lost in 72f8f466.) Bug 3: __getattr__ returns None for 'persona_manager', making hasattr() return True (the accompanying comment claims False, which is wrong). The integration symptom of Bug 1 was test_context_sim_live polling ai_status for 60s and seeing the constant 'switching to: temp_livecontextsim (stale ui - ops disabled)' string (older runs) or 'error: \\'model\\'' (newer runs after sim_context.py added an 'error in s' early-break check). These tests exercise the exact code paths that were crashing, in isolation, to prove the fixes prevent the original failures. The tests do NOT require the live_gui fixture. They use a real AppController() with a tmp_path for the project file, matching the pattern in tests/test_handle_reset_session_clears_project.py. """ import pytest import tomllib from pathlib import Path from src.app_controller import AppController @pytest.fixture def controller(tmp_path: Path) -> AppController: """Build a real AppController with a writable project file.""" proj_path = tmp_path / "test_project.toml" proj_path.write_text("[project]\nname = 'TestProject'\n") ctrl = AppController() ctrl.active_project_path = str(proj_path) # _flush_to_project reads several UI flags that __init__ does not set # (ui_project_preset_name, ui_word_wrap, ui_gemini_cli_path, # ui_auto_add_history). Set them so the test exercises the # mma_tier_usage code path without tripping on unrelated missing attrs. ctrl.ui_project_preset_name = None ctrl.ui_word_wrap = True ctrl.ui_gemini_cli_path = "" ctrl.ui_auto_add_history = False yield ctrl def test_reset_session_makes_flush_to_project_not_crash(controller: AppController) -> None: """Bug 1 fix: After _handle_reset_session, _flush_to_project must not raise KeyError. Pre-fix: the reset zeroes mma_tier_usage to empty dicts; _flush_to_project crashes on d['model']. Post-fix: the reset pre-populates the dicts (matching __init__ defaults), and _flush_to_project uses d.get('model') as a defensive fallback. This test asserts the round-trip works. """ for tier in ("Tier 1", "Tier 2", "Tier 3", "Tier 4"): assert "model" in controller.mma_tier_usage[tier], ( f"precondition failed: tier {tier} has no 'model' key in __init__" ) controller._handle_reset_session() for tier in ("Tier 1", "Tier 2", "Tier 3", "Tier 4"): assert "model" in controller.mma_tier_usage[tier], ( f"_handle_reset_session stripped 'model' from {tier}: " f"{controller.mma_tier_usage[tier]!r}" ) assert "provider" in controller.mma_tier_usage[tier], ( f"_handle_reset_session stripped 'provider' from {tier}: " f"{controller.mma_tier_usage[tier]!r}" ) controller._flush_to_project() assert Path(controller.active_project_path).exists() def test_flush_to_project_is_defensive_against_partial_tier_dict(controller: AppController) -> None: """Bug 1 fix (defense in depth): _flush_to_project must not raise KeyError on partial dicts. This is the defense-in-depth test for the d.get('model') change. Simulates a code path (like _handle_mma_state_update at line 484-497) that replaces the entire mma_tier_usage[tier] entry with a partial dict. """ controller.mma_tier_usage["Tier 3"] = {"input": 0, "output": 0, "provider": "gemini"} controller._flush_to_project() with open(controller.active_project_path, "rb") as f: saved = tomllib.load(f) tier_models = saved.get("mma", {}).get("tier_models", {}) assert "Tier 3" in tier_models, f"Tier 3 missing from saved tier_models: {tier_models!r}" assert tier_models["Tier 3"].get("model") in (None, ""), ( f"Expected None or empty model for the partial-dict case, got " f"{tier_models['Tier 3'].get('model')!r}" ) def test_context_preset_manager_is_initialized(controller: AppController) -> None: """Bug 2 fix: self.context_preset_manager must be a ContextPresetManager, not None. Pre-fix: __init__ did not set self.context_preset_manager; save_context_preset and load_context_preset both crashed with AttributeError. Post-fix: __init__ sets it to ContextPresetManager() (the line was lost in 72f8f466 and re-added). """ assert controller.context_preset_manager is not None, ( f"context_preset_manager is None; the __init__ line is missing" ) from src.context_presets import ContextPresetManager assert isinstance(controller.context_preset_manager, ContextPresetManager), ( f"context_preset_manager is {type(controller.context_preset_manager).__name__}, " f"expected ContextPresetManager" ) def test_hasattr_persona_manager_returns_false_for_fresh_controller() -> None: """Bug 3 fix: hasattr(ctrl, 'persona_manager') must be False for a fresh AppController. Pre-fix: __getattr__ returned None for 'persona_manager' (in _LAZY_MANAGER_DEFAULTS), making hasattr() return True. The comment claimed hasattr() returns False but that's wrong. Post-fix: 'persona_manager' is removed from _LAZY_MANAGER_DEFAULTS, so __getattr__ raises AttributeError, so hasattr() returns False. """ ctrl = AppController() assert not hasattr(ctrl, "persona_manager"), ( f"hasattr(ctrl, 'persona_manager') returned True for a fresh AppController. " f"__getattr__ likely still returns None for it. Check _LAZY_MANAGER_DEFAULTS " f"in src/app_controller.py." )