"""Tests for Modbus register utility helpers -- Sprint 9.""" from __future__ import annotations import math import pytest from arautopilot.shared.modbus_utils import ( UINT16_MAX, INT16_MAX, INT16_MIN, clamp_uint16, heading_deg_to_raw, raw_signed_to_scaled, raw_to_heading_deg, raw_to_scaled, rudder_deg_to_raw_signed, scale_to_raw, scale_to_raw_signed, validate_holding_write, ) class TestScaleToRaw: def test_basic_heading(self): assert scale_to_raw(45.0, scale=0.01) == 4500 def test_zero(self): assert scale_to_raw(0.0, scale=0.01) == 0 def test_max_uint16(self): assert scale_to_raw(655.35, scale=0.01) == UINT16_MAX def test_out_of_range_raises(self): with pytest.raises(ValueError, match="uint16"): scale_to_raw(700.0, scale=0.01) def test_negative_raises(self): with pytest.raises(ValueError, match="uint16"): scale_to_raw(-1.0, scale=0.01) def test_zero_scale_raises(self): with pytest.raises(ValueError, match="scale"): scale_to_raw(10.0, scale=0.0) def test_inf_raises(self): with pytest.raises(ValueError, match="finite"): scale_to_raw(math.inf, scale=0.01) def test_nan_raises(self): with pytest.raises(ValueError, match="finite"): scale_to_raw(math.nan, scale=0.01) def test_with_offset(self): # physical=10, offset=5, scale=1 → raw = (10-5)/1 = 5 assert scale_to_raw(10.0, scale=1.0, offset=5.0) == 5 class TestRawToScaled: def test_basic(self): assert raw_to_scaled(4500, scale=0.01) == pytest.approx(45.0) def test_with_offset(self): assert raw_to_scaled(5, scale=1.0, offset=5.0) == pytest.approx(10.0) class TestScaleToRawSigned: def test_positive_rudder(self): # +10.0 deg * 100 = 1000 assert scale_to_raw_signed(10.0, scale=0.01) == 1000 def test_negative_rudder(self): raw = scale_to_raw_signed(-10.0, scale=0.01) # -1000 in two's complement uint16 = 65536 - 1000 = 64536 assert raw == (0xFFFF + 1) - 1000 def test_zero(self): assert scale_to_raw_signed(0.0, scale=0.01) == 0 def test_max_positive(self): assert scale_to_raw_signed(327.67, scale=0.01) == INT16_MAX def test_out_of_range_positive_raises(self): with pytest.raises(ValueError): scale_to_raw_signed(400.0, scale=0.01) def test_out_of_range_negative_raises(self): with pytest.raises(ValueError): scale_to_raw_signed(-400.0, scale=0.01) def test_nan_raises(self): with pytest.raises(ValueError, match="finite"): scale_to_raw_signed(math.nan, scale=0.01) class TestRawSignedToScaled: def test_positive(self): assert raw_signed_to_scaled(1000, scale=0.01) == pytest.approx(10.0) def test_negative(self): raw = (0x10000 - 1000) # -1000 in two's complement assert raw_signed_to_scaled(raw, scale=0.01) == pytest.approx(-10.0) def test_zero(self): assert raw_signed_to_scaled(0, scale=0.01) == pytest.approx(0.0) class TestClampUint16: def test_in_range(self): assert clamp_uint16(1000) == 1000 def test_below_zero(self): assert clamp_uint16(-5) == 0 def test_above_max(self): assert clamp_uint16(70000) == UINT16_MAX def test_at_max(self): assert clamp_uint16(UINT16_MAX) == UINT16_MAX class TestValidateHoldingWrite: def test_valid(self): validate_holding_write("HEADING_SETPOINT_X100", 4500) # no exception def test_negative_raises(self): with pytest.raises(ValueError, match="uint16"): validate_holding_write("HEADING_SETPOINT_X100", -1) def test_over_max_raises(self): with pytest.raises(ValueError, match="uint16"): validate_holding_write("HEADING_SETPOINT_X100", UINT16_MAX + 1) def test_float_raises_type_error(self): with pytest.raises(TypeError): validate_holding_write("X", 45.0) def test_zero_valid(self): validate_holding_write("TEST", 0) def test_max_valid(self): validate_holding_write("TEST", UINT16_MAX) class TestHeadingConversion: def test_zero(self): assert heading_deg_to_raw(0.0) == 0 def test_45_deg(self): assert heading_deg_to_raw(45.0) == 4500 def test_360_wraps_to_0(self): assert heading_deg_to_raw(360.0) == 0 def test_over_360_wraps(self): assert heading_deg_to_raw(370.0) == heading_deg_to_raw(10.0) def test_negative_wraps(self): assert heading_deg_to_raw(-10.0) == heading_deg_to_raw(350.0) def test_inf_raises(self): with pytest.raises(ValueError): heading_deg_to_raw(math.inf) def test_roundtrip(self): for deg in [0.0, 45.0, 90.0, 135.0, 180.0, 270.0, 359.99]: raw = heading_deg_to_raw(deg) back = raw_to_heading_deg(raw) assert abs(back - deg) < 0.01 class TestRudderConversion: def test_positive(self): raw = rudder_deg_to_raw_signed(10.0) back = raw_signed_to_scaled(raw, scale=0.01) assert back == pytest.approx(10.0) def test_negative(self): raw = rudder_deg_to_raw_signed(-10.0) back = raw_signed_to_scaled(raw, scale=0.01) assert back == pytest.approx(-10.0) def test_zero(self): assert rudder_deg_to_raw_signed(0.0) == 0