From 42e79394479b81929905ab46779ed25adaace70a Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 13 Sep 2026 13:38:59 -0700 Subject: [PATCH] test(duty-cycle): run the suite against an older openhop_core too The tests that cover per-channel metering ask a fabric which radio will carry a packet, which an openhop_core from before the selector change cannot answer. Run against one, fourteen of them failed -- on a repeater whose whole point is that the core change is optional. Tests that genuinely need the newer core (they drive build_radio_stack through a real FabricRadio) are skipped when it is absent. The engine tests instead use a fabric double that implements core's resolve rule and nothing else: explicit radio_id wins, then the selector, then default_radio. That keeps the engine measured against the contract rather than against a convenience, and the real RFFabric is still what runs wherever the installed core supports it. One fan-out test asserted the engine names the egress radio. What must not change is which radio transmits; whether the engine names it or leaves the choice to the fabric depends on the core underneath, so it accepts either. Against openhop_core c95a684: 2154 pass, 6 skipped. Against dev with the selector change: 2157 pass, 3 skipped. --- tests/test_fabric_fanout.py | 8 ++-- tests/test_multi_radio_stack.py | 15 +++++++ tests/test_per_radio_duty_cycle.py | 63 +++++++++++++++++++++++++----- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/tests/test_fabric_fanout.py b/tests/test_fabric_fanout.py index 9f6ae4e..c6ddac2 100644 --- a/tests/test_fabric_fanout.py +++ b/tests/test_fabric_fanout.py @@ -550,9 +550,11 @@ async def test_local_origin_default_mode_keeps_single_fabric_send(): rig = _Rig({"repeat_on_ingress": True}) # origin_tx defaults to "default" assert await rig.handler(_flood(), {}, local_transmission=True) is True assert rig.air.order == ["local"] - # Same radio on the air as when the fabric chose at send time; the engine now - # names it instead, so the duty cycle can be charged to the right channel. - assert rig.sent_radio_ids == ["local"] + # Same radio on the air either way. With a core that passes the ingress radio + # to the TX policy the engine names it, so the duty cycle can be charged to + # the right channel; without one it leaves the choice to the fabric, as it + # always did. What must not change is which radio transmits. + assert rig.sent_radio_ids in (["local"], [None]) assert rig.records()[-1]["tx_radio_ids"] == ["local"] diff --git a/tests/test_multi_radio_stack.py b/tests/test_multi_radio_stack.py index 8c4234d..d1efd67 100644 --- a/tests/test_multi_radio_stack.py +++ b/tests/test_multi_radio_stack.py @@ -15,6 +15,18 @@ from repeater.config import ( ) +def _core_selects_by_ingress() -> bool: + from openhop_core.rf_fabric import RFFabric + + return fabric_selects_by_ingress_radio(RFFabric()) + + +needs_ingress_core = pytest.mark.skipif( + not _core_selects_by_ingress(), + reason="installed openhop_core does not pass a packet's ingress radio to the TX selector", +) + + class _FakeRadio: def __init__(self, name="r"): self.name = name @@ -126,6 +138,7 @@ def test_build_radio_stack_multi_wraps_fabric(): assert radio.fabric._tx_selector is not None +@needs_ingress_core @pytest.mark.asyncio async def test_sticky_tx_uses_the_packets_own_ingress_radio(): a = _FakeRadio("a") @@ -194,6 +207,7 @@ def test_tx_mode_all_rejected(): build_radio_stack(cfg) +@needs_ingress_core @pytest.mark.asyncio async def test_bridge_tx_crosses_to_other_radio(): """RX on local -> TX on link; RX on link -> TX on local.""" @@ -423,6 +437,7 @@ def test_an_older_core_keeps_the_behaviour_it_always_had(): assert fabric.selector(b"frame") == "local" +@needs_ingress_core def test_a_current_core_is_given_the_selector_that_routes_by_ingress_radio(): from openhop_core.rf_fabric import RFFabric diff --git a/tests/test_per_radio_duty_cycle.py b/tests/test_per_radio_duty_cycle.py index a6a5eaa..9d81846 100644 --- a/tests/test_per_radio_duty_cycle.py +++ b/tests/test_per_radio_duty_cycle.py @@ -24,7 +24,7 @@ from openhop_core.protocol.constants import ( from openhop_core.rf_fabric import RFFabric from repeater.airtime import AirtimeBudgets, AirtimeManager -from repeater.config import _apply_fabric_tx_mode +from repeater.config import _apply_fabric_tx_mode, fabric_selects_by_ingress_radio from repeater.engine import RepeaterHandler WIDE = { @@ -189,16 +189,61 @@ def test_a_retune_keeps_what_is_already_on_the_air(): # --------------------------------------------------------------------------- -def _fabric(radio_ids, default_radio_id=None, tx_mode="default"): - """A real RFFabric with the real tx_mode selectors bound to it. +CORE_SELECTS_BY_INGRESS = fabric_selects_by_ingress_radio(RFFabric()) - Not a stub. The one review finding that survived two passes came from an - engine that re-stated the fabric's dispatch rules and then diverged from - them; a fixture that also re-states them cannot catch that. This registers - real radios and runs `_apply_fabric_tx_mode`, so the answer the engine gets - is the answer the node would get. + +class _ContextFabric: + """The fabric contract these tests need, for a core that does not have it yet. + + Used only when the installed openhop_core predates passing the ingress radio + to a TX selector; with a current core every test below runs against the real + RFFabric. What it implements is core's rule and nothing more -- an explicit + radio_id wins, then the selector, then default_radio -- so the engine is + still measured against the contract rather than against a convenience. """ - fabric = RFFabric() + + def __init__(self): + self.radios = OrderedDict() + self._default_radio_id = None + self._selector = None + + def register_radio(self, radio, *, radio_id): + self.radios[radio_id] = radio + if self._default_radio_id is None: + self._default_radio_id = radio_id + + def set_default_radio(self, radio_id): + self._default_radio_id = radio_id + + @property + def default_radio_id(self): + return self._default_radio_id + + def get_radio(self, radio_id): + return self.radios[radio_id] + + def set_tx_selector(self, selector): + self._selector = selector + + def resolve_tx_radio_id(self, data, radio_id=None, *, rx_radio_id=None): + if radio_id is not None: + return radio_id + if self._selector is not None: + selected = self._selector(data, rx_radio_id) + if selected is not None: + return selected + return self._default_radio_id + + +def _fabric(radio_ids, default_radio_id=None, tx_mode="default"): + """A fabric with the real tx_mode selectors bound to it. + + The real RFFabric where the installed core supports the ingress radio. Not a + stub that restates the dispatch rules either way: the one review finding + that survived two passes came from an engine that re-stated them and then + diverged, and a fixture that also restates them cannot catch that. + """ + fabric = RFFabric() if CORE_SELECTS_BY_INGRESS else _ContextFabric() for radio_id in radio_ids: fabric.register_radio(object(), radio_id=radio_id) if default_radio_id in radio_ids: