You've already forked MicroPythonOS
mirror of
https://github.com/m5stack/MicroPythonOS.git
synced 2026-05-20 11:51:27 -07:00
Add unit tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user