mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 12:56:02 +00:00
Coverage baseline raised from 13.65% to 26% (+12.35%) Gate raised from 12% to 18% Tasks completed: - COV-001: Command system unit tests (100% coverage) - COV-002: API service unit tests batch 1 (user/apikey/model/provider) - COV-003: Provider model manager unit tests - COV-004: Pipeline remaining stage tests (aggregator/cntfilter/longtext/msgtrun) - COV-005: Storage and utils coverage pass - COV-006: Gate ratchet 12%→15% - COV-007: Gate ratchet 15%→18% - COV-008: API service batch 2 (bot/pipeline/webhook/space/maintenance/mcp) - COV-009: Blocked - API controller circular import issue documented - COV-010: Plugin runtime unit tests (+0.08%) - COV-011: RAG and vector unit tests (+0.68%) - COV-012: Core boot and migration unit tests - COV-013: Provider requester logic unit tests (+0.62%) Key additions: - tests/utils/import_isolation.py: sys.modules isolation for circular imports - Provider requester mock tests: proved HTTP-dependent code can be tested locally - Vector filter utilities: 100% coverage on pure functions - API services: fake persistence pattern for unit testing Blocked issue COV-009 documented in langbot-test-plan/1.5/issues/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
370 lines
12 KiB
Python
370 lines
12 KiB
Python
"""
|
|
Unit tests for LongTextProcessStage (longtext) pipeline stage.
|
|
|
|
Tests cover:
|
|
- Strategy selection (none/image/forward)
|
|
- Threshold boundary handling
|
|
- Plain/non-Plain component handling
|
|
- Strategy initialization and process
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from unittest.mock import Mock
|
|
from importlib import import_module
|
|
|
|
from tests.factories import (
|
|
FakeApp,
|
|
text_query,
|
|
)
|
|
|
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
|
|
|
|
def get_longtext_module():
|
|
"""Lazy import to avoid circular import issues."""
|
|
# Import pipelinemgr first to trigger stage registration
|
|
import_module('langbot.pkg.pipeline.pipelinemgr')
|
|
return import_module('langbot.pkg.pipeline.longtext.longtext')
|
|
|
|
|
|
def get_strategy_module():
|
|
"""Lazy import for strategy base."""
|
|
return import_module('langbot.pkg.pipeline.longtext.strategy')
|
|
|
|
|
|
def get_entities_module():
|
|
"""Lazy import for pipeline entities."""
|
|
return import_module('langbot.pkg.pipeline.entities')
|
|
|
|
|
|
def make_longtext_config(strategy: str = 'none', threshold: int = 1000):
|
|
"""Create a pipeline config for long text processing."""
|
|
return {
|
|
'output': {
|
|
'long-text-processing': {
|
|
'strategy': strategy,
|
|
'threshold': threshold,
|
|
'font-path': '/nonexistent/font.ttf', # For image strategy
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class TestLongTextProcessStageInit:
|
|
"""Tests for LongTextProcessStage initialization."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_none_strategy(self):
|
|
"""Initialize with strategy='none' should set strategy_impl to None."""
|
|
longtext = get_longtext_module()
|
|
|
|
app = FakeApp()
|
|
stage = longtext.LongTextProcessStage(app)
|
|
|
|
pipeline_config = make_longtext_config(strategy='none')
|
|
|
|
await stage.initialize(pipeline_config)
|
|
|
|
assert stage.strategy_impl is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_forward_strategy(self):
|
|
"""Initialize with strategy='forward' should use ForwardComponentStrategy."""
|
|
longtext = get_longtext_module()
|
|
strategy = get_strategy_module()
|
|
|
|
app = FakeApp()
|
|
stage = longtext.LongTextProcessStage(app)
|
|
|
|
pipeline_config = make_longtext_config(strategy='forward')
|
|
|
|
await stage.initialize(pipeline_config)
|
|
|
|
assert stage.strategy_impl is not None
|
|
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_unknown_strategy_raises(self):
|
|
"""Initialize with unknown strategy should raise ValueError."""
|
|
longtext = get_longtext_module()
|
|
strategy = get_strategy_module()
|
|
|
|
# Save original preregistered_strategies
|
|
original_strategies = strategy.preregistered_strategies.copy()
|
|
|
|
try:
|
|
# Clear registered strategies to simulate unknown
|
|
strategy.preregistered_strategies = []
|
|
|
|
app = FakeApp()
|
|
stage = longtext.LongTextProcessStage(app)
|
|
|
|
pipeline_config = make_longtext_config(strategy='unknown')
|
|
|
|
with pytest.raises(ValueError, match='Long message processing strategy not found'):
|
|
await stage.initialize(pipeline_config)
|
|
finally:
|
|
# Restore original strategies
|
|
strategy.preregistered_strategies = original_strategies
|
|
|
|
|
|
class TestLongTextProcessStageProcess:
|
|
"""Tests for LongTextProcessStage process behavior."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_none_strategy_continues(self):
|
|
"""strategy='none' should always continue."""
|
|
longtext = get_longtext_module()
|
|
entities = get_entities_module()
|
|
|
|
app = FakeApp()
|
|
stage = longtext.LongTextProcessStage(app)
|
|
|
|
pipeline_config = make_longtext_config(strategy='none')
|
|
|
|
await stage.initialize(pipeline_config)
|
|
|
|
query = text_query("hello")
|
|
query.pipeline_config = pipeline_config
|
|
query.resp_message_chain = [
|
|
platform_message.MessageChain([platform_message.Plain(text="very long response")])
|
|
]
|
|
|
|
result = await stage.process(query, 'LongTextProcessStage')
|
|
|
|
assert result.result_type == entities.ResultType.CONTINUE
|
|
assert result.new_query is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_short_text_continues_without_transform(self):
|
|
"""Text shorter than threshold should not be transformed."""
|
|
longtext = get_longtext_module()
|
|
entities = get_entities_module()
|
|
|
|
app = FakeApp()
|
|
stage = longtext.LongTextProcessStage(app)
|
|
|
|
# High threshold so text won't trigger transform
|
|
pipeline_config = make_longtext_config(strategy='forward', threshold=10000)
|
|
|
|
await stage.initialize(pipeline_config)
|
|
|
|
query = text_query("hello")
|
|
query.pipeline_config = pipeline_config
|
|
query.resp_message_chain = [
|
|
platform_message.MessageChain([platform_message.Plain(text="short response")])
|
|
]
|
|
|
|
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
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_plain_component_skips(self):
|
|
"""resp_message_chain with non-Plain components should skip processing."""
|
|
longtext = get_longtext_module()
|
|
entities = get_entities_module()
|
|
|
|
app = FakeApp()
|
|
stage = longtext.LongTextProcessStage(app)
|
|
|
|
pipeline_config = make_longtext_config(strategy='forward', threshold=10) # Low threshold
|
|
|
|
await stage.initialize(pipeline_config)
|
|
|
|
query = text_query("hello")
|
|
query.pipeline_config = pipeline_config
|
|
# Non-Plain component (Image)
|
|
query.resp_message_chain = [
|
|
platform_message.MessageChain([
|
|
platform_message.Plain(text="short"),
|
|
platform_message.Image(url="https://example.com/img.png")
|
|
])
|
|
]
|
|
|
|
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
|
|
|
|
|
|
class TestForwardStrategy:
|
|
"""Tests for ForwardComponentStrategy."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forward_strategy_processes(self):
|
|
"""ForwardComponentStrategy should create Forward component."""
|
|
longtext = get_longtext_module()
|
|
get_strategy_module()
|
|
entities = get_entities_module()
|
|
|
|
app = FakeApp()
|
|
stage = longtext.LongTextProcessStage(app)
|
|
|
|
# Low threshold to trigger
|
|
pipeline_config = make_longtext_config(strategy='forward', threshold=10)
|
|
|
|
await stage.initialize(pipeline_config)
|
|
|
|
query = text_query("hello")
|
|
query.pipeline_config = pipeline_config
|
|
# Create a mock adapter with bot_account_id
|
|
mock_adapter = Mock()
|
|
mock_adapter.bot_account_id = '12345'
|
|
query.adapter = mock_adapter
|
|
|
|
# Long text exceeding threshold
|
|
long_text = "This is a very long response that exceeds the threshold"
|
|
query.resp_message_chain = [
|
|
platform_message.MessageChain([platform_message.Plain(text=long_text)])
|
|
]
|
|
|
|
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
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forward_strategy_direct_process(self):
|
|
"""Test ForwardComponentStrategy process method directly."""
|
|
strategy = get_strategy_module()
|
|
|
|
app = FakeApp()
|
|
|
|
# Get ForwardComponentStrategy from preregistered
|
|
for strat_cls in strategy.preregistered_strategies:
|
|
if strat_cls.name == 'forward':
|
|
strat = strat_cls(app)
|
|
break
|
|
else:
|
|
pytest.skip('ForwardComponentStrategy not registered')
|
|
|
|
await strat.initialize()
|
|
|
|
query = text_query("hello")
|
|
query.pipeline_config = make_longtext_config()
|
|
mock_adapter = Mock()
|
|
mock_adapter.bot_account_id = '12345'
|
|
query.adapter = mock_adapter
|
|
|
|
components = await strat.process("test message", query)
|
|
|
|
assert len(components) == 1
|
|
assert isinstance(components[0], platform_message.Forward)
|
|
|
|
|
|
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."""
|
|
longtext = get_longtext_module()
|
|
entities = get_entities_module()
|
|
|
|
app = FakeApp()
|
|
stage = longtext.LongTextProcessStage(app)
|
|
|
|
threshold = 100
|
|
pipeline_config = make_longtext_config(strategy='forward', threshold=threshold)
|
|
|
|
await stage.initialize(pipeline_config)
|
|
|
|
query = text_query("hello")
|
|
query.pipeline_config = pipeline_config
|
|
|
|
# Text below threshold
|
|
short_text = "x" * (threshold - 1)
|
|
query.resp_message_chain = [
|
|
platform_message.MessageChain([platform_message.Plain(text=short_text)])
|
|
]
|
|
|
|
result = await stage.process(query, 'LongTextProcessStage')
|
|
|
|
assert result.result_type == entities.ResultType.CONTINUE
|
|
# Original chain should remain unchanged
|
|
|
|
|
|
class TestLongTextProcessStageImageStrategy:
|
|
"""Tests for image strategy handling (requires PIL/font)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_image_strategy_missing_font_fallback(self):
|
|
"""Missing font should fallback to forward strategy."""
|
|
longtext = get_longtext_module()
|
|
strategy = get_strategy_module()
|
|
|
|
app = FakeApp()
|
|
stage = longtext.LongTextProcessStage(app)
|
|
|
|
# Use non-existent font path
|
|
pipeline_config = make_longtext_config(strategy='image')
|
|
|
|
# On non-Windows without font, should fallback to forward
|
|
await stage.initialize(pipeline_config)
|
|
|
|
# Should have initialized (possibly with fallback strategy)
|
|
if stage.strategy_impl is not None:
|
|
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy) |