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