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.
220 lines
4.9 KiB
Elixir
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
|