refactor(transport): derive active transports from config, drop transport setting (#13)

* refactor(transport): derive active transports from config, drop transport setting

A mesh is active when its connection is configured: Meshtastic is the
always-on base; MeshCore runs whenever meshcore_host is set (blank = off);
both configured = both. Removes the transport mode field/toggle entirely
so there's no separate flag to miss.

* docs: fix stale transport comment after field removal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-03 00:18:53 -06:00 committed by GitHub
commit 47b56adab5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 128 additions and 144 deletions

View file

@ -142,7 +142,7 @@ class TestMaxChars:
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)
cfg = ConnectionConfig(mesh_max_chars=140)
mt = FakeChild("meshtastic", max_chars_val=200)
mc = FakeChild("meshcore", max_chars_val=140)
comp = CompositeTransport([mt, mc], config=cfg)
@ -151,7 +151,7 @@ class TestMaxChars:
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)
cfg = ConnectionConfig(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)

View file

@ -94,9 +94,8 @@ from meshai.transport.meshcore_transport import MeshCoreTransport # noqa: E402
# ---------------------------------------------------------------------------
def _mc_config(**overrides):
"""Return a ConnectionConfig wired for meshcore."""
"""Return a ConnectionConfig with meshcore_host set (activates MeshCore)."""
cfg = ConnectionConfig(
transport="meshcore",
meshcore_host="127.0.0.1",
meshcore_port=5050,
)
@ -186,11 +185,16 @@ def _install_channel_table(mc, table=None):
# ---------------------------------------------------------------------------
class TestBuildTransport:
def test_returns_meshcore_transport(self):
def test_returns_composite_when_meshcore_host_set(self):
"""meshcore_host set → build_transport returns CompositeTransport."""
from meshai.transport.composite_transport import CompositeTransport
t = build_transport(_mc_config())
assert isinstance(t, MeshCoreTransport)
assert isinstance(t, CompositeTransport)
# MeshCoreTransport must be the second child.
assert isinstance(t.children[1], MeshCoreTransport)
def test_is_mesh_transport_subclass(self):
"""CompositeTransport (returned when meshcore_host is set) is a MeshTransport."""
t = build_transport(_mc_config())
assert isinstance(t, MeshTransport)

View file

@ -1,10 +1,9 @@
"""Lightweight tests for the Phase-1 MeshTransport abstraction.
"""Tests for the MeshTransport abstraction and derive-from-config factory.
These tests exercise the structural contracts introduced by the refactor:
These tests exercise:
- MeshtasticTransport is a concrete subclass of MeshTransport
- build_transport returns the right type for the default config
- build_transport raises appropriately for unimplemented / unknown transports
- MeshMessage has the new additive fields with the expected defaults
- build_transport derives active transports from meshcore_host, not a flag
- MeshMessage has the expected transport-routing fields with correct defaults
No real radio, socket, or asyncio loop is required.
"""
@ -47,50 +46,78 @@ class TestMeshtasticTransportABC:
# ---------------------------------------------------------------------------
# Factory
# Factory — transports derived from meshcore_host, not a transport flag
# ---------------------------------------------------------------------------
class TestBuildTransport:
def _default_config(self):
return ConnectionConfig() # transport defaults to "meshtastic"
def _config_no_meshcore(self):
"""Default config: meshcore_host is blank → Meshtastic only."""
return ConnectionConfig()
def _config_with(self, transport_name):
def _config_with_meshcore(self, host="1.2.3.4"):
"""Config with a non-empty meshcore_host → composite."""
cfg = ConnectionConfig()
cfg.transport = transport_name
cfg.meshcore_host = host
return cfg
def test_default_config_returns_meshtastic_transport(self):
cfg = self._default_config()
def test_blank_host_returns_meshtastic_only(self):
"""Empty meshcore_host → MeshtasticTransport, no composite."""
cfg = self._config_no_meshcore()
assert cfg.meshcore_host == ""
transport = build_transport(cfg)
assert isinstance(transport, MeshtasticTransport)
def test_explicit_meshtastic_returns_meshtastic_transport(self):
cfg = self._config_with("meshtastic")
transport = build_transport(cfg)
assert isinstance(transport, MeshtasticTransport)
def test_meshcore_returns_meshcore_transport(self):
# Phase 2: meshcore is now implemented; build_transport returns a
# MeshCoreTransport instance (MeshTransport subclass).
from meshai.transport.meshcore_transport import MeshCoreTransport
cfg = self._config_with("meshcore")
transport = build_transport(cfg)
assert isinstance(transport, MeshCoreTransport)
assert isinstance(transport, MeshTransport)
def test_both_returns_composite(self):
"""Phase 4: transport='both' now returns a CompositeTransport (seam filled)."""
# Must NOT be a composite when MeshCore is not configured.
from meshai.transport.composite_transport import CompositeTransport
cfg = self._config_with("both")
assert not isinstance(transport, CompositeTransport)
def test_meshcore_host_set_returns_composite(self):
"""Non-empty meshcore_host → CompositeTransport with both children."""
from meshai.transport.composite_transport import CompositeTransport
from meshai.transport.meshcore_transport import MeshCoreTransport
cfg = self._config_with_meshcore("1.2.3.4")
t = build_transport(cfg)
assert isinstance(t, CompositeTransport)
assert len(t.children) == 2
assert isinstance(t.children[0], MeshtasticTransport)
assert isinstance(t.children[1], MeshCoreTransport)
def test_unknown_transport_raises_value_error(self):
cfg = self._config_with("unknown_transport_xyz")
with pytest.raises(ValueError):
build_transport(cfg)
def test_whitespace_only_host_is_treated_as_blank(self):
"""Whitespace-only meshcore_host is treated as blank → Meshtastic only."""
cfg = ConnectionConfig()
cfg.meshcore_host = " "
transport = build_transport(cfg)
assert isinstance(transport, MeshtasticTransport)
from meshai.transport.composite_transport import CompositeTransport
assert not isinstance(transport, CompositeTransport)
def test_config_has_no_transport_field(self):
"""ConnectionConfig must not have a transport attribute after the refactor."""
cfg = ConnectionConfig()
assert not hasattr(cfg, "transport"), (
"ConnectionConfig.transport was not removed; the refactor is incomplete."
)
def test_stray_transport_key_in_yaml_loads_without_error(self):
"""A config dict with a stale 'transport' key must load cleanly."""
from meshai.config import _dict_to_dataclass, ConnectionConfig as CC
raw = {
"type": "tcp",
"tcp_host": "10.0.0.1",
"transport": "both", # stale key — must be ignored
"meshcore_host": "192.168.1.253",
}
cfg = _dict_to_dataclass(CC, raw)
# The stale key must be silently dropped; no AttributeError.
assert not hasattr(cfg, "transport")
assert cfg.meshcore_host == "192.168.1.253"
def test_serialized_config_has_no_transport_field(self):
"""_dataclass_to_dict must not emit a 'transport' key."""
from meshai.config import _dataclass_to_dict
cfg = ConnectionConfig()
d = _dataclass_to_dict(cfg)
assert "transport" not in d
# ---------------------------------------------------------------------------

View file

@ -89,17 +89,16 @@ from meshai.notifications.pipeline import build_pipeline # noqa: E402
# ---------------------------------------------------------------------------
def _mt_config(**overrides):
"""Return a ConnectionConfig wired for meshtastic (default)."""
cfg = ConnectionConfig(transport="meshtastic")
"""Return a ConnectionConfig with no MeshCore host (Meshtastic-only)."""
cfg = ConnectionConfig() # meshcore_host defaults to "" → Meshtastic only
for k, v in overrides.items():
setattr(cfg, k, v)
return cfg
def _mc_config(**overrides):
"""Return a ConnectionConfig wired for meshcore."""
"""Return a ConnectionConfig with meshcore_host set (activates MeshCore)."""
cfg = ConnectionConfig(
transport="meshcore",
meshcore_host="127.0.0.1",
meshcore_port=5050,
)
@ -158,7 +157,7 @@ class TestTransportMaxChars:
"""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.
and the composite (meshcore_host set) all resolve to the same constant.
"""
from meshai.config import ConnectionConfig
from meshai.transport.composite_transport import CompositeTransport
@ -169,9 +168,8 @@ class TestTransportMaxChars:
# MeshCore
assert MeshCoreTransport(_mc_config()).max_chars == 140
# Composite (built via factory with transport="both")
# Composite — derived from meshcore_host being non-empty
cfg_both = ConnectionConfig(
transport="both",
type="tcp",
tcp_host="127.0.0.1",
tcp_port=4403,