This commit is contained in:
Graham McIntire 2025-06-21 12:26:43 -05:00
parent f6f1ad1e2f
commit d6ac5cccfc
No known key found for this signature in database
15 changed files with 451 additions and 195 deletions

View file

@ -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,
});
});

View file

@ -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",

View file

@ -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

View file

@ -3,6 +3,8 @@ defmodule Aprs.Packets do
The Packets context.
"""
@behaviour Aprs.PacketsBehaviour
import Ecto.Query, warn: false
alias Aprs.BadPacket

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"},

View file

@ -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

View file

@ -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

View file

@ -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: "<div>New live position</div>"
}
# 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

View file

@ -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

View file

@ -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

44
test/support/mocks.ex Normal file
View file

@ -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

View file

@ -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")