defmodule Microwaveprop.PskrTest do use ExUnit.Case, async: true alias Microwaveprop.Pskr @sample %{ "sq" => 30_142_870_791, "f" => 144_174_000, "md" => "FT8", "rp" => -5, "t" => 1_662_407_712, "t_tx" => 1_662_407_697, "sc" => "K5ABC", "sl" => "EM12kl", "rc" => "K7XYZ", "rl" => "DM43st", "sa" => 291, "ra" => 291, "b" => "2m" } describe "parse_spot/1" do test "preserves full subsquare precision when reported" do json = Jason.encode!(@sample) assert {:ok, spot} = Pskr.parse_spot(json) assert spot.band == "2m" assert spot.mode == "FT8" assert spot.snr_db == -5 assert spot.sender_grid == "EM12KL" assert spot.receiver_grid == "DM43ST" assert spot.sender_callsign == "K5ABC" assert spot.receiver_callsign == "K7XYZ" assert spot.sender_country == 291 assert spot.receiver_country == 291 assert %DateTime{} = spot.transmit_time assert DateTime.to_unix(spot.transmit_time) == 1_662_407_697 end test "accepts 4-char grids on both ends" do sender_4 = @sample |> Map.put("sl", "EM12") |> Jason.encode!() receiver_4 = @sample |> Map.put("rl", "DM43") |> Jason.encode!() assert {:ok, spot} = Pskr.parse_spot(sender_4) assert spot.sender_grid == "EM12" assert {:ok, spot} = Pskr.parse_spot(receiver_4) assert spot.receiver_grid == "DM43" end test "preserves extended-square (8-char) precision" do json = @sample |> Map.merge(%{"sl" => "EM12kl37", "rl" => "DM43st99"}) |> Jason.encode!() assert {:ok, spot} = Pskr.parse_spot(json) assert spot.sender_grid == "EM12KL37" assert spot.receiver_grid == "DM43ST99" end test "rejects odd-length grids (truncated 5-char)" do json = @sample |> Map.put("sl", "EM12k") |> Jason.encode!() assert {:error, _} = Pskr.parse_spot(json) end test "uses :t when :t_tx is missing" do json = @sample |> Map.delete("t_tx") |> Jason.encode!() assert {:ok, spot} = Pskr.parse_spot(json) assert DateTime.to_unix(spot.transmit_time) == 1_662_407_712 end test "rejects payloads without grids" do json = @sample |> Map.delete("sl") |> Jason.encode!() assert {:error, _} = Pskr.parse_spot(json) end test "rejects malformed JSON" do assert {:error, _} = Pskr.parse_spot("not-json") end end describe "path_key/1" do test "buckets to the top of the hour using the full grid as reported" do {:ok, spot} = Pskr.parse_spot(Jason.encode!(@sample)) {hour, band, snd, rcv} = Pskr.path_key(spot) assert band == "2m" assert snd == "EM12KL" assert rcv == "DM43ST" # 1_662_407_697 → 2022-09-05T19:54:57Z → bucket at 19:00:00Z assert hour == ~U[2022-09-05 19:00:00Z] end end describe "merge_spot/2" do test "initializes accumulator from a single spot" do {:ok, spot} = Pskr.parse_spot(Jason.encode!(@sample)) acc = Pskr.merge_spot(nil, spot) assert acc.spot_count == 1 assert acc.max_snr_db == -5 assert acc.min_snr_db == -5 assert acc.modes == ["FT8"] assert acc.sender_grid == "EM12KL" assert acc.receiver_grid == "DM43ST" assert is_float(acc.distance_km) assert acc.distance_km > 0 # Midpoint should land between the two grid centers assert acc.midpoint_lat > min(acc.sender_lat, acc.receiver_lat) assert acc.midpoint_lat < max(acc.sender_lat, acc.receiver_lat) end test "increments count and tracks SNR envelope across spots" do {:ok, first} = Pskr.parse_spot(Jason.encode!(@sample)) {:ok, louder} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -1, "md" => "FT4"})) {:ok, quieter} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -18})) acc = nil |> Pskr.merge_spot(first) |> Pskr.merge_spot(louder) |> Pskr.merge_spot(quieter) assert acc.spot_count == 3 assert acc.max_snr_db == -1 assert acc.min_snr_db == -18 assert Enum.sort(acc.modes) == ["FT4", "FT8"] assert DateTime.compare(acc.first_spot_at, acc.last_spot_at) in [:lt, :eq] end end describe "parse_spot/1 edge cases" do test "rejects empty band string" do json = @sample |> Map.put("b", "") |> Jason.encode!() assert {:error, _} = Pskr.parse_spot(json) end test "rejects payload with no timestamp fields" do json = @sample |> Map.delete("t_tx") |> Map.delete("t") |> Jason.encode!() assert {:error, _} = Pskr.parse_spot(json) end test "handles nil snr gracefully in merge" do json = @sample |> Map.put("rp", nil) |> Jason.encode!() {:ok, spot} = Pskr.parse_spot(json) acc = Pskr.merge_spot(nil, spot) assert acc.max_snr_db == nil assert acc.min_snr_db == nil {:ok, spot2} = Pskr.parse_spot(Jason.encode!(@sample)) acc2 = Pskr.merge_spot(acc, spot2) assert acc2.max_snr_db == -5 assert acc2.min_snr_db == -5 end test "handles empty mode string in merge" do json = @sample |> Map.put("md", "") |> Jason.encode!() {:ok, spot} = Pskr.parse_spot(json) acc = Pskr.merge_spot(nil, spot) assert acc.modes == [] end test "handles nil mode in merge" do json = @sample |> Map.delete("md") |> Jason.encode!() {:ok, spot} = Pskr.parse_spot(json) acc = Pskr.merge_spot(nil, spot) assert acc.modes == [] end test "does not add duplicate modes" do {:ok, first} = Pskr.parse_spot(Jason.encode!(@sample)) {:ok, second} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -10})) acc = nil |> Pskr.merge_spot(first) |> Pskr.merge_spot(second) assert acc.modes == ["FT8"] end end describe "callsign tracking" do test "collects unique sender and receiver callsigns across merges" do {:ok, s1} = Pskr.parse_spot(Jason.encode!(@sample)) {:ok, s2} = Pskr.parse_spot(Jason.encode!(%{@sample | "sc" => "K5DEF", "rp" => -3})) acc = nil |> Pskr.merge_spot(s1) |> Pskr.merge_spot(s2) assert Enum.sort(acc.sender_callsigns) == ["K5ABC", "K5DEF"] assert acc.receiver_callsigns == ["K7XYZ"] assert acc.spot_count == 2 end test "does not add duplicate callsigns" do {:ok, s1} = Pskr.parse_spot(Jason.encode!(@sample)) {:ok, s2} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -3})) acc = nil |> Pskr.merge_spot(s1) |> Pskr.merge_spot(s2) assert acc.sender_callsigns == ["K5ABC"] assert acc.receiver_callsigns == ["K7XYZ"] end test "handles nil and empty callsigns in payload" do nil_call = @sample |> Map.put("sc", nil) |> Jason.encode!() {:ok, s_nil} = Pskr.parse_spot(nil_call) assert s_nil.sender_callsign == nil empty_call = @sample |> Map.put("sc", "") |> Jason.encode!() {:ok, s_empty} = Pskr.parse_spot(empty_call) assert s_empty.sender_callsign == nil acc = nil |> Pskr.merge_spot(s_nil) |> Pskr.merge_spot(s_empty) assert acc.sender_callsigns == [] end test "uppercases callsigns from payload" do json = @sample |> Map.merge(%{"sc" => "k5abc", "rc" => "k7xyz"}) |> Jason.encode!() {:ok, spot} = Pskr.parse_spot(json) assert spot.sender_callsign == "K5ABC" assert spot.receiver_callsign == "K7XYZ" end test "initializes callsign arrays from first spot" do {:ok, spot} = Pskr.parse_spot(Jason.encode!(@sample)) acc = Pskr.merge_spot(nil, spot) assert acc.sender_callsigns == ["K5ABC"] assert acc.receiver_callsigns == ["K7XYZ"] end test "falls back to topic-derived callsign when JSON payload omits sc/rc" do # FT8/Q65 multi-decoder spots sometimes lack sc/rc in the JSON # payload, but the MQTT topic always carries them. payload = @sample |> Map.drop(["sc", "rc"]) |> Jason.encode!() opts = [sender_callsign: "K5TOPIC", receiver_callsign: "K7TOPIC"] {:ok, spot} = Pskr.parse_spot(payload, opts) assert spot.sender_callsign == "K5TOPIC" assert spot.receiver_callsign == "K7TOPIC" end test "prefers JSON callsign over topic fallback when both present" do payload = Jason.encode!(@sample) opts = [sender_callsign: "K5TOPIC", receiver_callsign: "K7TOPIC"] {:ok, spot} = Pskr.parse_spot(payload, opts) assert spot.sender_callsign == "K5ABC" assert spot.receiver_callsign == "K7XYZ" end test "topic fallback with nil/empty JSON callsign prefers topic" do payload = @sample |> Map.put("sc", "") |> Jason.encode!() opts = [sender_callsign: "K5TOPIC"] {:ok, spot} = Pskr.parse_spot(payload, opts) assert spot.sender_callsign == "K5TOPIC" assert spot.receiver_callsign == "K7XYZ" end end end