Integrate develop pytest layout, CI workflows, and routing test coverage on top of the downlink-clean application tree.pull/29/head
parent
905ec792f4
commit
f404dee708
@ -0,0 +1,80 @@
|
|||||||
|
# Remind release PR authors to use merge commit (not squash) so post-release
|
||||||
|
# develop sync can fast-forward and release PRs do not accumulate phantom commits.
|
||||||
|
|
||||||
|
name: release-pr-check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
if: github.event.pull_request.head.ref == 'develop'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout develop tip
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Measure delta vs master
|
||||||
|
id: delta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git fetch origin master
|
||||||
|
commits="$(git rev-list --count origin/master..HEAD)"
|
||||||
|
echo "commits=${commits}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Commits on develop not in master: ${commits}"
|
||||||
|
git diff origin/master...HEAD --stat | tail -1 || true
|
||||||
|
|
||||||
|
- name: Post merge-method reminder
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const marker = '<!-- release-pr-check -->';
|
||||||
|
const commits = '${{ steps.delta.outputs.commits }}';
|
||||||
|
const body = [
|
||||||
|
marker,
|
||||||
|
'## Release PR — merge method',
|
||||||
|
'',
|
||||||
|
`**Commits listed on this PR:** ${commits} (file diff may be smaller — that is normal when squash releases stacked up).`,
|
||||||
|
'',
|
||||||
|
'### Required',
|
||||||
|
'Merge with **Create a merge commit**. Do **not** use **Squash and merge**.',
|
||||||
|
'',
|
||||||
|
'### Why',
|
||||||
|
'- Squash on `master` creates new commit SHAs; `develop` keeps the original squash commits from feature PRs.',
|
||||||
|
'- The next release PR then lists old fixes again even though `master` already has them.',
|
||||||
|
'- **Merge commit** keeps the same SHAs on both branches; CI **fast-forwards** `develop` to `master` after `chore(release)` — no force-push, local `git pull` on `develop` still works.',
|
||||||
|
'',
|
||||||
|
'### One-time cleanup',
|
||||||
|
'If this PR lists commits from earlier releases (2.0.4, 2.0.5, …), merge once with **merge commit**; post-release sync realigns `develop` with `master` automatically.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.find((c) => c.body?.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
# Release workflow
|
||||||
|
#
|
||||||
|
# master: python-semantic-release on push → semver tag + GitHub Release → sync develop
|
||||||
|
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.repository }}-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref_name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Reset to workflow SHA
|
||||||
|
run: git reset --hard ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Semantic Release
|
||||||
|
id: semantic
|
||||||
|
uses: python-semantic-release/python-semantic-release@v10.5.3
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
git_committer_name: github-actions[bot]
|
||||||
|
git_committer_email: 41898282+github-actions[bot]@users.noreply.github.com
|
||||||
|
vcs_release: "false"
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
if: steps.semantic.outputs.released == 'true' && steps.semantic.outputs.tag != ''
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ steps.semantic.outputs.tag }}
|
||||||
|
VERSION: ${{ steps.semantic.outputs.version }}
|
||||||
|
RELEASE_NOTES: ${{ steps.semantic.outputs.release_notes }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "Release already exists for $TAG"
|
||||||
|
gh release view "$TAG" --json url -q .url
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
NOTES_FILE="$(mktemp)"
|
||||||
|
if [[ -n "$RELEASE_NOTES" ]]; then
|
||||||
|
printf '%s\n' "$RELEASE_NOTES" > "$NOTES_FILE"
|
||||||
|
else
|
||||||
|
printf 'Release %s\n' "$VERSION" > "$NOTES_FILE"
|
||||||
|
fi
|
||||||
|
gh release create "$TAG" --title "$VERSION" --notes-file "$NOTES_FILE"
|
||||||
|
rm -f "$NOTES_FILE"
|
||||||
|
|
||||||
|
- name: Verify GitHub Release
|
||||||
|
if: steps.semantic.outputs.released == 'true' && steps.semantic.outputs.tag != ''
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ steps.semantic.outputs.tag }}
|
||||||
|
run: gh release view "$TAG"
|
||||||
|
|
||||||
|
- name: Sync develop from master
|
||||||
|
if: steps.semantic.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.semantic.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git fetch origin master develop
|
||||||
|
git checkout -B develop origin/develop
|
||||||
|
phantom_before="$(git rev-list --count origin/master..HEAD)"
|
||||||
|
echo "Develop commits not in master (before sync): ${phantom_before}"
|
||||||
|
# Fast-forward works when the release PR used "Create a merge commit" (not squash):
|
||||||
|
# develop's commits are ancestors of master, so develop can catch up without force-push.
|
||||||
|
if git merge origin/master --ff-only; then
|
||||||
|
echo "Fast-forwarded develop to master (branches aligned)"
|
||||||
|
else
|
||||||
|
echo "::warning::Could not fast-forward develop to master."
|
||||||
|
echo "::warning::The release PR was likely squash-merged. Use 'Create a merge commit' on develop→master PRs."
|
||||||
|
echo "::warning::Applying fallback merge sync (phantom commits on develop will remain until a merge-commit release)."
|
||||||
|
git merge origin/master --no-edit \
|
||||||
|
-m "chore: sync develop from master after release ${VERSION}"
|
||||||
|
fi
|
||||||
|
git push origin develop
|
||||||
|
git fetch origin master develop
|
||||||
|
phantom_after="$(git rev-list --count origin/master..HEAD)"
|
||||||
|
echo "Develop commits not in master (after sync): ${phantom_after}"
|
||||||
|
if [[ "${phantom_after}" -eq 0 ]]; then
|
||||||
|
echo "Develop and master are aligned (next release PR will list only new commits)"
|
||||||
|
else
|
||||||
|
echo "::notice::Develop still has ${phantom_after} commit(s) not in master."
|
||||||
|
echo "::notice::Merge the next release PR with 'Create a merge commit' to realign automatically."
|
||||||
|
fi
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = src
|
||||||
|
addopts = -ra
|
||||||
|
markers =
|
||||||
|
smoke: minimal wiring checks; not sufficient alone for regression
|
||||||
|
behavior: observable regression test (preferred for bridge/voice integration)
|
||||||
|
unit: pure domain or isolated helper; private methods acceptable
|
||||||
|
udp_blackbox: opt-in subprocess UDP integration tests (not yet implemented)
|
||||||
|
|
||||||
|
[coverage:run]
|
||||||
|
source = adn_server
|
||||||
|
omit =
|
||||||
|
*/main.py
|
||||||
|
*/parrot_main.py
|
||||||
|
*/infrastructure/twisted_adapters/*
|
||||||
|
*/infrastructure/persistence/*
|
||||||
|
*/infrastructure/security/*
|
||||||
|
*/infrastructure/voice/*
|
||||||
|
*/infrastructure/logging_config.py
|
||||||
|
*/infrastructure/config_loader.py
|
||||||
|
|
||||||
|
[coverage:report]
|
||||||
|
show_missing = false
|
||||||
|
precision = 0
|
||||||
|
skip_empty = true
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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"
|
||||||
@ -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 == []
|
||||||
@ -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)
|
||||||
@ -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
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
"""Unit data path (SMS/GPS/CSBK) via _unit_data_received."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from tests.harness.assertions import (
|
||||||
|
assert_forwarded,
|
||||||
|
assert_inject_ok,
|
||||||
|
assert_not_forwarded,
|
||||||
|
assert_report_event,
|
||||||
|
)
|
||||||
|
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, bytes_4
|
||||||
|
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM
|
||||||
|
|
||||||
|
|
||||||
|
def _idle_hbp_slot() -> dict:
|
||||||
|
return {
|
||||||
|
"RX_TYPE": HBPF_SLT_VTERM,
|
||||||
|
"TX_TYPE": HBPF_SLT_VTERM,
|
||||||
|
"TX_TIME": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
@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_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 == []
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
"""Tests for config_validator."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from adn_server.domain.errors import ConfigError
|
||||||
|
from adn_server.infrastructure.config_validator import validate_config
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_config(**global_overrides) -> dict:
|
||||||
|
return {
|
||||||
|
"GLOBAL": {
|
||||||
|
"PING_TIME": 10,
|
||||||
|
"SERVER_ID": 73010,
|
||||||
|
**global_overrides,
|
||||||
|
},
|
||||||
|
"REPORTS": {"REPORT": True},
|
||||||
|
"LOGGER": {"LOG_HANDLERS": "console-timed"},
|
||||||
|
"SYSTEMS": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepts_string_port_security() -> None:
|
||||||
|
validate_config(_minimal_config(PORT_SECURITY="7070", URL_SECURITY="10.0.0.1", PASS_SECURITY="x"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_int_port_security() -> None:
|
||||||
|
with pytest.raises(ConfigError) as exc:
|
||||||
|
validate_config(
|
||||||
|
_minimal_config(PORT_SECURITY=7070, URL_SECURITY="10.0.0.1", PASS_SECURITY="x"),
|
||||||
|
config_path="/tmp/adn-server.yaml",
|
||||||
|
)
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "GLOBAL.PORT_SECURITY" in msg
|
||||||
|
assert "expected string" in msg
|
||||||
|
assert "int" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_missing_security_fields_when_url_set() -> None:
|
||||||
|
with pytest.raises(ConfigError) as exc:
|
||||||
|
validate_config(_minimal_config(URL_SECURITY="10.0.0.1"), config_path="adn-server.yaml")
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "GLOBAL.PORT_SECURITY" in msg
|
||||||
|
assert "GLOBAL.PASS_SECURITY" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_collects_multiple_errors() -> None:
|
||||||
|
with pytest.raises(ConfigError) as exc:
|
||||||
|
validate_config(
|
||||||
|
{
|
||||||
|
"GLOBAL": {
|
||||||
|
"URL_SECURITY": 1,
|
||||||
|
"PORT_SECURITY": 7070,
|
||||||
|
"PING_TIME": "ten",
|
||||||
|
},
|
||||||
|
"REPORTS": {"REPORT": "yes"},
|
||||||
|
"LOGGER": {"LOG_HANDLERS": 123},
|
||||||
|
"SYSTEMS": {"ECHO": {"MODE": "MASTER", "PORT": "bad", "ENABLED": "True"}},
|
||||||
|
},
|
||||||
|
config_path="bad.yaml",
|
||||||
|
)
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "bad.yaml" in msg
|
||||||
|
assert msg.count(" - ") >= 4
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
"""Parrot playback send loop and max recording timer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
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 (
|
||||||
|
_PACKET_INTERVAL_S,
|
||||||
|
_PLAYBACK_DELAY_S,
|
||||||
|
_RECORD_IDLE_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
|
||||||
|
|
||||||
|
|
||||||
|
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_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]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
@ -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])
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
"""Parrot re-key and playback packet prep."""
|
||||||
|
|
||||||
|
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, send_playback
|
||||||
|
|
||||||
|
from adn_server.application.playback_use_cases import PlaybackUseCases
|
||||||
|
|
||||||
|
|
||||||
|
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_renumbers_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
|
||||||
|
assert all(out[i][4] == (i + 1) for i in range(len(out)))
|
||||||
|
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]
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
# ADN DMR Peer Server - bridge slot contention behaviour
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Rodrigo Pérez, CE5RPY <ce5rpy@qmd.cl>
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from tests.harness.deterministic import DeterministicScenario, PacketSpec, minimal_config
|
||||||
|
from tests.support.hbp_repeat_stack import build_hbp_repeat_stack
|
||||||
|
|
||||||
|
from adn_server.domain import bytes_3, bytes_4
|
||||||
|
from adn_server.domain.hbp_protocol import HBPF_SLT_VHEAD, HBPF_SLT_VTERM
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.behavior
|
||||||
|
|
||||||
|
_TG_A = 7144
|
||||||
|
_TG_B = 730444
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_leg(sys: str, tg: int) -> dict:
|
||||||
|
return {
|
||||||
|
"SYSTEM": sys,
|
||||||
|
"TS": 2,
|
||||||
|
"TGID": tg,
|
||||||
|
"ACTIVE": True,
|
||||||
|
"TIMEOUT": 0,
|
||||||
|
"TO_TYPE": "OFF",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_table() -> dict:
|
||||||
|
return {
|
||||||
|
str(_TG_A): [_bridge_leg("HOTSPOT", _TG_A), _bridge_leg("NETWORK", _TG_A)],
|
||||||
|
str(_TG_B): [_bridge_leg("HOTSPOT", _TG_B), _bridge_leg("NETWORK", _TG_B)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_blocks_second_tg_on_busy_slot_with_zero_hangtime() -> None:
|
||||||
|
config = minimal_config(("HOTSPOT", "NETWORK"))
|
||||||
|
config["SYSTEMS"]["HOTSPOT"]["GROUP_HANGTIME"] = 0
|
||||||
|
config["SYSTEMS"]["NETWORK"]["GROUP_HANGTIME"] = 0
|
||||||
|
scenario = DeterministicScenario(config, _bridge_table())
|
||||||
|
scenario.protocols["HOTSPOT"].STATUS[2] = {
|
||||||
|
"RX_TYPE": HBPF_SLT_VHEAD,
|
||||||
|
"TX_TYPE": HBPF_SLT_VTERM,
|
||||||
|
"RX_TGID": bytes_3(_TG_A),
|
||||||
|
"RX_TIME": scenario.clock.time(),
|
||||||
|
"RX_STREAM_ID": bytes_4(0xAAAAAAAA),
|
||||||
|
"RX_RFS": bytes_3(7300444),
|
||||||
|
"RX_PEER": bytes_4(730001),
|
||||||
|
}
|
||||||
|
spec = PacketSpec(
|
||||||
|
rf_src=3120001,
|
||||||
|
dst_id=_TG_B,
|
||||||
|
slot=2,
|
||||||
|
stream_id=0xBBBBBBBB,
|
||||||
|
payload=b"\x00" * 33,
|
||||||
|
)
|
||||||
|
scenario.inject_hbp("NETWORK", DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1))
|
||||||
|
assert scenario.capture.for_system("HOTSPOT") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_peer_blocks_downlink_on_busy_slot() -> None:
|
||||||
|
stack = build_hbp_repeat_stack(talker_alias=False, system_name="MASTER-A")
|
||||||
|
stack.config["PROXY"] = {"TARGET_SYSTEM": "MASTER-A"}
|
||||||
|
stack.hbp._CONFIG = stack.config
|
||||||
|
peer = bytes_4(730002)
|
||||||
|
addr = ("10.0.0.31", 62031)
|
||||||
|
stack.register_peer(peer, addr, options=f"TS2={_TG_A},{_TG_B};")
|
||||||
|
stack.hbp.STATUS[2] = {
|
||||||
|
"RX_TYPE": HBPF_SLT_VHEAD,
|
||||||
|
"TX_TYPE": HBPF_SLT_VTERM,
|
||||||
|
"RX_TGID": bytes_3(_TG_A),
|
||||||
|
"RX_TIME": time.time(),
|
||||||
|
"RX_STREAM_ID": bytes_4(0xCCCCCCCC),
|
||||||
|
"RX_PEER": peer,
|
||||||
|
}
|
||||||
|
spec = PacketSpec(dst_id=_TG_B, slot=2, stream_id=0xDDDDDDDD, payload=b"\x00" * 33)
|
||||||
|
pkt = DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1).data()
|
||||||
|
stack.hbp.send_peer(peer, pkt)
|
||||||
|
assert stack.transport.for_addr(addr) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_hangtime_blocks_after_vterm() -> None:
|
||||||
|
config = minimal_config(("HOTSPOT", "NETWORK"))
|
||||||
|
config["SYSTEMS"]["HOTSPOT"]["GROUP_HANGTIME"] = 30
|
||||||
|
scenario = DeterministicScenario(config, _bridge_table())
|
||||||
|
now = scenario.clock.time()
|
||||||
|
scenario.protocols["HOTSPOT"].STATUS[2] = {
|
||||||
|
"RX_TYPE": HBPF_SLT_VTERM,
|
||||||
|
"TX_TYPE": HBPF_SLT_VTERM,
|
||||||
|
"RX_TGID": bytes_3(_TG_A),
|
||||||
|
"RX_TIME": now,
|
||||||
|
"TX_TGID": bytes_3(_TG_A),
|
||||||
|
"TX_TIME": now,
|
||||||
|
}
|
||||||
|
spec = PacketSpec(rf_src=3120001, dst_id=_TG_B, slot=2, stream_id=0xEEEEEEEE, payload=b"\x00" * 33)
|
||||||
|
scenario.inject_hbp("NETWORK", DeterministicScenario.voice_burst_spec(spec, seq=1, dtype_vseq=1))
|
||||||
|
assert scenario.capture.for_system("HOTSPOT") == []
|
||||||
@ -0,0 +1,193 @@
|
|||||||
|
"""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_security_fields_are_strings(tmp_path: Path) -> None:
|
||||||
|
cfg = _write_cfg(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
[GLOBAL]
|
||||||
|
URL_SECURITY: 143.47.40.69
|
||||||
|
PORT_SECURITY: 7070
|
||||||
|
PASS_SECURITY: secret
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
out = parse_freedmr_cfg(cfg)
|
||||||
|
assert out["GLOBAL"]["URL_SECURITY"] == "143.47.40.69"
|
||||||
|
assert out["GLOBAL"]["PORT_SECURITY"] == "7070"
|
||||||
|
assert out["GLOBAL"]["PASS_SECURITY"] == "secret"
|
||||||
|
assert isinstance(out["GLOBAL"]["PORT_SECURITY"], str)
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
|
assert out["GLOBAL"]["PORT_SECURITY"] == "7070"
|
||||||
|
assert isinstance(out["GLOBAL"]["PORT_SECURITY"], str)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue