mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
721 lines
42 KiB
Python
721 lines
42 KiB
Python
import itertools
|
|
import math
|
|
import unittest
|
|
from dataclasses import FrozenInstanceError, replace
|
|
from typing import Any, cast
|
|
|
|
from openpilot.starpilot.speed_limits.acceptance import (
|
|
CONFIRMATION_NS, MAX_REJECTIONS, AcceptedLimit, ActionKind, AdoptRequest, Authority, Candidate, DriverAction,
|
|
IdentityKind, LongitudinalOwner, Mode, Observation, ObservationIdentity, ObservationKind, Policy, State, new_session, step,
|
|
)
|
|
|
|
NS = 1_000_000_000
|
|
|
|
|
|
def geographic(value: str) -> ObservationIdentity:
|
|
return ObservationIdentity(IdentityKind.GEOGRAPHIC, value=value)
|
|
|
|
|
|
HIGH = Candidate("dashboard", geographic("road-1"), 29.0576)
|
|
LOW = Candidate("dashboard", geographic("road-2"), 20.1168)
|
|
ACTIVE = Authority(Mode.LONGITUDINAL_ONLY, LongitudinalOwner.SYSTEM, False, True, False, False)
|
|
PAUSED = replace(ACTIVE, longitudinal_active=False)
|
|
DISENGAGED = replace(PAUSED, fully_disengaged=True)
|
|
ABSENT = Observation(ObservationKind.ABSENT)
|
|
DEFAULT_POLICY = Policy()
|
|
|
|
|
|
def valid(candidate: Candidate) -> Observation:
|
|
return Observation(ObservationKind.VALID, candidate)
|
|
|
|
|
|
def saved(candidate: Candidate = HIGH) -> State:
|
|
return replace(new_session("drive", AcceptedLimit(candidate, "previous-drive", 0)), received_speed_mps=candidate.speed_mps)
|
|
|
|
|
|
class TestAcceptance(unittest.TestCase):
|
|
def test_set_alignment_requires_an_explicit_boolean(self):
|
|
for malformed in (None, 0, 1, 'yes'):
|
|
with self.subTest(value=malformed):
|
|
result = step(saved(), valid(LOW), ACTIVE, Policy(), now_ns=0, set_speed_matches=cast(Any, malformed))
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.history_write)
|
|
self.assertIsNone(result.control_target_mps)
|
|
|
|
def test_sources_share_confirmation_across_disengage_and_source_loss(self):
|
|
for source, confirmation, fallback in itertools.product(('dashboard', 'map', 'vision', 'online'), (False, True), (False, True)):
|
|
policy = Policy(confirmation, confirmation, fallback)
|
|
candidate = replace(LOW, source=source)
|
|
first = step(new_session('drive'), valid(candidate), DISENGAGED, policy, now_ns=0)
|
|
self.assertEqual(first.state.pending is not None, confirmation)
|
|
self.assertEqual(first.history_write is None, confirmation)
|
|
self.assertIsNone(first.control_target_mps)
|
|
ready = step(first.state, valid(candidate), ACTIVE, policy, now_ns=NS)
|
|
accepted = self.act(ready.state, ActionKind.ACCEPT, now_ns=4 * NS, policy=policy) if confirmation else ready
|
|
self.assertEqual(accepted.control_target_mps, candidate.speed_mps)
|
|
lost = step(accepted.state, ABSENT, ACTIVE, policy, now_ns=5 * NS)
|
|
self.assertEqual(lost.control_target_mps, candidate.speed_mps if fallback else None)
|
|
returned = step(lost.state, valid(candidate), ACTIVE, policy, now_ns=6 * NS)
|
|
self.assertIsNone(returned.state.pending)
|
|
self.assertIsNone(returned.history_write)
|
|
|
|
def pending(self, candidate=LOW, authority=ACTIVE, policy=DEFAULT_POLICY, state=None, now_ns=0):
|
|
decision = step(saved() if state is None else state, valid(candidate), authority, policy, now_ns=now_ns)
|
|
self.assertEqual(decision.errors, ())
|
|
self.assertIsNotNone(decision.state.pending)
|
|
return decision
|
|
|
|
def act(self, state, kind, *, now_ns=NS, sequence=0, authority=ACTIVE, policy=DEFAULT_POLICY):
|
|
self.assertIsNotNone(state.pending)
|
|
action = DriverAction(state.session_id, sequence, state.pending.decision_id, kind)
|
|
return step(state, state.observation, authority, policy, now_ns=now_ns, action=action)
|
|
|
|
def adopt_request(self, state, sequence=0):
|
|
self.assertIsNotNone(state.presentation)
|
|
return AdoptRequest(state.session_id, sequence, state.presentation.presentation_id, state.presentation.candidate)
|
|
|
|
def test_reject_then_source_loss_never_activates_rejected_limit(self):
|
|
policy = Policy(fallback_previous=True)
|
|
first = step(new_session("drive"), valid(HIGH), ACTIVE, policy, now_ns=0)
|
|
initial = self.act(first.state, ActionKind.ACCEPT, policy=policy, now_ns=NS)
|
|
self.assertEqual(initial.history_write.candidate, HIGH)
|
|
proposed = self.pending(state=initial.state, policy=policy, now_ns=2 * NS)
|
|
self.assertEqual(proposed.control_target_mps, HIGH.speed_mps)
|
|
rejected = self.act(proposed.state, ActionKind.REJECT, policy=policy, now_ns=3 * NS, sequence=1)
|
|
lost = step(rejected.state, ABSENT, ACTIVE, policy, now_ns=4 * NS)
|
|
returned = step(lost.state, valid(LOW), ACTIVE, policy, now_ns=5 * NS)
|
|
for decision in (rejected, lost, returned):
|
|
self.assertEqual(decision.control_target_mps, HIGH.speed_mps)
|
|
self.assertEqual(decision.state.accepted, initial.history_write)
|
|
self.assertIsNone(decision.state.pending)
|
|
self.assertIsNone(decision.history_write)
|
|
self.assertIn(LOW, decision.state.rejected)
|
|
self.assertTrue(rejected.action_receipt.consumed)
|
|
self.assertEqual(returned.basis, "rejected")
|
|
|
|
def test_previous_limit_survives_all_source_gaps_only_when_enabled(self):
|
|
for source, kind, fallback in itertools.product(('dashboard', 'map', 'vision', 'online', 'future-source'),
|
|
(ObservationKind.ABSENT, ObservationKind.UNKNOWN, ObservationKind.STALE), (False, True)):
|
|
with self.subTest(source=source, kind=kind, fallback=fallback):
|
|
prior = saved(replace(HIGH, source=source))
|
|
policy = Policy(fallback_previous=fallback)
|
|
result = step(prior, Observation(kind), ACTIVE, policy, now_ns=300 * NS)
|
|
self.assertEqual(result.control_target_mps, HIGH.speed_mps if fallback else None)
|
|
self.assertEqual(result.state.accepted, prior.accepted)
|
|
self.assertEqual(result.state.observation.kind, kind)
|
|
self.assertIsNone(result.display_candidate)
|
|
self.assertIsNone(result.history_write)
|
|
empty = step(new_session("drive"), Observation(kind), ACTIVE, policy, now_ns=300 * NS)
|
|
self.assertIsNone(empty.control_target_mps)
|
|
|
|
def test_confirmed_vision_is_retained_until_changed_limit_is_accepted(self):
|
|
policy = Policy(fallback_previous=True)
|
|
first = replace(HIGH, source='vision')
|
|
previous = saved(first)
|
|
for kind in (ObservationKind.ABSENT, ObservationKind.UNKNOWN, ObservationKind.STALE):
|
|
lost = step(previous, Observation(kind), ACTIVE, policy, now_ns=NS)
|
|
self.assertEqual(lost.control_target_mps, first.speed_mps)
|
|
self.assertEqual(lost.state.accepted, previous.accepted)
|
|
next_sign = replace(LOW, source='vision')
|
|
pending = step(lost.state, valid(next_sign), ACTIVE, policy, now_ns=2 * NS)
|
|
self.assertEqual(pending.control_target_mps, first.speed_mps)
|
|
self.assertIsNotNone(pending.state.pending)
|
|
recovered = step(pending.state, valid(first), ACTIVE, policy, now_ns=3 * NS)
|
|
self.assertIsNotNone(recovered.state.pending)
|
|
self.assertFalse(step(recovered.state, valid(first), ACTIVE, policy, now_ns=4 * NS).errors)
|
|
for kind in ActionKind:
|
|
action = DriverAction('drive', 1, pending.state.pending.decision_id, kind)
|
|
acted = step(pending.state, valid(next_sign), ACTIVE, policy, now_ns=3 * NS, action=action)
|
|
target = next_sign.speed_mps if kind is ActionKind.ACCEPT else first.speed_mps
|
|
gap = step(acted.state, Observation(ObservationKind.UNKNOWN), ACTIVE, policy, now_ns=300 * NS)
|
|
self.assertEqual(gap.control_target_mps, target)
|
|
timeout = step(pending.state, valid(next_sign), ACTIVE, policy, now_ns=33 * NS)
|
|
self.assertEqual(timeout.basis, 'timeout_reject')
|
|
self.assertEqual(timeout.control_target_mps, first.speed_mps)
|
|
|
|
def test_unknown_stale_and_absence_clear_pending_and_restart_decision_identity(self):
|
|
for kind in (ObservationKind.UNKNOWN, ObservationKind.STALE, ObservationKind.ABSENT):
|
|
with self.subTest(kind=kind):
|
|
before = self.pending()
|
|
missing = step(before.state, Observation(kind), ACTIVE, Policy(), now_ns=NS)
|
|
after = self.pending(state=missing.state, now_ns=2 * NS)
|
|
self.assertIsNone(missing.state.pending)
|
|
self.assertNotEqual(before.state.pending.decision_id, after.state.pending.decision_id)
|
|
self.assertEqual(after.state.pending.active_elapsed_ns, 0)
|
|
late = DriverAction("drive", 1, before.state.pending.decision_id, ActionKind.ACCEPT)
|
|
result = step(after.state, valid(LOW), ACTIVE, Policy(), now_ns=3 * NS, action=late)
|
|
self.assertFalse(result.action_receipt.consumed)
|
|
self.assertEqual(result.action_receipt.status, "different_decision")
|
|
self.assertEqual(result.state.accepted.candidate, HIGH)
|
|
|
|
def test_changed_received_speed_restarts_confirmation_after_a_decline(self):
|
|
rejected = self.act(self.pending().state, ActionKind.REJECT)
|
|
other = replace(LOW, source='map', speed_mps=25.)
|
|
changed = self.pending(candidate=other, state=rejected.state, now_ns=2 * NS)
|
|
returned = self.pending(candidate=LOW, state=changed.state, now_ns=3 * NS)
|
|
self.assertNotEqual(returned.state.pending.decision_id, rejected.state.next_decision_id - 1)
|
|
self.assertFalse(returned.state.rejected)
|
|
|
|
def test_same_received_speed_does_not_prompt_again_for_a_new_zone(self):
|
|
rejected = self.act(self.pending().state, ActionKind.REJECT)
|
|
next_zone = replace(LOW, observation_identity=geographic('road-3'))
|
|
result = step(rejected.state, valid(next_zone), ACTIVE, Policy(), now_ns=2 * NS)
|
|
self.assertIsNone(result.state.pending)
|
|
self.assertEqual(result.basis, 'rejected')
|
|
|
|
def test_same_received_speed_does_not_prompt_again_on_source_switch(self):
|
|
rejected = self.act(self.pending().state, ActionKind.REJECT)
|
|
mapped = replace(LOW, source='map', observation_identity=geographic('map-road'))
|
|
result = step(rejected.state, valid(mapped), ACTIVE, Policy(), now_ns=2 * NS)
|
|
self.assertEqual(result.basis, 'rejected')
|
|
self.assertIsNone(result.state.pending)
|
|
self.assertEqual(result.control_target_mps, HIGH.speed_mps)
|
|
|
|
def test_changed_pending_candidate_starts_new_clock_and_id(self):
|
|
first = self.pending()
|
|
progressed = step(first.state, valid(LOW), ACTIVE, Policy(), now_ns=29 * NS)
|
|
changed = self.pending(candidate=replace(LOW, speed_mps=19.0), state=progressed.state, now_ns=40 * NS)
|
|
self.assertNotEqual(changed.state.pending.decision_id, first.state.pending.decision_id)
|
|
self.assertEqual(changed.state.pending.active_elapsed_ns, 0)
|
|
|
|
def test_confirmation_expires_at_ten_seconds(self):
|
|
pending = self.pending()
|
|
before = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=CONFIRMATION_NS - 1)
|
|
self.assertIsNotNone(before.state.pending)
|
|
expired = step(before.state, valid(LOW), ACTIVE, Policy(), now_ns=CONFIRMATION_NS)
|
|
self.assertEqual(expired.basis, "timeout_reject")
|
|
self.assertIn(LOW, expired.state.rejected)
|
|
self.assertIsNone(expired.state.pending)
|
|
self.assertIsNone(expired.history_write)
|
|
self.assertEqual(expired.control_target_mps, HIGH.speed_mps)
|
|
|
|
def test_confirmation_expires_after_ten_seconds_even_without_active_longitudinal(self):
|
|
start = self.pending()
|
|
pause = step(start.state, valid(LOW), PAUSED, Policy(), now_ns=5 * NS)
|
|
self.assertEqual(pause.state.pending.active_elapsed_ns, 5 * NS)
|
|
expired = step(pause.state, valid(LOW), PAUSED, Policy(), now_ns=CONFIRMATION_NS)
|
|
self.assertEqual(expired.basis, 'timeout_reject')
|
|
self.assertIsNone(expired.control_target_mps)
|
|
self.assertEqual(expired.state.accepted.candidate, HIGH)
|
|
|
|
def test_no_implicit_ttl_or_maximum_active_gap(self):
|
|
# Freshness belongs to the adapter: an explicitly valid active interval counts.
|
|
start = self.pending()
|
|
result = step(start.state, valid(LOW), ACTIVE, Policy(), now_ns=1000 * NS)
|
|
self.assertEqual(result.basis, "timeout_reject")
|
|
|
|
def test_action_at_deadline_precedes_automatic_timeout(self):
|
|
for kind in ActionKind:
|
|
with self.subTest(kind=kind):
|
|
result = self.act(self.pending().state, kind, now_ns=CONFIRMATION_NS)
|
|
self.assertEqual(result.basis, "driver_accept" if kind is ActionKind.ACCEPT else "driver_reject")
|
|
self.assertTrue(result.action_receipt.consumed)
|
|
|
|
def test_first_and_changed_limits_adopt_immediately_or_require_confirmation(self):
|
|
for confirmation in (False, True):
|
|
policy = Policy(confirmation, confirmation)
|
|
for prior, candidate in itertools.product((new_session('drive'), saved()), (LOW, replace(HIGH, speed_mps=31.))):
|
|
with self.subTest(confirmation=confirmation, prior=prior.accepted, candidate=candidate):
|
|
first = step(prior, valid(candidate), ACTIVE, policy, now_ns=0)
|
|
self.assertEqual(first.state.pending is not None, confirmation)
|
|
if confirmation:
|
|
self.assertFalse(first.state.pending.automatic)
|
|
self.assertEqual(first.state.accepted, prior.accepted)
|
|
self.assertIsNone(first.history_write)
|
|
else:
|
|
self.assertEqual(first.state.accepted.candidate, candidate)
|
|
self.assertEqual(first.history_write, first.state.accepted)
|
|
self.assertEqual(first.control_target_mps, candidate.speed_mps)
|
|
repeated = step(first.state, valid(candidate), ACTIVE, policy, now_ns=1)
|
|
self.assertIsNone(repeated.history_write)
|
|
|
|
def test_unchanged_speed_keeps_approval_without_silently_accepting_new_identity(self):
|
|
for toward in (0.0, math.inf):
|
|
near = replace(HIGH, speed_mps=math.nextafter(HIGH.speed_mps, toward))
|
|
result = self.pending(candidate=near)
|
|
self.assertNotEqual(result.state.pending.candidate.speed_mps, HIGH.speed_mps)
|
|
same_speed = replace(HIGH, source="map", observation_identity=geographic("mapped-road"))
|
|
result = step(saved(), valid(same_speed), ACTIVE, Policy(), now_ns=0)
|
|
self.assertIsNone(result.state.pending)
|
|
self.assertIsNone(result.history_write)
|
|
self.assertEqual(result.state.accepted.candidate, HIGH)
|
|
unchanged = step(result.state, valid(same_speed), ACTIVE, Policy(), now_ns=NS)
|
|
self.assertIsNone(unchanged.history_write)
|
|
automatic = step(saved(), valid(same_speed), ACTIVE, Policy(False, False), now_ns=0)
|
|
self.assertIsNone(automatic.history_write)
|
|
|
|
def test_disengaged_automatic_adoption_is_immediate_without_commanding_speed(self):
|
|
ready = step(saved(), valid(LOW), DISENGAGED, Policy(False, False), now_ns=0)
|
|
self.assertEqual(ready.state.accepted.candidate, LOW)
|
|
self.assertIsNotNone(ready.history_write)
|
|
self.assertIsNone(ready.control_target_mps)
|
|
|
|
def test_automatic_adoption_still_waits_for_authority_then_adopts_without_delay(self):
|
|
policy = Policy(False, False)
|
|
pending = self.pending(authority=PAUSED, policy=policy)
|
|
self.assertEqual(pending.state.accepted.candidate, HIGH)
|
|
self.assertIsNone(pending.history_write)
|
|
ready = step(pending.state, valid(LOW), ACTIVE, policy, now_ns=1)
|
|
self.assertIsNone(ready.state.pending)
|
|
self.assertEqual(ready.control_target_mps, LOW.speed_mps)
|
|
|
|
def test_zero_active_axes_does_not_imply_fully_disengaged(self):
|
|
result = self.pending(authority=PAUSED)
|
|
self.assertEqual(result.state.accepted.candidate, HIGH)
|
|
self.assertIsNone(result.history_write)
|
|
self.assertIsNone(result.control_target_mps)
|
|
|
|
def test_modes_stock_owner_and_inactive_long_never_gain_control_but_driver_is_still_asked(self):
|
|
contexts = [
|
|
Authority(Mode.OFF, LongitudinalOwner.NONE, False, False, False, False),
|
|
Authority(Mode.LATERAL_ONLY, LongitudinalOwner.SYSTEM, True, False, False, False),
|
|
Authority(Mode.LATERAL_ONLY, LongitudinalOwner.STOCK, True, False, True, False),
|
|
Authority(Mode.COMBINED, LongitudinalOwner.STOCK, True, False, True, False),
|
|
Authority(Mode.LONGITUDINAL_ONLY, LongitudinalOwner.STOCK, False, False, True, False),
|
|
PAUSED,
|
|
]
|
|
for authority in contexts:
|
|
with self.subTest(authority=authority):
|
|
pending = self.pending(authority=authority)
|
|
acted = self.act(pending.state, ActionKind.ACCEPT, authority=authority)
|
|
lost = step(acted.state, ABSENT, authority, Policy(fallback_previous=True), now_ns=2 * NS)
|
|
for decision in (pending, acted, lost):
|
|
self.assertIsNone(decision.control_target_mps)
|
|
self.assertIsNone(pending.history_write)
|
|
# The driver's choice is recorded even though nothing here may act on it.
|
|
self.assertTrue(acted.action_receipt.consumed)
|
|
self.assertEqual(acted.state.accepted.candidate, LOW)
|
|
self.assertEqual(acted.state.last_action_sequence, 0)
|
|
|
|
def test_combined_and_long_only_have_equal_longitudinal_decisions(self):
|
|
for authority in (ACTIVE, replace(ACTIVE, mode=Mode.COMBINED, lateral_active=True)):
|
|
pending = self.pending(authority=authority)
|
|
accepted = self.act(pending.state, ActionKind.ACCEPT, authority=authority)
|
|
self.assertEqual(accepted.control_target_mps, LOW.speed_mps)
|
|
self.assertEqual(accepted.history_write.candidate, LOW)
|
|
|
|
def test_all_authority_combinations_reject_contradictions(self):
|
|
combinations = itertools.product(Mode, LongitudinalOwner, (False, True), (False, True), (False, True), (False, True))
|
|
for mode, owner, lateral, longitudinal, stock, disengaged in combinations:
|
|
authority = Authority(mode, owner, lateral, longitudinal, stock, disengaged)
|
|
invalid = ((lateral and mode not in (Mode.LATERAL_ONLY, Mode.COMBINED)) or
|
|
(longitudinal and (mode not in (Mode.LONGITUDINAL_ONLY, Mode.COMBINED) or owner is not LongitudinalOwner.SYSTEM)) or
|
|
(stock and owner is not LongitudinalOwner.STOCK) or (disengaged and (lateral or longitudinal or stock)))
|
|
with self.subTest(authority=authority):
|
|
result = step(saved(), valid(LOW), authority, Policy(False, False, True), now_ns=0)
|
|
self.assertEqual(bool(result.errors), bool(invalid))
|
|
if invalid:
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertIsNone(result.history_write)
|
|
elif not longitudinal:
|
|
self.assertIsNone(result.control_target_mps)
|
|
|
|
def test_display_only_clears_pending_and_never_accepts_or_falls_back(self):
|
|
pending = self.pending()
|
|
action = DriverAction("drive", 1, pending.state.pending.decision_id, ActionKind.ACCEPT)
|
|
for authority, observation in itertools.product((ACTIVE, DISENGAGED), (valid(LOW), ABSENT)):
|
|
with self.subTest(authority=authority, observation=observation):
|
|
result = step(pending.state, observation, authority, Policy(fallback_previous=True, display_only=True), now_ns=NS, action=action)
|
|
self.assertIsNone(result.state.pending)
|
|
self.assertEqual(result.state.accepted, pending.state.accepted)
|
|
self.assertIsNone(result.history_write)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertEqual(result.display_candidate, observation.candidate)
|
|
self.assertFalse(result.action_receipt.consumed)
|
|
self.assertEqual(result.state.last_action_sequence, 1)
|
|
|
|
def test_repeated_and_out_of_order_actions_are_seen_but_not_consumed(self):
|
|
pending = self.pending()
|
|
first = self.act(pending.state, ActionKind.ACCEPT, sequence=10, authority=PAUSED)
|
|
self.assertTrue(first.action_receipt.consumed)
|
|
seen = replace(pending.state, last_action_sequence=10)
|
|
for sequence in (10, 9):
|
|
result = self.act(seen, ActionKind.ACCEPT, sequence=sequence, now_ns=2 * NS)
|
|
self.assertFalse(result.action_receipt.consumed)
|
|
self.assertEqual(result.action_receipt.status, "already_seen")
|
|
self.assertEqual(result.state.last_action_sequence, 10)
|
|
self.assertEqual(result.state.accepted.candidate, HIGH)
|
|
accepted = self.act(seen, ActionKind.ACCEPT, sequence=11, now_ns=2 * NS)
|
|
self.assertTrue(accepted.action_receipt.consumed)
|
|
self.assertEqual(accepted.state.accepted.candidate, LOW)
|
|
|
|
def test_wrong_session_does_not_poison_sequence_watermark(self):
|
|
pending = self.pending()
|
|
foreign = DriverAction("other-drive", 10_000, pending.state.pending.decision_id, ActionKind.ACCEPT)
|
|
result = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, action=foreign)
|
|
self.assertEqual(result.action_receipt.status, "foreign_session")
|
|
self.assertFalse(result.action_receipt.consumed)
|
|
self.assertEqual(result.state.last_action_sequence, -1)
|
|
accepted = self.act(result.state, ActionKind.ACCEPT, sequence=0, now_ns=2 * NS)
|
|
self.assertTrue(accepted.action_receipt.consumed)
|
|
|
|
def test_unrelated_action_is_acknowledged_and_watermarked_without_consuming_it(self):
|
|
pending = self.pending()
|
|
unrelated = DriverAction("drive", 3, 987, ActionKind.REJECT)
|
|
result = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, action=unrelated)
|
|
self.assertTrue(result.action_receipt.acknowledged)
|
|
self.assertFalse(result.action_receipt.consumed)
|
|
self.assertEqual(result.action_receipt.status, "different_decision")
|
|
self.assertEqual(result.state.last_action_sequence, 3)
|
|
self.assertEqual(result.state.rejected, frozenset())
|
|
|
|
def test_event_cannot_accept_a_decision_not_previously_presented(self):
|
|
action = DriverAction("drive", 0, 1, ActionKind.ACCEPT)
|
|
result = step(saved(), valid(LOW), ACTIVE, Policy(), now_ns=0, action=action)
|
|
self.assertEqual(result.state.pending.decision_id, 1)
|
|
self.assertEqual(result.action_receipt.status, "not_presented")
|
|
self.assertFalse(result.action_receipt.consumed)
|
|
self.assertEqual(result.state.last_action_sequence, 0)
|
|
self.assertEqual(result.state.accepted.candidate, HIGH)
|
|
|
|
def test_invalid_input_fails_closed_and_valid_action_is_still_seen(self):
|
|
pending = self.pending(now_ns=10 * NS)
|
|
action = DriverAction("drive", 2, pending.state.pending.decision_id, ActionKind.ACCEPT)
|
|
result = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=9 * NS, action=action)
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertIsNone(result.state.pending)
|
|
self.assertFalse(result.state.timer_running)
|
|
self.assertEqual(result.state.last_timestamp_ns, 10 * NS)
|
|
self.assertIsNone(result.history_write)
|
|
self.assertFalse(result.action_receipt.consumed)
|
|
self.assertEqual(result.action_receipt.status, "invalid_input")
|
|
self.assertEqual(result.state.last_action_sequence, 2)
|
|
recovered = self.pending(state=result.state, now_ns=20 * NS)
|
|
self.assertEqual(recovered.state.pending.active_elapsed_ns, 0)
|
|
|
|
def test_clock_requires_nonnegative_monotonic_integer_nanoseconds(self):
|
|
invalid_times: tuple[Any, ...] = (-1, 0.0, True, math.nan, math.inf, "10", None)
|
|
for now_ns in invalid_times:
|
|
with self.subTest(now_ns=now_ns):
|
|
result = step(saved(), valid(LOW), ACTIVE, Policy(), now_ns=now_ns)
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.control_target_mps)
|
|
pending = self.pending(now_ns=NS)
|
|
same = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=NS)
|
|
self.assertEqual(same.state.pending.active_elapsed_ns, 0)
|
|
|
|
def test_candidate_requires_finite_positive_speed_and_explicit_bounded_identity(self):
|
|
malformed = [replace(LOW, speed_mps=value) for value in (0, -1, True, math.nan, math.inf, "20", None, 10 ** 1000)]
|
|
malformed += [replace(LOW, source=""), replace(LOW, observation_identity=geographic("")), replace(LOW, source=" "),
|
|
replace(LOW, observation_identity=geographic("x" * 257)),
|
|
replace(LOW, source="a\nb")]
|
|
for candidate in malformed:
|
|
with self.subTest(candidate=candidate):
|
|
result = step(saved(), valid(candidate), ACTIVE, Policy(False, False), now_ns=0)
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertIsNone(result.history_write)
|
|
|
|
def test_observation_classification_is_explicit_and_consistent(self):
|
|
malformed: list[Any] = [Observation(ObservationKind.VALID), Observation(cast(Any, "valid"), LOW), None]
|
|
malformed += [Observation(kind, LOW) for kind in (ObservationKind.ABSENT, ObservationKind.UNKNOWN, ObservationKind.STALE)]
|
|
for observation in malformed:
|
|
with self.subTest(observation=observation):
|
|
result = step(saved(), observation, ACTIVE, Policy(fallback_previous=True), now_ns=0)
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.control_target_mps)
|
|
|
|
def test_policy_authority_and_actions_reject_coerced_types(self):
|
|
for field in ("confirm_lower", "confirm_higher", "fallback_previous", "display_only"):
|
|
result = step(saved(), valid(LOW), ACTIVE, replace(Policy(), **{field: 1}), now_ns=0)
|
|
self.assertTrue(result.errors)
|
|
for authority in (replace(ACTIVE, mode="longitudinal_only"), replace(ACTIVE, owner="system"), replace(ACTIVE, fully_disengaged=0)):
|
|
result = step(saved(), valid(LOW), authority, Policy(), now_ns=0)
|
|
self.assertTrue(result.errors)
|
|
pending = self.pending()
|
|
action = DriverAction("drive", 1, pending.state.pending.decision_id, ActionKind.ACCEPT)
|
|
for invalid in (replace(action, sequence_id=True), replace(action, sequence_id=-1), replace(action, decision_id=0),
|
|
replace(action, session_id=""), replace(action, kind="accept")):
|
|
result = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, action=invalid)
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertIsNone(result.action_receipt)
|
|
self.assertEqual(result.state.last_action_sequence, -1)
|
|
|
|
def test_invalid_state_never_returns_a_control_target(self):
|
|
malformed = [replace(saved(), session_id=""), replace(saved(), rejected=set()), replace(saved(), last_timestamp_ns=-1),
|
|
replace(saved(), next_decision_id=0), replace(saved(), timer_running=True), replace(saved(), accepted=LOW)]
|
|
for state in malformed:
|
|
with self.subTest(state=state):
|
|
result = step(state, valid(LOW), ACTIVE, Policy(False, False, True), now_ns=0)
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertIsNone(result.history_write)
|
|
|
|
def test_pending_candidate_cannot_also_be_rejected(self):
|
|
state = replace(self.pending().state, rejected=frozenset({LOW}))
|
|
result = step(state, valid(HIGH), ACTIVE, Policy(False, False, True), now_ns=NS)
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertIsNone(result.history_write)
|
|
self.assertIs(result.state, state)
|
|
self.assertEqual(result.state.accepted.candidate, HIGH)
|
|
|
|
def test_rejecting_a_returned_limit_preserves_its_earlier_accepted_history(self):
|
|
other = self.pending()
|
|
returned = self.pending(candidate=HIGH, state=other.state, now_ns=NS)
|
|
declined = self.act(returned.state, ActionKind.REJECT, now_ns=2 * NS)
|
|
held = step(declined.state, valid(HIGH), ACTIVE, Policy(), now_ns=3 * NS)
|
|
self.assertFalse(held.errors)
|
|
self.assertEqual(held.basis, 'rejected')
|
|
self.assertEqual(held.state.accepted, returned.state.accepted)
|
|
self.assertIn(HIGH, held.state.rejected)
|
|
self.assertIsNone(held.state.pending)
|
|
|
|
def test_changed_limits_do_not_accumulate_obsolete_rejections(self):
|
|
state = saved()
|
|
for index in range(MAX_REJECTIONS + 1):
|
|
candidate = replace(LOW, speed_mps=15. + index / 100)
|
|
pending = self.pending(candidate=candidate, state=state, now_ns=2 * index)
|
|
result = self.act(pending.state, ActionKind.REJECT, sequence=index, now_ns=2 * index + 1)
|
|
state = result.state
|
|
self.assertEqual(state.rejected, frozenset({candidate}))
|
|
self.assertFalse(state.rejection_capacity_exhausted)
|
|
|
|
def test_state_and_inputs_are_immutable_and_new_session_validates_history(self):
|
|
original = saved()
|
|
step(original, valid(LOW), ACTIVE, Policy(), now_ns=0)
|
|
self.assertIsNone(original.pending)
|
|
self.assertIsNone(original.last_timestamp_ns)
|
|
with self.assertRaises(FrozenInstanceError):
|
|
cast(Any, original).session_id = "changed"
|
|
for session, history in (("", None), ("drive", LOW), ("drive", AcceptedLimit(replace(HIGH, speed_mps=0), "old", 0))):
|
|
with self.assertRaises(ValueError):
|
|
new_session(session, cast(Any, history))
|
|
|
|
def test_identity_kinds_are_explicit_and_not_interchangeable(self):
|
|
identities = (geographic("road"), ObservationIdentity(IdentityKind.PRODUCER_EPISODE, value="episode"),
|
|
ObservationIdentity(IdentityKind.SESSION_VALUE, session_id="drive"))
|
|
for identity in identities:
|
|
candidate = replace(HIGH, observation_identity=identity)
|
|
result = step(saved(), valid(candidate), ACTIVE, Policy(False, False), now_ns=0)
|
|
self.assertEqual(result.errors, ())
|
|
self.assertEqual(result.display_candidate, candidate)
|
|
self.assertEqual(len({replace(HIGH, observation_identity=identity) for identity in identities}), 3)
|
|
|
|
def test_session_value_retains_rejection_across_dropout_and_source_switching(self):
|
|
identity = ObservationIdentity(IdentityKind.SESSION_VALUE, session_id="drive")
|
|
candidate = replace(LOW, observation_identity=identity)
|
|
pending = self.pending(candidate=candidate)
|
|
rejected = self.act(pending.state, ActionKind.REJECT)
|
|
absent = step(rejected.state, ABSENT, ACTIVE, Policy(), now_ns=2 * NS)
|
|
alternate = step(absent.state, valid(replace(candidate, source="map")), ACTIVE, Policy(), now_ns=3 * NS)
|
|
returned = step(alternate.state, valid(Candidate("dashboard", ObservationIdentity(IdentityKind.SESSION_VALUE, session_id="drive"),
|
|
LOW.speed_mps)), ACTIVE, Policy(), now_ns=4 * NS)
|
|
self.assertIn(candidate, returned.state.rejected)
|
|
self.assertEqual(returned.basis, "rejected")
|
|
self.assertEqual(returned.state.accepted.candidate, HIGH)
|
|
self.assertIsNone(alternate.state.pending)
|
|
self.assertIsNone(returned.state.pending)
|
|
|
|
def test_session_value_has_no_alternate_token_and_cannot_cross_sessions(self):
|
|
malformed = [ObservationIdentity(IdentityKind.SESSION_VALUE, value="invented", session_id="drive"),
|
|
ObservationIdentity(IdentityKind.SESSION_VALUE), ObservationIdentity(IdentityKind.SESSION_VALUE, session_id="other"),
|
|
ObservationIdentity(IdentityKind.GEOGRAPHIC, value="road", session_id="drive"),
|
|
ObservationIdentity(IdentityKind.PRODUCER_EPISODE, value=""),
|
|
ObservationIdentity(cast(Any, "session_value"), session_id="drive"), cast(Any, "road")]
|
|
for identity in malformed:
|
|
with self.subTest(identity=identity):
|
|
result = step(saved(), valid(replace(LOW, observation_identity=identity)), ACTIVE, Policy(), now_ns=0)
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertIsNone(result.history_write)
|
|
candidate = replace(HIGH, observation_identity=ObservationIdentity(IdentityKind.SESSION_VALUE, session_id="old"))
|
|
history = AcceptedLimit(candidate, "old", 0)
|
|
with self.assertRaises(ValueError):
|
|
new_session("drive", history)
|
|
with self.assertRaises(ValueError):
|
|
new_session("drive", replace(history, session_id="drive"))
|
|
# No implicit rekeying: independently qualified geographic history retains its original accepted session.
|
|
qualified = AcceptedLimit(HIGH, "old", 0)
|
|
self.assertIs(new_session("drive", qualified).accepted, qualified)
|
|
|
|
def test_explicit_adopt_clears_only_matching_rejection(self):
|
|
first_reject = self.act(self.pending().state, ActionKind.REJECT)
|
|
other = replace(LOW, source="map", speed_mps=19.0)
|
|
second_pending = self.pending(candidate=other, state=first_reject.state, now_ns=2 * NS)
|
|
second_reject = self.act(second_pending.state, ActionKind.REJECT, sequence=1, now_ns=3 * NS)
|
|
shown = step(second_reject.state, valid(LOW), ACTIVE, Policy(), now_ns=4 * NS)
|
|
request = self.adopt_request(shown.state, sequence=2)
|
|
adopted = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=5 * NS, adopt=request)
|
|
self.assertEqual(adopted.basis, "driver_adopt")
|
|
self.assertTrue(adopted.adoption_receipt.consumed)
|
|
self.assertIsNone(adopted.action_receipt)
|
|
self.assertEqual(adopted.state.rejected, frozenset())
|
|
self.assertEqual(adopted.history_write.candidate, LOW)
|
|
self.assertEqual(adopted.control_target_mps, LOW.speed_mps)
|
|
self.assertIsNone(adopted.state.pending)
|
|
self.assertTrue(adopted.adoption.clear_override)
|
|
self.assertEqual(adopted.adoption.session_id, "drive")
|
|
self.assertEqual(adopted.adoption.action_sequence_id, 2)
|
|
self.assertEqual(adopted.adoption.presentation_id, request.presentation_id)
|
|
self.assertEqual(adopted.adoption.accepted, adopted.state.accepted)
|
|
self.assertEqual(adopted.adoption.reconciliation_speed_mps, LOW.speed_mps)
|
|
still_rejected = step(adopted.state, valid(other), ACTIVE, Policy(), now_ns=6 * NS)
|
|
self.assertEqual(still_rejected.basis, "pending")
|
|
self.assertEqual(still_rejected.control_target_mps, LOW.speed_mps)
|
|
|
|
def test_adopt_already_accepted_candidate_still_emits_reconciliation(self):
|
|
shown = step(saved(), valid(HIGH), ACTIVE, Policy(), now_ns=0)
|
|
for authority in (ACTIVE, replace(ACTIVE, mode=Mode.COMBINED, lateral_active=True)):
|
|
result = step(shown.state, valid(HIGH), authority, Policy(), now_ns=NS, adopt=self.adopt_request(shown.state))
|
|
self.assertTrue(result.adoption_receipt.consumed)
|
|
self.assertIsNone(result.history_write)
|
|
self.assertEqual(result.state.accepted, shown.state.accepted)
|
|
self.assertTrue(result.adoption.clear_override)
|
|
self.assertEqual(result.adoption.reconciliation_speed_mps, HIGH.speed_mps)
|
|
|
|
def test_explicit_adopt_clears_same_speed_rejection_after_provider_change(self):
|
|
rejected = self.act(self.pending().state, ActionKind.REJECT)
|
|
refreshed = replace(LOW, source='map', observation_identity=geographic('refreshed'))
|
|
shown = step(rejected.state, valid(refreshed), ACTIVE, Policy(), now_ns=2 * NS)
|
|
adopted = step(shown.state, valid(refreshed), ACTIVE, Policy(), now_ns=3 * NS,
|
|
adopt=self.adopt_request(shown.state, sequence=1))
|
|
self.assertTrue(adopted.adoption_receipt.consumed)
|
|
self.assertFalse(adopted.state.rejected)
|
|
held = step(adopted.state, valid(refreshed), ACTIVE, Policy(), now_ns=4 * NS)
|
|
self.assertFalse(held.errors)
|
|
self.assertEqual(held.basis, 'accepted')
|
|
|
|
def test_adopt_precedes_confirmation_timeout(self):
|
|
pending = self.pending()
|
|
result = step(pending.state, valid(LOW), ACTIVE, Policy(), now_ns=CONFIRMATION_NS, adopt=self.adopt_request(pending.state))
|
|
self.assertEqual(result.basis, "driver_adopt")
|
|
self.assertEqual(result.state.accepted.candidate, LOW)
|
|
self.assertNotIn(LOW, result.state.rejected)
|
|
|
|
def test_adopt_requires_prior_presentation_even_when_new_id_is_guessed(self):
|
|
guessed = AdoptRequest("drive", 0, 1, LOW)
|
|
unseen = step(saved(), valid(LOW), ACTIVE, Policy(), now_ns=0, adopt=guessed)
|
|
self.assertEqual(unseen.state.presentation.presentation_id, guessed.presentation_id)
|
|
self.assertFalse(unseen.adoption_receipt.consumed)
|
|
self.assertEqual(unseen.adoption_receipt.status, "different_presentation")
|
|
self.assertIsNone(unseen.adoption)
|
|
self.assertIsNone(unseen.history_write)
|
|
replay = step(unseen.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, adopt=guessed)
|
|
self.assertEqual(replay.adoption_receipt.status, "already_seen")
|
|
self.assertIsNone(replay.adoption)
|
|
fresh = step(replay.state, valid(LOW), ACTIVE, Policy(), now_ns=2 * NS, adopt=replace(guessed, sequence_id=1))
|
|
self.assertTrue(fresh.adoption_receipt.consumed)
|
|
|
|
def test_adopt_context_expires_on_source_change_or_loss(self):
|
|
shown = self.pending()
|
|
request = self.adopt_request(shown.state)
|
|
for observation in (valid(replace(LOW, source="map")), ABSENT, Observation(ObservationKind.UNKNOWN), Observation(ObservationKind.STALE)):
|
|
with self.subTest(observation=observation):
|
|
changed = step(shown.state, observation, ACTIVE, Policy(fallback_previous=True), now_ns=NS, adopt=request)
|
|
self.assertFalse(changed.adoption_receipt.consumed)
|
|
self.assertIsNone(changed.adoption)
|
|
self.assertIsNone(changed.history_write)
|
|
returned = step(changed.state, valid(LOW), ACTIVE, Policy(), now_ns=2 * NS, adopt=replace(request, sequence_id=1))
|
|
self.assertFalse(returned.adoption_receipt.consumed)
|
|
self.assertIsNone(returned.adoption)
|
|
self.assertNotEqual(returned.state.presentation.presentation_id, request.presentation_id)
|
|
|
|
def test_adopt_exact_candidate_cannot_be_swapped_under_existing_context(self):
|
|
shown = self.pending()
|
|
for other in (replace(LOW, source="map"), replace(LOW, speed_mps=19.0),
|
|
replace(LOW, observation_identity=geographic("other-road"))):
|
|
request = replace(self.adopt_request(shown.state), candidate=other)
|
|
result = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, adopt=request)
|
|
self.assertFalse(result.adoption_receipt.consumed)
|
|
self.assertEqual(result.adoption_receipt.status, "different_presentation")
|
|
self.assertIsNone(result.adoption)
|
|
|
|
def test_adopt_is_recorded_for_any_authority_but_only_commands_with_system_long(self):
|
|
rejected = self.act(self.pending().state, ActionKind.REJECT)
|
|
request = self.adopt_request(rejected.state, sequence=1)
|
|
contexts = [PAUSED, DISENGAGED, Authority(Mode.OFF, LongitudinalOwner.NONE, False, False, False, False),
|
|
Authority(Mode.LATERAL_ONLY, LongitudinalOwner.NONE, True, False, False, False),
|
|
Authority(Mode.LATERAL_ONLY, LongitudinalOwner.STOCK, True, False, True, False),
|
|
Authority(Mode.COMBINED, LongitudinalOwner.STOCK, True, False, True, False)]
|
|
for authority in contexts:
|
|
with self.subTest(authority=authority):
|
|
result = step(rejected.state, valid(LOW), authority, Policy(), now_ns=2 * NS, adopt=request)
|
|
self.assertTrue(result.adoption_receipt.consumed)
|
|
self.assertEqual(result.adoption_receipt.status, "adopt")
|
|
self.assertNotIn(LOW, result.state.rejected)
|
|
self.assertEqual(result.state.accepted.candidate, LOW)
|
|
self.assertIsNone(result.adoption)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertEqual(result.state.last_action_sequence, 1)
|
|
display = step(rejected.state, valid(LOW), ACTIVE, Policy(display_only=True), now_ns=2 * NS, adopt=request)
|
|
self.assertFalse(display.adoption_receipt.consumed)
|
|
self.assertEqual(display.adoption_receipt.status, "display_only")
|
|
self.assertIn(LOW, display.state.rejected)
|
|
self.assertIsNone(display.adoption)
|
|
self.assertIsNone(display.history_write)
|
|
|
|
def test_adopt_foreign_session_does_not_advance_watermark(self):
|
|
shown = self.pending()
|
|
foreign = replace(self.adopt_request(shown.state, sequence=999), session_id="other")
|
|
result = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, adopt=foreign)
|
|
self.assertEqual(result.adoption_receipt.status, "foreign_session")
|
|
self.assertEqual(result.state.last_action_sequence, -1)
|
|
self.assertIsNone(result.adoption)
|
|
accepted = step(result.state, valid(LOW), ACTIVE, Policy(), now_ns=2 * NS, adopt=self.adopt_request(result.state))
|
|
self.assertTrue(accepted.adoption_receipt.consumed)
|
|
|
|
def test_adoption_and_confirmation_share_seen_sequence_watermark(self):
|
|
shown = self.pending()
|
|
request = replace(self.adopt_request(shown.state, sequence=5), presentation_id=999)
|
|
paused = step(shown.state, valid(LOW), PAUSED, Policy(), now_ns=NS, adopt=request)
|
|
repeated_action = DriverAction("drive", 5, paused.state.pending.decision_id, ActionKind.ACCEPT)
|
|
repeated = step(paused.state, valid(LOW), ACTIVE, Policy(), now_ns=2 * NS, action=repeated_action)
|
|
self.assertEqual(repeated.action_receipt.status, "already_seen")
|
|
self.assertFalse(repeated.action_receipt.consumed)
|
|
fresh = step(repeated.state, valid(LOW), ACTIVE, Policy(), now_ns=3 * NS, action=replace(repeated_action, sequence_id=6))
|
|
self.assertTrue(fresh.action_receipt.consumed)
|
|
old_adopt = step(fresh.state, valid(LOW), ACTIVE, Policy(), now_ns=4 * NS, adopt=request)
|
|
self.assertFalse(old_adopt.adoption_receipt.consumed)
|
|
self.assertIsNone(old_adopt.adoption)
|
|
|
|
def test_ambiguous_action_and_adopt_fail_closed_and_watermark_both(self):
|
|
shown = self.pending()
|
|
for ordinary_sequence, adopt_sequence in ((3, 7), (7, 3), (7, 7)):
|
|
with self.subTest(ordinary=ordinary_sequence, adopt=adopt_sequence):
|
|
action = DriverAction("drive", ordinary_sequence, shown.state.pending.decision_id, ActionKind.ACCEPT)
|
|
adopt = self.adopt_request(shown.state, sequence=adopt_sequence)
|
|
result = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=NS, action=action, adopt=adopt)
|
|
self.assertIn("ambiguous action and adopt request", result.errors)
|
|
self.assertFalse(result.action_receipt.consumed)
|
|
self.assertFalse(result.adoption_receipt.consumed)
|
|
self.assertEqual(result.state.last_action_sequence, 7)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertIsNone(result.adoption)
|
|
self.assertIsNone(result.history_write)
|
|
self.assertEqual(result.state.accepted.candidate, HIGH)
|
|
self.assertIsNone(result.state.presentation)
|
|
|
|
def test_invalid_adopt_or_clock_never_produces_an_effect(self):
|
|
shown = self.pending(now_ns=10 * NS)
|
|
request = self.adopt_request(shown.state, sequence=1)
|
|
for malformed in (replace(request, sequence_id=True), replace(request, presentation_id=0),
|
|
replace(request, session_id=""), replace(request, candidate=replace(LOW, speed_mps=0))):
|
|
result = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=11 * NS, adopt=malformed)
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.adoption_receipt)
|
|
self.assertIsNone(result.adoption)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertEqual(result.state.last_action_sequence, -1)
|
|
reversed_clock = step(shown.state, valid(LOW), ACTIVE, Policy(), now_ns=9 * NS, adopt=request)
|
|
self.assertTrue(reversed_clock.errors)
|
|
self.assertFalse(reversed_clock.adoption_receipt.consumed)
|
|
self.assertEqual(reversed_clock.state.last_action_sequence, 1)
|
|
self.assertIsNone(reversed_clock.adoption)
|
|
|
|
def test_invalid_presentation_state_is_inert(self):
|
|
shown = self.pending()
|
|
for state in (replace(shown.state, presentation=None), replace(shown.state, next_presentation_id=1),
|
|
replace(shown.state, presentation=replace(shown.state.presentation, candidate=HIGH))):
|
|
result = step(state, valid(LOW), ACTIVE, Policy(), now_ns=NS, adopt=self.adopt_request(shown.state))
|
|
self.assertTrue(result.errors)
|
|
self.assertIsNone(result.control_target_mps)
|
|
self.assertIsNone(result.adoption)
|
|
self.assertIsNone(result.history_write)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|