test: tighten phase 1 coverage contracts

This commit is contained in:
huanghuoguoguo
2026-05-16 10:30:17 +08:00
parent 3ba727f0e4
commit bb55cd7ba9
44 changed files with 708 additions and 1164 deletions
+6 -6
View File
@@ -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
+20 -65
View File
@@ -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)
+7 -10
View File
@@ -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')
+32 -20
View File
@@ -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'
# ---------------------------------------------------------------------------
+6 -6
View File
@@ -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,
+3 -4
View File
@@ -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
-40
View File
@@ -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
+7 -6
View File
@@ -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