Files
AR-Autopilot/arautopilot/tests/test_appack_compiler.py
T
alro65 e4812e9b44 sprint-4: Project Editor + .appack compiler + IRudderActuator HAL + dual sensor config
Cherry-pick of ab28cb7 onto main (was missing from main branch history).

Python:
- arautopilot/core/sensor_config.py: RudderSensorConfig + DualRudderSensorConfig
  (AS5048A SPI / potentiometer, dual with cross-validation thresholds)
- arautopilot/core/vessel_config.py: add sensors: DualRudderSensorConfig field
- arautopilot/studio/compiler/appack.py: .appack compiler (ZIP with
  manifest.json + project.yaml + firmware_config.h + install_notes.txt)
- arautopilot/studio/editors/project_editor.py: full project config editor
  (vessel / actuator / sensors / PID, RBAC-gated fields, save/load .yaml/.json)
- arautopilot/studio/main_window.py: wire ProjectEditorWidget into Project tab
- tests: test_sensor_config.py (11 tests) + test_appack_compiler.py (10 tests)

Firmware:
- hal/rudder_actuator_iface.h: IRudderActuator abstract interface
- hal/rudder_actuator_hydraulic.h: reversible hydraulic pump (LEDC PWM)
- hal/rudder_actuator_electric.h: reversible DC motor + deadband compensation
- hal/rudder_actuator_factory.h: build-time type selection via AR_ACTUATOR_TYPE

Tests: 483 passed (was 462, +21 Sprint 4)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 10:29:17 -04:00

155 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for AppackCompiler — Sprint 4."""
from __future__ import annotations
import json
import zipfile
from pathlib import Path
import pytest
from arautopilot.core.actuator_config import ActuatorConfig, ActuatorType
from arautopilot.core.pid_config import PidConfig, PidGains
from arautopilot.core.project_config import ProjectConfig
from arautopilot.core.sensor_config import (
DualRudderSensorConfig,
RudderSensorConfig,
RudderSensorType,
)
from arautopilot.core.vessel_config import VesselConfig, VesselType
from arautopilot.studio.compiler.appack import AppackCompiler
def _make_project(*, dual_sensors: bool = False) -> ProjectConfig:
redundant = (
RudderSensorConfig(
type=RudderSensorType.AS5048A_SPI,
label="Redundant actuator arm",
spi_cs_gpio=11,
)
if dual_sensors
else None
)
return ProjectConfig(
client_name="Test Client",
project_name="Test Project",
vessel=VesselConfig(
name="Test Vessel",
type=VesselType.YACHT_MOTOR_PLANEO,
length_m=30.0,
max_speed_kn=18.0,
actuator=ActuatorConfig(
type=ActuatorType.HYDRAULIC_REVERSIBLE,
max_rudder_angle_deg=35.0,
),
pid=PidConfig(
inner_loop_base=PidGains(kp=1.5, ki=0.1, kd=0.05),
outer_loop_base=PidGains(kp=2.0, ki=0.05, kd=0.3),
),
sensors=DualRudderSensorConfig(
primary=RudderSensorConfig(
type=RudderSensorType.AS5048A_SPI,
spi_cs_gpio=10,
),
redundant=redundant,
),
),
)
class TestAppackCompiler:
def test_compile_creates_file(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
compiler = AppackCompiler(_make_project())
result = compiler.compile(out)
assert result == out
assert out.exists()
def test_appack_is_valid_zip(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
AppackCompiler(_make_project()).compile(out)
assert zipfile.is_zipfile(out)
def test_appack_contains_required_files(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project()
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
names = set(zf.namelist())
assert f"{prefix}/manifest.json" in names
assert f"{prefix}/project.yaml" in names
assert f"{prefix}/firmware_config.h" in names
assert f"{prefix}/install_notes.txt" in names
def test_manifest_fields(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project()
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
manifest = json.loads(zf.read(f"{prefix}/manifest.json"))
assert manifest["project_id"] == str(project.project_id)
assert manifest["client_name"] == "Test Client"
assert manifest["vessel_name"] == "Test Vessel"
assert "checksums" in manifest
assert "project_yaml_sha256" in manifest["checksums"]
assert "firmware_config_h_sha256" in manifest["checksums"]
def test_firmware_header_contains_defines(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project()
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
header = zf.read(f"{prefix}/firmware_config.h").decode()
assert "#define AR_RUDDER_ANGLE_LIMIT_DEG" in header
assert "#define AR_PID_INNER_KP" in header
assert "#define AR_PID_OUTER_KP" in header
assert "#define AR_SENSOR_PRIMARY_TYPE" in header
assert "ACTUATOR_HYDRAULIC_REVERSIBLE" in header
assert "RUDDER_SENSOR_AS5048A_SPI" in header
assert "35.0f" in header
def test_firmware_header_dual_sensor(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project(dual_sensors=True)
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
header = zf.read(f"{prefix}/firmware_config.h").decode()
notes = zf.read(f"{prefix}/install_notes.txt").decode()
assert "#define AR_SENSOR_REDUNDANT 1" in header
assert "GPIO CS=11" in notes
def test_firmware_header_single_sensor(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project(dual_sensors=False)
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
header = zf.read(f"{prefix}/firmware_config.h").decode()
assert "#define AR_SENSOR_REDUNDANT 0" in header
def test_project_yaml_roundtrips(self, tmp_path: Path) -> None:
out = tmp_path / "test.appack"
project = _make_project()
AppackCompiler(project).compile(out)
prefix = str(project.project_id)
with zipfile.ZipFile(out) as zf:
yaml_text = zf.read(f"{prefix}/project.yaml").decode()
restored = ProjectConfig.from_yaml(yaml_text)
assert restored.client_name == project.client_name
assert restored.vessel.name == project.vessel.name
def test_empty_client_name_raises(self) -> None:
project = _make_project()
object.__setattr__(project, "client_name", " ")
with pytest.raises(ValueError, match="client_name"):
AppackCompiler(project)
def test_output_parent_created(self, tmp_path: Path) -> None:
out = tmp_path / "deep" / "nested" / "output.appack"
AppackCompiler(_make_project()).compile(out)
assert out.exists()