Spaces:
Running
Running
RemiFabre commited on
Commit ·
599891c
1
Parent(s): a4d79b7
Removed outdated tests
Browse files- tests/audio/test_head_wobbler.py +0 -110
- tests/conftest.py +0 -10
- tests/test_openai_realtime.py +0 -117
- tests/vision/test_processors.py +0 -498
tests/audio/test_head_wobbler.py
DELETED
|
@@ -1,110 +0,0 @@
|
|
| 1 |
-
"""Regression tests for the audio-driven head wobble behaviour."""
|
| 2 |
-
|
| 3 |
-
import math
|
| 4 |
-
import time
|
| 5 |
-
import base64
|
| 6 |
-
import threading
|
| 7 |
-
from typing import Any, List, Tuple
|
| 8 |
-
from collections.abc import Callable
|
| 9 |
-
|
| 10 |
-
import numpy as np
|
| 11 |
-
|
| 12 |
-
from reachy_mini_conversation_app.audio.head_wobbler import HeadWobbler
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def _make_audio_chunk(duration_s: float = 0.3, frequency_hz: float = 220.0) -> str:
|
| 16 |
-
"""Generate a base64-encoded mono PCM16 sine wave."""
|
| 17 |
-
sample_rate = 24000
|
| 18 |
-
sample_count = int(sample_rate * duration_s)
|
| 19 |
-
t = np.linspace(0, duration_s, sample_count, endpoint=False)
|
| 20 |
-
wave = 0.6 * np.sin(2 * math.pi * frequency_hz * t)
|
| 21 |
-
pcm = np.clip(wave * np.iinfo(np.int16).max, -32768, 32767).astype(np.int16)
|
| 22 |
-
return base64.b64encode(pcm.tobytes()).decode("ascii")
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _wait_for(predicate: Callable[[], bool], timeout: float = 0.6) -> bool:
|
| 26 |
-
"""Poll `predicate` until true or timeout."""
|
| 27 |
-
end_time = time.time() + timeout
|
| 28 |
-
while time.time() < end_time:
|
| 29 |
-
if predicate():
|
| 30 |
-
return True
|
| 31 |
-
time.sleep(0.01)
|
| 32 |
-
return False
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def _start_wobbler() -> Tuple[HeadWobbler, List[Tuple[float, Tuple[float, float, float, float, float, float]]]]:
|
| 36 |
-
captured: List[Tuple[float, Tuple[float, float, float, float, float, float]]] = []
|
| 37 |
-
|
| 38 |
-
def capture(offsets: Tuple[float, float, float, float, float, float]) -> None:
|
| 39 |
-
captured.append((time.time(), offsets))
|
| 40 |
-
|
| 41 |
-
wobbler = HeadWobbler(set_speech_offsets=capture)
|
| 42 |
-
wobbler.start()
|
| 43 |
-
return wobbler, captured
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def test_reset_drops_pending_offsets() -> None:
|
| 47 |
-
"""Reset should stop wobble output derived from pre-reset audio."""
|
| 48 |
-
wobbler, captured = _start_wobbler()
|
| 49 |
-
try:
|
| 50 |
-
wobbler.feed(_make_audio_chunk(duration_s=0.35))
|
| 51 |
-
assert _wait_for(lambda: len(captured) > 0), "wobbler did not emit initial offsets"
|
| 52 |
-
|
| 53 |
-
pre_reset_count = len(captured)
|
| 54 |
-
wobbler.reset()
|
| 55 |
-
time.sleep(0.3)
|
| 56 |
-
assert len(captured) == pre_reset_count, "offsets continued after reset without new audio"
|
| 57 |
-
finally:
|
| 58 |
-
wobbler.stop()
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def test_reset_allows_future_offsets() -> None:
|
| 62 |
-
"""After reset, fresh audio must still produce wobble offsets."""
|
| 63 |
-
wobbler, captured = _start_wobbler()
|
| 64 |
-
try:
|
| 65 |
-
wobbler.feed(_make_audio_chunk(duration_s=0.35))
|
| 66 |
-
assert _wait_for(lambda: len(captured) > 0), "wobbler did not emit initial offsets"
|
| 67 |
-
|
| 68 |
-
wobbler.reset()
|
| 69 |
-
pre_second_count = len(captured)
|
| 70 |
-
|
| 71 |
-
wobbler.feed(_make_audio_chunk(duration_s=0.35, frequency_hz=440.0))
|
| 72 |
-
assert _wait_for(lambda: len(captured) > pre_second_count), "no offsets after reset"
|
| 73 |
-
assert wobbler._thread is not None and wobbler._thread.is_alive()
|
| 74 |
-
finally:
|
| 75 |
-
wobbler.stop()
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def test_reset_during_inflight_chunk_keeps_worker(monkeypatch: Any) -> None:
|
| 79 |
-
"""Simulate reset during chunk processing to ensure the worker survives."""
|
| 80 |
-
wobbler, captured = _start_wobbler()
|
| 81 |
-
ready = threading.Event()
|
| 82 |
-
release = threading.Event()
|
| 83 |
-
|
| 84 |
-
original_feed = wobbler.sway.feed
|
| 85 |
-
|
| 86 |
-
def blocking_feed(pcm, sr): # type: ignore[no-untyped-def]
|
| 87 |
-
ready.set()
|
| 88 |
-
release.wait(timeout=2.0)
|
| 89 |
-
return original_feed(pcm, sr)
|
| 90 |
-
|
| 91 |
-
monkeypatch.setattr(wobbler.sway, "feed", blocking_feed)
|
| 92 |
-
|
| 93 |
-
try:
|
| 94 |
-
wobbler.feed(_make_audio_chunk(duration_s=0.35))
|
| 95 |
-
assert ready.wait(timeout=1.0), "worker thread did not dequeue audio"
|
| 96 |
-
|
| 97 |
-
wobbler.reset()
|
| 98 |
-
release.set()
|
| 99 |
-
|
| 100 |
-
# Allow the worker to finish processing the first chunk (which should be discarded)
|
| 101 |
-
time.sleep(0.1)
|
| 102 |
-
|
| 103 |
-
assert wobbler._thread is not None and wobbler._thread.is_alive(), "worker thread died after reset"
|
| 104 |
-
|
| 105 |
-
pre_second = len(captured)
|
| 106 |
-
wobbler.feed(_make_audio_chunk(duration_s=0.35, frequency_hz=440.0))
|
| 107 |
-
assert _wait_for(lambda: len(captured) > pre_second), "no offsets emitted after in-flight reset"
|
| 108 |
-
assert wobbler._thread.is_alive()
|
| 109 |
-
finally:
|
| 110 |
-
wobbler.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/conftest.py
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
"""Pytest configuration for path setup."""
|
| 2 |
-
|
| 3 |
-
import sys
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
-
SRC_PATH = PROJECT_ROOT / "src"
|
| 9 |
-
if str(SRC_PATH) not in sys.path:
|
| 10 |
-
sys.path.insert(0, str(SRC_PATH))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_openai_realtime.py
DELETED
|
@@ -1,117 +0,0 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
-
import logging
|
| 3 |
-
from typing import Any
|
| 4 |
-
from datetime import datetime, timezone
|
| 5 |
-
from unittest.mock import MagicMock
|
| 6 |
-
|
| 7 |
-
import pytest
|
| 8 |
-
|
| 9 |
-
import reachy_mini_conversation_app.openai_realtime as rt_mod
|
| 10 |
-
from reachy_mini_conversation_app.openai_realtime import OpenaiRealtimeHandler
|
| 11 |
-
from reachy_mini_conversation_app.tools.core_tools import ToolDependencies
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
def _build_handler(loop: asyncio.AbstractEventLoop) -> OpenaiRealtimeHandler:
|
| 15 |
-
asyncio.set_event_loop(loop)
|
| 16 |
-
deps = ToolDependencies(reachy_mini=MagicMock(), movement_manager=MagicMock())
|
| 17 |
-
return OpenaiRealtimeHandler(deps)
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def test_format_timestamp_uses_wall_clock() -> None:
|
| 21 |
-
"""Test that format_timestamp uses wall clock time."""
|
| 22 |
-
loop = asyncio.new_event_loop()
|
| 23 |
-
try:
|
| 24 |
-
print("Testing format_timestamp...")
|
| 25 |
-
handler = _build_handler(loop)
|
| 26 |
-
formatted = handler.format_timestamp()
|
| 27 |
-
print(f"Formatted timestamp: {formatted}")
|
| 28 |
-
finally:
|
| 29 |
-
asyncio.set_event_loop(None)
|
| 30 |
-
loop.close()
|
| 31 |
-
|
| 32 |
-
# Extract year from "[YYYY-MM-DD ...]"
|
| 33 |
-
year = int(formatted[1:5])
|
| 34 |
-
assert year == datetime.now(timezone.utc).year
|
| 35 |
-
|
| 36 |
-
@pytest.mark.asyncio
|
| 37 |
-
async def test_start_up_retries_on_abrupt_close(monkeypatch: Any, caplog: Any) -> None:
|
| 38 |
-
"""First connection dies with ConnectionClosedError during iteration -> retried.
|
| 39 |
-
|
| 40 |
-
Second connection iterates cleanly (no events) -> start_up returns without raising.
|
| 41 |
-
Ensures handler clears self.connection at the end.
|
| 42 |
-
"""
|
| 43 |
-
caplog.set_level(logging.WARNING)
|
| 44 |
-
|
| 45 |
-
# Use a local Exception as the module's ConnectionClosedError to avoid ws dependency
|
| 46 |
-
FakeCCE = type("FakeCCE", (Exception,), {})
|
| 47 |
-
monkeypatch.setattr(rt_mod, "ConnectionClosedError", FakeCCE)
|
| 48 |
-
|
| 49 |
-
# Make asyncio.sleep return immediately (for backoff)
|
| 50 |
-
async def _fast_sleep(*_a: Any, **_kw: Any) -> None: return None
|
| 51 |
-
monkeypatch.setattr(asyncio, "sleep", _fast_sleep, raising=False)
|
| 52 |
-
|
| 53 |
-
attempt_counter = {"n": 0}
|
| 54 |
-
|
| 55 |
-
class FakeConn:
|
| 56 |
-
"""Minimal realtime connection stub."""
|
| 57 |
-
|
| 58 |
-
def __init__(self, mode: str):
|
| 59 |
-
self._mode = mode
|
| 60 |
-
|
| 61 |
-
class _Session:
|
| 62 |
-
async def update(self, **_kw: Any) -> None: return None
|
| 63 |
-
self.session = _Session()
|
| 64 |
-
|
| 65 |
-
class _InputAudioBuffer:
|
| 66 |
-
async def append(self, **_kw: Any) -> None: return None
|
| 67 |
-
self.input_audio_buffer = _InputAudioBuffer()
|
| 68 |
-
|
| 69 |
-
class _Item:
|
| 70 |
-
async def create(self, **_kw: Any) -> None: return None
|
| 71 |
-
|
| 72 |
-
class _Conversation:
|
| 73 |
-
item = _Item()
|
| 74 |
-
self.conversation = _Conversation()
|
| 75 |
-
|
| 76 |
-
class _Response:
|
| 77 |
-
async def create(self, **_kw: Any) -> None: return None
|
| 78 |
-
async def cancel(self, **_kw: Any) -> None: return None
|
| 79 |
-
self.response = _Response()
|
| 80 |
-
|
| 81 |
-
async def __aenter__(self) -> "FakeConn": return self
|
| 82 |
-
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: return False
|
| 83 |
-
async def close(self) -> None: return None
|
| 84 |
-
|
| 85 |
-
# Async iterator protocol
|
| 86 |
-
def __aiter__(self) -> "FakeConn": return self
|
| 87 |
-
async def __anext__(self) -> None:
|
| 88 |
-
if self._mode == "raise_on_iter":
|
| 89 |
-
raise FakeCCE("abrupt close (simulated)")
|
| 90 |
-
raise StopAsyncIteration # clean exit (no events)
|
| 91 |
-
|
| 92 |
-
class FakeRealtime:
|
| 93 |
-
def connect(self, **_kw: Any) -> FakeConn:
|
| 94 |
-
attempt_counter["n"] += 1
|
| 95 |
-
mode = "raise_on_iter" if attempt_counter["n"] == 1 else "clean"
|
| 96 |
-
return FakeConn(mode)
|
| 97 |
-
|
| 98 |
-
class FakeClient:
|
| 99 |
-
def __init__(self, **_kw: Any) -> None: self.realtime = FakeRealtime()
|
| 100 |
-
|
| 101 |
-
# Patch the OpenAI client used by the handler
|
| 102 |
-
monkeypatch.setattr(rt_mod, "AsyncOpenAI", FakeClient)
|
| 103 |
-
|
| 104 |
-
# Build handler with minimal deps
|
| 105 |
-
deps = ToolDependencies(reachy_mini=MagicMock(), movement_manager=MagicMock())
|
| 106 |
-
handler = rt_mod.OpenaiRealtimeHandler(deps)
|
| 107 |
-
|
| 108 |
-
# Run: should retry once and exit cleanly
|
| 109 |
-
await handler.start_up()
|
| 110 |
-
|
| 111 |
-
# Validate: two attempts total (fail -> retry -> succeed), and connection cleared
|
| 112 |
-
assert attempt_counter["n"] == 2
|
| 113 |
-
assert handler.connection is None
|
| 114 |
-
|
| 115 |
-
# Optional: confirm we logged the unexpected close once
|
| 116 |
-
warnings = [r for r in caplog.records if r.levelname == "WARNING" and "closed unexpectedly" in r.msg]
|
| 117 |
-
assert len(warnings) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/vision/test_processors.py
DELETED
|
@@ -1,498 +0,0 @@
|
|
| 1 |
-
"""Tests for the vision processing module."""
|
| 2 |
-
|
| 3 |
-
import time
|
| 4 |
-
from typing import Any
|
| 5 |
-
from unittest.mock import Mock, MagicMock, patch
|
| 6 |
-
|
| 7 |
-
import numpy as np
|
| 8 |
-
import pytest
|
| 9 |
-
|
| 10 |
-
from reachy_mini_conversation_app.vision.processors import (
|
| 11 |
-
VisionConfig,
|
| 12 |
-
VisionManager,
|
| 13 |
-
VisionProcessor,
|
| 14 |
-
initialize_vision_manager,
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def test_vision_config_defaults() -> None:
|
| 19 |
-
"""Test VisionConfig has sensible defaults."""
|
| 20 |
-
config = VisionConfig()
|
| 21 |
-
assert config.vision_interval == 5.0
|
| 22 |
-
assert config.max_new_tokens == 64
|
| 23 |
-
assert config.jpeg_quality == 85
|
| 24 |
-
assert config.max_retries == 3
|
| 25 |
-
assert config.retry_delay == 1.0
|
| 26 |
-
assert config.device_preference == "auto"
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def test_vision_config_custom_values() -> None:
|
| 30 |
-
"""Test VisionConfig accepts custom values."""
|
| 31 |
-
config = VisionConfig(
|
| 32 |
-
model_path="/custom/path",
|
| 33 |
-
vision_interval=10.0,
|
| 34 |
-
max_new_tokens=128,
|
| 35 |
-
jpeg_quality=95,
|
| 36 |
-
max_retries=5,
|
| 37 |
-
retry_delay=2.0,
|
| 38 |
-
device_preference="cpu",
|
| 39 |
-
)
|
| 40 |
-
assert config.model_path == "/custom/path"
|
| 41 |
-
assert config.vision_interval == 10.0
|
| 42 |
-
assert config.max_new_tokens == 128
|
| 43 |
-
assert config.jpeg_quality == 95
|
| 44 |
-
assert config.max_retries == 5
|
| 45 |
-
assert config.retry_delay == 2.0
|
| 46 |
-
assert config.device_preference == "cpu"
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
@pytest.fixture
|
| 51 |
-
def mock_torch() -> Any:
|
| 52 |
-
"""Mock torch module to avoid loading actual models."""
|
| 53 |
-
with patch("reachy_mini_conversation_app.vision.processors.torch") as mock:
|
| 54 |
-
mock.cuda.is_available.return_value = False
|
| 55 |
-
mock.backends.mps.is_available.return_value = False
|
| 56 |
-
mock.float32 = "float32"
|
| 57 |
-
mock.bfloat16 = "bfloat16"
|
| 58 |
-
yield mock
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
@pytest.fixture
|
| 62 |
-
def mock_transformers() -> Any:
|
| 63 |
-
"""Mock transformers module."""
|
| 64 |
-
with patch("reachy_mini_conversation_app.vision.processors.AutoProcessor") as proc, \
|
| 65 |
-
patch("reachy_mini_conversation_app.vision.processors.AutoModelForImageTextToText") as model:
|
| 66 |
-
|
| 67 |
-
# Mock processor
|
| 68 |
-
mock_processor = MagicMock()
|
| 69 |
-
mock_processor.apply_chat_template.return_value = {
|
| 70 |
-
"input_ids": MagicMock(to=lambda x: MagicMock()),
|
| 71 |
-
"attention_mask": MagicMock(to=lambda x: MagicMock()),
|
| 72 |
-
"pixel_values": MagicMock(to=lambda x: MagicMock()),
|
| 73 |
-
}
|
| 74 |
-
mock_processor.batch_decode.return_value = ["assistant\nThis is a test description."]
|
| 75 |
-
mock_processor.tokenizer.eos_token_id = 2
|
| 76 |
-
proc.from_pretrained.return_value = mock_processor
|
| 77 |
-
|
| 78 |
-
# Mock model
|
| 79 |
-
mock_model_instance = MagicMock()
|
| 80 |
-
mock_model_instance.eval.return_value = None
|
| 81 |
-
mock_model_instance.generate.return_value = [[1, 2, 3]]
|
| 82 |
-
mock_model_instance.to.return_value = mock_model_instance
|
| 83 |
-
model.from_pretrained.return_value = mock_model_instance
|
| 84 |
-
|
| 85 |
-
yield {"processor": proc, "model": model}
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
def test_vision_processor_device_selection_cpu(mock_torch: Any) -> None:
|
| 89 |
-
"""Test VisionProcessor selects CPU when specified."""
|
| 90 |
-
config = VisionConfig(device_preference="cpu")
|
| 91 |
-
processor = VisionProcessor(config)
|
| 92 |
-
assert processor.device == "cpu"
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def test_vision_processor_device_selection_cuda_unavailable(mock_torch: Any) -> None:
|
| 96 |
-
"""Test VisionProcessor falls back to CPU when CUDA unavailable."""
|
| 97 |
-
mock_torch.cuda.is_available.return_value = False
|
| 98 |
-
config = VisionConfig(device_preference="cuda")
|
| 99 |
-
processor = VisionProcessor(config)
|
| 100 |
-
assert processor.device == "cpu"
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def test_vision_processor_device_selection_cuda_available(mock_torch: Any) -> None:
|
| 104 |
-
"""Test VisionProcessor selects CUDA when available."""
|
| 105 |
-
mock_torch.cuda.is_available.return_value = True
|
| 106 |
-
config = VisionConfig(device_preference="cuda")
|
| 107 |
-
processor = VisionProcessor(config)
|
| 108 |
-
assert processor.device == "cuda"
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def test_vision_processor_device_selection_mps_available(mock_torch: Any) -> None:
|
| 112 |
-
"""Test VisionProcessor selects MPS when available on Apple Silicon."""
|
| 113 |
-
mock_torch.backends.mps.is_available.return_value = True
|
| 114 |
-
config = VisionConfig(device_preference="mps")
|
| 115 |
-
processor = VisionProcessor(config)
|
| 116 |
-
assert processor.device == "mps"
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def test_vision_processor_device_selection_auto_prefers_mps(mock_torch: Any) -> None:
|
| 120 |
-
"""Test VisionProcessor auto mode prefers MPS on Apple Silicon."""
|
| 121 |
-
mock_torch.backends.mps.is_available.return_value = True
|
| 122 |
-
mock_torch.cuda.is_available.return_value = False
|
| 123 |
-
config = VisionConfig(device_preference="auto")
|
| 124 |
-
processor = VisionProcessor(config)
|
| 125 |
-
assert processor.device == "mps"
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
def test_vision_processor_device_selection_auto_prefers_cuda_over_cpu(mock_torch: Any) -> None:
|
| 129 |
-
"""Test VisionProcessor auto mode prefers CUDA over CPU."""
|
| 130 |
-
mock_torch.backends.mps.is_available.return_value = False
|
| 131 |
-
mock_torch.cuda.is_available.return_value = True
|
| 132 |
-
config = VisionConfig(device_preference="auto")
|
| 133 |
-
processor = VisionProcessor(config)
|
| 134 |
-
assert processor.device == "cuda"
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def test_vision_processor_initialization(mock_torch: Any, mock_transformers: Any) -> None:
|
| 138 |
-
"""Test VisionProcessor initializes successfully."""
|
| 139 |
-
config = VisionConfig(model_path="test/model")
|
| 140 |
-
processor = VisionProcessor(config)
|
| 141 |
-
|
| 142 |
-
assert not processor._initialized
|
| 143 |
-
result = processor.initialize()
|
| 144 |
-
|
| 145 |
-
assert result is True
|
| 146 |
-
assert processor._initialized
|
| 147 |
-
mock_transformers["processor"].from_pretrained.assert_called_once_with("test/model")
|
| 148 |
-
mock_transformers["model"].from_pretrained.assert_called_once()
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
def test_vision_processor_initialization_failure(mock_torch: Any) -> None:
|
| 152 |
-
"""Test VisionProcessor handles initialization failure gracefully."""
|
| 153 |
-
with patch("reachy_mini_conversation_app.vision.processors.AutoProcessor") as mock_proc:
|
| 154 |
-
mock_proc.from_pretrained.side_effect = Exception("Model not found")
|
| 155 |
-
|
| 156 |
-
config = VisionConfig(model_path="invalid/model")
|
| 157 |
-
processor = VisionProcessor(config)
|
| 158 |
-
result = processor.initialize()
|
| 159 |
-
|
| 160 |
-
assert result is False
|
| 161 |
-
assert not processor._initialized
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
def test_vision_processor_process_image_not_initialized(mock_torch: Any) -> None:
|
| 165 |
-
"""Test process_image returns error when model not initialized."""
|
| 166 |
-
processor = VisionProcessor()
|
| 167 |
-
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 168 |
-
|
| 169 |
-
result = processor.process_image(test_image)
|
| 170 |
-
assert result == "Vision model not initialized"
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def test_vision_processor_process_image_success(mock_torch: Any, mock_transformers: Any) -> None:
|
| 174 |
-
"""Test process_image processes an image successfully."""
|
| 175 |
-
with patch("reachy_mini_conversation_app.vision.processors.cv2") as mock_cv2:
|
| 176 |
-
# Mock cv2.imencode to return success
|
| 177 |
-
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
|
| 178 |
-
mock_cv2.IMWRITE_JPEG_QUALITY = 1
|
| 179 |
-
|
| 180 |
-
processor = VisionProcessor()
|
| 181 |
-
processor.initialize()
|
| 182 |
-
|
| 183 |
-
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 184 |
-
result = processor.process_image(test_image, "Describe this image.")
|
| 185 |
-
|
| 186 |
-
assert isinstance(result, str)
|
| 187 |
-
assert result == "This is a test description."
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
def test_vision_processor_process_image_encode_failure(mock_torch: Any, mock_transformers: Any) -> None:
|
| 191 |
-
"""Test process_image handles image encoding failure."""
|
| 192 |
-
with patch("reachy_mini_conversation_app.vision.processors.cv2") as mock_cv2:
|
| 193 |
-
mock_cv2.imencode.return_value = (False, None)
|
| 194 |
-
mock_cv2.IMWRITE_JPEG_QUALITY = 1
|
| 195 |
-
|
| 196 |
-
processor = VisionProcessor()
|
| 197 |
-
processor.initialize()
|
| 198 |
-
|
| 199 |
-
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 200 |
-
result = processor.process_image(test_image)
|
| 201 |
-
|
| 202 |
-
assert result == "Failed to encode image"
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
def test_vision_processor_process_image_with_retry(mock_torch: Any, mock_transformers: Any) -> None:
|
| 206 |
-
"""Test process_image retries on failure."""
|
| 207 |
-
with patch("reachy_mini_conversation_app.vision.processors.cv2") as mock_cv2:
|
| 208 |
-
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
|
| 209 |
-
mock_cv2.IMWRITE_JPEG_QUALITY = 1
|
| 210 |
-
|
| 211 |
-
# Set up the OutOfMemoryError to be a proper exception
|
| 212 |
-
mock_torch.cuda.OutOfMemoryError = type("OutOfMemoryError", (Exception,), {})
|
| 213 |
-
|
| 214 |
-
processor = VisionProcessor(VisionConfig(max_retries=3, retry_delay=0.01))
|
| 215 |
-
processor.initialize()
|
| 216 |
-
|
| 217 |
-
# Make the model generate fail twice, then succeed
|
| 218 |
-
call_count = [0]
|
| 219 |
-
assert processor.model is not None
|
| 220 |
-
original_generate = processor.model.generate
|
| 221 |
-
|
| 222 |
-
def failing_generate(*args: Any, **kwargs: Any) -> Any:
|
| 223 |
-
call_count[0] += 1
|
| 224 |
-
if call_count[0] < 3:
|
| 225 |
-
raise Exception("Temporary failure")
|
| 226 |
-
return original_generate(*args, **kwargs)
|
| 227 |
-
|
| 228 |
-
processor.model.generate = failing_generate
|
| 229 |
-
|
| 230 |
-
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 231 |
-
result = processor.process_image(test_image)
|
| 232 |
-
|
| 233 |
-
assert isinstance(result, str)
|
| 234 |
-
assert call_count[0] == 3
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
def test_vision_processor_extract_response_variants() -> None:
|
| 238 |
-
"""Test _extract_response handles different response formats."""
|
| 239 |
-
processor = VisionProcessor()
|
| 240 |
-
|
| 241 |
-
# Test with "assistant\n" marker
|
| 242 |
-
result = processor._extract_response("user prompt\nassistant\nThe response text")
|
| 243 |
-
assert result == "The response text"
|
| 244 |
-
|
| 245 |
-
# Test with "Assistant:" marker
|
| 246 |
-
result = processor._extract_response("User: prompt\nAssistant: Another response")
|
| 247 |
-
assert result == "Another response"
|
| 248 |
-
|
| 249 |
-
# Test fallback to full text
|
| 250 |
-
result = processor._extract_response("Just some text without markers")
|
| 251 |
-
assert result == "Just some text without markers"
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
def test_vision_processor_get_model_info(mock_torch: Any, mock_transformers: Any) -> None:
|
| 255 |
-
"""Test get_model_info returns correct information."""
|
| 256 |
-
mock_torch.cuda.is_available.return_value = True
|
| 257 |
-
mock_torch.cuda.get_device_properties.return_value.total_memory = 8 * 1024**3
|
| 258 |
-
|
| 259 |
-
processor = VisionProcessor(VisionConfig(model_path="test/model", device_preference="cpu"))
|
| 260 |
-
processor.initialize()
|
| 261 |
-
|
| 262 |
-
info = processor.get_model_info()
|
| 263 |
-
|
| 264 |
-
assert info["initialized"] is True
|
| 265 |
-
assert info["device"] == "cpu"
|
| 266 |
-
assert info["model_path"] == "test/model"
|
| 267 |
-
assert "cuda_available" in info
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
@pytest.fixture
|
| 271 |
-
def mock_camera() -> Mock:
|
| 272 |
-
"""Create a mock camera object."""
|
| 273 |
-
camera = Mock()
|
| 274 |
-
camera.get_latest_frame.return_value = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 275 |
-
return camera
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
def test_vision_manager_initialization(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
|
| 279 |
-
"""Test VisionManager initializes successfully."""
|
| 280 |
-
config = VisionConfig(vision_interval=2.0)
|
| 281 |
-
manager = VisionManager(mock_camera, config)
|
| 282 |
-
|
| 283 |
-
assert manager.vision_interval == 2.0
|
| 284 |
-
assert manager.processor._initialized
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
def test_vision_manager_initialization_failure(mock_torch: Any, mock_camera: Mock) -> None:
|
| 288 |
-
"""Test VisionManager raises error when processor initialization fails."""
|
| 289 |
-
with patch("reachy_mini_conversation_app.vision.processors.AutoProcessor") as mock_proc:
|
| 290 |
-
mock_proc.from_pretrained.side_effect = Exception("Model not found")
|
| 291 |
-
|
| 292 |
-
with pytest.raises(RuntimeError, match="Vision processor initialization failed"):
|
| 293 |
-
VisionManager(mock_camera, VisionConfig())
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
def test_vision_manager_start_stop(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
|
| 297 |
-
"""Test VisionManager can start and stop."""
|
| 298 |
-
manager = VisionManager(mock_camera, VisionConfig())
|
| 299 |
-
|
| 300 |
-
manager.start()
|
| 301 |
-
assert manager._thread is not None
|
| 302 |
-
assert manager._thread.is_alive()
|
| 303 |
-
assert not manager._stop_event.is_set()
|
| 304 |
-
|
| 305 |
-
time.sleep(0.1) # Let thread run briefly
|
| 306 |
-
|
| 307 |
-
manager.stop()
|
| 308 |
-
assert manager._stop_event.is_set()
|
| 309 |
-
assert not manager._thread.is_alive()
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
def test_vision_manager_processes_frames(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
|
| 313 |
-
"""Test VisionManager processes frames at intervals."""
|
| 314 |
-
with patch("reachy_mini_conversation_app.vision.processors.cv2") as mock_cv2:
|
| 315 |
-
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
|
| 316 |
-
mock_cv2.IMWRITE_JPEG_QUALITY = 1
|
| 317 |
-
|
| 318 |
-
config = VisionConfig(vision_interval=0.1) # Fast interval for testing
|
| 319 |
-
manager = VisionManager(mock_camera, config)
|
| 320 |
-
|
| 321 |
-
manager.start()
|
| 322 |
-
time.sleep(0.3) # Wait for at least 2 processing cycles
|
| 323 |
-
manager.stop()
|
| 324 |
-
|
| 325 |
-
# Camera should have been called at least once
|
| 326 |
-
assert mock_camera.get_latest_frame.call_count >= 1
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
def test_vision_manager_handles_none_frame(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
|
| 330 |
-
"""Test VisionManager handles None frame gracefully."""
|
| 331 |
-
mock_camera.get_latest_frame.return_value = None
|
| 332 |
-
|
| 333 |
-
config = VisionConfig(vision_interval=0.1)
|
| 334 |
-
manager = VisionManager(mock_camera, config)
|
| 335 |
-
|
| 336 |
-
manager.start()
|
| 337 |
-
time.sleep(0.2)
|
| 338 |
-
manager.stop()
|
| 339 |
-
|
| 340 |
-
# Verify camera was called but no crashes occurred
|
| 341 |
-
assert mock_camera.get_latest_frame.called
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
def test_vision_manager_handles_processing_error(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
|
| 345 |
-
"""Test VisionManager handles processing errors gracefully."""
|
| 346 |
-
with patch("reachy_mini_conversation_app.vision.processors.cv2") as mock_cv2:
|
| 347 |
-
mock_cv2.imencode.side_effect = Exception("Processing error")
|
| 348 |
-
mock_cv2.IMWRITE_JPEG_QUALITY = 1
|
| 349 |
-
|
| 350 |
-
config = VisionConfig(vision_interval=0.1)
|
| 351 |
-
manager = VisionManager(mock_camera, config)
|
| 352 |
-
|
| 353 |
-
manager.start()
|
| 354 |
-
time.sleep(0.2)
|
| 355 |
-
manager.stop()
|
| 356 |
-
|
| 357 |
-
# Verify thread stopped gracefully despite errors
|
| 358 |
-
assert manager._stop_event.is_set()
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
def test_vision_manager_get_status(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
|
| 362 |
-
"""Test VisionManager get_status returns correct information."""
|
| 363 |
-
manager = VisionManager(mock_camera, VisionConfig(vision_interval=5.0))
|
| 364 |
-
|
| 365 |
-
status = manager.get_status()
|
| 366 |
-
|
| 367 |
-
assert "last_processed" in status
|
| 368 |
-
assert "processor_info" in status
|
| 369 |
-
assert "config" in status
|
| 370 |
-
assert status["config"]["interval"] == 5.0
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
def test_vision_manager_skips_invalid_responses(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
|
| 374 |
-
"""Test VisionManager doesn't update timestamp for invalid responses."""
|
| 375 |
-
with patch("reachy_mini_conversation_app.vision.processors.cv2") as mock_cv2:
|
| 376 |
-
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
|
| 377 |
-
mock_cv2.IMWRITE_JPEG_QUALITY = 1
|
| 378 |
-
|
| 379 |
-
# Make processor return invalid response
|
| 380 |
-
config = VisionConfig(vision_interval=0.1)
|
| 381 |
-
manager = VisionManager(mock_camera, config)
|
| 382 |
-
|
| 383 |
-
# Mock the processor's process_image method to return invalid response
|
| 384 |
-
with patch.object(manager.processor, 'process_image', return_value="Vision model not initialized"):
|
| 385 |
-
initial_time = manager._last_processed_time
|
| 386 |
-
|
| 387 |
-
manager.start()
|
| 388 |
-
time.sleep(0.2)
|
| 389 |
-
manager.stop()
|
| 390 |
-
|
| 391 |
-
# Last processed time should not have been updated
|
| 392 |
-
assert manager._last_processed_time == initial_time
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
def test_initialize_vision_manager_success(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
|
| 396 |
-
"""Test initialize_vision_manager creates VisionManager successfully."""
|
| 397 |
-
with patch("reachy_mini_conversation_app.vision.processors.snapshot_download") as mock_download, \
|
| 398 |
-
patch("reachy_mini_conversation_app.vision.processors.os.makedirs"), \
|
| 399 |
-
patch("reachy_mini_conversation_app.vision.processors.config") as mock_config:
|
| 400 |
-
|
| 401 |
-
mock_config.LOCAL_VISION_MODEL = "test/model"
|
| 402 |
-
mock_config.HF_HOME = "/tmp/hf_cache"
|
| 403 |
-
|
| 404 |
-
result = initialize_vision_manager(mock_camera)
|
| 405 |
-
|
| 406 |
-
assert result is not None
|
| 407 |
-
assert isinstance(result, VisionManager)
|
| 408 |
-
mock_download.assert_called_once()
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
def test_initialize_vision_manager_download_failure(mock_torch: Any, mock_camera: Mock) -> None:
|
| 412 |
-
"""Test initialize_vision_manager handles download failure."""
|
| 413 |
-
with patch("reachy_mini_conversation_app.vision.processors.snapshot_download") as mock_download, \
|
| 414 |
-
patch("reachy_mini_conversation_app.vision.processors.os.makedirs"), \
|
| 415 |
-
patch("reachy_mini_conversation_app.vision.processors.config") as mock_config:
|
| 416 |
-
|
| 417 |
-
mock_config.LOCAL_VISION_MODEL = "test/model"
|
| 418 |
-
mock_config.HF_HOME = "/tmp/hf_cache"
|
| 419 |
-
mock_download.side_effect = Exception("Network error")
|
| 420 |
-
|
| 421 |
-
result = initialize_vision_manager(mock_camera)
|
| 422 |
-
|
| 423 |
-
assert result is None
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
def test_initialize_vision_manager_processor_failure(mock_torch: Any, mock_camera: Mock) -> None:
|
| 427 |
-
"""Test initialize_vision_manager handles processor initialization failure."""
|
| 428 |
-
with patch("reachy_mini_conversation_app.vision.processors.snapshot_download"), \
|
| 429 |
-
patch("reachy_mini_conversation_app.vision.processors.os.makedirs"), \
|
| 430 |
-
patch("reachy_mini_conversation_app.vision.processors.config") as mock_config, \
|
| 431 |
-
patch("reachy_mini_conversation_app.vision.processors.AutoProcessor") as mock_proc:
|
| 432 |
-
|
| 433 |
-
mock_config.LOCAL_VISION_MODEL = "test/model"
|
| 434 |
-
mock_config.HF_HOME = "/tmp/hf_cache"
|
| 435 |
-
mock_proc.from_pretrained.side_effect = Exception("Model load error")
|
| 436 |
-
|
| 437 |
-
result = initialize_vision_manager(mock_camera)
|
| 438 |
-
|
| 439 |
-
assert result is None
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
def test_vision_processor_cuda_oom_recovery(mock_torch: Any, mock_transformers: Any) -> None:
|
| 443 |
-
"""Test VisionProcessor recovers from CUDA OOM errors."""
|
| 444 |
-
with patch("reachy_mini_conversation_app.vision.processors.cv2") as mock_cv2:
|
| 445 |
-
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
|
| 446 |
-
mock_cv2.IMWRITE_JPEG_QUALITY = 1
|
| 447 |
-
|
| 448 |
-
processor = VisionProcessor(VisionConfig(max_retries=2, retry_delay=0.01))
|
| 449 |
-
processor.initialize()
|
| 450 |
-
processor.device = "cuda" # Force CUDA for this test
|
| 451 |
-
|
| 452 |
-
# Make generate raise OOM error
|
| 453 |
-
mock_torch.cuda.OutOfMemoryError = type("OutOfMemoryError", (Exception,), {})
|
| 454 |
-
assert processor.model is not None
|
| 455 |
-
processor.model.generate.side_effect = mock_torch.cuda.OutOfMemoryError("OOM")
|
| 456 |
-
|
| 457 |
-
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 458 |
-
result = processor.process_image(test_image)
|
| 459 |
-
|
| 460 |
-
assert "GPU out of memory" in result
|
| 461 |
-
mock_torch.cuda.empty_cache.assert_called()
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
def test_vision_processor_cache_cleanup_mps(mock_torch: Any, mock_transformers: Any) -> None:
|
| 465 |
-
"""Test VisionProcessor cleans up MPS cache after processing."""
|
| 466 |
-
with patch("reachy_mini_conversation_app.vision.processors.cv2") as mock_cv2:
|
| 467 |
-
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
|
| 468 |
-
mock_cv2.IMWRITE_JPEG_QUALITY = 1
|
| 469 |
-
|
| 470 |
-
processor = VisionProcessor()
|
| 471 |
-
processor.initialize()
|
| 472 |
-
processor.device = "mps" # Force MPS for this test
|
| 473 |
-
|
| 474 |
-
test_image = np.zeros((480, 640, 3), dtype=np.uint8)
|
| 475 |
-
processor.process_image(test_image)
|
| 476 |
-
|
| 477 |
-
# Should call mps empty_cache
|
| 478 |
-
mock_torch.mps.empty_cache.assert_called()
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
def test_vision_manager_thread_safety(mock_torch: Any, mock_transformers: Any, mock_camera: Mock) -> None:
|
| 482 |
-
"""Test VisionManager thread safety with multiple start/stop cycles."""
|
| 483 |
-
with patch("reachy_mini_conversation_app.vision.processors.cv2") as mock_cv2:
|
| 484 |
-
mock_cv2.imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
|
| 485 |
-
mock_cv2.IMWRITE_JPEG_QUALITY = 1
|
| 486 |
-
|
| 487 |
-
config = VisionConfig(vision_interval=0.05)
|
| 488 |
-
manager = VisionManager(mock_camera, config)
|
| 489 |
-
|
| 490 |
-
# Multiple start/stop cycles
|
| 491 |
-
for _ in range(3):
|
| 492 |
-
manager.start()
|
| 493 |
-
time.sleep(0.1)
|
| 494 |
-
manager.stop()
|
| 495 |
-
time.sleep(0.05)
|
| 496 |
-
|
| 497 |
-
# Should not crash or leave dangling threads
|
| 498 |
-
assert manager._stop_event.is_set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|