import sys from pathlib import Path from unittest.mock import patch, MagicMock import pytest # Mock tkinter and other GUI/macOS specific imports sys.modules["tkinter"] = MagicMock() sys.modules["tkinter.ttk"] = MagicMock() sys.modules["tkinter.scrolledtext"] = MagicMock() sys.modules["tkinter.messagebox"] = MagicMock() sys.modules["tkinter.filedialog"] = MagicMock() sys.modules["Foundation"] = MagicMock() sys.modules["AppKit"] = MagicMock() sys.modules["PIL"] = MagicMock() sys.modules["PIL.Image"] = MagicMock() sys.modules["PIL.ImageTk"] = MagicMock() from install import KnoeInstaller @pytest.fixture def mock_installer(tmp_path): test_cfg_src = Path(__file__).parent / "fixtures" / "test-knoe.cfg" cfg_path = tmp_path / "knoe.cfg" cfg_path.write_text(test_cfg_src.read_text()) with patch("install.tk.Tk"), patch("install.tk.Frame"), patch( "install.tk.Canvas" ), patch("install.tk.Label"), patch("install.tk.Button"), patch( "install.tk.Entry" ) as mock_entry, patch( "install.ttk.Style" ), patch( "install.Path.exists", return_value=True ), patch( "install.Path.mkdir" ), patch.object( KnoeInstaller, "_save_env_to_file" ), patch.object( KnoeInstaller, "_save_knoe_cfg" ), patch.object( KnoeInstaller, "_save_ansible_knoe_vault" ), patch.object( KnoeInstaller, "_after_env_saved" ), patch( "threading.Thread" ): mock_entry.return_value.get.return_value.strip.return_value = "mock_val" root = MagicMock() # Mock winfo methods needed for centering root.winfo_screenwidth.return_value = 1920 root.winfo_screenheight.return_value = 1080 app = KnoeInstaller(root, config_path=str(cfg_path)) return app def test_initial_page(mock_installer): # Should start at index 0 (welcome) assert mock_installer.page_index == 0 assert mock_installer.pages[mock_installer.page_index][0] == "welcome" def test_show_page_by_id(mock_installer): mock_installer.show_page("network_scan") assert mock_installer.pages[mock_installer.page_index][0] == "network_scan" def test_show_page_invalid_id(mock_installer): # Current page is welcome (0) mock_installer.page_index = 0 with patch("install.messagebox.showerror") as mock_error: mock_installer.show_page("non_existent_page") # Should stay at current index and NOT fallback to 0 if it was elsewhere, # or just stay at 0 if it was at 0. # The key fix was NOT defaulting to 0 when index_or_id is a string and not found. assert mock_installer.page_index == 0 mock_error.assert_called_once() def test_navigation_flow_standard(mock_installer): # Welcome -> Dependencies mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "deps_summary" # Mock all dependencies installed to skip dep pages with patch.object(mock_installer, "all_dependencies_installed", return_value=True): mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "network_scan" mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "env_setup" mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "init_cluster" with patch.object( mock_installer, "_cluster_ready_for_navigation", return_value=True ), patch.object( mock_installer, "_should_show_cluster_nodes_screen", return_value=False ): mock_installer._common_services_success = True mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "common_services" mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "database_options" with patch.object( mock_installer, "_generate_knoe_db_dockerfile", return_value=True ): mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "init_db_build" mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "init_password" def test_ollama_screen_removed_and_supabase_flows_to_deploy(mock_installer): # Ensure the standalone Ollama screen is no longer part of the GUI page set/nav. page_ids = [pid for pid, _ in mock_installer.pages] assert "ollama_config" not in page_ids assert all(pid != "ollama_config" for _, pid in mock_installer.nav_items) # Ensure Next from Supabase goes directly to Deployment. mock_installer.knoe_cfg_data.setdefault("Optional Features", {}) supabase_idx = next(i for i, (pid, _) in enumerate(mock_installer.pages) if pid == "supabase_config") mock_installer.page_index = supabase_idx mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "init_cnpg_deploy" def test_navigation_flow_missing_deps(mock_installer): # Welcome -> Dependencies mock_installer.show_page("welcome") mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "deps_summary" # Mock one dependency missing mock_dep_id = mock_installer.dependencies[0]["id"] with patch.object( mock_installer, "all_dependencies_installed", return_value=False ), patch.object( mock_installer, "_dep_navigation_sequence", return_value=[f"dep_{mock_dep_id}"] ): mock_installer.on_next() assert ( mock_installer.pages[mock_installer.page_index][0] == f"dep_{mock_dep_id}" ) # Next from dep page with all installed now with patch.object( mock_installer, "all_dependencies_installed", return_value=True ): mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "network_scan" def test_on_prev(mock_installer): # init_password -> init_db_build -> database_options mock_installer.show_page("init_password") mock_installer.on_prev() assert mock_installer.pages[mock_installer.page_index][0] == "init_db_build" mock_installer.on_prev() assert mock_installer.pages[mock_installer.page_index][0] == "database_options" # database_options -> common_services -> init_cluster mock_installer.on_prev() assert mock_installer.pages[mock_installer.page_index][0] == "common_services" with patch.object(mock_installer, "_should_show_cluster_nodes_screen", return_value=False): mock_installer.on_prev() assert mock_installer.pages[mock_installer.page_index][0] == "init_cluster" # network_scan -> deps_summary mock_installer.show_page("network_scan") with patch.object(mock_installer, "all_dependencies_installed", return_value=True): mock_installer.on_prev() assert mock_installer.pages[mock_installer.page_index][0] == "deps_summary" # deps_summary -> welcome mock_installer.on_prev() assert mock_installer.pages[mock_installer.page_index][0] == "welcome" def test_infinite_loop_prevention(mock_installer): # This specifically tests the scenario that caused the bug: # trying to navigate to a missing ID should NOT reset to welcome (0) mock_installer.show_page("network_scan") current_idx = mock_installer.page_index mock_installer.show_page("invalid_id") assert mock_installer.page_index == current_idx assert mock_installer.pages[mock_installer.page_index][0] == "network_scan" def test_on_next_finish_loop(mock_installer): # From last page, on_next should not crash or loop last_idx = len(mock_installer.pages) - 1 mock_installer.show_page(last_idx) mock_installer.on_next() assert mock_installer.page_index == last_idx def test_on_prev_first_page(mock_installer): # From first page, on_prev should not crash or loop mock_installer.show_page(0) mock_installer.on_prev() assert mock_installer.page_index == 0 def test_navigation_to_create_installer(mock_installer): # From build page, if successful, next should go to create_installer mock_installer.show_page("build") mock_installer._built_success = True mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == "create_installer" # Last page Finish label mock_installer.next_button = MagicMock() mock_installer.prev_button = MagicMock() mock_installer.update_footer() mock_installer.next_button.configure.assert_any_call(text="Finish") def test_all_pages_render(mock_installer): with patch.object( mock_installer, "_verify_k3s_services", return_value=None ), patch.object( mock_installer, "check_cluster_status_async", return_value=None ), patch.object( mock_installer, "_ensure_db_build_registry_async", return_value=None ), patch.object( mock_installer, "_get_kubectx_list", return_value=["default"] ), patch.object( mock_installer, "_apply_k3s_defaults", return_value=None ): for page_id, _ in mock_installer.pages: mock_installer.show_page(page_id) # Exercise init_cluster conditional branches for service/prod modes if hasattr(mock_installer.cluster_env, "get") and hasattr( mock_installer.cluster_env.get, "return_value" ): mock_installer.cluster_env.get.return_value = "knoe-service-cluster" else: mock_installer.cluster_env.set("knoe-service-cluster") with patch.object(mock_installer, "_cluster_env_key", return_value="service"): mock_installer.show_page("init_cluster") if hasattr(mock_installer.cluster_env, "get") and hasattr( mock_installer.cluster_env.get, "return_value" ): mock_installer.cluster_env.get.return_value = "knoe-prod-cluster" else: mock_installer.cluster_env.set("knoe-prod-cluster") with patch.object(mock_installer, "_cluster_env_key", return_value="prod"): mock_installer.show_page("init_cluster")