mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-26 15:34:26 +00:00
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.
This commit is contained in:
+20
-4
@@ -15,7 +15,7 @@ class FakeApp:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
command_prefix: list[str] = ["/", "!"],
|
command_prefix: list[str] = ['/', '!'],
|
||||||
command_enable: bool = True,
|
command_enable: bool = True,
|
||||||
pipeline_concurrency: int = 10,
|
pipeline_concurrency: int = 10,
|
||||||
admins: list[str] | None = None,
|
admins: list[str] | None = None,
|
||||||
@@ -40,6 +40,8 @@ class FakeApp:
|
|||||||
self.telemetry = self._create_mock_telemetry()
|
self.telemetry = self._create_mock_telemetry()
|
||||||
self.survey = None
|
self.survey = None
|
||||||
self.cmd_mgr = self._create_mock_cmd_mgr()
|
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
|
# Apply any extra attributes for specific test scenarios
|
||||||
for name, value in extra_attrs.items():
|
for name, value in extra_attrs.items():
|
||||||
@@ -98,9 +100,9 @@ class FakeApp:
|
|||||||
):
|
):
|
||||||
instance_config = Mock()
|
instance_config = Mock()
|
||||||
instance_config.data = {
|
instance_config.data = {
|
||||||
"command": {"prefix": command_prefix, "enable": command_enable},
|
'command': {'prefix': command_prefix, 'enable': command_enable},
|
||||||
"concurrency": {"pipeline": pipeline_concurrency},
|
'concurrency': {'pipeline': pipeline_concurrency},
|
||||||
"admins": admins,
|
'admins': admins,
|
||||||
}
|
}
|
||||||
return instance_config
|
return instance_config
|
||||||
|
|
||||||
@@ -119,6 +121,20 @@ class FakeApp:
|
|||||||
cmd_mgr.execute = AsyncMock()
|
cmd_mgr.execute = AsyncMock()
|
||||||
return cmd_mgr
|
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):
|
def capture_message(self, message):
|
||||||
"""Capture an outbound message for test assertions."""
|
"""Capture an outbound message for test assertions."""
|
||||||
self._outbound_messages.append(message)
|
self._outbound_messages.append(message)
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, Mock
|
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.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Tests cover:
|
|||||||
- Tool schema generation for OpenAI and Anthropic
|
- Tool schema generation for OpenAI and Anthropic
|
||||||
- Tool execution dispatch
|
- Tool execution dispatch
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -52,11 +53,12 @@ class TestToolManagerSchemaGeneration:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sample_tools(self):
|
def sample_tools(self):
|
||||||
"""Create sample LLMTool list for testing."""
|
"""Create sample LLMTool list for testing."""
|
||||||
|
|
||||||
def dummy_weather_func(**kwargs):
|
def dummy_weather_func(**kwargs):
|
||||||
return "weather result"
|
return 'weather result'
|
||||||
|
|
||||||
def dummy_calc_func(**kwargs):
|
def dummy_calc_func(**kwargs):
|
||||||
return "calc result"
|
return 'calc result'
|
||||||
|
|
||||||
tools = [
|
tools = [
|
||||||
resource_tool.LLMTool(
|
resource_tool.LLMTool(
|
||||||
@@ -65,15 +67,10 @@ class TestToolManagerSchemaGeneration:
|
|||||||
description='Get current weather for a location',
|
description='Get current weather for a location',
|
||||||
parameters={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'properties': {
|
'properties': {'location': {'type': 'string', 'description': 'City name'}},
|
||||||
'location': {
|
'required': ['location'],
|
||||||
'type': 'string',
|
|
||||||
'description': 'City name'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'required': ['location']
|
|
||||||
},
|
},
|
||||||
func=dummy_weather_func
|
func=dummy_weather_func,
|
||||||
),
|
),
|
||||||
resource_tool.LLMTool(
|
resource_tool.LLMTool(
|
||||||
name='calculate',
|
name='calculate',
|
||||||
@@ -81,15 +78,10 @@ class TestToolManagerSchemaGeneration:
|
|||||||
description='Perform a calculation',
|
description='Perform a calculation',
|
||||||
parameters={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'properties': {
|
'properties': {'expression': {'type': 'string', 'description': 'Math expression'}},
|
||||||
'expression': {
|
'required': ['expression'],
|
||||||
'type': 'string',
|
|
||||||
'description': 'Math expression'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'required': ['expression']
|
|
||||||
},
|
},
|
||||||
func=dummy_calc_func
|
func=dummy_calc_func,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return tools
|
return tools
|
||||||
@@ -188,26 +180,48 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_app_with_loaders(self):
|
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 = Mock()
|
||||||
mock_app.logger = 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
|
# Create mock plugin loader
|
||||||
mock_plugin_loader = Mock()
|
mock_plugin_loader = _make_inert_loader()
|
||||||
mock_plugin_loader.has_tool = AsyncMock(return_value=False)
|
|
||||||
mock_plugin_loader.invoke_tool = AsyncMock(return_value='plugin_result')
|
mock_plugin_loader.invoke_tool = AsyncMock(return_value='plugin_result')
|
||||||
mock_plugin_loader.initialize = AsyncMock()
|
|
||||||
mock_plugin_loader.shutdown = AsyncMock()
|
|
||||||
|
|
||||||
# Create mock MCP loader
|
# Create mock MCP loader
|
||||||
mock_mcp_loader = Mock()
|
mock_mcp_loader = _make_inert_loader()
|
||||||
mock_mcp_loader.has_tool = AsyncMock(return_value=False)
|
|
||||||
mock_mcp_loader.invoke_tool = AsyncMock(return_value='mcp_result')
|
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
|
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
|
@pytest.fixture
|
||||||
def sample_query(self):
|
def sample_query(self):
|
||||||
"""Create sample query for testing."""
|
"""Create sample query for testing."""
|
||||||
@@ -215,9 +229,7 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
return query
|
return query
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_calls_plugin_loader_when_has_tool(
|
async def test_execute_calls_plugin_loader_when_has_tool(self, mock_app_with_loaders, sample_query):
|
||||||
self, mock_app_with_loaders, sample_query
|
|
||||||
):
|
|
||||||
"""Test that execute_func_call uses plugin loader when tool exists there."""
|
"""Test that execute_func_call uses plugin loader when tool exists there."""
|
||||||
toolmgr = get_toolmgr_module()
|
toolmgr = get_toolmgr_module()
|
||||||
|
|
||||||
@@ -225,26 +237,17 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
mock_plugin_loader.has_tool = AsyncMock(return_value=True)
|
mock_plugin_loader.has_tool = AsyncMock(return_value=True)
|
||||||
|
|
||||||
manager = toolmgr.ToolManager(mock_app)
|
manager = toolmgr.ToolManager(mock_app)
|
||||||
manager.plugin_tool_loader = mock_plugin_loader
|
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||||
manager.mcp_tool_loader = mock_mcp_loader
|
|
||||||
|
|
||||||
result = await manager.execute_func_call(
|
result = await manager.execute_func_call('test_tool', {'param': 'value'}, sample_query)
|
||||||
'test_tool',
|
|
||||||
{'param': 'value'},
|
|
||||||
sample_query
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == 'plugin_result'
|
assert result == 'plugin_result'
|
||||||
mock_plugin_loader.invoke_tool.assert_called_once_with(
|
mock_plugin_loader.invoke_tool.assert_called_once_with('test_tool', {'param': 'value'}, sample_query)
|
||||||
'test_tool', {'param': 'value'}, sample_query
|
|
||||||
)
|
|
||||||
# MCP loader should not be called
|
# MCP loader should not be called
|
||||||
mock_mcp_loader.invoke_tool.assert_not_called()
|
mock_mcp_loader.invoke_tool.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_calls_mcp_loader_when_plugin_not_found(
|
async def test_execute_calls_mcp_loader_when_plugin_not_found(self, mock_app_with_loaders, sample_query):
|
||||||
self, mock_app_with_loaders, sample_query
|
|
||||||
):
|
|
||||||
"""Test that execute_func_call uses MCP loader when plugin doesn't have tool."""
|
"""Test that execute_func_call uses MCP loader when plugin doesn't have tool."""
|
||||||
toolmgr = get_toolmgr_module()
|
toolmgr = get_toolmgr_module()
|
||||||
|
|
||||||
@@ -253,24 +256,15 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
|
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
|
||||||
|
|
||||||
manager = toolmgr.ToolManager(mock_app)
|
manager = toolmgr.ToolManager(mock_app)
|
||||||
manager.plugin_tool_loader = mock_plugin_loader
|
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||||
manager.mcp_tool_loader = mock_mcp_loader
|
|
||||||
|
|
||||||
result = await manager.execute_func_call(
|
result = await manager.execute_func_call('test_tool', {'param': 'value'}, sample_query)
|
||||||
'test_tool',
|
|
||||||
{'param': 'value'},
|
|
||||||
sample_query
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == 'mcp_result'
|
assert result == 'mcp_result'
|
||||||
mock_mcp_loader.invoke_tool.assert_called_once_with(
|
mock_mcp_loader.invoke_tool.assert_called_once_with('test_tool', {'param': 'value'}, sample_query)
|
||||||
'test_tool', {'param': 'value'}, sample_query
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_execute_raises_when_tool_not_found(
|
async def test_execute_raises_when_tool_not_found(self, mock_app_with_loaders, sample_query):
|
||||||
self, mock_app_with_loaders, sample_query
|
|
||||||
):
|
|
||||||
"""Test that execute_func_call raises ValueError when tool not found."""
|
"""Test that execute_func_call raises ValueError when tool not found."""
|
||||||
toolmgr = get_toolmgr_module()
|
toolmgr = get_toolmgr_module()
|
||||||
|
|
||||||
@@ -279,20 +273,13 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
mock_mcp_loader.has_tool = AsyncMock(return_value=False)
|
mock_mcp_loader.has_tool = AsyncMock(return_value=False)
|
||||||
|
|
||||||
manager = toolmgr.ToolManager(mock_app)
|
manager = toolmgr.ToolManager(mock_app)
|
||||||
manager.plugin_tool_loader = mock_plugin_loader
|
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||||
manager.mcp_tool_loader = mock_mcp_loader
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='未找到工具'):
|
with pytest.raises(ValueError, match='未找到工具'):
|
||||||
await manager.execute_func_call(
|
await manager.execute_func_call('unknown_tool', {}, sample_query)
|
||||||
'unknown_tool',
|
|
||||||
{},
|
|
||||||
sample_query
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_plugin_loader_checked_first(
|
async def test_plugin_loader_checked_first(self, mock_app_with_loaders, sample_query):
|
||||||
self, mock_app_with_loaders, sample_query
|
|
||||||
):
|
|
||||||
"""Test that plugin loader is checked before MCP loader."""
|
"""Test that plugin loader is checked before MCP loader."""
|
||||||
toolmgr = get_toolmgr_module()
|
toolmgr = get_toolmgr_module()
|
||||||
|
|
||||||
@@ -302,8 +289,7 @@ class TestToolManagerExecuteFuncCall:
|
|||||||
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
|
mock_mcp_loader.has_tool = AsyncMock(return_value=True)
|
||||||
|
|
||||||
manager = toolmgr.ToolManager(mock_app)
|
manager = toolmgr.ToolManager(mock_app)
|
||||||
manager.plugin_tool_loader = mock_plugin_loader
|
self._wire_loaders(manager, mock_app, mock_plugin_loader, mock_mcp_loader)
|
||||||
manager.mcp_tool_loader = mock_mcp_loader
|
|
||||||
|
|
||||||
await manager.execute_func_call('test_tool', {}, sample_query)
|
await manager.execute_func_call('test_tool', {}, sample_query)
|
||||||
|
|
||||||
@@ -317,20 +303,30 @@ class TestToolManagerShutdown:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_shutdown_calls_loader_shutdown(self):
|
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()
|
toolmgr = get_toolmgr_module()
|
||||||
|
|
||||||
mock_app = Mock()
|
mock_app = Mock()
|
||||||
mock_plugin_loader = Mock()
|
|
||||||
mock_plugin_loader.shutdown = AsyncMock()
|
def _make_loader():
|
||||||
mock_mcp_loader = Mock()
|
loader = Mock()
|
||||||
mock_mcp_loader.shutdown = AsyncMock()
|
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 = toolmgr.ToolManager(mock_app)
|
||||||
|
manager.native_tool_loader = mock_native_loader
|
||||||
manager.plugin_tool_loader = mock_plugin_loader
|
manager.plugin_tool_loader = mock_plugin_loader
|
||||||
manager.mcp_tool_loader = mock_mcp_loader
|
manager.mcp_tool_loader = mock_mcp_loader
|
||||||
|
manager.skill_tool_loader = mock_skill_loader
|
||||||
|
|
||||||
await manager.shutdown()
|
await manager.shutdown()
|
||||||
|
|
||||||
|
mock_native_loader.shutdown.assert_called_once()
|
||||||
mock_plugin_loader.shutdown.assert_called_once()
|
mock_plugin_loader.shutdown.assert_called_once()
|
||||||
mock_mcp_loader.shutdown.assert_called_once()
|
mock_mcp_loader.shutdown.assert_called_once()
|
||||||
|
mock_skill_loader.shutdown.assert_called_once()
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ Uses tmp_path for file system isolation where applicable.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckIfSourceInstall:
|
class TestCheckIfSourceInstall:
|
||||||
@@ -19,7 +18,7 @@ class TestCheckIfSourceInstall:
|
|||||||
|
|
||||||
def test_returns_true_for_source_install(self, tmp_path, monkeypatch):
|
def test_returns_true_for_source_install(self, tmp_path, monkeypatch):
|
||||||
"""Should return True when main.py with LangBot marker exists."""
|
"""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')
|
main_py.write_text('# LangBot/main.py\n# This is the entry point')
|
||||||
|
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
@@ -33,52 +32,14 @@ class TestCheckIfSourceInstall:
|
|||||||
|
|
||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
def test_returns_false_when_no_main_py(self, tmp_path, monkeypatch):
|
# Note: ``_check_if_source_install`` was refactored to walk
|
||||||
"""Should return False when main.py doesn't exist."""
|
# ``Path(__file__).resolve().parents`` looking for ``pyproject.toml`` +
|
||||||
monkeypatch.chdir(tmp_path)
|
# ``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
|
||||||
from langbot.pkg.utils import paths
|
# main.py" / "main.py without marker" / "IOError on read" cases no longer
|
||||||
|
# apply — there's no file read at all. The corresponding negative tests
|
||||||
paths._is_source_install = None
|
# were removed; ``test_returns_true_for_source_install`` still exercises
|
||||||
|
# the positive path because the repo checkout itself is a source install.
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetFrontendPath:
|
class TestGetFrontendPath:
|
||||||
@@ -92,16 +53,16 @@ class TestGetFrontendPath:
|
|||||||
|
|
||||||
result = paths.get_frontend_path()
|
result = paths.get_frontend_path()
|
||||||
# The result should contain web/dist or be an absolute path to it
|
# 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
|
paths._is_source_install = None
|
||||||
|
|
||||||
def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch):
|
def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch):
|
||||||
"""Should find web/dist when running from source mode."""
|
"""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')
|
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_dist.mkdir(parents=True)
|
||||||
|
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
@@ -111,18 +72,18 @@ class TestGetFrontendPath:
|
|||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
result = paths.get_frontend_path()
|
result = paths.get_frontend_path()
|
||||||
assert result == "web/dist"
|
assert result == 'web/dist'
|
||||||
|
|
||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
def test_prefers_dist_over_out_in_source_mode(self, tmp_path, monkeypatch):
|
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."""
|
"""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')
|
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_dist.mkdir(parents=True)
|
||||||
web_out = tmp_path / "web" / "out"
|
web_out = tmp_path / 'web' / 'out'
|
||||||
web_out.mkdir(parents=True)
|
web_out.mkdir(parents=True)
|
||||||
|
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
@@ -132,7 +93,7 @@ class TestGetFrontendPath:
|
|||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
result = paths.get_frontend_path()
|
result = paths.get_frontend_path()
|
||||||
assert result == "web/dist"
|
assert result == 'web/dist'
|
||||||
|
|
||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
@@ -148,19 +109,19 @@ class TestGetResourcePath:
|
|||||||
|
|
||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
result = paths.get_resource_path("nonexistent/file.txt")
|
result = paths.get_resource_path('nonexistent/file.txt')
|
||||||
assert result == "nonexistent/file.txt"
|
assert result == 'nonexistent/file.txt'
|
||||||
|
|
||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
def test_finds_resource_in_current_directory_source_mode(self, tmp_path, monkeypatch):
|
def test_finds_resource_in_current_directory_source_mode(self, tmp_path, monkeypatch):
|
||||||
"""Should find resource in current directory when in source mode."""
|
"""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')
|
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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
resource_file.write_text("test: value")
|
resource_file.write_text('test: value')
|
||||||
|
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
@@ -168,18 +129,18 @@ class TestGetResourcePath:
|
|||||||
|
|
||||||
paths._is_source_install = None
|
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)
|
assert os.path.exists(result)
|
||||||
|
|
||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
def test_returns_relative_path_in_source_mode(self, tmp_path, monkeypatch):
|
def test_returns_relative_path_in_source_mode(self, tmp_path, monkeypatch):
|
||||||
"""Should return relative path if resource exists in source mode."""
|
"""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')
|
main_py.write_text('# LangBot/main.py\n')
|
||||||
|
|
||||||
resource_file = tmp_path / "test_resource.txt"
|
resource_file = tmp_path / 'test_resource.txt'
|
||||||
resource_file.write_text("test content")
|
resource_file.write_text('test content')
|
||||||
|
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
@@ -187,8 +148,8 @@ class TestGetResourcePath:
|
|||||||
|
|
||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
result = paths.get_resource_path("test_resource.txt")
|
result = paths.get_resource_path('test_resource.txt')
|
||||||
assert result == "test_resource.txt"
|
assert result == 'test_resource.txt'
|
||||||
|
|
||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
@@ -198,7 +159,7 @@ class TestPathFunctionsCaching:
|
|||||||
|
|
||||||
def test_source_install_cache_is_used(self, tmp_path, monkeypatch):
|
def test_source_install_cache_is_used(self, tmp_path, monkeypatch):
|
||||||
"""_check_if_source_install should use cached result."""
|
"""_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')
|
main_py.write_text('# LangBot/main.py\n')
|
||||||
|
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
@@ -219,5 +180,5 @@ class TestPathFunctionsCaching:
|
|||||||
paths._is_source_install = None
|
paths._is_source_install = None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, '-v'])
|
||||||
|
|||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user