defmodule Aprsme.SpatialPubSubTest do use ExUnit.Case, async: false alias Aprsme.SpatialPubSub describe "viewport registration" do test "date-line-crossing viewport registers without timeout" do bounds = %{north: 10.0, south: -10.0, east: -170.0, west: 170.0} client_id = "test_dateline_#{:rand.uniform(100_000)}" # Before the fix, west=170 east=-170 generated ~341 grid cells. # Now it correctly generates ~21 cells. assert {:ok, _topic} = SpatialPubSub.register_viewport(client_id, bounds) SpatialPubSub.unregister_client(client_id) end test "normal viewport registers successfully" do bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} client_id = "test_normal_#{:rand.uniform(100_000)}" assert {:ok, _topic} = SpatialPubSub.register_viewport(client_id, bounds) SpatialPubSub.unregister_client(client_id) end test "update_viewport succeeds after registration" do bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} new_bounds = %{north: 42.0, south: 41.0, east: -72.0, west: -73.0} client_id = "test_update_#{:rand.uniform(100_000)}" {:ok, _topic} = SpatialPubSub.register_viewport(client_id, bounds) assert :ok = SpatialPubSub.update_viewport(client_id, new_bounds) SpatialPubSub.unregister_client(client_id) end test "re-registering an existing client replaces the old viewport without inflating counts" do client_id = "test_reregister_#{:rand.uniform(100_000)}" bounds_1 = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} bounds_2 = %{north: 36.0, south: 35.0, east: -79.0, west: -80.0} before_stats = SpatialPubSub.get_stats() assert {:ok, _topic} = SpatialPubSub.register_viewport(client_id, bounds_1) mid_stats = SpatialPubSub.get_stats() assert mid_stats.clients_count == before_stats.clients_count + 1 assert {:ok, _topic} = SpatialPubSub.register_viewport(client_id, bounds_2) after_stats = SpatialPubSub.get_stats() assert after_stats.clients_count == before_stats.clients_count + 1 state = :sys.get_state(SpatialPubSub) assert state.clients[client_id].bounds == bounds_2 SpatialPubSub.unregister_client(client_id) end end describe "duplicate packet prevention" do test "does not subscribe to postgres PubSub topic" do # SpatialPubSub used to subscribe to "postgres:aprsme_packets" causing # duplicate broadcasts (once from PacketConsumer direct cast, once from PubSub). # Verify it no longer subscribes. # The SpatialPubSub process should NOT be in the subscriber list for postgres topic spatial_pid = Process.whereis(SpatialPubSub) assert Map.has_key?(SpatialPubSub.get_stats(), :total_packets) subscribed? = Aprsme.PubSub |> Registry.lookup("postgres:aprsme_packets") |> Enum.any?(fn {pid, _} -> pid == spatial_pid end) refute subscribed?, "SpatialPubSub should not subscribe to postgres:aprsme_packets" end end describe "stats" do test "get_stats returns expected keys" do stats = SpatialPubSub.get_stats() assert Map.has_key?(stats, :total_packets) assert Map.has_key?(stats, :grid_cells) end test "grid cells increase after registering viewport" do bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} client_id = "test_stats_#{:rand.uniform(100_000)}" before_stats = SpatialPubSub.get_stats() {:ok, _topic} = SpatialPubSub.register_viewport(client_id, bounds) after_stats = SpatialPubSub.get_stats() assert after_stats.grid_cells >= before_stats.grid_cells SpatialPubSub.unregister_client(client_id) end end describe "broadcast_packet" do test "broadcasts to clients whose viewport contains the packet location" do client_id = "test_bc_#{:rand.uniform(100_000)}" bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} {:ok, topic} = SpatialPubSub.register_viewport(client_id, bounds) Phoenix.PubSub.subscribe(Aprsme.PubSub, topic) # Broadcast a packet inside the viewport. assert :ok = SpatialPubSub.broadcast_packet(%{lat: 40.5, lon: -73.5, sender: "IN1"}) assert_receive {:spatial_packet, %{sender: "IN1"}}, 1_000 SpatialPubSub.unregister_client(client_id) end test "does not broadcast to clients outside the viewport" do client_id = "test_outside_#{:rand.uniform(100_000)}" bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} {:ok, topic} = SpatialPubSub.register_viewport(client_id, bounds) Phoenix.PubSub.subscribe(Aprsme.PubSub, topic) # Broadcast far outside bounds. assert :ok = SpatialPubSub.broadcast_packet(%{lat: 0.0, lon: 0.0, sender: "OUT1"}) refute_receive {:spatial_packet, _}, 100 SpatialPubSub.unregister_client(client_id) end test "ignores packets with missing or invalid location" do # Packet with nil coords should not crash. assert :ok = SpatialPubSub.broadcast_packet(%{lat: nil, lon: nil}) assert :ok = SpatialPubSub.broadcast_packet(%{lat: "bad", lon: "bad"}) assert :ok = SpatialPubSub.broadcast_packet(%{}) end test "binary-string coordinates in bounds are parsed" do client_id = "test_strbounds_#{:rand.uniform(100_000)}" bounds = %{north: "41.0", south: "40.0", east: "-73.0", west: "-74.0"} assert {:ok, _topic} = SpatialPubSub.register_viewport(client_id, bounds) SpatialPubSub.unregister_client(client_id) end end describe "client lifecycle" do test "unregister removes client from spatial index" do client_id = "test_unreg_#{:rand.uniform(100_000)}" bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} {:ok, _topic} = SpatialPubSub.register_viewport(client_id, bounds) before_stats = SpatialPubSub.get_stats() SpatialPubSub.unregister_client(client_id) # Give cast time to process _ = SpatialPubSub.get_stats() after_stats = SpatialPubSub.get_stats() assert after_stats.clients_count <= before_stats.clients_count end test "update_viewport returns error for unregistered client" do bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} result = SpatialPubSub.update_viewport("never_registered_#{:rand.uniform(100_000)}", bounds) assert result == {:error, :not_registered} end end describe "ensure_float branches via register_viewport" do test "accepts integer bounds (ensure_float integer clause)" do client = "int-bounds-#{System.unique_integer([:positive])}" bounds = %{north: 41, south: 40, east: -73, west: -74} assert {:ok, _topic} = SpatialPubSub.register_viewport(client, bounds) SpatialPubSub.unregister_client(client) end test "ensure_float fallback handles bogus atom values without crashing" do client = "bogus-#{System.unique_integer([:positive])}" # Atoms aren't covered by the binary/integer/float clauses; fall through to 0.0. bounds = %{north: :unknown, south: :unknown, east: :unknown, west: :unknown} assert {:ok, _topic} = SpatialPubSub.register_viewport(client, bounds) SpatialPubSub.unregister_client(client) end test "registering the same client_id twice replaces the prior entry" do client = "replace-#{System.unique_integer([:positive])}" bounds_a = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} bounds_b = %{north: 51.0, south: 50.0, east: -83.0, west: -84.0} assert {:ok, _} = SpatialPubSub.register_viewport(client, bounds_a) assert {:ok, _} = SpatialPubSub.register_viewport(client, bounds_b) SpatialPubSub.unregister_client(client) end test "registering two clients in the same grid cell exercises the existing-set branch" do a = "share-a-#{System.unique_integer([:positive])}" b = "share-b-#{System.unique_integer([:positive])}" # Same exact bounds → both clients land in the same grid cell. bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} assert {:ok, _} = SpatialPubSub.register_viewport(a, bounds) assert {:ok, _} = SpatialPubSub.register_viewport(b, bounds) SpatialPubSub.unregister_client(a) SpatialPubSub.unregister_client(b) end end describe "broadcast_packet across the international date line" do test "matches a client whose bounds wrap from positive to negative longitude" do client = "dateline-#{System.unique_integer([:positive])}" # west > east → bounds cross the date line. bounds = %{north: 60.0, south: 30.0, east: -170.0, west: 170.0} {:ok, _topic} = SpatialPubSub.register_viewport(client, bounds) # A point west of the date line (lon: -179) should match via # `lon <= e` half of the wrap-aware comparison. assert :ok = SpatialPubSub.broadcast_packet(%{lat: 45.0, lon: -179.0}) # A point east of the date line (lon: 175) should match via the # `lon >= w` half. assert :ok = SpatialPubSub.broadcast_packet(%{lat: 45.0, lon: 175.0}) SpatialPubSub.unregister_client(client) end end describe "extract_location with no coordinates" do test "broadcast_packet ignores packets without lat/lon without crashing" do assert :ok = SpatialPubSub.broadcast_packet(%{some: "field"}) end end describe "DOWN handler removes the client" do test "monitored client process exiting unregisters the client" do task = Task.async(fn -> receive do :stop -> :ok after 2_000 -> :ok end end) client = "down-#{System.unique_integer([:positive])}" bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} {:ok, _} = SpatialPubSub |> GenServer.call({:register_viewport, client, bounds}, 5_000) |> tap(fn _ -> :ok end) _ = task # The above register_viewport monitored the test process; force the # task to exit and observe stats remain consistent. send(task.pid, :stop) _ = Task.shutdown(task, :brutal_kill) Process.sleep(50) assert Map.has_key?(SpatialPubSub.get_stats(), :total_packets) SpatialPubSub.unregister_client(client) end end describe "when the server is not running" do setup do pid = Process.whereis(SpatialPubSub) if pid do GenServer.stop(pid) end on_exit(fn -> if !Process.whereis(SpatialPubSub) do start_supervised!({SpatialPubSub, []}) end end) :ok end test "public APIs return safe defaults instead of crashing" do bounds = %{north: 41.0, south: 40.0, east: -73.0, west: -74.0} assert {:error, :not_running} = SpatialPubSub.register_viewport("missing", bounds) assert {:error, :not_running} = SpatialPubSub.update_viewport("missing", bounds) assert :ok = SpatialPubSub.unregister_client("missing") assert :ok = SpatialPubSub.broadcast_packet(%{lat: 40.5, lon: -73.5}) assert :ok = SpatialPubSub.start_telemetry_reporting() assert SpatialPubSub.get_stats() == %{ total_broadcasts: 0, filtered_broadcasts: 0, total_packets: 0, clients_count: 0, grid_cells: 0, avg_clients_per_cell: 0.0 } end end end