From d1ddff9cdb40688fe4763e791e5a508a4e655a2c Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 21 May 2026 00:04:34 +0800 Subject: [PATCH] test: reconcile master's unit tests with feat/sandbox refactors The merge from master brought in new unit tests that target pre-refactor APIs on feat/sandbox. Reconcile each: - factories/app.py: FakeApp now exposes a Mock skill_mgr (with empty .skills dict + inert prompt-addition builder) and a Mock pipeline_service so the PreProcessor skill-index injection branch can run end-to-end in tests. - pipeline/conftest.py: eagerly import langbot.pkg.pipeline.pipelinemgr so pipeline.stage is fully initialised before any individual stage test (preproc, longtext, ...) tries to lazy-load it. Without this preload, running test_preproc.py in isolation hit a circular-import error via the stage -> app -> pipelinemgr -> stage chain. - provider/test_tool_manager.py: ToolManager now probes four loaders (native -> plugin -> mcp -> skill). Inject inert native + skill mocks in the execute_func_call fixture and assert all four shutdowns fire. - utils/test_paths.py: drop the three cwd-dependent _check_if_source_install cases. The refactor walks Path(__file__).resolve().parents looking for pyproject.toml + main.py, so cwd no longer factors in and there's no file read to mock-fail. The positive case and caching test still apply. - utils/test_version.py: delete entirely. is_newer and compare_version_str were removed when VersionManager was refactored to use the Space API for release checks (1b4107a9); the tests targeted a surface that no longer exists. --- tests/factories/app.py | 26 +++- tests/unit_tests/pipeline/conftest.py | 6 + .../unit_tests/provider/test_tool_manager.py | 144 +++++++++--------- tests/unit_tests/utils/test_paths.py | 101 ++++-------- tests/unit_tests/utils/test_version.py | 136 ----------------- 5 files changed, 128 insertions(+), 285 deletions(-) delete mode 100644 tests/unit_tests/utils/test_version.py diff --git a/tests/factories/app.py b/tests/factories/app.py index 5f36df84..d1edf56a 100644 --- a/tests/factories/app.py +++ b/tests/factories/app.py @@ -15,7 +15,7 @@ class FakeApp: def __init__( self, *, - command_prefix: list[str] = ["/", "!"], + command_prefix: list[str] = ['/', '!'], command_enable: bool = True, pipeline_concurrency: int = 10, admins: list[str] | None = None, @@ -40,6 +40,8 @@ class FakeApp: self.telemetry = self._create_mock_telemetry() self.survey = None self.cmd_mgr = self._create_mock_cmd_mgr() + self.skill_mgr = self._create_mock_skill_mgr() + self.pipeline_service = self._create_mock_pipeline_service() # Apply any extra attributes for specific test scenarios for name, value in extra_attrs.items(): @@ -98,9 +100,9 @@ class FakeApp: ): instance_config = Mock() instance_config.data = { - "command": {"prefix": command_prefix, "enable": command_enable}, - "concurrency": {"pipeline": pipeline_concurrency}, - "admins": admins, + 'command': {'prefix': command_prefix, 'enable': command_enable}, + 'concurrency': {'pipeline': pipeline_concurrency}, + 'admins': admins, } return instance_config @@ -119,6 +121,20 @@ class FakeApp: cmd_mgr.execute = AsyncMock() return cmd_mgr + def _create_mock_skill_mgr(self): + """Mock SkillManager that returns no skill index addition by default.""" + skill_mgr = Mock() + skill_mgr.skills = {} + skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='') + skill_mgr.get_skill_index = Mock(return_value=[]) + return skill_mgr + + def _create_mock_pipeline_service(self): + """Mock PipelineService.get_pipeline returning empty extensions prefs.""" + pipeline_service = AsyncMock() + pipeline_service.get_pipeline = AsyncMock(return_value={'extensions_preferences': {}}) + return pipeline_service + def capture_message(self, message): """Capture an outbound message for test assertions.""" self._outbound_messages.append(message) @@ -134,4 +150,4 @@ class FakeApp: def fake_app(**kwargs) -> FakeApp: """Create a FakeApp instance with optional overrides.""" - return FakeApp(**kwargs) \ No newline at end of file + return FakeApp(**kwargs) diff --git a/tests/unit_tests/pipeline/conftest.py b/tests/unit_tests/pipeline/conftest.py index 7abc5b70..ce8ee7eb 100644 --- a/tests/unit_tests/pipeline/conftest.py +++ b/tests/unit_tests/pipeline/conftest.py @@ -12,6 +12,12 @@ from __future__ import annotations import pytest from unittest.mock import AsyncMock, Mock +# Preload pipelinemgr so the pipeline.stage module is fully initialised before +# any individual stage test (e.g. preproc, longtext) tries to import it. Without +# this, running a stage test in isolation triggers a circular-import error: +# stage.py → core.app → pipelinemgr → stage.stage_class (not yet bound). +import langbot.pkg.pipeline.pipelinemgr # noqa: F401 + import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events diff --git a/tests/unit_tests/provider/test_tool_manager.py b/tests/unit_tests/provider/test_tool_manager.py index 867b2e22..8e8439f5 100644 --- a/tests/unit_tests/provider/test_tool_manager.py +++ b/tests/unit_tests/provider/test_tool_manager.py @@ -4,6 +4,7 @@ Tests cover: - Tool schema generation for OpenAI and Anthropic - Tool execution dispatch """ + from __future__ import annotations import pytest @@ -52,11 +53,12 @@ class TestToolManagerSchemaGeneration: @pytest.fixture def sample_tools(self): """Create sample LLMTool list for testing.""" + def dummy_weather_func(**kwargs): - return "weather result" + return 'weather result' def dummy_calc_func(**kwargs): - return "calc result" + return 'calc result' tools = [ resource_tool.LLMTool( @@ -65,15 +67,10 @@ class TestToolManagerSchemaGeneration: description='Get current weather for a location', parameters={ 'type': 'object', - 'properties': { - 'location': { - 'type': 'string', - 'description': 'City name' - } - }, - 'required': ['location'] + 'properties': {'location': {'type': 'string', 'description': 'City name'}}, + 'required': ['location'], }, - func=dummy_weather_func + func=dummy_weather_func, ), resource_tool.LLMTool( name='calculate', @@ -81,15 +78,10 @@ class TestToolManagerSchemaGeneration: description='Perform a calculation', parameters={ 'type': 'object', - 'properties': { - 'expression': { - 'type': 'string', - 'description': 'Math expression' - } - }, - 'required': ['expression'] + 'properties': {'expression': {'type': 'string', 'description': 'Math expression'}}, + 'required': ['expression'], }, - func=dummy_calc_func + func=dummy_calc_func, ), ] return tools @@ -188,26 +180,48 @@ class TestToolManagerExecuteFuncCall: @pytest.fixture def mock_app_with_loaders(self): - """Create mock app with mock tool loaders.""" + """Create mock app with mock tool loaders. + + Returns (app, plugin_loader, mcp_loader). The native and skill loaders + are attached directly to the app for tests that don't need to assert + against them — they all default to ``has_tool == False`` so the + execute_func_call probe falls through to the plugin/mcp pair. + """ mock_app = Mock() mock_app.logger = Mock() + def _make_inert_loader(): + loader = Mock() + loader.has_tool = AsyncMock(return_value=False) + loader.invoke_tool = AsyncMock(return_value=None) + loader.initialize = AsyncMock() + loader.shutdown = AsyncMock() + return loader + # Create mock plugin loader - mock_plugin_loader = Mock() - mock_plugin_loader.has_tool = AsyncMock(return_value=False) + mock_plugin_loader = _make_inert_loader() mock_plugin_loader.invoke_tool = AsyncMock(return_value='plugin_result') - mock_plugin_loader.initialize = AsyncMock() - mock_plugin_loader.shutdown = AsyncMock() # Create mock MCP loader - mock_mcp_loader = Mock() - mock_mcp_loader.has_tool = AsyncMock(return_value=False) + mock_mcp_loader = _make_inert_loader() mock_mcp_loader.invoke_tool = AsyncMock(return_value='mcp_result') - mock_mcp_loader.initialize = AsyncMock() - mock_mcp_loader.shutdown = AsyncMock() + + # Stash inert native/skill loaders so the ToolManager probe order + # (native → plugin → mcp → skill) doesn't AttributeError. Tests that + # need to override these can replace the attributes on the manager. + mock_app._inert_native_loader = _make_inert_loader() + mock_app._inert_skill_loader = _make_inert_loader() return mock_app, mock_plugin_loader, mock_mcp_loader + @staticmethod + def _wire_loaders(manager, mock_app, plugin_loader, mcp_loader): + """Attach all four loaders (native + plugin + mcp + skill) to manager.""" + manager.native_tool_loader = mock_app._inert_native_loader + manager.plugin_tool_loader = plugin_loader + manager.mcp_tool_loader = mcp_loader + manager.skill_tool_loader = mock_app._inert_skill_loader + @pytest.fixture def sample_query(self): """Create sample query for testing.""" @@ -215,9 +229,7 @@ class TestToolManagerExecuteFuncCall: return query @pytest.mark.asyncio - async def test_execute_calls_plugin_loader_when_has_tool( - self, mock_app_with_loaders, sample_query - ): + async def test_execute_calls_plugin_loader_when_has_tool(self, mock_app_with_loaders, sample_query): """Test that execute_func_call uses plugin loader when tool exists there.""" toolmgr = get_toolmgr_module() @@ -225,26 +237,17 @@ class TestToolManagerExecuteFuncCall: mock_plugin_loader.has_tool = AsyncMock(return_value=True) manager = toolmgr.ToolManager(mock_app) - manager.plugin_tool_loader = mock_plugin_loader - manager.mcp_tool_loader = mock_mcp_loader + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) - result = await manager.execute_func_call( - 'test_tool', - {'param': 'value'}, - sample_query - ) + result = await manager.execute_func_call('test_tool', {'param': 'value'}, sample_query) assert result == 'plugin_result' - mock_plugin_loader.invoke_tool.assert_called_once_with( - 'test_tool', {'param': 'value'}, sample_query - ) + mock_plugin_loader.invoke_tool.assert_called_once_with('test_tool', {'param': 'value'}, sample_query) # MCP loader should not be called mock_mcp_loader.invoke_tool.assert_not_called() @pytest.mark.asyncio - async def test_execute_calls_mcp_loader_when_plugin_not_found( - self, mock_app_with_loaders, sample_query - ): + async def test_execute_calls_mcp_loader_when_plugin_not_found(self, mock_app_with_loaders, sample_query): """Test that execute_func_call uses MCP loader when plugin doesn't have tool.""" toolmgr = get_toolmgr_module() @@ -253,24 +256,15 @@ class TestToolManagerExecuteFuncCall: mock_mcp_loader.has_tool = AsyncMock(return_value=True) manager = toolmgr.ToolManager(mock_app) - manager.plugin_tool_loader = mock_plugin_loader - manager.mcp_tool_loader = mock_mcp_loader + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) - result = await manager.execute_func_call( - 'test_tool', - {'param': 'value'}, - sample_query - ) + result = await manager.execute_func_call('test_tool', {'param': 'value'}, sample_query) assert result == 'mcp_result' - mock_mcp_loader.invoke_tool.assert_called_once_with( - 'test_tool', {'param': 'value'}, sample_query - ) + mock_mcp_loader.invoke_tool.assert_called_once_with('test_tool', {'param': 'value'}, sample_query) @pytest.mark.asyncio - async def test_execute_raises_when_tool_not_found( - self, mock_app_with_loaders, sample_query - ): + async def test_execute_raises_when_tool_not_found(self, mock_app_with_loaders, sample_query): """Test that execute_func_call raises ValueError when tool not found.""" toolmgr = get_toolmgr_module() @@ -279,20 +273,13 @@ class TestToolManagerExecuteFuncCall: mock_mcp_loader.has_tool = AsyncMock(return_value=False) manager = toolmgr.ToolManager(mock_app) - manager.plugin_tool_loader = mock_plugin_loader - manager.mcp_tool_loader = mock_mcp_loader + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) with pytest.raises(ValueError, match='未找到工具'): - await manager.execute_func_call( - 'unknown_tool', - {}, - sample_query - ) + await manager.execute_func_call('unknown_tool', {}, sample_query) @pytest.mark.asyncio - async def test_plugin_loader_checked_first( - self, mock_app_with_loaders, sample_query - ): + async def test_plugin_loader_checked_first(self, mock_app_with_loaders, sample_query): """Test that plugin loader is checked before MCP loader.""" toolmgr = get_toolmgr_module() @@ -302,8 +289,7 @@ class TestToolManagerExecuteFuncCall: mock_mcp_loader.has_tool = AsyncMock(return_value=True) manager = toolmgr.ToolManager(mock_app) - manager.plugin_tool_loader = mock_plugin_loader - manager.mcp_tool_loader = mock_mcp_loader + self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader) await manager.execute_func_call('test_tool', {}, sample_query) @@ -317,20 +303,30 @@ class TestToolManagerShutdown: @pytest.mark.asyncio async def test_shutdown_calls_loader_shutdown(self): - """Test that shutdown calls shutdown on both loaders.""" + """Test that shutdown calls shutdown on every registered loader.""" toolmgr = get_toolmgr_module() mock_app = Mock() - mock_plugin_loader = Mock() - mock_plugin_loader.shutdown = AsyncMock() - mock_mcp_loader = Mock() - mock_mcp_loader.shutdown = AsyncMock() + + def _make_loader(): + loader = Mock() + loader.shutdown = AsyncMock() + return loader + + mock_native_loader = _make_loader() + mock_plugin_loader = _make_loader() + mock_mcp_loader = _make_loader() + mock_skill_loader = _make_loader() manager = toolmgr.ToolManager(mock_app) + manager.native_tool_loader = mock_native_loader manager.plugin_tool_loader = mock_plugin_loader manager.mcp_tool_loader = mock_mcp_loader + manager.skill_tool_loader = mock_skill_loader await manager.shutdown() + mock_native_loader.shutdown.assert_called_once() mock_plugin_loader.shutdown.assert_called_once() - mock_mcp_loader.shutdown.assert_called_once() \ No newline at end of file + mock_mcp_loader.shutdown.assert_called_once() + mock_skill_loader.shutdown.assert_called_once() diff --git a/tests/unit_tests/utils/test_paths.py b/tests/unit_tests/utils/test_paths.py index 390c8270..0043a333 100644 --- a/tests/unit_tests/utils/test_paths.py +++ b/tests/unit_tests/utils/test_paths.py @@ -11,7 +11,6 @@ Uses tmp_path for file system isolation where applicable. import os import pytest -from unittest.mock import patch class TestCheckIfSourceInstall: @@ -19,7 +18,7 @@ class TestCheckIfSourceInstall: def test_returns_true_for_source_install(self, tmp_path, monkeypatch): """Should return True when main.py with LangBot marker exists.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n# This is the entry point') monkeypatch.chdir(tmp_path) @@ -33,52 +32,14 @@ class TestCheckIfSourceInstall: paths._is_source_install = None - def test_returns_false_when_no_main_py(self, tmp_path, monkeypatch): - """Should return False when main.py doesn't exist.""" - monkeypatch.chdir(tmp_path) - - from langbot.pkg.utils import paths - - paths._is_source_install = None - - result = paths._check_if_source_install() - assert result is False - - paths._is_source_install = None - - def test_returns_false_when_main_py_without_marker(self, tmp_path, monkeypatch): - """Should return False when main.py exists but lacks LangBot marker.""" - main_py = tmp_path / "main.py" - main_py.write_text('# Some other project\nprint("hello")') - - monkeypatch.chdir(tmp_path) - - from langbot.pkg.utils import paths - - paths._is_source_install = None - - result = paths._check_if_source_install() - assert result is False - - paths._is_source_install = None - - def test_handles_io_error_gracefully(self, tmp_path, monkeypatch): - """Should return False when main.py cannot be read.""" - main_py = tmp_path / "main.py" - main_py.write_text('# LangBot/main.py\n') - - monkeypatch.chdir(tmp_path) - - from langbot.pkg.utils import paths - - paths._is_source_install = None - - # Patch open to raise IOError - with patch("builtins.open", side_effect=IOError("Cannot read")): - result = paths._check_if_source_install() - assert result is False - - paths._is_source_install = None + # Note: ``_check_if_source_install`` was refactored to walk + # ``Path(__file__).resolve().parents`` looking for ``pyproject.toml`` + + # ``main.py`` instead of relying on the cwd. That makes it robust to where + # the process is launched from but also means the old "cwd doesn't have + # main.py" / "main.py without marker" / "IOError on read" cases no longer + # apply — there's no file read at all. The corresponding negative tests + # were removed; ``test_returns_true_for_source_install`` still exercises + # the positive path because the repo checkout itself is a source install. class TestGetFrontendPath: @@ -92,16 +53,16 @@ class TestGetFrontendPath: result = paths.get_frontend_path() # The result should contain web/dist or be an absolute path to it - assert "web/dist" in result or result.endswith("dist") + assert 'web/dist' in result or result.endswith('dist') paths._is_source_install = None def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch): """Should find web/dist when running from source mode.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') - web_dist = tmp_path / "web" / "dist" + web_dist = tmp_path / 'web' / 'dist' web_dist.mkdir(parents=True) monkeypatch.chdir(tmp_path) @@ -111,18 +72,18 @@ class TestGetFrontendPath: paths._is_source_install = None result = paths.get_frontend_path() - assert result == "web/dist" + assert result == 'web/dist' paths._is_source_install = None def test_prefers_dist_over_out_in_source_mode(self, tmp_path, monkeypatch): """Should prefer web/dist over web/out when both exist in source mode.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') - web_dist = tmp_path / "web" / "dist" + web_dist = tmp_path / 'web' / 'dist' web_dist.mkdir(parents=True) - web_out = tmp_path / "web" / "out" + web_out = tmp_path / 'web' / 'out' web_out.mkdir(parents=True) monkeypatch.chdir(tmp_path) @@ -132,7 +93,7 @@ class TestGetFrontendPath: paths._is_source_install = None result = paths.get_frontend_path() - assert result == "web/dist" + assert result == 'web/dist' paths._is_source_install = None @@ -148,19 +109,19 @@ class TestGetResourcePath: paths._is_source_install = None - result = paths.get_resource_path("nonexistent/file.txt") - assert result == "nonexistent/file.txt" + result = paths.get_resource_path('nonexistent/file.txt') + assert result == 'nonexistent/file.txt' paths._is_source_install = None def test_finds_resource_in_current_directory_source_mode(self, tmp_path, monkeypatch): """Should find resource in current directory when in source mode.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') - resource_file = tmp_path / "templates" / "config.yaml" + resource_file = tmp_path / 'templates' / 'config.yaml' resource_file.parent.mkdir(parents=True, exist_ok=True) - resource_file.write_text("test: value") + resource_file.write_text('test: value') monkeypatch.chdir(tmp_path) @@ -168,18 +129,18 @@ class TestGetResourcePath: paths._is_source_install = None - result = paths.get_resource_path("templates/config.yaml") + result = paths.get_resource_path('templates/config.yaml') assert os.path.exists(result) paths._is_source_install = None def test_returns_relative_path_in_source_mode(self, tmp_path, monkeypatch): """Should return relative path if resource exists in source mode.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') - resource_file = tmp_path / "test_resource.txt" - resource_file.write_text("test content") + resource_file = tmp_path / 'test_resource.txt' + resource_file.write_text('test content') monkeypatch.chdir(tmp_path) @@ -187,8 +148,8 @@ class TestGetResourcePath: paths._is_source_install = None - result = paths.get_resource_path("test_resource.txt") - assert result == "test_resource.txt" + result = paths.get_resource_path('test_resource.txt') + assert result == 'test_resource.txt' paths._is_source_install = None @@ -198,7 +159,7 @@ class TestPathFunctionsCaching: def test_source_install_cache_is_used(self, tmp_path, monkeypatch): """_check_if_source_install should use cached result.""" - main_py = tmp_path / "main.py" + main_py = tmp_path / 'main.py' main_py.write_text('# LangBot/main.py\n') monkeypatch.chdir(tmp_path) @@ -219,5 +180,5 @@ class TestPathFunctionsCaching: paths._is_source_install = None -if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/unit_tests/utils/test_version.py b/tests/unit_tests/utils/test_version.py deleted file mode 100644 index df698caf..00000000 --- a/tests/unit_tests/utils/test_version.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Unit tests for version utility functions. - -Tests version comparison logic without network calls. -""" - -from __future__ import annotations - -from unittest.mock import Mock - -from langbot.pkg.utils.version import VersionManager - - -class TestVersionComparison: - """Tests for version comparison functions.""" - - def _create_version_manager(self): - """Create a VersionManager with mock app.""" - mock_app = Mock() - mock_app.proxy_mgr = Mock() - mock_app.proxy_mgr.get_forward_providers = Mock(return_value={}) - mock_app.logger = Mock() - return VersionManager(mock_app) - - def test_is_newer_same_version(self): - """is_newer returns False for same version.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.0.0', 'v1.0.0') - assert result is False - - def test_is_newer_different_major_version(self): - """is_newer returns False for different major version.""" - # Note: is_newer ignores major version changes - vm = self._create_version_manager() - result = vm.is_newer('v2.0.0', 'v1.0.0') - assert result is False - - def test_is_newer_minor_update(self): - """is_newer returns True for minor update within same major.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.1.0', 'v1.0.0') - assert result is True - - def test_is_newer_patch_update(self): - """is_newer returns True for patch update within same major.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.0.1', 'v1.0.0') - assert result is True - - def test_is_newer_with_fourth_segment(self): - """is_newer ignores fourth version segment.""" - # Both have same first 3 segments - vm = self._create_version_manager() - result = vm.is_newer('v1.0.0.1', 'v1.0.0.0') - assert result is False - - def test_is_newer_short_version(self): - """is_newer handles short version numbers.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.0', 'v1.0') - assert result is False - - def test_is_newer_older_version(self): - """is_newer returns True when new > old.""" - vm = self._create_version_manager() - result = vm.is_newer('v1.2.0', 'v1.1.0') - assert result is True - - -class TestCompareVersionStr: - """Tests for compare_version_str static method.""" - - def test_compare_equal_versions(self): - """Equal versions return 0.""" - result = VersionManager.compare_version_str('v1.0.0', 'v1.0.0') - assert result == 0 - - def test_compare_without_v_prefix(self): - """Versions without v prefix work the same.""" - result = VersionManager.compare_version_str('1.0.0', '1.0.0') - assert result == 0 - - def test_compare_mixed_prefix(self): - """Mixed v prefix works correctly.""" - result = VersionManager.compare_version_str('v1.0.0', '1.0.0') - assert result == 0 - - def test_compare_first_greater(self): - """First version greater returns 1.""" - result = VersionManager.compare_version_str('v1.1.0', 'v1.0.0') - assert result == 1 - - def test_compare_first_smaller(self): - """First version smaller returns -1.""" - result = VersionManager.compare_version_str('v1.0.0', 'v1.1.0') - assert result == -1 - - def test_compare_different_lengths(self): - """Different length versions are padded with zeros.""" - result = VersionManager.compare_version_str('v1.0', 'v1.0.0') - assert result == 0 - - def test_compare_shorter_greater(self): - """Shorter version padded, first still greater.""" - result = VersionManager.compare_version_str('v1.1', 'v1.0.0') - assert result == 1 - - def test_compare_longer_greater(self): - """Longer version, first smaller.""" - result = VersionManager.compare_version_str('v1.0', 'v1.0.1') - assert result == -1 - - def test_compare_major_version(self): - """Major version comparison.""" - result = VersionManager.compare_version_str('v2.0.0', 'v1.9.9') - assert result == 1 - - def test_compare_minor_version(self): - """Minor version comparison.""" - result = VersionManager.compare_version_str('v1.5.0', 'v1.4.9') - assert result == 1 - - def test_compare_patch_version(self): - """Patch version comparison.""" - result = VersionManager.compare_version_str('v1.0.1', 'v1.0.0') - assert result == 1 - - def test_compare_four_segments(self): - """Four segment version comparison.""" - result = VersionManager.compare_version_str('v1.0.0.1', 'v1.0.0.0') - assert result == 1 - - def test_compare_long_versions(self): - """Long version strings work correctly.""" - result = VersionManager.compare_version_str('v1.2.3.4.5', 'v1.2.3.4.4') - assert result == 1