Skip to content

Commit 86bd147

Browse files
test: add protocol tests for _decode_temperature, _check_length, and surviving mutants
Add direct tests for _decode_temperature and _check_length helpers. Kill survived mutants for encode_temperature modulus, decode_temp_unit unit=None, decode_flame_effect pulsating & vs |, and encode_heat_settings boost floor max(0,...) vs max(1,...). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e5b4b9b commit 86bd147

1 file changed

Lines changed: 169 additions & 0 deletions

File tree

tests/test_protocol.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,6 +1615,175 @@ def test_payload_size_255(self):
16151615
assert result == struct.pack("<HB", 100, 255)
16161616

16171617

1618+
# ---------------------------------------------------------------------------
1619+
# Direct tests for _decode_temperature
1620+
# ---------------------------------------------------------------------------
1621+
1622+
1623+
class TestDecodeTemperature:
1624+
"""Tests for _decode_temperature to kill no-test mutants."""
1625+
1626+
def test_decode_integer_temp(self):
1627+
"""22.0 => byte 22 + byte 0/10."""
1628+
raw = bytes([22, 0])
1629+
result = _protocol_module._decode_temperature(raw, 0)
1630+
assert result == 22.0
1631+
1632+
def test_decode_fractional_temp(self):
1633+
"""22.5 => byte 22 + byte 5/10."""
1634+
raw = bytes([22, 5])
1635+
result = _protocol_module._decode_temperature(raw, 0)
1636+
assert result == 22.5
1637+
1638+
def test_decode_uses_correct_offset(self):
1639+
"""Ensure offset is used correctly (not offset-1 or offset+2)."""
1640+
raw = bytes([99, 25, 3])
1641+
result = _protocol_module._decode_temperature(raw, 1)
1642+
assert result == 25.3
1643+
1644+
def test_decode_addition_not_subtraction(self):
1645+
"""Verify integer + fraction, not integer - fraction."""
1646+
raw = bytes([10, 5])
1647+
result = _protocol_module._decode_temperature(raw, 0)
1648+
assert result == 10.5 # not 9.5
1649+
1650+
def test_decode_division_by_10(self):
1651+
"""Verify division by 10, not multiplication or division by 11."""
1652+
raw = bytes([20, 9])
1653+
result = _protocol_module._decode_temperature(raw, 0)
1654+
assert result == 20.9 # 20 + 9/10.0
1655+
1656+
1657+
# ---------------------------------------------------------------------------
1658+
# Direct tests for _check_length
1659+
# ---------------------------------------------------------------------------
1660+
1661+
1662+
class TestCheckLength:
1663+
"""Tests for _check_length to kill no-test mutants."""
1664+
1665+
def test_exact_length_does_not_raise(self):
1666+
"""Exact length should pass (< not <=)."""
1667+
_protocol_module._check_length(bytes(4), 4, "Test")
1668+
1669+
def test_short_length_raises(self):
1670+
"""Shorter than expected should raise ProtocolError."""
1671+
with pytest.raises(ProtocolError, match="Insufficient data for Test"):
1672+
_protocol_module._check_length(bytes(3), 4, "Test")
1673+
1674+
def test_longer_length_does_not_raise(self):
1675+
"""Longer than expected should pass."""
1676+
_protocol_module._check_length(bytes(5), 4, "Test")
1677+
1678+
def test_error_message_contains_name(self):
1679+
"""Error message should include the parameter name."""
1680+
with pytest.raises(ProtocolError, match="FlameEffect"):
1681+
_protocol_module._check_length(bytes(0), 23, "FlameEffect")
1682+
1683+
def test_error_message_contains_expected_bytes(self):
1684+
"""Error message should include expected byte count."""
1685+
with pytest.raises(ProtocolError, match="expected 7 bytes"):
1686+
_protocol_module._check_length(bytes(3), 7, "HeatSettings")
1687+
1688+
1689+
# ---------------------------------------------------------------------------
1690+
# Tests to kill survived protocol mutants
1691+
# ---------------------------------------------------------------------------
1692+
1693+
1694+
class TestEncodeTemperatureModulus:
1695+
"""Kill _encode_temperature__mutmut_6: temp % 1 vs temp % 2."""
1696+
1697+
def test_fractional_part_with_temp_above_1(self):
1698+
"""For 22.5: fraction is 0.5, so second byte should be 5."""
1699+
result = _encode_temperature(22.5)
1700+
assert result == bytes([22, 5])
1701+
1702+
def test_fractional_part_with_1_point_5(self):
1703+
"""1.5 % 1 = 0.5 but 1.5 % 2 = 1.5 — kills the mutant."""
1704+
result = _encode_temperature(1.5)
1705+
assert result == bytes([1, 5])
1706+
1707+
1708+
class TestDecodeTempUnitReturnsUnit:
1709+
"""Kill _decode_temp_unit__mutmut_21: unit=None."""
1710+
1711+
def test_decoded_temp_unit_has_correct_value(self):
1712+
"""Verify decoded TempUnit carries the actual unit, not None."""
1713+
raw = _make_header(ParameterId.TEMPERATURE_UNIT, 1) + bytes([1])
1714+
result = decode_parameter(ParameterId.TEMPERATURE_UNIT, raw)
1715+
assert isinstance(result, TempUnitParam)
1716+
assert result.unit == TempUnit.CELSIUS
1717+
assert result.unit is not None
1718+
1719+
def test_fahrenheit_temp_unit(self):
1720+
raw = _make_header(ParameterId.TEMPERATURE_UNIT, 1) + bytes([0])
1721+
result = decode_parameter(ParameterId.TEMPERATURE_UNIT, raw)
1722+
assert isinstance(result, TempUnitParam)
1723+
assert result.unit == TempUnit.FAHRENHEIT
1724+
1725+
1726+
class TestDecodeFlameEffectPulsating:
1727+
"""Kill _decode_flame_effect__mutmut_26: & vs |."""
1728+
1729+
def test_pulsating_off_when_brightness_bit_only(self):
1730+
"""When brightness byte is 0b01, pulsating should be OFF.
1731+
1732+
With &1: (0b01 >> 1) & 1 = 0 & 1 = 0 -> OFF
1733+
With |1: (0b01 >> 1) | 1 = 0 | 1 = 1 -> ON (wrong!)
1734+
"""
1735+
header = _make_header(ParameterId.FLAME_EFFECT, 20)
1736+
payload = bytes(
1737+
[
1738+
1, # flame_effect (ON)
1739+
2, # flame_speed (wire 0-indexed) -> model 3
1740+
0b01, # brightness=HIGH, pulsating=OFF
1741+
0, # media_theme
1742+
0, # media_light
1743+
0,
1744+
0,
1745+
0,
1746+
0, # media_color (RBGW)
1747+
0, # padding
1748+
0, # overhead_light
1749+
0,
1750+
0,
1751+
0,
1752+
0, # overhead_color (RBGW)
1753+
0, # light_status
1754+
0, # flame_color
1755+
0,
1756+
0, # padding
1757+
0, # ambient_sensor
1758+
]
1759+
)
1760+
raw = header + payload
1761+
result = decode_parameter(ParameterId.FLAME_EFFECT, raw)
1762+
assert isinstance(result, FlameEffectParam)
1763+
assert result.pulsating_effect == PulsatingEffect.OFF
1764+
1765+
1766+
class TestEncodeHeatSettingsBoostFloor:
1767+
"""Kill _encode_heat_settings__mutmut_19: max(0,...) vs max(1,...)."""
1768+
1769+
def test_boost_duration_1_encodes_to_zero_wire(self):
1770+
"""boost_duration=1 -> wire_boost = max(0, 1-1) = 0.
1771+
1772+
With max(1, 1-1) = max(1, 0) = 1 (wrong!)
1773+
"""
1774+
param = HeatParam(
1775+
heat_status=HeatStatus.ON,
1776+
heat_mode=HeatMode.BOOST,
1777+
setpoint_temperature=22.0,
1778+
boost_duration=1,
1779+
)
1780+
b64 = encode_parameter(param)
1781+
raw = base64.b64decode(b64)
1782+
# Header(3) + [status, mode, temp_hi, temp_lo, boost]
1783+
wire_boost = raw[7] # offset 3+4 = 7
1784+
assert wire_boost == 0
1785+
1786+
16181787
class TestEncodeParameterAsciiCasing:
16191788
"""Document encode_parameter__mutmut_33 as equivalent mutant.
16201789

0 commit comments

Comments
 (0)