Add unit tests

This commit is contained in:
Thomas Farstrike
2025-11-14 12:45:04 +01:00
parent 58157bc3f3
commit 47d5c3723f
4 changed files with 1217 additions and 2 deletions
-2
View File
@@ -19,8 +19,6 @@
- Add fragmentation support for aiohttp_ws library
Known issues:
- Camera app: one in two times, camera image stays blank (workaround: close and re-open it)
- OSUpdate app: long changelog can't be scrolled without touchscreen (workaround: read the changelog here)
- Fri3d-2024 Badge: joystick arrow up ticks a radio button (workaround: un-tick the radio button)
0.3.1
+431
View File
@@ -0,0 +1,431 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
MicroPythonOS is an embedded operating system that runs on ESP32 hardware (particularly the Waveshare ESP32-S3-Touch-LCD-2) and desktop Linux/macOS. It provides an LVGL-based UI framework with an Android-inspired app architecture featuring Activities, Intents, and a PackageManager.
The OS supports:
- Touch and non-touch input devices (keyboard/joystick navigation)
- Camera with QR decoding (using quirc)
- WiFi connectivity
- Over-the-air (OTA) firmware updates
- App installation via MPK packages
- Bitcoin Lightning and Nostr protocols
## Repository Structure
### Core Directories
- `internal_filesystem/`: The runtime filesystem containing the OS and apps
- `boot.py`: Hardware initialization for ESP32-S3-Touch-LCD-2
- `boot_unix.py`: Desktop-specific boot initialization
- `main.py`: UI initialization, theme setup, and launcher start
- `lib/mpos/`: Core OS library (apps, config, UI, content management)
- `apps/`: User-installed apps (symlinks to external app repos)
- `builtin/`: System apps frozen into the firmware (launcher, appstore, settings, etc.)
- `data/`: Static data files
- `sdcard/`: SD card mount point
- `lvgl_micropython/`: Submodule containing LVGL bindings for MicroPython
- `micropython-camera-API/`: Submodule for camera support
- `micropython-nostr/`: Submodule for Nostr protocol
- `c_mpos/`: C extension modules (includes quirc for QR decoding)
- `secp256k1-embedded-ecdh/`: Submodule for cryptographic operations
- `manifests/`: Build manifests defining what gets frozen into firmware
- `freezeFS/`: Files to be frozen into the built-in filesystem
- `scripts/`: Build and deployment scripts
- `tests/`: Test suite (both unit tests and manual tests)
### Key Architecture Components
**App System**: Similar to Android
- Apps are identified by reverse-domain names (e.g., `com.micropythonos.camera`)
- Each app has a `META-INF/MANIFEST.JSON` with metadata and activity definitions
- Activities extend `mpos.app.activity.Activity` class (import: `from mpos.app.activity import Activity`)
- Apps implement `onCreate()` to set up their UI and `onDestroy()` for cleanup
- Activity lifecycle: `onCreate()``onStart()``onResume()``onPause()``onStop()``onDestroy()`
- Apps are packaged as `.mpk` files (zip archives)
- Built-in system apps (frozen into firmware): launcher, appstore, settings, wifi, osupdate, about
**UI Framework**: Built on LVGL 9.3.0
- `mpos.ui.topmenu`: Notification bar and drawer (top menu)
- `mpos.ui.display`: Root screen initialization
- Gesture support: left-edge swipe for back, top-edge swipe for menu
- Theme system with configurable colors and light/dark modes
- Focus groups for keyboard/joystick navigation
**Content Management**:
- `PackageManager`: Install/uninstall/query apps
- `Intent`: Launch activities with action/category filters
- `SharedPreferences`: Per-app key-value storage (similar to Android)
**Hardware Abstraction**:
- `boot.py` configures SPI, I2C, display (ST7789), touchscreen (CST816S), and battery ADC
- Platform detection via `sys.platform` ("esp32" vs others)
- Different boot files per hardware variant (boot_fri3d-2024.py, etc.)
## Build System
### Building Firmware
The main build script is `scripts/build_mpos.sh`:
```bash
# Development build (no frozen filesystem, requires ./scripts/install.sh after flashing)
./scripts/build_mpos.sh unix dev
# Production build (with frozen filesystem)
./scripts/build_mpos.sh unix prod
# ESP32 builds (specify hardware variant)
./scripts/build_mpos.sh esp32 dev waveshare-esp32-s3-touch-lcd-2
./scripts/build_mpos.sh esp32 prod fri3d-2024
```
**Build types**:
- `dev`: No preinstalled files or builtin filesystem. Boots to black screen until you run `./scripts/install.sh`
- `prod`: Files from `manifest*.py` are frozen into firmware. Run `./scripts/freezefs_mount_builtin.sh` before building
**Targets**:
- `esp32`: ESP32-S3 hardware (requires subtarget: `waveshare-esp32-s3-touch-lcd-2` or `fri3d-2024`)
- `unix`: Linux desktop
- `macOS`: macOS desktop
The build system uses `lvgl_micropython/make.py` which wraps MicroPython's build system. It:
1. Fetches SDL tags for desktop builds
2. Patches manifests to include camera and asyncio support
3. Creates symlinks for C modules (secp256k1, c_mpos)
4. Runs the lvgl_micropython build with appropriate flags
**ESP32 build configuration**:
- Board: `ESP32_GENERIC_S3` with `SPIRAM_OCT` variant
- Display driver: `st7789`
- Input device: `cst816s`
- OTA enabled with 4MB partition size (16MB total flash)
- Dual-core threading enabled (no GIL)
- User C modules: camera, secp256k1, c_mpos/quirc
**Desktop build configuration**:
- Display: `sdl_display`
- Input: `sdl_pointer`, `sdl_keyboard`
- Compiler flags: `-g -O0 -ggdb -ljpeg` (debug symbols enabled)
- STRIP is disabled to keep debug symbols
### Building and Bundling Apps
Apps can be bundled into `.mpk` files:
```bash
./scripts/bundle_apps.sh
```
### Running on Desktop
```bash
# Run normally (starts launcher)
./scripts/run_desktop.sh
# Run a specific Python script directly
./scripts/run_desktop.sh path/to/script.py
# Run a specific app by name
./scripts/run_desktop.sh com.micropythonos.camera
```
**Important environment variables**:
- `HEAPSIZE`: Set heap size (default 8M, matches ESP32-S3 PSRAM). Increase for memory-intensive apps
- `SDL_WINDOW_FULLSCREEN`: Set to `true` for fullscreen mode
The script automatically selects the correct binary (`lvgl_micropy_unix` or `lvgl_micropy_macOS`) and runs from the `internal_filesystem/` directory.
## Deploying to Hardware
### Flashing Firmware
```bash
# Flash firmware over USB
./scripts/flash_over_usb.sh
```
### Installing Files to Device
```bash
# Install all files to device (boot.py, main.py, lib/, apps/, builtin/)
./scripts/install.sh waveshare-esp32-s3-touch-lcd-2
# Install a single app to device
./scripts/install.sh waveshare-esp32-s3-touch-lcd-2 camera
```
Uses `mpremote` from MicroPython tools to copy files over serial connection.
## Testing
### Running Tests
Tests are in the `tests/` directory. There are two types: unit tests and manual tests.
**Unit tests** (automated, run on desktop or device):
```bash
# Run all unit tests on desktop
./tests/unittest.sh
# Run a specific test file on desktop
./tests/unittest.sh tests/test_shared_preferences.py
./tests/unittest.sh tests/test_intent.py
./tests/unittest.sh tests/test_package_manager.py
./tests/unittest.sh tests/test_start_app.py
# Run a specific test on connected device (via mpremote)
./tests/unittest.sh tests/test_shared_preferences.py ondevice
```
The `unittest.sh` script:
- Automatically detects the platform (Linux/macOS) and uses the correct binary
- Sets up the proper paths and heapsize
- Can run tests on device using `mpremote` with the `ondevice` argument
- Runs all `test_*.py` files when no argument is provided
**Available unit test modules**:
- `test_shared_preferences.py`: Tests for `mpos.config.SharedPreferences` (configuration storage)
- `test_intent.py`: Tests for `mpos.content.intent.Intent` (intent creation, extras, flags)
- `test_package_manager.py`: Tests for `PackageManager` (version comparison, app discovery)
- `test_start_app.py`: Tests for app launching (requires SDL display initialization)
**Manual tests** (interactive, for hardware-specific features):
- `manual_test_camera.py`: Camera and QR scanning
- `manual_test_nostr_asyncio.py`: Nostr protocol
- `manual_test_nwcwallet*.py`: Lightning wallet connectivity (Alby, Cashu)
- `manual_test_lnbitswallet.py`: LNbits wallet integration
- `test_websocket.py`: WebSocket functionality
- `test_multi_connect.py`: Multiple concurrent connections
Run manual tests with:
```bash
./scripts/run_desktop.sh tests/manual_test_camera.py
```
### Writing New Tests
**Unit test guidelines**:
- Use Python's `unittest` module (compatible with MicroPython)
- Place tests in `tests/` directory with `test_*.py` naming
- Use `setUp()` and `tearDown()` for test fixtures
- Clean up any created files/directories in `tearDown()`
- Tests should be runnable on desktop (unix build) without hardware dependencies
- Use descriptive test names: `test_<what_is_being_tested>`
- Group related tests in test classes
**Example test structure**:
```python
import unittest
from mpos.some_module import SomeClass
class TestSomeClass(unittest.TestCase):
def setUp(self):
# Initialize test fixtures
pass
def tearDown(self):
# Clean up after test
pass
def test_some_functionality(self):
# Arrange
obj = SomeClass()
# Act
result = obj.some_method()
# Assert
self.assertEqual(result, expected_value)
```
## Development Workflow
### Creating a New App
1. Create app directory: `internal_filesystem/apps/com.example.myapp/`
2. Create `META-INF/MANIFEST.JSON` with app metadata and activities
3. Create `assets/` directory for Python code
4. Create main activity file extending `Activity` class
5. Implement `onCreate()` method to build UI
6. Optional: Create `res/` directory for resources (icons, images)
**Minimal app structure**:
```
com.example.myapp/
├── META-INF/
│ └── MANIFEST.JSON
├── assets/
│ └── main_activity.py
└── res/
└── mipmap-mdpi/
└── icon_64x64.png
```
**Minimal Activity code**:
```python
from mpos.app.activity import Activity
import lvgl as lv
class MainActivity(Activity):
def onCreate(self):
screen = lv.obj()
label = lv.label(screen)
label.set_text('Hello World!')
label.center()
self.setContentView(screen)
```
See `internal_filesystem/apps/com.micropythonos.helloworld/` for a minimal example and built-in apps in `internal_filesystem/builtin/apps/` for more complex examples.
### Testing App Changes
For rapid iteration on desktop:
```bash
# Build desktop version (only needed once)
./scripts/build_mpos.sh unix dev
# Install filesystem to device (run after code changes)
./scripts/install.sh waveshare-esp32-s3-touch-lcd-2
# Or run directly on desktop
./scripts/run_desktop.sh com.example.myapp
```
### Debugging
Desktop builds include debug symbols by default. Use GDB:
```bash
gdb --args ./lvgl_micropython/build/lvgl_micropy_unix -X heapsize=8M -v -i -c "$(cat boot_unix.py main.py)"
```
For ESP32 debugging, enable core dumps:
```bash
./scripts/core_dump_activate.sh
```
## Important Constraints
### Memory Management
ESP32-S3 has 8MB PSRAM. Memory-intensive operations:
- Camera images consume ~2.5MB per frame
- LVGL image cache must be managed with `lv.image.cache_drop(None)`
- Large UI components should be created/destroyed rather than hidden
- Use `gc.collect()` strategically after deallocating large objects
### Threading
- Main UI/LVGL operations must run on main thread
- Background tasks use `_thread.start_new_thread()`
- Stack size: 16KB for ESP32, 24KB for desktop (see `mpos.apps.good_stack_size()`)
- Use `mpos.ui.async_call()` to safely invoke UI operations from background threads
### Async Operations
- OS uses `uasyncio` for networking (WebSockets, HTTP, Nostr)
- WebSocket library is custom `websocket.py` using uasyncio
- HTTP uses `aiohttp` package (in `lib/aiohttp/`)
- Async tasks are throttled per frame to prevent memory overflow
### File Paths
- Use `M:/path/to/file` prefix for LVGL file operations (registered in main.py)
- Absolute paths for Python imports
- Apps run with their directory added to `sys.path`
## Build Dependencies
The build requires all git submodules checked out recursively:
```bash
git submodule update --init --recursive
```
**Desktop dependencies**: See `.github/workflows/build.yml` for full list including:
- SDL2 development libraries
- Mesa/EGL libraries
- libjpeg
- Python 3.8+
- cmake, ninja-build
## Manifest System
Manifests define what gets frozen into firmware:
- `manifests/manifest.py`: ESP32 production builds
- `manifests/manifest_fri3d-2024.py`: Fri3d Camp 2024 Badge variant
- `manifests/manifest_unix.py`: Desktop builds
Manifests use `freeze()` directives to include files in the frozen filesystem. Frozen files are baked into the firmware and cannot be modified at runtime.
## Version Management
Versions are tracked in:
- `CHANGELOG.md`: User-facing changelog with release history
- App versions in `META-INF/MANIFEST.JSON` files
- OS update system checks `hardware_id` from `mpos.info.get_hardware_id()`
Current stable version: 0.3.3 (as of latest CHANGELOG entry)
## Critical Code Locations
- App lifecycle: `internal_filesystem/lib/mpos/apps.py:execute_script()`
- Activity base class: `internal_filesystem/lib/mpos/app/activity.py`
- Package management: `internal_filesystem/lib/mpos/content/package_manager.py`
- Intent system: `internal_filesystem/lib/mpos/content/intent.py`
- UI initialization: `internal_filesystem/main.py`
- Hardware init: `internal_filesystem/boot.py`
- Config/preferences: `internal_filesystem/lib/mpos/config.py`
- Top menu/drawer: `internal_filesystem/lib/mpos/ui/topmenu.py`
- Activity navigation: `internal_filesystem/lib/mpos/activity_navigator.py`
## Common Utilities and Helpers
**SharedPreferences**: Persistent key-value storage per app
```python
from mpos.config import SharedPreferences
# Load preferences
prefs = SharedPreferences("com.example.myapp")
value = prefs.get_string("key", "default_value")
number = prefs.get_int("count", 0)
data = prefs.get_dict("data", {})
# Save preferences
editor = prefs.edit()
editor.put_string("key", "value")
editor.put_int("count", 42)
editor.put_dict("data", {"key": "value"})
editor.commit()
```
**Intent system**: Launch activities and pass data
```python
from mpos.content.intent import Intent
# Launch activity by name
intent = Intent()
intent.setClassName("com.micropythonos.camera", "Camera")
self.startActivity(intent)
# Launch with extras
intent.putExtra("key", "value")
self.startActivityForResult(intent, self.handle_result)
def handle_result(self, result):
if result["result_code"] == Activity.RESULT_OK:
data = result["data"]
```
**UI utilities**:
- `mpos.ui.async_call(func, *args, **kwargs)`: Safely call UI operations from background threads
- `mpos.ui.back_screen()`: Navigate back to previous screen
- `mpos.ui.focus_direction`: Keyboard/joystick navigation helpers
- `mpos.ui.anim`: Animation utilities
**Other utilities**:
- `mpos.apps.good_stack_size()`: Returns appropriate thread stack size for platform (16KB ESP32, 24KB desktop)
- `mpos.wifi`: WiFi management utilities
- `mpos.sdcard.SDCardManager`: SD card mounting and management
- `mpos.clipboard`: System clipboard access
- `mpos.battery_voltage`: Battery level reading (ESP32 only)
+306
View File
@@ -0,0 +1,306 @@
import unittest
from mpos.content.intent import Intent
class TestIntent(unittest.TestCase):
"""Test suite for Intent class."""
# ============================================================
# Intent Construction
# ============================================================
def test_empty_intent(self):
"""Test creating an empty intent."""
intent = Intent()
self.assertIsNone(intent.activity_class)
self.assertIsNone(intent.action)
self.assertIsNone(intent.data)
self.assertEqual(intent.extras, {})
self.assertEqual(intent.flags, {})
def test_intent_with_activity_class(self):
"""Test creating an intent with an explicit activity class."""
class MockActivity:
pass
intent = Intent(activity_class=MockActivity)
self.assertEqual(intent.activity_class, MockActivity)
self.assertIsNone(intent.action)
def test_intent_with_action(self):
"""Test creating an intent with an action."""
intent = Intent(action="view")
self.assertEqual(intent.action, "view")
self.assertIsNone(intent.activity_class)
def test_intent_with_data(self):
"""Test creating an intent with data."""
intent = Intent(data="https://example.com")
self.assertEqual(intent.data, "https://example.com")
def test_intent_with_extras(self):
"""Test creating an intent with extras dictionary."""
extras = {"user_id": 123, "username": "alice"}
intent = Intent(extras=extras)
self.assertEqual(intent.extras, extras)
def test_intent_with_all_parameters(self):
"""Test creating an intent with all parameters."""
class MockActivity:
pass
extras = {"key": "value"}
intent = Intent(
activity_class=MockActivity,
action="share",
data="some_data",
extras=extras
)
self.assertEqual(intent.activity_class, MockActivity)
self.assertEqual(intent.action, "share")
self.assertEqual(intent.data, "some_data")
self.assertEqual(intent.extras, extras)
# ============================================================
# Extras Operations
# ============================================================
def test_put_extra_single(self):
"""Test adding a single extra to an intent."""
intent = Intent()
intent.putExtra("key", "value")
self.assertEqual(intent.extras["key"], "value")
def test_put_extra_multiple(self):
"""Test adding multiple extras to an intent."""
intent = Intent()
intent.putExtra("key1", "value1")
intent.putExtra("key2", 42)
intent.putExtra("key3", True)
self.assertEqual(intent.extras["key1"], "value1")
self.assertEqual(intent.extras["key2"], 42)
self.assertTrue(intent.extras["key3"])
def test_put_extra_chaining(self):
"""Test that putExtra returns self for method chaining."""
intent = Intent()
result = intent.putExtra("key", "value")
self.assertEqual(result, intent)
# Test actual chaining
intent.putExtra("a", 1).putExtra("b", 2).putExtra("c", 3)
self.assertEqual(intent.extras["a"], 1)
self.assertEqual(intent.extras["b"], 2)
self.assertEqual(intent.extras["c"], 3)
def test_put_extra_overwrites(self):
"""Test that putting an extra with the same key overwrites the value."""
intent = Intent()
intent.putExtra("key", "original")
intent.putExtra("key", "updated")
self.assertEqual(intent.extras["key"], "updated")
def test_put_extra_various_types(self):
"""Test putting extras of various data types."""
intent = Intent()
intent.putExtra("string", "text")
intent.putExtra("int", 123)
intent.putExtra("float", 3.14)
intent.putExtra("bool", True)
intent.putExtra("list", [1, 2, 3])
intent.putExtra("dict", {"nested": "value"})
intent.putExtra("none", None)
self.assertEqual(intent.extras["string"], "text")
self.assertEqual(intent.extras["int"], 123)
self.assertAlmostEqual(intent.extras["float"], 3.14)
self.assertTrue(intent.extras["bool"])
self.assertEqual(intent.extras["list"], [1, 2, 3])
self.assertEqual(intent.extras["dict"]["nested"], "value")
self.assertIsNone(intent.extras["none"])
# ============================================================
# Flag Operations
# ============================================================
def test_add_flag_single(self):
"""Test adding a single flag to an intent."""
intent = Intent()
intent.addFlag("clear_top")
self.assertTrue(intent.flags["clear_top"])
def test_add_flag_with_value(self):
"""Test adding a flag with a specific value."""
intent = Intent()
intent.addFlag("no_history", False)
self.assertFalse(intent.flags["no_history"])
intent.addFlag("no_animation", True)
self.assertTrue(intent.flags["no_animation"])
def test_add_flag_chaining(self):
"""Test that addFlag returns self for method chaining."""
intent = Intent()
result = intent.addFlag("clear_top")
self.assertEqual(result, intent)
# Test actual chaining
intent.addFlag("clear_top").addFlag("no_history").addFlag("no_animation")
self.assertTrue(intent.flags["clear_top"])
self.assertTrue(intent.flags["no_history"])
self.assertTrue(intent.flags["no_animation"])
def test_add_flag_overwrites(self):
"""Test that adding a flag with the same name overwrites the value."""
intent = Intent()
intent.addFlag("flag", True)
intent.addFlag("flag", False)
self.assertFalse(intent.flags["flag"])
def test_multiple_flags(self):
"""Test adding multiple different flags."""
intent = Intent()
intent.addFlag("clear_top", True)
intent.addFlag("no_history", False)
intent.addFlag("custom_flag", True)
self.assertEqual(len(intent.flags), 3)
self.assertTrue(intent.flags["clear_top"])
self.assertFalse(intent.flags["no_history"])
self.assertTrue(intent.flags["custom_flag"])
# ============================================================
# Combined Operations
# ============================================================
def test_chaining_extras_and_flags(self):
"""Test chaining both extras and flags together."""
intent = Intent(action="view")
intent.putExtra("user_id", 123)\
.putExtra("username", "alice")\
.addFlag("clear_top")\
.addFlag("no_history")
self.assertEqual(intent.action, "view")
self.assertEqual(intent.extras["user_id"], 123)
self.assertEqual(intent.extras["username"], "alice")
self.assertTrue(intent.flags["clear_top"])
self.assertTrue(intent.flags["no_history"])
def test_intent_builder_pattern(self):
"""Test using intent as a builder pattern."""
class MockActivity:
pass
intent = Intent()\
.putExtra("key1", "value1")\
.putExtra("key2", 42)\
.addFlag("clear_top")\
.addFlag("no_animation", False)
# Modify after initial creation
intent.activity_class = MockActivity
intent.action = "custom_action"
intent.data = "custom_data"
self.assertEqual(intent.activity_class, MockActivity)
self.assertEqual(intent.action, "custom_action")
self.assertEqual(intent.data, "custom_data")
self.assertEqual(intent.extras["key1"], "value1")
self.assertEqual(intent.extras["key2"], 42)
self.assertTrue(intent.flags["clear_top"])
self.assertFalse(intent.flags["no_animation"])
# ============================================================
# Common Intent Patterns
# ============================================================
def test_view_intent_pattern(self):
"""Test creating a typical 'view' intent."""
intent = Intent(action="view", data="https://micropythonos.com")
intent.putExtra("fullscreen", True)
self.assertEqual(intent.action, "view")
self.assertEqual(intent.data, "https://micropythonos.com")
self.assertTrue(intent.extras["fullscreen"])
def test_share_intent_pattern(self):
"""Test creating a typical 'share' intent."""
intent = Intent(action="share")
intent.putExtra("text", "Check out MicroPythonOS!")
intent.putExtra("subject", "Cool OS")
self.assertEqual(intent.action, "share")
self.assertEqual(intent.extras["text"], "Check out MicroPythonOS!")
self.assertEqual(intent.extras["subject"], "Cool OS")
def test_launcher_intent_pattern(self):
"""Test creating a typical launcher intent."""
intent = Intent(action="main")
intent.addFlag("clear_top")
self.assertEqual(intent.action, "main")
self.assertTrue(intent.flags["clear_top"])
def test_scan_qr_intent_pattern(self):
"""Test creating a scan QR code intent (from camera app)."""
intent = Intent(action="scan_qr_code")
intent.putExtra("result_key", "qr_data")
self.assertEqual(intent.action, "scan_qr_code")
self.assertEqual(intent.extras["result_key"], "qr_data")
# ============================================================
# Edge Cases
# ============================================================
def test_empty_strings(self):
"""Test intent with empty strings."""
intent = Intent(action="", data="")
intent.putExtra("empty", "")
self.assertEqual(intent.action, "")
self.assertEqual(intent.data, "")
self.assertEqual(intent.extras["empty"], "")
def test_special_characters_in_extras(self):
"""Test extras with special characters in keys."""
intent = Intent()
intent.putExtra("key.with.dots", "value1")
intent.putExtra("key_with_underscores", "value2")
intent.putExtra("key-with-dashes", "value3")
self.assertEqual(intent.extras["key.with.dots"], "value1")
self.assertEqual(intent.extras["key_with_underscores"], "value2")
self.assertEqual(intent.extras["key-with-dashes"], "value3")
def test_unicode_in_extras(self):
"""Test extras with Unicode strings."""
intent = Intent()
intent.putExtra("greeting", "Hello 世界")
intent.putExtra("emoji", "🚀")
self.assertEqual(intent.extras["greeting"], "Hello 世界")
self.assertEqual(intent.extras["emoji"], "🚀")
def test_complex_extras_data(self):
"""Test extras with complex nested data structures."""
intent = Intent()
complex_data = {
"users": ["alice", "bob"],
"config": {
"timeout": 30,
"retry": True
}
}
intent.putExtra("data", complex_data)
self.assertEqual(intent.extras["data"]["users"][0], "alice")
self.assertEqual(intent.extras["data"]["config"]["timeout"], 30)
self.assertTrue(intent.extras["data"]["config"]["retry"])
if __name__ == '__main__':
unittest.main()
+480
View File
@@ -0,0 +1,480 @@
import unittest
import os
from mpos.config import SharedPreferences, Editor
class TestSharedPreferences(unittest.TestCase):
"""Test suite for SharedPreferences configuration storage."""
def setUp(self):
"""Set up test fixtures before each test method."""
self.test_app_name = "com.test.unittest"
self.test_dir = f"data/{self.test_app_name}"
self.test_file = f"{self.test_dir}/config.json"
# Clean up any existing test data
self._cleanup()
def tearDown(self):
"""Clean up test fixtures after each test method."""
self._cleanup()
def _cleanup(self):
"""Remove test data directory if it exists."""
try:
# Use os.stat() instead of os.path.exists() for MicroPython compatibility
try:
os.stat(self.test_file)
os.remove(self.test_file)
except OSError:
pass # File doesn't exist, that's fine
try:
os.stat(self.test_dir)
os.rmdir(self.test_dir)
except OSError:
pass # Directory doesn't exist, that's fine
try:
os.stat("data")
# Try to remove data directory, but it might have other contents
try:
os.rmdir("data")
except OSError:
# Directory not empty, that's okay
pass
except OSError:
pass # Directory doesn't exist, that's fine
except Exception as e:
# Cleanup failure is not critical for tests
print(f"Cleanup warning: {e}")
# ============================================================
# Basic String Operations
# ============================================================
def test_put_get_string(self):
"""Test storing and retrieving a string value."""
prefs = SharedPreferences(self.test_app_name)
editor = prefs.edit()
editor.put_string("username", "testuser")
editor.commit()
# Reload to verify persistence
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_string("username"), "testuser")
def test_get_string_default(self):
"""Test getting a string with default value when key doesn't exist."""
prefs = SharedPreferences(self.test_app_name)
self.assertEqual(prefs.get_string("nonexistent", "default"), "default")
self.assertIsNone(prefs.get_string("nonexistent"))
def test_put_string_overwrites(self):
"""Test that putting a string overwrites existing value."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_string("key", "value1").commit()
prefs.edit().put_string("key", "value2").commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_string("key"), "value2")
# ============================================================
# Integer Operations
# ============================================================
def test_put_get_int(self):
"""Test storing and retrieving an integer value."""
prefs = SharedPreferences(self.test_app_name)
editor = prefs.edit()
editor.put_int("count", 42)
editor.commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_int("count"), 42)
def test_get_int_default(self):
"""Test getting an integer with default value when key doesn't exist."""
prefs = SharedPreferences(self.test_app_name)
self.assertEqual(prefs.get_int("nonexistent", 100), 100)
self.assertEqual(prefs.get_int("nonexistent"), 0)
def test_get_int_invalid_type(self):
"""Test getting an integer when stored value is invalid."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_string("invalid", "not_a_number").commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_int("invalid", 99), 99)
# ============================================================
# Boolean Operations
# ============================================================
def test_put_get_bool(self):
"""Test storing and retrieving boolean values."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_bool("enabled", True).commit()
prefs.edit().put_bool("disabled", False).commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertTrue(prefs2.get_bool("enabled"))
self.assertFalse(prefs2.get_bool("disabled"))
def test_get_bool_default(self):
"""Test getting a boolean with default value when key doesn't exist."""
prefs = SharedPreferences(self.test_app_name)
self.assertTrue(prefs.get_bool("nonexistent", True))
self.assertFalse(prefs.get_bool("nonexistent", False))
self.assertFalse(prefs.get_bool("nonexistent"))
# ============================================================
# List Operations
# ============================================================
def test_put_get_list(self):
"""Test storing and retrieving a list."""
prefs = SharedPreferences(self.test_app_name)
test_list = [1, 2, 3, "four"]
prefs.edit().put_list("mylist", test_list).commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_list("mylist"), test_list)
def test_get_list_default(self):
"""Test getting a list with default value when key doesn't exist."""
prefs = SharedPreferences(self.test_app_name)
self.assertEqual(prefs.get_list("nonexistent"), [])
self.assertEqual(prefs.get_list("nonexistent", ["a", "b"]), ["a", "b"])
def test_append_to_list(self):
"""Test appending items to a list."""
prefs = SharedPreferences(self.test_app_name)
editor = prefs.edit()
editor.append_to_list("items", {"id": 1, "name": "first"})
editor.append_to_list("items", {"id": 2, "name": "second"})
editor.commit()
prefs2 = SharedPreferences(self.test_app_name)
items = prefs2.get_list("items")
self.assertEqual(len(items), 2)
self.assertEqual(items[0]["id"], 1)
self.assertEqual(items[1]["name"], "second")
def test_update_list_item(self):
"""Test updating an item in a list."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_list("items", [{"a": 1}, {"b": 2}]).commit()
prefs.edit().update_list_item("items", 1, {"b": 99}).commit()
prefs2 = SharedPreferences(self.test_app_name)
items = prefs2.get_list("items")
self.assertEqual(items[1]["b"], 99)
def test_remove_from_list(self):
"""Test removing an item from a list."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_list("items", [{"a": 1}, {"b": 2}, {"c": 3}]).commit()
prefs.edit().remove_from_list("items", 1).commit()
prefs2 = SharedPreferences(self.test_app_name)
items = prefs2.get_list("items")
self.assertEqual(len(items), 2)
self.assertEqual(items[0]["a"], 1)
self.assertEqual(items[1]["c"], 3)
def test_get_list_item(self):
"""Test getting a specific field from a list item."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_list("users", [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}
]).commit()
self.assertEqual(prefs.get_list_item("users", 0, "name"), "Alice")
self.assertEqual(prefs.get_list_item("users", 1, "age"), 25)
self.assertIsNone(prefs.get_list_item("users", 99, "name"))
self.assertEqual(prefs.get_list_item("users", 99, "name", "default"), "default")
def test_get_list_item_dict(self):
"""Test getting an entire dictionary from a list."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_list("configs", [{"key": "value"}]).commit()
item = prefs.get_list_item_dict("configs", 0)
self.assertEqual(item["key"], "value")
self.assertEqual(prefs.get_list_item_dict("configs", 99), {})
# ============================================================
# Dictionary Operations
# ============================================================
def test_put_get_dict(self):
"""Test storing and retrieving a dictionary."""
prefs = SharedPreferences(self.test_app_name)
test_dict = {"key1": "value1", "key2": 42}
prefs.edit().put_dict("mydict", test_dict).commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_dict("mydict"), test_dict)
def test_get_dict_default(self):
"""Test getting a dict with default value when key doesn't exist."""
prefs = SharedPreferences(self.test_app_name)
self.assertEqual(prefs.get_dict("nonexistent"), {})
self.assertEqual(prefs.get_dict("nonexistent", {"default": True}), {"default": True})
def test_put_dict_item(self):
"""Test adding items to a dictionary."""
prefs = SharedPreferences(self.test_app_name)
editor = prefs.edit()
editor.put_dict_item("wifi_aps", "HomeNetwork", {"password": "secret123", "priority": 1})
editor.put_dict_item("wifi_aps", "WorkNetwork", {"password": "work456", "priority": 2})
editor.commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_dict_item("wifi_aps", "HomeNetwork")["password"], "secret123")
self.assertEqual(prefs2.get_dict_item("wifi_aps", "WorkNetwork")["priority"], 2)
def test_remove_dict_item(self):
"""Test removing an item from a dictionary."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_dict("items", {"a": 1, "b": 2, "c": 3}).commit()
prefs.edit().remove_dict_item("items", "b").commit()
prefs2 = SharedPreferences(self.test_app_name)
items = prefs2.get_dict("items")
self.assertEqual(len(items), 2)
self.assertIn("a", items)
self.assertIn("c", items)
self.assertFalse("b" in items) # Use 'in' operator instead of assertNotIn
def test_get_dict_item(self):
"""Test getting a specific item from a dictionary."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_dict("settings", {
"theme": {"color": "blue", "size": 14},
"audio": {"volume": 80}
}).commit()
theme = prefs.get_dict_item("settings", "theme")
self.assertEqual(theme["color"], "blue")
self.assertEqual(prefs.get_dict_item("settings", "nonexistent"), {})
def test_get_dict_item_field(self):
"""Test getting a specific field from a dict item."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_dict("networks", {
"ssid1": {"password": "pass1", "signal": 100},
"ssid2": {"password": "pass2", "signal": 50}
}).commit()
self.assertEqual(prefs.get_dict_item_field("networks", "ssid1", "password"), "pass1")
self.assertEqual(prefs.get_dict_item_field("networks", "ssid2", "signal"), 50)
self.assertIsNone(prefs.get_dict_item_field("networks", "ssid99", "password"))
self.assertEqual(prefs.get_dict_item_field("networks", "ssid1", "missing", "def"), "def")
def test_get_dict_keys(self):
"""Test getting all keys from a dictionary."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_dict("items", {"a": 1, "b": 2, "c": 3}).commit()
keys = prefs.get_dict_keys("items")
self.assertEqual(len(keys), 3)
self.assertIn("a", keys)
self.assertIn("b", keys)
self.assertIn("c", keys)
def test_get_dict_keys_nonexistent(self):
"""Test getting keys from a nonexistent dictionary."""
prefs = SharedPreferences(self.test_app_name)
self.assertEqual(prefs.get_dict_keys("nonexistent"), [])
# ============================================================
# Editor Operations
# ============================================================
def test_editor_chaining(self):
"""Test that editor methods can be chained."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit()\
.put_string("name", "test")\
.put_int("count", 5)\
.put_bool("enabled", True)\
.commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_string("name"), "test")
self.assertEqual(prefs2.get_int("count"), 5)
self.assertTrue(prefs2.get_bool("enabled"))
def test_editor_apply_vs_commit(self):
"""Test that both apply and commit save data."""
prefs = SharedPreferences(self.test_app_name)
# Test apply
prefs.edit().put_string("key1", "apply_test").apply()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_string("key1"), "apply_test")
# Test commit
prefs.edit().put_string("key2", "commit_test").commit()
prefs3 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs3.get_string("key2"), "commit_test")
def test_editor_without_commit_doesnt_save(self):
"""Test that changes without commit/apply are not persisted."""
prefs = SharedPreferences(self.test_app_name)
editor = prefs.edit()
editor.put_string("unsaved", "value")
# Don't call commit() or apply()
# Reload and verify data wasn't saved
prefs2 = SharedPreferences(self.test_app_name)
self.assertIsNone(prefs2.get_string("unsaved"))
def test_multiple_edits(self):
"""Test multiple sequential edit operations."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_string("step", "1").commit()
prefs.edit().put_string("step", "2").commit()
prefs.edit().put_string("step", "3").commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_string("step"), "3")
# ============================================================
# File Persistence
# ============================================================
def test_directory_creation(self):
"""Test that directory structure is created automatically."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_string("test", "value").commit()
# Use os.stat() instead of os.path.exists() for MicroPython
try:
os.stat("data")
data_exists = True
except OSError:
data_exists = False
self.assertTrue(data_exists)
try:
os.stat(self.test_dir)
dir_exists = True
except OSError:
dir_exists = False
self.assertTrue(dir_exists)
try:
os.stat(self.test_file)
file_exists = True
except OSError:
file_exists = False
self.assertTrue(file_exists)
def test_custom_filename(self):
"""Test using a custom filename for preferences."""
prefs = SharedPreferences(self.test_app_name, "custom.json")
prefs.edit().put_string("custom", "data").commit()
custom_file = f"{self.test_dir}/custom.json"
# Use os.stat() instead of os.path.exists() for MicroPython
try:
os.stat(custom_file)
file_exists = True
except OSError:
file_exists = False
self.assertTrue(file_exists)
prefs2 = SharedPreferences(self.test_app_name, "custom.json")
self.assertEqual(prefs2.get_string("custom"), "data")
def test_load_existing_file(self):
"""Test loading from an existing preferences file."""
# Create initial prefs
prefs1 = SharedPreferences(self.test_app_name)
prefs1.edit().put_string("existing", "data").commit()
# Load in a new instance
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_string("existing"), "data")
# ============================================================
# Edge Cases and Error Handling
# ============================================================
def test_empty_string_values(self):
"""Test storing and retrieving empty strings."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_string("empty", "").commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_string("empty"), "")
def test_zero_values(self):
"""Test storing and retrieving zero values."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_int("zero", 0).put_bool("false", False).commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_int("zero"), 0)
self.assertFalse(prefs2.get_bool("false"))
def test_none_values(self):
"""Test handling None values gracefully."""
prefs = SharedPreferences(self.test_app_name)
# Getting a nonexistent key should return None or default
self.assertIsNone(prefs.get_string("nonexistent"))
def test_special_characters_in_keys(self):
"""Test keys with special characters."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit()\
.put_string("key.with.dots", "value1")\
.put_string("key_with_underscores", "value2")\
.put_string("key-with-dashes", "value3")\
.commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_string("key.with.dots"), "value1")
self.assertEqual(prefs2.get_string("key_with_underscores"), "value2")
self.assertEqual(prefs2.get_string("key-with-dashes"), "value3")
def test_unicode_values(self):
"""Test storing and retrieving Unicode strings."""
prefs = SharedPreferences(self.test_app_name)
prefs.edit().put_string("unicode", "Hello 世界 🌍").commit()
prefs2 = SharedPreferences(self.test_app_name)
self.assertEqual(prefs2.get_string("unicode"), "Hello 世界 🌍")
def test_large_nested_structure(self):
"""Test storing a complex nested data structure."""
prefs = SharedPreferences(self.test_app_name)
complex_data = {
"users": {
"alice": {"age": 30, "roles": ["admin", "user"]},
"bob": {"age": 25, "roles": ["user"]}
},
"settings": {
"theme": "dark",
"notifications": True,
"limits": [10, 20, 30]
}
}
prefs.edit().put_dict("app_data", complex_data).commit()
prefs2 = SharedPreferences(self.test_app_name)
loaded = prefs2.get_dict("app_data")
self.assertEqual(loaded["users"]["alice"]["age"], 30)
self.assertEqual(loaded["settings"]["theme"], "dark")
self.assertEqual(loaded["settings"]["limits"][2], 30)
if __name__ == '__main__':
unittest.main()