"""The index is derived, not transcribed, so these pin the derivation. Every count here was read off the two vendored XML files. A bad parse that still produces plausible-looking objects is the failure mode worth catching, so the numbers are asserted rather than the shapes. """ import collections import shutil import pytest from mcbebop.protocol import xml_index from mcbebop.protocol.types import Buffer, Direction, Tier TOTAL_IN_XML = 264 TOTAL_WITH_FLAT_TRIM = 265 def test_command_count(): assert len(xml_index.all_commands()) == TOTAL_WITH_FLAT_TRIM from_xml = [c for c in xml_index.all_commands() if c.name != "FlatTrim"] assert len(from_xml) == TOTAL_IN_XML def test_direction_split(): counts = collections.Counter(c.direction for c in xml_index.all_commands() if c.name != "FlatTrim") assert counts[Direction.TO_DRONE] == 101 assert counts[Direction.FROM_DRONE] == 163 assert sum(counts.values()) == TOTAL_IN_XML def test_direction_fallback_count(): """31 commands state neither result nor triggered and fall back to the suffix.""" from xml.etree import ElementTree directory = xml_index.xml_dir() neither = 0 for name in xml_index.XML_FILES: project = ElementTree.parse(directory / name).getroot() for klass in project.iter("class"): for cmd in klass.findall("cmd"): comment = cmd.find("comment") attrs = comment.attrib if comment is not None else {} assert not ("result" in attrs and "triggered" in attrs), cmd.get("name") if "result" not in attrs and "triggered" not in attrs: neither += 1 assert neither == 31 def test_mixed_controller_class_is_not_decided_by_suffix(): # common.Controller holds one of each, which is why the suffix rule alone # would be wrong there. Both state result/triggered, so it never applies. assert xml_index.get("common.Controller.isPiloting").direction is Direction.TO_DRONE assert xml_index.get("common.Controller.PeerStateChanged").direction is Direction.FROM_DRONE def test_buffers(): counts = collections.Counter(c.buffer for c in xml_index.all_commands() if c.name != "FlatTrim") assert counts[Buffer.ACK] == 248 assert counts[Buffer.NON_ACK] == 15 assert counts[Buffer.HIGH_PRIO] == 1 high_prio = [c.full_name for c in xml_index.all_commands() if c.buffer is Buffer.HIGH_PRIO] assert high_prio == ["ardrone3.Piloting.Emergency"] def test_deprecated_count(): assert sum(1 for c in xml_index.all_commands() if c.deprecated) == 39 def test_expectation_block_count(): blocks = sum(1 for c in xml_index.all_commands() if c.expectations) assert blocks == 82 assert all(c.direction is Direction.TO_DRONE for c in xml_index.all_commands() if c.expectations) def test_ids_are_unique_and_lookups_agree(): for command in xml_index.all_commands(): assert xml_index.by_ids(command.ids) is command assert xml_index.get(command.full_name) is command assert xml_index.get("ardrone3.Piloting.Nope") is None assert xml_index.by_ids((9, 9, 9)) is None def test_known_ids_match_the_live_aircraft(): # Read off real traffic, see docs/notes/protocol.md. assert xml_index.get("common.Common.AllStates").ids == (0, 4, 0) assert xml_index.get("common.Settings.AllSettings").ids == (0, 2, 0) assert xml_index.get("ardrone3.Piloting.TakeOff").ids == (1, 0, 1) assert xml_index.get("ardrone3.Piloting.PCMD").ids == (1, 0, 2) def test_command_ids_have_gaps_and_document_order_is_not_id_order(): """Never infer an id from a command's position in the file.""" piloting = [c for c in xml_index.all_commands() if f"{c.project}.{c.klass}" == "ardrone3.Piloting"] in_document_order = [c.ids[2] for c in piloting] assert in_document_order != sorted(in_document_order) # ardrone3.PilotingState runs 1..22 with no id 0, so counting is not enough. state = [ c.ids[2] for c in xml_index.all_commands() if f"{c.project}.{c.klass}" == "ardrone3.PilotingState" ] assert len(state) < max(state) + 1 def test_flat_trim_is_injected(): flat_trim = xml_index.get("ardrone3.Piloting.FlatTrim") assert flat_trim is not None assert flat_trim.ids == (1, 0, 0) assert flat_trim.args == () assert flat_trim.buffer is Buffer.ACK assert flat_trim.direction is Direction.TO_DRONE assert flat_trim.tier is Tier.MOTION assert flat_trim.support is None assert "missing from Parrot's XML" in flat_trim.doc def test_commands_without_a_comment_keep_their_bare_text_as_doc(): bare = [ "common.CalibrationState.PitotCalibrationStateChanged", "common.ARLibsVersionsState.ControllerLibARCommandsVersion", "common.ARLibsVersionsState.SkyControllerLibARCommandsVersion", "common.ARLibsVersionsState.DeviceLibARCommandsVersion", "ardrone3.SettingsState.CPUID", "ardrone3.MediaStreamingState.VideoStreamModeChanged", ] for name in bare: command = xml_index.get(name) assert command.title == "" assert command.doc, name assert xml_index.get("ardrone3.SettingsState.CPUID").doc == "Product main cpu id" def test_enum_values_are_positions_and_names_stay_verbatim(): band = xml_index.get("ardrone3.Network.WifiScan").args[0] assert band.is_enum assert [(m.name, m.value) for m in band.members] == [("2_4ghz", 0), ("5ghz", 1), ("all", 2)] # Member names that are not valid identifiers must survive as strings. framerate = xml_index.get("ardrone3.PictureSettings.VideoFramerate").args[0] assert [m.name for m in framerate.members][:3] == ["24_FPS", "25_FPS", "30_FPS"] def test_only_name_and_type_on_args(): # Nothing in the parse may depend on an arg attribute Parrot does not set. for command in xml_index.all_commands(): for arg in command.args: assert arg.name and arg.type def test_search_ranks_the_command_above_the_prose(): results = xml_index.search("takeoff") assert results assert results[0].name == "TakeOff" assert any(c.full_name == "ardrone3.PilotingState.FlyingStateChanged" for c in xml_index.search("flying")) assert xml_index.search(" ") == [] def test_myclass_spelling_also_loads(tmp_path, monkeypatch): """pyparrot renamed to ; a copy from it must still parse.""" source = xml_index.xml_dir() for name in xml_index.XML_FILES: text = (source / name).read_text() text = text.replace("", "") (tmp_path / name).write_text(text) monkeypatch.setenv(xml_index.XML_DIR_ENV, str(tmp_path)) xml_index.index.cache_clear() try: assert len(xml_index.all_commands()) == TOTAL_WITH_FLAT_TRIM finally: monkeypatch.delenv(xml_index.XML_DIR_ENV) xml_index.index.cache_clear() def test_missing_xml_dir_says_what_to_set(tmp_path, monkeypatch): real = xml_index.xml_dir() shutil.copy(real / "common.xml", tmp_path / "common.xml") # one of the two, not both monkeypatch.setenv(xml_index.XML_DIR_ENV, str(tmp_path)) xml_index.index.cache_clear() try: with pytest.raises(FileNotFoundError, match=xml_index.XML_DIR_ENV): xml_index.all_commands() finally: monkeypatch.delenv(xml_index.XML_DIR_ENV) xml_index.index.cache_clear() @pytest.mark.parametrize( ("support", "expected"), [ ("drones", True), ("none", False), ("0901;090c;090e", True), ("0901;090e", False), ("090c", True), ("090c:4.3.0", True), # firmware 4.7.1 clears a 4.3.0 minimum ("090c:4.8.0", False), ("0901:2.0.29;090c;090e", True), ("0914:1.6.3;0919:1.6.3", False), ("090e;090c:4.3.0", True), (None, None), ("", None), (" ", None), ], ) def test_supports_bebop2(support, expected): assert xml_index.supports_bebop2(support) is expected def test_support_versions_compare_as_integers_not_strings(): # Lexically "2.0.29" < "2.0.3", which would make this unsupported. assert xml_index.supports_bebop2("090c:2.0.29", firmware=(2, 0, 3)) is False assert xml_index.supports_bebop2("090c:2.0.3", firmware=(2, 0, 29)) is True assert xml_index.supports_bebop2("090c:10.0.0", firmware=(9, 0, 0)) is False def test_unknown_support_is_not_unsupported(): unknown = [c for c in xml_index.all_commands() if xml_index.supports_bebop2(c.support) is None] # 30 commands carry a with no support attribute, 2 carry an empty # one, 6 have no at all, and FlatTrim is hand-added. assert len(unknown) == 39 assert xml_index.get("ardrone3.Piloting.FlatTrim") in unknown