From 92c1e906bdd3fc59fb65d9405cd5f6ca8097e7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Sat, 6 Jun 2026 17:10:25 -0400 Subject: [PATCH] test: add deterministic regression suite (142 tests) --- .gitignore | 4 +- README.md | 9 + docs/en/server/development/testing.md | 85 +++ mkdocs.yml | 1 + pyproject.toml | 1 + tests/README.md | 128 ++++ tests/__init__.py | 0 tests/bridge/test_config_reload.py | 38 ++ tests/bridge/test_crc_dedup.py | 62 ++ tests/bridge/test_echo_bridgereset.py | 141 ++++ tests/bridge/test_options_config_loop.py | 32 + tests/bridge/test_private_voice.py | 79 +++ tests/bridge/test_startup_bridges.py | 68 ++ tests/bridge/test_static_tg_options.py | 99 +++ tests/bridge/test_unit_data_ingress.py | 65 ++ tests/bridge/test_unit_data_routing.py | 93 +++ tests/bridge/unit_data_helpers.py | 13 + tests/fixtures/sample_freedmr.cfg | 149 +++++ tests/harness/__init__.py | 23 + tests/harness/assertions.py | 78 +++ tests/harness/deterministic.py | 633 ++++++++++++++++++ tests/harness/playback_helpers.py | 70 ++ tests/harness/scenarios.py | 42 ++ tests/harness/voice_helpers.py | 156 +++++ tests/hbp/test_hbp_loop_control.py | 45 ++ tests/hbp/test_hbp_rate_limit.py | 75 +++ tests/hbp/test_ingress.py | 86 +++ tests/hbp/test_master_maintenance.py | 59 ++ tests/hbp/test_timeout_collision.py | 88 +++ tests/infrastructure/test_logging_reload.py | 25 + tests/obp/test_loop_control.py | 120 ++++ tests/obp/test_obp_rate_limit.py | 33 + tests/obp/test_unit_data_loop.py | 42 ++ tests/parrot/test_playback_ingress.py | 64 ++ tests/parrot/test_playback_logging.py | 36 + tests/parrot/test_playback_send_loop.py | 108 +++ tests/parrot/test_recording_timers.py | 52 ++ tests/parrot/test_rekey_playback.py | 138 ++++ tests/scripts/test_freedmr_cfg_to_yaml.py | 174 +++++ tests/smoke/test_bridge_routing.py | 24 + tests/smoke/test_packet_builder.py | 20 + tests/talker_alias/test_bridge_inject.py | 109 +++ tests/talker_alias/test_embed_ta.py | 97 +++ tests/talker_alias/test_encode_decode.py | 59 ++ tests/talker_alias/test_format.py | 29 + tests/talker_alias/test_mmdvm_wire.py | 133 ++++ tests/talker_alias/test_passthrough.py | 181 +++++ .../voice/test_announcement_anticollision.py | 50 ++ tests/voice/test_broadcast_queue.py | 53 ++ tests/voice/test_disconnected_voice.py | 108 +++ tests/voice/test_embed_ta_forward.py | 39 ++ tests/voice/test_in_band_signalling.py | 70 ++ tests/voice/test_play_file_on_request.py | 88 +++ tests/voice/test_scheduled_announcement.py | 70 ++ tests/voice/test_scheduled_tts.py | 137 ++++ tests/voice/test_voice_config_reload.py | 92 +++ 56 files changed, 4571 insertions(+), 2 deletions(-) create mode 100644 docs/en/server/development/testing.md create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/bridge/test_config_reload.py create mode 100644 tests/bridge/test_crc_dedup.py create mode 100644 tests/bridge/test_echo_bridgereset.py create mode 100644 tests/bridge/test_options_config_loop.py create mode 100644 tests/bridge/test_private_voice.py create mode 100644 tests/bridge/test_startup_bridges.py create mode 100644 tests/bridge/test_static_tg_options.py create mode 100644 tests/bridge/test_unit_data_ingress.py create mode 100644 tests/bridge/test_unit_data_routing.py create mode 100644 tests/bridge/unit_data_helpers.py create mode 100644 tests/fixtures/sample_freedmr.cfg create mode 100644 tests/harness/__init__.py create mode 100644 tests/harness/assertions.py create mode 100644 tests/harness/deterministic.py create mode 100644 tests/harness/playback_helpers.py create mode 100644 tests/harness/scenarios.py create mode 100644 tests/harness/voice_helpers.py create mode 100644 tests/hbp/test_hbp_loop_control.py create mode 100644 tests/hbp/test_hbp_rate_limit.py create mode 100644 tests/hbp/test_ingress.py create mode 100644 tests/hbp/test_master_maintenance.py create mode 100644 tests/hbp/test_timeout_collision.py create mode 100644 tests/infrastructure/test_logging_reload.py create mode 100644 tests/obp/test_loop_control.py create mode 100644 tests/obp/test_obp_rate_limit.py create mode 100644 tests/obp/test_unit_data_loop.py create mode 100644 tests/parrot/test_playback_ingress.py create mode 100644 tests/parrot/test_playback_logging.py create mode 100644 tests/parrot/test_playback_send_loop.py create mode 100644 tests/parrot/test_recording_timers.py create mode 100644 tests/parrot/test_rekey_playback.py create mode 100644 tests/scripts/test_freedmr_cfg_to_yaml.py create mode 100644 tests/smoke/test_bridge_routing.py create mode 100644 tests/smoke/test_packet_builder.py create mode 100644 tests/talker_alias/test_bridge_inject.py create mode 100644 tests/talker_alias/test_embed_ta.py create mode 100644 tests/talker_alias/test_encode_decode.py create mode 100644 tests/talker_alias/test_format.py create mode 100644 tests/talker_alias/test_mmdvm_wire.py create mode 100644 tests/talker_alias/test_passthrough.py create mode 100644 tests/voice/test_announcement_anticollision.py create mode 100644 tests/voice/test_broadcast_queue.py create mode 100644 tests/voice/test_disconnected_voice.py create mode 100644 tests/voice/test_embed_ta_forward.py create mode 100644 tests/voice/test_in_band_signalling.py create mode 100644 tests/voice/test_play_file_on_request.py create mode 100644 tests/voice/test_scheduled_announcement.py create mode 100644 tests/voice/test_scheduled_tts.py create mode 100644 tests/voice/test_voice_config_reload.py diff --git a/.gitignore b/.gitignore index 59d2713..d95f6a7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,8 @@ adn-voice.yaml *.local.yaml *.local.yml -# Local/helper scripts (keep scripts out of git) -scripts/ +# Local/helper scripts (repo root only; not tests/scripts/) +/scripts/ # Env and secrets .env diff --git a/README.md b/README.md index c008dfa..3d26227 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ Use the same `python3` you use for the project (e.g. pyenv’s `3.11.8`). Previe Output: **`site/en/`** and **`site/es/`** under gitignored **`site/`**. +## Tests + +```bash +python3 -m pip install -e ".[dev]" +python3 -m pytest tests/ -q +``` + +See [Testing](docs/en/server/development/testing.md) in the docs site. File index: [`tests/README.md`](tests/README.md). + ## Run ```bash diff --git a/docs/en/server/development/testing.md b/docs/en/server/development/testing.md new file mode 100644 index 0000000..de97f80 --- /dev/null +++ b/docs/en/server/development/testing.md @@ -0,0 +1,85 @@ +# Testing + +Regression tests live under **`tests/`**, **one topic per file**, grouped by domain (`bridge/`, `hbp/`, `obp/`, …). See **[`tests/README.md`](../../../tests/README.md)** for the full file index. + +They use an in-process **deterministic harness** (no Twisted reactor, no UDP sockets). + +## Install dev dependencies + +```bash +python3 -m pip install -e ".[dev]" +``` + +Use the project interpreter, e.g. `/opt/.pyenv/versions/3.11.8/bin/python3`. + +## Run tests + +Full suite: + +```bash +python3 -m pytest tests/ -q +``` + +**By domain** (while working on one area): + +```bash +python3 -m pytest tests/bridge/ -q +python3 -m pytest tests/hbp/ -q +python3 -m pytest tests/parrot/ -q +``` + +**By file** (recommended for point checks): + +```bash +python3 -m pytest tests/bridge/test_unit_data_routing.py -q +python3 -m pytest tests/parrot/test_rekey_playback.py -q +``` + +**Single test**: + +```bash +python3 -m pytest tests/bridge/test_startup_bridges.py::test_startup_bridge_routes_voice_after_apply -q +``` + +Collect only: + +```bash +python3 -m pytest tests/ --collect-only -q +``` + +## Markers + +Registered in `pyproject.toml`: + +| Marker | Use | +|--------|-----| +| `@pytest.mark.behavior` | Integration-style regression (P0/P1) | +| `@pytest.mark.smoke` | Quick routing smoke checks | + +## Harness overview + +| Component | Role | +|-----------|------| +| `DeterministicScenario` | Wires `BridgeUseCases` with fakes and packet capture | +| `inject_hbp` / `inject_unit` / `inject_obp` | Public ingress paths into `dmrd_received` | +| `PacketCapture` | Records outbound `send_to_system` + parsed DMR fields | +| `FakeReportSender` + `ReportingUseCases` | Reporting events (via `scenario.report_factory.events`) | +| `tests/harness/assertions.py` | Reusable asserts: `assert_forwarded`, `assert_report_event`, … | + +Full audit (scores, red-test table, inventory): internal `docs-priv/en/test-audit.md` in maintainer checkouts. + +## Writing a regression test + +1. Docstring: **Regression:** if X breaks, this test fails because Y. +2. Prefer a **dedicated file** (or the smallest existing file for the same topic). +3. Enter through a public path (`inject_*`, use-case API, or `@pytest.mark.unit` for pure domain). +4. Assert an **observable** outcome (capture, `master.sent`, report strings), not only private flags. +5. Add a negative path where behaviour differs. +6. **Red-test:** break the production condition → test must fail → revert. +7. Mark `@pytest.mark.behavior` or `@pytest.mark.unit`. + +Do not chase coverage percentage; chase regressions operators would notice on RF. + +## v2 policy + +Each v2 feature branch should include or extend regression tests in the same PR. Baseline task **V2-TST-001** tracks bringing `tests/` into the repo on `develop`. diff --git a/mkdocs.yml b/mkdocs.yml index 952d2eb..9e78855 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -65,6 +65,7 @@ nav: - Development: - Architecture: server/development/architecture.md - Behaviour and timers: server/development/behaviour-and-timers.md + - Testing: server/development/testing.md - Contributing: - Translations: server/contributing/translations.md - Monitor: diff --git a/pyproject.toml b/pyproject.toml index 0060d15..d8535ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ ] [project.optional-dependencies] +dev = ["pytest>=7.0"] docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "pymdown-extensions>=10.3"] [tool.setuptools.packages.find] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..df9d51f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,128 @@ +# Test layout + +One **topic per file** — run only what you need while developing or validating a change. + +```bash +python3 -m pip install -e ".[dev]" +python3 -m pytest tests//test_.py -q # single file +python3 -m pytest tests//test_.py::test_foo -q # single test +python3 -m pytest tests/bridge/ -q # whole domain +python3 -m pytest tests/ -q # full suite (142) +``` + +Use the project interpreter, e.g. `/opt/.pyenv/versions/3.11.8/bin/python3`. + +## Directories + +| Directory | What it covers | +|-----------|----------------| +| `bridge/` | Static TG, startup bridges, unit data, CRC dedup, echo, private voice, config reload | +| `hbp/` | HBP ingress, loop control, rate limit, timeout/collision, master maintenance | +| `obp/` | OpenBridge loop, rate limit, unit-data loop | +| `voice/` | Announcements, TTS schedule, broadcast queue, disconnected voice, in-band signalling | +| `talker_alias/` | Encode/decode, passthrough, MMDVM wire, bridge inject | +| `parrot/` | Recording timers, playback loop, seq preservation, ingress path | +| `smoke/` | Quick routing smoke + packet builder | +| `infrastructure/` | Logging reload | +| `scripts/` | Config conversion helpers | +| `harness/` | Shared fakes (`DeterministicScenario`, assertions) — not run as tests | + +## Files by domain + +### bridge/ + +| File | Tests | Topic | +|------|-------|-------| +| `test_config_reload.py` | 1 | Merge system config on reload | +| `test_crc_dedup.py` | 3 | HBP/OBP CRC dedup, seq=0 | +| `test_echo_bridgereset.py` | 5 | Echo leg after BRIDGERESET / OPTIONS | +| `test_options_config_loop.py` | 2 | 26s OPTIONS static TG | +| `test_private_voice.py` | 3 | Private call routing | +| `test_startup_bridges.py` | 4 | Startup BRIDGES + voice E2E | +| `test_static_tg_options.py` | 4 | Static TG from peer OPTIONS | +| `test_unit_data_ingress.py` | 4 | Unit headers, CSBK, reports | +| `test_unit_data_routing.py` | 5 | SUB_MAP, hotspot, gateway, OBP fanout | + +### hbp/ + +| File | Tests | Topic | +|------|-------|-------| +| `test_hbp_loop_control.py` | 2 | HBP loop winner/loser | +| `test_hbp_rate_limit.py` | 2 | Ingress rate drop + stall | +| `test_ingress.py` | 3 | RX start, rate drop, OBP loop loser | +| `test_master_maintenance.py` | 2 | Peer timeout / shared PEERS dict | +| `test_timeout_collision.py` | 3 | 180s timeout, collision, rekey | + +### obp/ + +| File | Tests | Topic | +|------|-------|-------| +| `test_loop_control.py` | 4 | OBP loop, VTERM, BCSQ | +| `test_obp_rate_limit.py` | 1 | Rate limit epoch | +| `test_unit_data_loop.py` | 2 | Unit data loop loser | + +### voice/ + +| File | Tests | Topic | +|------|-------|-------| +| `test_announcement_anticollision.py` | 3 | Busy slot skip / abort | +| `test_broadcast_queue.py` | 2 | Same-TG broadcast queue | +| `test_disconnected_voice.py` | 3 | Not-linked / reflector prompts | +| `test_embed_ta_forward.py` | 2 | Embed TA state on bridge | +| `test_in_band_signalling.py` | 5 | Reflector / single-mode VTERM | +| `test_play_file_on_request.py` | 3 | On-demand file playback | +| `test_scheduled_announcement.py` | 4 | File announcements (AMBE) | +| `test_scheduled_tts.py` | 7 | TTS schedule + conversion | +| `test_voice_config_reload.py` | 3 | Hot reload announcement/TTS loops | + +### talker_alias/ + +| File | Tests | Topic | +|------|-------|-------| +| `test_bridge_inject.py` | 5 | TA inject on bridge VHEAD | +| `test_embed_ta.py` | 4 | Embedded LC modes | +| `test_encode_decode.py` | 8 | Domain encode/decode | +| `test_format.py` | 2 | Format from subscriber profile | +| `test_mmdvm_wire.py` | 10 | MMDVM wire blocks | +| `test_passthrough.py` | 9 | Passthrough / both modes | + +### parrot/ + +| File | Tests | Topic | +|------|-------|-------| +| `test_playback_ingress.py` | 2 | `ingress_pkt_time`, record→playback | +| `test_playback_logging.py` | 1 | Duration log format | +| `test_playback_send_loop.py` | 4 | Send loop, max duration, interval | +| `test_recording_timers.py` | 2 | Idle timeout, VTERM commit | +| `test_rekey_playback.py` | 4 | Seq preservation past 255 / 30s | + +### smoke/ · infrastructure/ · scripts/ + +| File | Tests | Topic | +|------|-------|-------| +| `smoke/test_bridge_routing.py` | 1 | Static TG forward smoke | +| `smoke/test_packet_builder.py` | 1 | PacketSpec builder | +| `infrastructure/test_logging_reload.py` | 2 | Log level reload | +| `scripts/test_freedmr_cfg_to_yaml.py` | 5 | Legacy cfg → YAML | + +## Examples (copy-paste) + +```bash +# After changing bridge unit-data routing +python3 -m pytest tests/bridge/test_unit_data_routing.py -q + +# After parrot seq fix +python3 -m pytest tests/parrot/test_rekey_playback.py -q + +# HBP loop + rate (common RF regressions) +python3 -m pytest tests/hbp/test_hbp_loop_control.py tests/hbp/test_hbp_rate_limit.py -q + +# One test by name +python3 -m pytest tests/bridge/test_startup_bridges.py::test_startup_bridge_routes_voice_after_apply -q +``` + +## Policy + +- **New tests:** add a new file (or extend the smallest existing file for the same topic). Avoid large multi-topic modules. +- **Harness:** shared code lives in `harness/` only. +- Full audit: `docs-priv/en/test-audit.md` (maintainer checkout). diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bridge/test_config_reload.py b/tests/bridge/test_config_reload.py new file mode 100644 index 0000000..570b622 --- /dev/null +++ b/tests/bridge/test_config_reload.py @@ -0,0 +1,38 @@ +"""Config reload merge preserves runtime MASTER state.""" + +from __future__ import annotations + +from adn_server.infrastructure.config_reload import merge_system_config + + +def test_merge_system_config_preserves_peers_and_static_tgs() -> None: + old = { + "MODE": "MASTER", + "ENABLED": True, + "IP": "127.0.0.1", + "PORT": 62031, + "PEERS": {"1001": {"IP": "10.0.0.1"}}, + "OPTIONS": "TS2=52090;TIMER=10", + "TS1_STATIC": "91", + "TS2_STATIC": "52090", + "DEFAULT_UA_TIMER": 10, + "_options_static_apply_fp": "52090|10", + } + new = { + "MODE": "MASTER", + "ENABLED": True, + "IP": "127.0.0.1", + "PORT": 62031, + "GROUP_HANGTIME": 3, + "TS1_STATIC": "", + "TS2_STATIC": "", + } + + merged = merge_system_config(old, new) + + assert merged["PEERS"] == old["PEERS"] + assert merged["OPTIONS"] == old["OPTIONS"] + assert merged["TS1_STATIC"] == old["TS1_STATIC"] + assert merged["TS2_STATIC"] == old["TS2_STATIC"] + assert merged["_options_static_apply_fp"] == old["_options_static_apply_fp"] + assert merged["GROUP_HANGTIME"] == 3 diff --git a/tests/bridge/test_crc_dedup.py b/tests/bridge/test_crc_dedup.py new file mode 100644 index 0000000..cd3e143 --- /dev/null +++ b/tests/bridge/test_crc_dedup.py @@ -0,0 +1,62 @@ +"""CRC payload dedup: only seq > 0 triggers drop.""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_forwarded, assert_inject_ok +from tests.harness.deterministic import ( + DeterministicScenario, + PacketSpec, + active_bridge, + add_openbridge_system, + patch_bridge_wall_time, +) + + +def test_hbp_crc_dedup_drops_duplicate_seq_gt_zero() -> None: + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + base = PacketSpec(dst_id=91, stream_id=0xABCD1234) + burst = DeterministicScenario.voice_burst_spec(base, seq=2, dtype_vseq=2) + t0 = scenario.clock.time() + + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_head_spec(base), + ingress_pkt_time=t0, + ) + scenario.inject_hbp("MASTER-A", burst, ingress_pkt_time=t0 + 0.06) + ok = scenario.inject_hbp("MASTER-A", burst, ingress_pkt_time=t0 + 0.12) + assert not ok + + +@pytest.mark.behavior +def test_hbp_crc_dedup_allows_seq_zero_repeat() -> None: + """Regression: duplicate VHEAD with seq 0 still forwards (not subject to crc dedup).""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + base = PacketSpec(dst_id=91, stream_id=0xDEADBEEF, seq=0) + vhead = DeterministicScenario.voice_head_spec(base) + t0 = scenario.clock.time() + + ok1 = scenario.inject_hbp("MASTER-A", vhead, ingress_pkt_time=t0) + ok2 = scenario.inject_hbp("MASTER-A", vhead, ingress_pkt_time=t0 + 0.06) + assert_inject_ok(ok1) + assert_inject_ok(ok2) + assert_forwarded(scenario, "MASTER-B", count=2, dst_id=91) + + +def test_obp_crc_dedup_drops_duplicate_seq_gt_zero() -> None: + bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2))) + config = DeterministicScenario().config + add_openbridge_system(config, "OBP-CL") + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090" + scenario = DeterministicScenario(config=config, bridges=bridges) + base = PacketSpec(dst_id=52090, stream_id=0xCAFEBABE, slot=1) + burst = DeterministicScenario.voice_burst_spec(base, seq=3, dtype_vseq=3) + + with patch_bridge_wall_time(scenario.clock): + scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base)) + scenario.inject_obp("OBP-CL", burst) + ok = scenario.inject_obp("OBP-CL", burst) + assert not ok diff --git a/tests/bridge/test_echo_bridgereset.py b/tests/bridge/test_echo_bridgereset.py new file mode 100644 index 0000000..d39d0d2 --- /dev/null +++ b/tests/bridge/test_echo_bridgereset.py @@ -0,0 +1,141 @@ +"""ECHO / parrot bridge leg restore after BRIDGERESET (regression for production outage).""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_forwarded +from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config + +from adn_server.domain import ID_MAX, PEER_MAX +from adn_server.infrastructure.config_loader import acl_build +from adn_server.main import _make_echo_bridges + + +def _echo_scenario_config() -> dict: + config = minimal_config(("SYSTEM-82",)) + config["SYSTEMS"]["ECHO"] = { + "MODE": "MASTER", + "ENABLED": True, + "REPEAT": True, + "MAX_PEERS": 1, + "IP": "127.0.0.1", + "PORT": 54917, + "PASSPHRASE": b"passw0rd", + "GROUP_HANGTIME": 5, + "USE_ACL": True, + "REG_ACL": "DENY:1", + "SUB_ACL": "DENY:1", + "TGID_TS1_ACL": "DENY:ALL", + "TGID_TS2_ACL": "PERMIT:9990", + "DEFAULT_UA_TIMER": 1, + "SINGLE_MODE": True, + "VOICE_IDENT": False, + "TS1_STATIC": "", + "TS2_STATIC": "9990", + "DEFAULT_REFLECTOR": 0, + "GENERATOR": 0, + "ALLOW_UNREG_ID": True, + "PEERS": {}, + } + config["SYSTEMS"]["SYSTEM-82"]["TS2_STATIC"] = "" + echo = config["SYSTEMS"]["ECHO"] + echo["REG_ACL"] = acl_build(str(echo["REG_ACL"]), PEER_MAX) + echo["SUB_ACL"] = acl_build(str(echo["SUB_ACL"]), ID_MAX) + echo["TG1_ACL"] = acl_build(str(echo["TGID_TS1_ACL"]), ID_MAX) + echo["TG2_ACL"] = acl_build(str(echo["TGID_TS2_ACL"]), ID_MAX) + return config + + +def _bridges_after_bridgereset(config: dict) -> dict: + bridges = _make_echo_bridges(config) + for entry in bridges["9990"]: + if entry["SYSTEM"] == "ECHO": + entry["ACTIVE"] = False + entry["TO_TYPE"] = "ON" + for entry in bridges["9990"]: + if entry["SYSTEM"] == "SYSTEM-82": + entry["ACTIVE"] = True + entry["TO_TYPE"] = "ON" + return bridges + + +def test_bridge_reset_restores_echo_leg_with_use_acl() -> None: + """BRIDGERESET must restore ECHO service leg when USE_ACL and TG2_ACL are processed.""" + config = _echo_scenario_config() + config["SYSTEMS"]["ECHO"]["_reset"] = True + bridges = _bridges_after_bridgereset(config) + scenario = DeterministicScenario(config=config, bridges=bridges) + + scenario.bridge.bridge_reset_loop() + + echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO") + assert echo_leg["ACTIVE"] is True + assert echo_leg["TO_TYPE"] == "NONE" + + +def test_options_config_restores_echo_after_bridgereset() -> None: + """Peer RPTO (options_config_for_system) re-enables ECHO leg after BRIDGERESET.""" + config = _echo_scenario_config() + echo_cfg = config["SYSTEMS"]["ECHO"] + echo_cfg["OPTIONS"] = "TS2=9990;" + bridges = _bridges_after_bridgereset(config) + scenario = DeterministicScenario(config=config, bridges=bridges) + + scenario.bridge.options_config_for_system("ECHO") + + echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO") + assert echo_leg["ACTIVE"] is True + assert echo_leg["TO_TYPE"] == "NONE" + + +@pytest.mark.behavior +def test_echo_voice_forwards_after_bridgereset_and_options() -> None: + """Regression: voice on TG 9990 reaches ECHO master after reset + RPTO restore.""" + config = _echo_scenario_config() + echo_cfg = config["SYSTEMS"]["ECHO"] + echo_cfg["OPTIONS"] = "TS2=9990;" + bridges = _bridges_after_bridgereset(config) + scenario = DeterministicScenario(config=config, bridges=bridges) + scenario.bridge.options_config_for_system("ECHO") + + base = PacketSpec(dst_id=9990, stream_id=0xDEADBEEF, slot=2) + scenario.inject_hbp("SYSTEM-82", DeterministicScenario.voice_head_spec(base)) + scenario.inject_hbp( + "SYSTEM-82", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + + assert_forwarded(scenario, "ECHO", count=2, dst_id=9990) + + +def test_bridge_reset_with_yaml_tgid_ts_acl_only() -> None: + """Restore reads TG2_ACL tuple (as after process_acls), not TGID_TS2_ACL YAML string.""" + config = _echo_scenario_config() + echo = config["SYSTEMS"]["ECHO"] + echo["TG1_ACL"] = acl_build(str(echo["TGID_TS1_ACL"]), ID_MAX) + echo["TG2_ACL"] = acl_build(str(echo["TGID_TS2_ACL"]), ID_MAX) + echo["_reset"] = True + bridges = _bridges_after_bridgereset(config) + scenario = DeterministicScenario(config=config, bridges=bridges) + + scenario.bridge.bridge_reset_loop() + + echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO") + assert echo_leg["ACTIVE"] is True + assert echo_leg["TO_TYPE"] == "NONE" + + +def test_bridge_reset_acl_uses_processed_tg2_acl_not_yaml_string() -> None: + """_restore must not read raw TGID_TS2_ACL strings (TypeError in acl_check).""" + config = _echo_scenario_config() + echo_cfg = config["SYSTEMS"]["ECHO"] + echo_cfg["TGID_TS2_ACL"] = "PERMIT:9990" + echo_cfg["TG2_ACL"] = acl_build("PERMIT:9990", 16776415) + echo_cfg["_reset"] = True + bridges = _bridges_after_bridgereset(config) + scenario = DeterministicScenario(config=config, bridges=bridges) + + scenario.bridge.bridge_reset_loop() + + echo_leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "ECHO") + assert echo_leg["ACTIVE"] is True diff --git a/tests/bridge/test_options_config_loop.py b/tests/bridge/test_options_config_loop.py new file mode 100644 index 0000000..bde6475 --- /dev/null +++ b/tests/bridge/test_options_config_loop.py @@ -0,0 +1,32 @@ +"""OPTIONS timer loop (options_config_loop, 26s refresh).""" + +from __future__ import annotations + +from tests.harness.deterministic import DeterministicScenario + + +def test_options_config_loop_applies_static_tg_from_options() -> None: + config = DeterministicScenario().config + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=52090;TIMER=10" + config["SYSTEMS"]["MASTER-A"]["TS1_STATIC"] = "" + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "" + scenario = DeterministicScenario(config=config) + + scenario.bridge.options_config_loop() + + sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"] + assert sys_cfg["TS2_STATIC"] == "52090" + assert "52090" in scenario.bridge.get_bridges() + + +def test_options_config_loop_skips_master_without_options() -> None: + config = DeterministicScenario().config + config["SYSTEMS"]["MASTER-A"].pop("OPTIONS", None) + config["SYSTEMS"]["MASTER-B"]["OPTIONS"] = "TS2=91;TIMER=10" + config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] = "" + scenario = DeterministicScenario(config=config) + + scenario.bridge.options_config_loop() + + assert scenario.config["SYSTEMS"]["MASTER-A"].get("TS2_STATIC", "") == "" + assert scenario.config["SYSTEMS"]["MASTER-B"]["TS2_STATIC"] == "91" diff --git a/tests/bridge/test_private_voice.py b/tests/bridge/test_private_voice.py new file mode 100644 index 0000000..055ee8a --- /dev/null +++ b/tests/bridge/test_private_voice.py @@ -0,0 +1,79 @@ +"""Private (unit) voice routing via SUB_MAP.""" + +from __future__ import annotations + +from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config, parse_dmr_fields + +from adn_server.domain import bytes_3, bytes_4 +from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD + + +def _private_call_scenario(dst_subscriber: int = 7123456) -> tuple[DeterministicScenario, int]: + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["PEERS"] = { + "1001": {"CALLSIGN": "SRC", "IP": "127.0.0.1", "PORT": 62032}, + } + config["SYSTEMS"]["MASTER-B"]["PEERS"] = { + "1002": {"CALLSIGN": "DST", "IP": "127.0.0.1", "PORT": 62033}, + } + config["_SUB_MAP"] = {bytes_3(dst_subscriber): ("MASTER-B", 2, 1000.0)} + return DeterministicScenario(config=config), dst_subscriber + + +def test_private_call_forwards_to_subscriber_system() -> None: + scenario, dst_sub = _private_call_scenario() + base = PacketSpec( + call_type="unit", + dst_id=dst_sub, + rf_src=3120001, + stream_id=0xAABBCCDD, + slot=2, + ) + + scenario.inject_unit("MASTER-A", DeterministicScenario.unit_voice_head_spec(base)) + scenario.inject_unit( + "MASTER-A", + DeterministicScenario.unit_voice_burst_spec(base, seq=1), + ) + + forwarded = scenario.capture.for_system("MASTER-B") + assert len(forwarded) >= 2 + assert all(parse_dmr_fields(p.packet)["call_type"] == "unit" for p in forwarded) + + +def test_private_call_same_system_does_not_forward() -> None: + config = minimal_config(("MASTER-A", "MASTER-B")) + dst_sub = 7123456 + config["_SUB_MAP"] = {bytes_3(dst_sub): ("MASTER-A", 2, 1000.0)} + scenario = DeterministicScenario(config=config) + base = PacketSpec(call_type="unit", dst_id=dst_sub, stream_id=0x11223344, slot=2) + + scenario.inject_unit("MASTER-A", DeterministicScenario.unit_voice_head_spec(base)) + + assert scenario.capture.packets == [] + + +def test_private_call_collision_drops_new_stream() -> None: + scenario, dst_sub = _private_call_scenario() + t0 = scenario.clock.time() + slot = scenario.protocols["MASTER-A"].STATUS[2] + slot.update( + { + "RX_STREAM_ID": bytes_4(0x99998888), + "RX_TYPE": HBPF_SLT_VHEAD, + "RX_RFS": bytes_3(1111111), + "RX_TIME": t0, + } + ) + scenario.clock.advance(0.1) + base = PacketSpec( + call_type="unit", + dst_id=dst_sub, + rf_src=2222222, + stream_id=0xDEADBEEF, + slot=2, + ) + + scenario.inject_unit("MASTER-A", DeterministicScenario.unit_voice_head_spec(base)) + + assert scenario.capture.packets == [] diff --git a/tests/bridge/test_startup_bridges.py b/tests/bridge/test_startup_bridges.py new file mode 100644 index 0000000..f8600f2 --- /dev/null +++ b/tests/bridge/test_startup_bridges.py @@ -0,0 +1,68 @@ +"""Startup bridge wiring (apply_startup_bridges).""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_forwarded +from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge, minimal_config + + +def test_apply_startup_bridges_creates_static_ts2_tg() -> None: + config = DeterministicScenario().config + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090" + config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10 + scenario = DeterministicScenario(config=config) + + scenario.bridge.apply_startup_bridges() + + assert "52090" in scenario.bridge.get_bridges() + leg = next(e for e in scenario.bridge.get_bridges()["52090"] if e["SYSTEM"] == "MASTER-A") + assert leg["TS"] == 2 + assert leg["ACTIVE"] is True + + +def test_apply_startup_bridges_creates_default_reflector_bridge() -> None: + config = DeterministicScenario().config + config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 310 + config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10 + scenario = DeterministicScenario(config=config) + + scenario.bridge.apply_startup_bridges() + + assert "#310" in scenario.bridge.get_bridges() + + +def test_options_config_for_system_matches_startup_static_tg() -> None: + """RPTO path (options_config_for_system) and startup both materialize the same TS2 leg.""" + config = DeterministicScenario().config + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=52090;TIMER=10" + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "" + scenario = DeterministicScenario(config=config) + + scenario.bridge.options_config_for_system("MASTER-A") + + assert scenario.config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] == "52090" + assert "52090" in scenario.bridge.get_bridges() + + scenario.bridge.apply_startup_bridges() + assert "52090" in scenario.bridge.get_bridges() + + +@pytest.mark.behavior +def test_startup_bridge_routes_voice_after_apply() -> None: + """Regression: static TG from apply_startup_bridges forwards HBP voice to peer MASTER.""" + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090" + config["SYSTEMS"]["MASTER-A"]["DEFAULT_UA_TIMER"] = 10 + bridges = active_bridge(52090, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=config, bridges=bridges) + scenario.bridge.apply_startup_bridges() + + base = PacketSpec(dst_id=52090, stream_id=0x80808080, slot=2) + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + + assert_forwarded(scenario, "MASTER-B", count=2, dst_id=52090) diff --git a/tests/bridge/test_static_tg_options.py b/tests/bridge/test_static_tg_options.py new file mode 100644 index 0000000..239ed66 --- /dev/null +++ b/tests/bridge/test_static_tg_options.py @@ -0,0 +1,99 @@ +"""Static TG / OPTIONS handling (commits fdf45d3, 4e8a2d0 echo leg restore).""" + +from __future__ import annotations + +from tests.harness.deterministic import DeterministicScenario, active_bridge + +from adn_server.domain import bytes_3 + + +def _master_with_options(options: str) -> DeterministicScenario: + config = DeterministicScenario().config + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = options + config["SYSTEMS"]["MASTER-A"]["TS1_STATIC"] = "" + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "" + return DeterministicScenario(config=config) + + +def test_options_ignores_malformed_ts_tokens() -> None: + scenario = _master_with_options("TS1=91,A92;TS2=52090;TIMER=10") + before_ts2 = scenario.config["SYSTEMS"]["MASTER-A"].get("TS2_STATIC", "") + + scenario.bridge.options_config_for_system("MASTER-A") + + sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"] + assert sys_cfg.get("TS1_STATIC", "") in ("", before_ts2) + assert sys_cfg.get("TS2_STATIC", "") == before_ts2 + + +def test_options_duplicate_fingerprint_restores_prohibited_legs() -> None: + """Identical RPTO fingerprint still runs _restore_prohibited_static_bridge_legs.""" + config = DeterministicScenario().config + config["SYSTEMS"]["MASTER-A"]["OPTIONS"] = "TS2=9990;TIMER=10" + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "9990" + config["SYSTEMS"]["MASTER-A"]["_options_static_apply_fp"] = "|9990|10" + bridges = active_bridge(9990, (("MASTER-A", 2),)) + bridges["9990"] = [ + { + "SYSTEM": "MASTER-A", + "TS": 2, + "TGID": bytes_3(9990), + "ACTIVE": False, + "TIMEOUT": 600, + "TO_TYPE": "STAT", + "ON": [], + "OFF": [], + "RESET": [], + "TIMER": 0, + } + ] + scenario = DeterministicScenario(config=config, bridges=bridges) + + scenario.bridge.options_config_for_system("MASTER-A") + + leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "MASTER-A") + assert leg["ACTIVE"] is True + assert leg["TO_TYPE"] == "NONE" + + +def test_bridge_reset_restores_prohibited_static_legs() -> None: + """BRIDGERESET after peer loss re-enables YAML static TG 9990 on MASTER.""" + config = DeterministicScenario().config + sys_cfg = config["SYSTEMS"]["MASTER-A"] + sys_cfg["TS2_STATIC"] = "9990" + sys_cfg["OPTIONS"] = "TS2=9990;TIMER=10" + sys_cfg["_reset"] = True + sys_cfg["_opt_key"] = "stale" + sys_cfg["_options_static_apply_fp"] = "|9990|10" + + bridges = active_bridge(9990, (("MASTER-A", 2),)) + for entry in bridges["9990"]: + if entry["SYSTEM"] == "MASTER-A": + entry["ACTIVE"] = False + entry["TO_TYPE"] = "ON" + + scenario = DeterministicScenario(config=config, bridges=bridges) + scenario.bridge.bridge_reset_loop() + + sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"] + assert sys_cfg["_reset"] is False + assert "_opt_key" not in sys_cfg + assert "_options_static_apply_fp" not in sys_cfg + + leg = next(e for e in scenario.bridge.get_bridges()["9990"] if e["SYSTEM"] == "MASTER-A") + assert leg["ACTIVE"] is True + assert leg["TO_TYPE"] == "NONE" + + +def test_options_applies_valid_static_tg_once() -> None: + scenario = _master_with_options("TS2=52090;TIMER=10") + scenario.bridge.options_config_for_system("MASTER-A") + + sys_cfg = scenario.config["SYSTEMS"]["MASTER-A"] + assert sys_cfg["TS2_STATIC"] == "52090" + assert "52090" in scenario.bridge.get_bridges() + fp = sys_cfg.get("_options_static_apply_fp") + assert fp == "|52090|10" + + scenario.bridge.options_config_for_system("MASTER-A") + assert scenario.config["SYSTEMS"]["MASTER-A"].get("_options_static_apply_fp") == fp diff --git a/tests/bridge/test_unit_data_ingress.py b/tests/bridge/test_unit_data_ingress.py new file mode 100644 index 0000000..efc540c --- /dev/null +++ b/tests/bridge/test_unit_data_ingress.py @@ -0,0 +1,65 @@ +"""Unit data ingress: headers, CSBK, reporting.""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_inject_ok, assert_report_event +from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config + +from adn_server.domain import bytes_4 + + +@pytest.mark.behavior +def test_unit_data_header_accepted_from_hbp() -> None: + """Regression: unit-data header (dtype 6) is accepted and reported.""" + scenario = DeterministicScenario(enable_reporting=True) + base = PacketSpec(call_type="unit", dst_id=5001, stream_id=0x51515151, slot=2) + + ok = scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base)) + + assert_inject_ok(ok) + assert_report_event(scenario, "UNIT DATA HEADER") + slot = scenario.protocols["MASTER-A"].STATUS[2] + assert slot.get("RX_STREAM_ID") == bytes_4(base.stream_id) + + +def test_unit_data_reports_rx_event_when_reporting_enabled() -> None: + config = minimal_config(("MASTER-A",)) + scenario = DeterministicScenario(config=config, enable_reporting=True) + base = PacketSpec(call_type="unit", dst_id=5002, stream_id=0x56565656, slot=2) + + scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base, dtype_vseq=7)) + + assert scenario.report_factory is not None + assert any("UNIT VCSBK 1/2 DATA BLOCK" in ev for ev in scenario.report_factory.events) + + +@pytest.mark.behavior +def test_unit_data_csbk_new_stream_is_handled() -> None: + """Regression: CSBK dtype 3 on new stream is accepted and reported.""" + scenario = DeterministicScenario(enable_reporting=True) + base = PacketSpec(call_type="unit", dst_id=5003, stream_id=0x63636363, slot=2) + + ok = scenario.inject_unit( + "MASTER-A", + DeterministicScenario.unit_data_header_spec(base, dtype_vseq=3), + ) + + assert_inject_ok(ok) + assert_report_event(scenario, "UNIT CSBK") + assert scenario.protocols["MASTER-A"].STATUS[2].get("RX_STREAM_ID") == bytes_4(base.stream_id) + + +def test_unit_data_csbk_ignored_when_stream_already_known() -> None: + scenario = DeterministicScenario() + base = PacketSpec(call_type="unit", dst_id=5004, stream_id=0x64646464, slot=2) + stream_bytes = base.data()[16:20] + scenario.protocols["MASTER-A"].STATUS[2]["RX_STREAM_ID"] = stream_bytes + + ok = scenario.inject_unit( + "MASTER-A", + DeterministicScenario.unit_data_header_spec(base, dtype_vseq=3), + ) + + assert ok is True + assert scenario.capture.packets == [] diff --git a/tests/bridge/test_unit_data_routing.py b/tests/bridge/test_unit_data_routing.py new file mode 100644 index 0000000..e9d16bf --- /dev/null +++ b/tests/bridge/test_unit_data_routing.py @@ -0,0 +1,93 @@ +"""Unit data routing: SUB_MAP, hotspot, gateway, OBP fanout.""" + +from __future__ import annotations + +import pytest +from tests.bridge.unit_data_helpers import idle_hbp_slot +from tests.harness.assertions import assert_forwarded, assert_not_forwarded +from tests.harness.deterministic import ( + DeterministicScenario, + PacketSpec, + add_openbridge_system, + minimal_config, + parse_dmr_fields, + patch_bridge_wall_time, +) + +from adn_server.domain import bytes_3 +from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM + + +@pytest.mark.behavior +def test_unit_data_sub_map_forwards_to_idle_hbp() -> None: + config = minimal_config(("MASTER-A", "MASTER-B")) + dst_sub = 7123456 + config["_SUB_MAP"] = {bytes_3(dst_sub): ("MASTER-B", 2, 1000.0)} + scenario = DeterministicScenario(config=config) + scenario.protocols["MASTER-B"].STATUS[2] = idle_hbp_slot() + base = PacketSpec(call_type="unit", dst_id=dst_sub, stream_id=0x52525252, slot=2) + + scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base)) + + assert_forwarded(scenario, "MASTER-B", count=1, call_type="unit") + + +def test_unit_data_sub_map_skips_busy_hbp_target() -> None: + config = minimal_config(("MASTER-A", "MASTER-B")) + dst_sub = 7123456 + config["_SUB_MAP"] = {bytes_3(dst_sub): ("MASTER-B", 2, 1000.0)} + scenario = DeterministicScenario(config=config) + scenario.protocols["MASTER-B"].STATUS[2] = { + "RX_TYPE": HBPF_SLT_VHEAD, + "TX_TYPE": HBPF_SLT_VTERM, + "TX_TIME": scenario.clock.time(), + } + base = PacketSpec(call_type="unit", dst_id=dst_sub, stream_id=0x53535353, slot=2) + + scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base)) + + assert_not_forwarded(scenario, "MASTER-B") + + +@pytest.mark.behavior +def test_unit_data_hotspot_peer_match_forwards_to_idle_hbp() -> None: + """Regression: hotspot peer ID match forwards unit data to idle MASTER slot.""" + config = minimal_config(("MASTER-A", "MASTER-B")) + config["SYSTEMS"]["MASTER-B"]["PEERS"] = { + 1234567890: {"CALLSIGN": "HS1", "IP": "127.0.0.1", "PORT": 62040}, + } + config["SYSTEMS"]["MASTER-B"]["GROUP_HANGTIME"] = 0 + scenario = DeterministicScenario(config=config) + scenario.protocols["MASTER-B"].STATUS[2] = idle_hbp_slot() + base = PacketSpec(call_type="unit", dst_id=123456, stream_id=0x54545454, slot=2) + + scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base)) + + assert_forwarded(scenario, "MASTER-B", count=1, call_type="unit", dst_id=123456) + + +def test_unit_data_forwards_to_data_gateway_when_enabled() -> None: + config = minimal_config(("MASTER-A",)) + config["GLOBAL"]["DATA_GATEWAY"] = True + add_openbridge_system(config, "DATA-GATEWAY") + config["SYSTEMS"]["DATA-GATEWAY"]["ENABLED"] = True + scenario = DeterministicScenario(config=config) + base = PacketSpec(call_type="unit", dst_id=5005, stream_id=0x65656565, slot=2) + + with patch_bridge_wall_time(scenario.clock): + scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base)) + + assert len(scenario.capture.for_system("DATA-GATEWAY")) == 1 + + +def test_unit_data_fanout_to_other_obp_with_ver_gt_1() -> None: + config = minimal_config(("MASTER-A",)) + add_openbridge_system(config, "OBP-FAN") + scenario = DeterministicScenario(config=config) + base = PacketSpec(call_type="unit", dst_id=1000001, stream_id=0x66666666, slot=2) + + with patch_bridge_wall_time(scenario.clock): + scenario.inject_unit("MASTER-A", DeterministicScenario.unit_data_header_spec(base)) + + assert len(scenario.capture.for_system("OBP-FAN")) == 1 + assert parse_dmr_fields(scenario.capture.for_system("OBP-FAN")[0].packet)["call_type"] == "unit" diff --git a/tests/bridge/unit_data_helpers.py b/tests/bridge/unit_data_helpers.py new file mode 100644 index 0000000..2a89abd --- /dev/null +++ b/tests/bridge/unit_data_helpers.py @@ -0,0 +1,13 @@ +"""Shared helpers for bridge unit-data tests.""" + +from __future__ import annotations + +from adn_server.domain.hbp_protocol import HBPF_SLT_VTERM + + +def idle_hbp_slot() -> dict: + return { + "RX_TYPE": HBPF_SLT_VTERM, + "TX_TYPE": HBPF_SLT_VTERM, + "TX_TIME": 0.0, + } diff --git a/tests/fixtures/sample_freedmr.cfg b/tests/fixtures/sample_freedmr.cfg new file mode 100644 index 0000000..1d6f611 --- /dev/null +++ b/tests/fixtures/sample_freedmr.cfg @@ -0,0 +1,149 @@ +[GLOBAL] +PATH: ./ +PING_TIME: 10 +MAX_MISSED: 3 +USE_ACL: True +REG_ACL: PERMIT:ALL +SUB_ACL: DENY:1 +TGID_TS1_ACL: PERMIT:ALL +TGID_TS2_ACL: PERMIT:ALL +GEN_STAT_BRIDGES: True +ALLOW_NULL_PASSPHRASE: True +ANNOUNCEMENT_LANGUAGES: +SERVER_ID: 73010 +DATA_GATEWAY: False +VALIDATE_SERVER_IDS: True +URL_SECURITY: 143.47.40.69 +PORT_SECURITY: 7070 +PASS_SECURITY: secret +USERS_PASS: user_passwords.json +HASH_ENCRYPT: encryption_key.secret + +[REPORTS] +REPORT: True +REPORT_INTERVAL: 60 +REPORT_PORT: 4321 +REPORT_CLIENTS: 127.0.0.1 + +[LOGGER] +LOG_FILE: /var/log/FreeDMR/FreeDMR.log +LOG_HANDLERS: file-timed +LOG_LEVEL: INFO +LOG_NAME: FreeDMR + +[ALIASES] +TRY_DOWNLOAD: True +PATH: ./ +PEER_FILE: peer_ids.json +SUBSCRIBER_FILE: subscriber_ids.json +TGID_FILE: talkgroup_ids.json +PEER_URL: https://adn.systems/files/peer_ids.json +SUBSCRIBER_URL: https://adn.systems/files/subscriber_ids.json +TGID_URL: https://adn.systems/files/talkgroup_ids.json +LOCAL_SUBSCRIBER_FILE: local_subcriber_ids.json +STALE_DAYS: 1 +SUB_MAP_FILE: +SERVER_ID_URL: https://adn.systems/files/server_ids.tsv +SERVER_ID_FILE: server_ids.tsv +CHECKSUM_URL: https://adn.systems/files/file_checksums.json +CHECKSUM_FILE: file_checksums.json + +[ALLSTAR] +ENABLED: False +USER: llcgi +PASS: mypass +SERVER: my.asl.server +PORT: 5038 +NODE: 0000 + +[SYSTEM] +MODE: MASTER +ENABLED: True +REPEAT: True +MAX_PEERS: 2 +EXPORT_AMBE: False +IP: 127.0.0.1 +PORT: 56400 +PASSPHRASE: passw0rd +GROUP_HANGTIME: 5 +USE_ACL: True +REG_ACL: DENY:1 +SUB_ACL: DENY:1 +TGID_TS1_ACL: PERMIT:ALL +TGID_TS2_ACL: PERMIT:ALL +DEFAULT_UA_TIMER: 60 +SINGLE_MODE: False +VOICE_IDENT: False +TS1_STATIC: +TS2_STATIC: +DEFAULT_REFLECTOR: 0 +ANNOUNCEMENT_LANGUAGE: es_ES +GENERATOR: 102 +ALLOW_UNREG_ID: False +PROXY_CONTROL: False +OVERRIDE_IDENT_TG: + +[ECHO] +MODE: MASTER +ENABLED: True +REPEAT: True +MAX_PEERS: 1 +EXPORT_AMBE: False +IP: 127.0.0.1 +PORT: 54917 +PASSPHRASE: passw0rd +GROUP_HANGTIME: 5 +USE_ACL: True +REG_ACL: DENY:1 +SUB_ACL: DENY:1 +TGID_TS1_ACL: DENY:ALL +TGID_TS2_ACL: PERMIT:9990 +DEFAULT_UA_TIMER: 1 +SINGLE_MODE: True +VOICE_IDENT: False +TS1_STATIC: +TS2_STATIC: 9990 +DEFAULT_REFLECTOR: 0 +ANNOUNCEMENT_LANGUAGE: en_GB +GENERATOR: 0 +ALLOW_UNREG_ID: True +PROXY_CONTROL: False +OVERRIDE_IDENT_TG: + +[OBP-ES] +MODE: OPENBRIDGE +ENABLED: True +IP: 44.31.61.66 +PORT: 62047 +NETWORK_ID: 21410 +PASSPHRASE: freedmr +TARGET_IP: freedmr-spain.duckdns.org +TARGET_PORT: 62047 +BOTH_SLOTS: True +USE_ACL: True +SUB_ACL: DENY:0-1000000 +TGID_ACL: DENY:0-82,92-199,800-899,9990-9999,730999 +TGID_TS1_ACL: DENY :0-89 +DEFAULT_UA_TIMER: 15 +RELAX_CHECKS: True +ENHANCED_OBP: True +PROTO_VER: 5 + +[OBP-UY] +MODE: OPENBRIDGE +ENABLED: False +IP: 44.31.61.66 +PORT: 62046 +NETWORK_ID: 74810 +PASSPHRASE: freedmr +TARGET_IP: 179.27.97.125 +TARGET_PORT: 62046 +BOTH_SLOTS: True +USE_ACL: True +SUB_ACL: DENY:0-1000000 +TGID_ACL: DENY:0-82,92-199,800-899,9990-9999,730999 +TGID_TS1_ACL: DENY :0-89 +DEFAULT_UA_TIMER: 15 +RELAX_CHECKS: True +ENHANCED_OBP: True +PROTO_VER: 5 diff --git a/tests/harness/__init__.py b/tests/harness/__init__.py new file mode 100644 index 0000000..f3c450c --- /dev/null +++ b/tests/harness/__init__.py @@ -0,0 +1,23 @@ +"""Test-only packet harnesses for adn-server (in-process deterministic layer).""" + +from tests.harness.assertions import ( + assert_all_dmr_fields, + assert_capture_unchanged, + assert_dmra_sent, + assert_forwarded, + assert_inject_ok, + assert_not_forwarded, + assert_report_event, + packets_to, +) + +__all__ = [ + "assert_all_dmr_fields", + "assert_capture_unchanged", + "assert_dmra_sent", + "assert_forwarded", + "assert_inject_ok", + "assert_not_forwarded", + "assert_report_event", + "packets_to", +] diff --git a/tests/harness/assertions.py b/tests/harness/assertions.py new file mode 100644 index 0000000..0ced79a --- /dev/null +++ b/tests/harness/assertions.py @@ -0,0 +1,78 @@ +"""Reusable assertion helpers for deterministic harness tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from adn_server.domain import int_id + +if TYPE_CHECKING: + from tests.harness.deterministic import CapturedPacket, DeterministicScenario + + +def packets_to(scenario: DeterministicScenario, system: str) -> list[CapturedPacket]: + return scenario.capture.for_system(system) + + +def assert_forwarded( + scenario: DeterministicScenario, + system: str, + *, + count: int | None = 1, + call_type: str | None = None, + dst_id: int | None = None, +) -> list[CapturedPacket]: + got = packets_to(scenario, system) + if count is not None: + assert len(got) == count, f"expected {count} packet(s) to {system!r}, got {len(got)}" + for pkt in got: + if call_type is not None: + assert pkt.fields.get("call_type") == call_type, pkt.fields + if dst_id is not None: + assert int_id(pkt.fields["dst_id"]) == dst_id, pkt.fields + return got + + +def assert_not_forwarded(scenario: DeterministicScenario, system: str) -> None: + got = packets_to(scenario, system) + assert got == [], f"expected no packets to {system!r}, got {len(got)}" + + +def assert_capture_unchanged(scenario: DeterministicScenario, system: str, before: int) -> None: + after = len(packets_to(scenario, system)) + assert after == before, f"capture to {system!r} changed: {before} -> {after}" + + +def assert_report_event(scenario: DeterministicScenario, *substrings: str) -> None: + assert scenario.report_factory is not None, "reporting not enabled on scenario" + events = scenario.report_factory.events + for sub in substrings: + assert any(sub in ev for ev in events), f"no report event containing {sub!r}: {events}" + + +def assert_dmra_sent( + scenario: DeterministicScenario, + *, + min_count: int = 1, + payload_contains: bytes | None = None, +) -> None: + assert len(scenario.dmra_capture) >= min_count + if payload_contains is not None: + payloads = [p for cap in scenario.dmra_capture for p in cap.packets] + assert any(payload_contains in p for p in payloads), payloads + + +def assert_inject_ok(result: bool | None, *, expected: bool = True) -> None: + if expected: + assert result is not False, "inject returned False (dropped)" + else: + assert result is not True, f"inject should have dropped, got {result!r}" + + +def assert_all_dmr_fields(packets: list[CapturedPacket], **expected: Any) -> None: + for pkt in packets: + for key, value in expected.items(): + actual = pkt.fields.get(key) + if key == "dst_id" and isinstance(value, int): + actual = int_id(actual) + assert actual == value, f"field {key}: expected {value!r}, got {actual!r}" diff --git a/tests/harness/deterministic.py b/tests/harness/deterministic.py new file mode 100644 index 0000000..ca6310f --- /dev/null +++ b/tests/harness/deterministic.py @@ -0,0 +1,633 @@ +"""In-process deterministic harness for BridgeUseCases. + +Inject at ``BridgeUseCases.dmrd_received()`` and capture outbound +``send_to_system`` calls without UDP or Twisted. +""" + +from __future__ import annotations + +import copy +from contextlib import contextmanager +from dataclasses import dataclass, field +from time import perf_counter +from typing import Any + +from adn_server.application.bridge_use_cases import BridgeUseCases +from adn_server.application.reporting_use_cases import ReportingUseCases +from adn_server.domain.dmr.bptc import encode_emblc +from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder +from adn_server.domain import bytes_3, bytes_4 +from adn_server.domain.hbp_protocol import ( + HBPF_DATA_SYNC, + HBPF_SLT_VHEAD, + HBPF_SLT_VTERM, + HBPF_VOICE, +) +from adn_server.infrastructure.bridge_router_impl import InMemoryBridgeRouter +from adn_server.infrastructure.hbp_constants import DMRD + +ID_MAX = 16776415 +PEER_MAX = 4294967295 + + +def acl_permit_all(max_id: int = ID_MAX) -> tuple[bool, list[tuple[int, int]]]: + return True, [(1, max_id)] + + +def hbp_bits(slot: int, call_type: str, frame_type: int, dtype_vseq: int) -> int: + bits = ((frame_type & 0x3) << 4) | (dtype_vseq & 0xF) + if slot == 2: + bits |= 0x80 + if call_type == "unit": + bits |= 0x40 + return bits + + +def parse_dmr_fields(packet: bytes) -> dict[str, Any]: + if len(packet) < 20 or packet[:4] != DMRD: + return {"raw": packet} + + bits = packet[15] + if bits & 0x40: + call_type = "unit" + elif (bits & 0x23) == 0x23: + call_type = "vcsbk" + else: + call_type = "group" + + return { + "opcode": packet[:4], + "seq": packet[4], + "rf_src": packet[5:8], + "dst_id": packet[8:11], + "peer_id": packet[11:15], + "bits": bits, + "slot": 2 if bits & 0x80 else 1, + "call_type": call_type, + "frame_type": (bits & 0x30) >> 4, + "dtype_vseq": bits & 0xF, + "stream_id": packet[16:20], + "dmr_payload": packet[20:53] if len(packet) >= 53 else b"", + "ber": packet[53:54] if len(packet) >= 54 else b"", + "rssi": packet[54:55] if len(packet) >= 55 else b"", + } + + +@dataclass(frozen=True) +class PacketSpec: + peer_id: int | bytes = 1001 + rf_src: int | bytes = 3120001 + dst_id: int | bytes = 91 + slot: int = 2 + stream_id: int | bytes = 0x01020304 + seq: int = 0 + call_type: str = "group" + frame_type: int = HBPF_VOICE + dtype_vseq: int = 0 + payload: bytes = b"\x00" * 33 + ber: bytes = b"\x00" + rssi: bytes = b"\x00" + + def data(self) -> bytes: + if len(self.payload) != 33: + raise ValueError("DMR payload must be exactly 33 bytes") + return b"".join( + [ + DMRD, + bytes([self.seq & 0xFF]), + bytes_3(self.rf_src), + bytes_3(self.dst_id), + bytes_4(self.peer_id), + bytes([hbp_bits(self.slot, self.call_type, self.frame_type, self.dtype_vseq)]), + bytes_4(self.stream_id), + self.payload, + self.ber, + self.rssi, + ] + ) + + def decoded_hbp_args(self) -> dict[str, Any]: + return { + "peer_id": bytes_4(self.peer_id), + "rf_src": bytes_3(self.rf_src), + "dst_id": bytes_3(self.dst_id), + "seq": self.seq & 0xFF, + "slot": self.slot, + "call_type": self.call_type, + "frame_type": self.frame_type, + "dtype_vseq": self.dtype_vseq, + "stream_id": bytes_4(self.stream_id), + "data": self.data(), + } + + +@dataclass +class CapturedPacket: + target_system: str + packet: bytes + hops: bytes | None = None + ber: bytes = b"\x00" + rssi: bytes = b"\x00" + source_server: bytes = b"\x00\x00\x00\x00" + source_rptr: bytes = b"\x00\x00\x00\x00" + fields: dict[str, Any] = field(init=False) + + def __post_init__(self) -> None: + self.fields = parse_dmr_fields(self.packet) + + +class PacketCapture: + def __init__(self) -> None: + self.packets: list[CapturedPacket] = [] + + def recorder(self, target_system: str): + def record( + packet: bytes, + hops: bytes | None = b"", + ber: bytes = b"\x00", + rssi: bytes = b"\x00", + source_server: bytes = b"\x00\x00\x00\x00", + source_rptr: bytes = b"\x00\x00\x00\x00", + ) -> None: + self.packets.append( + CapturedPacket( + target_system=target_system, + packet=packet, + hops=hops, + ber=ber, + rssi=rssi, + source_server=source_server, + source_rptr=source_rptr, + ) + ) + + return record + + def for_system(self, system: str) -> list[CapturedPacket]: + return [p for p in self.packets if p.target_system == system] + + +class FakeClock: + def __init__(self, start: float = 1_700_000_000.0) -> None: + self.now = float(start) + + def time(self) -> float: + return self.now + + def advance(self, seconds: float) -> float: + self.now += seconds + return self.now + + +class FakeHbpProtocol: + """Minimal MASTER/PEER STATUS keyed by slot.""" + + def __init__(self, name: str) -> None: + self.name = name + self.STATUS: dict[int, dict[str, Any]] = {1: {}, 2: {}} + + +class FakeReportSender: + """ReportSender port adapter for harness (events land in FakeReportFactory).""" + + def __init__(self, factory: FakeReportFactory) -> None: + self._factory = factory + + def send_config(self, systems: dict[str, Any]) -> None: + pass + + def send_bridge(self, bridges: dict[str, Any]) -> None: + pass + + def send_bridge_event(self, event: str) -> None: + self._factory.send_bridge_event(event) + + +class FakeReportFactory: + """Minimal report sink so OBP VTERM sets ``_fin``.""" + + def __init__(self) -> None: + self.events: list[str] = [] + + def send_bridge_event(self, msg: str) -> None: + self.events.append(msg) + + +class FakeObpProtocol: + """Minimal OPENBRIDGE STATUS keyed by stream_id.""" + + def __init__(self, name: str) -> None: + self.name = name + self.STATUS: dict[bytes, dict[str, Any]] = {} + + +def minimal_config(system_names: tuple[str, ...] = ("MASTER-A", "MASTER-B")) -> dict[str, Any]: + config: dict[str, Any] = { + "GLOBAL": { + "SERVER_ID": bytes_4(9990), + "USE_ACL": False, + "TG1_ACL": acl_permit_all(), + "TG2_ACL": acl_permit_all(), + "SUB_ACL": acl_permit_all(), + "GEN_STAT_BRIDGES": False, + "VALIDATE_SERVER_IDS": False, + "TALKER_ALIAS": False, + }, + "REPORTS": {"REPORT": False}, + "ALIASES": {"PATH": "./", "SUB_MAP_FILE": ""}, + "SYSTEMS": {}, + } + for name in system_names: + config["SYSTEMS"][name] = { + "MODE": "MASTER", + "ENABLED": True, + "REPEAT": True, + "MAX_PEERS": 1, + "IP": "127.0.0.1", + "PORT": 0, + "PASSPHRASE": b"", + "GROUP_HANGTIME": 0, + "USE_ACL": False, + "REG_ACL": acl_permit_all(PEER_MAX), + "SUB_ACL": acl_permit_all(), + "TG1_ACL": acl_permit_all(), + "TG2_ACL": acl_permit_all(), + "DEFAULT_UA_TIMER": 1, + "SINGLE_MODE": True, + "VOICE_IDENT": False, + "TS1_STATIC": "", + "TS2_STATIC": "", + "DEFAULT_REFLECTOR": 0, + "GENERATOR": 0, + "ALLOW_UNREG_ID": True, + "PEERS": {}, + } + return config + + +def add_openbridge_system( + config: dict[str, Any], + name: str = "OBP-CL", + *, + enhanced: bool = False, +) -> None: + config["SYSTEMS"][name] = { + "MODE": "OPENBRIDGE", + "ENABLED": True, + "NETWORK_ID": bytes_4(1), + "IP": "127.0.0.1", + "PORT": 0, + "PASSPHRASE": b"test-passphrase\x00\x00\x00\x00\x00\x00", + "TARGET_IP": "127.0.0.1", + "TARGET_PORT": 0, + "TARGET_SOCK": ("127.0.0.1", 0), + "USE_ACL": False, + "SUB_ACL": acl_permit_all(), + "TG1_ACL": acl_permit_all(), + "TG2_ACL": acl_permit_all(), + "RELAX_CHECKS": True, + "ENHANCED_OBP": enhanced, + "VER": 5, + } + + +@dataclass +class CapturedDmra: + target_system: str + packets: list[bytes] + exclude_peer: bytes | None = None + + +@dataclass +class CapturedBcsq: + system_name: str + tgid: bytes + stream_id: bytes + + +@contextmanager +def patch_bridge_wall_time(clock: FakeClock): + """Patch ``bridge_use_cases.time.time`` to the harness clock (OBP path).""" + import adn_server.application.bridge_use_cases as buc + + original = buc.time.time + buc.time.time = clock.time + try: + yield + finally: + buc.time.time = original + + +def active_bridge( + tg_id: int, + entries: tuple[tuple[str, int], ...], + timeout_minutes: int = 1, +) -> dict[str, list[dict[str, Any]]]: + tg_bytes = bytes_3(tg_id) + key = str(tg_id) + return { + key: [ + { + "SYSTEM": system, + "TS": slot, + "TGID": tg_bytes, + "ACTIVE": True, + "TIMEOUT": timeout_minutes * 60, + "TO_TYPE": "ON", + "OFF": [], + "ON": [tg_bytes], + "RESET": [], + "TIMER": 0, + } + for system, slot in entries + ] + } + + +class DeterministicScenario: + """Wires BridgeUseCases with fake protocols and outbound capture.""" + + def __init__( + self, + config: dict[str, Any] | None = None, + bridges: dict[str, list[dict[str, Any]]] | None = None, + *, + enable_reporting: bool = False, + ) -> None: + self.config = copy.deepcopy(config or minimal_config()) + if enable_reporting: + self.config.setdefault("REPORTS", {})["REPORT"] = True + self.clock = FakeClock() + self.capture = PacketCapture() + self.dmra_capture: list[CapturedDmra] = [] + self.bcsq_capture: list[CapturedBcsq] = [] + self.report_factory = FakeReportFactory() if enable_reporting else None + self.reporting = ( + ReportingUseCases(FakeReportSender(self.report_factory), self.config) + if self.report_factory + else None + ) + self.router = InMemoryBridgeRouter() + self.router.set_bridges(copy.deepcopy(bridges or {})) + self.protocols: dict[str, FakeHbpProtocol | FakeObpProtocol] = {} + self._wire_protocols_from_config() + self.bridge = BridgeUseCases( + self.router, + self.config, + send_to_system=self._send_capture, + get_protocols=lambda: self.protocols, + reporting=self.reporting, + send_bcsq=self._send_bcsq_capture, + send_dmra_to_system=self._send_dmra_capture, + get_dmra_blocks=lambda _sys, _sid: None, + encode_emblc=encode_emblc, + ta_emblc_encoder=default_ta_emblc_encoder, + ) + + def _wire_protocols_from_config(self) -> None: + self.protocols.clear() + for name, syscfg in self.config.get("SYSTEMS", {}).items(): + if syscfg.get("MODE") == "OPENBRIDGE": + self.protocols[name] = FakeObpProtocol(name) + else: + self.protocols[name] = FakeHbpProtocol(name) + + def _send_capture(self, target_system: str, packet: bytes, **kwargs: Any) -> None: + self.capture.recorder(target_system)( + packet, + hops=kwargs.get("_hops", kwargs.get("hops", b"")), + ber=kwargs.get("_ber", kwargs.get("ber", b"\x00")), + rssi=kwargs.get("_rssi", kwargs.get("rssi", b"\x00")), + source_server=kwargs.get("_source_server", kwargs.get("source_server", b"\x00\x00\x00\x00")), + source_rptr=kwargs.get("_source_rptr", kwargs.get("source_rptr", b"\x00\x00\x00\x00")), + ) + + def _send_bcsq_capture(self, system_name: str, tgid: bytes, stream_id: bytes) -> None: + self.bcsq_capture.append(CapturedBcsq(system_name, tgid, stream_id)) + + def _send_dmra_capture( + self, + target_system: str, + packets: list[bytes], + exclude_peer: bytes | None = None, + ) -> int: + self.dmra_capture.append( + CapturedDmra(target_system, list(packets), exclude_peer=exclude_peer) + ) + return 0 if exclude_peer else len(packets) + + def seed_obp_stream( + self, + system_name: str, + stream_id: int | bytes, + *, + tgid: int | bytes = 52090, + rf_src: int | bytes = 3120001, + peer_id: int | bytes = 1001, + first_at: float | None = None, + ) -> None: + """Pre-populate OBP STATUS for loop-control scenarios.""" + proto = self.protocols[system_name] + if not isinstance(proto, FakeObpProtocol): + raise TypeError(f"{system_name} is not OPENBRIDGE") + sid = bytes_4(stream_id) + t0 = self.clock.time() if first_at is None else first_at + proto.STATUS[sid] = { + "START": t0, + "CONTENTION": False, + "RFS": bytes_3(rf_src), + "TGID": bytes_3(tgid), + "1ST": perf_counter() - 1.0, + "lastSeq": False, + "lastData": False, + "RX_PEER": bytes_4(peer_id), + "packets": 1, + "loss": 0, + "crcs": set(), + "LC": b"\x00\x00\x00" + bytes_3(tgid) + bytes_3(rf_src), + } + + def seed_hbp_slot_stream( + self, + system_name: str, + slot: int, + stream_id: int | bytes, + *, + tgid: int | bytes = 91, + rf_src: int | bytes = 3120001, + peer_id: int | bytes = 1001, + ) -> None: + """Pre-populate competing HBP slot STATUS for loop-control scenarios.""" + proto = self.protocols[system_name] + if not isinstance(proto, FakeHbpProtocol): + raise TypeError(f"{system_name} is not HBP") + proto.STATUS[slot] = { + "RX_STREAM_ID": bytes_4(stream_id), + "RX_PEER": bytes_4(peer_id), + "RX_RFS": bytes_3(rf_src), + "RX_TGID": bytes_3(tgid), + "RX_TIME": self.clock.time(), + "RX_START": self.clock.time(), + "packets": 1, + } + + def _sync_hbp_slot(self, system_name: str, args: dict[str, Any]) -> None: + """Mirror udp_hbp STATUS[slot] updates after an accepted HBP packet.""" + proto = self.protocols.get(system_name) + if not isinstance(proto, FakeHbpProtocol): + return + slot = args["slot"] + st = proto.STATUS.setdefault(slot, {}) + st["RX_PEER"] = args["peer_id"] + st["RX_SEQ"] = args["seq"] + st["RX_RFS"] = args["rf_src"] + st["RX_TYPE"] = args["dtype_vseq"] + st["RX_TGID"] = args["dst_id"] + st["RX_TIME"] = self.clock.time() + st["RX_STREAM_ID"] = args["stream_id"] + if args["frame_type"] == HBPF_DATA_SYNC and args["dtype_vseq"] == HBPF_SLT_VHEAD: + st["RX_LC"] = bytes_3(0) + args["dst_id"] + args["rf_src"] + + def inject_hbp( + self, + system_name: str, + spec: PacketSpec, + *, + ingress_pkt_time: float | None = None, + ) -> bool | None: + args = spec.decoded_hbp_args() + pkt_time = self.clock.time() if ingress_pkt_time is None else ingress_pkt_time + ok = self.bridge.dmrd_received(system_name, ingress_pkt_time=pkt_time, **args) + if ok is not False: + self._sync_hbp_slot(system_name, args) + return ok + + def inject_unit( + self, + system_name: str, + spec: PacketSpec, + ) -> bool | None: + """Inject a unit (private) call packet; patches bridge wall time to harness clock.""" + args = spec.decoded_hbp_args() + with patch_bridge_wall_time(self.clock): + ok = self.bridge.dmrd_received(system_name, **args) + if ok is not False: + self._sync_hbp_slot(system_name, args) + return ok + + def inject_obp( + self, + system_name: str, + spec: PacketSpec, + *, + obp_hops: bytes = b"\x00\x00\x00\x00", + obp_source_server: bytes | None = None, + ) -> bool | None: + args = spec.decoded_hbp_args() + return self.bridge.dmrd_received( + system_name, + obp_use_parsed=True, + obp_hops=obp_hops, + obp_source_server=obp_source_server or bytes_4(9990), + **args, + ) + + @staticmethod + def voice_head_spec(base: PacketSpec) -> PacketSpec: + return PacketSpec( + peer_id=base.peer_id, + rf_src=base.rf_src, + dst_id=base.dst_id, + slot=base.slot, + stream_id=base.stream_id, + seq=0, + call_type=base.call_type, + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + payload=base.payload, + ) + + @staticmethod + def voice_burst_spec(base: PacketSpec, seq: int, dtype_vseq: int = 0) -> PacketSpec: + return PacketSpec( + peer_id=base.peer_id, + rf_src=base.rf_src, + dst_id=base.dst_id, + slot=base.slot, + stream_id=base.stream_id, + seq=seq, + call_type=base.call_type, + frame_type=HBPF_VOICE, + dtype_vseq=dtype_vseq, + payload=base.payload, + ) + + @staticmethod + def voice_term_spec(base: PacketSpec, seq: int = 99) -> PacketSpec: + return PacketSpec( + peer_id=base.peer_id, + rf_src=base.rf_src, + dst_id=base.dst_id, + slot=base.slot, + stream_id=base.stream_id, + seq=seq, + call_type=base.call_type, + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VTERM, + payload=base.payload, + ) + + @staticmethod + def unit_voice_head_spec( + base: PacketSpec, + *, + dst_id: int | bytes | None = None, + rf_src: int | bytes | None = None, + ) -> PacketSpec: + return PacketSpec( + peer_id=base.peer_id, + rf_src=rf_src if rf_src is not None else base.rf_src, + dst_id=dst_id if dst_id is not None else base.dst_id, + slot=base.slot, + stream_id=base.stream_id, + seq=0, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=HBPF_SLT_VHEAD, + payload=base.payload, + ) + + @staticmethod + def unit_voice_burst_spec(base: PacketSpec, seq: int, dtype_vseq: int = 1) -> PacketSpec: + return PacketSpec( + peer_id=base.peer_id, + rf_src=base.rf_src, + dst_id=base.dst_id, + slot=base.slot, + stream_id=base.stream_id, + seq=seq, + call_type="unit", + frame_type=HBPF_VOICE, + dtype_vseq=dtype_vseq, + payload=base.payload, + ) + + @staticmethod + def unit_data_header_spec( + base: PacketSpec, + *, + dst_id: int | bytes | None = None, + dtype_vseq: int = 6, + ) -> PacketSpec: + return PacketSpec( + peer_id=base.peer_id, + rf_src=base.rf_src, + dst_id=dst_id if dst_id is not None else base.dst_id, + slot=base.slot, + stream_id=base.stream_id, + seq=0, + call_type="unit", + frame_type=HBPF_DATA_SYNC, + dtype_vseq=dtype_vseq, + payload=base.payload, + ) diff --git a/tests/harness/playback_helpers.py b/tests/harness/playback_helpers.py new file mode 100644 index 0000000..5362e3f --- /dev/null +++ b/tests/harness/playback_helpers.py @@ -0,0 +1,70 @@ +"""Helpers for PlaybackUseCases tests without a live reactor.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from tests.harness.deterministic import PacketSpec + + +class FakePlaybackProtocol: + def __init__(self) -> None: + self.sent: list[bytes] = [] + self.STATUS: dict[int, dict[str, Any]] = {1: {}, 2: {}} + + def send_system(self, packet: bytes) -> None: + self.sent.append(packet) + + +def packet_bytes(spec: PacketSpec) -> bytes: + return spec.data() + + +def install_reactor_capture() -> tuple[MagicMock, list[tuple[float, Any, tuple[Any, ...]]]]: + """Patch reactor.callLater; returns mock and scheduled (delay, fn, args) list.""" + scheduled: list[tuple[float, Any, tuple[Any, ...]]] = [] + mock_reactor = MagicMock() + + def call_later(delay: float, fn: Any, *args: Any) -> MagicMock: + scheduled.append((delay, fn, args)) + handle = MagicMock() + handle.active.return_value = True + handle.cancel = MagicMock() + return handle + + mock_reactor.callLater = call_later + return mock_reactor, scheduled + + +def run_scheduled( + scheduled: list[tuple[float, Any, tuple[Any, ...]]], + *, + delay: float | None = None, +) -> None: + pending = list(scheduled) + scheduled.clear() + for item_delay, fn, args in pending: + if delay is None or item_delay == delay: + fn(*args) + + +def send_playback(pb: Any, system: str, spec: PacketSpec, *, ingress_pkt_time: float | None = None) -> None: + args = spec.decoded_hbp_args() + kwargs: dict[str, Any] = {} + if ingress_pkt_time is not None: + kwargs["ingress_pkt_time"] = ingress_pkt_time + pb.dmrd_received( + system, + args["peer_id"], + args["rf_src"], + args["dst_id"], + args["seq"], + args["slot"], + args["call_type"], + args["frame_type"], + args["dtype_vseq"], + args["stream_id"], + spec.data(), + **kwargs, + ) diff --git a/tests/harness/scenarios.py b/tests/harness/scenarios.py new file mode 100644 index 0000000..cc3f77c --- /dev/null +++ b/tests/harness/scenarios.py @@ -0,0 +1,42 @@ +"""Shared deterministic scenario builders for domain tests.""" + +from __future__ import annotations + +from typing import Any + +from adn_server.application.talker_alias_use_cases import TalkerAliasUseCases +from adn_server.infrastructure.talker_alias_emblc import default_ta_emblc_encoder + +from .deterministic import ( + DeterministicScenario, + active_bridge, + add_openbridge_system, +) + + +def obp_bridge_scenario(*obp_names: str, tg: int = 52090) -> DeterministicScenario: + entries = [(name, 1) for name in obp_names] + [("MASTER-A", 2)] + bridges = active_bridge(tg, tuple(entries)) + config = DeterministicScenario().config + for name in obp_names: + add_openbridge_system(config, name) + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = str(tg) + return DeterministicScenario(config=config, bridges=bridges) + + +def talker_alias_config() -> dict[str, Any]: + config = DeterministicScenario().config + config["GLOBAL"]["TALKER_ALIAS"] = True + config["GLOBAL"]["TALKER_ALIAS_MODE"] = "inject" + config["GLOBAL"]["TALKER_ALIAS_FORMAT"] = "{callsign} {fname}" + rid = 3120001 + config["_SUB_PROFILES"] = { + rid: {"callsign": "CE5RPY", "fname": "Rodrigo", "surname": "Perez"}, + } + config["_SUB_IDS"] = {rid: "CE5RPY"} + return config + + +def make_talker_alias_use_cases(config: dict[str, Any]) -> TalkerAliasUseCases: + """Talker Alias use cases with production embedded-LC encoder (test harness).""" + return TalkerAliasUseCases(config, ta_emblc_encoder=default_ta_emblc_encoder) diff --git a/tests/harness/voice_helpers.py b/tests/harness/voice_helpers.py new file mode 100644 index 0000000..9f1565d --- /dev/null +++ b/tests/harness/voice_helpers.py @@ -0,0 +1,156 @@ +"""Shared fakes for VoiceUseCases tests.""" + +from __future__ import annotations + +from typing import Any, Iterator + +from adn_server.application.voice_use_cases import VoiceUseCases +from adn_server.domain import bytes_3, bytes_4 +from tests.harness.deterministic import DeterministicScenario, FakeHbpProtocol, active_bridge + + +class FakeVoiceProvider: + def get_ambe_words(self, languages: str, audio_path: str) -> dict[str, dict[str, Any]]: + return {languages: {"silence": b"\x00" * 7}} + + def pkt_gen( + self, + rf_src: bytes, + dst_id: bytes, + peer: bytes, + slot: int, + phrase: list[Any], + ) -> Iterator[bytes]: + del phrase + ts_bit = 0x80 if slot else 0 + for seq in range(3): + yield b"DMRD" + bytes([seq]) + rf_src[:3] + dst_id[:3] + peer[:4] + bytes([ts_bit | 0x10]) + bytes_4(0xA0A0A0A0 + seq) + b"\x00" * 33 + b"\x00\x00" + + def read_single_file(self, audio_path: str, lang: str, file_number: str) -> list: + del audio_path, lang, file_number + return [b"\x00" * 7] + + def ensure_tts_ambe(self, config: dict, item: dict, audio_path: str) -> str | None: + del config, item, audio_path + return "/tmp/fake.ambe" + + +class FakeMasterForVoice(FakeHbpProtocol): + def __init__(self, name: str) -> None: + super().__init__(name) + self._system = name + self.sent: list[bytes] = [] + + def send_system(self, packet: bytes) -> None: + self.sent.append(packet) + + +def voice_master_scenario(tg: int = 91) -> tuple[DeterministicScenario, FakeMasterForVoice]: + config = DeterministicScenario().config + master = FakeMasterForVoice("MASTER-A") + config["SYSTEMS"]["MASTER-A"]["PEERS"] = { + "1001": {"CALLSIGN": "TEST", "IP": "127.0.0.1", "PORT": 62032}, + } + bridges = active_bridge(tg, (("MASTER-A", 2),)) + scenario = DeterministicScenario(config=config, bridges=bridges) + scenario.protocols["MASTER-A"] = master + master.STATUS[2] = { + "RX_TYPE": 2, + "TX_TYPE": 2, + "RX_STREAM_ID": b"\x00" * 4, + } + return scenario, master + + +def reflector_bridge_entry(system: str = "MASTER-A", reflector: int = 310) -> dict[str, list[dict[str, Any]]]: + return { + "#{}".format(reflector): [ + { + "SYSTEM": system, + "TS": 2, + "TGID": bytes_3(9), + "ACTIVE": True, + "TIMEOUT": 600, + "TO_TYPE": "ON", + "ON": [bytes_3(reflector)], + "OFF": [], + "RESET": [], + "TIMER": 1000.0, + } + ] + } + + +def make_voice_uc( + scenario: DeterministicScenario, + master: FakeMasterForVoice, + *, + audio_path: str = "/tmp/audio", +) -> VoiceUseCases: + scheduled: list[tuple[float, tuple]] = [] + + def call_later(delay, fn, *args): + scheduled.append((delay, (fn, args))) + return type("H", (), {"active": lambda self: True, "cancel": lambda self: None})() + + uc = VoiceUseCases( + FakeVoiceProvider(), + scenario.config, + get_protocols=lambda: {"MASTER-A": master}, + get_bridges=scenario.bridge.get_bridges, + call_later=call_later, + audio_path=audio_path, + ) + uc._scheduled = scheduled + return uc + + +def drain_call_later(uc: VoiceUseCases, max_rounds: int = 200) -> None: + scheduled = getattr(uc, "_scheduled", None) + if scheduled is None: + return + for _ in range(max_rounds): + if not scheduled: + break + _, (fn, args) = scheduled.pop(0) + fn(*args) + + +def voice_announcement_config( + scenario: DeterministicScenario, + *, + tg: int = 91, + enabled: bool = True, + file_number: str = "test-msg", +) -> None: + scenario.config["VOICE"] = { + "ANNOUNCEMENTS": [ + { + "ENABLED": enabled, + "TG": tg, + "FILE": file_number, + "LANGUAGE": "en_GB", + "MODE": "interval", + } + ] + } + + +def voice_tts_config( + scenario: DeterministicScenario, + *, + tg: int = 91, + enabled: bool = True, + file_number: str = "tts-msg.ambe", +) -> None: + scenario.config["VOICE"] = { + "TTS_ANNOUNCEMENTS": [ + { + "ENABLED": enabled, + "TG": tg, + "FILE": file_number, + "LANGUAGE": "en_GB", + "MODE": "interval", + } + ] + } diff --git a/tests/hbp/test_hbp_loop_control.py b/tests/hbp/test_hbp_loop_control.py new file mode 100644 index 0000000..945dd02 --- /dev/null +++ b/tests/hbp/test_hbp_loop_control.py @@ -0,0 +1,45 @@ +"""HBP vs HBP loop control (ingress packet control).""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_capture_unchanged, assert_inject_ok +from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge + + +def test_hbp_loop_loser_when_other_hbp_owns_stream() -> None: + """Second HBP source loses when another MASTER slot already has RX_STREAM_ID.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + stream_id = 0x41414141 + scenario.seed_hbp_slot_stream("MASTER-B", 2, stream_id, tgid=91) + base = PacketSpec(dst_id=91, stream_id=stream_id, slot=2) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + ok = scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + + assert_inject_ok(ok, expected=False) + assert scenario.protocols["MASTER-A"].STATUS[2].get("LOOPLOG") is True + + +@pytest.mark.behavior +def test_hbp_loop_winner_forwards_until_loser_detected() -> None: + """Regression: VHEAD bridges; loop loser burst does not add forwards.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + stream_id = 0x42424242 + scenario.seed_hbp_slot_stream("MASTER-B", 2, stream_id, tgid=91) + base = PacketSpec(dst_id=91, stream_id=stream_id, slot=2) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + forwarded_after_vhead = len(scenario.capture.for_system("MASTER-B")) + + ok = scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + assert_capture_unchanged(scenario, "MASTER-B", forwarded_after_vhead) + assert_inject_ok(ok, expected=False) diff --git a/tests/hbp/test_hbp_rate_limit.py b/tests/hbp/test_hbp_rate_limit.py new file mode 100644 index 0000000..00ada77 --- /dev/null +++ b/tests/hbp/test_hbp_rate_limit.py @@ -0,0 +1,75 @@ +"""HBP ingress rate limit.""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_capture_unchanged +from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge + +import adn_server.application.bridge_use_cases as buc + + +@pytest.mark.behavior +def test_hbp_rate_limit_drops_excessive_ingress_rate() -> None: + """Regression: excessive ingress rate triggers drop; post-drop forwards stall.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + base = PacketSpec(dst_id=91, stream_id=0x55667788) + t0 = scenario.clock.time() + + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_head_spec(base), + ingress_pkt_time=t0, + ) + dropped = False + dropped_at = 0 + for seq in range(1, 30): + ok = scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)), + ingress_pkt_time=t0 + seq * 0.01, + ) + if not ok: + dropped = True + dropped_at = seq + break + assert dropped + + forwarded_before = len(scenario.capture.for_system("MASTER-B")) + for seq in range(dropped_at, dropped_at + 5): + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)), + ingress_pkt_time=t0 + seq * 0.01, + ) + assert_capture_unchanged(scenario, "MASTER-B", forwarded_before) + + +def test_hbp_rate_limit_uses_ingress_time_not_wall_clock() -> None: + """Rate control must use ingress_pkt_time even when wall clock is frozen.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + base = PacketSpec(dst_id=91, stream_id=0x34343434) + t0 = 1_700_000_200.0 + + original_time = buc.time.time + buc.time.time = lambda: t0 + try: + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_head_spec(base), + ingress_pkt_time=t0, + ) + accepted = 0 + for seq in range(1, 24): + ok = scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)), + ingress_pkt_time=t0 + seq * 0.06, + ) + if ok is not False: + accepted += 1 + assert accepted >= 20 + finally: + buc.time.time = original_time diff --git a/tests/hbp/test_ingress.py b/tests/hbp/test_ingress.py new file mode 100644 index 0000000..be6a15c --- /dev/null +++ b/tests/hbp/test_ingress.py @@ -0,0 +1,86 @@ +"""HBP ingress timing and loop control.""" + +from __future__ import annotations + +from tests.harness.assertions import assert_inject_ok +from tests.harness.deterministic import ( + DeterministicScenario, + PacketSpec, + active_bridge, + add_openbridge_system, +) + + +def test_hbp_ingress_sets_rx_start_on_new_stream() -> None: + """New HBP group stream records RX_START from ingress_pkt_time.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + base = PacketSpec(dst_id=91, stream_id=0x12121212) + t0 = 1_700_000_100.0 + + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_head_spec(base), + ingress_pkt_time=t0, + ) + + slot_st = scenario.protocols["MASTER-A"].STATUS[2] + assert slot_st.get("RX_START") == t0 + assert slot_st.get("RX_STREAM_ID") == base.data()[16:20] + + +def test_hbp_rate_drop_prevents_bridge_forward() -> None: + """After ingress RATE DROP, no further packets are bridged.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + base = PacketSpec(dst_id=91, stream_id=0x56565656) + t0 = scenario.clock.time() + + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_head_spec(base), + ingress_pkt_time=t0, + ) + dropped_at: int | None = None + for seq in range(1, 30): + ok = scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)), + ingress_pkt_time=t0 + seq * 0.01, + ) + if not ok: + dropped_at = seq + break + assert dropped_at is not None + + forwarded_before = len(scenario.capture.for_system("MASTER-B")) + for seq in range(dropped_at, dropped_at + 10): + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)), + ingress_pkt_time=t0 + seq * 0.01, + ) + assert len(scenario.capture.for_system("MASTER-B")) == forwarded_before + + +def test_hbp_loop_loser_when_obp_already_has_stream() -> None: + """Regression: HBP loses loop when OBP already owns stream_id; no bridge forward.""" + bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2))) + config = DeterministicScenario().config + add_openbridge_system(config, "OBP-CL") + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090" + scenario = DeterministicScenario(config=config, bridges=bridges) + + stream_id = 0x77778888 + scenario.seed_obp_stream("OBP-CL", stream_id, tgid=52090) + + base = PacketSpec(dst_id=52090, stream_id=stream_id, slot=2) + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_head_spec(base), + ) + ok = scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + assert_inject_ok(ok, expected=False) diff --git a/tests/hbp/test_master_maintenance.py b/tests/hbp/test_master_maintenance.py new file mode 100644 index 0000000..450822c --- /dev/null +++ b/tests/hbp/test_master_maintenance.py @@ -0,0 +1,59 @@ +"""HBP MASTER maintenance loop (peer timeout / PEERS dict parity).""" + +from __future__ import annotations + +import time +from unittest.mock import MagicMock + +from adn_server.domain import bytes_4 +from adn_server.infrastructure.config_normalizer import ensure_system_runtime_config +from adn_server.infrastructure.twisted_adapters.udp_hbp import HBPProtocol + + +def _echo_master_config() -> dict: + config = { + "GLOBAL": {"PING_TIME": 10, "MAX_MISSED": 3, "USE_ACL": False}, + "SYSTEMS": { + "ECHO": { + "MODE": "MASTER", + "ENABLED": True, + "MAX_PEERS": 1, + "OPTIONS": "TS2=9990;", + } + }, + } + ensure_system_runtime_config(config) + return config + + +def test_maintenance_timeout_removes_peer_and_sets_reset() -> None: + """Timed-out peer is removed from PEERS and triggers _reset when last peer leaves.""" + config = _echo_master_config() + proto = HBPProtocol("ECHO", config) + proto.transport = MagicMock() + peer_id = bytes_4(9990) + proto._peers[peer_id] = { + "CONNECTION": "YES", + "LAST_PING": 0, + "SOCKADDR": ("127.0.0.1", 54915), + "CALLSIGN": b"ECHO ", + "RADIO_ID": "9990", + } + assert peer_id in config["SYSTEMS"]["ECHO"]["PEERS"] + + proto._master_maintenance_loop() + + assert peer_id not in proto._peers + assert peer_id not in config["SYSTEMS"]["ECHO"]["PEERS"] + assert config["SYSTEMS"]["ECHO"].get("_reset") is True + proto.transport.write.assert_called_once() + + +def test_peers_dict_is_shared_with_system_config() -> None: + """_peers must alias sys_cfg['PEERS'] so login state survives maintenance.""" + config = _echo_master_config() + del config["SYSTEMS"]["ECHO"]["PEERS"] + proto = HBPProtocol("ECHO", config) + peer_id = bytes_4(9990) + proto._peers[peer_id] = {"LAST_PING": time.time(), "SOCKADDR": ("127.0.0.1", 1)} + assert peer_id in config["SYSTEMS"]["ECHO"]["PEERS"] diff --git a/tests/hbp/test_timeout_collision.py b/tests/hbp/test_timeout_collision.py new file mode 100644 index 0000000..84cc0bd --- /dev/null +++ b/tests/hbp/test_timeout_collision.py @@ -0,0 +1,88 @@ +"""HBP ingress 180s timeout and stream collision.""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_forwarded, assert_inject_ok +from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge + +from adn_server.domain import bytes_3, bytes_4 +from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD + + +def test_hbp_source_timeout_drops_after_180_seconds() -> None: + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + base = PacketSpec(dst_id=91, stream_id=0x90909090) + t0 = scenario.clock.time() + + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_head_spec(base), + ingress_pkt_time=t0, + ) + ok = scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ingress_pkt_time=t0 + 181.0, + ) + + assert not ok + assert scenario.protocols["MASTER-A"].STATUS[2].get("LOOPLOG") is True + + +def test_hbp_stream_collision_drops_conflicting_new_stream() -> None: + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + t0 = scenario.clock.time() + slot = scenario.protocols["MASTER-A"].STATUS[2] + slot.update( + { + "RX_STREAM_ID": bytes_4(0x80808080), + "RX_TYPE": HBPF_SLT_VHEAD, + "RX_RFS": bytes_3(1111111), + "RX_TGID": bytes_3(91), + "RX_TIME": t0, + "RX_START": t0, + } + ) + base = PacketSpec(dst_id=91, stream_id=0x70707070, rf_src=2222222) + + ok = scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_head_spec(base), + ingress_pkt_time=t0 + 0.1, + ) + + assert not ok + assert len(scenario.capture.for_system("MASTER-B")) == 0 + + +@pytest.mark.behavior +def test_hbp_collision_allows_same_subscriber_rekey() -> None: + """Regression: same RF source may start a new stream while prior call is still open.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + t0 = scenario.clock.time() + rf_src = 3120001 + slot = scenario.protocols["MASTER-A"].STATUS[2] + slot.update( + { + "RX_STREAM_ID": bytes_4(0x60606060), + "RX_TYPE": HBPF_SLT_VHEAD, + "RX_RFS": bytes_3(rf_src), + "RX_TGID": bytes_3(91), + "RX_TIME": t0, + "RX_START": t0, + } + ) + base = PacketSpec(dst_id=91, stream_id=0x50505050, rf_src=rf_src) + + ok = scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_head_spec(base), + ingress_pkt_time=t0 + 0.1, + ) + + assert_inject_ok(ok) + assert_forwarded(scenario, "MASTER-B", count=1, dst_id=91) diff --git a/tests/infrastructure/test_logging_reload.py b/tests/infrastructure/test_logging_reload.py new file mode 100644 index 0000000..7bb8a89 --- /dev/null +++ b/tests/infrastructure/test_logging_reload.py @@ -0,0 +1,25 @@ +"""Tests for LOGGER hot-reload level application.""" + +from __future__ import annotations + +import logging + +from adn_server.infrastructure.logging_config import reapply_log_level + + +def test_reapply_log_level_updates_root_and_named_logger() -> None: + logging.basicConfig(level=logging.WARNING, force=True) + app = logging.getLogger("adn-server") + app.setLevel(logging.WARNING) + + name = reapply_log_level({"LOG_LEVEL": "DEBUG", "LOG_NAME": "adn-server"}) + + assert name == "DEBUG" + assert logging.getLogger().level == logging.DEBUG + assert app.level == logging.DEBUG + + +def test_reapply_log_level_disabled_logger_uses_critical() -> None: + name = reapply_log_level({"ENABLED": False, "LOG_LEVEL": "DEBUG", "LOG_NAME": "adn-server"}) + assert name == "CRITICAL" + assert logging.getLogger("adn-server").level == logging.CRITICAL diff --git a/tests/obp/test_loop_control.py b/tests/obp/test_loop_control.py new file mode 100644 index 0000000..eea6c78 --- /dev/null +++ b/tests/obp/test_loop_control.py @@ -0,0 +1,120 @@ +"""OBP loop control and VTERM stream lifecycle.""" + +from __future__ import annotations + +from tests.harness.deterministic import ( + DeterministicScenario, + FakeReportFactory, + FakeReportSender, + PacketSpec, + active_bridge, + add_openbridge_system, + patch_bridge_wall_time, +) +from tests.harness.scenarios import obp_bridge_scenario + +from adn_server.application.reporting_use_cases import ReportingUseCases +from adn_server.domain import bytes_4, int_id + + +def test_obp_first_packet_bypasses_rate_control() -> None: + """First frame of a stream must not enter packets/START rate drop.""" + scenario = obp_bridge_scenario("OBP-CL") + base = PacketSpec(dst_id=52090, stream_id=0xAABBCCDD, slot=1) + + with patch_bridge_wall_time(scenario.clock): + ok = scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base)) + assert ok is not False + obp_st = scenario.protocols["OBP-CL"].STATUS[bytes_4(base.stream_id)] + assert obp_st.get("packets", -1) == 0 + + +def test_obp_loop_loser_drops_second_obp_source() -> None: + """Second OBP on the same stream_id loses loop control after its first burst.""" + scenario = obp_bridge_scenario("OBP-A", "OBP-B") + stream_id = 0x01020304 + base = PacketSpec(dst_id=52090, stream_id=stream_id, slot=1) + + with patch_bridge_wall_time(scenario.clock): + scenario.inject_obp("OBP-A", DeterministicScenario.voice_head_spec(base)) + scenario.inject_obp( + "OBP-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + scenario.inject_obp("OBP-B", DeterministicScenario.voice_head_spec(base)) + ok = scenario.inject_obp( + "OBP-B", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + assert not ok + + +def test_obp_loop_loser_sends_bcsq_once_when_enhanced() -> None: + """Loop loser sends BCSQ once (not on every subsequent packet).""" + bridges = active_bridge(52090, (("OBP-A", 1), ("OBP-B", 1), ("MASTER-A", 2))) + config = DeterministicScenario().config + add_openbridge_system(config, "OBP-A") + add_openbridge_system(config, "OBP-B", enhanced=True) + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090" + scenario = DeterministicScenario(config=config, bridges=bridges) + + stream_id = 0x0D0E0F10 + base = PacketSpec(dst_id=52090, stream_id=stream_id, slot=1) + + with patch_bridge_wall_time(scenario.clock): + scenario.inject_obp("OBP-A", DeterministicScenario.voice_head_spec(base)) + scenario.inject_obp( + "OBP-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + scenario.inject_obp("OBP-B", DeterministicScenario.voice_head_spec(base)) + scenario.inject_obp( + "OBP-B", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + scenario.inject_obp( + "OBP-B", + DeterministicScenario.voice_burst_spec(base, seq=2, dtype_vseq=2), + ) + + assert len(scenario.bcsq_capture) == 1 + bcsq = scenario.bcsq_capture[0] + assert bcsq.system_name == "OBP-B" + assert int_id(bcsq.tgid) == 52090 + assert int_id(bcsq.stream_id) == stream_id + + +def test_obp_vterm_sets_fin_and_drops_late_packets() -> None: + """After VTERM, late packets from the same OBP stream are ignored (_fin).""" + scenario = obp_bridge_scenario("OBP-CL") + scenario.config.setdefault("REPORTS", {})["REPORT"] = True + scenario.report_factory = FakeReportFactory() + scenario.reporting = ReportingUseCases(FakeReportSender(scenario.report_factory), scenario.config) + scenario.bridge._reporting = scenario.reporting + + base = PacketSpec(dst_id=52090, stream_id=0x99887766, slot=1) + sid = bytes_4(base.stream_id) + + with patch_bridge_wall_time(scenario.clock): + scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base)) + scenario.inject_obp( + "OBP-CL", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + before = len(scenario.capture.for_system("MASTER-A")) + scenario.inject_obp( + "OBP-CL", + DeterministicScenario.voice_term_spec(base, seq=99), + ) + after_vterm = len(scenario.capture.for_system("MASTER-A")) + assert after_vterm >= before + + obp_st = scenario.protocols["OBP-CL"].STATUS[sid] + assert obp_st.get("_fin") is True + + ok = scenario.inject_obp( + "OBP-CL", + DeterministicScenario.voice_burst_spec(base, seq=100, dtype_vseq=1), + ) + assert not ok + assert len(scenario.capture.for_system("MASTER-A")) == after_vterm diff --git a/tests/obp/test_obp_rate_limit.py b/tests/obp/test_obp_rate_limit.py new file mode 100644 index 0000000..f6cea07 --- /dev/null +++ b/tests/obp/test_obp_rate_limit.py @@ -0,0 +1,33 @@ +"""OBP rate limit regressions.""" + +from __future__ import annotations + +from tests.harness.deterministic import ( + DeterministicScenario, + PacketSpec, + active_bridge, + add_openbridge_system, + patch_bridge_wall_time, +) + + +def test_obp_rate_limit_uses_start_epoch_not_elapsed() -> None: + """Regression: OBP must not RATE DROP at normal cadence (packets/START).""" + bridges = active_bridge(52090, (("OBP-CL", 1), ("MASTER-A", 2))) + config = DeterministicScenario().config + add_openbridge_system(config, "OBP-CL") + config["SYSTEMS"]["MASTER-A"]["TS2_STATIC"] = "52090" + scenario = DeterministicScenario(config=config, bridges=bridges) + + with patch_bridge_wall_time(scenario.clock): + base = PacketSpec(dst_id=52090, stream_id=0x11223344, slot=1) + scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base)) + accepted = 0 + for seq in range(1, 25): + ok = scenario.inject_obp( + "OBP-CL", + DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=min(seq, 4)), + ) + if ok is not False: + accepted += 1 + assert accepted >= 20 diff --git a/tests/obp/test_unit_data_loop.py b/tests/obp/test_unit_data_loop.py new file mode 100644 index 0000000..37616c0 --- /dev/null +++ b/tests/obp/test_unit_data_loop.py @@ -0,0 +1,42 @@ +"""OBP unit-data loop control (HBP/OBP earliest-wins).""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_not_forwarded +from tests.harness.deterministic import DeterministicScenario, PacketSpec, patch_bridge_wall_time +from tests.harness.scenarios import obp_bridge_scenario + +from adn_server.domain import bytes_4 + + +@pytest.mark.behavior +def test_obp_unit_data_loop_loser_when_hbp_owns_stream() -> None: + """Regression: OBP unit-data loses when HBP slot already has RX_STREAM_ID.""" + scenario = obp_bridge_scenario("OBP-CL") + stream_id = 0x71717171 + scenario.seed_hbp_slot_stream("MASTER-A", 2, stream_id, tgid=52090) + base = PacketSpec(call_type="unit", dst_id=1000001, stream_id=stream_id, slot=1) + + with patch_bridge_wall_time(scenario.clock): + scenario.inject_obp("OBP-CL", DeterministicScenario.unit_data_header_spec(base)) + + assert_not_forwarded(scenario, "MASTER-B") + obp_st = scenario.protocols["OBP-CL"].STATUS[bytes_4(stream_id)] + assert obp_st.get("LOOPLOG") is True + + +@pytest.mark.behavior +def test_obp_unit_data_loop_loser_for_second_obp_source() -> None: + """Regression: second OBP source loses unit-data loop to the first.""" + scenario = obp_bridge_scenario("OBP-A", "OBP-B") + stream_id = 0x72727272 + base = PacketSpec(call_type="unit", dst_id=1000001, stream_id=stream_id, slot=1) + + with patch_bridge_wall_time(scenario.clock): + scenario.inject_obp("OBP-A", DeterministicScenario.unit_data_header_spec(base)) + scenario.inject_obp("OBP-B", DeterministicScenario.unit_data_header_spec(base)) + + assert_not_forwarded(scenario, "MASTER-B") + obp_b_st = scenario.protocols["OBP-B"].STATUS[bytes_4(stream_id)] + assert obp_b_st.get("LOOPLOG") is True diff --git a/tests/parrot/test_playback_ingress.py b/tests/parrot/test_playback_ingress.py new file mode 100644 index 0000000..5650ce4 --- /dev/null +++ b/tests/parrot/test_playback_ingress.py @@ -0,0 +1,64 @@ +"""Parrot ingress path (PEER dmrd_received + record-to-playback).""" + +from __future__ import annotations + +import logging +from unittest.mock import patch + +from tests.harness.deterministic import DeterministicScenario, PacketSpec +from tests.harness.playback_helpers import FakePlaybackProtocol, install_reactor_capture, send_playback + +from adn_server.application.playback_use_cases import _PLAYBACK_DELAY_S, _RECORD_IDLE_S, PlaybackUseCases + + +def test_dmrd_received_accepts_ingress_pkt_time_kwarg() -> None: + """Regression: PEER udp_hbp passes ingress_pkt_time (parrot must not TypeError).""" + proto = FakePlaybackProtocol() + pb = PlaybackUseCases("PARROT", get_protocol=lambda: proto) + base = PacketSpec(dst_id=9990, stream_id=0x12121212, slot=2) + args = DeterministicScenario.voice_head_spec(base).decoded_hbp_args() + + pb.dmrd_received( + "PARROT", + args["peer_id"], + args["rf_src"], + args["dst_id"], + args["seq"], + args["slot"], + args["call_type"], + args["frame_type"], + args["dtype_vseq"], + args["stream_id"], + DeterministicScenario.voice_head_spec(base).data(), + ingress_pkt_time=1_700_000_000.0, + ) + + assert pb._recording_active is True + assert len(pb.CALL_DATA) == 1 + + +def test_ingress_pkt_time_enables_record_to_playback(caplog) -> None: + """Regression: PEER path (ingress_pkt_time) records voice and schedules playback.""" + proto = FakePlaybackProtocol() + pb = PlaybackUseCases("PARROT", get_protocol=lambda: proto) + base = PacketSpec(dst_id=9990, stream_id=0x34343434, slot=2) + mock_reactor, scheduled = install_reactor_capture() + t0 = 1_700_000_000.0 + + with patch("adn_server.application.playback_use_cases.reactor", mock_reactor): + with patch("adn_server.application.playback_use_cases.time", return_value=t0): + send_playback(pb, "PARROT", DeterministicScenario.voice_head_spec(base)) + send_playback( + pb, + "PARROT", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ingress_pkt_time=t0 + 2.0, + ) + with patch("adn_server.application.playback_use_cases.time", return_value=t0 + _RECORD_IDLE_S + 1): + with caplog.at_level(logging.INFO, logger="adn_server.application.playback_use_cases"): + pb._on_record_idle(proto) + + assert any(item[0] == _PLAYBACK_DELAY_S for item in scheduled) + assert pb._playback_busy is True + assert any("*END RECORDING*" in r.message for r in caplog.records) + assert not any("unexpected keyword argument" in r.message for r in caplog.records) diff --git a/tests/parrot/test_playback_logging.py b/tests/parrot/test_playback_logging.py new file mode 100644 index 0000000..65c672c --- /dev/null +++ b/tests/parrot/test_playback_logging.py @@ -0,0 +1,36 @@ +"""Parrot playback logging format.""" + +from __future__ import annotations + +import logging +import re + +from tests.harness.deterministic import DeterministicScenario, PacketSpec +from tests.harness.playback_helpers import FakePlaybackProtocol + +from adn_server.application.playback_use_cases import PlaybackUseCases +from adn_server.domain import bytes_3, bytes_4 + + +def test_start_playback_logs_duration_with_two_decimals(caplog) -> None: + """PLAYBACK duration matches bridge-style %.2f (no float noise in logs).""" + proto = FakePlaybackProtocol() + pb = PlaybackUseCases("PARROT", get_protocol=lambda: proto) + base = PacketSpec(dst_id=9990, stream_id=0x88888888, slot=2) + recorded = [DeterministicScenario.voice_head_spec(base).data()] + + with caplog.at_level(logging.INFO, logger="adn_server.application.playback_use_cases"): + pb._start_playback( + proto, + recorded, + bytes_3(base.rf_src), + bytes_4(base.peer_id), + bytes_3(base.dst_id), + 2, + 10.985645771026611, + ) + + playback_lines = [r.message for r in caplog.records if "*START PLAYBACK*" in r.message] + assert len(playback_lines) == 1 + assert re.search(r"Duration: 10\.99\b", playback_lines[0]) + assert "10.985645771026611" not in playback_lines[0] diff --git a/tests/parrot/test_playback_send_loop.py b/tests/parrot/test_playback_send_loop.py new file mode 100644 index 0000000..37d3fd9 --- /dev/null +++ b/tests/parrot/test_playback_send_loop.py @@ -0,0 +1,108 @@ +"""Parrot playback send loop and max recording timer.""" + +from __future__ import annotations + +from unittest.mock import patch + +from tests.harness.deterministic import DeterministicScenario, PacketSpec +from tests.harness.playback_helpers import FakePlaybackProtocol, install_reactor_capture, run_scheduled + +from adn_server.application.playback_use_cases import ( + _PACKET_INTERVAL_S, + _PLAYBACK_DELAY_S, + _SOURCE_MAX_S, + PlaybackUseCases, +) +from adn_server.domain import bytes_3, bytes_4 + + +def test_send_next_packet_emits_all_packets_then_finishes() -> None: + proto = FakePlaybackProtocol() + pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto) + pb._playback_busy = True + pb._playback_stream_id = bytes_4(0x77777777) + pb._playback_packets = [bytes([i]) * 55 for i in range(1, 5)] + pb._playback_index = 0 + mock_reactor, scheduled = install_reactor_capture() + + with patch("adn_server.application.playback_use_cases.reactor", mock_reactor): + pb._send_next_packet(proto) + while scheduled: + run_scheduled(scheduled) + + assert len(proto.sent) == 4 + assert pb._playback_busy is False + assert pb._playback_packets == [] + assert pb._playback_index == 0 + + +def test_start_playback_sends_first_packet_and_schedules_rest() -> None: + proto = FakePlaybackProtocol() + pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto) + base = PacketSpec(dst_id=9990, stream_id=0x88888888, slot=2) + recorded = [ + DeterministicScenario.voice_head_spec(base).data(), + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1).data(), + ] + mock_reactor, scheduled = install_reactor_capture() + + with patch("adn_server.application.playback_use_cases.reactor", mock_reactor): + pb._start_playback( + proto, + recorded, + bytes_3(base.rf_src), + bytes_4(base.peer_id), + bytes_3(base.dst_id), + 2, + 1.5, + ) + + assert len(proto.sent) == 1 + assert pb._playback_index == 1 + assert any(item[0] == _PACKET_INTERVAL_S for item in scheduled) + with patch("adn_server.application.playback_use_cases.reactor", mock_reactor): + while scheduled: + run_scheduled(scheduled) + + assert len(proto.sent) == 2 + assert pb._playback_busy is False + + +def test_max_duration_commits_recording_when_no_vterm() -> None: + proto = FakePlaybackProtocol() + pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto) + base = PacketSpec(dst_id=9990, stream_id=0x99999999, slot=2) + pb._recording_active = True + pb.CALL_DATA = [DeterministicScenario.voice_head_spec(base).data()] + pb._record_stream = base.data()[16:20] + pb.STATUS["RX_START"] = 100.0 + pb._record_ctx = { + "slot": 2, + "rf_src": bytes_3(base.rf_src), + "peer_id": bytes_4(base.peer_id), + "dst_id": bytes_3(base.dst_id), + } + mock_reactor, scheduled = install_reactor_capture() + + with patch("adn_server.application.playback_use_cases.reactor", mock_reactor): + with patch("adn_server.application.playback_use_cases.time", return_value=100.0 + _SOURCE_MAX_S): + pb._on_record_max_duration(proto) + + assert not pb._recording_active + assert pb._playback_busy is True + assert any(item[0] == _PLAYBACK_DELAY_S for item in scheduled) + + +def test_packet_interval_matches_expected() -> None: + proto = FakePlaybackProtocol() + pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto) + pb._playback_busy = True + pb._playback_stream_id = bytes_4(0xAAAAAAAA) + pb._playback_packets = [b"\x01" * 55, b"\x02" * 55] + pb._playback_index = 0 + mock_reactor, scheduled = install_reactor_capture() + + with patch("adn_server.application.playback_use_cases.reactor", mock_reactor): + pb._send_next_packet(proto) + + assert scheduled[0][0] == _PACKET_INTERVAL_S diff --git a/tests/parrot/test_recording_timers.py b/tests/parrot/test_recording_timers.py new file mode 100644 index 0000000..7bd2135 --- /dev/null +++ b/tests/parrot/test_recording_timers.py @@ -0,0 +1,52 @@ +"""Parrot recording idle/max timers and synthetic VTERM.""" + +from __future__ import annotations + +from unittest.mock import patch + +from tests.harness.deterministic import DeterministicScenario, PacketSpec +from tests.harness.playback_helpers import ( + FakePlaybackProtocol, + install_reactor_capture, + run_scheduled, + send_playback, +) + +from adn_server.application.playback_use_cases import _RECORD_IDLE_S, PlaybackUseCases + + +def test_idle_timeout_appends_synthetic_vterm_and_schedules_playback() -> None: + proto = FakePlaybackProtocol() + pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto) + base = PacketSpec(dst_id=9990, stream_id=0x55555555, slot=2) + mock_reactor, scheduled = install_reactor_capture() + + with patch("adn_server.application.playback_use_cases.reactor", mock_reactor): + with patch("adn_server.application.playback_use_cases.time", return_value=200.0): + send_playback(pb, "ECHO", DeterministicScenario.voice_head_spec(base)) + send_playback( + pb, "ECHO", DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + + idle_calls = [item for item in scheduled if item[0] == _RECORD_IDLE_S] + assert idle_calls + + with patch("adn_server.application.playback_use_cases.time", return_value=200.0 + _RECORD_IDLE_S): + run_scheduled(scheduled, delay=_RECORD_IDLE_S) + + assert not pb._recording_active + assert pb._playback_busy is True + assert any(item[0] == 2.0 for item in scheduled) + + +def test_vterm_commit_does_not_require_synthetic_vterm() -> None: + pb = PlaybackUseCases("ECHO") + base = PacketSpec(dst_id=9990, stream_id=0x66666666, slot=2) + recorded = [ + DeterministicScenario.voice_head_spec(base).data(), + DeterministicScenario.voice_term_spec(base, seq=2).data(), + ] + + with_vterm = pb._ensure_vterm(recorded, slot=2) + assert len(with_vterm) == len(recorded) + assert pb._packet_is_vterm(with_vterm[-1]) diff --git a/tests/parrot/test_rekey_playback.py b/tests/parrot/test_rekey_playback.py new file mode 100644 index 0000000..c110495 --- /dev/null +++ b/tests/parrot/test_rekey_playback.py @@ -0,0 +1,138 @@ +"""Parrot re-key and playback packet prep.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from tests.harness.deterministic import DeterministicScenario, PacketSpec +from tests.harness.playback_helpers import FakePlaybackProtocol, install_reactor_capture, send_playback + +from adn_server.application.playback_use_cases import PlaybackUseCases, _PACKET_INTERVAL_S +from adn_server.domain import bytes_3, bytes_4 + + +def _long_voice_recording( + base: PacketSpec, + *, + burst_count: int, +) -> list[bytes]: + """Simulate MMDVM seq byte wrapping (1..255, 0, 1..) over a long QSO.""" + recorded = [DeterministicScenario.voice_head_spec(base).data()] + for i in range(1, burst_count + 1): + recorded.append( + DeterministicScenario.voice_burst_spec( + base, + seq=i & 0xFF, + dtype_vseq=((i - 1) % 4) + 1, + ).data() + ) + recorded.append(DeterministicScenario.voice_term_spec(base, seq=(burst_count + 1) & 0xFF).data()) + return recorded + + +@pytest.mark.behavior +def test_prepare_playback_preserves_source_seq_past_255_packets() -> None: + """Regression b0578fb: renumbering 1..255 duplicates seq ~15–30s; MMDVMHost drops audio.""" + pb = PlaybackUseCases("ECHO") + base = PacketSpec(dst_id=9990, stream_id=0x55555555, slot=2) + recorded = _long_voice_recording(base, burst_count=500) + + out = pb._prepare_playback_packets(recorded) + + assert len(out) == len(recorded) + playback_sid = out[0][16:20] + for src, replay in zip(recorded, out): + assert replay[4] == src[4], ( + f"playback must keep source seq byte 0x{src[4]:02x}, got 0x{replay[4]:02x}" + ) + assert replay[16:20] == playback_sid + # After ~255 voice bursts (~15s), buggy renumber wraps to seq=1 while source has seq=0. + assert recorded[256][4] == 0 + assert out[256][4] == 0 + + +@pytest.mark.behavior +def test_start_playback_sends_preserved_seq_over_30s_recording() -> None: + """End-to-end: ~500 bursts @ 60ms ≈ 30s; replay seq on wire must match recording.""" + proto = FakePlaybackProtocol() + pb = PlaybackUseCases("ECHO", get_protocol=lambda: proto) + base = PacketSpec(dst_id=9990, stream_id=0x66666666, slot=2) + burst_count = 500 + recorded = _long_voice_recording(base, burst_count=burst_count) + mock_reactor, scheduled = install_reactor_capture() + + with patch("adn_server.application.playback_use_cases.reactor", mock_reactor): + pb._start_playback( + proto, + recorded, + bytes_3(base.rf_src), + bytes_4(base.peer_id), + bytes_3(base.dst_id), + 2, + burst_count * _PACKET_INTERVAL_S, + ) + while scheduled: + _delay, fn, args = scheduled.pop(0) + fn(*args) + + assert len(proto.sent) == len(recorded) + for src, sent in zip(recorded, proto.sent): + assert sent[4] == src[4] + + +def test_recording_rekey_does_not_store_second_vhead() -> None: + pb = PlaybackUseCases("ECHO", get_protocol=lambda: FakePlaybackProtocol()) + base = PacketSpec(dst_id=9990, stream_id=0x11111111, slot=2) + rekey = PacketSpec(dst_id=9990, stream_id=0x22222222, slot=2) + mock_reactor, _scheduled = install_reactor_capture() + + with patch("adn_server.application.playback_use_cases.reactor", mock_reactor): + with patch("adn_server.application.playback_use_cases.time") as mock_time: + mock_time.side_effect = [100.0, 100.1, 100.2, 100.3] + send_playback(pb, "ECHO", DeterministicScenario.voice_head_spec(base)) + send_playback( + pb, "ECHO", DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + send_playback(pb, "ECHO", DeterministicScenario.voice_head_spec(rekey)) + send_playback( + pb, "ECHO", DeterministicScenario.voice_burst_spec(rekey, seq=2, dtype_vseq=2), + ) + + vheads = sum( + 1 + for pkt in pb.CALL_DATA + if len(pkt) >= 16 and (pkt[15] & 0xF) == 1 and ((pkt[15] & 0x30) >> 4) == 2 + ) + assert vheads == 1 + assert pb._record_stream == rekey.data()[16:20] + + +def test_prepare_playback_skips_mid_call_vhead_and_preserves_seq() -> None: + pb = PlaybackUseCases("ECHO") + base = PacketSpec(dst_id=9990, stream_id=0x33333333, slot=2) + rekey = PacketSpec(dst_id=9990, stream_id=0x44444444, slot=2) + recorded = [ + DeterministicScenario.voice_head_spec(base).data(), + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1).data(), + DeterministicScenario.voice_head_spec(rekey).data(), + DeterministicScenario.voice_burst_spec(rekey, seq=2, dtype_vseq=2).data(), + DeterministicScenario.voice_term_spec(rekey, seq=3).data(), + ] + + out = pb._prepare_playback_packets(recorded) + + assert len(out) == 4 + # Mid-call VHEAD (recorded[2]) dropped; source seq preserved (legacy playback.py). + assert out[0][4] == recorded[0][4] + assert out[1][4] == recorded[1][4] + assert out[2][4] == recorded[3][4] + assert out[3][4] == recorded[4][4] + mid_vheads = sum( + 1 + for pkt in out[1:] + if len(pkt) >= 16 and (pkt[15] & 0xF) == 1 and ((pkt[15] & 0x30) >> 4) == 2 + ) + assert mid_vheads == 0 + assert out[0][16:20] == out[-1][16:20] diff --git a/tests/scripts/test_freedmr_cfg_to_yaml.py b/tests/scripts/test_freedmr_cfg_to_yaml.py new file mode 100644 index 0000000..4b03591 --- /dev/null +++ b/tests/scripts/test_freedmr_cfg_to_yaml.py @@ -0,0 +1,174 @@ +"""Tests for scripts/freedmr_cfg_to_yaml.py.""" + +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +import yaml + +_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_ROOT / "scripts")) + +from freedmr_cfg_to_yaml import dump_yaml, parse_freedmr_cfg # noqa: E402 + + +def _write_cfg(tmp_path: Path, body: str) -> Path: + path = tmp_path / "test.cfg" + path.write_text(textwrap.dedent(body).strip() + "\n", encoding="utf-8") + return path + + +def test_converts_global_echo_and_obp(tmp_path: Path) -> None: + cfg = _write_cfg( + tmp_path, + """ + [GLOBAL] + SERVER_ID: 73010 + USE_ACL: True + TGID_TS2_ACL: PERMIT:ALL + + [REPORTS] + REPORT: True + REPORT_CLIENTS: 127.0.0.1 + + [LOGGER] + LOG_FILE: /var/log/FreeDMR/FreeDMR.log + LOG_NAME: FreeDMR + + [ALIASES] + TRY_DOWNLOAD: True + STALE_DAYS: 1 + + [ECHO] + MODE: MASTER + ENABLED: True + PORT: 54917 + TS2_STATIC: 9990 + TGID_TS2_ACL: PERMIT:9990 + GENERATOR: 0 + + [OBP-CR] + MODE: OPENBRIDGE + ENABLED: False + PORT: 62052 + NETWORK_ID: 71210 + PASSPHRASE: passw0rd + TARGET_IP: freedmrcr.net + TARGET_PORT: 62026 + TGID_ACL: DENY :0-82,9990-9999 + TGID_TS1_ACL: DENY :0-89 + PROTO_VER: 5 + """, + ) + out = parse_freedmr_cfg(cfg) + + assert out["GLOBAL"]["SERVER_ID"] == 73010 + assert out["GLOBAL"]["TALKER_ALIAS"] is False + assert out["REPORTS"]["REPORT_CLIENTS"] == "127.0.0.1" + assert out["LOGGER"]["LOG_FILE"] == "/var/log/adn-server/adn-server.log" + assert out["LOGGER"]["LOG_NAME"] == "adn-server" + assert out["ALIASES"]["KEYS_FILE"] == "keys.json" + + echo = out["SYSTEMS"]["ECHO"] + assert echo["MODE"] == "MASTER" + assert echo["TS2_STATIC"] == "9990" + assert echo["TGID_TS2_ACL"] == "PERMIT:9990" + + obp = out["SYSTEMS"]["OBP-CR"] + assert obp["ENABLED"] is False + assert obp["TGID_ACL"] == "DENY:0-82,9990-9999" + assert obp["TGID_TS1_ACL"] == "DENY:0-89" + assert obp["PROTO_VER"] == 5 + + +def test_includes_disabled_systems(tmp_path: Path) -> None: + cfg = _write_cfg( + tmp_path, + """ + [OBP-OFF] + MODE: OPENBRIDGE + ENABLED: False + PORT: 62099 + NETWORK_ID: 1 + PASSPHRASE: x + TARGET_IP: 1.2.3.4 + TARGET_PORT: 62099 + """, + ) + out = parse_freedmr_cfg(cfg) + assert "OBP-OFF" in out["SYSTEMS"] + assert out["SYSTEMS"]["OBP-OFF"]["ENABLED"] is False + + +def test_preserves_section_and_key_order(tmp_path: Path) -> None: + cfg = _write_cfg( + tmp_path, + """ + [GLOBAL] + PATH: ./ + PING_TIME: 10 + SERVER_ID: 73010 + + [REPORTS] + REPORT: True + + [ALLSTAR] + ENABLED: False + + [ECHO] + MODE: MASTER + ENABLED: True + PORT: 54917 + TS2_STATIC: 9990 + + [OBP-CR] + MODE: OPENBRIDGE + ENABLED: False + PORT: 62052 + NETWORK_ID: 71210 + PASSPHRASE: passw0rd + TARGET_IP: freedmrcr.net + TARGET_PORT: 62026 + PROTO_VER: 5 + """, + ) + out = parse_freedmr_cfg(cfg) + + assert list(out.keys()) == ["GLOBAL", "REPORTS", "ALLSTAR", "SYSTEMS"] + assert list(out["GLOBAL"].keys())[:3] == ["PATH", "PING_TIME", "SERVER_ID"] + assert list(out["SYSTEMS"].keys()) == ["ECHO", "OBP-CR"] + assert list(out["SYSTEMS"]["ECHO"].keys())[:4] == ["MODE", "ENABLED", "PORT", "TS2_STATIC"] + + text = dump_yaml(out) + yaml_keys = [line.rstrip(":") for line in text.splitlines() if line.endswith(":") and not line.startswith("#")] + assert yaml_keys[:4] == ["GLOBAL", "REPORTS", "ALLSTAR", "SYSTEMS"] + + +def test_fixture_matches_cfg_section_order() -> None: + fixture = Path(__file__).resolve().parents[1] / "fixtures" / "sample_freedmr.cfg" + out = parse_freedmr_cfg(fixture) + expected_top = ["GLOBAL", "REPORTS", "LOGGER", "ALIASES", "ALLSTAR", "SYSTEMS"] + assert list(out.keys()) == expected_top + assert list(out["SYSTEMS"].keys()) == ["SYSTEM", "ECHO", "OBP-ES", "OBP-UY"] + assert list(out["GLOBAL"].keys())[:5] == ["PATH", "PING_TIME", "MAX_MISSED", "USE_ACL", "REG_ACL"] + + +def test_dump_yaml_is_parseable(tmp_path: Path) -> None: + cfg = _write_cfg( + tmp_path, + """ + [GLOBAL] + SERVER_ID: 1 + [SYSTEM] + MODE: MASTER + ENABLED: True + PORT: 56400 + PASSPHRASE: secret + GENERATOR: 1 + """, + ) + text = dump_yaml(parse_freedmr_cfg(cfg)) + loaded = yaml.safe_load(text.split("\n", 2)[2]) + assert loaded["SYSTEMS"]["SYSTEM"]["PORT"] == 56400 diff --git a/tests/smoke/test_bridge_routing.py b/tests/smoke/test_bridge_routing.py new file mode 100644 index 0000000..9eed8a7 --- /dev/null +++ b/tests/smoke/test_bridge_routing.py @@ -0,0 +1,24 @@ +"""Smoke tests for basic HBP bridge forwarding.""" + +from __future__ import annotations + +import pytest +from tests.harness.assertions import assert_forwarded +from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge + + +@pytest.mark.smoke +def test_static_tg_routes_hbp_to_peer_master() -> None: + """Regression: active bridge forwards HBP group voice to peer MASTER.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(bridges=bridges) + base = PacketSpec(dst_id=91, stream_id=0x0A0B0C0D) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + for seq in range(1, 4): + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=seq, dtype_vseq=seq), + ) + + assert_forwarded(scenario, "MASTER-B", count=4, call_type="group", dst_id=91) diff --git a/tests/smoke/test_packet_builder.py b/tests/smoke/test_packet_builder.py new file mode 100644 index 0000000..faef347 --- /dev/null +++ b/tests/smoke/test_packet_builder.py @@ -0,0 +1,20 @@ +"""Smoke tests for PacketSpec and DMR header parsing.""" + +from __future__ import annotations + +from tests.harness.deterministic import PacketSpec, parse_dmr_fields + +from adn_server.domain import int_id + + +def test_packet_spec_builds_valid_dmr_header() -> None: + spec = PacketSpec(dst_id=91, rf_src=3120001, peer_id=1001, seq=7) + packet = spec.data() + fields = parse_dmr_fields(packet) + + assert fields["opcode"] == b"DMRD" + assert fields["seq"] == 7 + assert int_id(fields["dst_id"]) == 91 + assert int_id(fields["rf_src"]) == 3120001 + assert fields["slot"] == 2 + assert fields["call_type"] == "group" diff --git a/tests/talker_alias/test_bridge_inject.py b/tests/talker_alias/test_bridge_inject.py new file mode 100644 index 0000000..db20d10 --- /dev/null +++ b/tests/talker_alias/test_bridge_inject.py @@ -0,0 +1,109 @@ +"""Talker Alias bridge/repeat injection.""" + +from __future__ import annotations + +from tests.harness.deterministic import ( + DeterministicScenario, + PacketSpec, + active_bridge, + add_openbridge_system, +) +from tests.harness.scenarios import talker_alias_config + +from adn_server.domain import bytes_3, bytes_4 +from adn_server.domain.talker_alias import DMRA_BLOCK_COUNT, DMRA_OPCODE + + +def test_talker_alias_inject_dmra_on_bridge_vhead() -> None: + """Bridge forward sends DMRA blocks once per target stream on VHEAD.""" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges) + base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x90909090) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + + assert len(scenario.dmra_capture) == 1 + dmra = scenario.dmra_capture[0] + assert dmra.target_system == "MASTER-B" + assert 1 <= len(dmra.packets) <= DMRA_BLOCK_COUNT + assert all(p[:4] == DMRA_OPCODE for p in dmra.packets) + payload = b"".join(p[8:15] for p in dmra.packets) + assert b"CE5RPY" in payload + + +def test_talker_alias_vhead_sent_once_per_target_stream() -> None: + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges) + base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x91919191) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + + assert len(scenario.dmra_capture) == 1 + + +def test_talker_alias_both_obp_source_injects_immediately() -> None: + """both + OBP source (can never carry TA): inject template at VHEAD without waiting.""" + config = talker_alias_config() + config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both" + add_openbridge_system(config, "OBP-CL") + bridges = active_bridge(52090, (("OBP-CL", 2), ("MASTER-A", 2))) + scenario = DeterministicScenario(config=config, bridges=bridges) + base = PacketSpec(dst_id=52090, rf_src=3120001, stream_id=0x93939393, slot=2) + + scenario.inject_obp("OBP-CL", DeterministicScenario.voice_head_spec(base)) + + assert len(scenario.dmra_capture) == 1 + dmra = scenario.dmra_capture[0] + assert dmra.target_system == "MASTER-A" + payload = b"".join(p[8:15] for p in dmra.packets) + assert b"CE5RPY" in payload + + +def test_both_mode_without_ta_still_rewrites_group_embedded_lc() -> None: + """Regression: with no injected TA (TX_TA_EMB None), bursts must still carry the + destination group LC (legacy bridge.py parity). + + The earlier 'preserve source embedded LC' shortcut left a stale LC on the wire, which + the receiving MMDVM rejected on TG-rewriting legs -> 'watchdog expired, packet loss'. + """ + from bitarray import bitarray + + config = talker_alias_config() + config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both" + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=config, bridges=bridges) + base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0x95959595, slot=2) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + scenario.inject_hbp( + "MASTER-A", DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1) + ) + + ts_st = scenario.protocols["MASTER-B"].STATUS[2] + assert ts_st.get("TX_TA_EMB") is None # no source TA buffered, no inject + bursts = [p for p in scenario.capture.for_system("MASTER-B") if p.fields["dtype_vseq"] == 1] + assert bursts, "voice burst B should be forwarded to MASTER-B" + bits = bitarray(endian="big") + bits.frombytes(bursts[-1].fields["dmr_payload"]) + emb = bits[116:148] + assert emb == ts_st["TX_EMB_LC"][1] # rewritten to destination group LC... + assert emb.any() # ...not the preserved all-zero source embedded LC + + +def test_talker_alias_local_repeat_excludes_source_peer() -> None: + scenario = DeterministicScenario( + config=talker_alias_config(), + bridges=active_bridge(91, (("MASTER-A", 2),)), + ) + stream_id = bytes_4(0x92929292) + peer = bytes_4(1001) + rf_src = bytes_3(3120001) + + scenario.bridge.send_talker_alias_local_repeat("MASTER-A", peer, rf_src, stream_id) + + assert len(scenario.dmra_capture) == 1 + assert scenario.dmra_capture[0].exclude_peer == peer diff --git a/tests/talker_alias/test_embed_ta.py b/tests/talker_alias/test_embed_ta.py new file mode 100644 index 0000000..18ac216 --- /dev/null +++ b/tests/talker_alias/test_embed_ta.py @@ -0,0 +1,97 @@ +"""Talker Alias embedded LC (encode_emblc) paths.""" + +from __future__ import annotations + +from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config +from tests.talker_alias.test_passthrough import _complete_blocks, _passthrough_config +from adn_server.domain import bytes_3, bytes_4 + + +def test_embedded_emblc_inject_mode_returns_blocks() -> None: + ta = make_talker_alias_use_cases(talker_alias_config()) + stream_id = bytes_4(0xD1D1D1D1) + + result = ta.embedded_emblc_for_stream( + "MASTER-A", + bytes_3(3120001), + stream_id, + lambda _s, _st: None, + target_system="MASTER-B", + ) + + assert result is not None + emblcs, count = result + assert 1 <= count <= 4 + assert len(emblcs) == count + + +def test_embedded_emblc_disabled_returns_none() -> None: + config = talker_alias_config() + config["GLOBAL"]["TALKER_ALIAS"] = False + ta = make_talker_alias_use_cases(config) + + assert ( + ta.embedded_emblc_for_stream( + "MASTER-A", + bytes_3(3120001), + bytes_4(0xD2D2D2D2), + lambda _s, _st: None, + ) + is None + ) + + +def test_embedded_emblc_passthrough_reencodes_source_ta() -> None: + """passthrough overlays the source TA (re-encoded) once its blocks are complete.""" + config = _passthrough_config() + ta = make_talker_alias_use_cases(config) + blocks = _complete_blocks("CE5RPY embed") + + # No source TA yet -> only the destination group LC is sent (None overlay). + assert ( + ta.embedded_emblc_for_stream( + "MASTER-A", bytes_3(3120001), bytes_4(0xD3D3D3D3), lambda _s, _st: None, + ) + is None + ) + # Source TA complete -> re-encode it as the overlay. + result = ta.embedded_emblc_for_stream( + "MASTER-A", bytes_3(3120001), bytes_4(0xD3D3D3D4), lambda _s, _st: blocks, + ) + assert result is not None + emblcs, count = result + assert count >= 1 + assert len(emblcs) == count + + +def test_embedded_emblc_both_source_ta_then_fallback() -> None: + """both overlays the source TA when present, and the template when none arrives.""" + config = talker_alias_config() + config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both" + ta = make_talker_alias_use_cases(config) + blocks = _complete_blocks("Radio TA") + + # Source TA present -> overlay the source TA (re-encoded). + source = ta.embedded_emblc_for_stream( + "MASTER-A", bytes_3(9999999), bytes_4(0xD4D4D4D4), lambda _s, _st: blocks, + ) + assert source is not None + # No source TA yet, no fallback -> None (group LC only). + assert ( + ta.embedded_emblc_for_stream( + "MASTER-A", bytes_3(9999999), bytes_4(0xD4D4D4D5), lambda _s, _st: None, + ) + is None + ) + # No source TA, fallback -> inject the template embedded LC. + fallback = ta.embedded_emblc_for_stream( + "MASTER-A", + bytes_3(9999999), + bytes_4(0xD5D5D5D5), + lambda _s, _st: None, + fallback_inject=True, + ) + assert fallback is not None + emblcs, count = fallback + assert count >= 1 + assert len(emblcs) == count diff --git a/tests/talker_alias/test_encode_decode.py b/tests/talker_alias/test_encode_decode.py new file mode 100644 index 0000000..3843ddc --- /dev/null +++ b/tests/talker_alias/test_encode_decode.py @@ -0,0 +1,59 @@ +"""Talker Alias domain encode/decode roundtrips.""" + +from __future__ import annotations + +from adn_server.domain.talker_alias import ( + buffer_from_blocks, + decode_7bit, + decode_ta, + encode_7bit, + encode_iso8, + encode_utf8, + parse_ta_text_formats, + required_ta_block_count, + truncate_talker_alias, +) +from adn_server.infrastructure.talker_alias_emblc import encode_talker_alias_emblc + + +def test_truncate_talker_alias_caps_at_29() -> None: + long_text = "A" * 40 + assert len(truncate_talker_alias(long_text)) == 29 + + +def test_encode_utf8_roundtrip() -> None: + text = "CE5RPY Rodrigo" + assert decode_7bit(encode_utf8(text)) == text + + +def test_encode_7bit_roundtrip() -> None: + text = "CE5RPY" + assert decode_7bit(encode_7bit(text)) == text + + +def test_required_ta_block_count_skips_trailing_zeros() -> None: + buf = encode_utf8("Hi") + assert 1 <= required_ta_block_count(buf) <= 4 + + +def test_buffer_from_blocks_merges_payloads() -> None: + blocks = {0: b"\x01" * 7, 1: b"\x02" * 7} + buf = buffer_from_blocks(blocks) + assert buf[:7] == b"\x01" * 7 + assert buf[7:14] == b"\x02" * 7 + + +def test_encode_iso8_roundtrip() -> None: + text = "CE5RPY Niño" + assert decode_ta(encode_iso8(text)) == text + + +def test_parse_ta_text_formats_comma_list() -> None: + assert parse_ta_text_formats("utf8, iso8") == ["utf8", "iso8"] + + +def test_multi_format_emblc_concatenates() -> None: + single, _ = encode_talker_alias_emblc("Hi", ("utf8",)) + dual, n = encode_talker_alias_emblc("Hi", ("utf8", "iso8")) + assert n == len(dual) + assert len(dual) > len(single) diff --git a/tests/talker_alias/test_format.py b/tests/talker_alias/test_format.py new file mode 100644 index 0000000..a29dc6d --- /dev/null +++ b/tests/talker_alias/test_format.py @@ -0,0 +1,29 @@ +"""Talker Alias formatting and stream state.""" + +from __future__ import annotations + +from tests.harness.deterministic import PacketSpec +from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config + +from adn_server.application.talker_alias_use_cases import format_talker_alias_text +from adn_server.domain import bytes_3 + + +def test_format_talker_alias_from_subscriber_profile() -> None: + config = talker_alias_config() + text = format_talker_alias_text(config, bytes_3(3120001)) + assert text == "CE5RPY Rodrigo" + + +def test_talker_alias_clear_stream_allows_resend() -> None: + ta = make_talker_alias_use_cases(talker_alias_config()) + stream_id = PacketSpec(stream_id=0x93939393).data()[16:20] + key = ("MASTER-B", stream_id) + + assert ta.should_send_on_vhead("MASTER-B", stream_id) is True + ta.mark_dmra_sent("MASTER-B", stream_id) + assert ta.should_send_on_vhead("MASTER-B", stream_id) is False + + ta.clear_stream("MASTER-B", stream_id) + assert key not in ta._sent_streams + assert ta.should_send_on_vhead("MASTER-B", stream_id) is True diff --git a/tests/talker_alias/test_mmdvm_wire.py b/tests/talker_alias/test_mmdvm_wire.py new file mode 100644 index 0000000..40507d6 --- /dev/null +++ b/tests/talker_alias/test_mmdvm_wire.py @@ -0,0 +1,133 @@ +"""MMDVMHost DMRA wire layout and short TA passthrough.""" + +from __future__ import annotations + +from bitarray import bitarray + +from adn_server.application.talker_alias_use_cases import passthrough_complete, passthrough_packets_from_blocks +from adn_server.domain.talker_alias import ( + decode_ta_from_blocks, + encode_utf8, + parse_dmra_packet, + required_ta_block_count, + store_ta_from_embed_lc, + talker_alias_block_id_from_lc, + talker_alias_lc_bytes, + try_buffer_ta_from_voice_fragments, +) +from adn_server.domain.dmr.bptc import encode_emblc +from adn_server.infrastructure.talker_alias_emblc import encode_talker_alias_emblc + +_encode_emblc_correct = encode_emblc + + +def _voice_pkt_with_embed(frag: bitarray) -> bytes: + dmrbits = bitarray(264, endian="big") + dmrbits.setall(0) + dmrbits[116:148] = frag + return dmrbits.tobytes()[:33] +def mmdvm_wire_blocks(text: str) -> dict[int, bytes]: + """Build wire payloads as MMDVMHost writeTalkerAlias / CDMRTA::add (m_buf[block*7:]).""" + buf = encode_utf8(text) + count = required_ta_block_count(buf) + blocks: dict[int, bytes] = {} + for block_id in range(count): + start = block_id * 7 + blocks[block_id] = buf[start : start + 7] + return blocks + + +def test_passthrough_complete_single_mmdvm_wire_block_ce5rpy() -> None: + blocks = mmdvm_wire_blocks("CE5RPY") + assert passthrough_complete(blocks) is True + assert decode_ta_from_blocks(blocks) == "CE5RPY" + + +def test_passthrough_complete_mmdvm_wire_two_blocks() -> None: + blocks = mmdvm_wire_blocks("CE5RPY Rodrigo") + assert passthrough_complete(blocks) is True + assert decode_ta_from_blocks(blocks) == "CE5RPY Rodrigo" + + +def test_peer_dmra_non_mmdvm_layout_rejected() -> None: + """Pi-Star log with byte7=0x3D is not MMDVM writeTalkerAlias (type must be 0-3).""" + data = b"DMRA+\x83\x83=oe(\x00\x8cCE" + assert parse_dmra_packet(data) is None + + +def test_talker_alias_block_id_from_lc() -> None: + lc9 = talker_alias_lc_bytes(2, encode_utf8("CE5RPY")[0:7]) + assert talker_alias_block_id_from_lc(lc9) == 2 + assert talker_alias_block_id_from_lc(b"\x00\x00" + b"\x00" * 7) is None + + +def test_buffer_ta_from_voice_fragments_decodes_ce5rpy() -> None: + """Voice bursts B–E carrying a correctly FEC-encoded TA decode losslessly to CE5RPY.""" + text = "CE5RPY" + lc9 = talker_alias_lc_bytes(0, encode_utf8(text)[0:7]) + frags = _encode_emblc_correct(lc9) + blocks: dict[int, bytes] = {} + acc: dict[int, object] = {} + for vseq in (1, 2, 3): + assert not try_buffer_ta_from_voice_fragments(acc, vseq, _voice_pkt_with_embed(frags[vseq]), blocks) + assert try_buffer_ta_from_voice_fragments(acc, 4, _voice_pkt_with_embed(frags[4]), blocks) + assert passthrough_complete(blocks) + assert decode_ta_from_blocks(blocks) == text + + +def test_injected_emblc_round_trips_losslessly() -> None: + """Injected embedded LC must survive decode_emblc (regression: 'Rodrigo' -> 'Rodrigg'). + + The upstream dmr_utils3 encoder corrupted segment D (bit 25); our vendored encoder fixes it, + so every injected block round-trips through decode_emblc. + """ + from adn_server.domain.dmr import bptc + + text = "CE5RPY Rodrigo" + emblcs, count = encode_talker_alias_emblc(text) + assert count >= 3 + recovered: dict[int, bytes] = {} + for block_id, emblc in enumerate(emblcs): + lc = bptc.decode_emblc(emblc[1] + emblc[2] + emblc[3] + emblc[4]) + assert lc == talker_alias_lc_bytes(block_id, encode_utf8(text)[block_id * 7 : block_id * 7 + 7]) + recovered[block_id] = lc[2:9] + assert decode_ta_from_blocks(recovered) == text + + +def test_buffer_ta_from_voice_ignores_group_lc() -> None: + """Group-call embedded LC (FLCO 0) is not stored as TA.""" + group_lc = bytes([0x00, 0x00]) + b"\x00\x12\x34\x00\x00\x07\x53" + frags = _encode_emblc_correct(group_lc) + blocks: dict[int, bytes] = {} + acc: dict[int, object] = {} + for vseq in (1, 2, 3, 4): + try_buffer_ta_from_voice_fragments(acc, vseq, _voice_pkt_with_embed(frags[vseq]), blocks) + assert blocks == {} + + +def test_store_ta_from_embed_lc_matches_wire() -> None: + """Wire payload == lc9[2:9] per DMRNetwork::writeTalkerAlias.""" + text = "CE5RPY" + encoded = encode_utf8(text) + lc9 = talker_alias_lc_bytes(0, encoded[0:7]) + blocks: dict[int, bytes] = {} + assert store_ta_from_embed_lc(blocks, 0, lc9) + parsed = parse_dmra_packet(b"DMRA" + b"\x00\x00\x01" + bytes([0]) + blocks[0]) + assert parsed is not None + assert parsed[2] == blocks[0] + + +def test_mmdvm_three_byte_id_layout() -> None: + data = b"DMRA\x2b\x83\x83\x01" + b"\x00" * 7 + parsed = parse_dmra_packet(data) + assert parsed == (bytes([0x2B, 0x83, 0x83]), 1, b"\x00" * 7) + + +def test_passthrough_packets_sends_only_required_blocks() -> None: + from tests.talker_alias.test_passthrough import _complete_blocks + + rf = bytes([0x2B, 0x83, 0x83]) + blocks = _complete_blocks("CE5RPY") + packets = passthrough_packets_from_blocks(rf, blocks) + assert len(packets) == 1 + assert packets[0][7] == 0 diff --git a/tests/talker_alias/test_passthrough.py b/tests/talker_alias/test_passthrough.py new file mode 100644 index 0000000..725c1e8 --- /dev/null +++ b/tests/talker_alias/test_passthrough.py @@ -0,0 +1,181 @@ +"""Talker Alias passthrough and both modes.""" + +from __future__ import annotations + +from tests.harness.scenarios import make_talker_alias_use_cases, talker_alias_config + +from adn_server.application.talker_alias_use_cases import ( + passthrough_complete, + passthrough_packets_from_blocks, +) +from adn_server.domain import bytes_3, bytes_4 +from adn_server.domain.talker_alias import ( + DMRA_BLOCK_COUNT, + DMRA_OPCODE, + blocks_from_buffer, + buffer_from_blocks, + decode_7bit, + encode_utf8, +) + + +def _complete_blocks(text: str) -> dict[int, bytes]: + payloads = blocks_from_buffer(encode_utf8(text)) + return {i: payloads[i] for i in range(DMRA_BLOCK_COUNT)} + + +def _passthrough_config() -> dict: + config = talker_alias_config() + config["GLOBAL"]["TALKER_ALIAS_MODE"] = "passthrough" + return config + + +def test_passthrough_complete_full_adn_blocks() -> None: + blocks = _complete_blocks("CE5RPY Test") + assert passthrough_complete(blocks) is True + + +def test_passthrough_complete_partial_adn_blocks_fails() -> None: + blocks = _complete_blocks("CE5RPY Test") + assert passthrough_complete({0: blocks[0]}) is False + + +def test_passthrough_packets_rebuild_dmra_opcode() -> None: + text = "CE5RPY Test" + blocks = _complete_blocks(text) + rf_src = bytes_3(3120001) + packets = passthrough_packets_from_blocks(rf_src, blocks) + + assert 1 <= len(packets) <= DMRA_BLOCK_COUNT + assert all(p[:4] == DMRA_OPCODE for p in packets) + assert all(p[4:7] == rf_src for p in packets) + rebuilt = {i: packets[i][8:15] for i in range(len(packets))} + assert decode_7bit(buffer_from_blocks(rebuilt)) == text + + +def test_packets_for_stream_passthrough_mode() -> None: + config = _passthrough_config() + ta = make_talker_alias_use_cases(config) + stream_id = bytes_4(0xA1A1A1A1) + blocks = _complete_blocks("CE5RPY Rodrigo") + + def get_blocks(_system: str, _stream: bytes) -> dict[int, bytes]: + return blocks + + packets = ta.packets_for_stream( + "MASTER-A", + bytes_3(3120001), + stream_id, + get_blocks, + target_system="MASTER-B", + ) + + assert packets is not None + assert 1 <= len(packets) <= DMRA_BLOCK_COUNT + assert all(p[:4] == DMRA_OPCODE for p in packets) + + +def test_packets_for_stream_passthrough_without_blocks_returns_none() -> None: + config = _passthrough_config() + ta = make_talker_alias_use_cases(config) + + assert ( + ta.packets_for_stream( + "MASTER-A", + bytes_3(3120001), + bytes_4(0xB2B2B2B2), + lambda _s, _st: None, + target_system="MASTER-B", + ) + is None + ) + + +def test_packets_for_stream_both_prefers_passthrough() -> None: + config = talker_alias_config() + config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both" + ta = make_talker_alias_use_cases(config) + passthrough_text = "From radio" + blocks = _complete_blocks(passthrough_text) + + packets = ta.packets_for_stream( + "MASTER-A", + bytes_3(9999999), + bytes_4(0xC3C3C3C3), + lambda _s, _st: blocks, + target_system="MASTER-B", + ) + + assert packets is not None + expected = passthrough_packets_from_blocks(bytes_3(9999999), blocks) + assert packets == expected + + +def test_packets_for_stream_both_without_blocks_preserves_source() -> None: + """both + no DMRA buffer (still waiting): do not inject; source embedded TA may arrive.""" + config = talker_alias_config() + config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both" + ta = make_talker_alias_use_cases(config) + + assert ( + ta.packets_for_stream( + "MASTER-A", + bytes_3(3120001), + bytes_4(0xD4D4D4D4), + lambda _s, _st: None, + target_system="MASTER-B", + ) + is None + ) + + +def test_packets_for_stream_both_fallback_inject_when_no_source_ta() -> None: + """both + window elapsed with no source TA: fall back to injecting the template.""" + config = talker_alias_config() + config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both" + ta = make_talker_alias_use_cases(config) + + packets = ta.packets_for_stream( + "MASTER-A", + bytes_3(3120001), + bytes_4(0xE5E5E5E5), + lambda _s, _st: None, + target_system="MASTER-B", + fallback_inject=True, + ) + + assert packets is not None + assert 1 <= len(packets) <= DMRA_BLOCK_COUNT + assert all(p[:4] == DMRA_OPCODE for p in packets) + assert all(p[4:7] == bytes_3(3120001) for p in packets) + + +def test_embedded_emblc_both_fallback_inject_returns_template() -> None: + """both fallback inject also rewrites the embedded LC with the template.""" + config = talker_alias_config() + config["GLOBAL"]["TALKER_ALIAS_MODE"] = "both" + ta = make_talker_alias_use_cases(config) + + # Without fallback the embedded LC is preserved (None); with fallback it injects. + assert ( + ta.embedded_emblc_for_stream( + "MASTER-A", + bytes_3(3120001), + bytes_4(0xE6E6E6E6), + lambda _s, _st: None, + target_system="MASTER-B", + ) + is None + ) + emblcs = ta.embedded_emblc_for_stream( + "MASTER-A", + bytes_3(3120001), + bytes_4(0xE7E7E7E7), + lambda _s, _st: None, + target_system="MASTER-B", + fallback_inject=True, + ) + assert emblcs is not None + blocks, count = emblcs + assert 1 <= count <= DMRA_BLOCK_COUNT + assert len(blocks) == count diff --git a/tests/voice/test_announcement_anticollision.py b/tests/voice/test_announcement_anticollision.py new file mode 100644 index 0000000..d2976c3 --- /dev/null +++ b/tests/voice/test_announcement_anticollision.py @@ -0,0 +1,50 @@ +"""Voice announcement anti-collision.""" + +from __future__ import annotations + +from tests.harness.voice_helpers import make_voice_uc, voice_master_scenario + +from adn_server.domain import HBPF_SLT_VHEAD, HBPF_SLT_VTERM, bytes_3 + + +def test_build_targets_skips_busy_qso_slot() -> None: + scenario, master = voice_master_scenario() + master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD + master.STATUS[2]["TX_TYPE"] = HBPF_SLT_VTERM + uc = make_voice_uc(scenario, master) + + targets, busy = uc._build_announcement_targets(91, "91", "ANN-TEST") + + assert targets == [] + assert busy == 1 + + +def test_build_targets_includes_idle_slot() -> None: + scenario, master = voice_master_scenario() + master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VTERM + master.STATUS[2]["TX_TYPE"] = HBPF_SLT_VTERM + uc = make_voice_uc(scenario, master) + + targets, busy = uc._build_announcement_targets(91, "91", "ANN-TEST") + + assert busy == 0 + assert len(targets) == 1 + assert targets[0]["name"] == "MASTER-A" + assert targets[0]["ts"] == 2 + + +def test_broadcast_aborts_when_qso_starts_mid_transmission() -> None: + scenario, master = voice_master_scenario() + master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VTERM + master.STATUS[2]["TX_TYPE"] = HBPF_SLT_VTERM + uc = make_voice_uc(scenario, master) + targets = [{"sys_obj": master, "name": "MASTER-A", "slot": master.STATUS[2], "ts": 2}] + pkts = {1: [b"\x00" * 55], 2: [b"\x00" * 55]} + source = bytes_3(5000) + dst = bytes_3(91) + + master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD + uc._announcement_send_broadcast(targets, pkts, 0, source, dst, 91, 0, "ANN-TEST", None) + + assert uc._announcement_running[0] is False + assert master.sent == [] diff --git a/tests/voice/test_broadcast_queue.py b/tests/voice/test_broadcast_queue.py new file mode 100644 index 0000000..6dadcd8 --- /dev/null +++ b/tests/voice/test_broadcast_queue.py @@ -0,0 +1,53 @@ +"""Voice broadcast queue (same-TG serialization).""" + +from __future__ import annotations + +from tests.harness.voice_helpers import make_voice_uc, voice_master_scenario + +from adn_server.domain import bytes_3 + + +def test_enqueue_broadcast_queues_second_same_tg() -> None: + scenario, master = voice_master_scenario() + uc = make_voice_uc(scenario, master) + targets = [{"sys_obj": master, "name": "MASTER-A", "slot": master.STATUS[2], "ts": 2}] + pkts = {1: [b"\x00" * 55], 2: [b"\x00" * 55]} + source = bytes_3(5000) + dst = bytes_3(91) + + uc._broadcast_active_tgs.add("91") + uc._enqueue_broadcast("ann", targets, pkts, source, dst, 91, 0, "ANN-2") + + assert len(uc._broadcast_queue) == 1 + assert uc._scheduled == [] + + +def test_broadcast_queue_drains_after_first_finishes() -> None: + scenario, master = voice_master_scenario() + uc = make_voice_uc(scenario, master) + targets = [{"sys_obj": master, "name": "MASTER-A", "slot": master.STATUS[2], "ts": 2}] + pkts = {1: [b"\x00" * 55], 2: [b"\x00" * 55]} + source = bytes_3(5000) + dst = bytes_3(91) + + uc._broadcast_active_tgs.add("91") + uc._broadcast_queue.append( + { + "type": "ann", + "targets": targets, + "pkts_by_ts": pkts, + "source_id": source, + "dst_id": dst, + "tg": 91, + "num": 1, + "label": "ANN-2", + } + ) + uc._broadcast_finished(91) + assert len(uc._scheduled) == 1 + uc._scheduled.pop(0)[1][0]() + + assert "91" in uc._broadcast_active_tgs + assert uc._broadcast_queue == [] + assert len(uc._scheduled) == 1 + assert uc._scheduled[0][0] == 0.5 diff --git a/tests/voice/test_disconnected_voice.py b/tests/voice/test_disconnected_voice.py new file mode 100644 index 0000000..af4b7ac --- /dev/null +++ b/tests/voice/test_disconnected_voice.py @@ -0,0 +1,108 @@ +"""Disconnected / linked-to-reflector voice prompts.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from tests.harness.voice_helpers import FakeMasterForVoice, FakeVoiceProvider, voice_master_scenario + +from adn_server.application.voice_use_cases import VoiceUseCases + + +class _VoiceMaster(FakeMasterForVoice): + def __init__(self, name: str) -> None: + super().__init__(name) + self.voice_packets: list[bytes] = [] + + def send_voice_packet(self, packet: bytes, _source_id: bytes, _dst_id: bytes, _slot: dict) -> None: + self.voice_packets.append(packet) + + +class _WordsProvider(FakeVoiceProvider): + def get_ambe_words(self, languages: str, audio_path: str) -> dict: + del audio_path + silence = b"\x00" * 7 + return { + languages: { + "silence": silence, + "notlinked": silence, + "linkedto": silence, + "to": silence, + **{str(d): silence for d in range(10)}, + } + } + + +def _disconnected_uc(master: _VoiceMaster, scenario, provider: FakeVoiceProvider | None = None) -> VoiceUseCases: + def call_from_reactor(fn, *args): + fn(*args) + + return VoiceUseCases( + provider or _WordsProvider(), + scenario.config, + get_protocols=lambda: {"MASTER-A": master}, + call_from_reactor=call_from_reactor, + audio_path="/tmp/audio", + ) + + +@pytest.mark.behavior +def test_disconnected_voice_not_linked_prompt() -> None: + """Regression: DEFAULT_REFLECTOR=0 sends not-linked prompt packets.""" + scenario, _ = voice_master_scenario() + master = _VoiceMaster("MASTER-A") + master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2} + scenario.config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 0 + uc = _disconnected_uc(master, scenario) + + with patch("adn_server.application.voice_use_cases.time") as mock_time: + mock_time.sleep = MagicMock() + mock_time.time.side_effect = [2000.0] * 50 + uc.disconnected_voice("MASTER-A") + + assert len(master.voice_packets) >= 1 + assert all(pkt[:4] == b"DMRD" for pkt in master.voice_packets) + + +@pytest.mark.behavior +def test_disconnected_voice_linked_to_reflector_includes_digits() -> None: + """Regression: linked reflector prompt builds longer speech sequence than not-linked.""" + scenario, _ = voice_master_scenario() + master = _VoiceMaster("MASTER-A") + master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2} + scenario.config["SYSTEMS"]["MASTER-A"]["DEFAULT_REFLECTOR"] = 310 + uc = _disconnected_uc(master, scenario) + linked_say: list[list] = [] + orig_pkt_gen = VoiceUseCases.pkt_gen + + def capture_pkt_gen(self, *args, **kwargs): + linked_say.append(list(args[4])) + return orig_pkt_gen(self, *args, **kwargs) + + with patch.object(VoiceUseCases, "pkt_gen", capture_pkt_gen): + with patch("adn_server.application.voice_use_cases.time") as mock_time: + mock_time.sleep = MagicMock() + mock_time.time.side_effect = [3000.0] * 80 + uc.disconnected_voice("MASTER-A") + + assert len(linked_say) == 1 + assert len(linked_say[0]) > 4 + assert len(master.voice_packets) >= 3 + assert all(pkt[:4] == b"DMRD" for pkt in master.voice_packets) + + +def test_disconnected_voice_skips_when_language_missing() -> None: + scenario, _ = voice_master_scenario() + master = _VoiceMaster("MASTER-A") + master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2} + + class EmptyWordsProvider(FakeVoiceProvider): + def get_ambe_words(self, languages: str, audio_path: str) -> dict: + del languages, audio_path + return {} + + uc = _disconnected_uc(master, scenario, EmptyWordsProvider()) + uc.disconnected_voice("MASTER-A") + + assert master.voice_packets == [] diff --git a/tests/voice/test_embed_ta_forward.py b/tests/voice/test_embed_ta_forward.py new file mode 100644 index 0000000..15fe52a --- /dev/null +++ b/tests/voice/test_embed_ta_forward.py @@ -0,0 +1,39 @@ +"""Talker Alias embedded LC alternation on voice bursts.""" + +from __future__ import annotations + +from tests.harness.deterministic import DeterministicScenario, PacketSpec, active_bridge +from tests.harness.scenarios import talker_alias_config + + +def test_talker_alias_embed_state_prepared_on_bridge_vhead() -> None: + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges) + base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0xABABABAB) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + + ts_st = scenario.protocols["MASTER-B"].STATUS.get(2, {}) + assert "TX_TA_EMB" in ts_st + assert ts_st.get("TX_TA_ON") is False + assert ts_st.get("TX_TA_PHASE") == 0 + + +def test_talker_alias_embed_cleared_on_vterm() -> None: + bridges = active_bridge(91, (("MASTER-A", 2), ("MASTER-B", 2))) + scenario = DeterministicScenario(config=talker_alias_config(), bridges=bridges) + base = PacketSpec(dst_id=91, rf_src=3120001, stream_id=0xCDCDCDCD) + + scenario.inject_hbp("MASTER-A", DeterministicScenario.voice_head_spec(base)) + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_burst_spec(base, seq=1, dtype_vseq=1), + ) + scenario.inject_hbp( + "MASTER-A", + DeterministicScenario.voice_term_spec(base, seq=99), + ) + + ts_st = scenario.protocols["MASTER-B"].STATUS.get(2, {}) + assert "TX_TA_EMB" not in ts_st + assert "TX_TA_ON" not in ts_st diff --git a/tests/voice/test_in_band_signalling.py b/tests/voice/test_in_band_signalling.py new file mode 100644 index 0000000..cf796db --- /dev/null +++ b/tests/voice/test_in_band_signalling.py @@ -0,0 +1,70 @@ +"""In-band voice signalling on VTERM.""" + +from __future__ import annotations + +import copy + +from tests.harness.deterministic import DeterministicScenario, active_bridge +from tests.harness.voice_helpers import reflector_bridge_entry + +from adn_server.domain import bytes_3 + + +def _user_bridge(active: bool = True) -> dict: + return active_bridge(52090, (("MASTER-A", 2),)) + + +def test_reflector_bridge_ignored_when_vterm_not_on_tg9() -> None: + bridges = _user_bridge() + bridges.update(reflector_bridge_entry()) + scenario = DeterministicScenario(bridges=bridges) + leg = scenario.bridge.get_bridges()["#310"][0] + timer_before = leg["TIMER"] + + scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(52090), pkt_time=2000.0) + + assert scenario.bridge.get_bridges()["#310"][0]["TIMER"] == timer_before + assert scenario.bridge.get_bridges()["#310"][0]["ACTIVE"] is True + + +def test_reflector_bridge_processes_vterm_on_tg9() -> None: + bridges = _user_bridge() + bridges.update(reflector_bridge_entry()) + scenario = DeterministicScenario(bridges=bridges) + + scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(9), pkt_time=2000.0) + + leg = scenario.bridge.get_bridges()["#310"][0] + assert leg["ACTIVE"] is True + assert leg["TIMER"] == 2000.0 + leg["TIMEOUT"] + + +def test_single_mode_deactivates_on_non_source_tg() -> None: + bridges = _user_bridge() + scenario = DeterministicScenario(bridges=bridges) + scenario.config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] = True + assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is True + + scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(91), pkt_time=3000.0) + + assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is False + + +def test_non_single_mode_keeps_bridge_on_arbitrary_vterm_tg() -> None: + bridges = _user_bridge() + scenario = DeterministicScenario(bridges=bridges) + scenario.config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] = False + + scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(91), pkt_time=3000.0) + + assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is True + + +def test_non_single_mode_deactivates_on_tg4000() -> None: + bridges = copy.deepcopy(_user_bridge()) + scenario = DeterministicScenario(bridges=bridges) + scenario.config["SYSTEMS"]["MASTER-A"]["SINGLE_MODE"] = False + + scenario.bridge.apply_in_band_signalling("MASTER-A", 2, bytes_3(4000), pkt_time=3000.0) + + assert scenario.bridge.get_bridges()["52090"][0]["ACTIVE"] is False diff --git a/tests/voice/test_play_file_on_request.py b/tests/voice/test_play_file_on_request.py new file mode 100644 index 0000000..2ab26a3 --- /dev/null +++ b/tests/voice/test_play_file_on_request.py @@ -0,0 +1,88 @@ +"""On-demand AMBE playback (TG 9991-9999, playFileOnRequest).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from tests.harness.voice_helpers import FakeMasterForVoice, FakeVoiceProvider, voice_master_scenario + +from adn_server.application.voice_use_cases import VoiceUseCases + + +class _MasterWithVoice(FakeMasterForVoice): + def __init__(self, name: str) -> None: + super().__init__(name) + self.voice_packets: list[bytes] = [] + + def send_voice_packet(self, packet: bytes, _source_id: bytes, _dst_id: bytes, _slot: dict) -> None: + self.voice_packets.append(packet) + + +def _play_file_uc(master: _MasterWithVoice, scenario) -> VoiceUseCases: + def call_from_reactor(fn, *args): + fn(*args) + + return VoiceUseCases( + FakeVoiceProvider(), + scenario.config, + get_protocols=lambda: {"MASTER-A": master}, + call_from_reactor=call_from_reactor, + audio_path="/tmp/audio", + ) + + +def test_play_file_on_request_sends_all_generated_packets() -> None: + scenario, _master = voice_master_scenario() + master = _MasterWithVoice("MASTER-A") + master.STATUS[2] = {"RX_TYPE": 2, "TX_TYPE": 2} + uc = _play_file_uc(master, scenario) + + with patch("adn_server.application.voice_use_cases.time") as mock_time: + mock_time.sleep = MagicMock() + mock_time.time.side_effect = [1000.0] * 20 + uc.play_file_on_request("9991", "MASTER-A") + + assert len(master.voice_packets) == 3 + assert all(pkt[:4] == b"DMRD" for pkt in master.voice_packets) + + +def test_play_file_on_request_skips_when_file_missing() -> None: + scenario, _master = voice_master_scenario() + master = _MasterWithVoice("MASTER-A") + master.STATUS[2] = {} + + class EmptyProvider(FakeVoiceProvider): + def read_single_file(self, audio_path: str, lang: str, file_number: str) -> list: + del audio_path, lang, file_number + return [] + + def call_from_reactor(fn, *args): + fn(*args) + + uc = VoiceUseCases( + EmptyProvider(), + scenario.config, + get_protocols=lambda: {"MASTER-A": master}, + call_from_reactor=call_from_reactor, + audio_path="/tmp/audio", + ) + + uc.play_file_on_request("9992", "MASTER-A") + + assert master.voice_packets == [] + + +def test_play_file_on_request_requires_reactor_callback() -> None: + scenario, _master = voice_master_scenario() + master = _MasterWithVoice("MASTER-A") + master.STATUS[2] = {} + uc = VoiceUseCases( + FakeVoiceProvider(), + scenario.config, + get_protocols=lambda: {"MASTER-A": master}, + audio_path="/tmp/audio", + ) + + uc.play_file_on_request("9993", "MASTER-A") + + assert master.voice_packets == [] diff --git a/tests/voice/test_scheduled_announcement.py b/tests/voice/test_scheduled_announcement.py new file mode 100644 index 0000000..55b091d --- /dev/null +++ b/tests/voice/test_scheduled_announcement.py @@ -0,0 +1,70 @@ +"""Scheduled file announcements (AMBE on disk).""" + +from __future__ import annotations + +from tests.harness.voice_helpers import ( + drain_call_later, + make_voice_uc, + voice_announcement_config, + voice_master_scenario, +) + +from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM + + +def test_scheduled_announcement_starts_broadcast_on_idle_slot() -> None: + scenario, master = voice_master_scenario() + voice_announcement_config(scenario) + uc = make_voice_uc(scenario, master) + + uc.scheduled_announcement(0) + + assert uc._announcement_running[0] is True + assert "91" in uc._broadcast_active_tgs + assert master.STATUS[2]["TX_TYPE"] == HBPF_SLT_VHEAD + assert len(uc._scheduled) == 1 + delay, _scheduled = uc._scheduled[0] + assert delay == 0.5 + drain_call_later(uc) + assert len(master.sent) == 3 + + +def test_scheduled_announcement_retries_when_slot_busy() -> None: + scenario, master = voice_master_scenario() + voice_announcement_config(scenario) + master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD + uc = make_voice_uc(scenario, master) + + uc.scheduled_announcement(0) + + assert uc._announcement_running.get(0) is not True + assert len(uc._scheduled) == 1 + assert master.sent == [] + delay, (_fn, args) = uc._scheduled[0] + assert delay == 5.0 + assert args == (0, 1) + + +def test_scheduled_announcement_skips_when_disabled() -> None: + scenario, master = voice_master_scenario() + voice_announcement_config(scenario, enabled=False) + uc = make_voice_uc(scenario, master) + + uc.scheduled_announcement(0) + + assert uc._announcement_running.get(0) is not True + assert uc._scheduled == [] + + +def test_scheduled_announcement_sends_packets_to_master() -> None: + scenario, master = voice_master_scenario() + voice_announcement_config(scenario) + uc = make_voice_uc(scenario, master) + + uc.scheduled_announcement(0) + drain_call_later(uc) + + assert uc._announcement_running[0] is False + assert "91" not in uc._broadcast_active_tgs + assert len(master.sent) == 3 + assert master.STATUS[2]["TX_TYPE"] == HBPF_SLT_VTERM diff --git a/tests/voice/test_scheduled_tts.py b/tests/voice/test_scheduled_tts.py new file mode 100644 index 0000000..19f2b4c --- /dev/null +++ b/tests/voice/test_scheduled_tts.py @@ -0,0 +1,137 @@ +"""Scheduled TTS announcements and conversion callbacks.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock, patch + +from tests.harness.voice_helpers import make_voice_uc, voice_master_scenario, voice_tts_config + +from adn_server.application.voice_use_cases import VoiceUseCases +from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD + + +def test_scheduled_tts_sync_path_enqueues_broadcast() -> None: + scenario, master = voice_master_scenario() + voice_tts_config(scenario) + uc = make_voice_uc(scenario, master) + + uc.scheduled_tts_announcement(0) + + assert uc._broadcast_active_tgs == {"91"} + assert len(uc._scheduled) == 1 + assert getattr(uc._scheduled[0][1][0], "__name__", "") == "_tts_send_broadcast" + + +def test_tts_conversion_error_clears_running_flag() -> None: + scenario, master = voice_master_scenario() + uc = make_voice_uc(scenario, master) + uc._tts_running[0] = True + + uc._tts_conversion_error(RuntimeError("TTS failed"), 0, "TTS-1") + + assert uc._tts_running[0] is False + + +def test_tts_conversion_done_without_ambe_clears_running() -> None: + scenario, master = voice_master_scenario() + uc = make_voice_uc(scenario, master) + uc._tts_running[0] = True + + uc._tts_conversion_done(None, 0, "msg.ambe", 91, "en_GB", "interval", "TTS-1") + + assert uc._tts_running[0] is False + assert uc._scheduled == [] + + +def test_tts_conversion_done_retries_when_slot_busy() -> None: + scenario, master = voice_master_scenario() + master.STATUS[2]["RX_TYPE"] = HBPF_SLT_VHEAD + uc = make_voice_uc(scenario, master) + uc._tts_running[0] = True + + uc._tts_conversion_done("/tmp/fake.ambe", 0, "msg.ambe", 91, "en_GB", "interval", "TTS-1") + + assert uc._tts_running[0] is True + assert len(uc._scheduled) == 1 + delay, (fn, args) = uc._scheduled[0] + assert delay == 5.0 + assert getattr(fn, "__name__", "") == "_tts_conversion_done" + assert args[-1] == 1 + + +def test_scheduled_tts_skips_outside_top_of_hour() -> None: + scenario, master = voice_master_scenario() + scenario.config["VOICE"] = { + "TTS_ANNOUNCEMENTS": [ + { + "ENABLED": True, + "TG": 91, + "FILE": "hourly.ambe", + "LANGUAGE": "en_GB", + "MODE": "hourly", + } + ] + } + uc = make_voice_uc(scenario, master) + + with patch("adn_server.application.voice_use_cases.datetime") as mock_dt: + mock_dt.now.return_value = datetime(2026, 5, 24, 10, 30) + uc.scheduled_tts_announcement(0) + + assert uc._tts_running.get(0) is not True + assert uc._scheduled == [] + + +def test_scheduled_tts_defers_when_same_tg_already_broadcasting() -> None: + scenario, master = voice_master_scenario() + voice_tts_config(scenario) + uc = make_voice_uc(scenario, master) + uc._broadcast_active_tgs.add("91") + + uc.scheduled_tts_announcement(0) + + assert uc._tts_running.get(0) is not True + assert len(uc._scheduled) == 1 + delay, (fn, args) = uc._scheduled[0] + assert delay == 3.0 + assert getattr(fn, "__name__", "") == "scheduled_tts_announcement" + assert args == (0, 1) + + +def test_scheduled_tts_sync_exception_clears_running() -> None: + scenario, master = voice_master_scenario() + voice_tts_config(scenario) + + class FailingProvider: + def ensure_tts_ambe(self, config, item, audio_path): + del config, item, audio_path + raise OSError("disk full") + + def read_single_file(self, *args): + return [b"\x00" * 7] + + def pkt_gen(self, *args, **kwargs): + return iter([]) + + def get_ambe_words(self, *args): + return {} + + scheduled: list[tuple[float, tuple]] = [] + + def call_later(delay, fn, *args): + scheduled.append((delay, (fn, args))) + return MagicMock() + + uc = VoiceUseCases( + FailingProvider(), + scenario.config, + get_protocols=lambda: {"MASTER-A": master}, + get_bridges=scenario.bridge.get_bridges, + call_later=call_later, + audio_path="/tmp/audio", + ) + + uc.scheduled_tts_announcement(0) + + assert uc._tts_running[0] is False diff --git a/tests/voice/test_voice_config_reload.py b/tests/voice/test_voice_config_reload.py new file mode 100644 index 0000000..c839e83 --- /dev/null +++ b/tests/voice/test_voice_config_reload.py @@ -0,0 +1,92 @@ +"""Voice config reload and announcement LoopingCall management.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from tests.harness.voice_helpers import FakeVoiceProvider, voice_master_scenario + +from adn_server.application.voice_use_cases import VoiceUseCases + + +def _reload_uc(scenario, *, start_looping_call) -> VoiceUseCases: + return VoiceUseCases( + FakeVoiceProvider(), + scenario.config, + start_looping_call=start_looping_call, + audio_path="/tmp/audio", + ) + + +def test_check_voice_config_reload_starts_enabled_announcement_loop() -> None: + scenario, _ = voice_master_scenario() + scenario.config["VOICE"] = { + "ANNOUNCEMENTS": [ + { + "ENABLED": True, + "TG": 91, + "FILE": "welcome", + "LANGUAGE": "en_GB", + "MODE": "interval", + "INTERVAL": 120, + } + ], + } + started: list[tuple[float, object]] = [] + + def start_looping_call(fn, interval, _now): + started.append((interval, fn)) + handle = MagicMock(running=True) + handle.stop = MagicMock() + return handle + + uc = _reload_uc(scenario, start_looping_call=start_looping_call) + uc.check_voice_config_reload() + + assert len(started) == 1 + assert started[0][0] == 120.0 + assert 0 in uc._ann_tasks + + +def test_check_voice_config_reload_stops_removed_announcement() -> None: + scenario, _ = voice_master_scenario() + scenario.config["VOICE"] = {"ANNOUNCEMENTS": [{"ENABLED": False, "TG": 91, "FILE": "x"}]} + stop_mock = MagicMock() + uc = VoiceUseCases( + FakeVoiceProvider(), + scenario.config, + start_looping_call=lambda *_a: MagicMock(running=True, stop=stop_mock), + audio_path="/tmp/audio", + ) + uc._ann_tasks[0] = MagicMock(running=True, stop=stop_mock) + + uc.check_voice_config_reload() + + assert 0 not in uc._ann_tasks + stop_mock.assert_called_once() + + +def test_check_voice_config_reload_starts_tts_loop() -> None: + scenario, _ = voice_master_scenario() + scenario.config["VOICE"] = { + "TTS_ANNOUNCEMENTS": [ + { + "ENABLED": True, + "TG": 91, + "FILE": "hourly.ambe", + "LANGUAGE": "en_GB", + "MODE": "hourly", + } + ], + } + started: list[float] = [] + + def start_looping_call(_fn, interval, _now): + started.append(interval) + return MagicMock(running=True, stop=MagicMock()) + + uc = _reload_uc(scenario, start_looping_call=start_looping_call) + uc.check_voice_config_reload() + + assert started == [30.0] + assert 0 in uc._tts_tasks