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:
LittleSam129
2026-08-10 15:29:47 +03:00
parent 2da80d0908
commit 92289bdf7c
3 changed files with 497 additions and 0 deletions

View 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