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:
@@ -434,3 +434,57 @@ git version 2.51.1.windows.1
|
||||
## Следующий этап
|
||||
|
||||
Криптографическая аутентификация отложена. Сначала выполняется этап стабилизации: быстрые тесты `protocol/` вместо единственной точки входа через лабораторную, разделение быстрых проверок и экспериментов, воспроизводимое описание окружения.
|
||||
|
||||
---
|
||||
|
||||
# Запись 018
|
||||
|
||||
## Дата
|
||||
|
||||
10 августа 2026 года
|
||||
|
||||
## Тема
|
||||
|
||||
Выбор радиочастотной архитектуры: рабочий диапазон, скачки по частоте и меры против подавления.
|
||||
|
||||
## Исходные данные
|
||||
|
||||
- Устройство опрошено напрямую: `Analog Devices PlutoSDR Rev.C (Z7010-AD9361)`. Приёмник перестраивается от 70 МГц до 6 ГГц, передатчик от 46,9 МГц. Минимальная частота дискретизации 2 083 333 Гц.
|
||||
- Ограничений по разрешённым частотам у проекта нет.
|
||||
- Усилителя мощности нет, покупка отложена до первых испытаний.
|
||||
|
||||
## Отвергнутые варианты и причины
|
||||
|
||||
**Выбор редкой фиксированной частоты.** Рассматривался как защита от подавления. Отклонён: одиночная несущая в пустом эфире обнаруживается сканирующим приёмником тривиально. Уязвимость создаёт не значение частоты, а её неизменность.
|
||||
|
||||
**Скачки по всему диапазону 70 МГц – 6 ГГц.** Теоретически требуют от заградительной помехи на 42 дБ больше мощности, но выигрыш недостижим. Отношение крайних частот 86:1, одна антенна такой полосы не перекрывает; разброс потерь на 10 км составляет 39 дБ, проектировать пришлось бы по худшему краю; выше примерно 1 ГГц дифракция исчезает и для наземного ровера без прямой видимости эти каналы мертвы. Кроме того, подавление направлено на диапазон, в котором связь действительно работает, а не на тот, который способно настроить оборудование.
|
||||
|
||||
**Диапазоны 330–433 МГц.** Отклонены как плотно занятые и общеизвестные.
|
||||
|
||||
## Принято
|
||||
|
||||
Рабочий диапазон **200–250 МГц**. Обоснование: четвертьволновая антенна 33 см пригодна для установки на ровер, дифракция ещё эффективна, участок заметно свободнее любительского 144 МГц и УКВ выше 300 МГц. Потери на 10 км составляют около 99,5 дБ.
|
||||
|
||||
Совокупность мер против подавления:
|
||||
|
||||
1. **Скачки по частоте в пределах 200–250 МГц.** Требуют от заградительной помехи на 22 дБ больше мощности при неизменной антенне и механике ровера.
|
||||
2. **Направленная антенна наземной станции.** Даёт 10–20 дБ усиления и пространственно подавляет помеху, приходящую с иного направления.
|
||||
3. **Расширение спектра для команд управления.** Поток команд составляет 10,88 кбит/с, поэтому расширение для него реализуемо в узкой полосе, в отличие от видеопотока.
|
||||
|
||||
Видео остаётся некритичным классом: при подавлении управление сохраняется в узкой полосе, а ровер переходит к безопасному поведению по уже реализованной логике.
|
||||
|
||||
## Запрещённые участки
|
||||
|
||||
- 108–137 МГц — авиационная связь.
|
||||
- 243 МГц — международная аварийная частота.
|
||||
|
||||
## Ограничения решения
|
||||
|
||||
- Нижняя граница 70 МГц определяется приёмником Pluto и без смены оборудования не преодолевается.
|
||||
- Скачки требуют согласованной последовательности у обеих сторон, то есть зависят от криптографической аутентификации, которая пока не реализована.
|
||||
- Расчёты выполнены для свободного пространства. Потери на препятствиях 20–40 дБ учтены оценочно и подлежат измерению.
|
||||
- Дальность 10 км без усилителя мощности не достигается.
|
||||
|
||||
## Влияние на ближайшие работы
|
||||
|
||||
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200–250 МГц вступает в силу начиная с эфирных испытаний.
|
||||
|
||||
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
|
||||
288
tests/test_session_state.py
Normal file
288
tests/test_session_state.py
Normal 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))
|
||||
Reference in New Issue
Block a user