mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-26 22:36:11 +00:00
test: tighten phase 1 coverage contracts
This commit is contained in:
@@ -91,9 +91,7 @@ class TestContentFilterStageInit:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
assert stage.filter_chain is not None
|
||||
# Should have at least 'content-ignore' filter
|
||||
assert len(stage.filter_chain) >= 1
|
||||
assert [filter_impl.name for filter_impl in stage.filter_chain] == ['content-ignore']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_sensitive_words(self):
|
||||
@@ -121,8 +119,10 @@ class TestContentFilterStageInit:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
# Should have content-ignore and ban-word-filter
|
||||
assert len(stage.filter_chain) >= 2
|
||||
assert [filter_impl.name for filter_impl in stage.filter_chain] == [
|
||||
'ban-word-filter',
|
||||
'content-ignore',
|
||||
]
|
||||
|
||||
|
||||
class TestPreContentFilter:
|
||||
@@ -511,4 +511,4 @@ class TestContentIgnoreFilterDirect:
|
||||
|
||||
result = await stage.process(query, 'PreContentFilterStage')
|
||||
|
||||
assert result.result_type == cntfilter.entities.ResultType.CONTINUE
|
||||
assert result.result_type == cntfilter.entities.ResultType.CONTINUE
|
||||
|
||||
@@ -160,8 +160,11 @@ class TestLongTextProcessStageProcess:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Should not transform short text
|
||||
assert result.new_query.resp_message_chain is not None
|
||||
assert len(result.new_query.resp_message_chain) == 1
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Plain)
|
||||
assert components[0].text == 'short response'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_plain_component_skips(self):
|
||||
@@ -189,35 +192,13 @@ class TestLongTextProcessStageProcess:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Should skip due to non-Plain component
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_resp_message_chain(self):
|
||||
"""Empty resp_message_chain should be handled gracefully."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='forward')
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
# Should handle gracefully (may raise or return CONTINUE)
|
||||
# This tests the defensive behavior
|
||||
try:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
# If it returns, should be CONTINUE
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
except (IndexError, AttributeError):
|
||||
# Expected if resp_message_chain is empty
|
||||
pass
|
||||
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert [type(component) for component in components] == [
|
||||
platform_message.Plain,
|
||||
platform_message.Image,
|
||||
]
|
||||
assert components[0].text == 'short'
|
||||
assert components[1].url == 'https://example.com/img.png'
|
||||
|
||||
class TestForwardStrategy:
|
||||
"""Tests for ForwardComponentStrategy."""
|
||||
@@ -253,8 +234,9 @@ class TestForwardStrategy:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Check that message chain was transformed
|
||||
assert result.new_query.resp_message_chain is not None
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Forward)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_strategy_direct_process(self):
|
||||
@@ -288,36 +270,6 @@ class TestForwardStrategy:
|
||||
class TestLongTextThreshold:
|
||||
"""Tests for threshold boundary handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_threshold_continues(self):
|
||||
"""Text exactly at threshold should trigger processing."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
threshold = 50
|
||||
pipeline_config = make_longtext_config(strategy='forward', threshold=threshold)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
mock_adapter = Mock()
|
||||
mock_adapter.bot_account_id = '12345'
|
||||
query.adapter = mock_adapter
|
||||
|
||||
# Text exactly at threshold
|
||||
exact_text = "x" * threshold
|
||||
query.resp_message_chain = [
|
||||
platform_message.MessageChain([platform_message.Plain(text=exact_text)])
|
||||
]
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_below_threshold_not_processed(self):
|
||||
"""Text below threshold should not be transformed."""
|
||||
@@ -344,7 +296,10 @@ class TestLongTextThreshold:
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Original chain should remain unchanged
|
||||
components = list(result.new_query.resp_message_chain[0])
|
||||
assert len(components) == 1
|
||||
assert isinstance(components[0], platform_message.Plain)
|
||||
assert components[0].text == short_text
|
||||
|
||||
|
||||
class TestLongTextProcessStageImageStrategy:
|
||||
@@ -367,4 +322,4 @@ class TestLongTextProcessStageImageStrategy:
|
||||
|
||||
# Should have initialized (possibly with fallback strategy)
|
||||
if stage.strategy_impl is not None:
|
||||
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
|
||||
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
|
||||
|
||||
@@ -254,13 +254,12 @@ class TestRoundTruncatorProcess:
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
|
||||
# Check order is preserved (user2 -> asst2 -> user3)
|
||||
messages = result.new_query.messages
|
||||
if len(messages) >= 3:
|
||||
assert messages[0].role == 'user'
|
||||
assert messages[0].content == 'user2'
|
||||
assert messages[1].role == 'assistant'
|
||||
assert messages[1].content == 'asst2'
|
||||
assert [(msg.role, msg.content) for msg in messages] == [
|
||||
('user', 'user2'),
|
||||
('assistant', 'asst2'),
|
||||
('user', 'user3'),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncate_max_round_one(self):
|
||||
@@ -286,10 +285,8 @@ class TestRoundTruncatorProcess:
|
||||
result = await stage.process(query, 'ConversationMessageTruncator')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
# Only last round (user + assistant pair) should remain
|
||||
messages = result.new_query.messages
|
||||
# At most 2 messages (user + assistant before current)
|
||||
assert len(messages) <= 2
|
||||
assert [(msg.role, msg.content) for msg in messages] == [('user', 'current')]
|
||||
|
||||
|
||||
class TestRoundTruncatorDirect:
|
||||
@@ -321,4 +318,4 @@ class TestRoundTruncatorDirect:
|
||||
result = await trun.truncate(query)
|
||||
|
||||
assert result is not None
|
||||
assert hasattr(result, 'messages')
|
||||
assert hasattr(result, 'messages')
|
||||
|
||||
@@ -19,14 +19,23 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
_mock_runner = MagicMock()
|
||||
_mock_runner.runner_class = lambda name: (lambda cls: cls) # no-op decorator
|
||||
_mock_runner.RequestRunner = object
|
||||
sys.modules.setdefault('langbot.pkg.provider.runner', _mock_runner)
|
||||
sys.modules.setdefault('langbot.pkg.core.app', MagicMock())
|
||||
sys.modules.setdefault('langbot.pkg.utils.httpclient', MagicMock())
|
||||
_mocked_imports = {
|
||||
'langbot.pkg.provider.runner': _mock_runner,
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
}
|
||||
_original_imports = {name: sys.modules.get(name) for name in _mocked_imports}
|
||||
sys.modules.update(_mocked_imports)
|
||||
|
||||
import pytest # noqa: E402
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message # noqa: E402
|
||||
from langbot.pkg.provider.runners.n8nsvapi import N8nServiceAPIRunner # noqa: E402
|
||||
|
||||
for _name, _original in _original_imports.items():
|
||||
if _original is None:
|
||||
sys.modules.pop(_name, None)
|
||||
else:
|
||||
sys.modules[_name] = _original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -82,10 +91,10 @@ async def test_stream_format_single_item():
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
assert len(chunks) >= 1
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'hello'
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'hello'
|
||||
assert chunks[0].msg_sequence == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -100,9 +109,10 @@ async def test_stream_format_multi_item_accumulates():
|
||||
|
||||
chunks = await collect_chunks(runner, chunks_data)
|
||||
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'foobar'
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'foobar'
|
||||
assert chunks[0].msg_sequence == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -115,9 +125,13 @@ async def test_stream_format_batches_every_8_items():
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
# At least the batch yield at chunk_idx==8 + final yield
|
||||
assert len(chunks) >= 2
|
||||
assert chunks[-1].is_final is True
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0].is_final is False
|
||||
assert chunks[0].content == '01234567'
|
||||
assert chunks[0].msg_sequence == 1
|
||||
assert chunks[1].is_final is True
|
||||
assert chunks[1].content == '01234567'
|
||||
assert chunks[1].msg_sequence == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -129,9 +143,9 @@ async def test_stream_format_split_across_network_chunks():
|
||||
|
||||
chunks = await collect_chunks(runner, [part1, part2])
|
||||
|
||||
final = chunks[-1]
|
||||
assert final.is_final is True
|
||||
assert final.content == 'world'
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].is_final is True
|
||||
assert chunks[0].content == 'world'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -143,10 +157,8 @@ async def test_stream_format_no_spurious_empty_yield():
|
||||
|
||||
chunks = await collect_chunks(runner, [data])
|
||||
|
||||
# No chunk should have empty content before the real content arrives
|
||||
non_final = [c for c in chunks if not c.is_final]
|
||||
for c in non_final:
|
||||
assert c.content # must be non-empty
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].content == 'x'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@ Tests query management, ID generation, and async context handling.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from langbot.pkg.pipeline.pool import QueryPool
|
||||
|
||||
@@ -37,8 +37,8 @@ class TestQueryPoolInit:
|
||||
class TestQueryPoolAddQuery:
|
||||
"""Tests for add_query method."""
|
||||
|
||||
async def test_add_query_returns_query_with_id(self):
|
||||
"""add_query creates a Query with correct ID."""
|
||||
async def test_add_query_adds_query_with_id(self):
|
||||
"""add_query creates, stores, and caches a Query with the correct ID."""
|
||||
pool = QueryPool()
|
||||
|
||||
# Mock Query creation
|
||||
@@ -134,7 +134,7 @@ class TestQueryPoolAddQuery:
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
query = await pool.add_query(
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
@@ -158,7 +158,7 @@ class TestQueryPoolAddQuery:
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
query = await pool.add_query(
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
@@ -184,7 +184,7 @@ class TestQueryPoolAddQuery:
|
||||
with patch('langbot.pkg.pipeline.pool.pipeline_query.Query') as MockQuery:
|
||||
MockQuery.return_value = mock_query
|
||||
|
||||
query = await pool.add_query(
|
||||
await pool.add_query(
|
||||
bot_uuid='bot1',
|
||||
launcher_type=Mock(),
|
||||
launcher_id=1,
|
||||
|
||||
@@ -155,11 +155,10 @@ class TestPreProcessorEmptyMessage:
|
||||
|
||||
result = await stage.process(query, 'PreProcessor')
|
||||
|
||||
# Empty message should still continue (behavior depends on code)
|
||||
# Empty message should still continue with an empty provider content list.
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query.user_message is not None
|
||||
# Empty content list
|
||||
assert result.new_query.user_message.content == [] or result.new_query.user_message.content is None
|
||||
assert result.new_query.user_message.content == []
|
||||
|
||||
|
||||
class TestPreProcessorImageSegment:
|
||||
@@ -428,4 +427,4 @@ class TestPreProcessorVariables:
|
||||
|
||||
variables = result.new_query.variables
|
||||
assert 'group_name' in variables
|
||||
assert 'sender_name' in variables
|
||||
assert 'sender_name' in variables
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
Simple standalone tests to verify test infrastructure
|
||||
These tests don't import the actual pipeline code to avoid circular import issues
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
|
||||
|
||||
def test_pytest_works():
|
||||
"""Verify pytest is working"""
|
||||
assert True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_works():
|
||||
"""Verify async tests work"""
|
||||
mock = AsyncMock(return_value=42)
|
||||
result = await mock()
|
||||
assert result == 42
|
||||
|
||||
|
||||
def test_mocks_work():
|
||||
"""Verify mocking works"""
|
||||
mock = Mock()
|
||||
mock.return_value = 'test'
|
||||
assert mock() == 'test'
|
||||
|
||||
|
||||
def test_fixtures_work(mock_app):
|
||||
"""Verify fixtures are loaded"""
|
||||
assert mock_app is not None
|
||||
assert mock_app.logger is not None
|
||||
assert mock_app.sess_mgr is not None
|
||||
|
||||
|
||||
def test_sample_query(sample_query):
|
||||
"""Verify sample query fixture works"""
|
||||
assert sample_query.query_id == 'test-query-id'
|
||||
assert sample_query.launcher_id == 12345
|
||||
@@ -238,9 +238,9 @@ class TestResponseWrapperAssistant:
|
||||
async def test_assistant_empty_content(self):
|
||||
"""Assistant with empty content should not emit event."""
|
||||
wrapper = get_wrapper_module()
|
||||
get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
app.plugin_connector.emit_event = AsyncMock()
|
||||
stage = wrapper.ResponseWrapper(app)
|
||||
|
||||
pipeline_config = make_wrapper_config()
|
||||
@@ -262,8 +262,9 @@ class TestResponseWrapperAssistant:
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
# Should have at least one result (for empty content case)
|
||||
assert len(results) >= 0
|
||||
assert results == []
|
||||
assert query.resp_message_chain == []
|
||||
app.plugin_connector.emit_event.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistant_tool_calls(self):
|
||||
@@ -313,10 +314,10 @@ class TestResponseWrapperAssistant:
|
||||
async for result in stage.process(query, 'ResponseWrapper'):
|
||||
results.append(result)
|
||||
|
||||
# Should have results for content and tool_calls
|
||||
assert len(results) >= 1
|
||||
assert len(results) == 2
|
||||
for result in results:
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert app.plugin_connector.emit_event.await_count == 2
|
||||
|
||||
|
||||
class TestResponseWrapperInterrupt:
|
||||
@@ -472,4 +473,4 @@ class TestResponseWrapperVariables:
|
||||
|
||||
# Check that bound_plugins was passed
|
||||
emit_call = app.plugin_connector.emit_event.call_args
|
||||
assert emit_call[0][1] == ['plugin1', 'plugin2'] # Second argument is bound_plugins
|
||||
assert emit_call[0][1] == ['plugin1', 'plugin2'] # Second argument is bound_plugins
|
||||
|
||||
Reference in New Issue
Block a user