"""Tests for thread_settings module.""" import threading from unittest.mock import MagicMock import pytest from local_deep_research.config.thread_settings import ( NoSettingsContextError, clear_settings_context, get_setting_from_snapshot, get_settings_context, set_settings_context, settings_context, _thread_local, ) class TestNoSettingsContextError: """Tests for NoSettingsContextError exception.""" def test_is_exception(self): """Should be an Exception subclass.""" assert issubclass(NoSettingsContextError, Exception) def test_can_be_raised_with_message(self): """Should accept error message.""" with pytest.raises(NoSettingsContextError) as exc_info: raise NoSettingsContextError("test message") assert "test message" in str(exc_info.value) class TestSetSettingsContext: """Tests for set_settings_context function.""" def test_sets_context_on_thread_local(self, clean_thread_local): """Should set settings_context on thread local.""" context = MagicMock() set_settings_context(context) assert _thread_local.settings_context is context def test_overwrites_previous_context(self, clean_thread_local): """Should overwrite previous context.""" context1 = MagicMock() context2 = MagicMock() set_settings_context(context1) set_settings_context(context2) assert _thread_local.settings_context is context2 class TestGetSettingsContext: """Tests for get_settings_context function.""" def test_returns_context_when_set(self, clean_thread_local): """Should return context when set.""" context = MagicMock() set_settings_context(context) assert get_settings_context() is context def test_returns_none_when_not_set(self, clean_thread_local): """Should return None when no context set.""" assert get_settings_context() is None class TestGetSettingFromSnapshot: """Tests for get_setting_from_snapshot function.""" def test_returns_value_from_snapshot(self, clean_thread_local): """Should return value from settings_snapshot.""" snapshot = {"test.key": "test_value"} result = get_setting_from_snapshot( "test.key", settings_snapshot=snapshot ) assert result == "test_value" def test_handles_full_format_value(self, clean_thread_local): """Should extract value from full format dict.""" snapshot = { "test.key": {"value": "extracted_value", "ui_element": "text"} } result = get_setting_from_snapshot( "test.key", settings_snapshot=snapshot ) assert result == "extracted_value" def test_returns_default_when_key_not_found(self, clean_thread_local): """Should return default when key not in snapshot.""" snapshot = {"other.key": "value"} result = get_setting_from_snapshot( "test.key", default="default_val", settings_snapshot=snapshot ) assert result == "default_val" def test_builds_dict_from_child_keys(self, clean_thread_local): """Should build dict from child keys.""" snapshot = { "parent.child1": "value1", "parent.child2": "value2", } result = get_setting_from_snapshot("parent", settings_snapshot=snapshot) assert result == {"child1": "value1", "child2": "value2"} def test_extracts_value_from_child_key_full_format( self, clean_thread_local ): """Should extract value from child keys with full format.""" snapshot = { "parent.child": {"value": "extracted", "ui_element": "text"}, } result = get_setting_from_snapshot("parent", settings_snapshot=snapshot) assert result == {"child": "extracted"} def test_uses_thread_context_when_no_snapshot( self, clean_thread_local, mock_settings_context ): """Should use thread-local context when no snapshot.""" set_settings_context(mock_settings_context) mock_settings_context.get_setting.return_value = "context_value" result = get_setting_from_snapshot("test.key") assert result == "context_value" mock_settings_context.get_setting.assert_called_with("test.key", None) def test_extracts_value_from_context_dict( self, clean_thread_local, mock_settings_context ): """Should extract value from context dict format.""" set_settings_context(mock_settings_context) mock_settings_context.get_setting.return_value = { "value": "extracted_value" } result = get_setting_from_snapshot("test.key") assert result == "extracted_value" def test_raises_when_no_context_and_no_default(self, clean_thread_local): """Should raise NoSettingsContextError when no context and no default.""" with pytest.raises(NoSettingsContextError) as exc_info: get_setting_from_snapshot("test.key") assert "test.key" in str(exc_info.value) def test_returns_default_instead_of_raising(self, clean_thread_local): """Should return default instead of raising when provided.""" result = get_setting_from_snapshot("test.key", default="my_default") assert result == "my_default" def test_returns_none_when_snapshot_value_is_none(self, clean_thread_local): """Key present in snapshot with value None should return None, not raise NoSettingsContextError. Regression for #4208: the OpenAI embeddings test endpoint blew up here because embeddings.openai.dimensions defaults to JSON null.""" snapshot = { "embeddings.openai.dimensions": { "value": None, "ui_element": "number", } } result = get_setting_from_snapshot( "embeddings.openai.dimensions", default=None, settings_snapshot=snapshot, ) assert result is None def test_returns_none_when_simplified_snapshot_value_is_none( self, clean_thread_local ): """Simplified-format snapshot (raw value, not {value: ...} dict) with explicit None should also return None, not raise.""" snapshot = {"some.key": None} result = get_setting_from_snapshot( "some.key", default=None, settings_snapshot=snapshot ) assert result is None class TestClearSettingsContext: """Tests for clear_settings_context function.""" def test_clears_existing_context(self, clean_thread_local): """Should clear context when set.""" set_settings_context(MagicMock()) clear_settings_context() assert get_settings_context() is None def test_noop_when_no_context(self, clean_thread_local): """Should not raise when no context is set.""" clear_settings_context() class TestSettingsContextManager: """Tests for settings_context context manager.""" def test_sets_and_clears_context(self, clean_thread_local): """Context should be set inside and cleared after the block.""" ctx = MagicMock() with settings_context(ctx): assert get_settings_context() is ctx assert get_settings_context() is None def test_clears_on_exception(self, clean_thread_local): """Context should be cleared even when an exception occurs.""" ctx = MagicMock() with pytest.raises(ValueError): with settings_context(ctx): raise ValueError("boom") assert get_settings_context() is None def test_works_in_thread(self, clean_thread_local): """Context manager should work correctly in a separate thread.""" results = [] def worker(): ctx = MagicMock() ctx.name = "thread-ctx" with settings_context(ctx): results.append(get_settings_context().name) results.append(get_settings_context()) t = threading.Thread(target=worker) t.start() t.join() assert results[0] == "thread-ctx" assert results[1] is None class TestThreadIsolation: """Tests for thread isolation of settings context.""" def test_contexts_isolated_between_threads(self, clean_thread_local): """Should isolate contexts between threads.""" results = {} def thread_func(name, context_value): context = MagicMock() context.get_setting.return_value = context_value set_settings_context(context) # Small delay to ensure overlap import time time.sleep(0.01) results[name] = get_settings_context().get_setting("key", None) thread1 = threading.Thread(target=thread_func, args=("t1", "value1")) thread2 = threading.Thread(target=thread_func, args=("t2", "value2")) thread1.start() thread2.start() thread1.join() thread2.join() assert results["t1"] == "value1" assert results["t2"] == "value2"