diff --git a/test/aprsme/encoding_test.exs b/test/aprsme/encoding_test.exs index d231b52..d14271b 100644 --- a/test/aprsme/encoding_test.exs +++ b/test/aprsme/encoding_test.exs @@ -83,4 +83,90 @@ defmodule Aprsme.EncodingTest do assert result == "Café" end end + + describe "to_float_safe/1" do + test "parses valid numeric string" do + assert Encoding.to_float_safe("3.14") == {:ok, 3.14} + assert Encoding.to_float_safe("42") == {:ok, 42.0} + end + + test "trims whitespace" do + assert Encoding.to_float_safe(" 3.14 ") == {:ok, 3.14} + end + + test "truncates strings longer than 30 characters" do + # "1.0" padded with trailing zeros — valid after truncation + long = "1." <> String.duplicate("0", 40) + assert Encoding.to_float_safe(long) == {:ok, 1.0} + end + + test "returns nil for out-of-range float" do + assert is_nil(Encoding.to_float_safe("9.1e15")) + assert is_nil(Encoding.to_float_safe("-9.1e15")) + end + + test "returns value for in-range float" do + assert {:ok, f} = Encoding.to_float_safe("1000000.0") + assert f == 1_000_000.0 + end + + test "returns nil for invalid string" do + assert is_nil(Encoding.to_float_safe("abc")) + assert is_nil(Encoding.to_float_safe("")) + end + + test "returns nil for non-binary input" do + assert is_nil(Encoding.to_float_safe(nil)) + assert is_nil(Encoding.to_float_safe(123)) + assert is_nil(Encoding.to_float_safe(:atom)) + end + + test "handles scientific notation" do + assert {:ok, f} = Encoding.to_float_safe("1.5e3") + assert f == 1500.0 + end + + test "handles zero" do + assert Encoding.to_float_safe("0") == {:ok, 0.0} + assert Encoding.to_float_safe("0.0") == {:ok, 0.0} + end + end + + describe "has_weather_data/4" do + test "returns true when single field present" do + assert Encoding.has_weather_data(72.0, nil, nil, nil) + assert Encoding.has_weather_data(nil, 50, nil, nil) + assert Encoding.has_weather_data(nil, nil, 5.0, nil) + assert Encoding.has_weather_data(nil, nil, nil, 1013.25) + end + + test "returns true when multiple fields present" do + assert Encoding.has_weather_data(72.0, 50, 5.0, 1013.25) + end + + test "returns false when all nil" do + refute Encoding.has_weather_data(nil, nil, nil, nil) + end + + test "treats zero as valid weather data" do + assert Encoding.has_weather_data(0, nil, nil, nil) + assert Encoding.has_weather_data(nil, 0, nil, nil) + end + + test "treats negative values as valid weather data" do + assert Encoding.has_weather_data(-10, nil, nil, nil) + end + end + + describe "to_hex/1" do + test "returns empty string for non-binary" do + assert Encoding.to_hex(nil) == "" + assert Encoding.to_hex(123) == "" + end + + test "handles boundary byte values" do + assert Encoding.to_hex(<<0>>) == "00" + assert Encoding.to_hex(<<255>>) == "FF" + end + end end diff --git a/test/aprsme/encoding_utils_test.exs b/test/aprsme/encoding_utils_test.exs index 3b980cd..ecb47f5 100644 --- a/test/aprsme/encoding_utils_test.exs +++ b/test/aprsme/encoding_utils_test.exs @@ -1,6 +1,7 @@ defmodule Aprsme.EncodingUtilsTest do use ExUnit.Case + alias Aprs.Types.MicE alias Aprsme.EncodingUtils alias Aprsme.Packet @@ -121,7 +122,7 @@ defmodule Aprsme.EncodingUtilsTest do test "sanitizes message field in MicE struct" do invalid_message = <<72, 101, 108, 108, 111, 211, 87, 111, 114, 108, 100>> - mic_e = %Aprs.Types.MicE{message: invalid_message, lat_degrees: 40} + mic_e = %MicE{message: invalid_message, lat_degrees: 40} sanitized = EncodingUtils.sanitize_data_extended(mic_e) @@ -149,6 +150,194 @@ defmodule Aprsme.EncodingUtilsTest do end end + describe "to_float/1" do + test "converts integer to float" do + assert EncodingUtils.to_float(1) == 1.0 + assert EncodingUtils.to_float(0) == 0.0 + assert EncodingUtils.to_float(-42) == -42.0 + end + + test "returns float unchanged" do + assert EncodingUtils.to_float(1.5) == 1.5 + assert EncodingUtils.to_float(-3.14) == -3.14 + assert EncodingUtils.to_float(0.0) == 0.0 + end + + test "returns nil for out-of-range integer" do + assert is_nil(EncodingUtils.to_float(10_000_000_000_000_000)) + assert is_nil(EncodingUtils.to_float(-10_000_000_000_000_000)) + end + + test "converts valid string to float" do + assert EncodingUtils.to_float("2.3") == 2.3 + assert EncodingUtils.to_float("42") == 42.0 + assert EncodingUtils.to_float("-7.5") == -7.5 + assert EncodingUtils.to_float("0") == 0.0 + end + + test "handles string with trailing text" do + assert EncodingUtils.to_float("12.5abc") == 12.5 + end + + test "returns nil for invalid string" do + assert is_nil(EncodingUtils.to_float("bad")) + assert is_nil(EncodingUtils.to_float("")) + end + + test "converts Decimal to float" do + assert EncodingUtils.to_float(Decimal.new("3.14")) == 3.14 + end + + test "returns nil for nil" do + assert is_nil(EncodingUtils.to_float(nil)) + end + + test "returns nil for unsupported types" do + assert is_nil(EncodingUtils.to_float(:atom)) + assert is_nil(EncodingUtils.to_float([1, 2])) + assert is_nil(EncodingUtils.to_float(%{a: 1})) + end + end + + describe "to_decimal/1" do + test "returns Decimal unchanged" do + d = Decimal.new("3.14") + assert EncodingUtils.to_decimal(d) == d + end + + test "converts float to Decimal" do + result = EncodingUtils.to_decimal(1.5) + assert is_struct(result, Decimal) + assert Decimal.to_float(result) == 1.5 + end + + test "converts integer to Decimal" do + result = EncodingUtils.to_decimal(42) + assert is_struct(result, Decimal) + assert Decimal.equal?(result, Decimal.new(42)) + end + + test "converts valid string to Decimal" do + result = EncodingUtils.to_decimal("2.3") + assert is_struct(result, Decimal) + assert Decimal.equal?(result, Decimal.new("2.3")) + end + + test "returns nil for invalid string" do + assert is_nil(EncodingUtils.to_decimal("bad")) + assert is_nil(EncodingUtils.to_decimal("")) + end + + test "handles negative values" do + result = EncodingUtils.to_decimal("-7.5") + assert is_struct(result, Decimal) + assert Decimal.negative?(result) + end + + test "handles zero" do + result = EncodingUtils.to_decimal(0) + assert Decimal.equal?(result, Decimal.new(0)) + end + + test "handles very large integer" do + result = EncodingUtils.to_decimal(999_999_999_999) + assert is_struct(result, Decimal) + assert Decimal.equal?(result, Decimal.new(999_999_999_999)) + end + + test "returns nil for unsupported types" do + assert is_nil(EncodingUtils.to_decimal(nil)) + assert is_nil(EncodingUtils.to_decimal(:atom)) + assert is_nil(EncodingUtils.to_decimal([1, 2])) + end + end + + describe "normalize_data_type/1" do + test "converts atom value with atom key" do + assert EncodingUtils.normalize_data_type(%{data_type: :weather}) == %{data_type: "weather"} + end + + test "converts atom value with string key" do + assert EncodingUtils.normalize_data_type(%{"data_type" => :position}) == %{"data_type" => "position"} + end + + test "leaves string value unchanged" do + assert EncodingUtils.normalize_data_type(%{data_type: "bar"}) == %{data_type: "bar"} + assert EncodingUtils.normalize_data_type(%{"data_type" => "baz"}) == %{"data_type" => "baz"} + end + + test "returns map without data_type unchanged" do + assert EncodingUtils.normalize_data_type(%{foo: 1}) == %{foo: 1} + end + + test "returns non-map input unchanged" do + assert EncodingUtils.normalize_data_type("string") == "string" + assert EncodingUtils.normalize_data_type(nil) == nil + end + end + + describe "weather_fields/0" do + test "returns all 10 weather fields" do + fields = EncodingUtils.weather_fields() + assert length(fields) == 10 + end + + test "includes expected fields" do + fields = EncodingUtils.weather_fields() + assert :temperature in fields + assert :humidity in fields + assert :wind_speed in fields + assert :wind_direction in fields + assert :wind_gust in fields + assert :pressure in fields + assert :rain_1h in fields + assert :rain_24h in fields + assert :rain_since_midnight in fields + assert :snow in fields + end + + test "all fields are atoms" do + assert Enum.all?(EncodingUtils.weather_fields(), &is_atom/1) + end + end + + describe "sanitize_packet_strings/1" do + test "passes DateTime through unchanged" do + dt = ~U[2024-01-01 00:00:00Z] + assert EncodingUtils.sanitize_packet_strings(dt) == dt + end + + test "passes NaiveDateTime through unchanged" do + ndt = ~N[2024-01-01 00:00:00] + assert EncodingUtils.sanitize_packet_strings(ndt) == ndt + end + + test "sanitizes nested map values" do + input = %{"outer" => %{"inner" => <<0, 65, 66>>}} + result = EncodingUtils.sanitize_packet_strings(input) + assert result["outer"]["inner"] == "AB" + end + + test "sanitizes list elements" do + input = ["abc", <<0, 65, 66, 67>>] + result = EncodingUtils.sanitize_packet_strings(input) + assert result == ["abc", "ABC"] + end + + test "converts struct to map and sanitizes" do + input = %MicE{message: <<0, 72, 73>>} + result = EncodingUtils.sanitize_packet_strings(input) + assert is_map(result) + assert result[:message] == "HI" + end + + test "passes non-binary values through" do + assert EncodingUtils.sanitize_packet_strings(nil) == nil + assert EncodingUtils.sanitize_packet_strings(42) == 42 + assert EncodingUtils.sanitize_packet_strings(true) == true + end + end + describe "encoding_info/1" do test "returns info for valid UTF-8 string" do info = EncodingUtils.encoding_info("Hello") diff --git a/test/aprsme/packet_geometry_test.exs b/test/aprsme/packet_geometry_test.exs new file mode 100644 index 0000000..08a1560 --- /dev/null +++ b/test/aprsme/packet_geometry_test.exs @@ -0,0 +1,109 @@ +defmodule Aprsme.PacketGeometryTest do + use Aprsme.DataCase, async: true + + alias Aprsme.Packet + + describe "create_point/2" do + test "creates point from valid float coordinates" do + point = Packet.create_point(40.7128, -74.006) + assert %Geo.Point{coordinates: {-74.006, 40.7128}, srid: 4326} = point + end + + test "creates point from Decimal coordinates" do + lat = Decimal.new("40.7128") + lon = Decimal.new("-74.006") + point = Packet.create_point(lat, lon) + assert %Geo.Point{srid: 4326} = point + end + + test "creates point from integer coordinates" do + point = Packet.create_point(40, -74) + assert %Geo.Point{coordinates: {-74, 40}, srid: 4326} = point + end + + test "returns nil for invalid latitude" do + assert is_nil(Packet.create_point(91.0, 0.0)) + assert is_nil(Packet.create_point(-91.0, 0.0)) + end + + test "returns nil for invalid longitude" do + assert is_nil(Packet.create_point(0.0, 181.0)) + assert is_nil(Packet.create_point(0.0, -181.0)) + end + + test "returns nil for nil coordinates" do + assert is_nil(Packet.create_point(nil, nil)) + assert is_nil(Packet.create_point(40.0, nil)) + assert is_nil(Packet.create_point(nil, -74.0)) + end + + test "handles boundary coordinates" do + # North pole + assert %Geo.Point{} = Packet.create_point(90.0, 0.0) + # South pole + assert %Geo.Point{} = Packet.create_point(-90.0, 0.0) + # Date line + assert %Geo.Point{} = Packet.create_point(0.0, 180.0) + assert %Geo.Point{} = Packet.create_point(0.0, -180.0) + # Equator/prime meridian + point = Packet.create_point(0.0, 0.0) + assert %Geo.Point{srid: 4326} = point + assert point.coordinates == {0.0, 0.0} + end + + test "returns nil for non-numeric types" do + assert is_nil(Packet.create_point("40.0", "-74.0")) + assert is_nil(Packet.create_point(:lat, :lon)) + end + end + + describe "extract_coordinates/1" do + test "extracts lat/lon from Geo.Point" do + point = %Geo.Point{coordinates: {-74.006, 40.7128}, srid: 4326} + assert {40.7128, -74.006} = Packet.extract_coordinates(point) + end + + test "returns nil tuple for nil" do + assert {nil, nil} = Packet.extract_coordinates(nil) + end + + test "returns nil tuple for non-point input" do + assert {nil, nil} = Packet.extract_coordinates(%{lat: 40.0, lon: -74.0}) + assert {nil, nil} = Packet.extract_coordinates("not a point") + end + end + + describe "lat/1" do + test "returns latitude from packet with location" do + packet = %Packet{location: %Geo.Point{coordinates: {-74.006, 40.7128}, srid: 4326}} + assert Packet.lat(packet) == 40.7128 + end + + test "returns nil for packet without location" do + packet = %Packet{location: nil} + assert is_nil(Packet.lat(packet)) + end + + test "returns nil for non-packet" do + assert is_nil(Packet.lat(nil)) + assert is_nil(Packet.lat(%{})) + end + end + + describe "lon/1" do + test "returns longitude from packet with location" do + packet = %Packet{location: %Geo.Point{coordinates: {-74.006, 40.7128}, srid: 4326}} + assert Packet.lon(packet) == -74.006 + end + + test "returns nil for packet without location" do + packet = %Packet{location: nil} + assert is_nil(Packet.lon(packet)) + end + + test "returns nil for non-packet" do + assert is_nil(Packet.lon(nil)) + assert is_nil(Packet.lon(%{})) + end + end +end diff --git a/test/aprsme/packet_test.exs b/test/aprsme/packet_test.exs index 3f8b358..aebdf54 100644 --- a/test/aprsme/packet_test.exs +++ b/test/aprsme/packet_test.exs @@ -352,4 +352,210 @@ defmodule Aprsme.PacketTest do assert is_nil(result[:comment]) end end + + describe "PHG extraction" do + test "extracts PHG from comment string" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "PHG5530 Some comment", + latitude: 33.0, + longitude: -96.0 + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + # PHG5530: power=5^2=25, height=10*2^5=320, gain=3, dir=0*45=0 + assert result.data["phg_power"] == 25 + assert result.data["phg_height"] == 320 + assert result.data["phg_gain"] == 3 + assert result.data["phg_directivity"] == 0 + end + + test "calculates omnidirectional antenna (digit 9 = 360 degrees)" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "PHG2369 My station", + latitude: 40.0, + longitude: -74.0 + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + # PHG2369: power=2^2=4, height=10*2^3=80, gain=6, dir=9→360 + assert result.data["phg_power"] == 4 + assert result.data["phg_height"] == 80 + assert result.data["phg_gain"] == 6 + assert result.data["phg_directivity"] == 360 + end + + test "calculates directional antenna" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "PHG1064 Directional", + latitude: 35.0, + longitude: -80.0 + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + # PHG1064: power=1^2=1, height=10*2^0=10, gain=6, dir=4*45=180 + assert result.data["phg_power"] == 1 + assert result.data["phg_height"] == 10 + assert result.data["phg_gain"] == 6 + assert result.data["phg_directivity"] == 180 + end + + test "extracts PHG from data_extended map" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "My station", + latitude: 33.0, + longitude: -96.0, + phg: %{power: 25, height: 320, gain: 3, directivity: 0} + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + assert result.data["phg_power"] == 25 + assert result.data["phg_height"] == 320 + assert result.data["phg_gain"] == 3 + assert result.data["phg_directivity"] == 0 + end + + test "extracts PHG from data_extended string format" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "My station", + latitude: 33.0, + longitude: -96.0, + phg: "5530" + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + assert result.data["phg_power"] == 25 + assert result.data["phg_height"] == 320 + assert result.data["phg_gain"] == 3 + assert result.data["phg_directivity"] == 0 + end + + test "handles zero PHG values" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "PHG0000 Minimal", + latitude: 33.0, + longitude: -96.0 + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + # PHG0000: power=0^2=0, height=10*2^0=10, gain=0, dir=0*45=0 + assert result.data["phg_power"] == 0 + assert result.data["phg_height"] == 10 + assert result.data["phg_gain"] == 0 + assert result.data["phg_directivity"] == 0 + end + end + + describe "altitude extraction" do + test "extracts standard altitude from comment" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "Some info /A=001234 more info", + latitude: 33.0, + longitude: -96.0 + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + assert result[:altitude] == 1234.0 + end + + test "extracts altitude from data_extended map" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "My station", + latitude: 33.0, + longitude: -96.0, + altitude: 5280.0 + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + assert result[:altitude] == 5280.0 + end + + test "extracts zero altitude" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "/A=000000 ground level", + latitude: 33.0, + longitude: -96.0 + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + assert result[:altitude] == 0.0 + end + + test "returns nil when no altitude present" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "Just a plain comment", + latitude: 33.0, + longitude: -96.0 + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + assert is_nil(result[:altitude]) + end + + test "prefers data_extended altitude over comment-extracted altitude" do + attrs = %{ + sender: "TEST", + data_type: "position", + data_extended: %{ + comment: "/A=001000 info", + latitude: 33.0, + longitude: -96.0, + altitude: 9999.0 + } + } + + result = Packet.extract_additional_data(attrs, "test_packet") + + assert result[:altitude] == 9999.0 + end + end end