Files
potato-mesh/tests/test_helpers.py
T

324 lines
11 KiB
Python

# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Additional tests that exercise defensive helpers and interfaces."""
import collections
import importlib
import sys
import types
from pathlib import Path
from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from data.mesh_ingestor import channels, config, interfaces, queue, serialization
@pytest.fixture(autouse=True)
def reset_state(monkeypatch):
"""Ensure mutable singletons are cleaned up between tests."""
repo_root = Path(__file__).resolve().parents[1]
monkeypatch.syspath_prepend(str(repo_root))
channels._reset_channel_cache()
yield
channels._reset_channel_cache()
importlib.reload(config)
def test_queue_stringification_and_ordering():
"""Exercise queue payload formatting and priority ordering."""
mapping_payload = {"b": 1, "a": 2}
assert queue._stringify_payload_value(mapping_payload).startswith('{"a"')
assert queue._stringify_payload_value([1, 2, 3]).startswith("[1")
assert queue._stringify_payload_value({1, 2}).replace(" ", "") in ("[1,2]", "[2,1]")
assert queue._stringify_payload_value(b"bytes") == '"bytes"'
assert queue._stringify_payload_value("text") == '"text"'
pairs = queue._payload_key_value_pairs(mapping_payload)
assert pairs.split(" ") == ["a=2", "b=1"]
state = queue.QueueState()
order = []
queue._enqueue_post_json("/low", {"x": 1}, priority=90, state=state)
queue._enqueue_post_json("/high", {"x": 2}, priority=10, state=state)
state.active = True
queue._drain_post_queue(
state=state, send=lambda path, payload: order.append((path, payload["x"]))
)
assert order == [("/high", 2), ("/low", 1)]
assert state.active is False
assert state.queue == []
def test_channels_iterator_and_capture(monkeypatch):
"""Verify channel helpers normalise roles and cache primary/secondary entries."""
channels._reset_channel_cache()
class StubSettings:
def __init__(self, name):
self.name = name
class PrimaryChannel:
def __init__(self):
self.role = "PRIMARY"
self.settings = StubSettings("Alpha")
class SecondaryChannel:
def __init__(self, index, name):
self.role = "SECONDARY"
self.index = index
self.settings = StubSettings(name)
class Container:
def __len__(self):
return 2
def __getitem__(self, idx):
if idx == 0:
return PrimaryChannel()
if idx == 1:
return SecondaryChannel(5, "Bravo")
raise IndexError
class StubLocalNode:
def __init__(self):
self.channels = Container()
class StubIface:
def __init__(self):
self.localNode = StubLocalNode()
def waitForConfig(self):
return True
channels.capture_from_interface(StubIface())
assert channels.channel_mappings() == ((0, "Alpha"), (5, "Bravo"))
assert channels.channel_name(5) == "Bravo"
assert list(channels._iter_channel_objects({"0": "zero"})) == ["zero"]
def test_candidate_node_id_and_normaliser():
"""Ensure node identifiers are found inside nested payloads."""
nested = {
"payload": {"meta": {"user": {"id": "0x42"}}},
"decoded": {"from": "!0000002a"},
}
node_id = interfaces._candidate_node_id(nested)
assert node_id == "!0000002a"
telemetry_packet = {"id": 123456, "from": "!0000000b"}
node_id = interfaces._candidate_node_id(telemetry_packet)
assert node_id == "!0000000b"
unknown_packet = {"id": "123456"}
assert interfaces._candidate_node_id(unknown_packet) is None
preferred_hex_packet = {"id": "0x2a"}
assert interfaces._candidate_node_id(preferred_hex_packet) == "!0000002a"
caret_alias_packet = {"id": "^abc"}
assert interfaces._candidate_node_id(caret_alias_packet) == "^abc"
non_node_numeric = {"id": 42.0}
assert interfaces._candidate_node_id(non_node_numeric) is None
packet = {"user": {"id": "!0000002a"}, "userId": None}
normalised = interfaces._normalise_nodeinfo_packet(packet)
assert normalised["id"] == "!0000002a"
assert normalised["user"]["id"] == "!0000002a"
def test_safe_nodeinfo_wrapper_handles_missing_id():
"""Cover the KeyError guard and wrapper marker."""
called = {}
def original(_iface, _packet):
called["ran"] = True
raise KeyError("id")
wrapper = interfaces._build_safe_nodeinfo_callback(original)
result = wrapper(SimpleNamespace(), {"anything": 1})
assert called["ran"] is True
assert result is None
assert getattr(wrapper, "_potato_mesh_safe_wrapper")
def test_nodeinfo_dispatch_guard_skips_a_profile_naming_another_node():
"""The guarded ``NODEINFO_APP`` entry lets the library store only the
sender's own profile, never one naming another node or no node, and
guarding twice wraps it once (SPEC NI1)."""
from data.mesh_ingestor.interfaces.patches import nodeinfo as nodeinfo_patch
stored = []
def library_callback(_iface, packet):
stored.append(packet)
return "stored"
known_protocol = collections.namedtuple(
"KnownProtocol", "name protobufFactory onReceive"
)
module = SimpleNamespace(
protocols={4: known_protocol("user", None, library_callback)},
portnums_pb2=SimpleNamespace(PortNum=SimpleNamespace(NODEINFO_APP=4)),
)
nodeinfo_patch._patch_meshtastic_nodeinfo_dispatch(module)
guarded = module.protocols[4]
nodeinfo_patch._patch_meshtastic_nodeinfo_dispatch(module)
assert module.protocols[4] is guarded
assert guarded.name == "user"
foreign = {"from": 0xA1A1A1A1, "decoded": {"user": {"id": "!b2b2b2b2"}}}
own = {"from": 0xA1A1A1A1, "decoded": {"user": {"id": "!a1a1a1a1"}}}
no_id = {"from": 0xA1A1A1A1, "decoded": {"user": {"longName": "No Id"}}}
no_user = {"from": 0xA1A1A1A1, "decoded": {}}
assert guarded.onReceive(None, foreign) is None
assert guarded.onReceive(None, own) == "stored"
# The library's callback raises KeyError('id') on a user without an id.
assert guarded.onReceive(None, no_id) is None
assert guarded.onReceive(None, no_user) is None
assert stored == [own]
def test_library_files_a_node_without_nodeinfo_under_a_stand_in_user():
"""The pinned library gives a nodeDB entry without a user a stand-in user
with ``hwModel: "UNSET"``, which the node snapshot posts as it is; the web
app keeps a placeholder role against it (SPEC NI5)."""
from meshtastic import mesh_interface
from meshtastic.protobuf import mesh_pb2
iface = mesh_interface.MeshInterface(noProto=True)
iface.nodes = {}
iface.nodesByNum = {}
info = mesh_pb2.NodeInfo(num=0x0B6F0004)
iface._handleFromRadio(mesh_pb2.FromRadio(node_info=info).SerializeToString())
assert iface.nodes["!0b6f0004"]["user"] == {
"id": "!0b6f0004",
"longName": "Meshtastic 0004",
"shortName": "0004",
"hwModel": "UNSET",
}
def test_nodeinfo_dispatch_guard_needs_a_replaceable_entry():
"""A package without the dispatch table or its entry is left as it is."""
from data.mesh_ingestor.interfaces.patches import nodeinfo as nodeinfo_patch
nodeinfo_patch._patch_meshtastic_nodeinfo_dispatch(SimpleNamespace())
port_enum = SimpleNamespace(PortNum=SimpleNamespace(NODEINFO_APP=4))
empty = SimpleNamespace(protocols={}, portnums_pb2=port_enum)
nodeinfo_patch._patch_meshtastic_nodeinfo_dispatch(empty)
plain_entry = SimpleNamespace(onReceive=lambda _iface, _packet: None)
plain = SimpleNamespace(protocols={4: plain_entry}, portnums_pb2=port_enum)
nodeinfo_patch._patch_meshtastic_nodeinfo_dispatch(plain)
assert empty.protocols == {}
assert plain.protocols[4] is plain_entry
def test_patch_nodeinfo_handler_class(monkeypatch):
"""Ensure NodeInfoHandler subclasses normalise packets with missing ids."""
class DummyHandler:
def __init__(self):
self.calls = []
def onReceive(self, iface, packet):
self.calls.append(packet)
return packet.get("id")
mesh_interface = types.SimpleNamespace(
NodeInfoHandler=DummyHandler, __name__="meshtastic.mesh_interface"
)
interfaces._patch_nodeinfo_handler_class(mesh_interface)
handler_cls = mesh_interface.NodeInfoHandler
handler = handler_cls()
iface = SimpleNamespace()
packet = {"user": {"id": "abcd"}}
result = handler.onReceive(iface, packet)
assert result == serialization._canonical_node_id("abcd")
assert handler.calls[0]["id"] == serialization._canonical_node_id("abcd")
def test_region_frequency_and_resolution_helpers():
"""Cover enum name parsing for LoRa region frequency."""
class EnumValue:
def __init__(self, name):
self.name = name
class EnumType:
def __init__(self):
self.values_by_number = {
1: EnumValue("REGION_915"),
2: EnumValue("US"),
}
class FieldDesc:
def __init__(self):
self.enum_type = EnumType()
class Descriptor:
def __init__(self):
self.fields_by_name = {"region": FieldDesc()}
class LoraMessage:
def __init__(self, region, override_frequency=None):
self.region = region
self.override_frequency = override_frequency
self.DESCRIPTOR = Descriptor()
freq = interfaces._region_frequency(LoraMessage(1))
assert freq == 915
freq = interfaces._region_frequency(LoraMessage(1, override_frequency=0))
assert freq == 915
freq = interfaces._region_frequency(LoraMessage(1, override_frequency=921.5))
assert freq == 921
freq = interfaces._region_frequency(LoraMessage(1, override_frequency="915MHz"))
assert freq == "915MHz"
freq = interfaces._region_frequency(LoraMessage(2))
assert freq == 902 # "US" is in the region lookup table → base 902 MHz
class StringRegionMessage:
def __init__(self, region):
self.region = region
freq = interfaces._region_frequency(StringRegionMessage("EU"))
assert freq == "EU"
class LocalConfig:
def __init__(self, lora):
self.lora = lora
lora_msg = LoraMessage(1)
resolved = interfaces._resolve_lora_message(LocalConfig(lora_msg))
assert resolved is lora_msg