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_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 "rejects 4-char grids on either end (insufficient precision)" do # A 4-char locator is ~70 km × 100 km — the midpoint and # distance error swamps any HRRR cell match (3 km native). # Drop the spot rather than store a calibration sample whose # geometry is meaningless. sender_short = @sample |> Map.put("sl", "EM12") |> Jason.encode!() receiver_short = @sample |> Map.put("rl", "DM43") |> Jason.encode!() assert {:error, _} = Pskr.parse_spot(sender_short) assert {:error, _} = Pskr.parse_spot(receiver_short) 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 end