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 "lookup returns nil for a used digi → drop that hop and do NOT advance source" do lookup = lookup_fn(%{ "K5XYZ-3" => {33.0, -96.5}, # W5ABC-7 deliberately missing "K5DEF-1" => {33.5, -95.5} }) hops = PathParser.parse_hops( @sender, @sender_pos, "K5XYZ-3*,W5ABC-7*,K5DEF-1*", @heard_at, lookup ) assert length(hops) == 2 # First hop is sender → K5XYZ-3 as before. assert Enum.at(hops, 0).src_callsign == @sender assert Enum.at(hops, 0).dst_callsign == "K5XYZ-3" # Critical: second emitted hop chains from K5XYZ-3 (NOT from W5ABC-7 # which was dropped). Proves source did not advance through the # missing-position digi. assert Enum.at(hops, 1).src_callsign == "K5XYZ-3" assert Enum.at(hops, 1).dst_callsign == "K5DEF-1" 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 "lowercase Q-construct qAo is recognized and yields no hops" do lookup = lookup_fn(%{}) hops = PathParser.parse_hops( @sender, @sender_pos, "qAo", @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 suppressed when used digi callsign matches sender callsign" do # Sender sees its own callsign reflected in the digi path (corrupted # frame). Should be skipped. lookup = lookup_fn(%{@sender => {99.0, 99.0}}) hops = PathParser.parse_hops( @sender, @sender_pos, "#{@sender}*", @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