Cover control messages and session state; record the RF decision
Two more suites, 69 checks, bringing the fast tests to 125 in 0.3 s. test_control_messages: roundtrip for all ten message types, a guard that fails if a type is added without a test, wrong version, unknown type, truncation, the control-state range rules including negative and non-finite speed, and sequence comparison across the 32-bit wrap and the ambiguous 2^31 boundary. test_session_state: the safety properties that previously only ran inside Lab041. Safe boot on both sides, status and authorization not being a movement command, a new operator command being required, stale session, boot and epoch identifiers, replay, sequence wrap, ambiguity, persisted emergency intent surviving a restart, acknowledgement not clearing it, ordinary commands not releasing the latch, idempotent reset, and reset not restoring the previous command. Two of these were literal zeros in the Lab041 report and measured nothing: negative speed and speed above the limit are now genuinely exercised against the encoder. PROJECT_LOG entry 018 records the RF architecture decision: 200-250 MHz with frequency hopping, a directional ground antenna and spread spectrum for the command channel, with the rejected alternatives and why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
155
tests/test_control_messages.py
Normal file
155
tests/test_control_messages.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Быстрые проверки формата управляющих сообщений: protocol/control_messages.py.
|
||||
|
||||
Перенесено из функциональных проверок Lab041, где эти свойства
|
||||
проверялись только при полном запуске лабораторной.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from protocol.control_messages import (
|
||||
COMMON_SIZE,
|
||||
ControlMessageError,
|
||||
ControlStateMessage,
|
||||
EmergencyAck,
|
||||
EmergencyStop,
|
||||
MessageContext,
|
||||
MessageType,
|
||||
MovementAuthorizeAck,
|
||||
MovementAuthorizeRequest,
|
||||
ResetAck,
|
||||
ResetRequest,
|
||||
RoverSafetyCode,
|
||||
SequenceComparison,
|
||||
SessionHello,
|
||||
SessionReject,
|
||||
SessionRejectReason,
|
||||
SessionStatus,
|
||||
compare_sequence,
|
||||
decode_message,
|
||||
encode_message,
|
||||
)
|
||||
|
||||
|
||||
CONTEXT = MessageContext(11, 22, 33, 44)
|
||||
|
||||
ALL_MESSAGES = (
|
||||
SessionHello(CONTEXT, True, 101),
|
||||
SessionStatus(CONTEXT, RoverSafetyCode.ROVER_SESSION_SYNCED_SAFE, False, 0),
|
||||
MovementAuthorizeRequest(CONTEXT, 201),
|
||||
MovementAuthorizeAck(CONTEXT, 201, True),
|
||||
ControlStateMessage(CONTEXT, 1.25, -0.5, False, True),
|
||||
EmergencyStop(CONTEXT, 301),
|
||||
EmergencyAck(CONTEXT, 301),
|
||||
ResetRequest(CONTEXT, 301, 401, 0.0, False),
|
||||
ResetAck(CONTEXT, 301, 401, True, SessionRejectReason.ACCEPTED),
|
||||
SessionReject(CONTEXT, MessageType.CONTROL_STATE, SessionRejectReason.CONTROL_EPOCH),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ALL_MESSAGES, ids=lambda m: type(m).__name__)
|
||||
def test_roundtrip_is_exact(message) -> None:
|
||||
assert decode_message(encode_message(message)) == message
|
||||
|
||||
|
||||
def test_every_message_type_is_covered() -> None:
|
||||
"""Если добавится тип сообщения, этот тест обязан упасть."""
|
||||
|
||||
assert len(ALL_MESSAGES) == len(MessageType)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("message", ALL_MESSAGES, ids=lambda m: type(m).__name__)
|
||||
def test_encoding_starts_with_the_common_header(message) -> None:
|
||||
encoded = encode_message(message)
|
||||
assert len(encoded) >= COMMON_SIZE
|
||||
assert encoded[0] == 1
|
||||
|
||||
|
||||
def test_wrong_version_is_rejected() -> None:
|
||||
payload = bytearray(encode_message(SessionHello(CONTEXT, False, 0)))
|
||||
payload[0] = 2
|
||||
with pytest.raises(ControlMessageError):
|
||||
decode_message(bytes(payload))
|
||||
|
||||
|
||||
def test_unknown_type_is_rejected() -> None:
|
||||
payload = bytearray(encode_message(SessionHello(CONTEXT, False, 0)))
|
||||
payload[1] = 255
|
||||
with pytest.raises(ControlMessageError):
|
||||
decode_message(bytes(payload))
|
||||
|
||||
|
||||
def test_truncated_message_is_rejected() -> None:
|
||||
payload = encode_message(SessionHello(CONTEXT, False, 0))
|
||||
for cut in (0, 1, COMMON_SIZE - 1, len(payload) - 1):
|
||||
with pytest.raises(ControlMessageError):
|
||||
decode_message(payload[:cut])
|
||||
|
||||
|
||||
# ------------------------------------------------------- диапазоны команд движения
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"speed,turn,braking,permitted",
|
||||
[
|
||||
(15.1, 0.0, False, True), # выше предела
|
||||
(-0.1, 0.0, False, True), # отрицательная скорость
|
||||
(-5.0, 0.0, False, True),
|
||||
(1.0, 1.1, False, True), # поворот вне диапазона
|
||||
(1.0, -1.1, False, True),
|
||||
(1.0, 0.0, True, False), # торможение при ненулевой скорости
|
||||
(1.0, 0.0, False, False), # движение без разрешения
|
||||
],
|
||||
)
|
||||
def test_invalid_control_state_is_rejected(speed, turn, braking, permitted) -> None:
|
||||
with pytest.raises(ControlMessageError):
|
||||
encode_message(ControlStateMessage(CONTEXT, speed, turn, braking, permitted))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"speed,turn,braking,permitted",
|
||||
[
|
||||
(0.0, 0.0, True, False),
|
||||
(15.0, 0.0, False, True),
|
||||
(0.0, 0.0, False, False),
|
||||
(7.5, 1.0, False, True),
|
||||
(7.5, -1.0, False, True),
|
||||
],
|
||||
)
|
||||
def test_valid_control_state_is_accepted(speed, turn, braking, permitted) -> None:
|
||||
message = ControlStateMessage(CONTEXT, speed, turn, braking, permitted)
|
||||
assert decode_message(encode_message(message)) == message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
|
||||
def test_non_finite_speed_is_rejected(bad: float) -> None:
|
||||
with pytest.raises(ControlMessageError):
|
||||
encode_message(ControlStateMessage(CONTEXT, bad, 0.0, False, True))
|
||||
|
||||
|
||||
# ------------------------------------------------------- сравнение номеров сообщений
|
||||
|
||||
|
||||
def test_sequence_comparison_basic_order() -> None:
|
||||
assert compare_sequence(5, 4) is SequenceComparison.NEWER
|
||||
assert compare_sequence(4, 5) is SequenceComparison.NOT_NEWER
|
||||
assert compare_sequence(4, 4) is SequenceComparison.NOT_NEWER
|
||||
|
||||
|
||||
def test_sequence_comparison_wraps_at_32_bits() -> None:
|
||||
"""Переход через 0xFFFFFFFF обязан считаться новым, а не откатом."""
|
||||
|
||||
assert compare_sequence(0, 0xFFFFFFFF) is SequenceComparison.NEWER
|
||||
assert compare_sequence(1, 0xFFFFFFFE) is SequenceComparison.NEWER
|
||||
|
||||
|
||||
def test_ambiguous_half_range_is_rejected() -> None:
|
||||
"""Разность ровно 2^31 неотличима от отката и должна отклоняться."""
|
||||
|
||||
assert compare_sequence(0x80000000, 0) is SequenceComparison.AMBIGUOUS
|
||||
assert compare_sequence(0, 0x80000000) is SequenceComparison.AMBIGUOUS
|
||||
|
||||
|
||||
def test_just_below_the_ambiguous_boundary_is_newer() -> None:
|
||||
assert compare_sequence(0x7FFFFFFF, 0) is SequenceComparison.NEWER
|
||||
Reference in New Issue
Block a user