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

288
tests/test_session_state.py Normal file
View File

@@ -0,0 +1,288 @@
"""Проверки автоматов сеанса: protocol/session_state.py.
Свойства безопасности, перенесённые из функциональных проверок Lab041.
До этого они проверялись только при полном запуске лабораторной на 180
сочетаний, а пять из них в отчёте вообще не измерялись.
"""
from __future__ import annotations
import pytest
from protocol.control_messages import (
ControlStateMessage,
MessageContext,
ResetRequest,
SequenceComparison,
compare_sequence,
)
from protocol.session_state import (
GroundSessionController,
GroundSessionState,
RejectionCause,
RoverSessionController,
RoverSessionState,
deterministic_identifier,
)
CONTROL_STREAM_ID = 20
def active_pair(seed: int) -> tuple[GroundSessionController, RoverSessionController]:
"""Довести обе стороны до состояния, в котором разрешено движение."""
ground = GroundSessionController(100 + seed)
rover = RoverSessionController(200 + seed)
status = rover.receive_hello(ground.start_synchronization())
assert ground.receive_status(status)
acknowledgement = rover.authorize_movement(ground.request_movement_authorization(1))
assert ground.receive_movement_authorize_ack(acknowledgement)
return ground, rover
def control_from(ground, rover, sequence_number: int, speed: float = 1.0) -> ControlStateMessage:
context = MessageContext(ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, sequence_number)
return ControlStateMessage(context, speed, 0.0, False, True)
# ------------------------------------------------------------------ безопасный запуск
def test_both_sides_boot_without_movement() -> None:
ground = GroundSessionController(7)
rover = RoverSessionController(8)
assert ground.state is GroundSessionState.GROUND_BOOT
assert not ground.movement_permitted
assert rover.state is RoverSessionState.ROVER_BOOT_SAFE
assert rover.safe
def test_session_status_alone_does_not_permit_movement() -> None:
ground = GroundSessionController(9)
rover = RoverSessionController(10)
assert ground.receive_status(rover.receive_hello(ground.start_synchronization()))
assert ground.state is GroundSessionState.GROUND_SAFE_SYNCHRONIZED
assert not ground.movement_permitted
assert rover.safe
def test_authorization_ack_does_not_restore_motion() -> None:
ground, rover = active_pair(11)
assert ground.current_speed_mps == 0.0
assert not ground.movement_permitted
assert rover.safe
def test_movement_requires_a_new_operator_command() -> None:
ground, rover = active_pair(12)
assert rover.safe
assert rover.receive_control(ground.new_operator_control(1.0), CONTROL_STREAM_ID).accepted
assert rover.speed_mps == 1.0
def test_restarted_rover_is_immediately_safe() -> None:
ground, rover = active_pair(13)
rover.receive_control(ground.new_operator_control(1.0), CONTROL_STREAM_ID)
restarted = RoverSessionController(999)
assert restarted.state is RoverSessionState.ROVER_BOOT_SAFE
assert restarted.safe
def test_new_ground_session_stops_the_old_command_stream() -> None:
old_ground, rover = active_pair(14)
old_command = old_ground.new_operator_control(1.0)
new_ground = GroundSessionController(1000)
rover.receive_hello(new_ground.start_synchronization())
decision = rover.receive_control(old_command, CONTROL_STREAM_ID)
assert decision.reason is RejectionCause.GROUND_SESSION_ID
def test_duplicate_hello_is_idempotent() -> None:
ground = GroundSessionController(30)
rover = RoverSessionController(31)
hello = ground.start_synchronization()
first = rover.receive_hello(hello)
second = rover.receive_hello(hello)
assert first == second
assert rover.duplicate_hellos == 1
# ------------------------------------------------------------------ старые пакеты
@pytest.mark.parametrize(
"field,expected",
[
("ground_session_id", RejectionCause.GROUND_SESSION_ID),
("rover_boot_id", RejectionCause.ROVER_BOOT_ID),
("control_epoch", RejectionCause.CONTROL_EPOCH),
],
)
def test_stale_identifier_is_rejected(field: str, expected: RejectionCause) -> None:
"""Пакет предыдущего сеанса, запуска или периода управления не принимается."""
ground, rover = active_pair(hash(field) % 500)
values = {
"ground_session_id": ground.ground_session_id,
"rover_boot_id": rover.rover_boot_id,
"control_epoch": rover.control_epoch,
"sequence_number": 1,
}
values[field] -= 1
message = ControlStateMessage(MessageContext(**values), 1.0, 0.0, False, True)
assert rover.receive_control(message, CONTROL_STREAM_ID).reason is expected
def test_sequence_zero_is_accepted_in_a_new_session() -> None:
ground, rover = active_pair(1)
assert rover.receive_control(control_from(ground, rover, 0), CONTROL_STREAM_ID).accepted
def test_sequence_wrap_is_treated_as_newer() -> None:
ground, rover = active_pair(5)
key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID)
rover.last_sequences[key] = 0xFFFFFFFF
assert rover.receive_control(control_from(ground, rover, 0), CONTROL_STREAM_ID).accepted
assert compare_sequence(0, 0xFFFFFFFF) is SequenceComparison.NEWER
def test_ambiguous_half_range_is_rejected() -> None:
ground, rover = active_pair(6)
key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID)
rover.last_sequences[key] = 0
decision = rover.receive_control(control_from(ground, rover, 0x80000000), CONTROL_STREAM_ID)
assert not decision.accepted
assert decision.ambiguous_sequence
def test_replayed_command_is_rejected() -> None:
ground, rover = active_pair(21)
assert rover.receive_control(control_from(ground, rover, 10), CONTROL_STREAM_ID).accepted
replay = rover.receive_control(control_from(ground, rover, 10), CONTROL_STREAM_ID)
assert not replay.accepted
assert replay.reason is RejectionCause.SEQUENCE_NUMBER
# ------------------------------------------------------------------ аварийное намерение
def test_persisted_emergency_intent_survives_a_ground_restart() -> None:
ground = GroundSessionController(15, persisted_emergency_intent=True, persisted_emergency_event_id=77)
assert ground.emergency_intent
assert not ground.movement_permitted
hello = ground.start_synchronization()
assert hello.emergency_intent
assert hello.emergency_event_id == 77
def test_emergency_acknowledgement_does_not_clear_the_intent() -> None:
ground, rover = active_pair(16)
acknowledgement = rover.receive_emergency(ground.request_emergency(88))
assert acknowledgement is not None
assert ground.receive_emergency_ack(acknowledgement)
assert ground.emergency_intent
assert not ground.movement_permitted
def test_ordinary_command_does_not_release_the_latch() -> None:
ground, rover = active_pair(20)
rover.receive_emergency(ground.request_emergency(96))
decision = rover.receive_control(control_from(ground, rover, 100), CONTROL_STREAM_ID)
assert not decision.accepted
assert rover.state is RoverSessionState.EMERGENCY_LATCHED
def test_movement_authorization_is_refused_while_intent_stands() -> None:
ground = GroundSessionController(40, persisted_emergency_intent=True, persisted_emergency_event_id=5)
rover = RoverSessionController(41)
ground.receive_status(rover.receive_hello(ground.start_synchronization()))
with pytest.raises(RuntimeError):
ground.request_movement_authorization(1)
# ------------------------------------------------------------------ сброс
def test_stale_reset_request_is_refused() -> None:
ground, rover = active_pair(17)
assert rover.receive_emergency(ground.request_emergency(90)) is not None
request = ground.request_reset(91)
stale_context = MessageContext(
request.context.ground_session_id - 1,
request.context.rover_boot_id,
request.context.control_epoch,
request.context.sequence_number,
)
stale = ResetRequest(stale_context, request.emergency_event_id, request.reset_request_id, 0.0, False)
assert not rover.receive_reset(stale).acknowledgement.accepted
def test_repeated_reset_is_idempotent() -> None:
ground, rover = active_pair(18)
rover.receive_emergency(ground.request_emergency(92))
request = ground.request_reset(93)
first = rover.receive_reset(request)
assert first.performed
repeated = rover.receive_reset(request)
assert repeated.duplicate
assert not repeated.performed
assert repeated.acknowledgement.accepted
def test_reset_requires_an_emergency_intent() -> None:
ground, _ = active_pair(50)
with pytest.raises(RuntimeError):
ground.request_reset(1)
def test_reset_does_not_restore_the_previous_command() -> None:
ground, rover = active_pair(51)
rover.receive_control(ground.new_operator_control(2.0), CONTROL_STREAM_ID)
rover.receive_emergency(ground.request_emergency(60))
assert rover.speed_mps == 0.0
decision = rover.receive_reset(ground.request_reset(61))
assert decision.performed
assert ground.receive_reset_ack(decision.acknowledgement)
assert not ground.movement_permitted
assert ground.current_speed_mps == 0.0
assert rover.safe
# ------------------------------------------------------------------ идентификаторы
def test_identifiers_are_reproducible_and_nonzero() -> None:
assert deterministic_identifier(42, "ground") == deterministic_identifier(42, "ground")
assert deterministic_identifier(42, "ground") != deterministic_identifier(42, "rover")
assert deterministic_identifier(42, "ground") != deterministic_identifier(43, "ground")
for seed in range(50):
assert 0 < deterministic_identifier(seed, "ground") <= 0xFFFFFFFFFFFFFFFF
def test_identifier_rejects_a_non_integer_seed() -> None:
with pytest.raises(TypeError):
deterministic_identifier("не число", "ground")
# ------------------------------------------------------------------ границы скорости
def test_negative_operator_speed_is_refused() -> None:
"""Свойство, которое в отчёте Lab041 стояло литеральным нулём."""
ground, _ = active_pair(70)
for speed in (-0.1, -5.0, 0.0):
with pytest.raises(ValueError):
ground.new_operator_control(speed)
def test_speed_above_the_limit_is_refused_by_encoding() -> None:
from protocol.control_messages import ControlMessageError, encode_message
ground, rover = active_pair(71)
with pytest.raises(ControlMessageError):
encode_message(control_from(ground, rover, 1, speed=15.1))