OSUpdate app: eliminate thread by using TaskManager and DownloadManager

This commit is contained in:
Thomas Farstrike
2025-12-17 14:40:30 +01:00
parent 1038dd828c
commit 4b9a147deb
3 changed files with 548 additions and 276 deletions
File diff suppressed because it is too large Load Diff
+214
View File
@@ -680,3 +680,217 @@ class MockTime:
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
async def download_url(self, url, outfile=None, total_size=None,
progress_callback=None, chunk_callback=None, headers=None):
"""
Mock async download with flexible output modes.
Simulates the real DownloadManager behavior including:
- Streaming chunks via chunk_callback
- Progress reporting via progress_callback (based on total size)
- 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)
chunk_callback: Async callback for streaming chunks (optional)
headers: HTTP headers dict (optional)
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
})
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
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)
# Report progress (like real DownloadManager does)
if progress_callback and effective_total_size > 0:
percent = round((bytes_sent * 100) / effective_total_size)
await progress_callback(percent)
# 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()
+112 -105
View File
@@ -1,12 +1,13 @@
import unittest
import sys
import asyncio
# Add parent directory to path so we can import network_test_helper
# When running from unittest.sh, we're in internal_filesystem/, so tests/ is ../tests/
sys.path.insert(0, '../tests')
# Import network test helpers
from network_test_helper import MockNetwork, MockRequests, MockJSON
from network_test_helper import MockNetwork, MockRequests, MockJSON, MockDownloadManager
class MockPartition:
@@ -42,6 +43,11 @@ sys.path.append('builtin/apps/com.micropythonos.osupdate/assets')
from osupdate import UpdateChecker, UpdateDownloader, round_up_to_multiple
def run_async(coro):
"""Helper to run async coroutines in sync tests."""
return asyncio.get_event_loop().run_until_complete(coro)
class TestUpdateChecker(unittest.TestCase):
"""Test UpdateChecker class."""
@@ -218,38 +224,37 @@ class TestUpdateChecker(unittest.TestCase):
class TestUpdateDownloader(unittest.TestCase):
"""Test UpdateDownloader class."""
"""Test UpdateDownloader class with async DownloadManager."""
def setUp(self):
self.mock_requests = MockRequests()
self.mock_download_manager = MockDownloadManager()
self.mock_partition = MockPartition
self.downloader = UpdateDownloader(
requests_module=self.mock_requests,
partition_module=self.mock_partition
partition_module=self.mock_partition,
download_manager=self.mock_download_manager
)
def test_download_and_install_success(self):
"""Test successful download and install."""
# Create 8KB of test data (2 blocks of 4096 bytes)
test_data = b'A' * 8192
self.mock_requests.set_next_response(
status_code=200,
headers={'Content-Length': '8192'},
content=test_data
)
self.mock_download_manager.set_download_data(test_data)
self.mock_download_manager.chunk_size = 4096
progress_calls = []
def progress_cb(percent):
async def progress_cb(percent):
progress_calls.append(percent)
result = self.downloader.download_and_install(
"http://example.com/update.bin",
progress_callback=progress_cb
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin",
progress_callback=progress_cb
)
result = run_async(run_test())
self.assertTrue(result['success'])
self.assertEqual(result['bytes_written'], 8192)
self.assertEqual(result['total_size'], 8192)
self.assertIsNone(result['error'])
# MicroPython unittest doesn't have assertGreater
self.assertTrue(len(progress_calls) > 0, "Should have progress callbacks")
@@ -257,21 +262,21 @@ class TestUpdateDownloader(unittest.TestCase):
def test_download_and_install_cancelled(self):
"""Test cancelled download."""
test_data = b'A' * 8192
self.mock_requests.set_next_response(
status_code=200,
headers={'Content-Length': '8192'},
content=test_data
)
self.mock_download_manager.set_download_data(test_data)
self.mock_download_manager.chunk_size = 4096
call_count = [0]
def should_continue():
call_count[0] += 1
return call_count[0] < 2 # Cancel after first chunk
result = self.downloader.download_and_install(
"http://example.com/update.bin",
should_continue_callback=should_continue
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin",
should_continue_callback=should_continue
)
result = run_async(run_test())
self.assertFalse(result['success'])
self.assertIn("cancelled", result['error'].lower())
@@ -280,44 +285,46 @@ class TestUpdateDownloader(unittest.TestCase):
"""Test that last chunk is properly padded."""
# 5000 bytes - not a multiple of 4096
test_data = b'B' * 5000
self.mock_requests.set_next_response(
status_code=200,
headers={'Content-Length': '5000'},
content=test_data
)
self.mock_download_manager.set_download_data(test_data)
self.mock_download_manager.chunk_size = 4096
result = self.downloader.download_and_install(
"http://example.com/update.bin"
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin"
)
result = run_async(run_test())
self.assertTrue(result['success'])
# Should be rounded up to 8192 (2 * 4096)
self.assertEqual(result['total_size'], 8192)
# Should be padded to 8192 (2 * 4096)
self.assertEqual(result['bytes_written'], 8192)
def test_download_with_network_error(self):
"""Test download with network error during transfer."""
self.mock_requests.set_exception(Exception("Network error"))
self.mock_download_manager.set_should_fail(True)
result = self.downloader.download_and_install(
"http://example.com/update.bin"
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin"
)
result = run_async(run_test())
self.assertFalse(result['success'])
self.assertIsNotNone(result['error'])
self.assertIn("Network error", result['error'])
def test_download_with_zero_content_length(self):
"""Test download with missing or zero Content-Length."""
test_data = b'C' * 1000
self.mock_requests.set_next_response(
status_code=200,
headers={}, # No Content-Length header
content=test_data
)
self.mock_download_manager.set_download_data(test_data)
self.mock_download_manager.chunk_size = 1000
result = self.downloader.download_and_install(
"http://example.com/update.bin"
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin"
)
result = run_async(run_test())
# Should still work, just with unknown total size initially
self.assertTrue(result['success'])
@@ -325,60 +332,58 @@ class TestUpdateDownloader(unittest.TestCase):
def test_download_progress_callback_called(self):
"""Test that progress callback is called during download."""
test_data = b'D' * 8192
self.mock_requests.set_next_response(
status_code=200,
headers={'Content-Length': '8192'},
content=test_data
)
self.mock_download_manager.set_download_data(test_data)
self.mock_download_manager.chunk_size = 4096
progress_values = []
def track_progress(percent):
async def track_progress(percent):
progress_values.append(percent)
result = self.downloader.download_and_install(
"http://example.com/update.bin",
progress_callback=track_progress
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin",
progress_callback=track_progress
)
result = run_async(run_test())
self.assertTrue(result['success'])
# Should have at least 2 progress updates (for 2 chunks of 4096)
self.assertTrue(len(progress_values) >= 2)
# Last progress should be 100%
self.assertEqual(progress_values[-1], 100.0)
self.assertEqual(progress_values[-1], 100)
def test_download_small_file(self):
"""Test downloading a file smaller than one chunk."""
test_data = b'E' * 100 # Only 100 bytes
self.mock_requests.set_next_response(
status_code=200,
headers={'Content-Length': '100'},
content=test_data
)
self.mock_download_manager.set_download_data(test_data)
self.mock_download_manager.chunk_size = 100
result = self.downloader.download_and_install(
"http://example.com/update.bin"
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin"
)
result = run_async(run_test())
self.assertTrue(result['success'])
# Should be padded to 4096
self.assertEqual(result['total_size'], 4096)
self.assertEqual(result['bytes_written'], 4096)
def test_download_exact_chunk_multiple(self):
"""Test downloading exactly 2 chunks (no padding needed)."""
test_data = b'F' * 8192 # Exactly 2 * 4096
self.mock_requests.set_next_response(
status_code=200,
headers={'Content-Length': '8192'},
content=test_data
)
self.mock_download_manager.set_download_data(test_data)
self.mock_download_manager.chunk_size = 4096
result = self.downloader.download_and_install(
"http://example.com/update.bin"
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin"
)
result = run_async(run_test())
self.assertTrue(result['success'])
self.assertEqual(result['total_size'], 8192)
self.assertEqual(result['bytes_written'], 8192)
def test_network_error_detection_econnaborted(self):
@@ -417,16 +422,16 @@ class TestUpdateDownloader(unittest.TestCase):
"""Test that download pauses when network error occurs during read."""
# Set up mock to raise network error after first chunk
test_data = b'G' * 16384 # 4 chunks
self.mock_requests.set_next_response(
status_code=200,
headers={'Content-Length': '16384'},
content=test_data,
fail_after_bytes=4096 # Fail after first chunk
)
self.mock_download_manager.set_download_data(test_data)
self.mock_download_manager.chunk_size = 4096
self.mock_download_manager.set_fail_after_bytes(4096) # Fail after first chunk
result = self.downloader.download_and_install(
"http://example.com/update.bin"
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin"
)
result = run_async(run_test())
self.assertFalse(result['success'])
self.assertTrue(result['paused'])
@@ -436,29 +441,27 @@ class TestUpdateDownloader(unittest.TestCase):
def test_download_resumes_from_saved_position(self):
"""Test that download resumes from the last written position."""
# Simulate partial download
test_data = b'H' * 12288 # 3 chunks
self.downloader.bytes_written_so_far = 8192 # Already downloaded 2 chunks
self.downloader.total_size_expected = 12288
# Server should receive Range header
# Server should receive Range header - only remaining data
remaining_data = b'H' * 4096 # Last chunk
self.mock_requests.set_next_response(
status_code=206, # Partial content
headers={'Content-Length': '4096'}, # Remaining bytes
content=remaining_data
)
self.mock_download_manager.set_download_data(remaining_data)
self.mock_download_manager.chunk_size = 4096
result = self.downloader.download_and_install(
"http://example.com/update.bin"
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin"
)
result = run_async(run_test())
self.assertTrue(result['success'])
self.assertEqual(result['bytes_written'], 12288)
# Check that Range header was set
last_request = self.mock_requests.last_request
self.assertIsNotNone(last_request)
self.assertIn('Range', last_request['headers'])
self.assertEqual(last_request['headers']['Range'], 'bytes=8192-')
self.assertIsNotNone(self.mock_download_manager.headers_received)
self.assertIn('Range', self.mock_download_manager.headers_received)
self.assertEqual(self.mock_download_manager.headers_received['Range'], 'bytes=8192-')
def test_resume_failure_preserves_state(self):
"""Test that resume failures preserve download state for retry."""
@@ -466,12 +469,16 @@ class TestUpdateDownloader(unittest.TestCase):
self.downloader.bytes_written_so_far = 245760 # 60 chunks already downloaded
self.downloader.total_size_expected = 3391488
# Resume attempt fails immediately with EHOSTUNREACH (network not ready)
self.mock_requests.set_exception(OSError(-118, "EHOSTUNREACH"))
# Resume attempt fails immediately with network error
self.mock_download_manager.set_download_data(b'')
self.mock_download_manager.set_fail_after_bytes(0) # Fail immediately
result = self.downloader.download_and_install(
"http://example.com/update.bin"
)
async def run_test():
return await self.downloader.download_and_install(
"http://example.com/update.bin"
)
result = run_async(run_test())
# Should pause, not fail
self.assertFalse(result['success'])