262 lines
9.3 KiB
Python
262 lines
9.3 KiB
Python
|
|
"""Unit tests for Signal stream buffer, coalescer, and formatting."""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import time
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.signal.streaming import (
|
||
|
|
StreamBuffer,
|
||
|
|
StreamCoalescer,
|
||
|
|
ReasoningLaneFormatter,
|
||
|
|
)
|
||
|
|
from yuxi.channels.adapters.signal.channel import SignalChannel
|
||
|
|
from yuxi.channels.models import DeliveryResult
|
||
|
|
|
||
|
|
|
||
|
|
class TestStreamBuffer:
|
||
|
|
def test_set_and_get(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
buf.set("chat1", "hello")
|
||
|
|
assert buf.get("chat1") == "hello"
|
||
|
|
|
||
|
|
def test_get_nonexistent_returns_empty(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
assert buf.get("nonexistent") == ""
|
||
|
|
|
||
|
|
def test_pop_returns_and_removes(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
buf.set("chat1", "hello")
|
||
|
|
result = buf.pop("chat1")
|
||
|
|
assert result == "hello"
|
||
|
|
assert buf.get("chat1") == ""
|
||
|
|
|
||
|
|
def test_pop_nonexistent_returns_none(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
assert buf.pop("nonexistent") is None
|
||
|
|
|
||
|
|
def test_clear_removes_all(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
buf.set("chat1", "a")
|
||
|
|
buf.set("chat2", "b")
|
||
|
|
buf.clear()
|
||
|
|
assert buf.get("chat1") == ""
|
||
|
|
assert buf.get("chat2") == ""
|
||
|
|
|
||
|
|
def test_idle_ms_returns_zero_when_empty(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
assert buf.idle_ms("chat1") == 0
|
||
|
|
|
||
|
|
def test_idle_ms_returns_positive_after_delay(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
buf.set("chat1", "hello")
|
||
|
|
time.sleep(0.05)
|
||
|
|
ms = buf.idle_ms("chat1")
|
||
|
|
assert ms > 0
|
||
|
|
|
||
|
|
def test_prune_removes_expired_entries(self):
|
||
|
|
buf = StreamBuffer(ttl=0.01)
|
||
|
|
buf.set("chat1", "hello")
|
||
|
|
time.sleep(0.02)
|
||
|
|
buf.prune()
|
||
|
|
assert buf.get("chat1") == ""
|
||
|
|
|
||
|
|
def test_prune_removes_excess_entries(self):
|
||
|
|
buf = StreamBuffer(max_entries=2)
|
||
|
|
for i in range(5):
|
||
|
|
buf.set(f"chat{i}", f"msg{i}")
|
||
|
|
buf.prune()
|
||
|
|
count = sum(1 for i in range(5) if buf.get(f"chat{i}"))
|
||
|
|
assert count <= 2
|
||
|
|
|
||
|
|
|
||
|
|
class TestStreamCoalescer:
|
||
|
|
def test_should_flush_on_idle_below_threshold(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
buf.set("chat1", "hello")
|
||
|
|
coalescer = StreamCoalescer(buf, idle_ms=1000)
|
||
|
|
assert coalescer.should_flush_on_idle("chat1") is False
|
||
|
|
|
||
|
|
def test_should_flush_on_idle_empty_buffer(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
coalescer = StreamCoalescer(buf, idle_ms=100)
|
||
|
|
assert coalescer.should_flush_on_idle("empty") is False
|
||
|
|
|
||
|
|
def test_should_flush_on_size_below_threshold(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
buf.set("chat1", "short")
|
||
|
|
coalescer = StreamCoalescer(buf, min_chars=1500)
|
||
|
|
assert coalescer.should_flush_on_size("chat1") is False
|
||
|
|
|
||
|
|
def test_should_flush_on_size_above_threshold(self):
|
||
|
|
buf = StreamBuffer()
|
||
|
|
buf.set("chat1", "x" * 1600)
|
||
|
|
coalescer = StreamCoalescer(buf, min_chars=1500)
|
||
|
|
assert coalescer.should_flush_on_size("chat1") is True
|
||
|
|
|
||
|
|
def test_is_reasoning_chunk_true(self):
|
||
|
|
assert StreamCoalescer.is_reasoning_chunk("lane:reasoning:hello") is True
|
||
|
|
|
||
|
|
def test_is_reasoning_chunk_false(self):
|
||
|
|
assert StreamCoalescer.is_reasoning_chunk("normal message") is False
|
||
|
|
|
||
|
|
def test_format_reasoning(self):
|
||
|
|
coalescer = StreamCoalescer(StreamBuffer(), lane_separator="---")
|
||
|
|
result = coalescer.format_reasoning("lane:reasoning:hello world")
|
||
|
|
assert "(thinking)" in result
|
||
|
|
assert "hello world" in result
|
||
|
|
assert "---" in result
|
||
|
|
|
||
|
|
|
||
|
|
class TestReasoningLaneFormatter:
|
||
|
|
def test_format(self):
|
||
|
|
fmt = ReasoningLaneFormatter("~~~")
|
||
|
|
result = fmt.format("lane:reasoning:test")
|
||
|
|
assert "(thinking)" in result
|
||
|
|
assert "test" in result
|
||
|
|
assert "~~~" in result
|
||
|
|
|
||
|
|
def test_is_reasoning_true(self):
|
||
|
|
assert ReasoningLaneFormatter.is_reasoning("lane:reasoning:x") is True
|
||
|
|
|
||
|
|
def test_is_reasoning_false(self):
|
||
|
|
assert ReasoningLaneFormatter.is_reasoning("normal") is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestStreamCoalesceIntegration:
|
||
|
|
def test_coalesce_below_threshold_not_sent(self):
|
||
|
|
channel = SignalChannel({"block_streaming": True})
|
||
|
|
channel._sender = MagicMock()
|
||
|
|
channel._rpc_client = MagicMock()
|
||
|
|
channel._sender.send_text = AsyncMock(return_value=DeliveryResult(success=True, message_id="1"))
|
||
|
|
|
||
|
|
async def _test():
|
||
|
|
result = await channel.send_stream_chunk("+1234", "1", "short", False)
|
||
|
|
assert result.success is True
|
||
|
|
|
||
|
|
asyncio.run(_test())
|
||
|
|
|
||
|
|
def test_coalesce_idle_timeout_sends_buffered(self):
|
||
|
|
channel = SignalChannel(
|
||
|
|
{"block_streaming": True, "block_streaming_coalesce": {"min_chars": 5000, "idle_ms": 0}}
|
||
|
|
)
|
||
|
|
channel._sender = MagicMock()
|
||
|
|
channel._rpc_client = MagicMock()
|
||
|
|
channel._sender.send_text = AsyncMock(return_value=DeliveryResult(success=True, message_id="1"))
|
||
|
|
|
||
|
|
async def _test():
|
||
|
|
channel._stream_buffer.set("+1234", "buffered_content")
|
||
|
|
channel._stream_coalesce_idle_ms = 0
|
||
|
|
result = await channel.send_stream_chunk("+1234", "1", " new part", False)
|
||
|
|
assert result.success is True
|
||
|
|
|
||
|
|
asyncio.run(_test())
|
||
|
|
|
||
|
|
def test_coalesce_finished_sends_all(self):
|
||
|
|
channel = SignalChannel({"block_streaming": True})
|
||
|
|
channel._sender = MagicMock()
|
||
|
|
channel._rpc_client = MagicMock()
|
||
|
|
channel._sender.send_text = AsyncMock(return_value=DeliveryResult(success=True, message_id="final"))
|
||
|
|
|
||
|
|
async def _test():
|
||
|
|
result = await channel.send_stream_chunk("+1234", "1", "final message", True)
|
||
|
|
assert result.success is True
|
||
|
|
|
||
|
|
asyncio.run(_test())
|
||
|
|
|
||
|
|
def test_block_streaming_mode_sends_immediately(self):
|
||
|
|
channel = SignalChannel({"block_streaming": True})
|
||
|
|
channel._sender = MagicMock()
|
||
|
|
channel._rpc_client = MagicMock()
|
||
|
|
channel._sender.send_text = AsyncMock(return_value=DeliveryResult(success=True, message_id="1"))
|
||
|
|
|
||
|
|
async def _test():
|
||
|
|
result = await channel.send_stream_chunk("+1234", None, "immediate", False)
|
||
|
|
assert result.success is True
|
||
|
|
|
||
|
|
asyncio.run(_test())
|
||
|
|
|
||
|
|
def test_progress_streaming_sends_each_chunk(self):
|
||
|
|
channel = SignalChannel({"block_streaming": False})
|
||
|
|
channel._sender = MagicMock()
|
||
|
|
channel._rpc_client = MagicMock()
|
||
|
|
channel._sender.send_text = AsyncMock(return_value=DeliveryResult(success=True, message_id="1"))
|
||
|
|
|
||
|
|
async def _test():
|
||
|
|
result = await channel.send_stream_chunk("+1234", None, "chunk1", False)
|
||
|
|
assert result.success is True
|
||
|
|
|
||
|
|
asyncio.run(_test())
|
||
|
|
|
||
|
|
|
||
|
|
class TestMarkdownFullConversion:
|
||
|
|
def test_bold_italic_strikethrough(self):
|
||
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
||
|
|
|
||
|
|
result = markdown_to_signal_styles("**bold** *italic* ~~strike~~")
|
||
|
|
assert result is not None
|
||
|
|
assert "bold" in result.body
|
||
|
|
assert "italic" in result.body
|
||
|
|
assert "strike" in result.body
|
||
|
|
style_names = {s.style for s in result.styles}
|
||
|
|
assert "BOLD" in style_names
|
||
|
|
assert "ITALIC" in style_names
|
||
|
|
assert "STRIKETHROUGH" in style_names
|
||
|
|
|
||
|
|
def test_code_and_spoiler(self):
|
||
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
||
|
|
|
||
|
|
result = markdown_to_signal_styles("`code` ||spoiler||")
|
||
|
|
assert result is not None
|
||
|
|
assert "code" in result.body
|
||
|
|
assert "spoiler" in result.body
|
||
|
|
style_names = {s.style for s in result.styles}
|
||
|
|
assert "MONOSPACE" in style_names
|
||
|
|
assert "SPOILER" in style_names
|
||
|
|
|
||
|
|
def test_h1_to_bold(self):
|
||
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
||
|
|
|
||
|
|
result = markdown_to_signal_styles("# Heading", heading_style="bold")
|
||
|
|
assert result is not None
|
||
|
|
assert "Heading" in result.body
|
||
|
|
|
||
|
|
def test_blockquote_prefix(self):
|
||
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
||
|
|
|
||
|
|
result = markdown_to_signal_styles("> quote text", blockquote_prefix="| ")
|
||
|
|
assert result is not None
|
||
|
|
assert "| " in result.body or "quote" in result.body
|
||
|
|
|
||
|
|
def test_tables_to_bullets(self):
|
||
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
||
|
|
|
||
|
|
result = markdown_to_signal_styles(
|
||
|
|
"| A | B |\n| --- | --- |\n| 1 | 2 |",
|
||
|
|
table_mode="bullets",
|
||
|
|
)
|
||
|
|
assert result is not None
|
||
|
|
assert "A" in result.body or "B" in result.body
|
||
|
|
|
||
|
|
def test_link_extraction(self):
|
||
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
||
|
|
|
||
|
|
result = markdown_to_signal_styles("[click me](https://example.com)")
|
||
|
|
assert result is not None
|
||
|
|
assert "https://example.com" in result.body or "click me" in result.body
|
||
|
|
|
||
|
|
def test_nested_styles(self):
|
||
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
||
|
|
|
||
|
|
result = markdown_to_signal_styles("**bold *and italic***")
|
||
|
|
assert result is not None
|
||
|
|
assert "bold" in result.body
|
||
|
|
|
||
|
|
def test_overlapping_styles_merge(self):
|
||
|
|
from yuxi.channels.adapters.signal.format import markdown_to_signal_styles
|
||
|
|
|
||
|
|
result = markdown_to_signal_styles("**same** **style**")
|
||
|
|
assert result is not None
|
||
|
|
assert "same" in result.body
|
||
|
|
assert "style" in result.body
|