From afe8434bc7d6e6b764f3178be8c395a4217e1c0c Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Wed, 17 Dec 2025 17:03:42 +0100 Subject: [PATCH] AudioFlinger: eliminate thread by using TaskManager (asyncio) Also simplify, and move all testing mocks to a dedicated file. --- .../lib/mpos/audio/__init__.py | 23 +- .../lib/mpos/audio/audioflinger.py | 161 +-- .../lib/mpos/audio/stream_rtttl.py | 14 +- .../lib/mpos/audio/stream_wav.py | 39 +- .../lib/mpos/board/fri3d_2024.py | 3 +- internal_filesystem/lib/mpos/board/linux.py | 6 +- .../board/waveshare_esp32_s3_touch_lcd_2.py | 4 +- .../lib/mpos/testing/__init__.py | 77 ++ internal_filesystem/lib/mpos/testing/mocks.py | 730 +++++++++++++ tests/network_test_helper.py | 965 ++---------------- tests/test_audioflinger.py | 212 +--- 11 files changed, 1013 insertions(+), 1221 deletions(-) create mode 100644 internal_filesystem/lib/mpos/testing/__init__.py create mode 100644 internal_filesystem/lib/mpos/testing/mocks.py diff --git a/internal_filesystem/lib/mpos/audio/__init__.py b/internal_filesystem/lib/mpos/audio/__init__.py index 86526aa9..86689f8e 100644 --- a/internal_filesystem/lib/mpos/audio/__init__.py +++ b/internal_filesystem/lib/mpos/audio/__init__.py @@ -1,17 +1,12 @@ # AudioFlinger - Centralized Audio Management Service for MicroPythonOS # Android-inspired audio routing with priority-based audio focus +# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer from . import audioflinger # Re-export main API from .audioflinger import ( - # Device types - DEVICE_NULL, - DEVICE_I2S, - DEVICE_BUZZER, - DEVICE_BOTH, - - # Stream types + # Stream types (for priority-based audio focus) STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM, @@ -25,17 +20,14 @@ from .audioflinger import ( resume, set_volume, get_volume, - get_device_type, is_playing, + + # Hardware availability checks + has_i2s, + has_buzzer, ) __all__ = [ - # Device types - 'DEVICE_NULL', - 'DEVICE_I2S', - 'DEVICE_BUZZER', - 'DEVICE_BOTH', - # Stream types 'STREAM_MUSIC', 'STREAM_NOTIFICATION', @@ -50,6 +42,7 @@ __all__ = [ 'resume', 'set_volume', 'get_volume', - 'get_device_type', 'is_playing', + 'has_i2s', + 'has_buzzer', ] diff --git a/internal_filesystem/lib/mpos/audio/audioflinger.py b/internal_filesystem/lib/mpos/audio/audioflinger.py index 167eea5a..543aa4c4 100644 --- a/internal_filesystem/lib/mpos/audio/audioflinger.py +++ b/internal_filesystem/lib/mpos/audio/audioflinger.py @@ -1,12 +1,11 @@ # AudioFlinger - Core Audio Management Service # Centralized audio routing with priority-based audio focus (Android-inspired) # Supports I2S (digital audio) and PWM buzzer (tones/ringtones) +# +# Simple routing: play_wav() -> I2S, play_rtttl() -> buzzer +# Uses TaskManager (asyncio) for non-blocking background playback -# Device type constants -DEVICE_NULL = 0 # No audio hardware (desktop fallback) -DEVICE_I2S = 1 # Digital audio output (WAV playback) -DEVICE_BUZZER = 2 # PWM buzzer (tones/RTTTL) -DEVICE_BOTH = 3 # Both I2S and buzzer available +from mpos.task_manager import TaskManager # Stream type constants (priority order: higher number = higher priority) STREAM_MUSIC = 0 # Background music (lowest priority) @@ -14,45 +13,47 @@ STREAM_NOTIFICATION = 1 # Notification sounds (medium priority) STREAM_ALARM = 2 # Alarms/alerts (highest priority) # Module-level state (singleton pattern, follows battery_voltage.py) -_device_type = DEVICE_NULL _i2s_pins = None # I2S pin configuration dict (created per-stream) _buzzer_instance = None # PWM buzzer instance _current_stream = None # Currently playing stream +_current_task = None # Currently running playback task _volume = 50 # System volume (0-100) -_stream_lock = None # Thread lock for stream management -def init(device_type, i2s_pins=None, buzzer_instance=None): +def init(i2s_pins=None, buzzer_instance=None): """ Initialize AudioFlinger with hardware configuration. Args: - device_type: One of DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH - i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S devices) - buzzer_instance: PWM instance for buzzer (for buzzer devices) + i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S/WAV playback) + buzzer_instance: PWM instance for buzzer (for RTTTL playback) """ - global _device_type, _i2s_pins, _buzzer_instance, _stream_lock + global _i2s_pins, _buzzer_instance - _device_type = device_type _i2s_pins = i2s_pins _buzzer_instance = buzzer_instance - # Initialize thread lock for stream management - try: - import _thread - _stream_lock = _thread.allocate_lock() - except ImportError: - # Desktop mode - no threading support - _stream_lock = None + # Build status message + capabilities = [] + if i2s_pins: + capabilities.append("I2S (WAV)") + if buzzer_instance: + capabilities.append("Buzzer (RTTTL)") + + if capabilities: + print(f"AudioFlinger initialized: {', '.join(capabilities)}") + else: + print("AudioFlinger initialized: No audio hardware") - device_names = { - DEVICE_NULL: "NULL (no audio)", - DEVICE_I2S: "I2S (digital audio)", - DEVICE_BUZZER: "Buzzer (PWM tones)", - DEVICE_BOTH: "Both (I2S + Buzzer)" - } - print(f"AudioFlinger initialized: {device_names.get(device_type, 'Unknown')}") +def has_i2s(): + """Check if I2S audio is available for WAV playback.""" + return _i2s_pins is not None + + +def has_buzzer(): + """Check if buzzer is available for RTTTL playback.""" + return _buzzer_instance is not None def _check_audio_focus(stream_type): @@ -85,35 +86,27 @@ def _check_audio_focus(stream_type): return True -def _playback_thread(stream): +async def _playback_coroutine(stream): """ - Background thread function for audio playback. + Async coroutine for audio playback. Args: stream: Stream instance (WAVStream or RTTTLStream) """ - global _current_stream + global _current_stream, _current_task - # Acquire lock and set as current stream - if _stream_lock: - _stream_lock.acquire() _current_stream = stream - if _stream_lock: - _stream_lock.release() try: - # Run playback (blocks until complete or stopped) - stream.play() + # Run async playback + await stream.play_async() except Exception as e: print(f"AudioFlinger: Playback error: {e}") finally: # Clear current stream - if _stream_lock: - _stream_lock.acquire() if _current_stream == stream: _current_stream = None - if _stream_lock: - _stream_lock.release() + _current_task = None def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None): @@ -129,29 +122,19 @@ def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None) Returns: bool: True if playback started, False if rejected or unavailable """ - if _device_type not in (DEVICE_I2S, DEVICE_BOTH): - print("AudioFlinger: play_wav() failed - no I2S device available") - return False + global _current_task if not _i2s_pins: - print("AudioFlinger: play_wav() failed - I2S pins not configured") + print("AudioFlinger: play_wav() failed - I2S not configured") return False # Check audio focus - if _stream_lock: - _stream_lock.acquire() - can_start = _check_audio_focus(stream_type) - if _stream_lock: - _stream_lock.release() - - if not can_start: + if not _check_audio_focus(stream_type): return False - # Create stream and start playback in background thread + # Create stream and start playback as async task try: from mpos.audio.stream_wav import WAVStream - import _thread - import mpos.apps stream = WAVStream( file_path=file_path, @@ -161,8 +144,7 @@ def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None) on_complete=on_complete ) - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(_playback_thread, (stream,)) + _current_task = TaskManager.create_task(_playback_coroutine(stream)) return True except Exception as e: @@ -183,29 +165,19 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co Returns: bool: True if playback started, False if rejected or unavailable """ - if _device_type not in (DEVICE_BUZZER, DEVICE_BOTH): - print("AudioFlinger: play_rtttl() failed - no buzzer device available") - return False + global _current_task if not _buzzer_instance: - print("AudioFlinger: play_rtttl() failed - buzzer not initialized") + print("AudioFlinger: play_rtttl() failed - buzzer not configured") return False # Check audio focus - if _stream_lock: - _stream_lock.acquire() - can_start = _check_audio_focus(stream_type) - if _stream_lock: - _stream_lock.release() - - if not can_start: + if not _check_audio_focus(stream_type): return False - # Create stream and start playback in background thread + # Create stream and start playback as async task try: from mpos.audio.stream_rtttl import RTTTLStream - import _thread - import mpos.apps stream = RTTTLStream( rtttl_string=rtttl_string, @@ -215,8 +187,7 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co on_complete=on_complete ) - _thread.stack_size(mpos.apps.good_stack_size()) - _thread.start_new_thread(_playback_thread, (stream,)) + _current_task = TaskManager.create_task(_playback_coroutine(stream)) return True except Exception as e: @@ -226,10 +197,7 @@ def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_co def stop(): """Stop current audio playback.""" - global _current_stream - - if _stream_lock: - _stream_lock.acquire() + global _current_stream, _current_task if _current_stream: _current_stream.stop() @@ -237,49 +205,30 @@ def stop(): else: print("AudioFlinger: No playback to stop") - if _stream_lock: - _stream_lock.release() - def pause(): """ Pause current audio playback (if supported by stream). Note: Most streams don't support pause, only stop. """ - global _current_stream - - if _stream_lock: - _stream_lock.acquire() - if _current_stream and hasattr(_current_stream, 'pause'): _current_stream.pause() print("AudioFlinger: Playback paused") else: print("AudioFlinger: Pause not supported or no playback active") - if _stream_lock: - _stream_lock.release() - def resume(): """ Resume paused audio playback (if supported by stream). Note: Most streams don't support resume, only play. """ - global _current_stream - - if _stream_lock: - _stream_lock.acquire() - if _current_stream and hasattr(_current_stream, 'resume'): _current_stream.resume() print("AudioFlinger: Playback resumed") else: print("AudioFlinger: Resume not supported or no playback active") - if _stream_lock: - _stream_lock.release() - def set_volume(volume): """ @@ -304,16 +253,6 @@ def get_volume(): return _volume -def get_device_type(): - """ - Get configured audio device type. - - Returns: - int: Device type (DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH) - """ - return _device_type - - def is_playing(): """ Check if audio is currently playing. @@ -321,12 +260,4 @@ def is_playing(): Returns: bool: True if playback active, False otherwise """ - if _stream_lock: - _stream_lock.acquire() - - result = _current_stream is not None and _current_stream.is_playing() - - if _stream_lock: - _stream_lock.release() - - return result + return _current_stream is not None and _current_stream.is_playing() diff --git a/internal_filesystem/lib/mpos/audio/stream_rtttl.py b/internal_filesystem/lib/mpos/audio/stream_rtttl.py index ea8d0a4e..45ccf5cf 100644 --- a/internal_filesystem/lib/mpos/audio/stream_rtttl.py +++ b/internal_filesystem/lib/mpos/audio/stream_rtttl.py @@ -1,9 +1,10 @@ # RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger # Ring Tone Text Transfer Language parser and player -# Ported from Fri3d Camp 2024 Badge firmware +# Uses async playback with TaskManager for non-blocking operation import math -import time + +from mpos.task_manager import TaskManager class RTTTLStream: @@ -179,8 +180,8 @@ class RTTTLStream: yield freq, msec - def play(self): - """Play RTTTL tune via buzzer (runs in background thread).""" + async def play_async(self): + """Play RTTTL tune via buzzer (runs as TaskManager task).""" self._is_playing = True # Calculate exponential duty cycle for perceptually linear volume @@ -212,9 +213,10 @@ class RTTTLStream: self.buzzer.duty_u16(duty) # Play for 90% of duration, silent for 10% (note separation) - time.sleep_ms(int(msec * 0.9)) + # Use async sleep to allow other tasks to run + await TaskManager.sleep_ms(int(msec * 0.9)) self.buzzer.duty_u16(0) - time.sleep_ms(int(msec * 0.1)) + await TaskManager.sleep_ms(int(msec * 0.1)) print(f"RTTTLStream: Finished playing '{self.name}'") if self.on_complete: diff --git a/internal_filesystem/lib/mpos/audio/stream_wav.py b/internal_filesystem/lib/mpos/audio/stream_wav.py index b5a71047..50191a1c 100644 --- a/internal_filesystem/lib/mpos/audio/stream_wav.py +++ b/internal_filesystem/lib/mpos/audio/stream_wav.py @@ -1,13 +1,14 @@ # WAVStream - WAV File Playback Stream for AudioFlinger # Supports 8/16/24/32-bit PCM, mono+stereo, auto-upsampling, volume control -# Ported from MusicPlayer's AudioPlayer class +# Uses async playback with TaskManager for non-blocking operation import machine import micropython import os -import time import sys +from mpos.task_manager import TaskManager + # Volume scaling function - Viper-optimized for ESP32 performance # NOTE: The line below is automatically commented out by build_mpos.sh during # Unix/macOS builds (cross-compiler doesn't support Viper), then uncommented after build. @@ -313,8 +314,8 @@ class WAVStream: # ---------------------------------------------------------------------- # Main playback routine # ---------------------------------------------------------------------- - def play(self): - """Main playback routine (runs in background thread).""" + async def play_async(self): + """Main async playback routine (runs as TaskManager task).""" self._is_playing = True try: @@ -363,23 +364,12 @@ class WAVStream: print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)") f.seek(data_start) - # smaller chunk size means less jerks but buffer can run empty - # at 22050 Hz, 16-bit, 2-ch, 4096/4 = 1024 samples / 22050 = 46ms - # with rough volume scaling: - # 4096 => audio stutters during quasibird at ~20fps - # 8192 => no audio stutters and quasibird runs at ~16 fps => good compromise! - # 16384 => no audio stutters during quasibird but low framerate (~8fps) - # with optimized volume scaling: - # 6144 => audio stutters and quasibird at ~17fps - # 7168 => audio slightly stutters and quasibird at ~16fps - # 8192 => no audio stutters and quasibird runs at ~15-17fps => this is probably best - # with shift volume scaling: - # 6144 => audio slightly stutters and quasibird at ~16fps?! - # 8192 => no audio stutters, quasibird runs at ~13fps?! - # with power of 2 thing: - # 6144 => audio sutters and quasibird at ~18fps - # 8192 => no audio stutters, quasibird runs at ~14fps - chunk_size = 8192 + # Chunk size tuning notes: + # - Smaller chunks = more responsive to stop(), better async yielding + # - Larger chunks = less overhead, smoother audio + # - 4096 bytes with async yield works well for responsiveness + # - The 32KB I2S buffer handles timing smoothness + chunk_size = 4096 bytes_per_original_sample = (bits_per_sample // 8) * channels total_original = 0 @@ -412,8 +402,6 @@ class WAVStream: raw = self._upsample_buffer(raw, upsample_factor) # 3. Volume scaling - #shift = 16 - int(self.volume / 6.25) - #_scale_audio_powers_of_2(raw, len(raw), shift) scale = self.volume / 100.0 if scale < 1.0: scale_fixed = int(scale * 32768) @@ -425,9 +413,12 @@ class WAVStream: else: # Simulate playback timing if no I2S num_samples = len(raw) // (2 * channels) - time.sleep(num_samples / playback_rate) + await TaskManager.sleep(num_samples / playback_rate) total_original += to_read + + # Yield to other async tasks after each chunk + await TaskManager.sleep_ms(0) print(f"WAVStream: Finished playing {self.file_path}") if self.on_complete: diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index 19cc307c..8eeb1047 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -304,9 +304,8 @@ i2s_pins = { 'sd': 16, } -# Initialize AudioFlinger (both I2S and buzzer available) +# Initialize AudioFlinger with I2S and buzzer AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BOTH, i2s_pins=i2s_pins, buzzer_instance=buzzer ) diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index a82a12ce..0ca9ba5c 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -100,11 +100,7 @@ import mpos.audio.audioflinger as AudioFlinger # Note: Desktop builds have no audio hardware # AudioFlinger functions will return False (no-op) -AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None -) +AudioFlinger.init() # === LED HARDWARE === # Note: Desktop builds have no LED hardware diff --git a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py index e2075c66..15642eec 100644 --- a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py +++ b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py @@ -113,8 +113,8 @@ except Exception as e: # === AUDIO HARDWARE === import mpos.audio.audioflinger as AudioFlinger -# Note: Waveshare board has no buzzer or I2S audio: -AudioFlinger.init(device_type=AudioFlinger.DEVICE_NULL) +# Note: Waveshare board has no buzzer or I2S audio +AudioFlinger.init() # === LED HARDWARE === # Note: Waveshare board has no NeoPixel LEDs diff --git a/internal_filesystem/lib/mpos/testing/__init__.py b/internal_filesystem/lib/mpos/testing/__init__.py new file mode 100644 index 00000000..437da22e --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/__init__.py @@ -0,0 +1,77 @@ +""" +MicroPythonOS Testing Module + +Provides mock implementations for testing without actual hardware. +These mocks work on both desktop (unit tests) and device (integration tests). + +Usage: + from mpos.testing import MockMachine, MockTaskManager, MockNetwork + + # Inject mocks before importing modules that use hardware + import sys + sys.modules['machine'] = MockMachine() + + # Or use the helper function + from mpos.testing import inject_mocks + inject_mocks(['machine', 'mpos.task_manager']) +""" + +from .mocks import ( + # Hardware mocks + MockMachine, + MockPin, + MockPWM, + MockI2S, + MockTimer, + MockSocket, + + # MPOS mocks + MockTaskManager, + MockTask, + MockDownloadManager, + + # Network mocks + MockNetwork, + MockRequests, + MockResponse, + MockRaw, + + # Utility mocks + MockTime, + MockJSON, + MockModule, + + # Helper functions + inject_mocks, + create_mock_module, +) + +__all__ = [ + # Hardware mocks + 'MockMachine', + 'MockPin', + 'MockPWM', + 'MockI2S', + 'MockTimer', + 'MockSocket', + + # MPOS mocks + 'MockTaskManager', + 'MockTask', + 'MockDownloadManager', + + # Network mocks + 'MockNetwork', + 'MockRequests', + 'MockResponse', + 'MockRaw', + + # Utility mocks + 'MockTime', + 'MockJSON', + 'MockModule', + + # Helper functions + 'inject_mocks', + 'create_mock_module', +] \ No newline at end of file diff --git a/internal_filesystem/lib/mpos/testing/mocks.py b/internal_filesystem/lib/mpos/testing/mocks.py new file mode 100644 index 00000000..f0dc6a1b --- /dev/null +++ b/internal_filesystem/lib/mpos/testing/mocks.py @@ -0,0 +1,730 @@ +""" +Mock implementations for MicroPythonOS testing. + +This module provides mock implementations of hardware and system modules +for testing without actual hardware. Works on both desktop and device. +""" + +import sys + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +class MockModule: + """ + Simple class that acts as a module container. + MicroPython doesn't have types.ModuleType, so we use this instead. + """ + pass + + +def create_mock_module(name, **attrs): + """ + Create a mock module with the given attributes. + + Args: + name: Module name (for debugging) + **attrs: Attributes to set on the module + + Returns: + MockModule instance with attributes set + """ + module = MockModule() + module.__name__ = name + for key, value in attrs.items(): + setattr(module, key, value) + return module + + +def inject_mocks(mock_specs): + """ + Inject mock modules into sys.modules. + + Args: + mock_specs: Dict mapping module names to mock instances/classes + e.g., {'machine': MockMachine(), 'mpos.task_manager': mock_tm} + """ + for name, mock in mock_specs.items(): + sys.modules[name] = mock + + +# ============================================================================= +# Hardware Mocks - machine module +# ============================================================================= + +class MockPin: + """Mock machine.Pin for testing GPIO operations.""" + + IN = 0 + OUT = 1 + PULL_UP = 2 + PULL_DOWN = 3 + + def __init__(self, pin_number, mode=None, pull=None): + self.pin_number = pin_number + self.mode = mode + self.pull = pull + self._value = 0 + + def value(self, val=None): + """Get or set pin value.""" + if val is None: + return self._value + self._value = val + + def on(self): + """Set pin high.""" + self._value = 1 + + def off(self): + """Set pin low.""" + self._value = 0 + + +class MockPWM: + """Mock machine.PWM for testing PWM operations (buzzer, etc.).""" + + def __init__(self, pin, freq=0, duty=0): + self.pin = pin + self.last_freq = freq + self.last_duty = duty + + def freq(self, value=None): + """Get or set frequency.""" + if value is not None: + self.last_freq = value + return self.last_freq + + def duty_u16(self, value=None): + """Get or set duty cycle (16-bit).""" + if value is not None: + self.last_duty = value + return self.last_duty + + def duty(self, value=None): + """Get or set duty cycle (10-bit).""" + if value is not None: + self.last_duty = value * 64 # Convert to 16-bit + return self.last_duty // 64 + + def deinit(self): + """Deinitialize PWM.""" + self.last_freq = 0 + self.last_duty = 0 + + +class MockI2S: + """Mock machine.I2S for testing audio I2S operations.""" + + TX = 0 + RX = 1 + MONO = 0 + STEREO = 1 + + def __init__(self, id, sck=None, ws=None, sd=None, mode=None, + bits=16, format=None, rate=44100, ibuf=None): + self.id = id + self.sck = sck + self.ws = ws + self.sd = sd + self.mode = mode + self.bits = bits + self.format = format + self.rate = rate + self.ibuf = ibuf + self._write_buffer = bytearray(1024) + self._bytes_written = 0 + + def write(self, buf): + """Write audio data (blocking).""" + self._bytes_written += len(buf) + return len(buf) + + def write_readinto(self, write_buf, read_buf): + """Non-blocking write with readback.""" + self._bytes_written += len(write_buf) + return len(write_buf) + + def deinit(self): + """Deinitialize I2S.""" + pass + + +class MockTimer: + """Mock machine.Timer for testing periodic callbacks.""" + + _all_timers = {} + + PERIODIC = 1 + ONE_SHOT = 0 + + def __init__(self, timer_id=-1): + self.timer_id = timer_id + self.callback = None + self.period = None + self.mode = None + self.active = False + if timer_id >= 0: + MockTimer._all_timers[timer_id] = self + + def init(self, period=None, mode=None, callback=None): + """Initialize/configure the timer.""" + self.period = period + self.mode = mode + self.callback = callback + self.active = True + + def deinit(self): + """Deinitialize the timer.""" + self.active = False + self.callback = None + + def trigger(self, *args, **kwargs): + """Manually trigger the timer callback (for testing).""" + if self.callback and self.active: + self.callback(*args, **kwargs) + + @classmethod + def get_timer(cls, timer_id): + """Get a timer by ID.""" + return cls._all_timers.get(timer_id) + + @classmethod + def trigger_all(cls): + """Trigger all active timers (for testing).""" + for timer in cls._all_timers.values(): + if timer.active: + timer.trigger() + + @classmethod + def reset_all(cls): + """Reset all timers (clear registry).""" + cls._all_timers.clear() + + +class MockMachine: + """ + Mock machine module containing all hardware mocks. + + Usage: + sys.modules['machine'] = MockMachine() + """ + + Pin = MockPin + PWM = MockPWM + I2S = MockI2S + Timer = MockTimer + + @staticmethod + def freq(freq=None): + """Get or set CPU frequency.""" + return 240000000 # 240 MHz + + @staticmethod + def reset(): + """Reset the device (no-op in mock).""" + pass + + @staticmethod + def soft_reset(): + """Soft reset the device (no-op in mock).""" + pass + + +# ============================================================================= +# MPOS Mocks - TaskManager +# ============================================================================= + +class MockTask: + """Mock asyncio Task for testing.""" + + def __init__(self): + self.ph_key = 0 + self._done = False + self.coro = None + self._result = None + self._exception = None + + def done(self): + """Check if task is done.""" + return self._done + + def cancel(self): + """Cancel the task.""" + self._done = True + + def result(self): + """Get task result.""" + if self._exception: + raise self._exception + return self._result + + +class MockTaskManager: + """ + Mock TaskManager for testing async operations. + + Usage: + mock_tm = create_mock_module('mpos.task_manager', TaskManager=MockTaskManager) + sys.modules['mpos.task_manager'] = mock_tm + """ + + task_list = [] + + @classmethod + def create_task(cls, coroutine): + """Create a mock task from a coroutine.""" + task = MockTask() + task.coro = coroutine + cls.task_list.append(task) + return task + + @staticmethod + async def sleep(seconds): + """Mock async sleep (no actual delay).""" + pass + + @staticmethod + async def sleep_ms(milliseconds): + """Mock async sleep in milliseconds (no actual delay).""" + pass + + @staticmethod + async def wait_for(awaitable, timeout): + """Mock wait_for with timeout.""" + return await awaitable + + @staticmethod + def notify_event(): + """Create a mock async event.""" + class MockEvent: + def __init__(self): + self._set = False + + async def wait(self): + pass + + def set(self): + self._set = True + + def is_set(self): + return self._set + + return MockEvent() + + @classmethod + def clear_tasks(cls): + """Clear all tracked tasks (for test cleanup).""" + cls.task_list = [] + + +# ============================================================================= +# Network Mocks +# ============================================================================= + +class MockNetwork: + """Mock network module for testing network connectivity.""" + + STA_IF = 0 + AP_IF = 1 + + class MockWLAN: + """Mock WLAN interface.""" + + def __init__(self, interface, connected=True): + self.interface = interface + self._connected = connected + self._active = True + self._config = {} + self._scan_results = [] + + def isconnected(self): + """Return whether the WLAN is connected.""" + return self._connected + + def active(self, is_active=None): + """Get/set whether the interface is active.""" + if is_active is None: + return self._active + self._active = is_active + + def connect(self, ssid, password): + """Simulate connecting to a network.""" + self._connected = True + self._config['ssid'] = ssid + + def disconnect(self): + """Simulate disconnecting from network.""" + self._connected = False + + def config(self, param): + """Get configuration parameter.""" + return self._config.get(param) + + def ifconfig(self): + """Get IP configuration.""" + if self._connected: + return ('192.168.1.100', '255.255.255.0', '192.168.1.1', '8.8.8.8') + return ('0.0.0.0', '0.0.0.0', '0.0.0.0', '0.0.0.0') + + def scan(self): + """Scan for available networks.""" + return self._scan_results + + def __init__(self, connected=True): + self._connected = connected + self._wlan_instances = {} + + def WLAN(self, interface): + """Create or return a WLAN interface.""" + if interface not in self._wlan_instances: + self._wlan_instances[interface] = self.MockWLAN(interface, self._connected) + return self._wlan_instances[interface] + + def set_connected(self, connected): + """Change the connection state of all WLAN interfaces.""" + self._connected = connected + for wlan in self._wlan_instances.values(): + wlan._connected = connected + + +class MockRaw: + """Mock raw HTTP response for streaming.""" + + def __init__(self, content, fail_after_bytes=None): + self.content = content + self.position = 0 + self.fail_after_bytes = fail_after_bytes + + def read(self, size): + """Read a chunk of data.""" + if self.fail_after_bytes is not None and self.position >= self.fail_after_bytes: + raise OSError(-113, "ECONNABORTED") + + chunk = self.content[self.position:self.position + size] + self.position += len(chunk) + return chunk + + +class MockResponse: + """Mock HTTP response.""" + + def __init__(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): + self.status_code = status_code + self.text = text + self.headers = headers or {} + self.content = content + self._closed = False + self.raw = MockRaw(content, fail_after_bytes=fail_after_bytes) + + def close(self): + """Close the response.""" + self._closed = True + + def json(self): + """Parse response as JSON.""" + import json + return json.loads(self.text) + + +class MockRequests: + """Mock requests module for testing HTTP operations.""" + + def __init__(self): + self.last_url = None + self.last_headers = None + self.last_timeout = None + self.last_stream = None + self.last_request = None + self.next_response = None + self.raise_exception = None + self.call_history = [] + + def get(self, url, stream=False, timeout=None, headers=None): + """Mock GET request.""" + self.last_url = url + self.last_headers = headers + self.last_timeout = timeout + self.last_stream = stream + + self.last_request = { + 'method': 'GET', + 'url': url, + 'stream': stream, + 'timeout': timeout, + 'headers': headers or {} + } + self.call_history.append(self.last_request.copy()) + + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + if self.next_response: + response = self.next_response + self.next_response = None + return response + + return MockResponse() + + def post(self, url, data=None, json=None, timeout=None, headers=None): + """Mock POST request.""" + self.last_url = url + self.last_headers = headers + self.last_timeout = timeout + + self.call_history.append({ + 'method': 'POST', + 'url': url, + 'data': data, + 'json': json, + 'timeout': timeout, + 'headers': headers + }) + + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + if self.next_response: + response = self.next_response + self.next_response = None + return response + + return MockResponse() + + def set_next_response(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): + """Configure the next response to return.""" + self.next_response = MockResponse(status_code, text, headers, content, fail_after_bytes=fail_after_bytes) + return self.next_response + + def set_exception(self, exception): + """Configure an exception to raise on the next request.""" + self.raise_exception = exception + + def clear_history(self): + """Clear the call history.""" + self.call_history = [] + + +class MockSocket: + """Mock socket for testing socket operations.""" + + AF_INET = 2 + SOCK_STREAM = 1 + + def __init__(self, af=None, sock_type=None): + self.af = af + self.sock_type = sock_type + self.connected = False + self.bound = False + self.listening = False + self.address = None + self._send_exception = None + self._recv_data = b'' + self._recv_position = 0 + + def connect(self, address): + """Simulate connecting to an address.""" + self.connected = True + self.address = address + + def bind(self, address): + """Simulate binding to an address.""" + self.bound = True + self.address = address + + def listen(self, backlog): + """Simulate listening for connections.""" + self.listening = True + + def send(self, data): + """Simulate sending data.""" + if self._send_exception: + exc = self._send_exception + self._send_exception = None + raise exc + return len(data) + + def recv(self, size): + """Simulate receiving data.""" + chunk = self._recv_data[self._recv_position:self._recv_position + size] + self._recv_position += len(chunk) + return chunk + + def close(self): + """Close the socket.""" + self.connected = False + + def set_send_exception(self, exception): + """Configure an exception to raise on next send().""" + self._send_exception = exception + + def set_recv_data(self, data): + """Configure data to return from recv().""" + self._recv_data = data + self._recv_position = 0 + + +# ============================================================================= +# Utility Mocks +# ============================================================================= + +class MockTime: + """Mock time module for testing time-dependent code.""" + + def __init__(self, start_time=0): + self._current_time_ms = start_time + self._sleep_calls = [] + + def ticks_ms(self): + """Get current time in milliseconds.""" + return self._current_time_ms + + def ticks_diff(self, ticks1, ticks2): + """Calculate difference between two tick values.""" + return ticks1 - ticks2 + + def sleep(self, seconds): + """Simulate sleep (doesn't actually sleep).""" + self._sleep_calls.append(seconds) + + def sleep_ms(self, milliseconds): + """Simulate sleep in milliseconds.""" + self._sleep_calls.append(milliseconds / 1000.0) + + def advance(self, milliseconds): + """Advance the mock time.""" + self._current_time_ms += milliseconds + + def get_sleep_calls(self): + """Get history of sleep calls.""" + return self._sleep_calls + + def clear_sleep_calls(self): + """Clear the sleep call history.""" + self._sleep_calls = [] + + +class MockJSON: + """Mock JSON module for testing JSON parsing.""" + + def __init__(self): + self.raise_exception = None + + def loads(self, text): + """Parse JSON string.""" + if self.raise_exception: + exc = self.raise_exception + self.raise_exception = None + raise exc + + import json + return json.loads(text) + + def dumps(self, obj): + """Serialize object to JSON string.""" + import json + return json.dumps(obj) + + def set_exception(self, exception): + """Configure an exception to raise on the next loads() call.""" + self.raise_exception = exception + + +class MockDownloadManager: + """Mock DownloadManager for testing async downloads.""" + + def __init__(self): + self.download_data = b'' + self.should_fail = False + self.fail_after_bytes = None + self.headers_received = None + self.url_received = None + self.call_history = [] + self.chunk_size = 1024 + self.simulated_speed_bps = 100 * 1024 + + async def download_url(self, url, outfile=None, total_size=None, + progress_callback=None, chunk_callback=None, headers=None, + speed_callback=None): + """Mock async download with flexible output modes.""" + self.url_received = url + self.headers_received = headers + + self.call_history.append({ + 'url': url, + 'outfile': outfile, + 'total_size': total_size, + 'headers': headers, + 'has_progress_callback': progress_callback is not None, + 'has_chunk_callback': chunk_callback is not None, + 'has_speed_callback': speed_callback is not None + }) + + if self.should_fail: + if outfile or chunk_callback: + return False + return None + + if self.fail_after_bytes is not None and self.fail_after_bytes == 0: + raise OSError(-113, "ECONNABORTED") + + bytes_sent = 0 + chunks = [] + total_data_size = len(self.download_data) + effective_total_size = total_size if total_size else total_data_size + last_progress_pct = -1.0 + bytes_since_speed_update = 0 + speed_update_threshold = 1000 + + while bytes_sent < total_data_size: + if self.fail_after_bytes is not None and bytes_sent >= self.fail_after_bytes: + raise OSError(-113, "ECONNABORTED") + + chunk = self.download_data[bytes_sent:bytes_sent + self.chunk_size] + + if chunk_callback: + await chunk_callback(chunk) + elif outfile: + pass + else: + chunks.append(chunk) + + bytes_sent += len(chunk) + bytes_since_speed_update += len(chunk) + + if progress_callback and effective_total_size > 0: + percent = round((bytes_sent * 100) / effective_total_size, 2) + if percent != last_progress_pct: + await progress_callback(percent) + last_progress_pct = percent + + if speed_callback and bytes_since_speed_update >= speed_update_threshold: + await speed_callback(self.simulated_speed_bps) + bytes_since_speed_update = 0 + + if outfile or chunk_callback: + return True + else: + return b''.join(chunks) + + def set_download_data(self, data): + """Configure the data to return from downloads.""" + self.download_data = data + + def set_should_fail(self, should_fail): + """Configure whether downloads should fail.""" + self.should_fail = should_fail + + def set_fail_after_bytes(self, bytes_count): + """Configure network failure after specified bytes.""" + self.fail_after_bytes = bytes_count + + def clear_history(self): + """Clear the call history.""" + self.call_history = [] \ No newline at end of file diff --git a/tests/network_test_helper.py b/tests/network_test_helper.py index 9d5bebe7..1a6d235b 100644 --- a/tests/network_test_helper.py +++ b/tests/network_test_helper.py @@ -2,592 +2,50 @@ Network testing helper module for MicroPythonOS. This module provides mock implementations of network-related modules -for testing without requiring actual network connectivity. These mocks -are designed to be used with dependency injection in the classes being tested. +for testing without requiring actual network connectivity. + +NOTE: This module re-exports mocks from mpos.testing for backward compatibility. +New code should import directly from mpos.testing. Usage: from network_test_helper import MockNetwork, MockRequests, MockTimer - - # Create mocks - mock_network = MockNetwork(connected=True) - mock_requests = MockRequests() - - # Configure mock responses - mock_requests.set_next_response(status_code=200, text='{"key": "value"}') - - # Pass to class being tested - obj = MyClass(network_module=mock_network, requests_module=mock_requests) - - # Test behavior - result = obj.fetch_data() - assert mock_requests.last_url == "http://expected.url" + + # Or use the centralized module directly: + from mpos.testing import MockNetwork, MockRequests, MockTimer """ -import time - - -class MockNetwork: - """ - Mock network module for testing network connectivity. - - Simulates the MicroPython 'network' module with WLAN interface. - """ - - STA_IF = 0 # Station interface constant - AP_IF = 1 # Access Point interface constant - - class MockWLAN: - """Mock WLAN interface.""" - - def __init__(self, interface, connected=True): - self.interface = interface - self._connected = connected - self._active = True - self._config = {} - self._scan_results = [] # Can be configured for testing - - def isconnected(self): - """Return whether the WLAN is connected.""" - return self._connected - - def active(self, is_active=None): - """Get/set whether the interface is active.""" - if is_active is None: - return self._active - self._active = is_active - - def connect(self, ssid, password): - """Simulate connecting to a network.""" - self._connected = True - self._config['ssid'] = ssid - - def disconnect(self): - """Simulate disconnecting from network.""" - self._connected = False - - def config(self, param): - """Get configuration parameter.""" - return self._config.get(param) - - def ifconfig(self): - """Get IP configuration.""" - if self._connected: - return ('192.168.1.100', '255.255.255.0', '192.168.1.1', '8.8.8.8') - return ('0.0.0.0', '0.0.0.0', '0.0.0.0', '0.0.0.0') - - def scan(self): - """Scan for available networks.""" - return self._scan_results - - def __init__(self, connected=True): - """ - Initialize mock network module. - - Args: - connected: Initial connection state (default: True) - """ - self._connected = connected - self._wlan_instances = {} - - def WLAN(self, interface): - """ - Create or return a WLAN interface. - - Args: - interface: Interface type (STA_IF or AP_IF) - - Returns: - MockWLAN instance - """ - if interface not in self._wlan_instances: - self._wlan_instances[interface] = self.MockWLAN(interface, self._connected) - return self._wlan_instances[interface] - - def set_connected(self, connected): - """ - Change the connection state of all WLAN interfaces. - - Args: - connected: New connection state - """ - self._connected = connected - for wlan in self._wlan_instances.values(): - wlan._connected = connected - - -class MockRaw: - """ - Mock raw HTTP response for streaming. - - Simulates the 'raw' attribute of requests.Response for chunked reading. - """ - - def __init__(self, content, fail_after_bytes=None): - """ - Initialize mock raw response. - - Args: - content: Binary content to stream - fail_after_bytes: If set, raise OSError(-113) after reading this many bytes - """ - self.content = content - self.position = 0 - self.fail_after_bytes = fail_after_bytes - - def read(self, size): - """ - Read a chunk of data. - - Args: - size: Number of bytes to read - - Returns: - bytes: Chunk of data (may be smaller than size at end of stream) - - Raises: - OSError: If fail_after_bytes is set and reached - """ - # Check if we should simulate network failure - if self.fail_after_bytes is not None and self.position >= self.fail_after_bytes: - raise OSError(-113, "ECONNABORTED") - - chunk = self.content[self.position:self.position + size] - self.position += len(chunk) - return chunk - - -class MockResponse: - """ - Mock HTTP response. - - Simulates requests.Response object with status code, text, headers, etc. - """ - - def __init__(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): - """ - Initialize mock response. - - Args: - status_code: HTTP status code (default: 200) - text: Response text content (default: '') - headers: Response headers dict (default: {}) - content: Binary response content (default: b'') - fail_after_bytes: If set, raise OSError after reading this many bytes - """ - self.status_code = status_code - self.text = text - self.headers = headers or {} - self.content = content - self._closed = False - - # Mock raw attribute for streaming - self.raw = MockRaw(content, fail_after_bytes=fail_after_bytes) - - def close(self): - """Close the response.""" - self._closed = True - - def json(self): - """Parse response as JSON.""" - import json - return json.loads(self.text) - - -class MockRequests: - """ - Mock requests module for testing HTTP operations. - - Provides configurable mock responses and exception injection for testing - HTTP client code without making actual network requests. - """ - - def __init__(self): - """Initialize mock requests module.""" - self.last_url = None - self.last_headers = None - self.last_timeout = None - self.last_stream = None - self.last_request = None # Full request info dict - self.next_response = None - self.raise_exception = None - self.call_history = [] - - def get(self, url, stream=False, timeout=None, headers=None): - """ - Mock GET request. - - Args: - url: URL to fetch - stream: Whether to stream the response - timeout: Request timeout in seconds - headers: Request headers dict - - Returns: - MockResponse object - - Raises: - Exception: If an exception was configured via set_exception() - """ - self.last_url = url - self.last_headers = headers - self.last_timeout = timeout - self.last_stream = stream - - # Store full request info - self.last_request = { - 'method': 'GET', - 'url': url, - 'stream': stream, - 'timeout': timeout, - 'headers': headers or {} - } - - # Record call in history - self.call_history.append(self.last_request.copy()) - - if self.raise_exception: - exc = self.raise_exception - self.raise_exception = None # Clear after raising - raise exc - - if self.next_response: - response = self.next_response - self.next_response = None # Clear after returning - return response - - # Default response - return MockResponse() - - def post(self, url, data=None, json=None, timeout=None, headers=None): - """ - Mock POST request. - - Args: - url: URL to post to - data: Form data to send - json: JSON data to send - timeout: Request timeout in seconds - headers: Request headers dict - - Returns: - MockResponse object - - Raises: - Exception: If an exception was configured via set_exception() - """ - self.last_url = url - self.last_headers = headers - self.last_timeout = timeout - - # Record call in history - self.call_history.append({ - 'method': 'POST', - 'url': url, - 'data': data, - 'json': json, - 'timeout': timeout, - 'headers': headers - }) - - if self.raise_exception: - exc = self.raise_exception - self.raise_exception = None - raise exc - - if self.next_response: - response = self.next_response - self.next_response = None - return response - - return MockResponse() - - def set_next_response(self, status_code=200, text='', headers=None, content=b'', fail_after_bytes=None): - """ - Configure the next response to return. - - Args: - status_code: HTTP status code (default: 200) - text: Response text (default: '') - headers: Response headers dict (default: {}) - content: Binary response content (default: b'') - fail_after_bytes: If set, raise OSError after reading this many bytes - - Returns: - MockResponse: The configured response object - """ - self.next_response = MockResponse(status_code, text, headers, content, fail_after_bytes=fail_after_bytes) - return self.next_response - - def set_exception(self, exception): - """ - Configure an exception to raise on the next request. - - Args: - exception: Exception instance to raise - """ - self.raise_exception = exception - - def clear_history(self): - """Clear the call history.""" - self.call_history = [] - - -class MockJSON: - """ - Mock JSON module for testing JSON parsing. - - Allows injection of parse errors for testing error handling. - """ - - def __init__(self): - """Initialize mock JSON module.""" - self.raise_exception = None - - def loads(self, text): - """ - Parse JSON string. - - Args: - text: JSON string to parse - - Returns: - Parsed JSON object - - Raises: - Exception: If an exception was configured via set_exception() - """ - if self.raise_exception: - exc = self.raise_exception - self.raise_exception = None - raise exc - - # Use Python's real json module for actual parsing - import json - return json.loads(text) - - def dumps(self, obj): - """ - Serialize object to JSON string. - - Args: - obj: Object to serialize - - Returns: - str: JSON string - """ - import json - return json.dumps(obj) - - def set_exception(self, exception): - """ - Configure an exception to raise on the next loads() call. - - Args: - exception: Exception instance to raise - """ - self.raise_exception = exception - - -class MockTimer: - """ - Mock Timer for testing periodic callbacks. - - Simulates machine.Timer without actual delays. Useful for testing - code that uses timers for periodic tasks. - """ - - # Class-level registry of all timers - _all_timers = {} - _next_timer_id = 0 - - PERIODIC = 1 - ONE_SHOT = 0 - - def __init__(self, timer_id): - """ - Initialize mock timer. - - Args: - timer_id: Timer ID (0-3 on most MicroPython platforms) - """ - self.timer_id = timer_id - self.callback = None - self.period = None - self.mode = None - self.active = False - MockTimer._all_timers[timer_id] = self - - def init(self, period=None, mode=None, callback=None): - """ - Initialize/configure the timer. - - Args: - period: Timer period in milliseconds - mode: Timer mode (PERIODIC or ONE_SHOT) - callback: Callback function to call on timer fire - """ - self.period = period - self.mode = mode - self.callback = callback - self.active = True - - def deinit(self): - """Deinitialize the timer.""" - self.active = False - self.callback = None - - def trigger(self, *args, **kwargs): - """ - Manually trigger the timer callback (for testing). - - Args: - *args: Arguments to pass to callback - **kwargs: Keyword arguments to pass to callback - """ - if self.callback and self.active: - self.callback(*args, **kwargs) - - @classmethod - def get_timer(cls, timer_id): - """ - Get a timer by ID. - - Args: - timer_id: Timer ID to retrieve - - Returns: - MockTimer instance or None if not found - """ - return cls._all_timers.get(timer_id) - - @classmethod - def trigger_all(cls): - """Trigger all active timers (for testing).""" - for timer in cls._all_timers.values(): - if timer.active: - timer.trigger() - - @classmethod - def reset_all(cls): - """Reset all timers (clear registry).""" - cls._all_timers.clear() - - -class MockSocket: - """ - Mock socket for testing socket operations. - - Simulates usocket module without actual network I/O. - """ - - AF_INET = 2 - SOCK_STREAM = 1 - - def __init__(self, af=None, sock_type=None): - """ - Initialize mock socket. - - Args: - af: Address family (AF_INET, etc.) - sock_type: Socket type (SOCK_STREAM, etc.) - """ - self.af = af - self.sock_type = sock_type - self.connected = False - self.bound = False - self.listening = False - self.address = None - self.port = None - self._send_exception = None - self._recv_data = b'' - self._recv_position = 0 - - def connect(self, address): - """ - Simulate connecting to an address. - - Args: - address: Tuple of (host, port) - """ - self.connected = True - self.address = address - - def bind(self, address): - """ - Simulate binding to an address. - - Args: - address: Tuple of (host, port) - """ - self.bound = True - self.address = address - - def listen(self, backlog): - """ - Simulate listening for connections. - - Args: - backlog: Maximum number of queued connections - """ - self.listening = True - - def send(self, data): - """ - Simulate sending data. - - Args: - data: Bytes to send - - Returns: - int: Number of bytes sent - - Raises: - Exception: If configured via set_send_exception() - """ - if self._send_exception: - exc = self._send_exception - self._send_exception = None - raise exc - return len(data) - - def recv(self, size): - """ - Simulate receiving data. - - Args: - size: Maximum bytes to receive - - Returns: - bytes: Received data - """ - chunk = self._recv_data[self._recv_position:self._recv_position + size] - self._recv_position += len(chunk) - return chunk - - def close(self): - """Close the socket.""" - self.connected = False - - def set_send_exception(self, exception): - """ - Configure an exception to raise on next send(). - - Args: - exception: Exception instance to raise - """ - self._send_exception = exception - - def set_recv_data(self, data): - """ - Configure data to return from recv(). - - Args: - data: Bytes to return from recv() calls - """ - self._recv_data = data - self._recv_position = 0 - - +# Re-export all mocks from centralized module for backward compatibility +from mpos.testing import ( + # Hardware mocks + MockMachine, + MockPin, + MockPWM, + MockI2S, + MockTimer, + MockSocket, + + # MPOS mocks + MockTaskManager, + MockTask, + MockDownloadManager, + + # Network mocks + MockNetwork, + MockRequests, + MockResponse, + MockRaw, + + # Utility mocks + MockTime, + MockJSON, + MockModule, + + # Helper functions + inject_mocks, + create_mock_module, +) + +# For backward compatibility, also provide socket() function def socket(af=MockSocket.AF_INET, sock_type=MockSocket.SOCK_STREAM): """ Create a mock socket. @@ -602,318 +60,33 @@ def socket(af=MockSocket.AF_INET, sock_type=MockSocket.SOCK_STREAM): return MockSocket(af, sock_type) -class MockTime: - """ - Mock time module for testing time-dependent code. - - Allows manual control of time progression for deterministic testing. - """ - - def __init__(self, start_time=0): - """ - Initialize mock time module. - - Args: - start_time: Initial time in milliseconds (default: 0) - """ - self._current_time_ms = start_time - self._sleep_calls = [] - - def ticks_ms(self): - """ - Get current time in milliseconds. - - Returns: - int: Current time in milliseconds - """ - return self._current_time_ms - - def ticks_diff(self, ticks1, ticks2): - """ - Calculate difference between two tick values. - - Args: - ticks1: End time - ticks2: Start time - - Returns: - int: Difference in milliseconds - """ - return ticks1 - ticks2 - - def sleep(self, seconds): - """ - Simulate sleep (doesn't actually sleep). - - Args: - seconds: Number of seconds to sleep - """ - self._sleep_calls.append(seconds) - - def sleep_ms(self, milliseconds): - """ - Simulate sleep in milliseconds. - - Args: - milliseconds: Number of milliseconds to sleep - """ - self._sleep_calls.append(milliseconds / 1000.0) - - def advance(self, milliseconds): - """ - Advance the mock time. - - Args: - milliseconds: Number of milliseconds to advance - """ - self._current_time_ms += milliseconds - - def get_sleep_calls(self): - """ - Get history of sleep calls. - - Returns: - list: List of sleep durations in seconds - """ - return self._sleep_calls - - def clear_sleep_calls(self): - """Clear the sleep call history.""" - self._sleep_calls = [] - - -class MockDownloadManager: - """ - Mock DownloadManager for testing async downloads. - - Simulates the mpos.DownloadManager module for testing without actual network I/O. - Supports chunk_callback mode for streaming downloads. - """ - - def __init__(self): - """Initialize mock download manager.""" - self.download_data = b'' - self.should_fail = False - self.fail_after_bytes = None - self.headers_received = None - self.url_received = None - self.call_history = [] - self.chunk_size = 1024 # Default chunk size for streaming - self.simulated_speed_bps = 100 * 1024 # 100 KB/s default simulated speed - - async def download_url(self, url, outfile=None, total_size=None, - progress_callback=None, chunk_callback=None, headers=None, - speed_callback=None): - """ - Mock async download with flexible output modes. - - Simulates the real DownloadManager behavior including: - - Streaming chunks via chunk_callback - - Progress reporting via progress_callback with 2-decimal precision - - Speed reporting via speed_callback - - Network failure simulation - - Args: - url: URL to download - outfile: Path to write file (optional) - total_size: Expected size for progress tracking (optional) - progress_callback: Async callback for progress updates (optional) - Called with percent as float with 2 decimal places (0.00-100.00) - chunk_callback: Async callback for streaming chunks (optional) - headers: HTTP headers dict (optional) - speed_callback: Async callback for speed updates (optional) - Called with bytes_per_second as float - - Returns: - bytes: Downloaded content (if outfile and chunk_callback are None) - bool: True if successful (when using outfile or chunk_callback) - """ - self.url_received = url - self.headers_received = headers - - # Record call in history - self.call_history.append({ - 'url': url, - 'outfile': outfile, - 'total_size': total_size, - 'headers': headers, - 'has_progress_callback': progress_callback is not None, - 'has_chunk_callback': chunk_callback is not None, - 'has_speed_callback': speed_callback is not None - }) - - if self.should_fail: - if outfile or chunk_callback: - return False - return None - - # Check for immediate failure (fail_after_bytes=0) - if self.fail_after_bytes is not None and self.fail_after_bytes == 0: - raise OSError(-113, "ECONNABORTED") - - # Stream data in chunks - bytes_sent = 0 - chunks = [] - total_data_size = len(self.download_data) - - # Use provided total_size or actual data size for progress calculation - effective_total_size = total_size if total_size else total_data_size - - # Track progress to avoid duplicate callbacks - last_progress_pct = -1.0 - - # Track speed reporting (simulate every ~1000 bytes for testing) - bytes_since_speed_update = 0 - speed_update_threshold = 1000 - - while bytes_sent < total_data_size: - # Check if we should simulate network failure - if self.fail_after_bytes is not None and bytes_sent >= self.fail_after_bytes: - raise OSError(-113, "ECONNABORTED") - - chunk = self.download_data[bytes_sent:bytes_sent + self.chunk_size] - - if chunk_callback: - await chunk_callback(chunk) - elif outfile: - # For file mode, we'd write to file (mock just tracks) - pass - else: - chunks.append(chunk) - - bytes_sent += len(chunk) - bytes_since_speed_update += len(chunk) - - # Report progress with 2-decimal precision (like real DownloadManager) - # Only call callback if progress changed by at least 0.01% - if progress_callback and effective_total_size > 0: - percent = round((bytes_sent * 100) / effective_total_size, 2) - if percent != last_progress_pct: - await progress_callback(percent) - last_progress_pct = percent - - # Report speed periodically - if speed_callback and bytes_since_speed_update >= speed_update_threshold: - await speed_callback(self.simulated_speed_bps) - bytes_since_speed_update = 0 - - # Return based on mode - if outfile or chunk_callback: - return True - else: - return b''.join(chunks) - - def set_download_data(self, data): - """ - Configure the data to return from downloads. - - Args: - data: Bytes to return from download - """ - self.download_data = data - - def set_should_fail(self, should_fail): - """ - Configure whether downloads should fail. - - Args: - should_fail: True to make downloads fail - """ - self.should_fail = should_fail - - def set_fail_after_bytes(self, bytes_count): - """ - Configure network failure after specified bytes. - - Args: - bytes_count: Number of bytes to send before failing - """ - self.fail_after_bytes = bytes_count - - def clear_history(self): - """Clear the call history.""" - self.call_history = [] - - -class MockTaskManager: - """ - Mock TaskManager for testing async operations. - - Provides mock implementations of TaskManager methods for testing. - """ - - def __init__(self): - """Initialize mock task manager.""" - self.tasks_created = [] - self.sleep_calls = [] - - @classmethod - def create_task(cls, coroutine): - """ - Mock create_task - just runs the coroutine synchronously for testing. - - Args: - coroutine: Coroutine to execute - - Returns: - The coroutine (for compatibility) - """ - # In tests, we typically run with asyncio.run() so just return the coroutine - return coroutine - - @staticmethod - async def sleep(seconds): - """ - Mock async sleep. - - Args: - seconds: Number of seconds to sleep (ignored in mock) - """ - pass # Don't actually sleep in tests - - @staticmethod - async def sleep_ms(milliseconds): - """ - Mock async sleep in milliseconds. - - Args: - milliseconds: Number of milliseconds to sleep (ignored in mock) - """ - pass # Don't actually sleep in tests - - @staticmethod - async def wait_for(awaitable, timeout): - """ - Mock wait_for with timeout. - - Args: - awaitable: Coroutine to await - timeout: Timeout in seconds (ignored in mock) - - Returns: - Result of the awaitable - """ - return await awaitable - - @staticmethod - def notify_event(): - """ - Create a mock async event. - - Returns: - A simple mock event object - """ - class MockEvent: - def __init__(self): - self._set = False - - async def wait(self): - pass - - def set(self): - self._set = True - - def is_set(self): - return self._set - - return MockEvent() +__all__ = [ + # Hardware mocks + 'MockMachine', + 'MockPin', + 'MockPWM', + 'MockI2S', + 'MockTimer', + 'MockSocket', + + # MPOS mocks + 'MockTaskManager', + 'MockTask', + 'MockDownloadManager', + + # Network mocks + 'MockNetwork', + 'MockRequests', + 'MockResponse', + 'MockRaw', + + # Utility mocks + 'MockTime', + 'MockJSON', + 'MockModule', + + # Helper functions + 'inject_mocks', + 'create_mock_module', + 'socket', +] diff --git a/tests/test_audioflinger.py b/tests/test_audioflinger.py index 039d6b1d..3a4e3b47 100644 --- a/tests/test_audioflinger.py +++ b/tests/test_audioflinger.py @@ -2,66 +2,21 @@ import unittest import sys +# Import centralized mocks +from mpos.testing import ( + MockMachine, + MockPWM, + MockPin, + MockTaskManager, + create_mock_module, + inject_mocks, +) -# Mock hardware before importing -class MockPWM: - def __init__(self, pin, freq=0, duty=0): - self.pin = pin - self.last_freq = freq - self.last_duty = duty - - def freq(self, value=None): - if value is not None: - self.last_freq = value - return self.last_freq - - def duty_u16(self, value=None): - if value is not None: - self.last_duty = value - return self.last_duty - - -class MockPin: - IN = 0 - OUT = 1 - - def __init__(self, pin_number, mode=None): - self.pin_number = pin_number - self.mode = mode - - -# Inject mocks -class MockMachine: - PWM = MockPWM - Pin = MockPin -sys.modules['machine'] = MockMachine() - -class MockLock: - def acquire(self): - pass - def release(self): - pass - -class MockThread: - @staticmethod - def allocate_lock(): - return MockLock() - @staticmethod - def start_new_thread(func, args, **kwargs): - pass # No-op for testing - @staticmethod - def stack_size(size=None): - return 16384 if size is None else None - -sys.modules['_thread'] = MockThread() - -class MockMposApps: - @staticmethod - def good_stack_size(): - return 16384 - -sys.modules['mpos.apps'] = MockMposApps() - +# Inject mocks before importing AudioFlinger +inject_mocks({ + 'machine': MockMachine(), + 'mpos.task_manager': create_mock_module('mpos.task_manager', TaskManager=MockTaskManager), +}) # Now import the module to test import mpos.audio.audioflinger as AudioFlinger @@ -79,7 +34,6 @@ class TestAudioFlinger(unittest.TestCase): AudioFlinger.set_volume(70) AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BOTH, i2s_pins=self.i2s_pins, buzzer_instance=self.buzzer ) @@ -90,16 +44,28 @@ class TestAudioFlinger(unittest.TestCase): def test_initialization(self): """Test that AudioFlinger initializes correctly.""" - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BOTH) self.assertEqual(AudioFlinger._i2s_pins, self.i2s_pins) self.assertEqual(AudioFlinger._buzzer_instance, self.buzzer) - def test_device_types(self): - """Test device type constants.""" - self.assertEqual(AudioFlinger.DEVICE_NULL, 0) - self.assertEqual(AudioFlinger.DEVICE_I2S, 1) - self.assertEqual(AudioFlinger.DEVICE_BUZZER, 2) - self.assertEqual(AudioFlinger.DEVICE_BOTH, 3) + def test_has_i2s(self): + """Test has_i2s() returns correct value.""" + # With I2S configured + AudioFlinger.init(i2s_pins=self.i2s_pins, buzzer_instance=None) + self.assertTrue(AudioFlinger.has_i2s()) + + # Without I2S configured + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + self.assertFalse(AudioFlinger.has_i2s()) + + def test_has_buzzer(self): + """Test has_buzzer() returns correct value.""" + # With buzzer configured + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + self.assertTrue(AudioFlinger.has_buzzer()) + + # Without buzzer configured + AudioFlinger.init(i2s_pins=self.i2s_pins, buzzer_instance=None) + self.assertFalse(AudioFlinger.has_buzzer()) def test_stream_types(self): """Test stream type constants and priority order.""" @@ -124,58 +90,34 @@ class TestAudioFlinger(unittest.TestCase): AudioFlinger.set_volume(-10) self.assertEqual(AudioFlinger.get_volume(), 0) - def test_device_null_rejects_playback(self): - """Test that DEVICE_NULL rejects all playback requests.""" - # Re-initialize with no device - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None - ) - - # WAV should be rejected - result = AudioFlinger.play_wav("test.wav") - self.assertFalse(result) - - # RTTTL should be rejected - result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") - self.assertFalse(result) - - def test_device_i2s_only_rejects_rtttl(self): - """Test that DEVICE_I2S rejects buzzer playback.""" - # Re-initialize with I2S only - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins=self.i2s_pins, - buzzer_instance=None - ) - - # RTTTL should be rejected (no buzzer) - result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") - self.assertFalse(result) - - def test_device_buzzer_only_rejects_wav(self): - """Test that DEVICE_BUZZER rejects I2S playback.""" - # Re-initialize with buzzer only - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BUZZER, - i2s_pins=None, - buzzer_instance=self.buzzer - ) + def test_no_hardware_rejects_playback(self): + """Test that no hardware rejects all playback requests.""" + # Re-initialize with no hardware + AudioFlinger.init(i2s_pins=None, buzzer_instance=None) # WAV should be rejected (no I2S) result = AudioFlinger.play_wav("test.wav") self.assertFalse(result) - def test_missing_i2s_pins_rejects_wav(self): - """Test that missing I2S pins rejects WAV playback.""" - # Re-initialize with I2S device but no pins - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins=None, - buzzer_instance=None - ) + # RTTTL should be rejected (no buzzer) + result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") + self.assertFalse(result) + def test_i2s_only_rejects_rtttl(self): + """Test that I2S-only config rejects buzzer playback.""" + # Re-initialize with I2S only + AudioFlinger.init(i2s_pins=self.i2s_pins, buzzer_instance=None) + + # RTTTL should be rejected (no buzzer) + result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c") + self.assertFalse(result) + + def test_buzzer_only_rejects_wav(self): + """Test that buzzer-only config rejects I2S playback.""" + # Re-initialize with buzzer only + AudioFlinger.init(i2s_pins=None, buzzer_instance=self.buzzer) + + # WAV should be rejected (no I2S) result = AudioFlinger.play_wav("test.wav") self.assertFalse(result) @@ -189,55 +131,13 @@ class TestAudioFlinger(unittest.TestCase): AudioFlinger.stop() self.assertFalse(AudioFlinger.is_playing()) - def test_get_device_type(self): - """Test that get_device_type() returns correct value.""" - # Test DEVICE_BOTH - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BOTH, - i2s_pins=self.i2s_pins, - buzzer_instance=self.buzzer - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BOTH) - - # Test DEVICE_I2S - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_I2S, - i2s_pins=self.i2s_pins, - buzzer_instance=None - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_I2S) - - # Test DEVICE_BUZZER - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_BUZZER, - i2s_pins=None, - buzzer_instance=self.buzzer - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BUZZER) - - # Test DEVICE_NULL - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None - ) - self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_NULL) - def test_audio_focus_check_no_current_stream(self): """Test audio focus allows playback when no stream is active.""" result = AudioFlinger._check_audio_focus(AudioFlinger.STREAM_MUSIC) self.assertTrue(result) - def test_init_creates_lock(self): - """Test that initialization creates a stream lock.""" - self.assertIsNotNone(AudioFlinger._stream_lock) - def test_volume_default_value(self): """Test that default volume is reasonable.""" # After init, volume should be at default (70) - AudioFlinger.init( - device_type=AudioFlinger.DEVICE_NULL, - i2s_pins=None, - buzzer_instance=None - ) + AudioFlinger.init(i2s_pins=None, buzzer_instance=None) self.assertEqual(AudioFlinger.get_volume(), 70)