feat(k1): add offline control execution gate
This commit is contained in:
@@ -6,6 +6,7 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationBootstrapError,
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
ShadowApplicationBootstrapOrchestrator,
|
||||
build_shadow_bootstrap,
|
||||
decode_and_bind_device_info_response,
|
||||
)
|
||||
@@ -75,6 +76,13 @@ def _device_info_response(*, session_id: str = ":DeviceInfoRequest") -> bytes:
|
||||
return _bytes(1, header) + _bytes(2, device_info) + _bytes(15, error)
|
||||
|
||||
|
||||
def _generic_response(session_id: str) -> bytes:
|
||||
header = _text(4, VENDOR_DEVICE_ID) + _text(5, session_id) + _text(6, APPLICATION_KEY)
|
||||
error = _uint(1, OPENAPI_SUCCESS) + _text(2, "success")
|
||||
repeated_vendor_body = _text(3, "opaque-a") + _text(3, "opaque-b")
|
||||
return _bytes(1, header) + repeated_vendor_body + _bytes(15, error)
|
||||
|
||||
|
||||
def test_exact_clean_cycle_pre_start_order_and_wire_lengths() -> None:
|
||||
plan = build_shadow_bootstrap(
|
||||
_authority(),
|
||||
@@ -165,3 +173,89 @@ def test_device_info_response_correlation_and_profile_attestation_fail_closed()
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
|
||||
def test_response_barrier_orchestrator_emits_each_retained_batch_once() -> None:
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
_authority(),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
identity = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in identity] == [1]
|
||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||
orchestrator.next_batch()
|
||||
orchestrator.accept_response(identity[0].response_topic, _device_info_response())
|
||||
|
||||
initial_reads = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in initial_reads] == [2, 3]
|
||||
assert [request.response_required for request in initial_reads] == [False, True]
|
||||
orchestrator.accept_response(
|
||||
initial_reads[1].response_topic,
|
||||
_generic_response(initial_reads[1].session_id),
|
||||
)
|
||||
|
||||
preparation = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in preparation] == [4, 5, 6]
|
||||
device_info = preparation[1]
|
||||
orchestrator.accept_response(
|
||||
device_info.response_topic,
|
||||
_device_info_response(session_id=device_info.session_id),
|
||||
)
|
||||
with pytest.raises(ApplicationBootstrapError, match="already issued"):
|
||||
orchestrator.next_batch()
|
||||
for request in (preparation[2], preparation[0]):
|
||||
orchestrator.accept_response(
|
||||
request.response_topic,
|
||||
_generic_response(request.session_id),
|
||||
)
|
||||
|
||||
ntrip = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in ntrip] == [7]
|
||||
orchestrator.accept_response(
|
||||
ntrip[0].response_topic,
|
||||
_generic_response(ntrip[0].session_id),
|
||||
)
|
||||
|
||||
final_reads = orchestrator.next_batch()
|
||||
assert [request.ordinal for request in final_reads] == [8, 9, 10]
|
||||
for request in final_reads:
|
||||
payload = (
|
||||
_device_info_response(session_id=request.session_id)
|
||||
if request.message_type == "DeviceInfoRequest"
|
||||
else _generic_response(request.session_id)
|
||||
)
|
||||
orchestrator.accept_response(request.response_topic, payload)
|
||||
|
||||
snapshot = orchestrator.snapshot()
|
||||
assert snapshot.bootstrap_complete
|
||||
assert snapshot.live_binding_observed
|
||||
assert snapshot.pending_response_topics == ()
|
||||
assert snapshot.executable is False
|
||||
assert snapshot.vendor_writes_enabled is False
|
||||
with pytest.raises(ApplicationBootstrapError, match="already complete"):
|
||||
orchestrator.next_batch()
|
||||
|
||||
|
||||
def test_orchestrator_rejects_unexpected_response_without_advancing() -> None:
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
_authority(),
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
orchestrator.next_batch()
|
||||
|
||||
with pytest.raises(ApplicationBootstrapError, match="not required"):
|
||||
orchestrator.accept_response(
|
||||
"lixel/application/response/get_rtk_advance",
|
||||
_generic_response(":GetRtkAdvanceRequest"),
|
||||
)
|
||||
|
||||
assert orchestrator.snapshot().current_batch == 1
|
||||
assert orchestrator.snapshot().batch_issued
|
||||
|
||||
|
||||
def test_application_authority_requires_exact_captured_profile_length() -> None:
|
||||
with pytest.raises(ApplicationBootstrapError, match="36-byte"):
|
||||
ApplicationControlAuthority(openapi_key="too-short")
|
||||
|
||||
Reference in New Issue
Block a user