first commit

This commit is contained in:
2025-11-10 18:06:11 +01:00
commit 3817b86ad1
38 changed files with 6466 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Unit tests for dreader-hal."""
+207
View File
@@ -0,0 +1,207 @@
"""
Unit tests for dreader_hal.gesture module.
Tests gesture detection state machine and classification logic.
"""
import pytest
import time
from dreader_hal.gesture import GestureDetector, TouchState
from dreader_hal.types import GestureType
class TestGestureDetector:
"""Tests for GestureDetector class."""
def setup_method(self):
"""Set up fresh detector for each test."""
self.detector = GestureDetector()
def test_initial_state(self):
"""Test detector initial state."""
assert self.detector.state == TouchState.IDLE
assert self.detector.start_pos is None
assert self.detector.current_pos is None
def test_tap_detection(self):
"""Test tap gesture detection."""
# Touch down
self.detector.on_touch_down(100, 100)
assert self.detector.state == TouchState.TOUCHING
# Small delay (less than long press)
time.sleep(0.1)
# Touch up at same position
gesture = self.detector.on_touch_up(105, 105) # 5px movement
assert gesture == GestureType.TAP
assert self.detector.state == TouchState.IDLE
def test_long_press_detection(self):
"""Test long press gesture detection."""
# Touch down
self.detector.on_touch_down(100, 100)
# Wait for long press duration
time.sleep(0.6) # Longer than 0.5s threshold
# Touch up at same position
gesture = self.detector.on_touch_up(105, 105)
assert gesture == GestureType.LONG_PRESS
assert self.detector.state == TouchState.IDLE
def test_swipe_left_detection(self):
"""Test swipe left gesture detection."""
# Touch down
self.detector.on_touch_down(200, 100)
# Quick movement to the left
time.sleep(0.1)
gesture = self.detector.on_touch_up(100, 105) # 100px left, 5px down
assert gesture == GestureType.SWIPE_LEFT
def test_swipe_right_detection(self):
"""Test swipe right gesture detection."""
self.detector.on_touch_down(100, 100)
time.sleep(0.1)
gesture = self.detector.on_touch_up(200, 105) # 100px right
assert gesture == GestureType.SWIPE_RIGHT
def test_swipe_up_detection(self):
"""Test swipe up gesture detection."""
self.detector.on_touch_down(100, 200)
time.sleep(0.1)
gesture = self.detector.on_touch_up(105, 100) # 100px up
assert gesture == GestureType.SWIPE_UP
def test_swipe_down_detection(self):
"""Test swipe down gesture detection."""
self.detector.on_touch_down(100, 100)
time.sleep(0.1)
gesture = self.detector.on_touch_up(105, 200) # 100px down
assert gesture == GestureType.SWIPE_DOWN
def test_drag_start_detection(self):
"""Test drag start detection."""
# Touch down
self.detector.on_touch_down(100, 100)
# Move beyond threshold
gesture = self.detector.on_touch_move(150, 100) # 50px movement
assert gesture == GestureType.DRAG_START
assert self.detector.state == TouchState.MOVING
def test_drag_move_detection(self):
"""Test drag move events."""
# Start dragging
self.detector.on_touch_down(100, 100)
self.detector.on_touch_move(150, 100) # Start drag
# Continue moving
gesture = self.detector.on_touch_move(200, 100)
assert gesture == GestureType.DRAG_MOVE
assert self.detector.state == TouchState.MOVING
def test_drag_end_detection(self):
"""Test drag end."""
# Start dragging
self.detector.on_touch_down(100, 100)
self.detector.on_touch_move(150, 100)
# Touch up
time.sleep(0.6) # Wait long enough to not be a swipe
gesture = self.detector.on_touch_up(200, 100)
assert gesture == GestureType.DRAG_END
def test_reset(self):
"""Test detector reset."""
# Set some state
self.detector.on_touch_down(100, 100)
assert self.detector.state == TouchState.TOUCHING
# Reset
self.detector.reset()
# Check clean state
assert self.detector.state == TouchState.IDLE
assert self.detector.start_pos is None
assert self.detector.current_pos is None
def test_custom_thresholds(self):
"""Test creating detector with custom thresholds."""
detector = GestureDetector(
tap_threshold=50.0,
long_press_duration=1.0
)
assert detector.tap_threshold == 50.0
assert detector.long_press_duration == 1.0
def test_pinch_detection(self):
"""Test two-finger pinch detection."""
# First finger down
self.detector.on_touch_down(100, 100, finger=0)
# Second finger down
self.detector.on_touch_down(200, 100, finger=1)
# Move fingers apart (pinch out)
self.detector.on_touch_move(90, 100, finger=0)
self.detector.on_touch_move(210, 100, finger=1)
# Second finger up
gesture = self.detector.on_touch_up(210, 100, finger=1)
assert gesture == GestureType.PINCH_OUT
def test_check_long_press(self):
"""Test long press check during touch."""
# Touch down
self.detector.on_touch_down(100, 100)
# Check before threshold
time.sleep(0.2)
assert not self.detector.check_long_press()
# Check after threshold
time.sleep(0.4) # Total 0.6s
assert self.detector.check_long_press()
assert self.detector.state == TouchState.LONG_PRESS_DETECTED
def test_no_gesture_on_small_movement(self):
"""Test that small movements during long delay don't trigger swipe."""
self.detector.on_touch_down(100, 100)
time.sleep(0.6) # Wait long
# Small movement
gesture = self.detector.on_touch_up(110, 110) # Only 14px diagonal
# Should be long press, not swipe
assert gesture == GestureType.LONG_PRESS
class TestTouchState:
"""Tests for TouchState enum."""
def test_states_defined(self):
"""Test all required states are defined."""
assert TouchState.IDLE
assert TouchState.TOUCHING
assert TouchState.MOVING
assert TouchState.LONG_PRESS_DETECTED
def test_state_values(self):
"""Test state enum values."""
assert TouchState.IDLE.value == "idle"
assert TouchState.TOUCHING.value == "touching"
assert TouchState.MOVING.value == "moving"
assert TouchState.LONG_PRESS_DETECTED.value == "long_press_detected"
+327
View File
@@ -0,0 +1,327 @@
"""
Unit tests for dreader_hal.hal and dreader_hal.ereader_hal modules.
Tests the DisplayHAL interface and EReaderDisplayHAL implementation.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from PIL import Image
from dreader_hal.hal import DisplayHAL
from dreader_hal.ereader_hal import EReaderDisplayHAL
from dreader_hal.types import GestureType, TouchEvent
class TestDisplayHALInterface:
"""Tests for DisplayHAL abstract interface."""
def test_displayhal_is_abstract(self):
"""Test that DisplayHAL cannot be instantiated directly."""
with pytest.raises(TypeError):
DisplayHAL()
def test_required_methods_defined(self):
"""Test that required methods are defined in interface."""
required_methods = [
'show_image',
'get_touch_event',
'set_brightness',
]
for method_name in required_methods:
assert hasattr(DisplayHAL, method_name)
class TestEReaderDisplayHAL:
"""Tests for EReaderDisplayHAL implementation."""
@pytest.fixture
def mock_components(self):
"""Create mock hardware components."""
# Since imports are now lazy (inside __init__), we need to patch the actual modules
with patch('dreader_hal.display.it8951.IT8951DisplayDriver') as mock_display, \
patch('dreader_hal.touch.ft5xx6.FT5xx6TouchDriver') as mock_touch, \
patch('dreader_hal.sensors.bma400.BMA400OrientationSensor') as mock_orientation, \
patch('dreader_hal.rtc.pcf8523.PCF8523RTC') as mock_rtc, \
patch('dreader_hal.power.ina219.INA219PowerMonitor') as mock_power:
# Set up the mocks to return mock instances with async methods
mock_display_instance = MagicMock()
mock_display_instance.initialize = AsyncMock()
mock_display_instance.cleanup = AsyncMock()
mock_display_instance.show_image = AsyncMock()
mock_display_instance.set_brightness = AsyncMock()
mock_display_instance.sleep = AsyncMock()
mock_display_instance.wake = AsyncMock()
mock_display_instance.refresh_count = 0
mock_display.return_value = mock_display_instance
mock_touch_instance = MagicMock()
mock_touch_instance.initialize = AsyncMock()
mock_touch_instance.cleanup = AsyncMock()
mock_touch_instance.get_touch_event = AsyncMock(return_value=None)
mock_touch_instance.set_polling_rate = AsyncMock()
mock_touch.return_value = mock_touch_instance
mock_orientation_instance = MagicMock()
mock_orientation_instance.initialize = AsyncMock()
mock_orientation_instance.cleanup = AsyncMock()
mock_orientation_instance.current_angle = 0
mock_orientation.return_value = mock_orientation_instance
mock_rtc_instance = MagicMock()
mock_rtc_instance.initialize = AsyncMock()
mock_rtc_instance.cleanup = AsyncMock()
mock_rtc.return_value = mock_rtc_instance
mock_power_instance = MagicMock()
mock_power_instance.initialize = AsyncMock()
mock_power_instance.cleanup = AsyncMock()
mock_power_instance.get_battery_percent = AsyncMock(return_value=75.0)
mock_power_instance.is_low_battery = AsyncMock(return_value=False)
mock_power.return_value = mock_power_instance
yield {
'display': mock_display_instance,
'touch': mock_touch_instance,
'orientation': mock_orientation_instance,
'rtc': mock_rtc_instance,
'power': mock_power_instance,
}
def test_hal_initialization(self, mock_components):
"""Test HAL initialization with default parameters."""
hal = EReaderDisplayHAL(virtual_display=True)
assert hal.width == 800
assert hal.height == 1200
assert hal._brightness == 5
assert hal._initialized is False
def test_hal_custom_dimensions(self, mock_components):
"""Test HAL with custom dimensions."""
hal = EReaderDisplayHAL(
width=1024,
height=768,
virtual_display=True
)
assert hal.width == 1024
assert hal.height == 768
@pytest.mark.asyncio
async def test_initialize(self, mock_components):
"""Test HAL initialize method."""
# Setup mocks
for component in mock_components.values():
component.initialize = AsyncMock()
hal = EReaderDisplayHAL(virtual_display=True)
# Initialize
await hal.initialize()
# Verify all components initialized
assert hal._initialized is True
mock_components['display'].initialize.assert_called_once()
mock_components['touch'].initialize.assert_called_once()
@pytest.mark.asyncio
async def test_initialize_idempotent(self, mock_components):
"""Test that initialize can be called multiple times safely."""
for component in mock_components.values():
component.initialize = AsyncMock()
hal = EReaderDisplayHAL(virtual_display=True)
# Initialize twice
await hal.initialize()
await hal.initialize()
# Should only initialize once
assert mock_components['display'].initialize.call_count == 1
@pytest.mark.asyncio
async def test_cleanup(self, mock_components):
"""Test HAL cleanup method."""
for component in mock_components.values():
component.initialize = AsyncMock()
component.cleanup = AsyncMock()
hal = EReaderDisplayHAL(virtual_display=True)
await hal.initialize()
# Cleanup
await hal.cleanup()
# Verify all components cleaned up
assert hal._initialized is False
mock_components['display'].cleanup.assert_called_once()
mock_components['touch'].cleanup.assert_called_once()
@pytest.mark.asyncio
async def test_show_image(self, mock_components):
"""Test show_image method."""
mock_components['display'].initialize = AsyncMock()
mock_components['display'].show_image = AsyncMock()
mock_components['touch'].initialize = AsyncMock()
hal = EReaderDisplayHAL(virtual_display=True, enable_orientation=False)
await hal.initialize()
# Create test image
image = Image.new('RGB', (800, 1200), color=(255, 255, 255))
# Show image
await hal.show_image(image)
# Verify display driver called
mock_components['display'].show_image.assert_called_once()
@pytest.mark.asyncio
async def test_show_image_not_initialized(self, mock_components):
"""Test show_image raises error when not initialized."""
hal = EReaderDisplayHAL(virtual_display=True)
image = Image.new('RGB', (800, 1200))
with pytest.raises(RuntimeError, match="not initialized"):
await hal.show_image(image)
@pytest.mark.asyncio
async def test_get_touch_event(self, mock_components):
"""Test get_touch_event method."""
mock_components['display'].initialize = AsyncMock()
mock_components['touch'].initialize = AsyncMock()
mock_components['touch'].get_touch_event = AsyncMock(
return_value=TouchEvent(GestureType.TAP, 100, 200)
)
hal = EReaderDisplayHAL(virtual_display=True)
await hal.initialize()
# Get touch event
event = await hal.get_touch_event()
# Verify
assert event is not None
assert event.gesture == GestureType.TAP
assert event.x == 100
assert event.y == 200
@pytest.mark.asyncio
async def test_set_brightness(self, mock_components):
"""Test set_brightness method."""
mock_components['display'].initialize = AsyncMock()
mock_components['display'].set_brightness = AsyncMock()
mock_components['touch'].initialize = AsyncMock()
hal = EReaderDisplayHAL(virtual_display=True)
await hal.initialize()
# Set brightness
await hal.set_brightness(7)
# Verify
assert hal._brightness == 7
mock_components['display'].set_brightness.assert_called_once_with(7)
@pytest.mark.asyncio
async def test_set_brightness_invalid(self, mock_components):
"""Test set_brightness with invalid values."""
hal = EReaderDisplayHAL(virtual_display=True)
with pytest.raises(ValueError, match="must be 0-10"):
await hal.set_brightness(11)
with pytest.raises(ValueError, match="must be 0-10"):
await hal.set_brightness(-1)
@pytest.mark.asyncio
async def test_get_battery_level(self, mock_components):
"""Test get_battery_level method."""
mock_components['display'].initialize = AsyncMock()
mock_components['touch'].initialize = AsyncMock()
mock_components['power'].initialize = AsyncMock()
mock_components['power'].get_battery_percent = AsyncMock(return_value=85.0)
hal = EReaderDisplayHAL(virtual_display=True, enable_power_monitor=True)
await hal.initialize()
# Get battery level
level = await hal.get_battery_level()
assert level == 85.0
mock_components['power'].get_battery_percent.assert_called_once()
@pytest.mark.asyncio
async def test_get_battery_level_no_power_monitor(self, mock_components):
"""Test get_battery_level when power monitor disabled."""
hal = EReaderDisplayHAL(virtual_display=True, enable_power_monitor=False)
# Should return 0.0 when power monitor not enabled
level = await hal.get_battery_level()
assert level == 0.0
@pytest.mark.asyncio
async def test_is_low_battery(self, mock_components):
"""Test is_low_battery method."""
mock_components['display'].initialize = AsyncMock()
mock_components['touch'].initialize = AsyncMock()
mock_components['power'].initialize = AsyncMock()
mock_components['power'].is_low_battery = AsyncMock(return_value=True)
hal = EReaderDisplayHAL(virtual_display=True, enable_power_monitor=True)
await hal.initialize()
# Check low battery
is_low = await hal.is_low_battery(20.0)
assert is_low is True
mock_components['power'].is_low_battery.assert_called_once_with(20.0)
def test_disable_optional_components(self, mock_components):
"""Test HAL with optional components disabled."""
hal = EReaderDisplayHAL(
virtual_display=True,
enable_orientation=False,
enable_rtc=False,
enable_power_monitor=False,
)
assert hal.orientation is None
assert hal.rtc is None
assert hal.power is None
@pytest.mark.asyncio
async def test_set_low_power_mode(self, mock_components):
"""Test set_low_power_mode method."""
mock_components['display'].initialize = AsyncMock()
mock_components['display'].sleep = AsyncMock()
mock_components['display'].wake = AsyncMock()
mock_components['touch'].initialize = AsyncMock()
mock_components['touch'].set_polling_rate = AsyncMock()
hal = EReaderDisplayHAL(virtual_display=True)
await hal.initialize()
# Enable low power mode
await hal.set_low_power_mode(True)
mock_components['display'].sleep.assert_called_once()
mock_components['touch'].set_polling_rate.assert_called_once_with(10)
# Disable low power mode
await hal.set_low_power_mode(False)
mock_components['display'].wake.assert_called_once()
assert mock_components['touch'].set_polling_rate.call_count == 2
def test_refresh_count_property(self, mock_components):
"""Test refresh_count property."""
mock_components['display'].refresh_count = 42
hal = EReaderDisplayHAL(virtual_display=True)
assert hal.refresh_count == 42
+206
View File
@@ -0,0 +1,206 @@
"""
Unit tests for dreader_hal.types module.
Tests type definitions, enums, and data structures.
"""
import pytest
from dreader_hal.types import (
GestureType,
TouchEvent,
PowerStats,
Orientation,
RefreshMode,
GESTURE_THRESHOLDS,
)
class TestGestureType:
"""Tests for GestureType enum."""
def test_all_gestures_defined(self):
"""Test that all required gestures are defined."""
required_gestures = [
"TAP", "LONG_PRESS",
"SWIPE_LEFT", "SWIPE_RIGHT", "SWIPE_UP", "SWIPE_DOWN",
"PINCH_IN", "PINCH_OUT",
"DRAG_START", "DRAG_MOVE", "DRAG_END"
]
for gesture_name in required_gestures:
assert hasattr(GestureType, gesture_name), f"Missing gesture: {gesture_name}"
def test_gesture_values(self):
"""Test gesture enum values."""
assert GestureType.TAP.value == "tap"
assert GestureType.SWIPE_LEFT.value == "swipe_left"
assert GestureType.PINCH_OUT.value == "pinch_out"
class TestTouchEvent:
"""Tests for TouchEvent dataclass."""
def test_create_touch_event(self):
"""Test creating a TouchEvent."""
event = TouchEvent(
gesture=GestureType.TAP,
x=100,
y=200,
timestamp_ms=1234567890.0
)
assert event.gesture == GestureType.TAP
assert event.x == 100
assert event.y == 200
assert event.x2 is None
assert event.y2 is None
assert event.timestamp_ms == 1234567890.0
def test_touch_event_with_two_fingers(self):
"""Test TouchEvent with two-finger coordinates."""
event = TouchEvent(
gesture=GestureType.PINCH_OUT,
x=100,
y=200,
x2=300,
y2=400,
)
assert event.x2 == 300
assert event.y2 == 400
def test_touch_event_defaults(self):
"""Test TouchEvent default values."""
event = TouchEvent(gesture=GestureType.TAP, x=0, y=0)
assert event.x2 is None
assert event.y2 is None
assert event.timestamp_ms == 0
class TestPowerStats:
"""Tests for PowerStats dataclass."""
def test_create_power_stats(self):
"""Test creating PowerStats."""
stats = PowerStats(
voltage=3.7,
current=150.0,
power=555.0,
battery_percent=85.0,
time_remaining=180,
is_charging=False
)
assert stats.voltage == 3.7
assert stats.current == 150.0
assert stats.power == 555.0
assert stats.battery_percent == 85.0
assert stats.time_remaining == 180
assert stats.is_charging is False
def test_charging_state(self):
"""Test PowerStats with charging enabled."""
stats = PowerStats(
voltage=4.2,
current=-100.0, # Negative = charging
power=420.0,
battery_percent=95.0,
time_remaining=None, # N/A when charging
is_charging=True
)
assert stats.is_charging is True
assert stats.time_remaining is None
class TestOrientation:
"""Tests for Orientation enum."""
def test_orientation_angles(self):
"""Test orientation angle values."""
assert Orientation.PORTRAIT_0.value == 0
assert Orientation.LANDSCAPE_90.value == 90
assert Orientation.PORTRAIT_180.value == 180
assert Orientation.LANDSCAPE_270.value == 270
def test_is_portrait(self):
"""Test is_portrait property."""
assert Orientation.PORTRAIT_0.is_portrait is True
assert Orientation.PORTRAIT_180.is_portrait is True
assert Orientation.LANDSCAPE_90.is_portrait is False
assert Orientation.LANDSCAPE_270.is_portrait is False
def test_is_landscape(self):
"""Test is_landscape property."""
assert Orientation.LANDSCAPE_90.is_landscape is True
assert Orientation.LANDSCAPE_270.is_landscape is True
assert Orientation.PORTRAIT_0.is_landscape is False
assert Orientation.PORTRAIT_180.is_landscape is False
def test_angle_property(self):
"""Test angle property."""
assert Orientation.PORTRAIT_0.angle == 0
assert Orientation.LANDSCAPE_90.angle == 90
assert Orientation.PORTRAIT_180.angle == 180
assert Orientation.LANDSCAPE_270.angle == 270
class TestRefreshMode:
"""Tests for RefreshMode enum."""
def test_refresh_modes_defined(self):
"""Test that all refresh modes are defined."""
assert RefreshMode.AUTO
assert RefreshMode.FAST
assert RefreshMode.QUALITY
assert RefreshMode.FULL
def test_refresh_mode_values(self):
"""Test refresh mode values."""
assert RefreshMode.AUTO.value == "auto"
assert RefreshMode.FAST.value == "fast"
assert RefreshMode.QUALITY.value == "quality"
assert RefreshMode.FULL.value == "full"
class TestGestureThresholds:
"""Tests for GESTURE_THRESHOLDS configuration."""
def test_thresholds_exist(self):
"""Test that all required thresholds are defined."""
required_thresholds = [
'tap_distance',
'swipe_min_distance',
'drag_threshold',
'long_press_duration',
'tap_max_duration',
'swipe_max_duration',
'swipe_angle_threshold',
]
for threshold in required_thresholds:
assert threshold in GESTURE_THRESHOLDS, f"Missing threshold: {threshold}"
def test_threshold_values_reasonable(self):
"""Test that threshold values are reasonable."""
# Distance thresholds should be positive
assert GESTURE_THRESHOLDS['tap_distance'] > 0
assert GESTURE_THRESHOLDS['swipe_min_distance'] > 0
assert GESTURE_THRESHOLDS['drag_threshold'] > 0
# Duration thresholds should be positive
assert GESTURE_THRESHOLDS['long_press_duration'] > 0
assert GESTURE_THRESHOLDS['tap_max_duration'] > 0
assert GESTURE_THRESHOLDS['swipe_max_duration'] > 0
# Angle threshold should be 0-90 degrees
assert 0 <= GESTURE_THRESHOLDS['swipe_angle_threshold'] <= 90
def test_threshold_relationships(self):
"""Test logical relationships between thresholds."""
# Long press should be longer than tap
assert GESTURE_THRESHOLDS['long_press_duration'] > GESTURE_THRESHOLDS['tap_max_duration']
# Swipe should have minimum distance
assert GESTURE_THRESHOLDS['swipe_min_distance'] >= GESTURE_THRESHOLDS['tap_distance']