defmodule AprsmeWeb.Live.Shared.BoundsUtilsTest do use ExUnit.Case, async: true use ExUnitProperties alias AprsmeWeb.Live.Shared.BoundsUtils defp world_bounds, do: %{north: 85.0, south: -85.0, east: 180.0, west: -180.0} defp usa_bounds, do: %{north: 49.0, south: 25.0, east: -67.0, west: -125.0} # crosses antimeridian defp pacific_bounds, do: %{north: 60.0, south: -60.0, east: -170.0, west: 170.0} describe "valid_bounds?/1" do test "accepts valid bounds" do assert BoundsUtils.valid_bounds?(world_bounds()) assert BoundsUtils.valid_bounds?(usa_bounds()) end test "rejects bounds with north <= south" do refute BoundsUtils.valid_bounds?(%{north: 10.0, south: 20.0, east: 0.0, west: -10.0}) refute BoundsUtils.valid_bounds?(%{north: 10.0, south: 10.0, east: 0.0, west: -10.0}) end test "rejects bounds with out-of-range latitude" do refute BoundsUtils.valid_bounds?(%{north: 91.0, south: 0.0, east: 10.0, west: -10.0}) refute BoundsUtils.valid_bounds?(%{north: 10.0, south: -91.0, east: 10.0, west: -10.0}) end test "rejects non-map input" do refute BoundsUtils.valid_bounds?(nil) refute BoundsUtils.valid_bounds?(%{}) refute BoundsUtils.valid_bounds?("bounds") end test "rejects bounds with non-numeric values" do refute BoundsUtils.valid_bounds?(%{north: "10", south: 0.0, east: 0.0, west: 0.0}) end end describe "compare_bounds/2" do test "two nils are equal" do assert BoundsUtils.compare_bounds(nil, nil) end test "nil vs map is not equal" do refute BoundsUtils.compare_bounds(nil, usa_bounds()) refute BoundsUtils.compare_bounds(usa_bounds(), nil) end test "identical bounds compare equal" do assert BoundsUtils.compare_bounds(usa_bounds(), usa_bounds()) end test "bounds equal within rounding to 4 places" do a = %{north: 10.00001, south: 0.0, east: 0.0, west: -10.0} b = %{north: 10.00002, south: 0.0, east: 0.0, west: -10.0} assert BoundsUtils.compare_bounds(a, b) end test "bounds different beyond 4 places compare unequal" do a = %{north: 10.0001, south: 0.0, east: 0.0, west: -10.0} b = %{north: 10.0003, south: 0.0, east: 0.0, west: -10.0} refute BoundsUtils.compare_bounds(a, b) end test "integer vs equivalent float compares equal" do a = %{north: 10, south: 0, east: 5, west: -5} b = %{north: 10.0, south: 0.0, east: 5.0, west: -5.0} assert BoundsUtils.compare_bounds(a, b) end end describe "within_bounds?/2" do test "coordinates inside USA bounds match" do assert BoundsUtils.within_bounds?({40.0, -100.0}, usa_bounds()) assert BoundsUtils.within_bounds?(%{lat: 40.0, lon: -100.0}, usa_bounds()) end test "coordinates outside USA bounds don't match" do refute BoundsUtils.within_bounds?({0.0, 0.0}, usa_bounds()) refute BoundsUtils.within_bounds?(%{lat: 60.0, lon: -100.0}, usa_bounds()) end test "coordinates on antimeridian-crossing bounds" do # Pacific bounds go from west=170 to east=-170, wrapping across 180. assert BoundsUtils.within_bounds?({0.0, 175.0}, pacific_bounds()) assert BoundsUtils.within_bounds?({0.0, -175.0}, pacific_bounds()) refute BoundsUtils.within_bounds?({0.0, 0.0}, pacific_bounds()) end test "nil coordinates are never within bounds" do refute BoundsUtils.within_bounds?(%{lat: nil, lon: nil}, usa_bounds()) refute BoundsUtils.within_bounds?(%{}, usa_bounds()) end end describe "filter_packets_by_bounds/2" do test "filters a list of packets" do packets = [ %{lat: 40.0, lon: -100.0, id: "in"}, %{lat: 0.0, lon: 0.0, id: "out"} ] result = BoundsUtils.filter_packets_by_bounds(packets, usa_bounds()) assert length(result) == 1 assert hd(result).id == "in" end test "filters a map of packets" do packets = %{ "a" => %{lat: 40.0, lon: -100.0}, "b" => %{lat: 0.0, lon: 0.0} } result = BoundsUtils.filter_packets_by_bounds(packets, usa_bounds()) assert Map.has_key?(result, "a") refute Map.has_key?(result, "b") end test "returns empty on empty input" do assert BoundsUtils.filter_packets_by_bounds([], usa_bounds()) == [] assert BoundsUtils.filter_packets_by_bounds(%{}, usa_bounds()) == %{} end end describe "reject_packets_by_bounds/2" do test "returns keys for packets outside bounds" do packets = %{ "in" => %{lat: 40.0, lon: -100.0}, "out" => %{lat: 0.0, lon: 0.0} } assert BoundsUtils.reject_packets_by_bounds(packets, usa_bounds()) == ["out"] end test "returns empty list when everything is in bounds" do packets = %{"a" => %{lat: 40.0, lon: -100.0}} assert BoundsUtils.reject_packets_by_bounds(packets, usa_bounds()) == [] end end describe "normalize_bounds/1" do test "converts string keys to atoms" do result = BoundsUtils.normalize_bounds(%{ "north" => 49.0, "south" => 25.0, "east" => -67.0, "west" => -125.0 }) assert result == usa_bounds() end test "converts string values to floats" do result = BoundsUtils.normalize_bounds(%{ "north" => "49.0", "south" => "25.0", "east" => "-67.0", "west" => "-125.0" }) assert result.north == 49.0 assert result.south == 25.0 end test "passes through already-atom bounds" do assert BoundsUtils.normalize_bounds(usa_bounds()) == usa_bounds() end test "returns nil for unsupported input" do assert BoundsUtils.normalize_bounds(nil) == nil assert BoundsUtils.normalize_bounds(%{foo: "bar"}) == nil assert BoundsUtils.normalize_bounds("bounds") == nil end end describe "calculate_bounds_from_center_and_zoom/2" do test "centered bounds contain the center" do center = %{lat: 40.0, lng: -100.0} bounds = BoundsUtils.calculate_bounds_from_center_and_zoom(center, 5) assert bounds.south <= center.lat and bounds.north >= center.lat assert bounds.west <= center.lng and bounds.east >= center.lng end test "higher zoom produces tighter bounds" do center = %{lat: 40.0, lng: -100.0} b_low = BoundsUtils.calculate_bounds_from_center_and_zoom(center, 3) b_high = BoundsUtils.calculate_bounds_from_center_and_zoom(center, 15) low_width = b_low.east - b_low.west high_width = b_high.east - b_high.west assert high_width < low_width end test "center-symmetric about the origin produces symmetric bounds" do bounds = BoundsUtils.calculate_bounds_from_center_and_zoom(%{lat: 0.0, lng: 0.0}, 5) assert_in_delta bounds.north, -bounds.south, 0.0001 assert_in_delta bounds.east, -bounds.west, 0.0001 end end describe "property: filter + reject partition the input" do property "every packet key ends up in exactly one of filter/reject" do packet_gen = fixed_map(%{ lat: float(min: -89.0, max: 89.0), lon: float(min: -179.0, max: 179.0) }) check all(packets <- map_of(string(:alphanumeric, min_length: 1, max_length: 5), packet_gen)) do bounds = usa_bounds() kept = packets |> BoundsUtils.filter_packets_by_bounds(bounds) |> Map.keys() dropped = BoundsUtils.reject_packets_by_bounds(packets, bounds) assert Enum.sort(kept ++ dropped) == packets |> Map.keys() |> Enum.sort() assert kept -- dropped == kept end end end describe "property: within_bounds? matches manual range check (non-wrapping bounds)" do property "matches manual check for any coordinate against USA bounds" do usa = usa_bounds() check all( lat <- float(min: -90.0, max: 90.0), lon <- float(min: -180.0, max: 180.0) ) do expected = lat >= usa.south and lat <= usa.north and lon >= usa.west and lon <= usa.east assert BoundsUtils.within_bounds?({lat, lon}, usa) == expected end end end end