diff --git a/test/microwaveprop/geo_test.exs b/test/microwaveprop/geo_test.exs
new file mode 100644
index 00000000..003d2d58
--- /dev/null
+++ b/test/microwaveprop/geo_test.exs
@@ -0,0 +1,98 @@
+defmodule Microwaveprop.GeoTest do
+ use ExUnit.Case, async: true
+
+ alias Microwaveprop.Geo
+ alias Microwaveprop.Radio.Maidenhead
+
+ describe "haversine_km/4" do
+ test "returns 0.0 for identical points" do
+ assert Geo.haversine_km(32.9, -96.8, 32.9, -96.8) == 0.0
+ end
+
+ test "matches a known long-distance pair (DFW to Newington, CT)" do
+ assert_in_delta Geo.haversine_km(32.9, -96.8, 41.714, -72.727), 2333.3, 1.0
+ end
+
+ test "computes a half-circumference for antipodal equator points" do
+ # Half the Earth's circumference at R = 6371 km is ~20015 km.
+ assert_in_delta Geo.haversine_km(0.0, 0.0, 0.0, 180.0), 20_015.0, 1.0
+ end
+
+ test "is symmetric in its arguments" do
+ forward = Geo.haversine_km(32.9, -96.8, 41.714, -72.727)
+ reverse = Geo.haversine_km(41.714, -72.727, 32.9, -96.8)
+ assert_in_delta forward, reverse, 0.0001
+ end
+
+ test "one degree of latitude is roughly 111 km" do
+ assert_in_delta Geo.haversine_km(32.0, -96.0, 33.0, -96.0), 111.195, 0.1
+ end
+ end
+
+ describe "bearing_deg/4" do
+ test "returns 0.0 for due north" do
+ assert Geo.bearing_deg(32.0, -96.0, 33.0, -96.0) == 0.0
+ end
+
+ test "returns ~90 for due east at the equator" do
+ assert_in_delta Geo.bearing_deg(0.0, 0.0, 0.0, 1.0), 90.0, 0.1
+ end
+
+ test "returns 180.0 for due south" do
+ assert Geo.bearing_deg(32.0, -96.0, 31.0, -96.0) == 180.0
+ end
+
+ test "returns a bearing in the range [0, 360)" do
+ bearing = Geo.bearing_deg(32.0, -96.0, 32.0, -97.0)
+ assert bearing >= 0.0 and bearing < 360.0
+ assert_in_delta bearing, 270.0, 1.0
+ end
+
+ test "rounds to one decimal place" do
+ # DFW to Newington - pick any pair, the result must have at most 1 decimal.
+ bearing = Geo.bearing_deg(32.9, -96.8, 41.714, -72.727)
+ assert Float.round(bearing, 1) == bearing
+ end
+ end
+
+ describe "maidenhead_center/1" do
+ test "returns the center of a 4-character grid" do
+ # EM12 is lat 32-33, lon -98 to -96, center (32.5, -97.0).
+ assert Geo.maidenhead_center("EM12") == {32.5, -97.0}
+ end
+
+ test "returns the center of a 6-character grid at higher precision" do
+ {lat, lon} = Geo.maidenhead_center("EM12kp")
+ assert_in_delta lat, 32.6458, 0.001
+ assert_in_delta lon, -97.125, 0.001
+ end
+
+ test "accepts lowercase grids" do
+ assert Geo.maidenhead_center("em12") == {32.5, -97.0}
+ end
+
+ test "returns nil for invalid grid characters" do
+ assert Geo.maidenhead_center("ZZZZ") == nil
+ end
+ end
+
+ describe "latlon_to_grid4/2" do
+ test "encodes the DFW area as EM12" do
+ assert Geo.latlon_to_grid4(32.9, -96.8) == "EM12"
+ end
+
+ test "encodes Newington, CT as FN31" do
+ assert Geo.latlon_to_grid4(41.714, -72.727) == "FN31"
+ end
+
+ test "encodes the prime-meridian/equator origin as JJ00" do
+ assert Geo.latlon_to_grid4(0.0, 0.0) == "JJ00"
+ end
+
+ test "agrees with Maidenhead.from_latlon/3 at precision 4 for typical US coords" do
+ for {lat, lon} <- [{32.9, -96.8}, {41.714, -72.727}, {40.0, -105.0}, {47.6, -122.3}] do
+ assert Geo.latlon_to_grid4(lat, lon) == Maidenhead.from_latlon(lat, lon, 4)
+ end
+ end
+ end
+end
diff --git a/test/microwaveprop/qrz/record_test.exs b/test/microwaveprop/qrz/record_test.exs
new file mode 100644
index 00000000..aad8baf9
--- /dev/null
+++ b/test/microwaveprop/qrz/record_test.exs
@@ -0,0 +1,100 @@
+defmodule Microwaveprop.Qrz.RecordTest do
+ use ExUnit.Case, async: true
+
+ alias Microwaveprop.Qrz.Record
+
+ describe "from_map/1" do
+ test "returns a struct with all fields nil for an empty map" do
+ assert %Record{
+ call: nil,
+ fname: nil,
+ name: nil,
+ grid: nil,
+ addr1: nil,
+ addr2: nil,
+ state: nil,
+ zip: nil,
+ country: nil,
+ lat: nil,
+ lon: nil
+ } = Record.from_map(%{})
+ end
+
+ test "parses every known string field" do
+ data = %{
+ "call" => "W1AW",
+ "fname" => "Hiram Percy",
+ "name" => "Maxim",
+ "grid" => "FN31pr",
+ "addr1" => "225 Main St",
+ "addr2" => "Newington",
+ "state" => "CT",
+ "zip" => "06111",
+ "country" => "United States"
+ }
+
+ record = Record.from_map(data)
+
+ assert record.call == "W1AW"
+ assert record.fname == "Hiram Percy"
+ assert record.name == "Maxim"
+ assert record.grid == "FN31pr"
+ assert record.addr1 == "225 Main St"
+ assert record.addr2 == "Newington"
+ assert record.state == "CT"
+ assert record.zip == "06111"
+ assert record.country == "United States"
+ end
+
+ test "parses lat and lon as floats from numeric strings" do
+ record = Record.from_map(%{"lat" => "41.714775", "lon" => "-72.727260"})
+
+ assert record.lat == 41.714775
+ assert record.lon == -72.727260
+ end
+
+ test "parses partial float prefixes via Float.parse/1" do
+ # Float.parse/1 returns {41.714, "abc"} — the parser keeps the float.
+ assert %Record{lat: 41.714} = Record.from_map(%{"lat" => "41.714abc"})
+ end
+
+ test "returns nil for unparseable float strings" do
+ assert %Record{lat: nil} = Record.from_map(%{"lat" => "not-a-number"})
+ end
+
+ test "treats non-binary string values as nil" do
+ assert %Record{call: nil} = Record.from_map(%{"call" => 123})
+ end
+
+ test "treats non-binary lat/lon values as nil" do
+ # The parser only accepts binaries — a raw float is rejected.
+ assert %Record{lat: nil, lon: nil} = Record.from_map(%{"lat" => 41.714, "lon" => -72.727})
+ end
+
+ test "leaves a field as nil when the key is absent" do
+ assert %Record{call: "W1AW", fname: nil} = Record.from_map(%{"call" => "W1AW"})
+ end
+
+ test "treats an explicit nil value the same as a missing key" do
+ assert %Record{call: nil} = Record.from_map(%{"call" => nil})
+ end
+
+ test "ignores unknown XML keys" do
+ record = Record.from_map(%{"call" => "W1AW", "eqsl" => "1", "dxcc" => "291"})
+
+ assert record.call == "W1AW"
+ # No crash, no struct pollution.
+ assert record |> Map.from_struct() |> Map.keys() |> Enum.sort() ==
+ [:addr1, :addr2, :call, :country, :fname, :grid, :lat, :lon, :name, :state, :zip]
+ end
+
+ test "is JSON-encodable via Jason" do
+ record = Record.from_map(%{"call" => "W1AW", "lat" => "41.714"})
+ json = Jason.encode!(record)
+ decoded = Jason.decode!(json)
+
+ assert decoded["call"] == "W1AW"
+ assert decoded["lat"] == 41.714
+ end
+ end
+end
diff --git a/test/microwaveprop/radio/callsign_client_test.exs b/test/microwaveprop/radio/callsign_client_test.exs
new file mode 100644
index 00000000..cdaf638d
--- /dev/null
+++ b/test/microwaveprop/radio/callsign_client_test.exs
@@ -0,0 +1,108 @@
+defmodule Microwaveprop.Radio.CallsignClientTest do
+ use Microwaveprop.DataCase, async: false
+
+ alias Microwaveprop.CallsignLocation.Location
+ alias Microwaveprop.Qrz.Client
+ alias Microwaveprop.Radio.CallsignClient
+
+ setup do
+ Client.reset_session()
+ :ok
+ end
+
+ describe "locate/1" do
+ test "reshapes a cached CallsignLocation into the public shape" do
+ {:ok, _} =
+ %Location{}
+ |> Location.changeset(%{
+ callsign: "W1AW",
+ latitude: 41.714,
+ longitude: -72.727,
+ gridsquare: "FN31pr58"
+ })
+ |> Repo.insert()
+
+ assert {:ok, result} = CallsignClient.locate("W1AW")
+
+ assert result == %{
+ callsign: "W1AW",
+ gridsquare: "FN31pr58",
+ lat: 41.714,
+ lon: -72.727
+ }
+ end
+
+ test "returns only callsign, gridsquare, lat, lon (drops name and address)" do
+ {:ok, _} =
+ %Location{}
+ |> Location.changeset(%{
+ callsign: "W1AW",
+ latitude: 41.714,
+ longitude: -72.727,
+ gridsquare: "FN31pr58"
+ })
+ |> Repo.insert()
+
+ assert {:ok, result} = CallsignClient.locate("W1AW")
+
+ assert result |> Map.keys() |> Enum.sort() == [:callsign, :gridsquare, :lat, :lon]
+ end
+
+ test "renames latitude/longitude to lat/lon" do
+ {:ok, _} =
+ %Location{}
+ |> Location.changeset(%{
+ callsign: "K5QE",
+ latitude: 30.5,
+ longitude: -94.1,
+ gridsquare: "EM30wn"
+ })
+ |> Repo.insert()
+
+ assert {:ok, %{lat: 30.5, lon: -94.1}} = CallsignClient.locate("K5QE")
+ end
+
+ test "propagates {:error, reason} tuples from the underlying lookup" do
+ Req.Test.expect(Client, 2, fn conn ->
+ params = Plug.Conn.fetch_query_params(conn).query_params
+
+ if Map.has_key?(params, "username") do
+ Plug.Conn.send_resp(conn, 200, """
+
+
+ session123
+
+ """)
+ else
+ Plug.Conn.send_resp(conn, 200, """
+
+
+
+ Not found: INVALID
+ session123
+
+
+ """)
+ end
+ end)
+
+ assert {:error, _reason} = CallsignClient.locate("INVALID")
+ end
+
+ test "upcases lookups, matching a cached row inserted in different casing" do
+ # The underlying CallsignLocation.lookup/1 upcases the callsign before
+ # the DB read, so a mixed-case request finds an uppercase cache hit.
+ {:ok, _} =
+ %Location{}
+ |> Location.changeset(%{
+ callsign: "W1AW",
+ latitude: 41.714,
+ longitude: -72.727,
+ gridsquare: "FN31pr58"
+ })
+ |> Repo.insert()
+
+ assert {:ok, %{callsign: "W1AW"}} = CallsignClient.locate("w1aw")
+ end
+ end
+end