diff --git a/src/app_controller.py b/src/app_controller.py index 6f4e659..4f699c2 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -418,6 +418,15 @@ class AppController: self._loop_thread = threading.Thread(target=self._run_event_loop, daemon=True) self._loop_thread.start() + def stop_services(self) -> None: + """Stops background threads and cleans up resources.""" + import ai_client + ai_client.cleanup() + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + if self._loop_thread and self._loop_thread.is_alive(): + self._loop_thread.join(timeout=2.0) + def _init_ai_and_hooks(self, app: Any = None) -> None: import api_hooks ai_client.set_provider(self._current_provider, self._current_model) diff --git a/src/gui_2.py b/src/gui_2.py index 1d05df5..b48c0cf 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -33,7 +33,7 @@ from log_pruner import LogPruner import conductor_tech_lead import multi_agent_conductor from models import Track, Ticket, DISC_ROLES, AGENT_TOOL_NAMES, CONFIG_PATH, load_config, parse_history_entries -from app_controller import AppController +from app_controller import AppController, ConfirmDialog, MMAApprovalDialog, MMASpawnApprovalDialog from file_cache import ASTParser from fastapi import FastAPI, Depends, HTTPException diff --git a/tests/conftest.py b/tests/conftest.py index 9d18f27..bb0f02c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -181,8 +181,12 @@ def live_gui() -> Generator[tuple[subprocess.Popen, str], None, None]: # 1. Create a isolated workspace for the live GUI temp_workspace = Path("tests/artifacts/live_gui_workspace") if temp_workspace.exists(): - shutil.rmtree(temp_workspace) - temp_workspace.mkdir(parents=True, exist_ok=True) + for _ in range(5): + try: + shutil.rmtree(temp_workspace) + break + except PermissionError: + time.sleep(0.5) # Create minimal project files to avoid cluttering root # NOTE: Do NOT create config.toml here - we use SLOP_CONFIG env var @@ -276,8 +280,14 @@ def live_gui() -> Generator[tuple[subprocess.Popen, str], None, None]: time.sleep(0.5) except: pass kill_process_tree(process.pid) + time.sleep(1.0) log_file.close() - # Cleanup temp workspace - try: - shutil.rmtree(temp_workspace) - except: pass + # Cleanup temp workspace with retry for Windows file locks + for _ in range(5): + try: + shutil.rmtree(temp_workspace) + break + except PermissionError: + time.sleep(0.5) + except: + break diff --git a/tests/test_mma_approval_indicators.py b/tests/test_mma_approval_indicators.py index 758b45d..ab3f02b 100644 --- a/tests/test_mma_approval_indicators.py +++ b/tests/test_mma_approval_indicators.py @@ -40,15 +40,16 @@ def _make_imgui_mock(): m.begin_table.return_value = False m.begin_child.return_value = False m.checkbox.return_value = (False, False) + m.button.return_value = False m.input_text.side_effect = lambda label, value, *args, **kwargs: (False, value) m.input_text_multiline.side_effect = lambda label, value, *args, **kwargs: (False, value) m.combo.side_effect = lambda label, current_item, items, *args, **kwargs: (False, current_item) m.collapsing_header.return_value = False + m.begin_combo.return_value = False m.ImVec2.return_value = MagicMock() m.ImVec4.return_value = MagicMock() return m - def _collect_text_colored_args(imgui_mock): """Return a single joined string of all text_colored second-arg strings.""" parts = [] diff --git a/tests/test_mma_dashboard_refresh.py b/tests/test_mma_dashboard_refresh.py index ce2eb11..064c10d 100644 --- a/tests/test_mma_dashboard_refresh.py +++ b/tests/test_mma_dashboard_refresh.py @@ -3,60 +3,73 @@ from unittest.mock import patch, MagicMock from typing import Any from gui_2 import App + @pytest.fixture def app_instance() -> Any: -# We patch the dependencies of App.__init__ to avoid side effects - with ( - patch('src.models.load_config', return_value={'ai': {}, 'projects': {}}), - patch('gui_2.save_config'), - patch('gui_2.project_manager') as mock_pm, - patch('gui_2.session_logger'), - patch('gui_2.immapp.run'), - patch('src.app_controller.AppController._load_active_project'), - patch('src.app_controller.AppController._fetch_models'), - patch.object(App, '_load_fonts'), - patch.object(App, '_post_init'), - patch('src.app_controller.AppController._prune_old_logs'), - patch('src.app_controller.AppController.start_services'), - patch('src.app_controller.AppController._init_ai_and_hooks') - ): - app = App() - # Ensure project and ui_files_base_dir are set for _refresh_from_project - app.project = {} - app.ui_files_base_dir = "." - # Return the app and the mock_pm for use in tests - yield app, mock_pm + with ( + patch("src.models.load_config", return_value={"ai": {}, "projects": {}}), + patch("gui_2.save_config"), + patch("gui_2.project_manager"), + patch("app_controller.project_manager") as mock_pm, + patch("gui_2.session_logger"), + patch("gui_2.immapp.run"), + patch("src.app_controller.AppController._load_active_project"), + patch("src.app_controller.AppController._fetch_models"), + patch.object(App, "_load_fonts"), + patch.object(App, "_post_init"), + patch("src.app_controller.AppController._prune_old_logs"), + patch("src.app_controller.AppController.start_services"), + patch("src.app_controller.AppController._init_ai_and_hooks"), + ): + app = App() + app.project = {} + app.ui_files_base_dir = "." + yield app, mock_pm + def test_mma_dashboard_refresh(app_instance: Any) -> None: - app, mock_pm = app_instance - # 1. Define mock tracks - mock_tracks = [ - MagicMock(id="track_1", description="Track 1"), - MagicMock(id="track_2", description="Track 2") - ] - # 2. Patch get_all_tracks to return our mock list - mock_pm.get_all_tracks.return_value = mock_tracks - # 3. Call _refresh_from_project - app._refresh_from_project() - # 4. Verify that app.tracks contains the mock tracks - assert hasattr(app, 'tracks'), "App instance should have a 'tracks' attribute" - assert app.tracks == mock_tracks - assert len(app.tracks) == 2 - assert app.tracks[0].id == "track_1" - assert app.tracks[1].id == "track_2" - # Verify get_all_tracks was called with the correct base_dir - mock_pm.get_all_tracks.assert_called_with(app.ui_files_base_dir) + app, mock_pm = app_instance + mock_tracks = [ + { + "id": "track_1", + "title": "Track 1", + "status": "new", + "complete": 0, + "total": 0, + "progress": 0.0, + }, + { + "id": "track_2", + "title": "Track 2", + "status": "new", + "complete": 0, + "total": 0, + "progress": 0.0, + }, + ] + mock_pm.get_all_tracks.return_value = mock_tracks + app._refresh_from_project() + assert hasattr(app, "tracks"), "App instance should have a 'tracks' attribute" + assert app.tracks == mock_tracks + assert len(app.tracks) == 2 + assert app.tracks[0]["id"] == "track_1" + assert app.tracks[1]["id"] == "track_2" + mock_pm.get_all_tracks.assert_called_with(app.ui_files_base_dir) + def test_mma_dashboard_initialization_refresh(app_instance: Any) -> None: - """ - Checks that _refresh_from_project is called during initialization if - _load_active_project is NOT mocked to skip it (but here it IS mocked in fixture). - This test verifies that calling it manually works as expected for initialization scenarios. - """ - app, mock_pm = app_instance - mock_tracks = [MagicMock(id="init_track", description="Initial Track")] - mock_pm.get_all_tracks.return_value = mock_tracks - # Simulate the refresh that would happen during a project load - app._refresh_from_project() - assert app.tracks == mock_tracks - assert app.tracks[0].id == "init_track" + app, mock_pm = app_instance + mock_tracks = [ + { + "id": "init_track", + "title": "Initial Track", + "status": "new", + "complete": 0, + "total": 0, + "progress": 0.0, + } + ] + mock_pm.get_all_tracks.return_value = mock_tracks + app._refresh_from_project() + assert app.tracks == mock_tracks + assert app.tracks[0]["id"] == "init_track"