RemiFabre commited on
Commit
599891c
·
1 Parent(s): a4d79b7

Removed outdated tests

Browse files
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()