Merge pull request #1048 from pipecat-ai/aleix/add-unittest-utils

tests: add some initial run_test() utilities
This commit is contained in:
Aleix Conchillo Flaqué
2025-01-21 17:39:06 -08:00
committed by GitHub
21 changed files with 451 additions and 293 deletions

View File

@@ -1,4 +1,4 @@
name: test name: tests
on: on:
workflow_dispatch: workflow_dispatch:
@@ -49,4 +49,4 @@ jobs:
- name: Test with pytest - name: Test with pytest
run: | run: |
source .venv/bin/activate source .venv/bin/activate
pytest --ignore-glob="*to_be_updated*" --ignore-glob=*pipeline_source* src tests pytest

View File

@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- It is now possible to specify the period of the `PipelineTask` heartbeat
frames with `heartbeats_period_secs`.
### Fixed ### Fixed
- Fixed a type error when using `voice_settings` in `ElevenLabsHttpTTSService`. - Fixed a type error when using `voice_settings` in `ElevenLabsHttpTTSService`.

View File

@@ -2,7 +2,7 @@
 <img alt="pipecat" width="300px" height="auto" src="https://raw.githubusercontent.com/pipecat-ai/pipecat/main/pipecat.png">  <img alt="pipecat" width="300px" height="auto" src="https://raw.githubusercontent.com/pipecat-ai/pipecat/main/pipecat.png">
</div></h1> </div></h1>
[![PyPI](https://img.shields.io/pypi/v/pipecat-ai)](https://pypi.org/project/pipecat-ai) [![Docs](https://img.shields.io/badge/Documentation-blue)](https://docs.pipecat.ai) [![Discord](https://img.shields.io/discord/1239284677165056021)](https://discord.gg/pipecat) <a href="https://app.commanddash.io/agent/github_pipecat-ai_pipecat"><img src="https://img.shields.io/badge/AI-Code%20Agent-EB9FDA"></a> [![PyPI](https://img.shields.io/pypi/v/pipecat-ai)](https://pypi.org/project/pipecat-ai) ![Tests](https://github.com/pipecat-ai/pipecat/actions/workflows/tests.yaml/badge.svg) [![Docs](https://img.shields.io/badge/Documentation-blue)](https://docs.pipecat.ai) [![Discord](https://img.shields.io/discord/1239284677165056021)](https://discord.gg/pipecat) <a href="https://app.commanddash.io/agent/github_pipecat-ai_pipecat"><img src="https://img.shields.io/badge/AI-Code%20Agent-EB9FDA"></a>
Pipecat is an open source Python framework for building voice and multimodal conversational agents. It handles the complex orchestration of AI services, network transport, audio processing, and multimodal interactions, letting you focus on creating engaging experiences. Pipecat is an open source Python framework for building voice and multimodal conversational agents. It handles the complex orchestration of AI services, network transport, audio processing, and multimodal interactions, letting you focus on creating engaging experiences.
@@ -53,12 +53,6 @@ To keep things lightweight, only the core framework is included by default. If y
pip install "pipecat-ai[option,...]" pip install "pipecat-ai[option,...]"
``` ```
Or you can install all of them with:
```shell
pip install "pipecat-ai[all]"
```
Available options include: Available options include:
| Category | Services | Install Command Example | | Category | Services | Install Command Example |
@@ -195,7 +189,7 @@ pip install "path_to_this_repo[option,...]"
From the root directory, run: From the root directory, run:
```shell ```shell
pytest --doctest-modules --ignore-glob="*to_be_updated*" --ignore-glob=*pipeline_source* src tests pytest
``` ```
## Setting up your editor ## Setting up your editor

View File

@@ -4,6 +4,7 @@ pip-tools~=7.4.1
pre-commit~=4.0.1 pre-commit~=4.0.1
pyright~=1.1.392 pyright~=1.1.392
pytest~=8.3.4 pytest~=8.3.4
pytest-asyncio~=0.25.2
ruff~=0.9.1 ruff~=0.9.1
setuptools~=75.8.0 setuptools~=75.8.0
setuptools_scm~=8.1.0 setuptools_scm~=8.1.0

View File

@@ -85,7 +85,13 @@ openrouter = [ "openai~=1.59.6" ]
where = ["src"] where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "--verbose"
testpaths = ["tests"]
pythonpath = ["src"] pythonpath = ["src"]
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:'audioop' is deprecated:DeprecationWarning",
]
[tool.setuptools_scm] [tool.setuptools_scm]
local_scheme = "no-local-version" local_scheme = "no-local-version"

View File

@@ -0,0 +1,56 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
from abc import ABC, abstractmethod
from typing import AsyncIterable, Iterable
from pipecat.frames.frames import Frame
class BaseTask(ABC):
@abstractmethod
def has_finished(self) -> bool:
"""Indicates whether the tasks has finished. That is, all processors
have stopped.
"""
pass
@abstractmethod
async def stop_when_done(self):
"""This is a helper function that sends an EndFrame to the pipeline in
order to stop the task after everything in it has been processed.
"""
pass
@abstractmethod
async def cancel(self):
"""
Stops the running pipeline immediately.
"""
pass
@abstractmethod
async def run(self):
"""
Starts running the given pipeline.
"""
pass
@abstractmethod
async def queue_frame(self, frame: Frame):
"""
Queue a frame to be pushed down the pipeline.
"""
pass
@abstractmethod
async def queue_frames(self, frames: Iterable[Frame] | AsyncIterable[Frame]):
"""
Queues multiple frames to be pushed down the pipeline.
"""
pass

View File

@@ -27,6 +27,7 @@ from pipecat.frames.frames import (
from pipecat.metrics.metrics import ProcessingMetricsData, TTFBMetricsData from pipecat.metrics.metrics import ProcessingMetricsData, TTFBMetricsData
from pipecat.observers.base_observer import BaseObserver from pipecat.observers.base_observer import BaseObserver
from pipecat.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.base_pipeline import BasePipeline
from pipecat.pipeline.base_task import BaseTask
from pipecat.pipeline.task_observer import TaskObserver from pipecat.pipeline.task_observer import TaskObserver
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.utils.utils import obj_count, obj_id from pipecat.utils.utils import obj_count, obj_id
@@ -45,6 +46,7 @@ class PipelineParams(BaseModel):
send_initial_empty_metrics: bool = True send_initial_empty_metrics: bool = True
report_only_initial_ttfb: bool = False report_only_initial_ttfb: bool = False
observers: List[BaseObserver] = [] observers: List[BaseObserver] = []
heartbeats_period_secs: float = HEARTBEAT_SECONDS
class Source(FrameProcessor): class Source(FrameProcessor):
@@ -85,7 +87,7 @@ class Sink(FrameProcessor):
await self._down_queue.put(frame) await self._down_queue.put(frame)
class PipelineTask: class PipelineTask(BaseTask):
def __init__( def __init__(
self, self,
pipeline: BasePipeline, pipeline: BasePipeline,
@@ -121,7 +123,7 @@ class PipelineTask:
self._observer = TaskObserver(params.observers) self._observer = TaskObserver(params.observers)
def has_finished(self): def has_finished(self) -> bool:
"""Indicates whether the tasks has finished. That is, all processors """Indicates whether the tasks has finished. That is, all processors
have stopped. have stopped.
@@ -315,7 +317,7 @@ class PipelineTask:
async def _heartbeat_push_handler(self): async def _heartbeat_push_handler(self):
""" """
This tasks pushes a heartbeat frame every HEARTBEAT_SECONDS. This tasks pushes a heartbeat frame every heartbeat period.
""" """
while True: while True:
try: try:
@@ -323,7 +325,7 @@ class PipelineTask:
# task will just stop waiting for the pipeline to finish not # task will just stop waiting for the pipeline to finish not
# allowing more frames to be pushed. # allowing more frames to be pushed.
await self._source.queue_frame(HeartbeatFrame(timestamp=self._clock.get_time())) await self._source.queue_frame(HeartbeatFrame(timestamp=self._clock.get_time()))
await asyncio.sleep(HEARTBEAT_SECONDS) await asyncio.sleep(self._params.heartbeats_period_secs)
except asyncio.CancelledError: except asyncio.CancelledError:
break break

View File

@@ -11,7 +11,7 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
class FrameFilter(FrameProcessor): class FrameFilter(FrameProcessor):
def __init__(self, types: Tuple[Type[Frame]]): def __init__(self, types: Tuple[Type[Frame], ...]):
super().__init__() super().__init__()
self._types = types self._types = types

View File

@@ -293,8 +293,7 @@ class FrameProcessor:
await self.__input_frame_task await self.__input_frame_task
async def __input_frame_task_handler(self): async def __input_frame_task_handler(self):
running = True while True:
while running:
try: try:
if self.__should_block_frames: if self.__should_block_frames:
logger.trace(f"{self}: frame processing paused") logger.trace(f"{self}: frame processing paused")
@@ -311,8 +310,6 @@ class FrameProcessor:
if callback: if callback:
await callback(self, frame, direction) await callback(self, frame, direction)
running = not isinstance(frame, EndFrame)
self.__input_queue.task_done() self.__input_queue.task_done()
except asyncio.CancelledError: except asyncio.CancelledError:
logger.trace(f"{self}: cancelled input task") logger.trace(f"{self}: cancelled input task")
@@ -330,12 +327,10 @@ class FrameProcessor:
await self.__push_frame_task await self.__push_frame_task
async def __push_frame_task_handler(self): async def __push_frame_task_handler(self):
running = True while True:
while running:
try: try:
(frame, direction) = await self.__push_queue.get() (frame, direction) = await self.__push_queue.get()
await self.__internal_push_frame(frame, direction) await self.__internal_push_frame(frame, direction)
running = not isinstance(frame, EndFrame)
self.__push_queue.task_done() self.__push_queue.task_done()
except asyncio.CancelledError: except asyncio.CancelledError:
logger.trace(f"{self}: cancelled push task") logger.trace(f"{self}: cancelled push task")

View File

@@ -93,11 +93,11 @@ class ProtobufFrameSerializer(FrameSerializer):
id = getattr(args, "id", None) id = getattr(args, "id", None)
name = getattr(args, "name", None) name = getattr(args, "name", None)
pts = getattr(args, "pts", None) pts = getattr(args, "pts", None)
if not id and "id" in args_dict: if "id" in args_dict:
del args_dict["id"] del args_dict["id"]
if not name and "name" in args_dict: if "name" in args_dict:
del args_dict["name"] del args_dict["name"]
if not pts and "pts" in args_dict: if "pts" in args_dict:
del args_dict["pts"] del args_dict["pts"]
# Create the instance # Create the instance
@@ -105,10 +105,10 @@ class ProtobufFrameSerializer(FrameSerializer):
# Set special fields # Set special fields
if id: if id:
setattr(instance, "id", getattr(args, "id", None)) setattr(instance, "id", id)
if name: if name:
setattr(instance, "name", getattr(args, "name", None)) setattr(instance, "name", name)
if pts: if pts:
setattr(instance, "pts", getattr(args, "pts", None)) setattr(instance, "pts", pts)
return instance return instance

0
tests/__init__.py Normal file
View File

View File

@@ -1,122 +1,70 @@
import asyncio #
import doctest # Copyright (c) 2024-2025 Daily
import functools #
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest import unittest
from pipecat.frames.frames import ( from pipecat.frames.frames import (
AudioRawFrame,
EndFrame,
Frame,
ImageRawFrame, ImageRawFrame,
LLMFullResponseEndFrame, LLMFullResponseEndFrame,
LLMFullResponseStartFrame, LLMFullResponseStartFrame,
OutputAudioRawFrame,
OutputImageRawFrame,
TextFrame, TextFrame,
) )
from pipecat.pipeline.parallel_pipeline import ParallelPipeline
from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.gated import GatedAggregator from pipecat.processors.aggregators.gated import GatedAggregator
from pipecat.processors.aggregators.sentence import SentenceAggregator from pipecat.processors.aggregators.sentence import SentenceAggregator
from pipecat.processors.text_transformer import StatelessTextTransformer from tests.utils import run_test
class TestDailyFrameAggregators(unittest.IsolatedAsyncioTestCase): class TestSentenceAggregator(unittest.IsolatedAsyncioTestCase):
@unittest.skip("FIXME: This test is failing")
async def test_sentence_aggregator(self): async def test_sentence_aggregator(self):
sentence = "Hello, world. How are you? I am fine"
expected_sentences = ["Hello, world.", " How are you?", " I am fine "]
aggregator = SentenceAggregator() aggregator = SentenceAggregator()
sentence = "Hello, world. How are you? I am fine!"
frames_to_send = []
for word in sentence.split(" "): for word in sentence.split(" "):
async for sentence in aggregator.process_frame(TextFrame(word + " ")): frames_to_send.append(TextFrame(text=word + " "))
self.assertIsInstance(sentence, TextFrame)
if isinstance(sentence, TextFrame):
self.assertEqual(sentence.text, expected_sentences.pop(0))
async for sentence in aggregator.process_frame(EndFrame()): expected_returned_frames = [TextFrame, TextFrame, TextFrame]
if len(expected_sentences):
self.assertIsInstance(sentence, TextFrame)
if isinstance(sentence, TextFrame):
self.assertEqual(sentence.text, expected_sentences.pop(0))
else:
self.assertIsInstance(sentence, EndFrame)
self.assertEqual(expected_sentences, []) (received_down, _) = await run_test(aggregator, frames_to_send, expected_returned_frames)
assert received_down[-3].text == "Hello, world. "
assert received_down[-2].text == "How are you? "
assert received_down[-1].text == "I am fine! "
@unittest.skip("FIXME: This test is failing")
async def test_gated_accumulator(self): class TestGatedAggregator(unittest.IsolatedAsyncioTestCase):
async def test_gated_aggregator(self):
gated_aggregator = GatedAggregator( gated_aggregator = GatedAggregator(
gate_open_fn=lambda frame: isinstance(frame, ImageRawFrame), gate_open_fn=lambda frame: isinstance(frame, ImageRawFrame),
gate_close_fn=lambda frame: isinstance(frame, LLMFullResponseStartFrame), gate_close_fn=lambda frame: isinstance(frame, LLMFullResponseStartFrame),
start_open=False, start_open=False,
) )
frames = [ frames_to_send = [
LLMFullResponseStartFrame(), LLMFullResponseStartFrame(),
TextFrame("Hello, "), TextFrame("Hello, "),
TextFrame("world."), TextFrame("world."),
AudioRawFrame(b"hello"), OutputAudioRawFrame(audio=b"hello", sample_rate=16000, num_channels=1),
ImageRawFrame(b"image", (0, 0)), OutputImageRawFrame(image=b"image", size=(0, 0), format="RGB"),
AudioRawFrame(b"world"), OutputAudioRawFrame(audio=b"world", sample_rate=16000, num_channels=1),
LLMFullResponseEndFrame(), LLMFullResponseEndFrame(),
] ]
expected_output_frames = [ expected_returned_frames = [
ImageRawFrame(b"image", (0, 0)), OutputImageRawFrame,
LLMFullResponseStartFrame(), LLMFullResponseStartFrame,
TextFrame("Hello, "), TextFrame,
TextFrame("world."), TextFrame,
AudioRawFrame(b"hello"), OutputAudioRawFrame,
AudioRawFrame(b"world"), OutputAudioRawFrame,
LLMFullResponseEndFrame(), LLMFullResponseEndFrame,
] ]
for frame in frames:
async for out_frame in gated_aggregator.process_frame(frame):
self.assertEqual(out_frame, expected_output_frames.pop(0))
self.assertEqual(expected_output_frames, [])
@unittest.skip("FIXME: This test is failing") (received_down, _) = await run_test(
async def test_parallel_pipeline(self): gated_aggregator, frames_to_send, expected_returned_frames
async def slow_add(sleep_time: float, name: str, x: str):
await asyncio.sleep(sleep_time)
return ":".join([x, name])
pipe1_annotation = StatelessTextTransformer(functools.partial(slow_add, 0.1, "pipe1"))
pipe2_annotation = StatelessTextTransformer(functools.partial(slow_add, 0.2, "pipe2"))
sentence_aggregator = SentenceAggregator()
add_dots = StatelessTextTransformer(lambda x: x + ".")
source = asyncio.Queue()
sink = asyncio.Queue()
pipeline = Pipeline(
[
ParallelPipeline([[pipe1_annotation], [sentence_aggregator, pipe2_annotation]]),
add_dots,
],
source,
sink,
) )
frames = [TextFrame("Hello, "), TextFrame("world."), EndFrame()]
expected_output_frames: list[Frame] = [
TextFrame(text="Hello, :pipe1."),
TextFrame(text="world.:pipe1."),
TextFrame(text="Hello, world.:pipe2."),
EndFrame(),
]
for frame in frames:
await source.put(frame)
await pipeline.run_pipeline()
while not sink.empty():
frame = await sink.get()
self.assertEqual(frame, expected_output_frames.pop(0))
def load_tests(loader, tests, ignore):
"""Run doctests on the aggregators module."""
from pipecat.processors import aggregators
tests.addTests(doctest.DocTestSuite(aggregators))
return tests

View File

@@ -1,3 +1,9 @@
#
# Copyright (c) 2024-2025 Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest import unittest

94
tests/test_filters.py Normal file
View File

@@ -0,0 +1,94 @@
#
# Copyright (c) 2024-2025 Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
import unittest
from pipecat.frames.frames import (
EndFrame,
Frame,
TextFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.processors.filters.frame_filter import FrameFilter
from pipecat.processors.filters.function_filter import FunctionFilter
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.processors.filters.wake_check_filter import WakeCheckFilter
from tests.utils import EndTestFrame, run_test
class TestIdentifyFilter(unittest.IsolatedAsyncioTestCase):
async def test_identity(self):
filter = IdentityFilter()
frames_to_send = [UserStartedSpeakingFrame(), UserStoppedSpeakingFrame()]
expected_returned_frames = [UserStartedSpeakingFrame, UserStoppedSpeakingFrame]
await run_test(filter, frames_to_send, expected_returned_frames)
class TestFrameFilter(unittest.IsolatedAsyncioTestCase):
async def test_text_frame(self):
filter = FrameFilter(types=(TextFrame, EndTestFrame))
frames_to_send = [TextFrame(text="Hello Pipecat!")]
expected_returned_frames = [TextFrame]
await run_test(filter, frames_to_send, expected_returned_frames)
async def test_end_frame(self):
filter = FrameFilter(types=(EndFrame, EndTestFrame))
frames_to_send = [EndFrame()]
expected_returned_frames = [EndFrame]
await run_test(filter, frames_to_send, expected_returned_frames)
async def test_system_frame(self):
filter = FrameFilter(types=(EndTestFrame,))
frames_to_send = [UserStartedSpeakingFrame()]
expected_returned_frames = [UserStartedSpeakingFrame]
await run_test(filter, frames_to_send, expected_returned_frames)
class TestFunctionFilter(unittest.IsolatedAsyncioTestCase):
async def test_passthrough(self):
async def passthrough(frame: Frame):
return True
filter = FunctionFilter(filter=passthrough)
frames_to_send = [TextFrame(text="Hello Pipecat!")]
expected_returned_frames = [TextFrame]
await run_test(filter, frames_to_send, expected_returned_frames)
async def test_no_passthrough(self):
async def no_passthrough(frame: Frame):
return False
filter = FunctionFilter(filter=no_passthrough)
frames_to_send = [TextFrame(text="Hello Pipecat!")]
expected_returned_frames = [TextFrame]
try:
await asyncio.wait_for(
run_test(filter, frames_to_send, expected_returned_frames), timeout=0.5
)
assert False
except asyncio.TimeoutError:
pass
class TestWakeCheckFilter(unittest.IsolatedAsyncioTestCase):
async def test_no_wake_word(self):
filter = WakeCheckFilter(wake_phrases=["Hey, Pipecat"])
frames_to_send = [TranscriptionFrame(user_id="test", text="Phrase 1", timestamp="")]
expected_returned_frames = []
await run_test(filter, frames_to_send, expected_returned_frames)
async def test_wake_word(self):
filter = WakeCheckFilter(wake_phrases=["Hey, Pipecat"])
frames_to_send = [
TranscriptionFrame(user_id="test", text="Hey, Pipecat", timestamp=""),
TranscriptionFrame(user_id="test", text="Phrase 1", timestamp=""),
]
expected_returned_frames = [TranscriptionFrame, TranscriptionFrame]
(received_down, _) = await run_test(filter, frames_to_send, expected_returned_frames)
assert received_down[-1].text == "Phrase 1"

View File

@@ -93,7 +93,3 @@ class TestLangchain(unittest.IsolatedAsyncioTestCase):
# This next one would fail with: # This next one would fail with:
# AssertionError: ' H e l l o d e a r h u m a n' != 'Hello dear human' # AssertionError: ' H e l l o d e a r h u m a n' != 'Hello dear human'
# self.assertEqual(tma_out.messages[-1]["content"], self.expected_response) # self.assertEqual(tma_out.messages[-1]["content"], self.expected_response)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,37 +0,0 @@
import asyncio
import unittest
import openai
import pyaudio
from dotenv import load_dotenv
from pipecat.frames.frames import AudioRawFrame, ErrorFrame
from pipecat.services.openai import OpenAITTSService
load_dotenv()
class TestWhisperOpenAIService(unittest.IsolatedAsyncioTestCase):
@unittest.skip("FIXME: This test is failing")
async def test_whisper_tts(self):
pa = pyaudio.PyAudio()
stream = pa.open(format=pyaudio.paInt16, channels=1, rate=24_000, output=True)
tts = OpenAITTSService(voice="nova")
async for frame in tts.run_tts("Hello, there. Nice to meet you, seems to work well"):
self.assertIsInstance(frame, AudioRawFrame)
stream.write(frame.audio)
await asyncio.sleep(0.5)
stream.stop_stream()
pa.terminate()
tts = OpenAITTSService(voice="invalid_voice")
with self.assertRaises(openai.BadRequestError):
async for frame in tts.run_tts("wont work"):
self.assertIsInstance(frame, ErrorFrame)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,116 +1,92 @@
#
# Copyright (c) 2024-2025 Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio import asyncio
import unittest import unittest
from unittest.mock import Mock
from pipecat.frames.frames import EndFrame, TextFrame from pipecat.frames.frames import EndFrame, HeartbeatFrame, TextFrame
from pipecat.pipeline.parallel_pipeline import ParallelPipeline
from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.pipeline import Pipeline
from pipecat.processors.aggregators.sentence import SentenceAggregator from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.filters.identity_filter import IdentityFilter
from pipecat.processors.frame_processor import FrameProcessor from pipecat.processors.frame_processor import FrameProcessor
from pipecat.processors.text_transformer import StatelessTextTransformer from tests.utils import HeartbeatsObserver, run_test
class TestDailyPipeline(unittest.IsolatedAsyncioTestCase): class TestPipeline(unittest.IsolatedAsyncioTestCase):
@unittest.skip("FIXME: This test is failing") async def test_pipeline_single(self):
async def test_pipeline_simple(self): pipeline = Pipeline([IdentityFilter()])
aggregator = SentenceAggregator()
outgoing_queue = asyncio.Queue() frames_to_send = [TextFrame(text="Hello from Pipecat!")]
incoming_queue = asyncio.Queue() expected_returned_frames = [TextFrame]
pipeline = Pipeline([aggregator], incoming_queue, outgoing_queue) await run_test(pipeline, frames_to_send, expected_returned_frames)
await incoming_queue.put(TextFrame("Hello, ")) async def test_pipeline_multiple(self):
await incoming_queue.put(TextFrame("world.")) identity1 = IdentityFilter()
await incoming_queue.put(EndFrame()) identity2 = IdentityFilter()
identity3 = IdentityFilter()
await pipeline.run_pipeline() pipeline = Pipeline([identity1, identity2, identity3])
self.assertEqual(await outgoing_queue.get(), TextFrame("Hello, world.")) frames_to_send = [TextFrame(text="Hello from Pipecat!")]
self.assertIsInstance(await outgoing_queue.get(), EndFrame) expected_returned_frames = [TextFrame]
await run_test(pipeline, frames_to_send, expected_returned_frames)
@unittest.skip("FIXME: This test is failing")
async def test_pipeline_multiple_stages(self):
sentence_aggregator = SentenceAggregator()
to_upper = StatelessTextTransformer(lambda x: x.upper())
add_space = StatelessTextTransformer(lambda x: x + " ")
outgoing_queue = asyncio.Queue() class TestParallelPipeline(unittest.IsolatedAsyncioTestCase):
incoming_queue = asyncio.Queue() async def test_parallel_single(self):
pipeline = Pipeline( pipeline = ParallelPipeline([IdentityFilter()])
[add_space, sentence_aggregator, to_upper], incoming_queue, outgoing_queue
frames_to_send = [TextFrame(text="Hello from Pipecat!")]
expected_returned_frames = [TextFrame]
await run_test(pipeline, frames_to_send, expected_returned_frames)
async def test_parallel_multiple(self):
"""Should only passthrough one instance of TextFrame."""
pipeline = ParallelPipeline([IdentityFilter()], [IdentityFilter()])
frames_to_send = [TextFrame(text="Hello from Pipecat!")]
expected_returned_frames = [TextFrame]
await run_test(pipeline, frames_to_send, expected_returned_frames)
class TestPipelineTask(unittest.IsolatedAsyncioTestCase):
async def test_task_single(self):
pipeline = Pipeline([IdentityFilter()])
task = PipelineTask(pipeline)
await task.queue_frame(TextFrame(text="Hello!"))
await task.queue_frames([TextFrame(text="Bye!"), EndFrame()])
await task.run()
assert task.has_finished()
async def test_task_heartbeats(self):
heartbeats_counter = 0
async def heartbeat_received(processor: FrameProcessor, heartbeat: HeartbeatFrame):
nonlocal heartbeats_counter
heartbeats_counter += 1
identity = IdentityFilter()
pipeline = Pipeline([identity])
heartbeats_observer = HeartbeatsObserver(
target=identity, heartbeat_callback=heartbeat_received
)
task = PipelineTask(
pipeline,
params=PipelineParams(
enable_heartbeats=True, heartbeats_period_secs=0.2, observers=[heartbeats_observer]
),
) )
sentence = "Hello, world. It's me, a pipeline." expected_heartbeats = 1.0 / 0.2
for c in sentence:
await incoming_queue.put(TextFrame(c))
await incoming_queue.put(EndFrame())
await pipeline.run_pipeline() await task.queue_frame(TextFrame(text="Hello!"))
try:
self.assertEqual(await outgoing_queue.get(), TextFrame("H E L L O , W O R L D .")) await asyncio.wait_for(task.run(), timeout=1.0)
self.assertEqual( except asyncio.TimeoutError:
await outgoing_queue.get(), pass
TextFrame(" I T ' S M E , A P I P E L I N E ."), assert heartbeats_counter == expected_heartbeats
)
# leftover little bit because of the spacing
self.assertEqual(
await outgoing_queue.get(),
TextFrame(" "),
)
self.assertIsInstance(await outgoing_queue.get(), EndFrame)
class TestLogFrame(unittest.TestCase):
class MockProcessor(FrameProcessor):
def __init__(self, name):
self.name = name
def __str__(self):
return self.name
def setUp(self):
self.processor1 = self.MockProcessor("processor1")
self.processor2 = self.MockProcessor("processor2")
self.pipeline = Pipeline(processors=[self.processor1, self.processor2])
self.pipeline._name = "MyClass"
self.pipeline._logger = Mock()
@unittest.skip("FIXME: This test is failing")
def test_log_frame_from_source(self):
frame = Mock(__class__=Mock(__name__="MyFrame"))
self.pipeline._log_frame(frame, depth=1)
self.pipeline._logger.debug.assert_called_once_with(
"MyClass source -> MyFrame -> processor1"
)
@unittest.skip("FIXME: This test is failing")
def test_log_frame_to_sink(self):
frame = Mock(__class__=Mock(__name__="MyFrame"))
self.pipeline._log_frame(frame, depth=3)
self.pipeline._logger.debug.assert_called_once_with(
"MyClass processor2 -> MyFrame -> sink"
)
@unittest.skip("FIXME: This test is failing")
def test_log_frame_repeated_log(self):
frame = Mock(__class__=Mock(__name__="MyFrame"))
self.pipeline._log_frame(frame, depth=2)
self.pipeline._logger.debug.assert_called_once_with(
"MyClass processor1 -> MyFrame -> processor2"
)
self.pipeline._log_frame(frame, depth=2)
self.pipeline._logger.debug.assert_called_with("MyClass ... repeated")
@unittest.skip("FIXME: This test is failing")
def test_log_frame_reset_repeated_log(self):
frame1 = Mock(__class__=Mock(__name__="MyFrame1"))
frame2 = Mock(__class__=Mock(__name__="MyFrame2"))
self.pipeline._log_frame(frame1, depth=2)
self.pipeline._logger.debug.assert_called_once_with(
"MyClass processor1 -> MyFrame1 -> processor2"
)
self.pipeline._log_frame(frame1, depth=2)
self.pipeline._logger.debug.assert_called_with("MyClass ... repeated")
self.pipeline._log_frame(frame2, depth=2)
self.pipeline._logger.debug.assert_called_with(
"MyClass processor1 -> MyFrame2 -> processor2"
)

View File

@@ -1,6 +1,16 @@
#
# Copyright (c) 2024-2025 Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest import unittest
from pipecat.frames.frames import AudioRawFrame, TextFrame, TranscriptionFrame from pipecat.frames.frames import (
OutputAudioRawFrame,
TextFrame,
TranscriptionFrame,
)
from pipecat.serializers.protobuf import ProtobufFrameSerializer from pipecat.serializers.protobuf import ProtobufFrameSerializer
@@ -8,22 +18,19 @@ class TestProtobufFrameSerializer(unittest.IsolatedAsyncioTestCase):
def setUp(self): def setUp(self):
self.serializer = ProtobufFrameSerializer() self.serializer = ProtobufFrameSerializer()
@unittest.skip("FIXME: This test is failing")
async def test_roundtrip(self): async def test_roundtrip(self):
text_frame = TextFrame(text="hello world") text_frame = TextFrame(text="hello world")
frame = self.serializer.deserialize(self.serializer.serialize(text_frame)) frame = self.serializer.deserialize(self.serializer.serialize(text_frame))
self.assertEqual(frame, TextFrame(text="hello world")) self.assertEqual(text_frame, frame)
transcription_frame = TranscriptionFrame( transcription_frame = TranscriptionFrame(
text="Hello there!", participantId="123", timestamp="2021-01-01" text="Hello there!", user_id="123", timestamp="2021-01-01"
) )
frame = self.serializer.deserialize(self.serializer.serialize(transcription_frame)) frame = self.serializer.deserialize(self.serializer.serialize(transcription_frame))
self.assertEqual(frame, transcription_frame) self.assertEqual(frame, transcription_frame)
audio_frame = AudioRawFrame(data=b"1234567890") audio_frame = OutputAudioRawFrame(audio=b"1234567890", sample_rate=16000, num_channels=1)
frame = self.serializer.deserialize(self.serializer.serialize(audio_frame)) frame = self.serializer.deserialize(self.serializer.serialize(audio_frame))
self.assertEqual(frame, audio_frame) self.assertEqual(frame.audio, audio_frame.audio)
self.assertEqual(frame.sample_rate, audio_frame.sample_rate)
self.assertEqual(frame.num_channels, audio_frame.num_channels)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,28 +1,15 @@
#
# Copyright (c) 2024-2025 Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import unittest import unittest
from typing import AsyncGenerator
from pipecat.frames.frames import EndFrame, Frame, TextFrame from pipecat.utils.string import match_endofsentence
from pipecat.services.ai_services import AIService, match_endofsentence
class SimpleAIService(AIService): class TestUtilsString(unittest.IsolatedAsyncioTestCase):
async def process_frame(self, frame: Frame) -> AsyncGenerator[Frame, None]:
yield frame
class TestBaseAIService(unittest.IsolatedAsyncioTestCase):
async def test_simple_processing(self):
service = SimpleAIService()
input_frames = [TextFrame("hello"), EndFrame()]
output_frames = []
for input_frame in input_frames:
async for output_frame in service.process_frame(input_frame):
output_frames.append(output_frame)
self.assertEqual(input_frames, output_frames)
async def test_endofsentence(self): async def test_endofsentence(self):
assert match_endofsentence("This is a sentence.") assert match_endofsentence("This is a sentence.")
assert match_endofsentence("This is a sentence! ") assert match_endofsentence("This is a sentence! ")
@@ -51,7 +38,3 @@ class TestBaseAIService(unittest.IsolatedAsyncioTestCase):
for i in chinese_sentences: for i in chinese_sentences:
assert match_endofsentence(i) assert match_endofsentence(i)
assert not match_endofsentence("你好,") assert not match_endofsentence("你好,")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,3 +1,9 @@
#
# Copyright (c) 2024-2025 Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
# import asyncio # import asyncio
# import unittest # import unittest
# from unittest.mock import AsyncMock, patch, Mock # from unittest.mock import AsyncMock, patch, Mock

120
tests/utils.py Normal file
View File

@@ -0,0 +1,120 @@
#
# Copyright (c) 2024-2025 Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import asyncio
from dataclasses import dataclass
from typing import Awaitable, Callable, Sequence, Tuple
from pipecat.clocks.system_clock import SystemClock
from pipecat.frames.frames import (
ControlFrame,
Frame,
HeartbeatFrame,
StartFrame,
)
from pipecat.observers.base_observer import BaseObserver
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
@dataclass
class EndTestFrame(ControlFrame):
pass
class HeartbeatsObserver(BaseObserver):
def __init__(
self,
*,
target: FrameProcessor,
heartbeat_callback: Callable[[FrameProcessor, HeartbeatFrame], Awaitable[None]],
):
self._target = target
self._callback = heartbeat_callback
async def on_push_frame(
self,
src: FrameProcessor,
dst: FrameProcessor,
frame: Frame,
direction: FrameDirection,
timestamp: int,
):
if src == self._target and isinstance(frame, HeartbeatFrame):
await self._callback(self._target, frame)
class QueuedFrameProcessor(FrameProcessor):
def __init__(self, queue: asyncio.Queue, ignore_start: bool = True):
super().__init__()
self._queue = queue
self._ignore_start = ignore_start
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if self._ignore_start and isinstance(frame, StartFrame):
return
await self._queue.put(frame)
async def run_test(
processor: FrameProcessor,
frames_to_send: Sequence[Frame],
expected_down_frames: Sequence[type],
expected_up_frames: Sequence[type] = [],
) -> Tuple[Sequence[Frame], Sequence[Frame]]:
received_up = asyncio.Queue()
received_down = asyncio.Queue()
up_processor = QueuedFrameProcessor(received_up)
down_processor = QueuedFrameProcessor(received_down)
up_processor.link(processor)
processor.link(down_processor)
await processor.queue_frame(StartFrame(clock=SystemClock()))
for frame in frames_to_send:
await processor.process_frame(frame, FrameDirection.DOWNSTREAM)
await processor.queue_frame(EndTestFrame())
await processor.queue_frame(EndTestFrame(), FrameDirection.UPSTREAM)
#
# Down frames
#
received_down_frames: Sequence[Frame] = []
running = True
while running:
frame = await received_down.get()
running = not isinstance(frame, EndTestFrame)
if running:
received_down_frames.append(frame)
print("received DOWN frames =", received_down_frames)
assert len(received_down_frames) == len(expected_down_frames)
for real, expected in zip(received_down_frames, expected_down_frames):
assert isinstance(real, expected)
#
# Up frames
#
received_up_frames: Sequence[Frame] = []
running = True
while running:
frame = await received_up.get()
running = not isinstance(frame, EndTestFrame)
if running:
received_up_frames.append(frame)
print("received UP frames =", received_up_frames)
assert len(received_up_frames) == len(expected_up_frames)
for real, expected in zip(received_up_frames, expected_up_frames):
assert isinstance(real, expected)
return (received_down_frames, received_up_frames)