merge: integrate final rover control with operator simulation workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:48:42 +03:00
88 changed files with 6963 additions and 97 deletions
+42
View File
@@ -0,0 +1,42 @@
import json
import threading
from types import SimpleNamespace
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from k1link.fleet import board_layout
from k1link.fleet.trust import PairingError
from k1link.web.fleet_api import router, local_operator
def test_layout_api_persists_empty_and_isolates_vehicles(tmp_path):
app = FastAPI()
app.include_router(router)
def find(identifier):
if identifier not in ("rover-a", "rover-b"):
raise PairingError("Unknown")
fleet = SimpleNamespace(root=tmp_path, lock=threading.RLock(), find=find)
app.dependency_overrides[local_operator] = lambda: fleet
client = TestClient(app)
for section in board_layout.SECTIONS:
response = client.patch('/api/v1/fleet/rover-a/board-layout', json={"section": section, "open": False})
assert response.status_code == 200
assert client.get('/api/v1/fleet/rover-a/board-layout').json()['open_sections'] == []
assert client.get('/api/v1/fleet/rover-b/board-layout').json()['open_sections'] == list(board_layout.SECTIONS)
assert board_layout.read(tmp_path, 'rover-a')['revision'] == 3
assert board_layout.path(tmp_path, 'rover-a').stat().st_mode & 0o777 == 0o600
for body in ({"section":"motor","open":True},{"section":"computer","open":"true"},{"section":"computer","open":True,"extra":0}):
assert client.patch('/api/v1/fleet/rover-a/board-layout', json=body).status_code == 422
assert client.get('/api/v1/fleet/missing/board-layout').status_code == 404
def test_corrupt_layout_is_preserved(tmp_path):
board_layout.update(tmp_path, "a", "computer", False)
target = board_layout.path(tmp_path, "a")
raw = json.dumps({"schema":board_layout.SCHEMA,"revision":3,"open_sections":["motor"]})
target.write_text(raw)
with pytest.raises(ValueError):
board_layout.update(tmp_path, "a", "devices", False)
assert target.read_text() == raw
+19
View File
@@ -84,6 +84,25 @@ def cert(row):
serialization.Encoding.DER
)
def test_rover_stream_requires_current_pairing_and_binding(setup):
fleet, _, _, _ = setup
public, _ = create(setup)
fleet.advance(public["id"])
row = fleet.find(public["id"])
body = {"schema": SCHEMA, "node_id": row["node_id"],
"binding_id": row["binding"]["binding_id"], "relay_id": "a"*32}
certificate = cert(row)
status, result = fleet.receive(certificate, "/v1/node/rover-stream", body)
assert status == 200
assert result['command'] is None
assert result['control_clock']['instance'] == fleet.rover_control.clock_id
status, _ = fleet.receive(certificate, "/v1/node/rover-stream", {**body, "binding_id": "other"})
assert status == 410
row['enrollment'] = 'revoked'
fleet.save(row)
status, _ = fleet.receive(certificate, "/v1/node/rover-stream", body)
assert status == 410
def test_migrated_heartbeat_preserves_identity_and_old_reply_cannot_reverse(setup):
fleet, _, _, _ = setup
+115
View File
@@ -0,0 +1,115 @@
import pytest
from k1link.fleet.rover_control import RoverControl
@pytest.fixture
def hub():
now=[10.]
h=RoverControl(lambda:now[0])
h.exchange('node',{'relay_id':'a'*32,'rover':{'supported':True,'instance':'driver','state':'observing'}})
return h,now
def arm(h):
return h.arm('node',{'standstill_confirmed':True,'current_a':30,'max_erpm':2000})['session_id']
def cmd(id,seq=1,**kw):
return {'session_id':id,'sequence':seq,'left':1,'right':1,'stop':False,**kw}
def exchange(h,**kw):
return h.exchange('node',{'relay_id':'a'*32,'rover':{'supported':True,'instance':'driver',**kw}})
def test_expired_browser_cannot_be_revived_by_late_packet(hub):
h,now=hub;id=arm(h);h.command('node',cmd(id));now[0]+=.401
assert exchange(h)['command'] is None
with pytest.raises(ValueError):h.command('node',cmd(id,2))
def test_sequence_stop_and_single_owner(hub):
h,_=hub;id=arm(h)
with pytest.raises(ValueError):arm(h)
h.command('node',cmd(id,2))
with pytest.raises(ValueError):h.command('node',cmd(id,1))
assert exchange(h)['command']['sequence']==2
h.command('node',cmd(id,3,stop=True))
with pytest.raises(ValueError):h.command('node',cmd(id,4))
assert exchange(h)['command'] is None
@pytest.mark.parametrize('snapshot',[{'state':'receiver'},{'state':'fault'},{'instance':'new-driver'}])
def test_takeover_fault_or_driver_restart_revokes(hub,snapshot):
h,_=hub;id=arm(h)
assert exchange(h,session_id=id,**snapshot)['command'] is None
def test_relay_restart_and_cross_board_do_not_inherit_authority(hub):
h,_=hub;id=arm(h)
with pytest.raises(ValueError):h.command('other',cmd(id))
assert h.exchange('node',{'relay_id':'b'*32,'rover':{}})['command'] is None
@pytest.mark.parametrize('value',[True,float('nan'),float('inf'),1.01,-1.01,'1'])
def test_invalid_demand_cannot_refresh(hub,value):
h,_=hub;id=arm(h)
with pytest.raises(ValueError):h.command('node',cmd(id,left=value))
def test_stale_telemetry_is_unavailable_and_arm_requires_current_board(hub):
h,now=hub;now[0]+=1.1
assert h.view('node')['snapshot']=={}
with pytest.raises(ValueError):arm(h)
def test_stream_keeps_absolute_deadline_and_never_renews_held_frame(hub):
h, now = hub
identifier = arm(h)
h.command('node', cmd(identifier, 1))
first = h.stream('node', 'a'*32)
now[0] += .2
repeated = h.stream('node', 'a'*32)
assert repeated['command'] == first['command']
assert repeated['control_clock']['monotonic_ms'] > first['control_clock']['monotonic_ms']
assert first['command']['expires_mono_ms'] == pytest.approx(10400)
now[0] += .201
assert h.stream('node', 'a'*32)['command'] is None
with pytest.raises(ValueError): h.command('node', cmd(identifier, 2))
def test_stream_delivers_new_intent_without_waiting_for_telemetry(hub):
h, now = hub
identifier = arm(h)
h.command('node', cmd(identifier, 1))
h.command('node', cmd(identifier, 2, left=-1))
assert h.stream('node', 'a'*32)['command']['left'] == -1
assert h.stream('node', 'b'*32)['command'] is None
h.command('node', cmd(identifier, 3, stop=True))
assert h.stream('node', 'a'*32)['command'] is None
def test_new_core_has_distinct_clock_epoch(hub):
h, _ = hub
assert h.stream('node', 'a'*32)['control_clock']['instance'] != RoverControl().clock_id
def test_stream_retires_control_if_return_telemetry_is_lost(hub):
h, now = hub
identifier = arm(h)
for seq in range(1, 13):
h.command('node', cmd(identifier, seq))
now[0] += .09
assert h.stream('node', 'a'*32)['command'] is None
with pytest.raises(ValueError): h.command('node', cmd(identifier, 13))
def test_intent_wait_wakes_on_update_and_does_not_miss_prior_update(hub):
import threading
from unittest.mock import patch
h, _ = hub
identifier = arm(h)
previous = h.stream('node', 'a'*32)['command']
entered, done = threading.Event(), threading.Event()
def waiter():
entered.set()
h.wait_for_intent('node', 'a'*32, previous, timeout=2)
done.set()
worker = threading.Thread(target=waiter)
worker.start()
assert entered.wait(1)
h.command('node', cmd(identifier, 1))
assert done.wait(.5), 'new intent waited for the periodic keepalive'
worker.join(2)
# A command arriving after socket write but before wait must not be lost.
with patch.object(h.changed, 'wait', side_effect=AssertionError('missed prior update')):
h.wait_for_intent('node', 'a'*32, previous, timeout=2)
latest = h.stream('node', 'a'*32)['command']
h.command('node', cmd(identifier, 2, stop=True))
with patch.object(h.changed, 'wait', side_effect=AssertionError('missed stop')):
h.wait_for_intent('node', 'a'*32, latest, timeout=2)
@@ -0,0 +1,52 @@
"""Exercise HTTP framing and per-frame binding checks without a real board."""
import http.client
import json
import threading
import time
from http.server import ThreadingHTTPServer
from types import SimpleNamespace
from k1link.fleet.transport import NodeChannelHandler
def test_stream_rechecks_binding_and_closes_when_revoked():
calls = []
def receive(certificate, path, body, *, address):
calls.append((certificate, path, body, address))
if len(calls) == 3:
return 410, {"error": "Binding revoked"}
return 200, {"watch": True, "command": {"sequence": len(calls)}}
class Handler(NodeChannelHandler):
def setup(self):
super().setup()
# Only TLS certificate extraction is substituted. HTTP reads,
# writes, streaming, loop and reauthentication use production code.
self.connection = SimpleNamespace(getpeercert=lambda **_: b"synthetic-cert")
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
server.address = "127.0.0.1"
server.registry = SimpleNamespace(receive=receive, stop=threading.Event(),
rover_control=SimpleNamespace(wait_for_intent=lambda *_: time.sleep(.01)))
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
client = http.client.HTTPConnection(*server.server_address, timeout=2)
try:
body = {"relay_id": "a" * 32, "node_id": "synthetic-node"}
client.request("POST", "/v1/node/rover-stream", json.dumps(body),
{"Content-Type": "application/json"})
response = client.getresponse()
assert response.status == 200
assert response.getheader("Content-Type") == "application/x-ndjson"
assert response.getheader("Content-Length") is None
frames = [json.loads(line) for line in response.read().splitlines()]
assert [frame["command"]["sequence"] for frame in frames] == [1, 2]
assert len(calls) == 3
assert all(call == (b"synthetic-cert", "/v1/node/rover-stream", body, "127.0.0.1") for call in calls)
finally:
client.close()
server.shutdown()
server.server_close()
worker.join(timeout=2)
assert not worker.is_alive()
+69
View File
@@ -0,0 +1,69 @@
"""Periodic planning reports must not serialize/compress on the ASGI loop."""
import threading
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from k1link.web import planning_live_api
from k1link.web.response_compression import ResponseCompressionMiddleware
@pytest.mark.parametrize("encoding", ["gzip", "identity"])
def test_active_report_keeps_content_and_encodes_off_loop(monkeypatch, encoding):
document = {"state": "completed", "evidence": [{"label": "Проверка", "x": 1.25}] * 50}
calls = []
original_json = planning_live_api.JSONResponse
original_compress = planning_live_api.gzip.compress
class RecordedJSON(original_json):
def render(self, content):
calls.append(("json", threading.get_ident()))
return super().render(content)
def compress(*args, **kwargs):
calls.append(("gzip", threading.get_ident()))
return original_compress(*args, **kwargs)
monkeypatch.setattr(planning_live_api, "JSONResponse", RecordedJSON)
monkeypatch.setattr(planning_live_api.gzip, "compress", compress)
app = FastAPI()
app.include_router(planning_live_api.build_planning_live_router(SimpleNamespace(get=lambda: document)))
app.add_middleware(ResponseCompressionMiddleware)
@app.get("/loop-thread")
async def loop_thread():
return threading.get_ident()
with TestClient(app) as client:
loop_id = client.get("/loop-thread").json()
result = client.get("/api/v1/mission-planner/live-tests/active", headers={"Accept-Encoding": encoding})
assert result.status_code == 200
assert result.json() == document # Includes HTTP decompression; no double gzip.
assert result.headers["cache-control"] == "no-store"
assert result.headers["content-type"] == "application/json"
assert [name for name, _ in calls] == (["json", "gzip"] if encoding == "gzip" else ["json"])
assert all(thread != loop_id for _, thread in calls)
if encoding == "gzip":
assert result.headers["content-encoding"] == "gzip"
assert result.headers["vary"] == "Accept-Encoding"
else:
assert "content-encoding" not in result.headers
def test_active_report_retains_empty_and_failure_contract():
service = SimpleNamespace(get=lambda: None)
app = FastAPI()
app.include_router(planning_live_api.build_planning_live_router(service))
with TestClient(app) as client:
result = client.get("/api/v1/mission-planner/live-tests/active")
assert result.status_code == 200 and result.json() is None
def fail():
raise RuntimeError("synthetic unavailable report")
service.get = fail
result = client.get("/api/v1/mission-planner/live-tests/active")
assert result.status_code == 409
assert result.json() == {"detail": "synthetic unavailable report"}