prop/test/microwaveprop/aprs/path_parser_test.exs
Graham McIntire 4a5864f090
feat(aprs): add PathParser for verified RF hops from TNC2 paths
Pure-Elixir parser that walks an APRS digipeater path left-to-right and
emits verified hops for callsigns carrying the used flag (`*`). Filters
Q-constructs, TCPIP/TCPXX, and routing aliases (WIDE/TRACE/RELAY/etc.);
handles anonymous-digi cases, missing station-position lookups, and
self-loops without advancing source past unattributable hops.

Used by upcoming 144 MHz calibration to attribute aprs.me receives to
real digipeater geometry without persisting any APRS data on our side.
2026-05-01 12:35:53 -05:00

220 lines
4.9 KiB
Elixir

defmodule Microwaveprop.Aprs.PathParserTest do
use ExUnit.Case, async: true
alias Microwaveprop.Aprs.PathParser
@sender "N5XXX-9"
@sender_pos {32.897, -97.038}
@heard_at ~U[2026-04-30 12:00:00Z]
defp lookup_fn(map) do
fn callsign -> Map.get(map, callsign) end
end
describe "parse_hops/5" do
test "single used-flag callsign emits one hop from sender" do
lookup = lookup_fn(%{"K5GVL-10" => {32.95, -96.10}})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"K5GVL-10*,WIDE1-1,WIDE2-1",
@heard_at,
lookup
)
assert [hop] = hops
assert hop.src_callsign == @sender
assert hop.src_pos == @sender_pos
assert hop.dst_callsign == "K5GVL-10"
assert hop.dst_pos == {32.95, -96.10}
assert hop.heard_at == @heard_at
assert is_float(hop.distance_km)
end
test "two chained used-flag callsigns emit two hops, source advances" do
lookup =
lookup_fn(%{
"K5XYZ-3" => {33.0, -96.5},
"W5ABC-7" => {33.5, -95.5}
})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"K5XYZ-3*,W5ABC-7*,WIDE2-1",
@heard_at,
lookup
)
assert length(hops) == 2
[first, second] = hops
assert first.src_callsign == @sender
assert first.src_pos == @sender_pos
assert first.dst_callsign == "K5XYZ-3"
assert first.dst_pos == {33.0, -96.5}
assert second.src_callsign == "K5XYZ-3"
assert second.src_pos == {33.0, -96.5}
assert second.dst_callsign == "W5ABC-7"
assert second.dst_pos == {33.5, -95.5}
end
test "anonymous WIDE1* used-flag yields no hops" do
lookup = lookup_fn(%{})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"WIDE1*,WIDE2-1",
@heard_at,
lookup
)
assert hops == []
end
test "mixed path with no used real callsigns yields no hops" do
lookup = lookup_fn(%{"K5VOM-10" => {32.5, -96.5}})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10",
@heard_at,
lookup
)
assert hops == []
end
test "trailing unused real callsign does not emit a hop" do
lookup =
lookup_fn(%{
"K5GVL-10" => {32.95, -96.10},
"N5TXZ-10" => {33.1, -96.2}
})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"K5GVL-10*,N5TXZ-10",
@heard_at,
lookup
)
assert [hop] = hops
assert hop.dst_callsign == "K5GVL-10"
end
test "TCPIP and Q-construct yield no hops" do
lookup = lookup_fn(%{})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"TCPIP*,qAC,T2TEXAS",
@heard_at,
lookup
)
assert hops == []
end
test "used callsign with nil lookup is dropped and source does NOT advance" do
lookup =
lookup_fn(%{
"K5XYZ-3" => {33.0, -96.5}
# W5ABC-7 deliberately missing
})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"K5XYZ-3*,W5ABC-7*",
@heard_at,
lookup
)
assert [hop] = hops
assert hop.src_callsign == @sender
assert hop.dst_callsign == "K5XYZ-3"
end
test "empty path string yields no hops" do
lookup = lookup_fn(%{})
hops = PathParser.parse_hops(@sender, @sender_pos, "", @heard_at, lookup)
assert hops == []
end
test "aliases-only path yields no hops" do
lookup = lookup_fn(%{})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"WIDE1-1,WIDE2-2",
@heard_at,
lookup
)
assert hops == []
end
test "Q-construct followed by IGate name yields no hops" do
lookup = lookup_fn(%{"IGATE-CALL" => {33.0, -96.0}})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"qAR,IGATE-CALL",
@heard_at,
lookup
)
assert hops == []
end
test "self-loop (callsign resolves to sender's own position) yields no hops" do
lookup = lookup_fn(%{"K5GVL-10" => @sender_pos})
hops =
PathParser.parse_hops(
@sender,
@sender_pos,
"K5GVL-10*",
@heard_at,
lookup
)
assert hops == []
end
test "haversine distance for DFW → K5GVL-10 is approximately 88.4 km" do
lookup = lookup_fn(%{"K5GVL-10" => {32.95, -96.10}})
[hop] =
PathParser.parse_hops(
@sender,
@sender_pos,
"K5GVL-10*",
@heard_at,
lookup
)
assert abs(hop.distance_km - 88.4) < 1.0
end
end
end