"""The MCP resources, driven through a real client. The point of the split under test here is that the command catalogue needs no aircraft while everything else does, and that the ones that do degrade into an explanation rather than an exception. """ import json import pytest from fastmcp import Client from test_ulog import entry from mcbebop.config import Settings from mcbebop.protocol import xml_index from mcbebop.server import build_server from mcbebop.tools import _common, logs def body(result) -> dict: return json.loads(result[0].text) @pytest.fixture async def client(): async with Client(build_server()) as c: yield c @pytest.fixture async def connected(client): await client.call_tool("connect", {"target": "sim"}) yield client await client.call_tool("disconnect", {}) # --- what is registered ------------------------------------------------------ async def test_the_static_resources_are_registered(client): uris = {str(r.uri) for r in await client.list_resources()} assert uris == {"bebop://commands", "bebop://state"} async def test_the_templated_resources_are_registered(client): uris = {t.uri_template for t in await client.list_resource_templates()} assert uris == { "bebop://commands/{name}", "bebop://state/{key}", "bebop://files/{area}", "bebop://log/{tag}", } # --- the catalogue, which needs no drone ------------------------------------- async def test_the_whole_catalogue_reads_without_a_drone(client): data = body(await client.read_resource("bebop://commands")) assert data["count"] == len(xml_index.all_commands()) names = {c["name"] for c in data["commands"]} assert "ardrone3.Piloting.TakeOff" in names assert all({"tier", "direction", "bebop2"} <= set(c) for c in data["commands"]) async def test_one_command_reads_in_full_with_its_dotted_name(client): data = body(await client.read_resource("bebop://commands/ardrone3.Camera.OrientationV2")) assert data["name"] == "ardrone3.Camera.OrientationV2" assert [a["name"] for a in data["args"]] == ["tilt", "pan"] assert data["ids"] and data["buffer"] async def test_an_enum_argument_arrives_with_its_allowed_names(client): # Without these a caller has to guess at the string an argument accepts, # which is the main reason to read a command rather than send and see. data = body(await client.read_resource("bebop://commands/common.Mavlink.Start")) (arg,) = [a for a in data["args"] if a["name"] == "type"] assert arg["enum_values"] == ["flightPlan", "mapMyHouse"] async def test_an_unknown_command_suggests_rather_than_failing(client): data = body(await client.read_resource("bebop://commands/ardrone3.Piloting.Takeof")) assert data["available"] is False assert "ardrone3.Piloting.TakeOff" in data["did_you_mean"] # --- the ones that need a drone ---------------------------------------------- async def test_state_explains_itself_when_nothing_is_connected(client): data = body(await client.read_resource("bebop://state")) assert data["available"] is False assert "connect" in data["reason"].lower() async def test_one_state_key_explains_itself_when_nothing_is_connected(client): data = body(await client.read_resource("bebop://state/BatteryStateChanged_percent")) assert data["available"] is False async def test_state_carries_every_reported_value_with_its_age(connected): data = body(await connected.read_resource("bebop://state")) assert data["available"] is True assert data["keys"] > 20 battery = data["values"]["BatteryStateChanged_percent"] assert isinstance(battery["value"], int) and battery["age"] >= 0 async def test_state_reports_link_freshness_because_connected_is_not_evidence(connected): # The aircraft accepts a second controller and silently redirects # telemetry to it, leaving this session reporting connected with nothing # arriving. The age of the newest event is the only tell. data = body(await connected.read_resource("bebop://state")) assert data["last_event_age"] is not None and data["last_event_age"] < 10 async def test_a_state_key_matches_on_a_prefix(connected): data = body(await connected.read_resource("bebop://state/BatteryStateChanged")) assert data["available"] is True assert "BatteryStateChanged_percent" in data["values"] async def test_a_state_key_the_drone_never_sent_says_so(connected): data = body(await connected.read_resource("bebop://state/NoSuchThing")) assert data["available"] is False assert "bebop://state" in data["reason"] async def test_a_file_listing_on_the_simulator_explains_itself(connected): data = body(await connected.read_resource("bebop://files/media")) assert data["available"] is False assert "simulator" in data["reason"] async def test_an_unknown_file_area_lists_the_real_ones(client): data = body(await client.read_resource("bebop://files/update")) assert data["available"] is False assert "flightplans" in data["reason"] async def test_the_log_resource_explains_itself_with_no_aircraft(connected): data = body(await connected.read_resource("bebop://log/KERNEL")) assert data["available"] is False assert "simulator" in data["reason"] # --- the log resource against a synthetic log -------------------------------- @pytest.fixture async def with_log(monkeypatch, tmp_path): log = tmp_path / "ckcm.bin" log.write_bytes( entry("Machine: Milos board", tag="KERNEL", uptime_us=0) + entry("Cmd Tx : battery level <62%>", tag="COMMANDS", uptime_us=9_000_000) + entry("eth0: no IPv6 routers present", tag="KERNEL", priority="W", uptime_us=12_000_000) ) monkeypatch.setattr("mcbebop.files.ftp.fetch", lambda *a, **k: log) async with Client(build_server(Settings(capture_dir=tmp_path))) as c: _common.app().target = "192.0.2.1" yield c async def test_the_log_resource_returns_recent_entries_for_one_tag(with_log): data = body(await with_log.read_resource("bebop://log/KERNEL")) assert data["available"] is True assert data["matched"] == 2 assert data["parsed"] == 3 assert data["entries"][0]["message"].startswith("eth0"), "newest first" async def test_the_log_resource_shows_the_whole_tag_census(with_log): data = body(await with_log.read_resource("bebop://log/COMMANDS")) assert data["tags"] == {"KERNEL": 2, "COMMANDS": 1} async def test_the_log_resource_caps_what_it_returns(with_log): from mcbebop import resources assert resources.LOG_RESOURCE_LIMIT <= 100, "a resource read must not flood a context" data = body(await with_log.read_resource("bebop://log/KERNEL")) assert len(data["entries"]) <= resources.LOG_RESOURCE_LIMIT async def test_a_tag_the_log_does_not_carry_is_empty_not_an_error(with_log): data = body(await with_log.read_resource("bebop://log/NETMON")) assert data["available"] is True assert data["matched"] == 0 assert data["entries"] == [] async def test_the_log_resource_does_not_apply_the_tools_default_exclusions(with_log): # It addresses one tag by name, so there is nothing to protect the caller # from; a resource that silently returned nothing for a tag it was asked # for would be worse than a long answer. assert logs.NOISY_TAGS, "the tool excludes something by default" data = body(await with_log.read_resource("bebop://log/KERNEL")) assert data["matched"] == 2