diff --git a/assets/js/map.ts b/assets/js/map.ts index c3452fc..6a7205b 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -112,9 +112,12 @@ let MapAPRSMap = { typeof lat === "number" && typeof lng === "number" && typeof zoom === "number" && - lat >= -90 && lat <= 90 && - lng >= -180 && lng <= 180 && - zoom >= 1 && zoom <= 20 + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180 && + zoom >= 1 && + zoom <= 20 ) { initialCenter = { lat, lng }; initialZoom = zoom; @@ -136,8 +139,10 @@ let MapAPRSMap = { !initialCenter || typeof initialCenter.lat !== "number" || typeof initialCenter.lng !== "number" - ) throw new Error("Invalid center data"); - if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) throw new Error("Invalid zoom data"); + ) + throw new Error("Invalid center data"); + if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) + throw new Error("Invalid zoom data"); } catch (error) { console.error("Error parsing map data attributes:", error); initialCenter = { lat: 39.8283, lng: -98.5795 }; @@ -283,7 +288,7 @@ let MapAPRSMap = { const zoom = self.map.getZoom(); localStorage.setItem( "aprs_map_state", - JSON.stringify({ lat: center.lat, lng: center.lng, zoom }) + JSON.stringify({ lat: center.lat, lng: center.lng, zoom }), ); }, 300); }); @@ -307,7 +312,7 @@ let MapAPRSMap = { const zoom = self.map.getZoom(); localStorage.setItem( "aprs_map_state", - JSON.stringify({ lat: center.lat, lng: center.lng, zoom }) + JSON.stringify({ lat: center.lat, lng: center.lng, zoom }), ); }, 300); }); @@ -528,6 +533,19 @@ let MapAPRSMap = { }); }); + // Handle bulk loading of historical packets + self.handleEvent("add_historical_packets", (data: { packets: MarkerData[] }) => { + if (data.packets && Array.isArray(data.packets)) { + data.packets.forEach((packet) => { + self.addMarker({ + ...packet, + historical: true, + popup: self.buildPopupContent(packet), + }); + }); + } + }); + // Handle refresh markers event self.handleEvent("refresh_markers", () => { // Remove markers that are outside current bounds @@ -658,7 +676,7 @@ let MapAPRSMap = { id: data.id, callsign: data.callsign, lat: lat, - lng: lng + lng: lng, }); }); diff --git a/config/test.exs b/config/test.exs index 014ff9a..af34913 100644 --- a/config/test.exs +++ b/config/test.exs @@ -14,7 +14,7 @@ config :aprs, Aprs.Repo, hostname: "localhost", database: "aprs_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, - pool_size: 10, + pool_size: System.schedulers_online() * 2, types: Aprs.PostgresTypes # We don't run a server during test. If one is required, @@ -27,6 +27,9 @@ config :aprs, AprsWeb.Endpoint, # Disable Oban during tests to prevent background job execution config :aprs, Oban, testing: :inline +# Configure the packets module to use the mock in tests +config :aprs, :packets_module, Aprs.PacketsMock + # Disable APRS-IS external connections in test environment config :aprs, aprs_is_server: "mock.aprs.test", diff --git a/lib/aprs/packet_replay_behaviour.ex b/lib/aprs/packet_replay_behaviour.ex new file mode 100644 index 0000000..6cdf341 --- /dev/null +++ b/lib/aprs/packet_replay_behaviour.ex @@ -0,0 +1,13 @@ +defmodule Aprs.PacketReplayBehaviour do + @moduledoc """ + Behavior definition for the PacketReplay module. + This allows us to mock the PacketReplay module in tests. + """ + + @callback start_replay(pid(), list(Aprs.Packet.t()), keyword()) :: {:ok, reference()} | {:error, term()} + @callback stop_replay(reference()) :: :ok + @callback pause_replay(reference()) :: :ok | {:error, :not_found} + @callback resume_replay(reference()) :: :ok | {:error, :not_found} + @callback get_replay_status(reference()) :: {:ok, map()} | {:error, :not_found} + @callback adjust_replay_speed(reference(), float()) :: :ok | {:error, :not_found} +end diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index cde5c43..56eb43c 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -3,6 +3,8 @@ defmodule Aprs.Packets do The Packets context. """ + @behaviour Aprs.PacketsBehaviour + import Ecto.Query, warn: false alias Aprs.BadPacket diff --git a/lib/aprs/packets_behaviour.ex b/lib/aprs/packets_behaviour.ex new file mode 100644 index 0000000..77a66e8 --- /dev/null +++ b/lib/aprs/packets_behaviour.ex @@ -0,0 +1,11 @@ +defmodule Aprs.PacketsBehaviour do + @moduledoc """ + Behavior definition for the Packets context. + This allows us to mock the Packets module in tests. + """ + + @callback get_packets_for_replay(map()) :: [Aprs.Packet.t()] + @callback get_recent_packets(map()) :: [Aprs.Packet.t()] + @callback get_historical_packet_count(map()) :: non_neg_integer() + @callback stream_packets_for_replay(map()) :: Enumerable.t() +end diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 09910b2..1c41647 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -12,7 +12,6 @@ defmodule AprsWeb.MapLive.Index do @default_center %{lat: 39.8283, lng: -98.5795} @default_zoom 5 - @default_replay_speed 25.0 @finch_name Aprs.Finch @impl true @@ -46,18 +45,10 @@ defmodule AprsWeb.MapLive.Index do }, map_center: @default_center, map_zoom: @default_zoom, - replay_active: false, - replay_speed: @default_replay_speed, - replay_paused: false, - replay_packets: [], - replay_index: 0, - replay_timer_ref: nil, - replay_start_time: nil, - replay_end_time: nil, historical_packets: %{}, packet_age_threshold: one_hour_ago, map_ready: false, - replay_started: false, + historical_loaded: false, pending_geolocation: nil, bounds_update_timer: nil, pending_bounds: nil, @@ -158,59 +149,6 @@ defmodule AprsWeb.MapLive.Index do {:noreply, socket} end - @impl true - def handle_event("toggle_replay", _params, socket) do - if socket.assigns.replay_active do - # Stop replay - if socket.assigns.replay_timer_ref, - do: Process.cancel_timer(socket.assigns.replay_timer_ref) - - # Clear historical packets from the map - socket = - socket - |> push_event("clear_historical_packets", %{}) - |> assign( - replay_active: false, - replay_timer_ref: nil, - replay_paused: false, - replay_packets: [], - replay_index: 0, - historical_packets: %{}, - replay_started: false - ) - - {:noreply, socket} - else - # If not active, the user manually requested a replay restart - {:noreply, start_historical_replay(socket)} - end - end - - @impl true - def handle_event("pause_replay", _params, socket) do - if socket.assigns.replay_active do - if socket.assigns.replay_paused do - # Resume replay - timer_ref = Process.send_after(self(), :replay_next_packet, 1000) - {:noreply, assign(socket, replay_paused: false, replay_timer_ref: timer_ref)} - else - # Pause replay - if socket.assigns.replay_timer_ref, - do: Process.cancel_timer(socket.assigns.replay_timer_ref) - - {:noreply, assign(socket, replay_paused: true, replay_timer_ref: nil)} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do - speed_float = to_float(speed) - {:noreply, assign(socket, replay_speed: speed_float)} - end - @impl true def handle_event("clear_and_reload_markers", _params, socket) do # Only filter the current visible_packets, do not re-query the database @@ -333,7 +271,7 @@ defmodule AprsWeb.MapLive.Index do def handle_info({:ip_location, %{lat: lat, lng: lng}}, socket), do: handle_info_ip_location(lat, lng, socket) def handle_info(:initialize_replay, socket), do: handle_info_initialize_replay(socket) - def handle_info(:replay_next_packet, socket), do: handle_replay_next_packet(socket) + def handle_info(:cleanup_old_packets, socket), do: handle_cleanup_old_packets(socket) def handle_info({:postgres_packet, packet}, socket), do: handle_info_postgres_packet(packet, socket) @@ -383,9 +321,9 @@ defmodule AprsWeb.MapLive.Index do end defp handle_info_initialize_replay(socket) do - if not socket.assigns.replay_started and socket.assigns.map_ready do + if not socket.assigns.historical_loaded and socket.assigns.map_ready do socket = start_historical_replay(socket) - {:noreply, assign(socket, replay_started: true)} + {:noreply, socket} else {:noreply, socket} end @@ -448,67 +386,6 @@ defmodule AprsWeb.MapLive.Index do end # Handle replaying the next historical packet - defp handle_replay_next_packet(socket) do - %{ - replay_packets: packets, - replay_index: index, - replay_speed: speed, - historical_packets: historical_packets - } = socket.assigns - - if index < length(packets) do - handle_next_replay_packet(socket, packets, index, speed, historical_packets) - else - handle_replay_end(socket) - end - end - - defp handle_next_replay_packet(socket, packets, index, speed, historical_packets) do - packet = Enum.at(packets, index) - next_index = index + 1 - packet_data = build_packet_data(packet) - - socket = - if packet_data do - packet_data = - Map.merge(packet_data, %{ - "is_historical" => true, - "timestamp" => - if(Map.has_key?(packet, :received_at), - do: DateTime.to_iso8601(packet.received_at) - ) - }) - - packet_id = - "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}" - - new_historical_packets = Map.put(historical_packets, packet_id, packet) - - socket - |> push_event("historical_packet", packet_data) - |> assign( - replay_index: next_index, - historical_packets: new_historical_packets - ) - else - assign(socket, replay_index: next_index) - end - - delay_ms = trunc(1000 / speed) - timer_ref = Process.send_after(self(), :replay_next_packet, delay_ms) - {:noreply, assign(socket, replay_timer_ref: timer_ref)} - end - - defp handle_replay_end(socket) do - socket = - assign(socket, - replay_active: false, - replay_timer_ref: nil, - replay_started: false - ) - - {:noreply, socket} - end @impl true def render(assigns) do @@ -693,8 +570,8 @@ defmodule AprsWeb.MapLive.Index do # Helper function to start historical replay @spec start_historical_replay(Socket.t()) :: Socket.t() defp start_historical_replay(socket) do - # Only fetch historical packets if map is ready and not already replaying - if socket.assigns.map_ready and not socket.assigns.replay_active do + # Only fetch historical packets if map is ready + if socket.assigns.map_ready do # Get time range for historical data now = DateTime.utc_now() one_hour_ago = DateTime.add(now, -60 * 60, :second) @@ -711,24 +588,56 @@ defmodule AprsWeb.MapLive.Index do historical_packets = fetch_historical_packets(bounds, one_hour_ago, now) if Enum.empty?(historical_packets) do - # No historical packets found - silently continue without replay + # No historical packets found - silently continue socket else # Clear any previous historical packets from the map socket = push_event(socket, "clear_historical_packets", %{}) - # Start replay - timer_ref = Process.send_after(self(), :replay_next_packet, 1000) + # Group packets by callsign and get only the most recent for each + latest_packets_by_callsign = + historical_packets + |> Enum.group_by(&generate_callsign/1) + |> Enum.map(fn {_callsign, packets} -> + # Sort by received_at descending and take the most recent + Enum.max_by(packets, & &1.received_at, DateTime) + end) + + # Build packet data for all latest packets + packet_data_list = + latest_packets_by_callsign + |> Enum.map(fn packet -> + case build_packet_data(packet) do + nil -> + nil + + packet_data -> + # Generate a unique ID for this historical packet + packet_id = + "hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}" + + packet_data + |> Map.put("id", packet_id) + |> Map.put("is_historical", true) + end + end) + # Remove nil values + |> Enum.filter(& &1) + + # Send all historical packets at once + socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list}) + + # Store historical packets in assigns for reference + historical_packets_map = + packet_data_list + |> Enum.zip(latest_packets_by_callsign) + |> Enum.reduce(%{}, fn {packet_data, packet}, acc -> + Map.put(acc, packet_data["id"], packet) + end) assign(socket, - replay_active: true, - replay_packets: historical_packets, - replay_index: 0, - replay_timer_ref: timer_ref, - replay_start_time: one_hour_ago, - replay_end_time: now, - historical_packets: %{}, - replay_started: true + historical_packets: historical_packets_map, + historical_loaded: true ) end else @@ -758,7 +667,8 @@ defmodule AprsWeb.MapLive.Index do } # Call the database through the Packets context - packets = Aprs.Packets.get_packets_for_replay(packets_params) + packets_module = Application.get_env(:aprs, :packets_module, Aprs.Packets) + packets = packets_module.get_packets_for_replay(packets_params) # Sort packets by received_at timestamp to ensure chronological replay Enum.sort_by(packets, fn packet -> packet.received_at end) @@ -1001,19 +911,6 @@ defmodule AprsWeb.MapLive.Index do defp send_default_ip_location, do: send(self(), {:ip_location, @default_center}) - # Helper function to convert string or float to float - @spec to_float(number() | String.t()) :: float() - defp to_float(value) when is_float(value), do: value - defp to_float(value) when is_integer(value), do: value * 1.0 - - @spec to_float(String.t()) :: float() - defp to_float(value) when is_binary(value) do - case Float.parse(value) do - {float_val, _} -> float_val - :error -> 0.0 - end - end - @impl true def terminate(_reason, socket) do if socket.assigns.buffer_timer, do: Process.cancel_timer(socket.assigns.buffer_timer) @@ -1022,11 +919,6 @@ defmodule AprsWeb.MapLive.Index do Process.cancel_timer(socket.assigns.bounds_update_timer) end - # Clean up replay timer - if socket.assigns[:replay_timer_ref] do - Process.cancel_timer(socket.assigns.replay_timer_ref) - end - :ok end diff --git a/mix.exs b/mix.exs index 87b12bc..b1255d7 100644 --- a/mix.exs +++ b/mix.exs @@ -76,6 +76,7 @@ defmodule Aprs.MixProject do {:mix_test_watch, "~> 1.1", only: [:dev, :test]}, {:sobelow, "~> 0.8", only: :dev}, {:stream_data, "~> 1.2.0", only: [:dev, :test]}, + {:mox, "~> 1.2", only: :test}, {:styler, "~> 1.4.2", only: [:dev, :test], runtime: false} ] end diff --git a/mix.lock b/mix.lock index 407374d..54924da 100644 --- a/mix.lock +++ b/mix.lock @@ -47,7 +47,9 @@ "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, diff --git a/test/aprs_web/integration/aprs_status_test.exs b/test/aprs_web/integration/aprs_status_test.exs index c11f2ed..044dafc 100644 --- a/test/aprs_web/integration/aprs_status_test.exs +++ b/test/aprs_web/integration/aprs_status_test.exs @@ -110,9 +110,7 @@ defmodule AprsWeb.Integration.AprsStatusTest do # Test map ready event assert render_hook(view, "map_ready", %{}) - # Test replay speed adjustment if available - speed_params = %{"speed" => "1.0"} - render_hook(view, "adjust_replay_speed", speed_params) + # Replay functionality has been removed - historical packets are now loaded all at once end test "real-time updates are disabled without APRS connection", %{conn: conn} do diff --git a/test/aprs_web/live/map_live/index_test.exs b/test/aprs_web/live/map_live/index_test.exs index b395ecd..641e519 100644 --- a/test/aprs_web/live/map_live/index_test.exs +++ b/test/aprs_web/live/map_live/index_test.exs @@ -64,36 +64,24 @@ defmodule AprsWeb.MapLive.IndexTest do assert render_hook(view, "bounds_changed", bounds_params) end - test "handles adjust_replay_speed event with float values", %{conn: conn} do + test "loads historical packets when map is ready", %{conn: conn} do {:ok, view, _html} = live(conn, "/") - # Test with float speed value - speed_params = %{"speed" => 1.5} - assert render_hook(view, "adjust_replay_speed", speed_params) + # Simulate map initialization which should trigger historical packet loading + assert render_hook(view, "map_ready", %{}) + + # Wait for the initialize_replay message to be processed + Process.sleep(600) + + # The view should still be rendering without errors after loading historical packets + assert render(view) =~ "aprs-map" end - test "handles adjust_replay_speed event with string values", %{conn: conn} do + test "handles clear_and_reload_markers event", %{conn: conn} do {:ok, view, _html} = live(conn, "/") - # Test with string speed value - speed_params = %{"speed" => "2.0"} - assert render_hook(view, "adjust_replay_speed", speed_params) - end - - test "handles adjust_replay_speed event with integer values", %{conn: conn} do - {:ok, view, _html} = live(conn, "/") - - # Test with integer speed value - speed_params = %{"speed" => 3} - assert render_hook(view, "adjust_replay_speed", speed_params) - end - - test "handles adjust_replay_speed event with integer string values", %{conn: conn} do - {:ok, view, _html} = live(conn, "/") - - # Test with integer string value (should work with improved Float.parse) - speed_params = %{"speed" => "1"} - assert render_hook(view, "adjust_replay_speed", speed_params) + # This should not crash and should work without replay functionality + assert render_hook(view, "clear_and_reload_markers", %{}) end test "sets correct page title", %{conn: conn} do diff --git a/test/integration/historical_packets_test.exs b/test/integration/historical_packets_test.exs new file mode 100644 index 0000000..d05e95f --- /dev/null +++ b/test/integration/historical_packets_test.exs @@ -0,0 +1,266 @@ +defmodule Aprs.Integration.HistoricalPacketsTest do + use AprsWeb.ConnCase + + import Phoenix.LiveViewTest + + @moduletag :skip_packets_mock + + describe "historical packet loading" do + setup do + # Create test packets with different timestamps + now = DateTime.utc_now() + one_hour_ago = DateTime.add(now, -3600, :second) + thirty_minutes_ago = DateTime.add(now, -1800, :second) + ten_minutes_ago = DateTime.add(now, -600, :second) + + # Mock packet data for the same callsign at different times + packet1 = %Aprs.Packet{ + id: 1, + base_callsign: "TEST1", + ssid: 0, + has_position: true, + lat: 39.8, + lon: -98.5, + received_at: one_hour_ago, + data_extended: %{ + symbol_table_id: "/", + symbol_code: ">", + comment: "Old position" + } + } + + packet2 = %Aprs.Packet{ + id: 2, + base_callsign: "TEST1", + ssid: 0, + has_position: true, + lat: 39.9, + lon: -98.4, + received_at: thirty_minutes_ago, + data_extended: %{ + symbol_table_id: "/", + symbol_code: ">", + comment: "Middle position" + } + } + + packet3 = %Aprs.Packet{ + id: 3, + base_callsign: "TEST1", + ssid: 0, + has_position: true, + lat: 40.0, + lon: -98.3, + received_at: ten_minutes_ago, + data_extended: %{ + symbol_table_id: "/", + symbol_code: ">", + comment: "Latest position" + } + } + + # Mock packet for different callsign + packet4 = %Aprs.Packet{ + id: 4, + base_callsign: "TEST2", + ssid: 0, + has_position: true, + lat: 38.8, + lon: -97.5, + received_at: thirty_minutes_ago, + data_extended: %{ + symbol_table_id: "/", + symbol_code: "k", + comment: "Different station" + } + } + + {:ok, packets: [packet1, packet2, packet3, packet4]} + end + + test "loads all historical packets at once when map is ready", %{conn: conn, packets: mock_packets} do + # Mock the Packets.get_packets_for_replay function + expect_packets_for_replay_with_bounds(mock_packets) + + {:ok, view, _html} = live(conn, "/") + + # Set map bounds that include our test packets + bounds_params = %{ + "bounds" => %{ + "north" => "45.0", + "south" => "35.0", + "east" => "-90.0", + "west" => "-105.0" + } + } + + # Update bounds first + render_hook(view, "bounds_changed", bounds_params) + + # Trigger map ready event + render_hook(view, "map_ready", %{}) + + # Wait for the historical packet loading to complete + Process.sleep(700) + + # The LiveView should have pushed an event with historical packets + # Note: In real implementation, we'd need to verify the push_event was called + # For now, we verify the mock was called correctly + verify!() + end + + test "only loads packets within map bounds", %{conn: conn, packets: mock_packets} do + # Mock to return only packets within the specified bounds + # TEST2 is at lat: 38.8, lon: -97.5, which is outside these bounds + test1_packets = Enum.filter(mock_packets, fn packet -> packet.base_callsign == "TEST1" end) + expect_packets_for_replay_with_bounds(test1_packets) + + {:ok, view, _html} = live(conn, "/") + + # Set map bounds that exclude TEST2 + bounds_params = %{ + "bounds" => %{ + "north" => "41.0", + "south" => "39.0", + "east" => "-98.0", + "west" => "-99.0" + } + } + + # Update bounds first + render_hook(view, "bounds_changed", bounds_params) + + # Trigger map ready event + render_hook(view, "map_ready", %{}) + + # Wait for the historical packet loading to complete + Process.sleep(700) + + verify!() + end + + test "handles empty historical packets gracefully", %{conn: conn} do + # Mock to return empty list + expect_packets_for_replay([]) + + {:ok, view, _html} = live(conn, "/") + + # Trigger map ready event + render_hook(view, "map_ready", %{}) + + # Wait for the historical packet loading attempt + Process.sleep(700) + + # Should not crash + verify!() + end + + test "clears historical packets when requested", %{conn: conn, packets: mock_packets} do + expect_packets_for_replay(mock_packets) + + {:ok, view, _html} = live(conn, "/") + + # First load historical packets + render_hook(view, "map_ready", %{}) + Process.sleep(700) + + # Clear and reload markers + render_hook(view, "clear_and_reload_markers", %{}) + + verify!() + end + + test "handles locate_me event after historical packets are loaded", %{conn: conn, packets: mock_packets} do + expect_packets_for_replay(mock_packets) + + {:ok, view, _html} = live(conn, "/") + + # Load historical packets first + render_hook(view, "map_ready", %{}) + Process.sleep(700) + + # Request location + render_hook(view, "locate_me", %{}) + + verify!() + end + end + + describe "live packet updates with historical data" do + setup do + # Create a historical packet + now = DateTime.utc_now() + thirty_minutes_ago = DateTime.add(now, -1800, :second) + + historical_packet = %Aprs.Packet{ + id: 100, + base_callsign: "LIVE1", + ssid: 0, + has_position: true, + lat: 39.8, + lon: -98.5, + received_at: thirty_minutes_ago, + data_extended: %{ + symbol_table_id: "/", + symbol_code: ">", + comment: "Old position" + } + } + + {:ok, historical_packet: historical_packet} + end + + test "new live packet updates marker for same callsign", %{conn: conn, historical_packet: historical_packet} do + expect_packets_for_replay([historical_packet]) + + {:ok, view, _html} = live(conn, "/") + + # Set bounds and load historical packets + bounds_params = %{ + "bounds" => %{ + "north" => "45.0", + "south" => "35.0", + "east" => "-90.0", + "west" => "-105.0" + } + } + + render_hook(view, "bounds_changed", bounds_params) + render_hook(view, "map_ready", %{}) + Process.sleep(700) + + # Simulate a new live packet for the same callsign + new_packet = %{ + id: "LIVE1", + callsign: "LIVE1", + base_callsign: "LIVE1", + ssid: 0, + lat: 39.9, + lng: -98.4, + data_type: "position", + path: "WIDE1-1,WIDE2-1", + comment: "New live position", + data_extended: %{ + symbol_table_id: "/", + symbol_code: ">" + }, + symbol_table_id: "/", + symbol_code: ">", + timestamp: DateTime.to_iso8601(DateTime.utc_now()), + popup: "
New live position
" + } + + # Send the new packet event + send(view.pid, %Phoenix.Socket.Broadcast{ + topic: "aprs_messages", + event: "packet", + payload: new_packet + }) + + # Give the LiveView time to process the message + Process.sleep(100) + + verify!() + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 5943ddd..9a6dea6 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -22,17 +22,28 @@ defmodule AprsWeb.ConnCase do # The default endpoint for testing use AprsWeb, :verified_routes + import Aprs.MockHelpers + # Import conveniences for testing with connections import AprsWeb.ConnCase + import Mox import Phoenix.ConnTest import Plug.Conn @endpoint AprsWeb.Endpoint + + setup :verify_on_exit! end end setup tags do Aprs.DataCase.setup_sandbox(tags) + + # Stub PacketsMock by default for tests that use MapLive but don't test historical packets + if !tags[:skip_packets_mock] do + Aprs.MockHelpers.stub_packets_mock() + end + {:ok, conn: Phoenix.ConnTest.build_conn()} end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 37db1c5..70f84fb 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -24,8 +24,11 @@ defmodule Aprs.DataCase do import Ecto import Ecto.Changeset import Ecto.Query + import Mox alias Aprs.Repo + + setup :verify_on_exit! end end diff --git a/test/support/mocks.ex b/test/support/mocks.ex new file mode 100644 index 0000000..77fbc3c --- /dev/null +++ b/test/support/mocks.ex @@ -0,0 +1,44 @@ +# Mocks are defined in test_helper.exs +# This file can be used for mock implementations or test utilities + +defmodule Aprs.MockHelpers do + @moduledoc """ + Helper functions for setting up mocks in tests + """ + + import Mox + + @doc """ + Stubs the PacketsMock to return an empty list for get_packets_for_replay. + Use this in tests that don't care about historical packets but use MapLive. + """ + def stub_packets_mock do + stub(Aprs.PacketsMock, :get_packets_for_replay, fn _opts -> [] end) + end + + @doc """ + Sets up an expectation for PacketsMock with custom return value. + """ + def expect_packets_for_replay(packets) do + expect(Aprs.PacketsMock, :get_packets_for_replay, fn _opts -> packets end) + end + + @doc """ + Sets up an expectation for PacketsMock with filtering based on bounds. + """ + def expect_packets_for_replay_with_bounds(packets) do + expect(Aprs.PacketsMock, :get_packets_for_replay, fn opts -> + case opts[:bounds] do + [west, south, east, north] -> + Enum.filter(packets, fn packet -> + packet.has_position && + packet.lat >= south && packet.lat <= north && + packet.lon >= west && packet.lon <= east + end) + + _ -> + packets + end + end) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index d8f9d73..5e49136 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,6 +1,10 @@ ExUnit.start() Ecto.Adapters.SQL.Sandbox.mode(Aprs.Repo, :manual) +# Configure Mox +Mox.defmock(Aprs.PacketsMock, for: Aprs.PacketsBehaviour) +Mox.defmock(Aprs.PacketReplayMock, for: Aprs.PacketReplayBehaviour) + # Ensure no external APRS connections during tests Application.put_env(:aprs, :disable_aprs_connection, true) Application.put_env(:aprs, :aprs_is_server, "mock.aprs.test")