diff --git a/tests/test_mma_tier_usage_reset_fix.py b/tests/test_mma_tier_usage_reset_fix.py new file mode 100644 index 00000000..ace3df4c --- /dev/null +++ b/tests/test_mma_tier_usage_reset_fix.py @@ -0,0 +1,122 @@ +"""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." + )