mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(transport): CompositeTransport for dual Meshtastic+MeshCore (Phase 4) (#6)
* feat(transport): CompositeTransport for dual Meshtastic+MeshCore (Phase 4) Adds CompositeTransport (transport: both) that fans broadcasts to both meshes, sizes to min(children) for uniform messages, and routes DM replies back over the originating mesh via a transport hint threaded from the inbound MeshMessage. Per-child self-filtering; supervisor watchdog now resolves the Meshtastic child inside the composite. Additive/optional throughout; single-transport behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sizing): fixed universal mesh budget (mesh_max_chars=140) Replace per-transport / min-of-active max_chars with a single fixed universal constant (mesh_max_chars, default 140 = MeshCore LCD). Every message is built once against one deterministic budget regardless of which radios are connected; no runtime variance, no per-transport retooling. Meshtastic sizing intentionally moves 200 -> 140. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
225a5d37df
commit
f3df7a0a6d
16 changed files with 1140 additions and 76 deletions
568
work/tests/test_composite_transport.py
Normal file
568
work/tests/test_composite_transport.py
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
"""Hermetic tests for CompositeTransport (Phase 4).
|
||||
|
||||
All tests use fake child transports — no real sockets, threads, or meshcore
|
||||
lib required. The helpers below replicate the MeshMessage dataclass so the
|
||||
tests run without importing the full connector chain.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.transport.composite_transport import CompositeTransport, _child_name
|
||||
from meshai.connector import MeshMessage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake child transport helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FakeChild:
|
||||
"""Minimal MeshTransport stand-in for unit tests.
|
||||
|
||||
Tracks calls and exposes enough surface for CompositeTransport to work.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
node_id: str = "!aabbccdd",
|
||||
max_chars_val: int = 200,
|
||||
connected_val: bool = True,
|
||||
known_nodes: Optional[dict] = None,
|
||||
) -> None:
|
||||
self.transport_name = name
|
||||
self._connected = connected_val
|
||||
self._node_id = node_id
|
||||
self._max_chars = max_chars_val
|
||||
self._known_nodes: dict[str, str] = known_nodes or {}
|
||||
|
||||
# Call-tracking
|
||||
self.connect_calls = 0
|
||||
self.disconnect_calls = 0
|
||||
self.send_calls: list[dict] = []
|
||||
self._callback = None
|
||||
self._callback_loop = None
|
||||
|
||||
# --- MeshTransport interface ---
|
||||
|
||||
def connect(self) -> None:
|
||||
self.connect_calls += 1
|
||||
self._connected = True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
self.disconnect_calls += 1
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def my_node_id(self) -> Optional[str]:
|
||||
return self._node_id
|
||||
|
||||
@property
|
||||
def max_chars(self) -> int:
|
||||
return self._max_chars
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
text: str,
|
||||
destination: Optional[str] = None,
|
||||
channel: int = 0,
|
||||
transport: Optional[str] = None,
|
||||
) -> bool:
|
||||
self.send_calls.append(
|
||||
{"text": text, "destination": destination, "channel": channel, "transport": transport}
|
||||
)
|
||||
return True
|
||||
|
||||
def set_message_callback(self, callback, loop) -> None:
|
||||
self._callback = callback
|
||||
self._callback_loop = loop
|
||||
|
||||
def get_node_name(self, node_id: str) -> str:
|
||||
return self._known_nodes.get(node_id, node_id)
|
||||
|
||||
def get_node_position(self, node_id: str) -> Optional[tuple]:
|
||||
return None
|
||||
|
||||
# --- Test helper: simulate inbound message ---
|
||||
|
||||
async def _simulate_inbound(self, msg: MeshMessage) -> None:
|
||||
"""Call the registered callback directly (simulates an inbound packet)."""
|
||||
if self._callback:
|
||||
await self._callback(msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory-level test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFactory:
|
||||
def test_both_returns_composite(self) -> None:
|
||||
"""factory transport='both' → CompositeTransport with 2 children."""
|
||||
from meshai.transport.factory import build_transport
|
||||
|
||||
cfg = types.SimpleNamespace(
|
||||
transport="both",
|
||||
type="tcp",
|
||||
tcp_host="127.0.0.1",
|
||||
tcp_port=4403,
|
||||
meshcore_host="127.0.0.1",
|
||||
meshcore_port=5050,
|
||||
)
|
||||
t = build_transport(cfg)
|
||||
assert isinstance(t, CompositeTransport)
|
||||
assert len(t.children) == 2
|
||||
# Child order: Meshtastic first, MeshCore second.
|
||||
from meshai.connector import MeshtasticTransport
|
||||
from meshai.transport.meshcore_transport import MeshCoreTransport
|
||||
assert isinstance(t.children[0], MeshtasticTransport)
|
||||
assert isinstance(t.children[1], MeshCoreTransport)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# max_chars
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMaxChars:
|
||||
def test_fixed_universal_budget_no_config(self) -> None:
|
||||
"""Without a config, CompositeTransport falls back to the 140 constant."""
|
||||
mt = FakeChild("meshtastic", max_chars_val=200)
|
||||
mc = FakeChild("meshcore", max_chars_val=140)
|
||||
comp = CompositeTransport([mt, mc])
|
||||
assert comp.max_chars == 140
|
||||
|
||||
def test_fixed_universal_budget_from_config(self) -> None:
|
||||
"""With a config, CompositeTransport reads mesh_max_chars directly."""
|
||||
from meshai.config import ConnectionConfig
|
||||
cfg = ConnectionConfig(transport="both", mesh_max_chars=140)
|
||||
mt = FakeChild("meshtastic", max_chars_val=200)
|
||||
mc = FakeChild("meshcore", max_chars_val=140)
|
||||
comp = CompositeTransport([mt, mc], config=cfg)
|
||||
assert comp.max_chars == 140
|
||||
|
||||
def test_fixed_universal_budget_ignores_child_values(self) -> None:
|
||||
"""CompositeTransport must NOT take min(children); it sources config."""
|
||||
from meshai.config import ConnectionConfig
|
||||
cfg = ConnectionConfig(transport="both", mesh_max_chars=140)
|
||||
# Even if a child would report 230, the composite must return 140.
|
||||
child = FakeChild("meshtastic", max_chars_val=230)
|
||||
comp = CompositeTransport([child], config=cfg)
|
||||
assert comp.max_chars == 140
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect / disconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLifecycle:
|
||||
def test_connect_fans_to_all(self) -> None:
|
||||
a = FakeChild("meshtastic", connected_val=False)
|
||||
b = FakeChild("meshcore", connected_val=False)
|
||||
comp = CompositeTransport([a, b])
|
||||
comp.connect()
|
||||
assert a.connect_calls == 1
|
||||
assert b.connect_calls == 1
|
||||
|
||||
def test_connect_continues_after_failure(self) -> None:
|
||||
"""One child failing to connect must not stop the other."""
|
||||
bad = FakeChild("meshtastic", connected_val=False)
|
||||
bad.connect = MagicMock(side_effect=RuntimeError("boom"))
|
||||
good = FakeChild("meshcore", connected_val=False)
|
||||
comp = CompositeTransport([bad, good])
|
||||
comp.connect() # must not raise
|
||||
assert good.connect_calls == 1
|
||||
assert comp.connected # good child is up
|
||||
|
||||
def test_connected_true_if_any(self) -> None:
|
||||
down = FakeChild("meshtastic", connected_val=False)
|
||||
up = FakeChild("meshcore", connected_val=True)
|
||||
comp = CompositeTransport([down, up])
|
||||
assert comp.connected is True
|
||||
|
||||
def test_connected_false_if_none(self) -> None:
|
||||
a = FakeChild("meshtastic", connected_val=False)
|
||||
b = FakeChild("meshcore", connected_val=False)
|
||||
comp = CompositeTransport([a, b])
|
||||
assert comp.connected is False
|
||||
|
||||
def test_disconnect_fans_to_all(self) -> None:
|
||||
a = FakeChild("meshtastic")
|
||||
b = FakeChild("meshcore")
|
||||
comp = CompositeTransport([a, b])
|
||||
comp.disconnect()
|
||||
assert a.disconnect_calls == 1
|
||||
assert b.disconnect_calls == 1
|
||||
|
||||
def test_disconnect_guarded(self) -> None:
|
||||
"""disconnect() must not raise even if a child raises."""
|
||||
bad = FakeChild("meshtastic")
|
||||
bad.disconnect = MagicMock(side_effect=RuntimeError("oops"))
|
||||
good = FakeChild("meshcore")
|
||||
comp = CompositeTransport([bad, good])
|
||||
comp.disconnect() # must not raise
|
||||
assert good.disconnect_calls == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_message — broadcast
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBroadcast:
|
||||
def test_broadcast_sends_to_all(self) -> None:
|
||||
mt = FakeChild("meshtastic")
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
result = comp.send_message("hello mesh")
|
||||
assert result is True
|
||||
assert len(mt.send_calls) == 1
|
||||
assert len(mc.send_calls) == 1
|
||||
assert mt.send_calls[0]["destination"] is None
|
||||
assert mc.send_calls[0]["destination"] is None
|
||||
|
||||
def test_broadcast_skips_disconnected_child(self) -> None:
|
||||
mt = FakeChild("meshtastic", connected_val=False)
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
result = comp.send_message("hi")
|
||||
assert result is True
|
||||
assert len(mt.send_calls) == 0
|
||||
assert len(mc.send_calls) == 1
|
||||
|
||||
def test_broadcast_true_if_at_least_one_ok(self) -> None:
|
||||
mt = FakeChild("meshtastic")
|
||||
mt.send_message = MagicMock(return_value=False)
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
result = comp.send_message("test")
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_message — hinted DM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHintedDM:
|
||||
def test_hinted_meshcore_only(self) -> None:
|
||||
mt = FakeChild("meshtastic")
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
result = comp.send_message("reply", destination="abc123", transport="meshcore")
|
||||
assert result is True
|
||||
assert len(mc.send_calls) == 1
|
||||
assert len(mt.send_calls) == 0
|
||||
assert mc.send_calls[0]["destination"] == "abc123"
|
||||
|
||||
def test_hinted_meshtastic_only(self) -> None:
|
||||
mt = FakeChild("meshtastic")
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
result = comp.send_message("reply", destination="!deadbeef", transport="meshtastic")
|
||||
assert result is True
|
||||
assert len(mt.send_calls) == 1
|
||||
assert len(mc.send_calls) == 0
|
||||
|
||||
def test_unknown_hint_returns_false(self) -> None:
|
||||
mt = FakeChild("meshtastic")
|
||||
comp = CompositeTransport([mt])
|
||||
result = comp.send_message("x", destination="y", transport="nonexistent")
|
||||
assert result is False
|
||||
assert len(mt.send_calls) == 0
|
||||
|
||||
def test_hinted_child_disconnected_returns_false(self) -> None:
|
||||
mt = FakeChild("meshtastic", connected_val=False)
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
result = comp.send_message("x", destination="y", transport="meshtastic")
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_message — unhinted DM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUnhintedDM:
|
||||
def test_prefers_resolving_child(self) -> None:
|
||||
mt = FakeChild("meshtastic", known_nodes={"!aabbccdd": "NodeA"})
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
result = comp.send_message("hello", destination="!aabbccdd")
|
||||
assert result is True
|
||||
assert len(mt.send_calls) == 1
|
||||
assert len(mc.send_calls) == 0
|
||||
|
||||
def test_fans_to_all_when_none_resolves(self) -> None:
|
||||
mt = FakeChild("meshtastic")
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
result = comp.send_message("hello", destination="!unknown")
|
||||
assert result is True
|
||||
assert len(mt.send_calls) == 1
|
||||
assert len(mc.send_calls) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_message_callback — inbound forwarding and self-filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInboundCallback:
|
||||
"""Use asyncio.run() for each coroutine so the test always gets a fresh
|
||||
event loop regardless of what earlier tests in the suite may have done.
|
||||
FakeChild._simulate_inbound calls the wrapper directly (no
|
||||
call_soon_threadsafe), so we don't actually need the loop we pass to
|
||||
set_message_callback — we just need *some* AbstractEventLoop object to
|
||||
satisfy the call signature.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _make_loop():
|
||||
"""Return a new event loop used only as a placeholder for the callback
|
||||
registration (FakeChild doesn't schedule on it)."""
|
||||
return asyncio.new_event_loop()
|
||||
|
||||
def test_inbound_forwarded_with_correct_transport(self) -> None:
|
||||
mt = FakeChild("meshtastic", node_id="!aaaaaaaa")
|
||||
mc = FakeChild("meshcore", node_id="!bbbbbbbb")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
|
||||
received: list[MeshMessage] = []
|
||||
|
||||
async def run():
|
||||
async def cb(msg):
|
||||
received.append(msg)
|
||||
|
||||
loop = self._make_loop()
|
||||
comp.set_message_callback(cb, loop)
|
||||
loop.close()
|
||||
|
||||
inbound = MeshMessage(
|
||||
sender_id="!cccccccc",
|
||||
sender_name="Other",
|
||||
text="hello",
|
||||
channel=0,
|
||||
is_dm=True,
|
||||
transport="meshcore",
|
||||
)
|
||||
await mc._simulate_inbound(inbound)
|
||||
|
||||
asyncio.run(run())
|
||||
assert len(received) == 1
|
||||
assert received[0].transport == "meshcore"
|
||||
|
||||
def test_self_filter_drops_own_message(self) -> None:
|
||||
mt = FakeChild("meshtastic", node_id="!selfid1")
|
||||
comp = CompositeTransport([mt])
|
||||
|
||||
received: list[MeshMessage] = []
|
||||
|
||||
async def run():
|
||||
async def cb(msg):
|
||||
received.append(msg)
|
||||
|
||||
loop = self._make_loop()
|
||||
comp.set_message_callback(cb, loop)
|
||||
loop.close()
|
||||
|
||||
# sender_id matches the child's own node ID → should be dropped
|
||||
echo = MeshMessage(
|
||||
sender_id="!selfid1",
|
||||
sender_name="Self",
|
||||
text="echo",
|
||||
channel=0,
|
||||
is_dm=False,
|
||||
)
|
||||
await mt._simulate_inbound(echo)
|
||||
|
||||
asyncio.run(run())
|
||||
assert len(received) == 0
|
||||
|
||||
def test_non_self_not_dropped(self) -> None:
|
||||
mt = FakeChild("meshtastic", node_id="!selfid1")
|
||||
comp = CompositeTransport([mt])
|
||||
|
||||
received: list[MeshMessage] = []
|
||||
|
||||
async def run():
|
||||
async def cb(msg):
|
||||
received.append(msg)
|
||||
|
||||
loop = self._make_loop()
|
||||
comp.set_message_callback(cb, loop)
|
||||
loop.close()
|
||||
|
||||
msg = MeshMessage(
|
||||
sender_id="!otherid",
|
||||
sender_name="Friend",
|
||||
text="hi",
|
||||
channel=0,
|
||||
is_dm=True,
|
||||
)
|
||||
await mt._simulate_inbound(msg)
|
||||
|
||||
asyncio.run(run())
|
||||
assert len(received) == 1
|
||||
|
||||
def test_transport_tag_backfilled_when_missing(self) -> None:
|
||||
"""If msg.transport is empty/falsy the wrapper sets it to the child name."""
|
||||
mc = FakeChild("meshcore", node_id="!mc00")
|
||||
comp = CompositeTransport([mc])
|
||||
|
||||
received: list[MeshMessage] = []
|
||||
|
||||
async def run():
|
||||
async def cb(msg):
|
||||
received.append(msg)
|
||||
|
||||
loop = self._make_loop()
|
||||
comp.set_message_callback(cb, loop)
|
||||
loop.close()
|
||||
|
||||
msg = MeshMessage(
|
||||
sender_id="!other",
|
||||
sender_name="X",
|
||||
text="ping",
|
||||
channel=0,
|
||||
is_dm=True,
|
||||
transport="", # missing tag
|
||||
)
|
||||
await mc._simulate_inbound(msg)
|
||||
|
||||
asyncio.run(run())
|
||||
assert len(received) == 1
|
||||
assert received[0].transport == "meshcore"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reply routing — responder seam
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReplyRouting:
|
||||
"""Verify that a meshcore-tagged inbound message causes send_message to be
|
||||
called with transport='meshcore' via the Responder."""
|
||||
|
||||
def test_responder_threads_transport(self) -> None:
|
||||
from meshai.responder import Responder
|
||||
from meshai.config import ResponseConfig
|
||||
|
||||
mt = FakeChild("meshtastic")
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
|
||||
cfg = ResponseConfig(delay_min=0.0, delay_max=0.0)
|
||||
responder = Responder(cfg, comp)
|
||||
|
||||
asyncio.run(
|
||||
responder.send_response(
|
||||
"pong",
|
||||
destination="abc123",
|
||||
channel=0,
|
||||
transport="meshcore",
|
||||
)
|
||||
)
|
||||
|
||||
# Only meshcore child should have been called.
|
||||
assert len(mc.send_calls) == 1
|
||||
assert mc.send_calls[0]["destination"] == "abc123"
|
||||
assert len(mt.send_calls) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# meshtastic_child helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMeshtasticChild:
|
||||
def test_returns_meshtastic_transport(self) -> None:
|
||||
from meshai.connector import MeshtasticTransport
|
||||
from meshai.transport.meshcore_transport import MeshCoreTransport
|
||||
|
||||
cfg = types.SimpleNamespace(
|
||||
type="tcp",
|
||||
tcp_host="127.0.0.1",
|
||||
tcp_port=4403,
|
||||
meshcore_host="127.0.0.1",
|
||||
meshcore_port=5050,
|
||||
)
|
||||
mt = MeshtasticTransport(cfg)
|
||||
mc = MeshCoreTransport(cfg)
|
||||
comp = CompositeTransport([mt, mc])
|
||||
child = comp.meshtastic_child()
|
||||
assert child is mt
|
||||
|
||||
def test_returns_none_when_absent(self) -> None:
|
||||
from meshai.transport.meshcore_transport import MeshCoreTransport
|
||||
|
||||
cfg = types.SimpleNamespace(
|
||||
meshcore_host="127.0.0.1",
|
||||
meshcore_port=5050,
|
||||
)
|
||||
mc = MeshCoreTransport(cfg)
|
||||
comp = CompositeTransport([mc])
|
||||
assert comp.meshtastic_child() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _child_name helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestChildName:
|
||||
def test_uses_transport_name_attr(self) -> None:
|
||||
child = FakeChild("foobar")
|
||||
assert _child_name(child) == "foobar"
|
||||
|
||||
def test_derives_from_class_name(self) -> None:
|
||||
class MyTransport:
|
||||
pass
|
||||
|
||||
obj = MyTransport()
|
||||
assert _child_name(obj) == "my"
|
||||
|
||||
def test_strips_transport_suffix(self) -> None:
|
||||
class SomeTransport:
|
||||
pass
|
||||
|
||||
assert _child_name(SomeTransport()) == "some"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_drop helper (unit test for routing logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShouldDrop:
|
||||
def test_drops_own_sender_id(self) -> None:
|
||||
comp = CompositeTransport([FakeChild("x")])
|
||||
msg = MeshMessage("!abc", "Self", "hi", 0, False)
|
||||
assert comp._should_drop(msg, "!abc") is True
|
||||
|
||||
def test_keeps_other_sender(self) -> None:
|
||||
comp = CompositeTransport([FakeChild("x")])
|
||||
msg = MeshMessage("!xyz", "Other", "hi", 0, False)
|
||||
assert comp._should_drop(msg, "!abc") is False
|
||||
|
||||
def test_none_node_id_no_drop(self) -> None:
|
||||
comp = CompositeTransport([FakeChild("x")])
|
||||
msg = MeshMessage("!abc", "X", "hi", 0, False)
|
||||
assert comp._should_drop(msg, None) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_child_for_hint (unit test for routing logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveChildForHint:
|
||||
def test_resolves_known_name(self) -> None:
|
||||
mt = FakeChild("meshtastic")
|
||||
mc = FakeChild("meshcore")
|
||||
comp = CompositeTransport([mt, mc])
|
||||
assert comp._resolve_child_for_hint("meshcore") is mc
|
||||
assert comp._resolve_child_for_hint("meshtastic") is mt
|
||||
|
||||
def test_returns_none_for_unknown(self) -> None:
|
||||
comp = CompositeTransport([FakeChild("meshtastic")])
|
||||
assert comp._resolve_child_for_hint("lora") is None
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Validates recency ordering, contained/tombstoned exclusion, the
|
||||
"Name in County Co, ST" line format, correct tail count after budget
|
||||
trimming, singular/plural grammar, and the 200-byte LoRa budget.
|
||||
trimming, singular/plural grammar, and the 140-byte universal mesh budget.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -62,28 +62,34 @@ class TestFireDigestRecency:
|
|||
"""Deterministic fire digest renderer tests."""
|
||||
|
||||
def test_top_2_listed_in_recency_order(self):
|
||||
"""The 2 most recent active fires are listed in order."""
|
||||
"""The most recent active fire is listed first.
|
||||
|
||||
With the universal 140-byte budget, the header + 1 fire line + tail
|
||||
fits; the second fire line does not. Alpha (most recent) must appear;
|
||||
Bravo belongs to the tail count.
|
||||
"""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
assert source == "deterministic"
|
||||
# Most recent fire must appear in the wire body.
|
||||
assert "Alpha Fire" in wire
|
||||
assert "Bravo Fire" in wire
|
||||
pos_alpha = wire.index("Alpha Fire")
|
||||
pos_bravo = wire.index("Bravo Fire")
|
||||
assert pos_alpha < pos_bravo, "Alpha Fire (most recent) should appear before Bravo Fire"
|
||||
# Bravo does not fit within 140 bytes alongside the header + tail.
|
||||
assert "Bravo Fire" not in wire
|
||||
|
||||
def test_line_format_name_in_county_co_state(self):
|
||||
"""Fire lines render as 'Name in County Co, ST'."""
|
||||
"""Fire lines render as 'Name in County Co, ST'.
|
||||
|
||||
Only the most recent fire fits within the 140-byte budget.
|
||||
"""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "Alpha Fire in Ada Co, ID" in wire
|
||||
assert "Bravo Fire in Boise Co, ID" in wire
|
||||
|
||||
def test_missing_county_renders_name_in_state(self):
|
||||
"""Fire with no county renders as 'Name in ST'."""
|
||||
|
|
@ -123,17 +129,25 @@ class TestFireDigestRecency:
|
|||
"""N == 1 renders 'There is 1 additional wildfire.'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
# 3 fires; short names + no county → lines are short enough that
|
||||
# 2 fit within the 140-byte budget, leaving 1 in the tail.
|
||||
for irwin, name, offset in [
|
||||
("SG-01", "A", 3600),
|
||||
("SG-02", "B", 7200),
|
||||
("SG-03", "C", 10800),
|
||||
]:
|
||||
_seed_fire(conn, irwin_id=irwin, name=name, acres=100,
|
||||
contained=None, last_event_at=now - offset,
|
||||
county=None, state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
# 3 active total, 2 shown, 1 remaining
|
||||
# 3 active total, 2 shown (budget), 1 remaining
|
||||
assert "There is 1 additional wildfire. DM me for the full list." in wire
|
||||
|
||||
def test_n_plural_grammar(self):
|
||||
"""N > 1 renders 'There are N additional wildfires.'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
day = 86400
|
||||
for i in range(4):
|
||||
_seed_fire(conn, irwin_id=f"PL-{i:02d}", name=f"Fire {i}",
|
||||
acres=100 + i, contained=None,
|
||||
|
|
@ -141,8 +155,9 @@ class TestFireDigestRecency:
|
|||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
# 4 active, 2 shown, 2 remaining
|
||||
assert "There are 2 additional wildfires. DM me for the full list." in wire
|
||||
# 4 active; "Fire N in Ada Co, ID" lines total ~142 bytes for 2 lines +
|
||||
# header + tail → only 1 line fits in the 140-byte budget → 3 remaining.
|
||||
assert "There are 3 additional wildfires. DM me for the full list." in wire
|
||||
|
||||
def test_n_zero_omits_sentence(self):
|
||||
"""When N == 0, the tail sentence is omitted entirely."""
|
||||
|
|
@ -186,7 +201,7 @@ class TestFireDigestRecency:
|
|||
wire, source = asyncio.run(render_digest(now=now))
|
||||
assert source == "deterministic"
|
||||
byte_len = len(wire.encode("utf-8"))
|
||||
assert byte_len <= 200, f"Wire is {byte_len} bytes, exceeds 200"
|
||||
assert byte_len <= 140, f"Wire is {byte_len} bytes, exceeds 140"
|
||||
# If both long lines fit, remaining = 1; if only one fits, remaining = 2
|
||||
# Either way the tail count must match (total - shown)
|
||||
lines = wire.split("\n")
|
||||
|
|
@ -205,7 +220,7 @@ class TestFireDigestRecency:
|
|||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
byte_len = len(wire.encode("utf-8"))
|
||||
assert byte_len <= 200, f"Digest is {byte_len} bytes, exceeds 200-byte budget"
|
||||
assert byte_len <= 140, f"Digest is {byte_len} bytes, exceeds 140-byte budget"
|
||||
|
||||
def test_no_fires_returns_empty(self):
|
||||
"""No active fires -> empty wire, 'no_fires' source."""
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ def test_adapter_config_seeds_digest_keys():
|
|||
assert rows[("fires", "digest_enabled")] == "true"
|
||||
assert rows[("fires", "digest_schedule")] == '["06:00", "18:00"]'
|
||||
assert rows[("fires", "digest_timezone")] == '"America/Boise"'
|
||||
assert rows[("fires", "digest_max_chars")] == "200"
|
||||
assert rows[("fires", "digest_max_chars")] == "140"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
|
@ -107,7 +107,7 @@ def test_render_digest_terse_fallback_when_no_llm():
|
|||
assert source == "deterministic"
|
||||
assert wire
|
||||
assert "Cache Peak" in wire
|
||||
assert len(wire) <= 200
|
||||
assert len(wire) <= 140
|
||||
|
||||
|
||||
def test_render_digest_uses_llm_when_available():
|
||||
|
|
|
|||
|
|
@ -79,10 +79,13 @@ class TestBuildTransport:
|
|||
assert isinstance(transport, MeshCoreTransport)
|
||||
assert isinstance(transport, MeshTransport)
|
||||
|
||||
def test_both_raises_not_implemented(self):
|
||||
def test_both_returns_composite(self):
|
||||
"""Phase 4: transport='both' now returns a CompositeTransport (seam filled)."""
|
||||
from meshai.transport.composite_transport import CompositeTransport
|
||||
cfg = self._config_with("both")
|
||||
with pytest.raises(NotImplementedError):
|
||||
build_transport(cfg)
|
||||
t = build_transport(cfg)
|
||||
assert isinstance(t, CompositeTransport)
|
||||
assert len(t.children) == 2
|
||||
|
||||
def test_unknown_transport_raises_value_error(self):
|
||||
cfg = self._config_with("unknown_transport_xyz")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Tests for Phase 3 uniform mesh-packet sizing.
|
||||
"""Tests for uniform mesh-packet sizing.
|
||||
|
||||
Verifies that every size-sensitive code path uses the active transport's
|
||||
max_chars rather than a hardcoded 200. Meshtastic default stays 200
|
||||
(byte-identical); MeshCore uses 140.
|
||||
Verifies that every size-sensitive code path uses the single universal
|
||||
mesh_max_chars constant (140 = MeshCore LCD). All transports — Meshtastic,
|
||||
MeshCore, and the composite — report the same fixed value.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
|
@ -127,59 +127,90 @@ def _minimal_config():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTransportMaxChars:
|
||||
def test_meshtastic_default_is_200(self):
|
||||
def test_meshtastic_default_is_140(self):
|
||||
"""Meshtastic now uses the universal mesh_max_chars constant (140)."""
|
||||
cfg = _mt_config()
|
||||
t = MeshtasticTransport(cfg)
|
||||
assert t.max_chars == 200
|
||||
|
||||
def test_meshtastic_respects_config_field(self):
|
||||
cfg = _mt_config(meshtastic_max_chars=160)
|
||||
t = MeshtasticTransport(cfg)
|
||||
assert t.max_chars == 160
|
||||
assert t.max_chars == 140
|
||||
|
||||
def test_meshcore_default_is_140(self):
|
||||
cfg = _mc_config()
|
||||
t = MeshCoreTransport(cfg)
|
||||
assert t.max_chars == 140
|
||||
|
||||
def test_meshcore_respects_config_field(self):
|
||||
cfg = _mc_config(meshcore_max_chars=120)
|
||||
t = MeshCoreTransport(cfg)
|
||||
assert t.max_chars == 120
|
||||
|
||||
def test_build_transport_meshtastic_max_chars(self):
|
||||
"""build_transport(meshtastic) → 140 (universal constant)."""
|
||||
t = build_transport(_mt_config())
|
||||
assert t.max_chars == 200
|
||||
assert t.max_chars == 140
|
||||
|
||||
def test_build_transport_meshcore_max_chars(self):
|
||||
t = build_transport(_mc_config())
|
||||
assert t.max_chars == 140
|
||||
|
||||
def test_mesh_max_chars_config_field_governs_all(self):
|
||||
"""A non-default mesh_max_chars propagates to both transport types."""
|
||||
cfg_mt = _mt_config(mesh_max_chars=120)
|
||||
assert MeshtasticTransport(cfg_mt).max_chars == 120
|
||||
|
||||
cfg_mc = _mc_config(mesh_max_chars=120)
|
||||
assert MeshCoreTransport(cfg_mc).max_chars == 120
|
||||
|
||||
def test_all_three_transports_and_composite_report_140(self):
|
||||
"""Sanity: all transports + CompositeTransport report mesh_max_chars=140.
|
||||
|
||||
This is the canonical single-budget guarantee: Meshtastic, MeshCore,
|
||||
and the composite (transport=both) all resolve to the same constant.
|
||||
"""
|
||||
from meshai.config import ConnectionConfig
|
||||
from meshai.transport.composite_transport import CompositeTransport
|
||||
|
||||
# Meshtastic
|
||||
assert MeshtasticTransport(_mt_config()).max_chars == 140
|
||||
|
||||
# MeshCore
|
||||
assert MeshCoreTransport(_mc_config()).max_chars == 140
|
||||
|
||||
# Composite (built via factory with transport="both")
|
||||
cfg_both = ConnectionConfig(
|
||||
transport="both",
|
||||
type="tcp",
|
||||
tcp_host="127.0.0.1",
|
||||
tcp_port=4403,
|
||||
meshcore_host="127.0.0.1",
|
||||
meshcore_port=5050,
|
||||
meshcore_channel_index=0,
|
||||
)
|
||||
comp = build_transport(cfg_both)
|
||||
assert isinstance(comp, CompositeTransport)
|
||||
assert comp.max_chars == 140
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. MeshRenderer char_limit propagation via channels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestChannelRendererBudget:
|
||||
def test_broadcast_channel_with_meshcore_connector_uses_140(self):
|
||||
def test_broadcast_channel_propagates_connector_max_chars(self):
|
||||
"""Channel renderer inherits max_chars from whatever the connector reports."""
|
||||
conn = _fake_connector(140)
|
||||
ch = MeshBroadcastChannel(connector=conn, channel_index=0)
|
||||
assert ch._renderer._limit == 140
|
||||
|
||||
def test_broadcast_channel_with_meshtastic_connector_uses_200(self):
|
||||
conn = _fake_connector(200)
|
||||
def test_broadcast_channel_with_meshtastic_connector_uses_140(self):
|
||||
"""After the universal budget refactor, a meshtastic connector reports 140."""
|
||||
conn = _fake_connector(140)
|
||||
ch = MeshBroadcastChannel(connector=conn, channel_index=0)
|
||||
assert ch._renderer._limit == 200
|
||||
assert ch._renderer._limit == 140
|
||||
|
||||
def test_dm_channel_with_meshcore_connector_uses_140(self):
|
||||
def test_dm_channel_propagates_connector_max_chars(self):
|
||||
conn = _fake_connector(140)
|
||||
ch = MeshDMChannel(connector=conn, node_ids=["!aabbccdd"])
|
||||
assert ch._renderer._limit == 140
|
||||
|
||||
def test_dm_channel_with_meshtastic_connector_uses_200(self):
|
||||
conn = _fake_connector(200)
|
||||
def test_dm_channel_with_meshtastic_connector_uses_140(self):
|
||||
conn = _fake_connector(140)
|
||||
ch = MeshDMChannel(connector=conn, node_ids=["!aabbccdd"])
|
||||
assert ch._renderer._limit == 200
|
||||
assert ch._renderer._limit == 140
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -187,11 +218,12 @@ class TestChannelRendererBudget:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildPipelineMeshCharLimit:
|
||||
def test_no_connector_uses_200(self):
|
||||
def test_no_connector_uses_140(self):
|
||||
"""Without a connector the pipeline falls back to the universal constant (140)."""
|
||||
cfg = _minimal_config()
|
||||
bus = build_pipeline(cfg, llm_backend=None, connector=None)
|
||||
acc = bus._pipeline_components["accumulator"]
|
||||
assert acc._mesh_char_limit == 200
|
||||
assert acc._mesh_char_limit == 140
|
||||
|
||||
def test_meshcore_connector_uses_140(self):
|
||||
cfg = _minimal_config()
|
||||
|
|
@ -200,12 +232,13 @@ class TestBuildPipelineMeshCharLimit:
|
|||
acc = bus._pipeline_components["accumulator"]
|
||||
assert acc._mesh_char_limit == 140
|
||||
|
||||
def test_meshtastic_connector_uses_200(self):
|
||||
def test_meshtastic_connector_uses_140(self):
|
||||
"""After universal budget, a meshtastic connector also reports 140."""
|
||||
cfg = _minimal_config()
|
||||
conn = _fake_connector(200)
|
||||
conn = _fake_connector(140)
|
||||
bus = build_pipeline(cfg, llm_backend=None, connector=conn)
|
||||
acc = bus._pipeline_components["accumulator"]
|
||||
assert acc._mesh_char_limit == 200
|
||||
assert acc._mesh_char_limit == 140
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue