chore: import upstream snapshot with attribution
Test Migrations / Migrations (SQLite) (push) Has been cancelled
Build Dev Image / build-dev-image (push) Has been cancelled
Check i18n Keys / Check i18n Key Consistency (push) Has been cancelled
Lint / Ruff Lint & Format (push) Has been cancelled
Lint / Frontend Lint (push) Has been cancelled
Test Migrations / Migrations (PostgreSQL) (push) Has been cancelled
Test Migrations / Migrations (SQLite) (push) Has been cancelled
Build Dev Image / build-dev-image (push) Has been cancelled
Check i18n Keys / Check i18n Key Consistency (push) Has been cancelled
Lint / Ruff Lint & Format (push) Has been cancelled
Lint / Frontend Lint (push) Has been cancelled
Test Migrations / Migrations (PostgreSQL) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
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 AsyncMock, 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
|
||||
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):
|
||||
"""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
|
||||
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'
|
||||
|
||||
@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 = []
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is query
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_message_chain_does_not_call_strategy(self):
|
||||
"""Empty response chains should be a no-op for long text processing."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
stage.strategy_impl = AsyncMock()
|
||||
|
||||
query = text_query('hello')
|
||||
query.pipeline_config = make_longtext_config(strategy='forward', threshold=1)
|
||||
query.resp_message_chain = []
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is query
|
||||
stage.strategy_impl.process.assert_not_called()
|
||||
|
||||
|
||||
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
|
||||
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):
|
||||
"""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_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
|
||||
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:
|
||||
"""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)
|
||||
Reference in New Issue
Block a user