226 lines
8.6 KiB
Python
226 lines
8.6 KiB
Python
|
|
import asyncio
|
||
|
|
from contextlib import asynccontextmanager
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from lingkong_motor_service.api import create_app
|
||
|
|
from lingkong_motor_service.config import Settings
|
||
|
|
from lingkong_motor_service.controller import MotorCommunicationError
|
||
|
|
from lingkong_motor_service.protocol import CONTROL_FRAME_ID, MIXED_COMMAND_FRAME_ID
|
||
|
|
|
||
|
|
from .fakes import FakeTransport
|
||
|
|
|
||
|
|
|
||
|
|
@asynccontextmanager
|
||
|
|
async def client_for(app):
|
||
|
|
async with app.router.lifespan_context(app):
|
||
|
|
transport = httpx.ASGITransport(app=app)
|
||
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||
|
|
yield client
|
||
|
|
|
||
|
|
|
||
|
|
def test_hardware_gate_is_closed_by_default() -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
app = create_app(Settings())
|
||
|
|
|
||
|
|
async with client_for(app) as client:
|
||
|
|
assert (await client.get("/healthz")).json() == {
|
||
|
|
"status": "ok",
|
||
|
|
"hardware_enabled": False,
|
||
|
|
"can_connected": False,
|
||
|
|
}
|
||
|
|
assert (await client.get("/readyz")).status_code == 503
|
||
|
|
response = await client.post("/v1/motor/open")
|
||
|
|
assert (await client.post("/v1/motor/enable")).status_code == 404
|
||
|
|
assert (await client.post("/v1/motor/disable")).status_code == 404
|
||
|
|
openapi = (await client.get("/openapi.json")).json()
|
||
|
|
assert "/v1/motor/enable" not in openapi["paths"]
|
||
|
|
assert "/v1/motor/disable" not in openapi["paths"]
|
||
|
|
|
||
|
|
assert response.status_code == 409
|
||
|
|
assert "MOTOR_HARDWARE_ENABLED=true" in response.json()["detail"]
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
def test_hardware_service_auto_enables_and_shutdown_stops_then_disables() -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
transport = FakeTransport()
|
||
|
|
app = create_app(
|
||
|
|
Settings(hardware_enabled=True),
|
||
|
|
transport_factory=lambda settings: transport,
|
||
|
|
)
|
||
|
|
|
||
|
|
async with client_for(app) as client:
|
||
|
|
ready = await client.get("/readyz")
|
||
|
|
motor_status = await client.get("/v1/motor/status")
|
||
|
|
|
||
|
|
assert ready.status_code == 200
|
||
|
|
assert ready.json() == {"ready": True}
|
||
|
|
assert motor_status.json()["state"] == "ready"
|
||
|
|
with transport.lock:
|
||
|
|
startup_frames = [
|
||
|
|
(frame.arbitration_id, frame.data.hex()) for frame in transport.frames
|
||
|
|
]
|
||
|
|
assert startup_frames == [
|
||
|
|
(CONTROL_FRAME_ID, "0000000000000000"),
|
||
|
|
(MIXED_COMMAND_FRAME_ID, "8800880000000000"),
|
||
|
|
]
|
||
|
|
|
||
|
|
with transport.lock:
|
||
|
|
shutdown_frames = [
|
||
|
|
(frame.arbitration_id, frame.data.hex()) for frame in transport.frames[-5:]
|
||
|
|
]
|
||
|
|
assert shutdown_frames == [
|
||
|
|
(CONTROL_FRAME_ID, "0000000000000000"),
|
||
|
|
(MIXED_COMMAND_FRAME_ID, "8100810000000000"),
|
||
|
|
(CONTROL_FRAME_ID, "0000000000000000"),
|
||
|
|
(MIXED_COMMAND_FRAME_ID, "8100810000000000"),
|
||
|
|
(MIXED_COMMAND_FRAME_ID, "8000800000000000"),
|
||
|
|
]
|
||
|
|
assert transport.is_open is False
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
def test_http_open_and_close_can_switch_directions() -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
transport = FakeTransport()
|
||
|
|
app = create_app(
|
||
|
|
Settings(hardware_enabled=True),
|
||
|
|
transport_factory=lambda settings: transport,
|
||
|
|
)
|
||
|
|
|
||
|
|
async with client_for(app) as client:
|
||
|
|
opening = await client.post("/v1/motor/open")
|
||
|
|
await asyncio.sleep(0.06)
|
||
|
|
still_opening = await client.get("/v1/motor/status")
|
||
|
|
closing = await client.post("/v1/motor/close")
|
||
|
|
reopened = await client.post("/v1/motor/open")
|
||
|
|
stopped = await client.post("/v1/motor/stop")
|
||
|
|
|
||
|
|
assert opening.status_code == 202
|
||
|
|
assert opening.json()["action"] == "open"
|
||
|
|
assert opening.json()["state"] == "opening"
|
||
|
|
assert opening.json()["control_raw"] == 400
|
||
|
|
assert opening.json()["control_raw_by_motor"] == {"1": 400, "2": -400}
|
||
|
|
assert opening.json()["control_mode"] == "open_loop"
|
||
|
|
assert opening.json()["streaming"] is True
|
||
|
|
assert opening.json()["broadcast_frequency_hz"] == 10
|
||
|
|
assert still_opening.json()["state"] == "opening"
|
||
|
|
assert still_opening.json()["streaming"] is True
|
||
|
|
assert closing.status_code == 202
|
||
|
|
assert closing.json()["action"] == "close"
|
||
|
|
assert closing.json()["state"] == "closing"
|
||
|
|
assert closing.json()["control_raw"] == -400
|
||
|
|
assert closing.json()["control_raw_by_motor"] == {"1": -400, "2": 400}
|
||
|
|
assert "not verified" in closing.json()["transmission"]
|
||
|
|
assert reopened.status_code == 202
|
||
|
|
assert reopened.json()["action"] == "open"
|
||
|
|
assert reopened.json()["state"] == "opening"
|
||
|
|
assert reopened.json()["control_raw"] == 400
|
||
|
|
assert reopened.json()["control_raw_by_motor"] == {"1": 400, "2": -400}
|
||
|
|
assert stopped.status_code == 202
|
||
|
|
assert stopped.json()["state"] == "stopped"
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
def test_automatic_enable_and_motion_do_not_require_feedback_replies() -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
transport = FakeTransport()
|
||
|
|
app = create_app(
|
||
|
|
Settings(hardware_enabled=True),
|
||
|
|
transport_factory=lambda settings: transport,
|
||
|
|
)
|
||
|
|
|
||
|
|
async with client_for(app) as client:
|
||
|
|
opening = await client.post("/v1/motor/open")
|
||
|
|
stopped = await client.post("/v1/motor/stop")
|
||
|
|
|
||
|
|
assert opening.status_code == 202
|
||
|
|
assert opening.json()["streaming"] is True
|
||
|
|
assert stopped.status_code == 202
|
||
|
|
assert stopped.json()["streaming"] is False
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
def test_one_http_stop_sends_two_explicit_stop_rounds() -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
transport = FakeTransport()
|
||
|
|
app = create_app(
|
||
|
|
Settings(hardware_enabled=True),
|
||
|
|
transport_factory=lambda settings: transport,
|
||
|
|
)
|
||
|
|
|
||
|
|
async with client_for(app) as client:
|
||
|
|
start = len(transport.frames)
|
||
|
|
|
||
|
|
stopped = await client.post("/v1/motor/stop")
|
||
|
|
|
||
|
|
with transport.lock:
|
||
|
|
frames = [
|
||
|
|
(frame.arbitration_id, frame.data.hex()) for frame in transport.frames[start:]
|
||
|
|
]
|
||
|
|
timeouts_s = transport.tx_queue_timeouts_s[start:]
|
||
|
|
assert stopped.status_code == 202
|
||
|
|
assert stopped.json()["state"] == "stopped"
|
||
|
|
assert frames == [
|
||
|
|
(CONTROL_FRAME_ID, "0000000000000000"),
|
||
|
|
(MIXED_COMMAND_FRAME_ID, "8100810000000000"),
|
||
|
|
(CONTROL_FRAME_ID, "0000000000000000"),
|
||
|
|
(MIXED_COMMAND_FRAME_ID, "8100810000000000"),
|
||
|
|
]
|
||
|
|
assert timeouts_s == [None, 0.1, None, 0.1]
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
def test_auto_enable_failure_aborts_startup_and_closes_transport() -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
transport = FakeTransport()
|
||
|
|
transport.fail_next = True
|
||
|
|
app = create_app(
|
||
|
|
Settings(hardware_enabled=True),
|
||
|
|
transport_factory=lambda settings: transport,
|
||
|
|
)
|
||
|
|
|
||
|
|
with pytest.raises(MotorCommunicationError, match="injected CAN failure"):
|
||
|
|
async with client_for(app):
|
||
|
|
raise AssertionError("startup failure must prevent HTTP service readiness")
|
||
|
|
|
||
|
|
with transport.lock:
|
||
|
|
cleanup_frames = [
|
||
|
|
(frame.arbitration_id, frame.data.hex()) for frame in transport.frames
|
||
|
|
]
|
||
|
|
cleanup_timeouts = list(transport.tx_queue_timeouts_s)
|
||
|
|
assert cleanup_frames == [
|
||
|
|
(CONTROL_FRAME_ID, "0000000000000000"),
|
||
|
|
(MIXED_COMMAND_FRAME_ID, "8100810000000000"),
|
||
|
|
(CONTROL_FRAME_ID, "0000000000000000"),
|
||
|
|
(MIXED_COMMAND_FRAME_ID, "8100810000000000"),
|
||
|
|
(MIXED_COMMAND_FRAME_ID, "8000800000000000"),
|
||
|
|
]
|
||
|
|
assert cleanup_timeouts == [None, 0.1, None, 0.1, 0.1]
|
||
|
|
assert transport.is_open is False
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|
||
|
|
|
||
|
|
|
||
|
|
def test_api_key_is_required_when_configured() -> None:
|
||
|
|
async def scenario() -> None:
|
||
|
|
transport = FakeTransport()
|
||
|
|
app = create_app(
|
||
|
|
Settings(hardware_enabled=True, api_token="test-secret"),
|
||
|
|
transport_factory=lambda settings: transport,
|
||
|
|
)
|
||
|
|
|
||
|
|
async with client_for(app) as client:
|
||
|
|
assert (await client.get("/v1/motor/status")).status_code == 401
|
||
|
|
response = await client.get("/v1/motor/status", headers={"X-API-Key": "test-secret"})
|
||
|
|
assert response.status_code == 200
|
||
|
|
|
||
|
|
asyncio.run(scenario())
|