From 2ebb652d9a28e687faa83b9a13e505897aeb9355 Mon Sep 17 00:00:00 2001 From: axodentally <24368475+Axolord@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:46:53 +0000 Subject: [PATCH 1/5] chore: add pytest dependency --- LICENSES/pytest/LICENSE | 21 +++++++++++++++++++++ requirements.txt | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 LICENSES/pytest/LICENSE diff --git a/LICENSES/pytest/LICENSE b/LICENSES/pytest/LICENSE new file mode 100644 index 0000000..c3f1657 --- /dev/null +++ b/LICENSES/pytest/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2004 Holger Krekel and others + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/requirements.txt b/requirements.txt index 91140b0..c5b7268 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ networkx==3.6.1 nicegui==3.13.0 pyaml==26.2.1 python-dateutil==2.9.0.post0 +pytest==9.1.1 + From eb9081a0f8aa459a86c6430f44ba95a2eb676904 Mon Sep 17 00:00:00 2001 From: axodentally <24368475+Axolord@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:47:11 +0000 Subject: [PATCH 2/5] tests: add cases for ccp.py --- tests/test_ccp.py | 408 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 tests/test_ccp.py diff --git a/tests/test_ccp.py b/tests/test_ccp.py new file mode 100644 index 0000000..4eb17eb --- /dev/null +++ b/tests/test_ccp.py @@ -0,0 +1,408 @@ +"""Tests for CCP parsing and resolution.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tools.contact_player.ccp import ( + Contact, + ContactPlan, + ContactState, + LinkProperties, + RawCcpContact, + RawCcpContactPlan, + _resolve_contacts, + _resolve_destination, + _resolve_node, +) +from tools.contact_player.scenario import NetworkInterface, Node + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- +@pytest.fixture +def sample_nodes() -> dict[str, Node]: + """Two nodes sharing one network: n1 and n2 on n1_n2.""" + return { + "n1": Node("n1", "1", "ipn:1.0"), + "n2": Node("n2", "2", "ipn:2.0"), + } + + +@pytest.fixture +def multi_net_nodes() -> dict[str, Node]: + """Three nodes with pairwise shared networks.""" + return { + "n1": Node( + "n1", + "1", + "ipn:1.0", + interfaces={ + "n1_n2": NetworkInterface("10.0.0.1", "n1_n2_0"), + "n1_n3": NetworkInterface("10.0.1.1", "n1_n3_0"), + }, + ), + "n2": Node( + "n2", + "2", + "ipn:2.0", + interfaces={ + "n1_n2": NetworkInterface("10.0.0.2", "n1_n2_0"), + "n2_n3": NetworkInterface("10.0.2.1", "n2_n3_0"), + }, + ), + "n3": Node( + "n3", + "3", + "ipn:3.0", + interfaces={ + "n1_n3": NetworkInterface("10.0.1.2", "n1_n3_0"), + "n2_n3": NetworkInterface("10.0.2.2", "n2_n3_0"), + }, + ), + } + + +# =================================================================== +# RawCcpContact.from_string +# =================================================================== +class TestRawCcpContactFromString: + def test_valid_contact_line(self) -> None: + r = RawCcpContact.from_string("a contact +20 +40 n2 n3 2mbit 0.0 10 0.0") + assert r.begin == 20 + assert r.end == 40 + assert r.src == "n2" + assert r.dst == "n3" + assert r.props == LinkProperties("2mbit", 0.0, 10.0, 0.0) + assert not r.symmetric + + def test_valid_fixed_line(self) -> None: + r = RawCcpContact.from_string("a fixed n1 n2 100mbit 0.0 100 0.0") + assert r.begin == 0 + assert r.end == -1 + assert r.src == "n1" + assert r.dst == "n2" + assert r.props == LinkProperties("100mbit", 0.0, 100.0, 0.0) + assert not r.symmetric + + def test_contact_with_symmetric_suffix(self) -> None: + r = RawCcpContact.from_string("a contact 0 10 n1 n2 1mbit 0 0 0 =") + assert r.begin == 0 + assert r.end == 10 + assert r.symmetric is True + + def test_fixed_with_symmetric_suffix(self) -> None: + r = RawCcpContact.from_string("a fixed n1 n2 1mbit 0 0 0 =") + assert r.begin == 0 + assert r.end == -1 + assert r.symmetric is True + + def test_no_symmetric_equals(self) -> None: + r = RawCcpContact.from_string("a contact 0 10 n1 n2 1mbit 0 0 0") + assert r.symmetric is False + + def test_contact_too_few_fields(self) -> None: + with pytest.raises(ValueError, match="Invalid Contact line"): + RawCcpContact.from_string("a contact 0 10 n1 n2 1mbit") + + def test_contact_too_many_fields(self) -> None: + with pytest.raises(ValueError, match="Invalid Contact line"): + RawCcpContact.from_string("a contact 0 10 n1 n2 1mbit 0 0 0 = extra") + + def test_fixed_too_few_fields(self) -> None: + with pytest.raises(ValueError, match="Invalid Fixed Link line"): + RawCcpContact.from_string("a fixed n1 n2 1mbit 0 0") + + def test_fixed_too_many_fields(self) -> None: + with pytest.raises(ValueError, match="Invalid Fixed Link line"): + RawCcpContact.from_string("a fixed n1 n2 1mbit 0 0 0 = extra") + + def test_bad_prefix(self) -> None: + with pytest.raises(ValueError, match="Invalid CoreContact line"): + RawCcpContact.from_string("a foo n1 n2 1mbit 0 0 0") + + def test_props_as_float(self) -> None: + r = RawCcpContact.from_string("a contact 0 10 n1 n2 1mbit 0 5 2") + assert r.props.loss == 0.0 + assert r.props.delay == 5.0 + assert r.props.jitter == 2.0 + + +# =================================================================== +# RawCcpContactPlan.from_file +# =================================================================== +SIMPLE_CCP = """s loop 1 + +a fixed n1 n2 100mbit 0.0 100 0.0 +a fixed n2 n1 100mbit 0.0 100 0.0 + +a contact 20 40 n2 n3 2mbit 0.0 10 0.0 +a contact 20 40 n3 n2 2mbit 0.0 10 0.0 +""" + + +class TestRawCcpContactPlanFromFile: + def test_full_file(self, tmp_path: Path) -> None: + path = tmp_path / "test.ccp" + path.write_text(SIMPLE_CCP) + plan = RawCcpContactPlan.from_file(path) + assert len(plan.contacts) == 2 + assert len(plan.fixed_contacts) == 2 + assert plan.loop is True + # Spot-check first contact + c = plan.contacts[0] + assert c.src == "n2" + assert c.dst == "n3" + assert c.begin == 20 + assert c.end == 40 + assert c.props.bandwidth == "2mbit" + + def test_loop_false(self, tmp_path: Path) -> None: + path = tmp_path / "test.ccp" + path.write_text("s loop 0\n") + plan = RawCcpContactPlan.from_file(path) + assert plan.loop is False + + def test_loop_true(self, tmp_path: Path) -> None: + path = tmp_path / "test.ccp" + path.write_text("s loop 1\n") + plan = RawCcpContactPlan.from_file(path) + assert plan.loop is True + + def test_unknown_record_type(self, tmp_path: Path) -> None: + path = tmp_path / "test.ccp" + path.write_text("a foo n1 n2 1mbit 0 0 0\n") + with pytest.raises(ValueError) as exc_info: + RawCcpContactPlan.from_file(path) + assert "line 1" in str(exc_info.value) + + def test_malformed_line_with_line_number(self, tmp_path: Path) -> None: + path = tmp_path / "test.ccp" + path.write_text("# comment\n\ns loop 1\n\na contact 0 10 n1 n2 1mbit\n") + with pytest.raises(ValueError) as exc_info: + RawCcpContactPlan.from_file(path) + msg = str(exc_info.value) + assert "line 5" in msg + assert "a contact 0 10 n1 n2 1mbit" in msg + + def test_empty_file(self, tmp_path: Path) -> None: + path = tmp_path / "test.ccp" + path.write_text("") + plan = RawCcpContactPlan.from_file(path) + assert plan.contacts == [] + assert plan.fixed_contacts == [] + assert plan.loop is False + + def test_comment_only_file(self, tmp_path: Path) -> None: + path = tmp_path / "test.ccp" + path.write_text("# just a comment\n# another one\n") + plan = RawCcpContactPlan.from_file(path) + assert plan.contacts == [] + assert plan.fixed_contacts == [] + assert plan.loop is False + + +# =================================================================== +# _resolve_node +# =================================================================== +class TestResolveNode: + def test_by_name(self, sample_nodes: dict[str, Node]) -> None: + assert _resolve_node("n1", sample_nodes) is sample_nodes["n1"] + + def test_by_id(self, sample_nodes: dict[str, Node]) -> None: + assert _resolve_node("1", sample_nodes) is sample_nodes["n1"] + + def test_unknown_node(self, sample_nodes: dict[str, Node]) -> None: + with pytest.raises(ValueError, match="Could not resolve node"): + _resolve_node("nope", sample_nodes) + + +# =================================================================== +# _resolve_destination +# =================================================================== +class TestResolveDestination: + def test_single_shared_net(self, multi_net_nodes: dict[str, Node]) -> None: + n1 = multi_net_nodes["n1"] + dst, net = _resolve_destination(n1, "n2", multi_net_nodes) + assert dst is multi_net_nodes["n2"] + assert net == "n1_n2" + + def test_no_shared_net(self) -> None: + # Remove the network they share + n1 = Node( + "n1", "1", "ipn:1.0", interfaces={"netA": NetworkInterface("10.0.0.1")} + ) + n2 = Node( + "n2", "2", "ipn:2.0", interfaces={"netB": NetworkInterface("10.0.0.2")} + ) + nodes = {"n1": n1, "n2": n2} + with pytest.raises(ValueError, match="No shared network"): + _resolve_destination(n1, "n2", nodes) + + def test_multiple_shared_nets(self) -> None: + n1 = Node( + "n1", + "1", + "ipn:1.0", + interfaces={ + "netA": NetworkInterface("10.0.0.1"), + "netB": NetworkInterface("10.0.1.1"), + }, + ) + n2 = Node( + "n2", + "2", + "ipn:2.0", + interfaces={ + "netA": NetworkInterface("10.0.0.2"), + "netB": NetworkInterface("10.0.1.2"), + }, + ) + nodes = {"n1": n1, "n2": n2} + with pytest.raises(ValueError, match="Ambiguous: multiple networks"): + _resolve_destination(n1, "n2", nodes) + + def test_dev_syntax(self, multi_net_nodes: dict[str, Node]) -> None: + n1 = multi_net_nodes["n1"] + # n1 has interface with dev="n1_n2_0" on network "n1_n2" + dst, net = _resolve_destination(n1, "dev:n1_n2", multi_net_nodes) + assert dst is multi_net_nodes["n2"] + assert net == "n1_n2" + + def test_dev_no_matching_interface(self, multi_net_nodes: dict[str, Node]) -> None: + n1 = multi_net_nodes["n1"] + with pytest.raises(ValueError, match="No interface"): + _resolve_destination(n1, "dev:nope", multi_net_nodes) + + def test_dev_ambiguous_multiple_peers( + self, + ) -> None: + # Put n1, n2, n3 all on a "shared" network + n1 = Node( + "n1", + "1", + "ipn:1.0", + interfaces={"shared": NetworkInterface("10.0.0.1", "shared_0")}, + ) + n2 = Node( + "n2", + "2", + "ipn:2.0", + interfaces={"shared": NetworkInterface("10.0.0.2", "shared_0")}, + ) + n3 = Node( + "n3", + "3", + "ipn:3.0", + interfaces={"shared": NetworkInterface("10.0.0.3", "shared_0")}, + ) + nodes = {"n1": n1, "n2": n2, "n3": n3} + with pytest.raises(ValueError, match="Ambiguous: multiple peers on network"): + _resolve_destination(n1, "dev:shared", nodes) + + +# =================================================================== +# _resolve_contacts / ContactPlan.from_ccp_file +# =================================================================== +class TestResolveContacts: + def test_asymmetric_contact_one_directed( + self, multi_net_nodes: dict[str, Node] + ) -> None: + raw = [ + RawCcpContact( + "n1", "n2", 10, 20, LinkProperties("1mbit", 0, 0, 0), symmetric=False + ), + ] + + contacts = _resolve_contacts(raw, multi_net_nodes) + assert len(contacts) == 1 + c = contacts[0] + assert c.src is multi_net_nodes["n1"] + assert c.dst is multi_net_nodes["n2"] + assert c.network == "n1_n2" + assert c.begin == 10 + assert c.end == 20 + + def test_symmetric_contact_two_directed( + self, multi_net_nodes: dict[str, Node] + ) -> None: + raw = [ + RawCcpContact( + "n1", "n2", 10, 20, LinkProperties("1mbit", 0, 0, 0), symmetric=True + ), + ] + + contacts = _resolve_contacts(raw, multi_net_nodes) + assert len(contacts) == 2 + src_set = {c.src for c in contacts} + dst_set = {c.dst for c in contacts} + assert src_set == {multi_net_nodes["n1"], multi_net_nodes["n2"]} + assert dst_set == {multi_net_nodes["n2"], multi_net_nodes["n1"]} + for c in contacts: + assert c.network == "n1_n2" + + def test_props_match_raw(self, multi_net_nodes: dict[str, Node]) -> None: + props = LinkProperties("5mbit", 1.5, 30.0, 2.5) + raw = [RawCcpContact("n1", "n2", 0, 100, props, symmetric=False)] + + contacts = _resolve_contacts(raw, multi_net_nodes) + assert contacts[0].props == props + + def test_from_ccp_file_merges_fixed_and_contacts( + self, multi_net_nodes: dict[str, Node], tmp_path: Path + ) -> None: + ccp = """s loop 0 +a fixed n1 n2 1mbit 0 0 0 +a contact 10 20 n2 n3 2mbit 0 5 0 +""" + path = tmp_path / "test.ccp" + path.write_text(ccp) + plan = ContactPlan.from_ccp_file(path, multi_net_nodes) + # 1 fixed + 1 contact = 2 raw, but fixed n1→n2 + contact n2→n3 = 2 resolved + assert len(plan.contacts) == 2 + # Both should be INACTIVE initially + assert all(s == ContactState.INACTIVE for s in plan.contacts.values()) + + def test_from_ccp_file_carries_loop( + self, multi_net_nodes: dict[str, Node], tmp_path: Path + ) -> None: + ccp = "s loop 1\na fixed n1 n2 1mbit 0 0 0\n" + path = tmp_path / "test.ccp" + path.write_text(ccp) + plan = ContactPlan.from_ccp_file(path, multi_net_nodes) + assert plan.loop is True + + +# =================================================================== +# Contact.is_active +# =================================================================== +class TestContactIsActive: + def _make_contact(self, begin: int, end: int) -> Contact: + return Contact( + src=Node("s", "1", "ipn:1.0"), + dst=Node("d", "2", "ipn:2.0"), + network="net", + begin=begin, + end=end, + props=LinkProperties("1mbit", 0, 0, 0), + ) + + def test_active_during_interval(self) -> None: + c = self._make_contact(10, 20) + assert c.is_active(10) is True + assert c.is_active(15) is True + assert c.is_active(19) is True + + def test_inactive_before_and_after(self) -> None: + c = self._make_contact(10, 20) + assert c.is_active(9) is False + assert c.is_active(20) is False + + def test_fixed_always_active(self) -> None: + c = self._make_contact(0, -1) + assert c.is_active(0) is True + assert c.is_active(100) is True + assert c.is_active(1000000) is True From f8fe0b32c45e853dc4646a9e3ce408cfe5a939ea Mon Sep 17 00:00:00 2001 From: axodentally <24368475+Axolord@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:47:24 +0000 Subject: [PATCH 3/5] tests: add cases for contact_player.py --- tests/test_contact_player.py | 483 +++++++++++++++++++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 tests/test_contact_player.py diff --git a/tests/test_contact_player.py b/tests/test_contact_player.py new file mode 100644 index 0000000..eb313f0 --- /dev/null +++ b/tests/test_contact_player.py @@ -0,0 +1,483 @@ +"""Tests for the ContactPlayer lifecycle and link-set logic. + +These tests target the current ``ContactPlayer`` implementation in +``tools/contact_player/contact_player.py``. Socket-bound players are always +created with ``CONTROL_PORT=0`` (ephemeral) and closed afterwards via the +:func:`player_scope` context manager to avoid port-9966 collisions and socket +leaks between tests. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path + +import pytest + +from tools.contact_player.ccp import ( + Contact, + ContactPlan, + ContactState, + LinkProperties, +) +from tools.contact_player.contact_player import ContactPlayer +from tools.contact_player.scenario import NetworkInterface, Node + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _make_node(name: str, node_id: str, nets: list[str]) -> Node: + """Build a Node with one interface per network, dev = '{net}_0'.""" + idx = int(node_id) + interfaces: dict[str, NetworkInterface] = {} + for j, net in enumerate(nets): + interfaces[net] = NetworkInterface( + ip=f"10.{idx}.{j}.1", + dev=f"{net}_0", + ) + return Node(name, node_id, f"ipn:{node_id}.0", interfaces=interfaces) + + +def _make_plan(contacts: list[Contact], loop: bool = False) -> ContactPlan: + """Build a ContactPlan with given contacts all initially INACTIVE.""" + return ContactPlan( + ccp_path=Path("."), + contacts={c: ContactState.INACTIVE for c in contacts}, + loop=loop, + ) + + +@contextmanager +def player_scope(player: ContactPlayer): + """Yield a player and guarantee its socket is closed afterwards.""" + try: + yield player + finally: + player.sock.close() + + +class _CallRecorder: + """Record every ``set_on_interface`` call as ``(args, kwargs)`` tuples.""" + + def __init__(self) -> None: + self.calls: list[tuple[tuple[str, ...], dict[str, object]]] = [] + + def __call__(self, *args: str, **kwargs: object) -> str: + self.calls.append((args, kwargs)) + return "ok" + + def reset(self) -> None: + self.calls.clear() + + +# =================================================================== +# unique_interfaces +# =================================================================== +class TestUniqueInterfaces: + def test_dedup_same_src_and_network(self) -> None: + n1 = _make_node("n1", "1", ["net"]) + n2 = _make_node("n2", "2", ["net"]) + n3 = _make_node("n3", "3", ["net"]) + nodes = {"n1": n1, "n2": n2, "n3": n3} + + # Two contacts from n1 on the same network must dedup to one interface + c1 = Contact(n1, n2, "net", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + c2 = Contact(n1, n3, "net", 30, 40, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([c1, c2]) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + assert player.unique_interfaces == {(n1, "net_0")} + + def test_multiple_networks_multiple_entries(self) -> None: + n1 = _make_node("n1", "1", ["netA", "netB"]) + n2 = _make_node("n2", "2", ["netA", "netB"]) + nodes = {"n1": n1, "n2": n2} + + ca = Contact(n1, n2, "netA", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + cb = Contact(n1, n2, "netB", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([ca, cb]) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + assert player.unique_interfaces == { + (n1, "netA_0"), + (n1, "netB_0"), + } + + +# =================================================================== +# static_links / active_dynamic_links +# =================================================================== +class TestLinkSets: + def test_compose_link_no_contact(self) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2", "n2_n3"]) + n3 = _make_node("n3", "3", ["n2_n3"]) + nodes = {"n1": n1, "n2": n2, "n3": n3} + + contact = Contact(n1, n2, "n1_n2", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([contact]) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + # n2-n3 is a physical link with no contact -> static + assert frozenset({n2, n3}) in player.static_links + # n1-n2 has a dynamic contact -> not static + assert frozenset({n1, n2}) not in player.static_links + assert player.active_dynamic_links == set() + + def test_fixed_link_in_static_not_dynamic(self) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2"]) + nodes = {"n1": n1, "n2": n2} + + fixed = Contact(n1, n2, "n1_n2", 0, -1, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([fixed]) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + assert frozenset({n1, n2}) in player.static_links + assert frozenset({n1, n2}) not in player.active_dynamic_links + + def test_dynamic_contact_lifecycle(self) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2", "n2_n3"]) + n3 = _make_node("n3", "3", ["n2_n3"]) + nodes = {"n1": n1, "n2": n2, "n3": n3} + + contact = Contact(n2, n3, "n2_n3", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([contact]) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + pair = frozenset({n2, n3}) + # Initially inactive + assert pair not in player.static_links + assert pair not in player.active_dynamic_links + + # Activate + player.plan.contacts[contact] = ContactState.ACTIVE + assert pair in player.active_dynamic_links + assert pair not in player.static_links + + # Deactivate + player.plan.contacts[contact] = ContactState.INACTIVE + assert pair not in player.active_dynamic_links + assert pair not in player.static_links + + def test_pair_with_both_fixed_and_dynamic(self) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2"]) + nodes = {"n1": n1, "n2": n2} + + fixed = Contact(n1, n2, "n1_n2", 0, -1, LinkProperties("1mbit", 0, 0, 0)) + dynamic = Contact(n1, n2, "n1_n2", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([fixed, dynamic]) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + pair = frozenset({n1, n2}) + # Dynamic contact makes the pair a dynamic link (even if inactive) + assert pair not in player.static_links + assert pair not in player.active_dynamic_links + + def test_one_directional_dynamic_removes_pair(self) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2", "n2_n3"]) + n3 = _make_node("n3", "3", ["n2_n3"]) + nodes = {"n1": n1, "n2": n2, "n3": n3} + + # Only one directed contact n1->n2 (not symmetric) + contact = Contact(n1, n2, "n1_n2", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([contact]) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + pair = frozenset({n1, n2}) + # The undirected pair counts as dynamic + assert pair not in player.static_links + assert pair not in player.active_dynamic_links # inactive + + +# =================================================================== +# setup() +# =================================================================== +class TestSetup: + def test_one_add_per_unique_interface( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2", "n2_n3"]) + n3 = _make_node("n3", "3", ["n2_n3"]) + nodes = {"n1": n1, "n2": n2, "n3": n3} + + fixed = Contact(n1, n2, "n1_n2", 0, -1, LinkProperties("100mbit", 0, 0, 0)) + dynamic = Contact(n2, n3, "n2_n3", 20, 40, LinkProperties("2mbit", 0, 0, 0)) + plan = _make_plan([fixed, dynamic]) + + recorder = _CallRecorder() + monkeypatch.setattr( + "tools.contact_player.contact_player.set_on_interface", recorder + ) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + player.setup() + + # One "add" call per unique (node, dev) + assert len(recorder.calls) == 2 + seen: set[tuple[str, str]] = set() + for args, kwargs in recorder.calls: + assert args[0] in ("n1", "n2") + assert args[2] == "add" + assert kwargs.get("loss") == 100 + seen.add((args[0], args[1])) + assert seen == {("n1", "n1_n2_0"), ("n2", "n2_n3_0")} + + def test_same_network_two_contacts_one_add( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2"]) + nodes = {"n1": n1, "n2": n2} + + c1 = Contact(n1, n2, "n1_n2", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + c2 = Contact(n1, n2, "n1_n2", 30, 40, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([c1, c2]) + + recorder = _CallRecorder() + monkeypatch.setattr( + "tools.contact_player.contact_player.set_on_interface", recorder + ) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + player.setup() + # Same network -> one deduplicated add + assert len(recorder.calls) == 1 + args, kwargs = recorder.calls[0] + assert args == ("n1", "n1_n2_0", "add") + assert kwargs == {"loss": 100} + + def test_symmetric_contact_adds_both(self, monkeypatch: pytest.MonkeyPatch) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2"]) + nodes = {"n1": n1, "n2": n2} + + c1 = Contact(n1, n2, "n1_n2", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + c2 = Contact(n2, n1, "n1_n2", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([c1, c2]) + + recorder = _CallRecorder() + monkeypatch.setattr( + "tools.contact_player.contact_player.set_on_interface", recorder + ) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + player.setup() + assert len(recorder.calls) == 2 + seen = {(a[0], a[1]) for a, _ in recorder.calls} + assert seen == {("n1", "n1_n2_0"), ("n2", "n1_n2_0")} + for _, kwargs in recorder.calls: + assert kwargs == {"loss": 100} + + +# =================================================================== +# cleanup() +# =================================================================== +class TestCleanup: + def test_one_del_per_interface_and_truncates_netmap( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2", "n2_n3"]) + n3 = _make_node("n3", "3", ["n2_n3"]) + nodes = {"n1": n1, "n2": n2, "n3": n3} + + fixed = Contact(n1, n2, "n1_n2", 0, -1, LinkProperties("100mbit", 0, 0, 0)) + dynamic = Contact(n2, n3, "n2_n3", 20, 40, LinkProperties("2mbit", 0, 0, 0)) + plan = _make_plan([fixed, dynamic]) + + netmap = tmp_path / "topo.netmap" + netmap.write_text("stale content\n") + + recorder = _CallRecorder() + monkeypatch.setattr( + "tools.contact_player.contact_player.set_on_interface", recorder + ) + + with player_scope( + ContactPlayer( + plan, Path("x.compose"), nodes, netmap_path=netmap, CONTROL_PORT=0 + ) + ) as player: + player.cleanup() + + # One "del" per unique interface + assert len(recorder.calls) == 2 + seen = {(a[0], a[1]) for a, _ in recorder.calls} + assert seen == {("n1", "n1_n2_0"), ("n2", "n2_n3_0")} + for _args, kwargs in recorder.calls: + assert kwargs == {"command": "del"} + + # netmap truncated + assert netmap.read_text() == "" + # socket closed + assert player.sock._closed is True + + def test_resilient_to_runtime_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Cleanup is best-effort: a failing set_on_interface does not + propagate and the remaining interfaces are still processed.""" + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2", "n2_n3"]) + n3 = _make_node("n3", "3", ["n2_n3"]) + nodes = {"n1": n1, "n2": n2, "n3": n3} + + fixed = Contact(n1, n2, "n1_n2", 0, -1, LinkProperties("100mbit", 0, 0, 0)) + dynamic = Contact(n2, n3, "n2_n3", 20, 40, LinkProperties("2mbit", 0, 0, 0)) + plan = _make_plan([fixed, dynamic]) + + calls: list[tuple[object, object]] = [] + + def failing(name: object, interface: object, **_kwargs: object) -> str: + calls.append((name, interface)) + raise RuntimeError("boom") + + monkeypatch.setattr( + "tools.contact_player.contact_player.set_on_interface", failing + ) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + # Should NOT propagate + player.cleanup() + + # All unique interfaces were attempted despite the errors + attempted = {(c[0], c[1]) for c in calls} + assert attempted == {("n1", "n1_n2_0"), ("n2", "n2_n3_0")} + assert player.sock._closed is True + + +# =================================================================== +# activate() / deactivate() +# =================================================================== +class TestActivateDeactivate: + def test_activate_passes_contact_props( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + n1 = _make_node("n1", "1", ["net"]) + n2 = _make_node("n2", "2", ["net"]) + nodes = {"n1": n1, "n2": n2} + contact = Contact(n1, n2, "net", 10, 20, LinkProperties("1mbit", 5, 10, 2)) + plan = _make_plan([contact]) + + recorder = _CallRecorder() + monkeypatch.setattr( + "tools.contact_player.contact_player.set_on_interface", recorder + ) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + player.activate(contact) + + assert len(recorder.calls) == 1 + args, kwargs = recorder.calls[0] + assert args == ("n1", "net_0") + assert kwargs == { + "command": "change", + "loss": 5.0, + "delay": 10.0, + "jitter": 2.0, + "bandwidth": "1mbit", + } + assert plan.contacts[contact] == ContactState.ACTIVE + + def test_deactivate_forces_loss_100(self, monkeypatch: pytest.MonkeyPatch) -> None: + n1 = _make_node("n1", "1", ["net"]) + n2 = _make_node("n2", "2", ["net"]) + nodes = {"n1": n1, "n2": n2} + contact = Contact(n1, n2, "net", 10, 20, LinkProperties("1mbit", 5, 10, 2)) + plan = _make_plan([contact]) + plan.contacts[contact] = ContactState.ACTIVE + + recorder = _CallRecorder() + monkeypatch.setattr( + "tools.contact_player.contact_player.set_on_interface", recorder + ) + + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + player.deactivate(contact) + + assert len(recorder.calls) == 1 + args, kwargs = recorder.calls[0] + assert args == ("n1", "net_0") + # loss forced to 100.0, other props still forwarded + assert kwargs == { + "command": "change", + "loss": 100.0, + "delay": 10.0, + "jitter": 2.0, + "bandwidth": "1mbit", + } + assert plan.contacts[contact] == ContactState.INACTIVE + + +# =================================================================== +# update_netmap() +# =================================================================== +class TestUpdateNetmap: + def test_writes_static_and_active_dynamic(self, tmp_path: Path) -> None: + n1 = _make_node("n1", "1", ["n1_n2"]) + n2 = _make_node("n2", "2", ["n1_n2", "n2_n3"]) + n3 = _make_node("n3", "3", ["n2_n3"]) + nodes = {"n1": n1, "n2": n2, "n3": n3} + + # n1-n2 has no contact -> static; n2-n3 dynamic and active + dynamic = Contact(n2, n3, "n2_n3", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([dynamic]) + plan.contacts[dynamic] = ContactState.ACTIVE + + netmap = tmp_path / "topo.netmap" + + with player_scope( + ContactPlayer( + plan, Path("x.compose"), nodes, netmap_path=netmap, CONTROL_PORT=0 + ) + ) as player: + player.update_netmap() + + lines = {line for line in netmap.read_text().splitlines() if line} + # link endpoints come from a frozenset, so order is nondeterministic + assert {"n1 - n2", "n2 - n1"} & lines + assert {"n2 . n3", "n3 . n2"} & lines + assert len(lines) == 2 + + def test_no_netmap_path_is_noop(self) -> None: + n1 = _make_node("n1", "1", ["net"]) + n2 = _make_node("n2", "2", ["net"]) + nodes = {"n1": n1, "n2": n2} + contact = Contact(n1, n2, "net", 10, 20, LinkProperties("1mbit", 0, 0, 0)) + plan = _make_plan([contact]) + + # No netmap_path -> update_netmap should simply return without error + with player_scope( + ContactPlayer(plan, Path("x.compose"), nodes, CONTROL_PORT=0) + ) as player: + player.update_netmap() From 5bd833060c53711b60c3fb26d7b0679d626f678c Mon Sep 17 00:00:00 2001 From: axodentally <24368475+Axolord@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:47:35 +0000 Subject: [PATCH 4/5] tests: add cases for scenario.py --- tests/test_scenario.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/test_scenario.py diff --git a/tests/test_scenario.py b/tests/test_scenario.py new file mode 100644 index 0000000..dc2de39 --- /dev/null +++ b/tests/test_scenario.py @@ -0,0 +1,34 @@ +"""Smoke tests for scenario loading and compose parsing.""" + +from __future__ import annotations + +from pathlib import Path + +from tools.contact_player.scenario import _parse_compose_nodes + + +def test_parse_compose_nodes_simple_scenario() -> None: + """Smoke-test _parse_compose_nodes on the real scenarios/simple/compose.yml.""" + repo_root = Path(__file__).parent.parent + compose_path = repo_root / "scenarios" / "simple" / "compose.yml" + + nodes = _parse_compose_nodes(compose_path) + + assert len(nodes) == 3 + assert set(nodes.keys()) == {"n1", "n2", "n3"} + + n1 = nodes["n1"] + assert n1.id == "1" + assert n1.eid == "ipn:1.0" + + n2 = nodes["n2"] + assert n2.id == "2" + assert n2.eid == "ipn:2.0" + assert set(n2.interfaces.keys()) == {"n1_n2", "n2_n3"} + assert n2.interfaces["n1_n2"].ip == "172.33.0.3" + assert n2.interfaces["n2_n3"].ip == "172.33.1.2" + + n3 = nodes["n3"] + assert n3.id == "3" + assert n3.eid == "ipn:3.0" + assert n3.interfaces["n2_n3"].ip == "172.33.1.3" From 363b6bd5b6a48ad0fb9f3168fe38377c08d30dba Mon Sep 17 00:00:00 2001 From: axodentally <24368475+Axolord@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:07:10 +0000 Subject: [PATCH 5/5] tests: add cases for tc_netem --- tests/test_tc_netem.py | 180 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/test_tc_netem.py diff --git a/tests/test_tc_netem.py b/tests/test_tc_netem.py new file mode 100644 index 0000000..e67e4a0 --- /dev/null +++ b/tests/test_tc_netem.py @@ -0,0 +1,180 @@ +"""Unit tests for the tc_netem module. + +Two functions are exercised: + +* ``set_on_interface`` — builds the tc command string and delegates to + ``run_in_container``. We monkeypatch ``run_in_container`` and assert the + constructed command string and pass-through arguments. +* ``run_in_container`` — shells out via ``subprocess.run``. We monkeypatch + ``subprocess.run`` and assert command construction, return value, and the + ``RuntimeError`` raised on non-zero exit codes. +""" + +from __future__ import annotations + +import subprocess + +import pytest + +from tools.contact_player import tc_netem +from tools.contact_player.tc_netem import run_in_container, set_on_interface + + +# --------------------------------------------------------------------------- +# run_in_container fixtures +# --------------------------------------------------------------------------- +class _FakeResult: + """Minimal stand-in for ``subprocess.CompletedProcess``.""" + + returncode: int + stdout: str + stderr: str + args: str + + def __init__(self, returncode: int, stdout: str, stderr: str, args: str) -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + self.args = args + + +# =================================================================== +# set_on_interface — command construction +# =================================================================== +class TestSetOnInterface: + @pytest.fixture + def fake_run(self, monkeypatch: pytest.MonkeyPatch): + """Monkeypatch run_in_container to capture the command it receives.""" + captured: list[tuple[str, str]] = [] + + def _fake(container: str, command: str, **_rest: object) -> str: + captured.append((container, command)) + return "stdout" + + monkeypatch.setattr(tc_netem, "run_in_container", _fake) + return captured + + def test_default_command_is_change(self, fake_run: list[tuple[str, str]]) -> None: + set_on_interface("c", "eth0") + _container, command = fake_run[0] + assert "qdisc change" in command + + def test_command_string_defaults(self, fake_run: list[tuple[str, str]]) -> None: + set_on_interface("c", "eth0") + _container, command = fake_run[0] + assert command == "tc qdisc change dev eth0 root netem loss 0.0% delay 0ms 0ms" + + def test_delay_above_1000_uses_seconds(self, fake_run: list[tuple[str, str]]) -> None: + # 1500 // 1000 == 1 -> "delay 1s" + set_on_interface("c", "eth0", delay=1500) + _container, command = fake_run[0] + assert "delay 1s " in command or command.endswith("delay 1s") + + def test_delay_boundary_1000_uses_ms(self, fake_run: list[tuple[str, str]]) -> None: + # 1000 is NOT > 1000, so it stays in milliseconds + set_on_interface("c", "eth0", delay=1000) + _container, command = fake_run[0] + assert "delay 1000ms" in command + + def test_delay_below_1000_uses_ms(self, fake_run: list[tuple[str, str]]) -> None: + set_on_interface("c", "eth0", delay=500) + _container, command = fake_run[0] + assert "delay 500ms" in command + + def test_delay_zero(self, fake_run: list[tuple[str, str]]) -> None: + set_on_interface("c", "eth0", delay=0) + _container, command = fake_run[0] + assert "delay 0ms" in command + + def test_loss_interpolated(self, fake_run: list[tuple[str, str]]) -> None: + set_on_interface("c", "eth0", loss=5.5) + _container, command = fake_run[0] + assert "loss 5.5%" in command + + def test_loss_default_is_zero(self, fake_run: list[tuple[str, str]]) -> None: + set_on_interface("c", "eth0") + _container, command = fake_run[0] + assert "loss 0.0%" in command + + def test_jitter_interpolated(self, fake_run: list[tuple[str, str]]) -> None: + set_on_interface("c", "eth0", jitter=2.5) + _container, command = fake_run[0] + assert "2.5ms" in command + + def test_bandwidth_appended_when_nonempty( + self, fake_run: list[tuple[str, str]] + ) -> None: + set_on_interface("c", "eth0", bandwidth="100mbit") + _container, command = fake_run[0] + assert "rate 100mbit" in command + + def test_bandwidth_omitted_when_empty( + self, fake_run: list[tuple[str, str]] + ) -> None: + set_on_interface("c", "eth0") + _container, command = fake_run[0] + assert "rate" not in command + + def test_full_command_structure(self, fake_run: list[tuple[str, str]]) -> None: + set_on_interface( + "c", "eth0", command="add", loss=10.0, delay=200, jitter=5, bandwidth="1gbit" + ) + _container, command = fake_run[0] + assert command == ( + "tc qdisc add dev eth0 root netem loss 10.0% delay 200ms 5ms rate 1gbit" + ) + + def test_returns_run_in_container_stdout( + self, fake_run: list[tuple[str, str]] + ) -> None: + assert set_on_interface("c", "eth0") == "stdout" + # run_in_container was actually delegated to with the right container + assert len(fake_run) == 1 + assert fake_run[0][0] == "c" + + def test_passes_through_container_and_interface( + self, fake_run: list[tuple[str, str]] + ) -> None: + set_on_interface("mycontainer", "myiface", command="del") + container, command = fake_run[0] + assert container == "mycontainer" + assert "dev myiface" in command + assert "qdisc del" in command + + +# =================================================================== +# run_in_container — subprocess delegation +# =================================================================== +class TestRunInContainer: + def test_success_returns_stdout(self, monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + def _fake(args: str, **_kwargs: object) -> _FakeResult: + captured["args"] = args + return _FakeResult(0, "hello out", "", args) + + monkeypatch.setattr(subprocess, "run", _fake) + + out = run_in_container("node1", "tc qdisc show") + assert out == "hello out" + assert captured["args"] == "docker exec node1 bash -c 'tc qdisc show'" + + def test_failure_raises_runtimeerror_with_stderr( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + def _fake(args: str, **_kwargs: object) -> _FakeResult: + return _FakeResult(1, "", "something broke", args) + + monkeypatch.setattr(subprocess, "run", _fake) + + with pytest.raises(RuntimeError, match="something broke"): + run_in_container("node1", "bad command") + + def test_debug_print_does_not_break(self, monkeypatch: pytest.MonkeyPatch) -> None: + def _fake(args: str, **_kwargs: object) -> _FakeResult: + return _FakeResult(0, "ok", "", args) + + monkeypatch.setattr(subprocess, "run", _fake) + + # debug_print=True must not change behaviour + assert run_in_container("node1", "echo hi", debug_print=True) == "ok"