""" 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)