prop/test/microwaveprop/pskr_test.exs
Graham McIntire b924cc2fc2
feat(pskr): preserve full locator precision from MQTT spots
Spots that include subsquare (EM12kl), extended-square (EM12kl37),
or extended-extended-subsquare (EM12kl37ab) precision now keep
that resolution end-to-end. Previously we truncated everything to
4 chars at parse time, which collapsed distinct paths into the same
row whenever two spotters shared a field.

The aggregation key uses the full grid as reported, so two
spotters in different subsquares of `EM12` produce two rows —
correct, since they are physically different paths. Any spot that
fails Maidenhead validation (odd length, illegal char at position
N) is still dropped via the same error path.

Storage normalizes to uppercase so the unique index doesn't split
on case. Maidenhead.valid?/1 already accepts both cases on input.
2026-05-04 12:42:46 -05:00

127 lines
3.9 KiB
Elixir

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 "accepts 4-char grids verbatim" do
json = @sample |> Map.merge(%{"sl" => "EM12", "rl" => "DM43"}) |> Jason.encode!()
assert {:ok, spot} = Pskr.parse_spot(json)
assert spot.sender_grid == "EM12"
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
end