Add command-line interface, test suite, and unit tests for hardware manager and recipe models

This commit is contained in:
2025-08-06 22:23:16 +02:00
parent c3bc2e453b
commit 696f2af81f
6 changed files with 994 additions and 24 deletions

View File

@@ -0,0 +1,3 @@
"""
Test suite for chocolate tempering machine control system.
"""

View File

@@ -0,0 +1,227 @@
"""
pytest configuration and shared fixtures.
"""
import asyncio
import pytest
import pytest_asyncio
from typing import AsyncGenerator, Generator
from unittest.mock import AsyncMock, MagicMock
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from fastapi.testclient import TestClient
from src.tempering_machine.shared.database import Base, get_db
from src.tempering_machine.shared.config import settings
from src.tempering_machine.services.web.main import app
from src.tempering_machine.services.hardware.hardware_manager import hardware_manager
from src.tempering_machine.services.safety.safety_monitor import safety_monitor
from src.tempering_machine.services.recipe.recipe_controller import recipe_controller
# Test database URL
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
"""Create event loop for the test session."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture
async def test_db_engine():
"""Create test database engine."""
engine = create_async_engine(
TEST_DATABASE_URL,
echo=False,
future=True
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def test_db_session(test_db_engine) -> AsyncGenerator[AsyncSession, None]:
"""Create test database session."""
async_session = sessionmaker(
test_db_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
yield session
@pytest.fixture
def test_client(test_db_session):
"""Create test client with database override."""
def override_get_db():
return test_db_session
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture
def mock_hardware_manager():
"""Mock hardware manager for testing."""
mock = MagicMock()
mock.initialize = AsyncMock(return_value=True)
mock.shutdown = AsyncMock(return_value=True)
mock.get_hardware_status = MagicMock()
mock.get_all_temperatures = AsyncMock(return_value={})
mock.get_temperature = AsyncMock(return_value=None)
mock.is_safe_to_operate = AsyncMock(return_value=(True, []))
mock.emergency_stop = AsyncMock(return_value=True)
mock.set_motor_state = AsyncMock(return_value=True)
mock.set_heater_state = AsyncMock(return_value=True)
return mock
@pytest.fixture
def mock_safety_monitor():
"""Mock safety monitor for testing."""
mock = MagicMock()
mock.initialize = AsyncMock(return_value=True)
mock.shutdown = AsyncMock(return_value=True)
mock.get_active_alarms = MagicMock(return_value=[])
mock.get_alarm_summary = MagicMock(return_value={
"total_active_alarms": 0,
"active_by_priority": {"critical": 0, "high": 0, "medium": 0, "low": 0},
"emergency_stop_active": False,
"last_safety_check": "2025-01-08T12:00:00",
})
mock.acknowledge_alarm = AsyncMock(return_value=True)
return mock
@pytest.fixture
def mock_recipe_controller():
"""Mock recipe controller for testing."""
mock = MagicMock()
mock.start_recipe = AsyncMock(return_value="test-session-id")
mock.stop_recipe = AsyncMock(return_value=True)
mock.pause_recipe = AsyncMock(return_value=True)
mock.resume_recipe = AsyncMock(return_value=True)
mock.emergency_stop = AsyncMock(return_value=True)
mock.get_process_status = AsyncMock(return_value=None)
mock.get_active_sessions = AsyncMock(return_value=[])
return mock
@pytest.fixture
def sample_recipe_data():
"""Sample recipe data for testing."""
return {
"name": "Test Recipe",
"description": "A test recipe for unit testing",
"heating_goal": 46.0,
"cooling_goal": 27.0,
"pouring_goal": 30.0,
"tank_temp": 45.0,
"fountain_temp": 32.0,
"mixer_enabled": True,
"fountain_enabled": True,
"mold_heater_enabled": False,
"vibration_enabled": False,
"vib_heater_enabled": False,
"pedal_control_enabled": True,
"pedal_on_time": 2.0,
"pedal_off_time": 3.0,
}
@pytest.fixture
def sample_temperature_reading():
"""Sample temperature reading for testing."""
from datetime import datetime
from src.tempering_machine.services.hardware.hardware_manager import TemperatureReading
return TemperatureReading(
value=25.5,
timestamp=datetime.now(),
sensor_name="tank_bottom",
units="°C",
is_valid=True,
error_message=None
)
@pytest.fixture
def sample_hardware_status():
"""Sample hardware status for testing."""
from datetime import datetime
from src.tempering_machine.services.hardware.hardware_manager import (
HardwareStatus, TemperatureReading, MotorStatus, MotorState, SafetyStatus, ComponentStatus
)
return HardwareStatus(
temperatures={
"tank_bottom": TemperatureReading(
value=25.5,
timestamp=datetime.now(),
sensor_name="tank_bottom",
units="°C",
is_valid=True
),
"fountain": TemperatureReading(
value=30.2,
timestamp=datetime.now(),
sensor_name="fountain",
units="°C",
is_valid=True
)
},
motors={
"mixer_motor": MotorStatus(
name="mixer_motor",
state=MotorState.STOPPED,
is_enabled=False
)
},
safety=SafetyStatus(
emergency_stop_active=False,
cover_sensor_closed=True,
temperature_alarms=[],
current_alarms=[],
last_safety_check=datetime.now()
),
communication_health=95.0,
system_status=ComponentStatus.ONLINE,
last_update=datetime.now()
)
@pytest.fixture(autouse=True)
async def setup_test_environment(monkeypatch):
"""Set up test environment with mocked services."""
# Override settings for testing
monkeypatch.setattr(settings, "environment", "testing")
monkeypatch.setattr(settings, "debug", True)
monkeypatch.setattr(settings.database, "url", TEST_DATABASE_URL)
# Prevent actual hardware initialization during tests
monkeypatch.setattr(hardware_manager, "initialize", AsyncMock(return_value=True))
monkeypatch.setattr(safety_monitor, "initialize", AsyncMock(return_value=True))
# Pytest markers
pytest_plugins = ["pytest_asyncio"]

View File

@@ -0,0 +1,288 @@
"""
Unit tests for hardware manager functionality.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime
from src.tempering_machine.services.hardware.hardware_manager import (
HardwareManager, TemperatureReading, MotorStatus, MotorState, ComponentStatus
)
class TestHardwareManager:
"""Test cases for HardwareManager."""
@pytest.fixture
def hardware_manager(self):
"""Create hardware manager instance for testing."""
return HardwareManager()
@pytest.mark.asyncio
async def test_initialize_success(self, hardware_manager):
"""Test successful hardware manager initialization."""
with patch('src.tempering_machine.services.hardware.modbus_client.modbus_client') as mock_modbus:
mock_modbus.connect = AsyncMock(return_value=True)
result = await hardware_manager.initialize()
assert result is True
mock_modbus.connect.assert_called_once()
@pytest.mark.asyncio
async def test_initialize_failure(self, hardware_manager):
"""Test hardware manager initialization failure."""
with patch('src.tempering_machine.services.hardware.modbus_client.modbus_client') as mock_modbus:
mock_modbus.connect = AsyncMock(return_value=False)
result = await hardware_manager.initialize()
assert result is False
@pytest.mark.asyncio
async def test_get_temperature_valid(self, hardware_manager):
"""Test getting valid temperature reading."""
# Setup mock temperature data
hardware_manager.current_status.temperatures["tank_bottom"] = TemperatureReading(
value=25.5,
timestamp=datetime.now(),
sensor_name="tank_bottom",
is_valid=True
)
reading = await hardware_manager.get_temperature("tank_bottom")
assert reading is not None
assert reading.value == 25.5
assert reading.sensor_name == "tank_bottom"
assert reading.is_valid is True
@pytest.mark.asyncio
async def test_get_temperature_invalid_sensor(self, hardware_manager):
"""Test getting temperature for non-existent sensor."""
reading = await hardware_manager.get_temperature("non_existent_sensor")
assert reading is None
@pytest.mark.asyncio
async def test_get_all_temperatures(self, hardware_manager):
"""Test getting all temperature readings."""
# Setup mock temperature data
hardware_manager.current_status.temperatures = {
"tank_bottom": TemperatureReading(
value=25.5, timestamp=datetime.now(), sensor_name="tank_bottom", is_valid=True
),
"fountain": TemperatureReading(
value=30.2, timestamp=datetime.now(), sensor_name="fountain", is_valid=True
)
}
temperatures = await hardware_manager.get_all_temperatures()
assert len(temperatures) == 2
assert "tank_bottom" in temperatures
assert "fountain" in temperatures
assert temperatures["tank_bottom"].value == 25.5
assert temperatures["fountain"].value == 30.2
@pytest.mark.asyncio
async def test_get_average_tank_temperature(self, hardware_manager):
"""Test calculating average tank temperature."""
# Setup mock temperature data
hardware_manager.current_status.temperatures = {
"tank_bottom": TemperatureReading(
value=25.0, timestamp=datetime.now(), sensor_name="tank_bottom", is_valid=True
),
"tank_wall": TemperatureReading(
value=27.0, timestamp=datetime.now(), sensor_name="tank_wall", is_valid=True
)
}
avg_temp = await hardware_manager.get_average_tank_temperature()
assert avg_temp == 26.0 # Average of 25.0 and 27.0
@pytest.mark.asyncio
async def test_get_average_tank_temperature_no_data(self, hardware_manager):
"""Test calculating average tank temperature with no valid data."""
hardware_manager.current_status.temperatures = {}
avg_temp = await hardware_manager.get_average_tank_temperature()
assert avg_temp is None
@pytest.mark.asyncio
async def test_set_motor_state_success(self, hardware_manager):
"""Test successful motor state change."""
with patch('src.tempering_machine.services.hardware.modbus_client.modbus_client') as mock_modbus:
# Mock successful read and write operations
mock_modbus.read_coils = AsyncMock(return_value=MagicMock(
success=True, value=[False, False, False, False, False, False, False, False]
))
mock_modbus.write_multiple_coils = AsyncMock(return_value=MagicMock(success=True))
result = await hardware_manager.set_motor_state("mixer_motor", True)
assert result is True
mock_modbus.read_coils.assert_called_once()
mock_modbus.write_multiple_coils.assert_called_once()
@pytest.mark.asyncio
async def test_set_motor_state_unknown_motor(self, hardware_manager):
"""Test setting state for unknown motor."""
result = await hardware_manager.set_motor_state("unknown_motor", True)
assert result is False
@pytest.mark.asyncio
async def test_set_motor_state_communication_failure(self, hardware_manager):
"""Test motor state change with communication failure."""
with patch('src.tempering_machine.services.hardware.modbus_client.modbus_client') as mock_modbus:
mock_modbus.read_coils = AsyncMock(return_value=MagicMock(success=False, error="Communication error"))
result = await hardware_manager.set_motor_state("mixer_motor", True)
assert result is False
@pytest.mark.asyncio
async def test_enable_motor(self, hardware_manager):
"""Test enabling a motor."""
with patch.object(hardware_manager, 'set_motor_state', new=AsyncMock(return_value=True)) as mock_set:
result = await hardware_manager.enable_motor("mixer_motor")
assert result is True
mock_set.assert_called_once_with("mixer_motor", True)
@pytest.mark.asyncio
async def test_disable_motor(self, hardware_manager):
"""Test disabling a motor."""
with patch.object(hardware_manager, 'set_motor_state', new=AsyncMock(return_value=True)) as mock_set:
result = await hardware_manager.disable_motor("mixer_motor")
assert result is True
mock_set.assert_called_once_with("mixer_motor", False)
@pytest.mark.asyncio
async def test_disable_all_motors(self, hardware_manager):
"""Test disabling all motors."""
with patch.object(hardware_manager, 'disable_motor', new=AsyncMock(return_value=True)) as mock_disable:
result = await hardware_manager.disable_all_motors()
assert result is True
# Should be called for each motor that contains "motor" in its name
assert mock_disable.call_count > 0
@pytest.mark.asyncio
async def test_emergency_stop(self, hardware_manager):
"""Test emergency stop functionality."""
with patch.object(hardware_manager, 'disable_all_motors', new=AsyncMock(return_value=True)) as mock_motors, \
patch.object(hardware_manager, 'disable_all_heaters', new=AsyncMock(return_value=True)) as mock_heaters:
result = await hardware_manager.emergency_stop()
assert result is True
mock_motors.assert_called_once()
mock_heaters.assert_called_once()
@pytest.mark.asyncio
async def test_is_safe_to_operate_safe(self, hardware_manager):
"""Test safety check when system is safe."""
from src.tempering_machine.services.hardware.hardware_manager import SafetyStatus
# Setup safe conditions
hardware_manager.current_status.safety = SafetyStatus(
emergency_stop_active=False,
cover_sensor_closed=True,
temperature_alarms=[],
current_alarms=[]
)
hardware_manager.current_status.communication_health = 95.0
hardware_manager.current_status.temperatures = {
"tank_bottom": TemperatureReading(
value=25.0, timestamp=datetime.now(), sensor_name="tank_bottom", is_valid=True
)
}
is_safe, issues = await hardware_manager.is_safe_to_operate()
assert is_safe is True
assert len(issues) == 0
@pytest.mark.asyncio
async def test_is_safe_to_operate_emergency_stop(self, hardware_manager):
"""Test safety check when emergency stop is active."""
from src.tempering_machine.services.hardware.hardware_manager import SafetyStatus
hardware_manager.current_status.safety = SafetyStatus(
emergency_stop_active=True,
cover_sensor_closed=True,
temperature_alarms=[],
current_alarms=[]
)
is_safe, issues = await hardware_manager.is_safe_to_operate()
assert is_safe is False
assert "Emergency stop is active" in issues
@pytest.mark.asyncio
async def test_is_safe_to_operate_cover_open(self, hardware_manager):
"""Test safety check when cover is open."""
from src.tempering_machine.services.hardware.hardware_manager import SafetyStatus
hardware_manager.current_status.safety = SafetyStatus(
emergency_stop_active=False,
cover_sensor_closed=False,
temperature_alarms=[],
current_alarms=[]
)
is_safe, issues = await hardware_manager.is_safe_to_operate()
assert is_safe is False
assert "Safety cover is open" in issues
@pytest.mark.asyncio
async def test_is_safe_to_operate_communication_issues(self, hardware_manager):
"""Test safety check with communication issues."""
from src.tempering_machine.services.hardware.hardware_manager import SafetyStatus
hardware_manager.current_status.safety = SafetyStatus(
emergency_stop_active=False,
cover_sensor_closed=True,
temperature_alarms=[],
current_alarms=[]
)
hardware_manager.current_status.communication_health = 30.0 # Poor communication
is_safe, issues = await hardware_manager.is_safe_to_operate()
assert is_safe is False
assert "Poor communication with hardware" in issues
def test_get_hardware_status(self, hardware_manager):
"""Test getting complete hardware status."""
status = hardware_manager.get_hardware_status()
assert status is not None
assert hasattr(status, 'temperatures')
assert hasattr(status, 'motors')
assert hasattr(status, 'safety')
assert hasattr(status, 'communication_health')
assert hasattr(status, 'system_status')
@pytest.mark.asyncio
async def test_shutdown(self, hardware_manager):
"""Test hardware manager shutdown."""
with patch.object(hardware_manager, 'stop_monitoring', new=AsyncMock()) as mock_stop, \
patch.object(hardware_manager, 'emergency_stop', new=AsyncMock(return_value=True)) as mock_emergency, \
patch('src.tempering_machine.services.hardware.modbus_client.modbus_client') as mock_modbus:
mock_modbus.disconnect = AsyncMock()
await hardware_manager.shutdown()
mock_stop.assert_called_once()
mock_emergency.assert_called_once()
mock_modbus.disconnect.assert_called_once()

View File

@@ -0,0 +1,217 @@
"""
Unit tests for recipe models and validation.
"""
import pytest
from datetime import datetime
from src.tempering_machine.shared.models.recipe import Recipe, RecipePhase
class TestRecipe:
"""Test cases for Recipe model."""
def test_recipe_creation(self):
"""Test basic recipe creation."""
recipe = Recipe(
name="Test Recipe",
heating_goal=46.0,
cooling_goal=27.0,
tank_temp=45.0,
fountain_temp=32.0
)
assert recipe.name == "Test Recipe"
assert recipe.heating_goal == 46.0
assert recipe.cooling_goal == 27.0
assert recipe.is_active is True
assert recipe.version == 1
def test_recipe_temperature_validation_valid(self):
"""Test valid temperature parameters."""
recipe = Recipe(
name="Valid Recipe",
heating_goal=50.0,
cooling_goal=25.0,
pouring_goal=30.0,
tank_temp=45.0,
fountain_temp=32.0
)
assert recipe.validate_temperatures() is True
def test_recipe_temperature_validation_invalid_cooling_too_high(self):
"""Test invalid temperature parameters - cooling goal too high."""
recipe = Recipe(
name="Invalid Recipe",
heating_goal=45.0,
cooling_goal=50.0, # Higher than heating goal
tank_temp=45.0,
fountain_temp=32.0
)
assert recipe.validate_temperatures() is False
def test_recipe_temperature_validation_invalid_ranges(self):
"""Test invalid temperature ranges."""
# Heating goal too low
recipe1 = Recipe(
name="Invalid Recipe 1",
heating_goal=35.0, # Below minimum of 40°C
cooling_goal=25.0,
tank_temp=45.0,
fountain_temp=32.0
)
assert recipe1.validate_temperatures() is False
# Cooling goal too high
recipe2 = Recipe(
name="Invalid Recipe 2",
heating_goal=50.0,
cooling_goal=45.0, # Above maximum of 40°C
tank_temp=45.0,
fountain_temp=32.0
)
assert recipe2.validate_temperatures() is False
def test_recipe_pouring_goal_validation(self):
"""Test pouring goal validation."""
# Valid pouring goal within range
recipe1 = Recipe(
name="Valid Pouring",
heating_goal=50.0,
cooling_goal=25.0,
pouring_goal=30.0, # Between cooling and heating
tank_temp=45.0,
fountain_temp=32.0
)
assert recipe1.validate_temperatures() is True
# Invalid pouring goal - too low
recipe2 = Recipe(
name="Invalid Pouring Low",
heating_goal=50.0,
cooling_goal=25.0,
pouring_goal=20.0, # Below cooling goal
tank_temp=45.0,
fountain_temp=32.0
)
assert recipe2.validate_temperatures() is False
# Invalid pouring goal - too high
recipe3 = Recipe(
name="Invalid Pouring High",
heating_goal=50.0,
cooling_goal=25.0,
pouring_goal=55.0, # Above heating goal
tank_temp=45.0,
fountain_temp=32.0
)
assert recipe3.validate_temperatures() is False
def test_get_phase_sequence(self):
"""Test phase sequence generation."""
recipe = Recipe(
name="Phase Test",
heating_goal=46.0,
cooling_goal=27.0,
tank_temp=45.0,
fountain_temp=32.0
)
phases = recipe.get_phase_sequence()
expected_phases = [
RecipePhase.PREHEATING,
RecipePhase.HEATING,
RecipePhase.HEATING_DELAY,
RecipePhase.COOLING,
RecipePhase.COOLING_DELAY,
RecipePhase.POURING
]
assert phases == expected_phases
def test_recipe_to_dict(self):
"""Test recipe serialization to dictionary."""
recipe = Recipe(
name="Serialization Test",
description="Test description",
heating_goal=46.0,
cooling_goal=27.0,
pouring_goal=30.0,
tank_temp=45.0,
fountain_temp=32.0,
mixer_enabled=True,
fountain_enabled=True,
pedal_on_time=2.5,
pedal_off_time=3.5
)
recipe_dict = recipe.to_dict()
assert recipe_dict["name"] == "Serialization Test"
assert recipe_dict["description"] == "Test description"
assert recipe_dict["heating_goal"] == 46.0
assert recipe_dict["cooling_goal"] == 27.0
assert recipe_dict["pouring_goal"] == 30.0
assert recipe_dict["mixer_enabled"] is True
assert recipe_dict["pedal_on_time"] == 2.5
assert recipe_dict["pedal_off_time"] == 3.5
assert recipe_dict["version"] == 1
assert recipe_dict["is_active"] is True
def test_from_csv_row(self):
"""Test creating recipe from CSV data."""
csv_row = {
"ID": "1",
"Name": "CSV Recipe",
"HeatingGoal": "46.0",
"CoolingGoal": "27.0",
"PouringGoal": "30.0",
"TankTemp": "45.0",
"FountainTemp": "32.0",
"Mixer": "1",
"Fountain": "1",
"MoldHeater": "0",
"Vibration": "0",
"VibHeater": "0",
"Pedal": "1",
"PedalOnTime": "2.0",
"PedalOffTime": "3.0"
}
recipe = Recipe.from_csv_row(csv_row)
assert recipe.id == 1
assert recipe.name == "CSV Recipe"
assert recipe.heating_goal == 46.0
assert recipe.cooling_goal == 27.0
assert recipe.pouring_goal == 30.0
assert recipe.mixer_enabled is True
assert recipe.mold_heater_enabled is False
assert recipe.pedal_on_time == 2.0
class TestRecipePhase:
"""Test cases for RecipePhase enum."""
def test_recipe_phase_values(self):
"""Test recipe phase enum values."""
assert RecipePhase.PREHEATING.value == "preheating"
assert RecipePhase.HEATING.value == "heating"
assert RecipePhase.HEATING_DELAY.value == "heating_delay"
assert RecipePhase.COOLING.value == "cooling"
assert RecipePhase.COOLING_DELAY.value == "cooling_delay"
assert RecipePhase.POURING.value == "pouring"
assert RecipePhase.COMPLETED.value == "completed"
assert RecipePhase.STOPPED.value == "stopped"
assert RecipePhase.ERROR.value == "error"
def test_recipe_phase_membership(self):
"""Test recipe phase enum membership."""
assert RecipePhase.PREHEATING in RecipePhase
assert RecipePhase.HEATING in RecipePhase
assert RecipePhase.COMPLETED in RecipePhase
# Test string conversion
assert str(RecipePhase.HEATING) == "RecipePhase.HEATING"