import asyncio import unittest.mock from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from browser_utils.page_controller_modules.thinking import ( ThinkingCategory, ThinkingController, ) @pytest.fixture def mock_controller(mock_page): logger = MagicMock() controller = ThinkingController(mock_page, logger, "req_123") controller.params_cache = {} controller.cache_lock = asyncio.Lock() return controller # --- Helper Logic Tests --- def test_get_thinking_category(mock_controller): """Test model category detection via _get_thinking_category.""" # THINKING_LEVEL_FLASH: gemini-3-flash models (4-level thinking) assert ( mock_controller._get_thinking_category("gemini-3-flash-preview") == ThinkingCategory.THINKING_LEVEL_FLASH ) assert ( mock_controller._get_thinking_category("gemini-3-flash") == ThinkingCategory.THINKING_LEVEL_FLASH ) # THINKING_LEVEL: gemini-3-pro models (2-level thinking) assert ( mock_controller._get_thinking_category("gemini-3-pro") == ThinkingCategory.THINKING_LEVEL ) assert ( mock_controller._get_thinking_category("gemini-3-pro-preview") == ThinkingCategory.THINKING_LEVEL ) # THINKING_PRO: gemini-2.5-pro models assert ( mock_controller._get_thinking_category("gemini-2.5-pro") == ThinkingCategory.THINKING_PRO ) assert ( mock_controller._get_thinking_category("gemini-2.5-pro-preview") == ThinkingCategory.THINKING_PRO ) # THINKING_FLASH: gemini-2.5-flash models (including lite) assert ( mock_controller._get_thinking_category("gemini-2.5-flash") == ThinkingCategory.THINKING_FLASH ) assert ( mock_controller._get_thinking_category("gemini-2.5-flash-lite") == ThinkingCategory.THINKING_FLASH ) # NON_THINKING: gemini-2.0-*, gemini-1.5-*, None assert ( mock_controller._get_thinking_category("gemini-2.0-flash") == ThinkingCategory.NON_THINKING ) assert ( mock_controller._get_thinking_category("gemini-2.0-flash-lite") == ThinkingCategory.NON_THINKING ) assert ( mock_controller._get_thinking_category("gemini-1.5-pro") == ThinkingCategory.NON_THINKING ) assert mock_controller._get_thinking_category(None) == ThinkingCategory.NON_THINKING @pytest.mark.asyncio async def test_has_thinking_dropdown(mock_controller, mock_page): # Case 1: Exists and visible mock_locator = MagicMock() mock_locator.count = AsyncMock(return_value=1) mock_locator.first = mock_locator mock_page.locator.return_value = mock_locator mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): assert await mock_controller._has_thinking_dropdown() is True # Case 2: Does not exist mock_page.locator.return_value.count = AsyncMock(return_value=0) assert await mock_controller._has_thinking_dropdown() is False # Case 3: Exception during check (e.g. timeout on visibility, returns True as fallback logic says "return True" on exception inside inner try) mock_page.locator.return_value.count = AsyncMock(return_value=1) with patch( "browser_utils.page_controller_modules.thinking.expect_async", side_effect=Exception("Timeout"), ): # The code catches exception in inner try and returns True? # Lines 170-174: try expect... except: return True. assert await mock_controller._has_thinking_dropdown() is True # Case 4: Exception during locator creation (outer try) mock_page.locator.side_effect = Exception("Fatal") assert await mock_controller._has_thinking_dropdown() is False # --- _handle_thinking_budget Logic Tests --- @pytest.mark.asyncio async def test_handle_thinking_budget_disabled(mock_controller): # Mock helpers - THINKING_FLASH category mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() # reasoning_effort=0 -> disabled await mock_controller._handle_thinking_budget( {"reasoning_effort": 0}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) mock_controller._control_thinking_mode_toggle.assert_called_with( should_be_enabled=False, check_client_disconnected=unittest.mock.ANY ) mock_controller._control_thinking_budget_toggle.assert_not_called() # Flash model behavior (toggle hidden) @pytest.mark.asyncio async def test_handle_thinking_budget_disabled_non_flash(mock_controller): # Non-flash model (gemini-2.5-pro), disable thinking - but toggle is always on mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_PRO ) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() await mock_controller._handle_thinking_budget( {"reasoning_effort": 0}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-2.5-pro", MagicMock(return_value=False), ) # THINKING_PRO has no main toggle (always on), so budget toggle is called mock_controller._control_thinking_mode_toggle.assert_not_called() mock_controller._control_thinking_budget_toggle.assert_called_with( should_be_checked=False, check_client_disconnected=unittest.mock.ANY ) @pytest.mark.asyncio async def test_handle_thinking_budget_enabled_level(mock_controller): # Gemini 3 Pro with level mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._set_thinking_level = AsyncMock() # High await mock_controller._handle_thinking_budget( {"reasoning_effort": "high"}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) mock_controller._set_thinking_level.assert_called_with("high", unittest.mock.ANY) # Low await mock_controller._handle_thinking_budget( {"reasoning_effort": "low"}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) mock_controller._set_thinking_level.assert_called_with("low", unittest.mock.ANY) # Int >= 8000 -> High await mock_controller._handle_thinking_budget( {"reasoning_effort": 8000}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) mock_controller._set_thinking_level.assert_called_with("high", unittest.mock.ANY) # Int < 8000 -> Low await mock_controller._handle_thinking_budget( {"reasoning_effort": 100}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) mock_controller._set_thinking_level.assert_called_with("low", unittest.mock.ANY) # Invalid -> Keep current (None) mock_controller._set_thinking_level.reset_mock() await mock_controller._handle_thinking_budget( {"reasoning_effort": "invalid"}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) mock_controller._set_thinking_level.assert_not_called() @pytest.mark.asyncio async def test_handle_thinking_budget_flash_4_levels(mock_controller): """Test Gemini 3 Flash 4-level thinking (minimal, low, medium, high). This tests the THINKING_LEVEL_FLASH category which maps reasoning_effort to 4 distinct levels based on thresholds: - high: >= 16000 or -1 (unlimited) - medium: >= 8000 - low: >= 1024 - minimal: < 1024 """ mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._set_thinking_level = AsyncMock() # Test string levels (direct mapping) test_cases = [ ("minimal", "minimal"), ("low", "low"), ("medium", "medium"), ("high", "high"), ("MINIMAL", "minimal"), # Case insensitive ("HIGH", "high"), ] for input_level, expected_level in test_cases: mock_controller._set_thinking_level.reset_mock() await mock_controller._handle_thinking_budget( {"reasoning_effort": input_level}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-flash-preview", MagicMock(return_value=False), ) ( mock_controller._set_thinking_level.assert_called_with( expected_level, unittest.mock.ANY ), f"Failed for input '{input_level}': expected '{expected_level}'", ) @pytest.mark.asyncio async def test_handle_thinking_budget_flash_numeric_thresholds(mock_controller): """Test Gemini 3 Flash numeric threshold mapping. Thresholds: - >= 16000: high - >= 8000: medium - >= 1024: low - < 1024: minimal """ mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._set_thinking_level = AsyncMock() # Test numeric thresholds (boundary cases) test_cases = [ (16000, "high"), # Exactly at high threshold (20000, "high"), # Above high threshold (-1, "high"), # Unlimited (8000, "medium"), # Exactly at medium threshold (15999, "medium"), # Just below high (1024, "low"), # Exactly at low threshold (7999, "low"), # Just below medium (1023, "minimal"), # Just below low (1, "minimal"), # Minimum positive value (0 disables thinking) (500, "minimal"), # Well below low ] for input_value, expected_level in test_cases: mock_controller._set_thinking_level.reset_mock() await mock_controller._handle_thinking_budget( {"reasoning_effort": input_value}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-flash-preview", MagicMock(return_value=False), ) ( mock_controller._set_thinking_level.assert_called_with( expected_level, unittest.mock.ANY ), f"Failed for input {input_value}: expected '{expected_level}'", ) @pytest.mark.asyncio async def test_handle_thinking_budget_enabled_budget_caps(mock_controller): # Flash models with budget caps mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() mock_controller._set_thinking_budget_value = AsyncMock() # Flash Lite (cap 32k or 24k? Code says 24576 for flash-lite) await mock_controller._handle_thinking_budget( {"reasoning_effort": 100000}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-2.0-flash-lite", MagicMock(return_value=False), ) mock_controller._set_thinking_budget_value.assert_called_with( 24576, unittest.mock.ANY ) @pytest.mark.asyncio async def test_handle_thinking_budget_no_limit(mock_controller): # Budget enabled but set to 0/None -> disable manual budget mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() # normalize_reasoning_effort_with_stream_check returns budget_enabled=False for -1 or 'none' if configured so? # Actually normalize_reasoning_effort_with_stream_check behavior: 'none' -> thinking_enabled=True, budget_enabled=False # Let's rely on _handle_thinking_budget logic for "budget_enabled" # If reasoning_effort is None -> thinking disabled by default if normalize returns disabled # If reasoning_effort is "none" (string) -> Thinking enabled (default), Budget disabled (unlimited) await mock_controller._handle_thinking_budget( {"reasoning_effort": "none"}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) mock_controller._control_thinking_mode_toggle.assert_called_with( should_be_enabled=True, check_client_disconnected=unittest.mock.ANY ) mock_controller._control_thinking_budget_toggle.assert_called_with( should_be_checked=False, check_client_disconnected=unittest.mock.ANY ) # --- Interaction Methods Tests --- @pytest.mark.asyncio async def test_set_thinking_level(mock_controller, mock_page): trigger = AsyncMock() option = AsyncMock() mock_page.locator.side_effect = [ trigger, option, AsyncMock(), ] # trigger, option, listbox check mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() mock_expect.return_value.to_be_hidden = AsyncMock() trigger.locator.return_value.inner_text = AsyncMock(return_value="High") with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() await mock_controller._set_thinking_level("High", MagicMock(return_value=False)) trigger.click.assert_called() option.click.assert_called() @pytest.mark.asyncio async def test_control_thinking_budget_toggle(mock_controller, mock_page): toggle = AsyncMock() mock_page.locator.return_value = toggle # Initial state: false. Desired: true. toggle.get_attribute.side_effect = ["false", "true"] # Before click, after click mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() await mock_controller._control_thinking_budget_toggle( True, MagicMock(return_value=False) ) toggle.click.assert_called() # Test verify failure toggle.get_attribute.side_effect = ["false", "false"] # Fails to change with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): await mock_controller._control_thinking_budget_toggle( True, MagicMock(return_value=False) ) # Should log warning but not raise (unless strict check implemented? Code just warns) mock_controller.logger.warning.assert_called() @pytest.mark.asyncio async def test_set_thinking_budget_value_complex(mock_controller, mock_page): input_el = AsyncMock() mock_page.locator.return_value = input_el mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() # Simulate to_have_value failing initially, triggering fallback verification mock_expect.return_value.to_have_value.side_effect = [Exception("Mismatch"), None] # Fallback verification reads input_value input_el.input_value.return_value = "5000" with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() await mock_controller._set_thinking_budget_value( 5000, MagicMock(return_value=False) ) input_el.fill.assert_called_with("5000", timeout=5000) # Should log success after reading value mock_controller.logger.info.assert_any_call(unittest.mock.ANY) @pytest.mark.asyncio async def test_set_thinking_budget_value_max_fallback(mock_controller, mock_page): input_el = AsyncMock() mock_page.locator.return_value = input_el mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() mock_expect.return_value.to_have_value.side_effect = Exception("Mismatch") input_el.input_value.return_value = "8000" # Less than desired 10000 input_el.get_attribute.return_value = "8000" # Max is 8000 with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() await mock_controller._set_thinking_budget_value( 10000, MagicMock(return_value=False) ) # Should warn and try to set to max mock_controller.logger.warning.assert_called() # Should verify it called fill with 8000 eventually assert call("8000", timeout=5000) in input_el.fill.call_args_list @pytest.mark.asyncio async def test_control_thinking_mode_toggle_click_failure_fallback( mock_controller, mock_page ): # Test fallback to aria-label based toggle click if main toggle click fails toggle = AsyncMock() toggle.click.side_effect = Exception("Not clickable") alt_toggle = AsyncMock() alt_toggle.count = AsyncMock(return_value=1) def locator_side_effect(selector): if "button" in selector and "switch" in selector: return toggle if 'aria-label="Toggle thinking mode"' in selector: return alt_toggle if "mat-slide-toggle" in selector: # Old fallback root = MagicMock() root.locator.return_value = AsyncMock() return root return toggle mock_page.locator.side_effect = locator_side_effect toggle.get_attribute.return_value = "false" mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() await mock_controller._control_thinking_mode_toggle( True, MagicMock(return_value=False) ) alt_toggle.click.assert_called() @pytest.mark.asyncio async def test_handle_thinking_budget_various_inputs(mock_controller): # Test various inputs for reasoning_effort triggering enable/disable mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() mock_controller._set_thinking_budget_value = AsyncMock() # String "none" -> enabled (No budget limit implies enabled) await mock_controller._handle_thinking_budget( {"reasoning_effort": "none"}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) mock_controller._control_thinking_mode_toggle.assert_called_with( should_be_enabled=True, check_client_disconnected=unittest.mock.ANY ) # String "100" -> enabled await mock_controller._handle_thinking_budget( {"reasoning_effort": "100"}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) mock_controller._control_thinking_mode_toggle.assert_called_with( should_be_enabled=True, check_client_disconnected=unittest.mock.ANY ) # String "0" -> disabled await mock_controller._handle_thinking_budget( {"reasoning_effort": "0"}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) mock_controller._control_thinking_mode_toggle.assert_called_with( should_be_enabled=False, check_client_disconnected=unittest.mock.ANY ) # String "-1" -> enabled await mock_controller._handle_thinking_budget( {"reasoning_effort": "-1"}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) mock_controller._control_thinking_mode_toggle.assert_called_with( should_be_enabled=True, check_client_disconnected=unittest.mock.ANY ) # Int -1 -> enabled await mock_controller._handle_thinking_budget( {"reasoning_effort": -1}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) mock_controller._control_thinking_mode_toggle.assert_called_with( should_be_enabled=True, check_client_disconnected=unittest.mock.ANY ) @pytest.mark.asyncio async def test_handle_thinking_budget_disabled_uses_level(mock_controller): # THINKING_LEVEL models with disabled thinking should skip toggle and just return mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() await mock_controller._handle_thinking_budget( {"reasoning_effort": 0}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) # THINKING_LEVEL has no main toggle (has_main_toggle=False), so no toggle call mock_controller._control_thinking_mode_toggle.assert_not_called() # Level models skip budget toggle logic entirely when disabled mock_controller._control_thinking_budget_toggle.assert_not_called() @pytest.mark.asyncio async def test_handle_thinking_budget_downgrade_logic(mock_controller): # Test the path where directive says disabled but raw says enabled, and we fail to disable? # Actually lines 113-127: if not directive.thinking_enabled (but desired_enabled=True) mock_directive = MagicMock() mock_directive.thinking_enabled = False with patch( "browser_utils.page_controller_modules.thinking.normalize_reasoning_effort_with_stream_check", return_value=mock_directive, ): mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) # _control_thinking_mode_toggle fails (returns False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=False) mock_controller._control_thinking_budget_toggle = AsyncMock() mock_controller._set_thinking_budget_value = AsyncMock() # Pass "high" -> _should_enable_from_raw returns True -> desired_enabled=True # But mock directive says False await mock_controller._handle_thinking_budget( {"reasoning_effort": "high"}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) # Should attempt to enable toggle first (master toggle logic at line 136) # then attempt to disable it (fallback/downgrade logic at line 249) assert mock_controller._control_thinking_mode_toggle.call_count == 2 # Check second call (the one we're interested in for downgrade logic) _, kwargs = mock_controller._control_thinking_mode_toggle.call_args assert kwargs["should_be_enabled"] is False # Upon failure (since we mocked return_value=False), should set budget to 0 mock_controller._control_thinking_budget_toggle.assert_called_with( should_be_checked=True, check_client_disconnected=unittest.mock.ANY ) mock_controller._set_thinking_budget_value.assert_called_with( 0, unittest.mock.ANY ) @pytest.mark.asyncio async def test_handle_thinking_budget_cap_gemini_2_5(mock_controller): mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() mock_controller._set_thinking_budget_value = AsyncMock() await mock_controller._handle_thinking_budget( {"reasoning_effort": 40000}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-2.5-pro", MagicMock(return_value=False), ) mock_controller._set_thinking_budget_value.assert_called_with( 32768, unittest.mock.ANY ) @pytest.mark.asyncio async def test_handle_thinking_budget_should_enable_variations(mock_controller): # Test _should_enable_from_raw logic via _handle_thinking_budget mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() mock_controller._set_thinking_budget_value = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) # "high" -> Enable await mock_controller._handle_thinking_budget( {"reasoning_effort": "high"}, mock_controller.params_cache, mock_controller.cache_lock, "model", check_disconnect_mock, ) assert mock_controller._control_thinking_mode_toggle.call_count == 1 _, kwargs = mock_controller._control_thinking_mode_toggle.call_args assert kwargs["should_be_enabled"] is True mock_controller._control_thinking_mode_toggle.reset_mock() # "low" -> Enable await mock_controller._handle_thinking_budget( {"reasoning_effort": "low"}, mock_controller.params_cache, mock_controller.cache_lock, "model", check_disconnect_mock, ) assert mock_controller._control_thinking_mode_toggle.call_count == 1 _, kwargs = mock_controller._control_thinking_mode_toggle.call_args assert kwargs["should_be_enabled"] is True mock_controller._control_thinking_mode_toggle.reset_mock() # "-1" -> Enable await mock_controller._handle_thinking_budget( {"reasoning_effort": "-1"}, mock_controller.params_cache, mock_controller.cache_lock, "model", check_disconnect_mock, ) assert mock_controller._control_thinking_mode_toggle.call_count == 1 _, kwargs = mock_controller._control_thinking_mode_toggle.call_args assert kwargs["should_be_enabled"] is True mock_controller._control_thinking_mode_toggle.reset_mock() # -1 (int) -> Enable await mock_controller._handle_thinking_budget( {"reasoning_effort": -1}, mock_controller.params_cache, mock_controller.cache_lock, "model", check_disconnect_mock, ) assert mock_controller._control_thinking_mode_toggle.call_count == 1 _, kwargs = mock_controller._control_thinking_mode_toggle.call_args assert kwargs["should_be_enabled"] is True mock_controller._control_thinking_mode_toggle.reset_mock() # "none" -> Enable (unlimited budget) # normalize_reasoning_effort_with_stream_check("none") -> thinking_enabled=True await mock_controller._handle_thinking_budget( {"reasoning_effort": "none"}, mock_controller.params_cache, mock_controller.cache_lock, "model", check_disconnect_mock, ) assert mock_controller._control_thinking_mode_toggle.call_count == 1 _, kwargs = mock_controller._control_thinking_mode_toggle.call_args assert kwargs["should_be_enabled"] is True mock_controller._control_thinking_mode_toggle.reset_mock() # "invalid" -> Disable (when ENABLE_THINKING_BUDGET is False) with patch( "browser_utils.page_controller_modules.thinking.normalize_reasoning_effort_with_stream_check" ) as mock_norm: from browser_utils.thinking_normalizer import ThinkingDirective mock_norm.return_value = ThinkingDirective( thinking_enabled=False, budget_enabled=False, budget_value=None, original_value="invalid", ) await mock_controller._handle_thinking_budget( {"reasoning_effort": "invalid"}, mock_controller.params_cache, mock_controller.cache_lock, "model", check_disconnect_mock, ) assert mock_controller._control_thinking_mode_toggle.call_count == 1 _, kwargs = mock_controller._control_thinking_mode_toggle.call_args assert kwargs["should_be_enabled"] is False mock_controller._control_thinking_mode_toggle.reset_mock() @pytest.mark.asyncio async def test_handle_thinking_budget_skip_level_disabled(mock_controller): # Skip logic: THINKING_LEVEL with disabled thinking should skip toggle mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) await mock_controller._handle_thinking_budget( {"reasoning_effort": 0}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", check_disconnect_mock, ) # THINKING_LEVEL has no main toggle mock_controller._control_thinking_mode_toggle.assert_not_called() mock_controller._control_thinking_budget_toggle.assert_not_called() @pytest.mark.asyncio async def test_handle_thinking_budget_caps(mock_controller): mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() mock_controller._set_thinking_budget_value = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) # flash -> 24576 await mock_controller._handle_thinking_budget( {"reasoning_effort": 40000}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-flash", check_disconnect_mock, ) mock_controller._set_thinking_budget_value.assert_called_with( 24576, unittest.mock.ANY ) # flash-lite -> 24576 await mock_controller._handle_thinking_budget( {"reasoning_effort": 40000}, mock_controller.params_cache, mock_controller.cache_lock, "flash-lite", check_disconnect_mock, ) mock_controller._set_thinking_budget_value.assert_called_with( 24576, unittest.mock.ANY ) @pytest.mark.asyncio async def test_set_thinking_level_errors(mock_controller): # Test error handling in _set_thinking_level # Simulate success path but verification mismatch trigger = MagicMock() trigger.click = AsyncMock() trigger.scroll_into_view_if_needed = AsyncMock() trigger.locator.return_value.inner_text = AsyncMock(return_value="Low") mock_controller.page.locator.return_value = trigger mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() mock_expect.return_value.to_be_hidden = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): await mock_controller._set_thinking_level("High", check_disconnect_mock) # Should verify warning log mock_controller.logger.warning.assert_called() @pytest.mark.asyncio async def test_control_thinking_mode_toggle_fallback(mock_controller): # Test fallback to aria-label based toggle click toggle = MagicMock() toggle.count = AsyncMock(return_value=1) # Element exists toggle.get_attribute = AsyncMock(return_value="false") toggle.click = AsyncMock(side_effect=Exception("Click failed")) alt_toggle = MagicMock() alt_toggle.count = AsyncMock(return_value=1) alt_toggle.click = AsyncMock() def locator_side_effect(selector): if "button" in selector and "switch" in selector: return toggle if 'aria-label="Toggle thinking mode"' in selector: return alt_toggle if 'data-test-toggle="enable-thinking"' in selector: root = MagicMock() root.locator.return_value = MagicMock() return root return toggle mock_controller.page.locator.side_effect = locator_side_effect # Mock expect_async mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): await mock_controller._control_thinking_mode_toggle(True, check_disconnect_mock) alt_toggle.click.assert_called() @pytest.mark.asyncio async def test_control_thinking_budget_toggle_fallback(mock_controller): # Test fallback to aria-label based toggle click toggle = MagicMock() toggle.count = AsyncMock(return_value=1) # Element exists toggle.get_attribute = AsyncMock(return_value="false") toggle.click = AsyncMock(side_effect=Exception("Click failed")) alt_toggle = MagicMock() alt_toggle.count = AsyncMock(return_value=1) alt_toggle.click = AsyncMock() def locator_side_effect(selector): if "button" in selector and "switch" in selector: return toggle if 'aria-label="Toggle thinking budget between auto and manual"' in selector: return alt_toggle if 'data-test-toggle="manual-budget"' in selector: root = MagicMock() root.locator.return_value = MagicMock() return root return toggle mock_controller.page.locator.side_effect = locator_side_effect # Mock expect_async mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): await mock_controller._control_thinking_budget_toggle( True, check_disconnect_mock ) alt_toggle.click.assert_called() @pytest.mark.asyncio async def test_set_thinking_budget_value_fallback(mock_controller): # Test fallback in _set_thinking_budget_value budget_input = MagicMock() budget_input.fill = AsyncMock() # Verification raises exception budget_input.input_value = AsyncMock(return_value="20000") # Mismatch mock_controller.page.locator.return_value = budget_input # Mock expect_async to fail first verification mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() mock_expect.return_value.to_have_value = AsyncMock( side_effect=Exception("Value mismatch") ) check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): await mock_controller._set_thinking_budget_value(30000, check_disconnect_mock) # Should check fallback path # It tries to read input_value, sees 20000 != 30000 # Then tries max attribute budget_input.get_attribute.assert_called_with("max") # --- Additional Coverage Tests --- @pytest.mark.asyncio async def test_handle_thinking_budget_invalid_string(mock_controller): """Test handling invalid string value for reasoning_effort""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL ) mock_controller._control_thinking_budget_toggle = AsyncMock() mock_controller._set_thinking_level = AsyncMock() # Test with invalid string that can't be parsed to int - should hit exception handler params = {"reasoning_effort": "invalid_value"} await mock_controller._handle_thinking_budget( params, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) # The exception handling path should be taken, leading to level_to_set = None # which logs "Cannot parse level" and returns without calling _set_thinking_level # Note: This test mainly ensures the exception path is covered # --- Additional Coverage Tests for Missing Lines --- @pytest.mark.asyncio async def test_should_enable_from_raw_edge_cases(mock_controller): """Test _should_enable_from_raw with various edge cases to cover lines 59, 66.""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() # Test string "none" (line 58-59) - should enable thinking await mock_controller._handle_thinking_budget( {"reasoning_effort": "none"}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) mock_controller._control_thinking_mode_toggle.assert_called_with( should_be_enabled=True, check_client_disconnected=unittest.mock.ANY ) mock_controller._control_thinking_mode_toggle.reset_mock() # Test invalid type (list) - normalize_reasoning_effort_with_stream_check returns default config # which typically enables thinking (line 66 returns False in _should_enable_from_raw, # but directive.thinking_enabled takes precedence) await mock_controller._handle_thinking_budget( {"reasoning_effort": [1, 2, 3]}, mock_controller.params_cache, mock_controller.cache_lock, "model", MagicMock(return_value=False), ) # normalize_reasoning_effort_with_stream_check returns default (ENABLE_THINKING_BUDGET) # Just verify it doesn't crash - behavior depends on config @pytest.mark.asyncio async def test_set_thinking_level_string_conversion_paths(mock_controller): """Test _set_thinking_level with string conversion paths (lines 113-117).""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._set_thinking_level = AsyncMock() # Test string "none" -> high (line 110-111) await mock_controller._handle_thinking_budget( {"reasoning_effort": "none"}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) mock_controller._set_thinking_level.assert_called_with("high", unittest.mock.ANY) # Test string that parses to int >= 8000 (line 114-115) await mock_controller._handle_thinking_budget( {"reasoning_effort": "9000"}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) mock_controller._set_thinking_level.assert_called_with("high", unittest.mock.ANY) # Test string that parses to int < 8000 (line 115) await mock_controller._handle_thinking_budget( {"reasoning_effort": "5000"}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) mock_controller._set_thinking_level.assert_called_with("low", unittest.mock.ANY) # Test string with exception during parsing (line 116-117) mock_controller._set_thinking_level.reset_mock() await mock_controller._handle_thinking_budget( {"reasoning_effort": "not_a_number_or_keyword"}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) # Should not call _set_thinking_level (line 122) mock_controller._set_thinking_level.assert_not_called() @pytest.mark.asyncio async def test_handle_thinking_budget_no_main_toggle_enabled(mock_controller): """Test enabled path when model has no main toggle (lines 147-152).""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=False) mock_controller._control_thinking_mode_toggle = AsyncMock(return_value=True) mock_controller._control_thinking_budget_toggle = AsyncMock() mock_controller._set_thinking_budget_value = AsyncMock() # Test with reasoning_effort that enables thinking but model has no main toggle await mock_controller._handle_thinking_budget( {"reasoning_effort": 5000}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-2.5-pro", MagicMock(return_value=False), ) # Should call _control_thinking_mode_toggle even without main toggle (line 148-152) mock_controller._control_thinking_mode_toggle.assert_called() @pytest.mark.asyncio async def test_has_thinking_dropdown_cancelled_error(mock_controller, mock_page): """Test CancelledError propagation in _has_thinking_dropdown (lines 190-195).""" mock_page.locator.return_value.count = AsyncMock(return_value=1) # Mock expect_async to raise CancelledError import asyncio with patch( "browser_utils.page_controller_modules.thinking.expect_async" ) as mock_expect: mock_expect.return_value.to_be_visible = AsyncMock( side_effect=asyncio.CancelledError() ) # Should re-raise CancelledError (line 191) with pytest.raises(asyncio.CancelledError): await mock_controller._has_thinking_dropdown() # Test outer CancelledError (line 195) mock_page.locator.return_value.count = AsyncMock( side_effect=asyncio.CancelledError() ) with pytest.raises(asyncio.CancelledError): await mock_controller._has_thinking_dropdown() @pytest.mark.asyncio async def test_set_thinking_level_listbox_close_fallback(mock_controller, mock_page): """Test listbox close fallback with keyboard escape (lines 243-250).""" trigger = AsyncMock() option = AsyncMock() listbox = AsyncMock() locator_calls = [trigger, option, listbox] mock_page.locator.side_effect = locator_calls mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() # Simulate listbox not closing automatically (line 242 exception) mock_expect.return_value.to_be_hidden = AsyncMock( side_effect=Exception("Listbox still visible") ) trigger.locator.return_value.inner_text = AsyncMock(return_value="High") mock_page.keyboard.press = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() await mock_controller._set_thinking_level("High", check_disconnect_mock) # Should press Escape key (line 247) mock_page.keyboard.press.assert_called_with("Escape") @pytest.mark.asyncio async def test_set_thinking_level_value_verification_mismatch( mock_controller, mock_page ): """Test value verification mismatch warning (lines 255, 262).""" trigger = AsyncMock() option = AsyncMock() listbox = AsyncMock() # Setup trigger to return mismatched value value_locator = AsyncMock() value_locator.inner_text = AsyncMock(return_value="Low") trigger.locator = MagicMock(return_value=value_locator) trigger.scroll_into_view_if_needed = AsyncMock() trigger.click = AsyncMock() option.click = AsyncMock() locator_call_count = [0] def locator_side_effect(selector): locator_call_count[0] += 1 if locator_call_count[0] == 1: # First call for trigger return trigger elif locator_call_count[0] == 2: # Second call for option return option else: # Third call for listbox return listbox mock_page.locator.side_effect = locator_side_effect mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() mock_expect.return_value.to_be_hidden = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() await mock_controller._set_thinking_level("High", check_disconnect_mock) # Should log warning (line 257-259) mock_controller.logger.warning.assert_called() @pytest.mark.asyncio async def test_set_thinking_level_client_disconnect(mock_controller, mock_page): """Test ClientDisconnectedError handling in _set_thinking_level (lines 262, 265).""" from models import ClientDisconnectedError trigger = AsyncMock() trigger.click = AsyncMock(side_effect=ClientDisconnectedError("Client gone")) mock_page.locator.return_value = trigger mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should re-raise ClientDisconnectedError (line 265) with pytest.raises(ClientDisconnectedError): await mock_controller._set_thinking_level("High", check_disconnect_mock) @pytest.mark.asyncio async def test_set_thinking_budget_value_evaluate_exception(mock_controller, mock_page): """Test evaluate exception handling in _set_thinking_budget_value (lines 336-339).""" import asyncio input_el = AsyncMock() mock_page.locator.return_value = input_el mock_page.evaluate = AsyncMock(side_effect=Exception("Evaluate failed")) mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() mock_expect.return_value.to_have_value = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should catch exception and continue (line 338) await mock_controller._set_thinking_budget_value(5000, check_disconnect_mock) # Should still call fill input_el.fill.assert_called() # Test CancelledError in evaluate (line 336-337) mock_page.evaluate = AsyncMock(side_effect=asyncio.CancelledError()) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() with pytest.raises(asyncio.CancelledError): await mock_controller._set_thinking_budget_value( 5000, check_disconnect_mock ) @pytest.mark.asyncio async def test_set_thinking_budget_value_verification_int_exception( mock_controller, mock_page ): """Test int parsing exception in verification fallback (lines 357-358).""" input_el = AsyncMock() mock_page.locator.return_value = input_el mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() mock_expect.return_value.to_have_value = AsyncMock( side_effect=Exception("Mismatch") ) # Return non-numeric string (line 357 exception) input_el.input_value = AsyncMock(return_value="not_a_number") input_el.get_attribute = AsyncMock(return_value=None) check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() await mock_controller._set_thinking_budget_value(5000, check_disconnect_mock) # Should log warning (line 403-405) mock_controller.logger.warning.assert_called() @pytest.mark.asyncio async def test_set_thinking_budget_value_fallback_cancelled_error( mock_controller, mock_page ): """Test CancelledError in fallback evaluation (lines 389-392, 399).""" import asyncio input_el = AsyncMock() mock_page.locator.return_value = input_el mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() mock_expect.return_value.to_have_value = AsyncMock( side_effect=Exception("Mismatch") ) input_el.input_value = AsyncMock(return_value="8000") input_el.get_attribute = AsyncMock(return_value="8000") # Mock page.evaluate to raise CancelledError in fallback path (line 389) mock_page.evaluate = AsyncMock(side_effect=asyncio.CancelledError()) check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should re-raise CancelledError (line 390) with pytest.raises(asyncio.CancelledError): await mock_controller._set_thinking_budget_value( 10000, check_disconnect_mock ) @pytest.mark.asyncio async def test_set_thinking_budget_value_top_level_errors(mock_controller, mock_page): """Test top-level error handling in _set_thinking_budget_value (lines 407-412).""" import asyncio from models import ClientDisconnectedError # Test CancelledError at top level (line 408) input_el = AsyncMock() mock_page.locator.return_value = input_el mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock( side_effect=asyncio.CancelledError() ) check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should re-raise CancelledError (line 409) with pytest.raises(asyncio.CancelledError): await mock_controller._set_thinking_budget_value( 5000, check_disconnect_mock ) # Test ClientDisconnectedError (line 411-412) mock_expect.return_value.to_be_visible = AsyncMock( side_effect=ClientDisconnectedError("Client gone") ) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should re-raise ClientDisconnectedError (line 412) with pytest.raises(ClientDisconnectedError): await mock_controller._set_thinking_budget_value( 5000, check_disconnect_mock ) @pytest.mark.asyncio async def test_control_thinking_mode_toggle_scroll_exception( mock_controller, mock_page ): """Test scroll exception in _control_thinking_mode_toggle (lines 438).""" toggle = AsyncMock() toggle.scroll_into_view_if_needed = AsyncMock( side_effect=Exception("Scroll failed") ) toggle.get_attribute = AsyncMock(return_value="false") toggle.click = AsyncMock() mock_page.locator.return_value = toggle mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should catch exception and continue (line 439) await mock_controller._control_thinking_mode_toggle(True, check_disconnect_mock) # Should still attempt click toggle.click.assert_called() @pytest.mark.asyncio async def test_control_thinking_mode_toggle_click_cancelled_error( mock_controller, mock_page ): """Test CancelledError during toggle click (lines 457-458).""" import asyncio toggle = AsyncMock() toggle.get_attribute = AsyncMock(return_value="false") toggle.click = AsyncMock(side_effect=asyncio.CancelledError()) toggle.scroll_into_view_if_needed = AsyncMock() mock_page.locator.return_value = toggle mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should re-raise CancelledError (line 458) with pytest.raises(asyncio.CancelledError): await mock_controller._control_thinking_mode_toggle( True, check_disconnect_mock ) @pytest.mark.asyncio async def test_control_thinking_mode_toggle_fallback_success( mock_controller, mock_page ): """Test successful fallback to aria-label based toggle click.""" toggle = AsyncMock() toggle.get_attribute = AsyncMock(side_effect=["false", "true"]) # Before and after toggle.click = AsyncMock(side_effect=Exception("Click failed")) toggle.scroll_into_view_if_needed = AsyncMock() alt_toggle = AsyncMock() alt_toggle.count = AsyncMock(return_value=1) alt_toggle.click = AsyncMock() # Success on fallback locator_call_count = [0] def locator_side_effect(selector): locator_call_count[0] += 1 if "button" in selector and "switch" in selector: return toggle if 'aria-label="Toggle thinking mode"' in selector: return alt_toggle # Old fallback path root = MagicMock() root.locator.return_value = MagicMock() return root mock_page.locator.side_effect = locator_side_effect mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should succeed via fallback result = await mock_controller._control_thinking_mode_toggle( True, check_disconnect_mock ) # Verify alt_toggle click was called (fallback) alt_toggle.click.assert_called() assert result is True @pytest.mark.asyncio async def test_control_thinking_mode_toggle_verification_failure( mock_controller, mock_page ): """Test verification failure after toggle click (lines 478-481).""" toggle = AsyncMock() # Simulate toggle not changing state toggle.get_attribute = AsyncMock(side_effect=["false", "false"]) toggle.click = AsyncMock() toggle.scroll_into_view_if_needed = AsyncMock() mock_page.locator.return_value = toggle mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() result = await mock_controller._control_thinking_mode_toggle( True, check_disconnect_mock ) # Should log warning and return False (lines 483-486) mock_controller.logger.warning.assert_called() assert result is False @pytest.mark.asyncio async def test_control_thinking_mode_toggle_already_correct_state( mock_controller, mock_page ): """Test toggle already in desired state (lines 488-489).""" toggle = AsyncMock() toggle.get_attribute = AsyncMock(return_value="true") toggle.scroll_into_view_if_needed = AsyncMock() mock_page.locator.return_value = toggle mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() result = await mock_controller._control_thinking_mode_toggle( True, check_disconnect_mock ) # Should not click and return True (line 489) toggle.click.assert_not_called() assert result is True @pytest.mark.asyncio async def test_control_thinking_mode_toggle_top_level_errors( mock_controller, mock_page ): """Test top-level error handling in _control_thinking_mode_toggle (lines 497-503).""" import asyncio from playwright.async_api import TimeoutError from models import ClientDisconnectedError # Test TimeoutError (lines 491-495) mock_page.locator.return_value = AsyncMock() mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock( side_effect=TimeoutError("Not found") ) check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() result = await mock_controller._control_thinking_mode_toggle( True, check_disconnect_mock ) # Should return False and log warning (lines 492-495) mock_controller.logger.warning.assert_called() assert result is False # Test CancelledError (lines 497-498) mock_expect.return_value.to_be_visible = AsyncMock( side_effect=asyncio.CancelledError() ) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() with pytest.raises(asyncio.CancelledError): await mock_controller._control_thinking_mode_toggle( True, check_disconnect_mock ) # Test ClientDisconnectedError (lines 501-502) mock_expect.return_value.to_be_visible = AsyncMock( side_effect=ClientDisconnectedError("Client gone") ) with ( patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ), patch( "browser_utils.page_controller_modules.thinking.save_error_snapshot", AsyncMock(), ), ): mock_controller._check_disconnect = AsyncMock() with pytest.raises(ClientDisconnectedError): await mock_controller._control_thinking_mode_toggle( True, check_disconnect_mock ) @pytest.mark.asyncio async def test_control_thinking_budget_toggle_scroll_exception( mock_controller, mock_page ): """Test scroll exception in _control_thinking_budget_toggle (lines 523).""" toggle = AsyncMock() toggle.scroll_into_view_if_needed = AsyncMock( side_effect=Exception("Scroll failed") ) toggle.get_attribute = AsyncMock(return_value="false") toggle.click = AsyncMock() mock_page.locator.return_value = toggle mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should catch exception and continue (line 524) await mock_controller._control_thinking_budget_toggle( True, check_disconnect_mock ) # Should still attempt click toggle.click.assert_called() @pytest.mark.asyncio async def test_control_thinking_budget_toggle_click_cancelled_error( mock_controller, mock_page ): """Test CancelledError during budget toggle click (lines 543-544).""" import asyncio toggle = AsyncMock() toggle.get_attribute = AsyncMock(return_value="false") toggle.click = AsyncMock(side_effect=asyncio.CancelledError()) toggle.scroll_into_view_if_needed = AsyncMock() mock_page.locator.return_value = toggle mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should re-raise CancelledError (line 544) with pytest.raises(asyncio.CancelledError): await mock_controller._control_thinking_budget_toggle( True, check_disconnect_mock ) @pytest.mark.asyncio async def test_control_thinking_budget_toggle_fallback_success( mock_controller, mock_page ): """Test successful fallback to aria-label based toggle click.""" toggle = AsyncMock() toggle.get_attribute = AsyncMock(side_effect=["false", "true"]) # Before and after toggle.click = AsyncMock(side_effect=Exception("Click failed")) toggle.scroll_into_view_if_needed = AsyncMock() alt_toggle = AsyncMock() alt_toggle.count = AsyncMock(return_value=1) alt_toggle.click = AsyncMock() # Success on fallback def locator_side_effect(selector): if "button" in selector and "switch" in selector: return toggle if 'aria-label="Toggle thinking budget between auto and manual"' in selector: return alt_toggle # Old fallback path root = MagicMock() root.locator.return_value = MagicMock() return root mock_page.locator.side_effect = locator_side_effect mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should succeed via fallback await mock_controller._control_thinking_budget_toggle( True, check_disconnect_mock ) # Verify alt_toggle click was called (fallback) alt_toggle.click.assert_called() @pytest.mark.asyncio async def test_control_thinking_budget_toggle_already_correct( mock_controller, mock_page ): """Test budget toggle already in desired state (lines 572-573).""" toggle = AsyncMock() toggle.get_attribute = AsyncMock(return_value="true") toggle.scroll_into_view_if_needed = AsyncMock() mock_page.locator.return_value = toggle mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock() check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() await mock_controller._control_thinking_budget_toggle( True, check_disconnect_mock ) # Should not click (line 572) toggle.click.assert_not_called() mock_controller.logger.info.assert_any_call( unittest.mock.ANY ) # Log "already in desired state" @pytest.mark.asyncio async def test_control_thinking_budget_toggle_top_level_errors( mock_controller, mock_page ): """Test top-level error handling in _control_thinking_budget_toggle (lines 574-579).""" import asyncio from models import ClientDisconnectedError # Test CancelledError (lines 575-576) mock_page.locator.return_value = AsyncMock() mock_expect = MagicMock() mock_expect.return_value.to_be_visible = AsyncMock( side_effect=asyncio.CancelledError() ) check_disconnect_mock = MagicMock(return_value=False) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should re-raise CancelledError (line 576) with pytest.raises(asyncio.CancelledError): await mock_controller._control_thinking_budget_toggle( True, check_disconnect_mock ) # Test ClientDisconnectedError (lines 578-579) mock_expect.return_value.to_be_visible = AsyncMock( side_effect=ClientDisconnectedError("Client gone") ) with patch( "browser_utils.page_controller_modules.thinking.expect_async", mock_expect ): mock_controller._check_disconnect = AsyncMock() # Should re-raise ClientDisconnectedError (line 579) with pytest.raises(ClientDisconnectedError): await mock_controller._control_thinking_budget_toggle( True, check_disconnect_mock ) # --- Additional Coverage Tests for Uncovered Lines --- @pytest.mark.asyncio async def test_handle_thinking_budget_non_thinking_category(mock_controller): """Test early return for NON_THINKING category (lines 66-67).""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.NON_THINKING ) mock_controller._control_thinking_mode_toggle = AsyncMock() mock_controller._control_thinking_budget_toggle = AsyncMock() # Should return early without calling any toggles await mock_controller._handle_thinking_budget( {"reasoning_effort": "high"}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-2.0-flash", MagicMock(return_value=False), ) mock_controller._control_thinking_mode_toggle.assert_not_called() mock_controller._control_thinking_budget_toggle.assert_not_called() @pytest.mark.asyncio async def test_handle_thinking_budget_flash_string_numeric_parsing(mock_controller): """Test Flash 4-level string numeric parsing (lines 149-163).""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._set_thinking_level = AsyncMock() # Test string numeric values for Flash 4-level model test_cases = [ ("16000", "high"), # String >= 16000 -> high ("20000", "high"), # String > 16000 -> high ("8000", "medium"), # String >= 8000 -> medium ("10000", "medium"), # String between 8000-16000 -> medium ("1024", "low"), # String >= 1024 -> low ("2000", "low"), # String between 1024-8000 -> low ("500", "minimal"), # String < 1024 -> minimal ("100", "minimal"), # String well below 1024 -> minimal ("none", "high"), # "none" maps to "high" for Flash (lines 149-150) ("-1", "high"), # "-1" maps to "high" (lines 149-150) ] for input_value, expected_level in test_cases: mock_controller._set_thinking_level.reset_mock() await mock_controller._handle_thinking_budget( {"reasoning_effort": input_value}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-flash-preview", MagicMock(return_value=False), ) ( mock_controller._set_thinking_level.assert_called_with( expected_level, unittest.mock.ANY ), f"Failed for input '{input_value}': expected '{expected_level}'", ) @pytest.mark.asyncio async def test_handle_thinking_budget_pro_string_exception(mock_controller): """Test Pro level string parsing exception path (lines 174-175).""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._set_thinking_level = AsyncMock() # Test string that can't be parsed as int await mock_controller._handle_thinking_budget( {"reasoning_effort": "invalid_string"}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) # Should not call _set_thinking_level (level_to_set is None) mock_controller._set_thinking_level.assert_not_called() @pytest.mark.asyncio async def test_handle_thinking_budget_pro_string_numeric(mock_controller): """Test Pro level string numeric parsing (lines 171-175).""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._set_thinking_level = AsyncMock() # Test string numeric values for Pro 2-level model test_cases = [ ("8000", "high"), # String >= 8000 -> high ("10000", "high"), # String > 8000 -> high ("7999", "low"), # String < 8000 -> low ("100", "low"), # String well below 8000 -> low ] for input_value, expected_level in test_cases: mock_controller._set_thinking_level.reset_mock() await mock_controller._handle_thinking_budget( {"reasoning_effort": input_value}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) ( mock_controller._set_thinking_level.assert_called_with( expected_level, unittest.mock.ANY ), f"Failed for input '{input_value}': expected '{expected_level}'", ) @pytest.mark.asyncio async def test_handle_thinking_budget_default_level_none(mock_controller): """Test default level assignment when reasoning_effort is None (lines 193-200).""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._set_thinking_level = AsyncMock() # Pass None reasoning_effort to trigger default level logic (line 101, 191-200) await mock_controller._handle_thinking_budget( {"reasoning_effort": None}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-pro", MagicMock(return_value=False), ) # Should use DEFAULT_THINKING_LEVEL_PRO mock_controller._set_thinking_level.assert_called() @pytest.mark.asyncio async def test_handle_thinking_budget_default_level_flash(mock_controller): """Test default level assignment for Flash model (lines 193-196).""" mock_controller._get_thinking_category = MagicMock( return_value=ThinkingCategory.THINKING_LEVEL_FLASH ) mock_controller._has_thinking_dropdown = AsyncMock(return_value=True) mock_controller._set_thinking_level = AsyncMock() # Pass None reasoning_effort to trigger default level logic await mock_controller._handle_thinking_budget( {"reasoning_effort": None}, mock_controller.params_cache, mock_controller.cache_lock, "gemini-3-flash", MagicMock(return_value=False), ) # Should use DEFAULT_THINKING_LEVEL_FLASH mock_controller._set_thinking_level.assert_called()