from __future__ import annotations import asyncio from collections.abc import Mapping from time import time_ns from typing import Any import pytest from cmvr_edge_ai.contracts import ApprovedRobotCommand, RobotCommand from cmvr_edge_ai.core import Envelope from cmvr_edge_ai.plugins.builtin import CommandSafetyGate def _envelope( action: str, *, valid_until_ns: int, sequence: int = 1, arguments: Mapping[str, Any] | None = None, ) -> Envelope[RobotCommand]: return Envelope( RobotCommand( device_id="src1100", action=action, valid_until_ns=valid_until_ns, sequence=sequence, arguments={} if arguments is None else arguments, ), schema_name="RobotCommand", ) def test_command_safety_gate_is_fail_closed() -> None: gate = CommandSafetyGate( "gate", { "allowed_actions": ["emergency_stop"], "allowed_devices": ["src1100"], }, ) future = time_ns() + 5_000_000_000 command_envelope = _envelope("emergency_stop", valid_until_ns=future) accepted = asyncio.run(gate.process(command_envelope)) expired = asyncio.run( gate.process(_envelope("emergency_stop", valid_until_ns=time_ns() - 1)) ) unauthorized = asyncio.run( gate.process(_envelope("set_velocity", valid_until_ns=future)) ) assert accepted is not None assert accepted.port == "commands" assert accepted.envelope.schema == "ApprovedRobotCommand/v1" assert isinstance(accepted.envelope.payload, ApprovedRobotCommand) assert accepted.envelope.payload.command is command_envelope.payload assert accepted.envelope.payload.approved_by == "gate" assert accepted.envelope.trace_id == command_envelope.trace_id assert expired is None assert unauthorized is None assert asyncio.run(gate.health()).detail == "rejected_commands=2" def test_velocity_gate_requires_limits_and_rejects_replays() -> None: with pytest.raises(ValueError, match="requires argument_limits"): CommandSafetyGate("gate", {"allowed_actions": ["set_velocity"]}) gate = CommandSafetyGate( "gate", { "allowed_actions": ["set_velocity"], "allowed_devices": ["src1100"], "argument_limits": { "set_velocity": { "vx": {"min": -1.0, "max": 1.0}, "vy": {"min": -1.0, "max": 1.0}, "wz": {"min": -2.0, "max": 2.0}, } }, }, ) future = time_ns() + 5_000_000_000 accepted = _envelope( "set_velocity", valid_until_ns=future, sequence=10, arguments={"vx": 0.5, "vy": 0.0, "wz": 0.25}, ) approved = asyncio.run(gate.process(accepted)) assert approved is not None assert isinstance(approved.envelope.payload, ApprovedRobotCommand) assert asyncio.run(gate.process(accepted)) is None outside_limits = asyncio.run( gate.process( _envelope( "set_velocity", valid_until_ns=future, sequence=11, arguments={"vx": 1.5, "vy": 0.0, "wz": 0.0}, ) ) ) assert outside_limits is None assert asyncio.run(gate.health()).detail == "rejected_commands=2"