Historical map loading previously fetched all 73 columns (SELECT *) from the packets table (~2068 bytes/row). Added get_recent_packets_for_map/1 with select_map_fields/1 that fetches only the 22 columns needed for map rendering (~200 bytes/row), yielding a ~3x query speedup. Also removes unused all_packets tracking from PacketProcessor and simplifies visible_packets cleanup in Index.
1036 lines
30 KiB
Elixir
1036 lines
30 KiB
Elixir
defmodule Aprsme.PacketsTest do
|
|
use Aprsme.DataCase, async: true
|
|
|
|
alias Aprsme.BadPacket
|
|
alias Aprsme.Packet
|
|
alias Aprsme.Packets
|
|
alias Aprsme.PacketsFixtures
|
|
|
|
describe "store_packet/1" do
|
|
test "stores a valid packet" do
|
|
packet_data = %{
|
|
sender: "TEST1",
|
|
base_callsign: "TEST1",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
raw_packet: "TEST1>APRS,WIDE1-1:=3950.00N/09830.00W>Test packet",
|
|
lat: 39.833333,
|
|
lon: -98.5,
|
|
comment: "Test packet",
|
|
data_type: "position_without_timestamp_with_messaging",
|
|
symbol_table_id: "/",
|
|
symbol_code: ">"
|
|
}
|
|
|
|
assert {:ok, packet} = Packets.store_packet(packet_data)
|
|
assert packet.sender == "TEST1"
|
|
# Handle the fact that lat/lon may be stored as Decimal
|
|
lat_value = if is_struct(packet.lat, Decimal), do: Decimal.to_float(packet.lat), else: packet.lat
|
|
lon_value = if is_struct(packet.lon, Decimal), do: Decimal.to_float(packet.lon), else: packet.lon
|
|
assert Float.round(lat_value, 4) == 39.8333
|
|
assert Float.round(lon_value, 1) == -98.5
|
|
assert packet.comment == "Test packet"
|
|
end
|
|
|
|
test "stores packet with data_extended containing position" do
|
|
packet_data = %{
|
|
sender: "TEST2",
|
|
base_callsign: "TEST2",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
raw_packet: "TEST2>APRS:!3950.00N/09830.00W>",
|
|
data_type: "position",
|
|
data_extended: %{
|
|
latitude: 39.833333,
|
|
longitude: -98.5,
|
|
symbol_table_id: "/",
|
|
symbol_code: ">"
|
|
}
|
|
}
|
|
|
|
assert {:ok, packet} = Packets.store_packet(packet_data)
|
|
lat_value = if is_struct(packet.lat, Decimal), do: Decimal.to_float(packet.lat), else: packet.lat
|
|
lon_value = if is_struct(packet.lon, Decimal), do: Decimal.to_float(packet.lon), else: packet.lon
|
|
assert Float.round(lat_value, 4) == 39.8333
|
|
assert Float.round(lon_value, 1) == -98.5
|
|
end
|
|
|
|
test "stores packet with MicE data" do
|
|
packet_data = %{
|
|
sender: "TEST3",
|
|
base_callsign: "TEST3",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "",
|
|
raw_packet: "TEST3>APRS:`123abc",
|
|
data_type: "mic_e",
|
|
data_extended: %{
|
|
__struct__: Aprs.Types.MicE,
|
|
lat_degrees: 39,
|
|
lat_minutes: 50,
|
|
lat_direction: :north,
|
|
lon_degrees: 98,
|
|
lon_minutes: 30,
|
|
lon_direction: :west
|
|
}
|
|
}
|
|
|
|
assert {:ok, packet} = Packets.store_packet(packet_data)
|
|
# MicE calculation: 39 + 50/60 = 39.833333
|
|
lat_value = if is_struct(packet.lat, Decimal), do: Decimal.to_float(packet.lat), else: packet.lat
|
|
lon_value = if is_struct(packet.lon, Decimal), do: Decimal.to_float(packet.lon), else: packet.lon
|
|
assert Float.round(lat_value, 4) == 39.8333
|
|
assert Float.round(lon_value, 1) == -98.5
|
|
end
|
|
|
|
test "handles validation errors gracefully" do
|
|
# Missing required fields
|
|
packet_data = %{
|
|
raw_packet: "INVALID>PACKET"
|
|
}
|
|
|
|
assert {:error, :validation_error} = Packets.store_packet(packet_data)
|
|
|
|
# Verify bad packet was stored
|
|
assert [bad_packet] = Repo.all(BadPacket)
|
|
assert bad_packet.raw_packet == "INVALID>PACKET"
|
|
assert bad_packet.error_type == "ValidationError"
|
|
end
|
|
|
|
test "handles invalid data types gracefully" do
|
|
# Create invalid data with wrong type for data_extended
|
|
packet_data = %{
|
|
sender: "TEST4",
|
|
destination: "APRS",
|
|
raw_packet: "TEST4>APRS:Invalid",
|
|
# Invalid: data_extended should be a map, not a string
|
|
data_extended: "invalid_data_extended"
|
|
}
|
|
|
|
# Should return validation error due to missing required fields
|
|
assert {:error, :validation_error} = Packets.store_packet(packet_data)
|
|
end
|
|
|
|
test "normalizes SSID to string" do
|
|
packet_data = %{
|
|
sender: "TEST5",
|
|
base_callsign: "TEST5",
|
|
ssid: 9,
|
|
destination: "APRS",
|
|
raw_packet: "TEST5-9>APRS:=3950.00N/09830.00W>",
|
|
data_type: "position",
|
|
lat: 39.833333,
|
|
lon: -98.5
|
|
}
|
|
|
|
assert {:ok, packet} = Packets.store_packet(packet_data)
|
|
assert packet.ssid == "9"
|
|
end
|
|
|
|
test "sets received_at timestamp" do
|
|
packet_data = %{
|
|
sender: "TEST6",
|
|
base_callsign: "TEST6",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
raw_packet: "TEST6>APRS:=3950.00N/09830.00W>",
|
|
data_type: "position",
|
|
lat: 39.833333,
|
|
lon: -98.5
|
|
}
|
|
|
|
assert {:ok, packet} = Packets.store_packet(packet_data)
|
|
|
|
# Just verify that received_at was set
|
|
assert packet.received_at
|
|
assert %DateTime{} = packet.received_at
|
|
end
|
|
|
|
test "sanitizes binary data in packets" do
|
|
packet_data = %{
|
|
sender: "TEST7",
|
|
base_callsign: "TEST7",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
raw_packet: "TEST7>APRS:Test with null byte",
|
|
comment: "Comment with null",
|
|
data_type: "comment",
|
|
lat: 39.833333,
|
|
lon: -98.5
|
|
}
|
|
|
|
assert {:ok, packet} = Packets.store_packet(packet_data)
|
|
# Verify the packet was stored successfully
|
|
assert packet.sender == "TEST7"
|
|
end
|
|
end
|
|
|
|
describe "store_bad_packet/2" do
|
|
test "stores bad packet from string" do
|
|
error = %{message: "Parse error", type: "ParseError"}
|
|
|
|
assert {:ok, bad_packet} = Packets.store_bad_packet("INVALID>PACKET", error)
|
|
assert bad_packet.raw_packet == "INVALID>PACKET"
|
|
assert bad_packet.error_message == "Parse error"
|
|
assert bad_packet.error_type == "ParseError"
|
|
end
|
|
|
|
test "stores bad packet from map" do
|
|
packet_data = %{
|
|
raw_packet: "TEST>APRS:Invalid",
|
|
sender: "TEST"
|
|
}
|
|
|
|
error = %{message: "Validation failed", type: "ValidationError"}
|
|
|
|
assert {:ok, bad_packet} = Packets.store_bad_packet(packet_data, error)
|
|
assert bad_packet.raw_packet == "TEST>APRS:Invalid"
|
|
assert bad_packet.error_message == "Validation failed"
|
|
end
|
|
|
|
test "handles exception objects" do
|
|
packet_data = %{raw_packet: "TEST>APRS:Invalid"}
|
|
error = %RuntimeError{message: "Something went wrong"}
|
|
|
|
assert {:ok, bad_packet} = Packets.store_bad_packet(packet_data, error)
|
|
assert bad_packet.error_type == "RuntimeError"
|
|
assert bad_packet.error_message == "Something went wrong"
|
|
end
|
|
end
|
|
|
|
describe "get_recent_packets/1" do
|
|
setup do
|
|
# Create packets with different timestamps
|
|
now = DateTime.utc_now()
|
|
|
|
packets = [
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "OLD1",
|
|
received_at: DateTime.add(now, -48 * 3600, :second),
|
|
lat: 39.8,
|
|
lon: -98.5
|
|
}),
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "RECENT1",
|
|
received_at: DateTime.add(now, -12 * 3600, :second),
|
|
lat: 39.9,
|
|
lon: -98.4
|
|
}),
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "RECENT2",
|
|
received_at: DateTime.add(now, -6 * 3600, :second),
|
|
lat: 40.0,
|
|
lon: -98.3
|
|
}),
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "NOW1",
|
|
received_at: now,
|
|
lat: 40.1,
|
|
lon: -98.2
|
|
})
|
|
]
|
|
|
|
{:ok, packets: packets}
|
|
end
|
|
|
|
test "returns packets from last 24 hours by default", %{packets: _packets} do
|
|
results = Packets.get_recent_packets()
|
|
|
|
# Should include RECENT1, RECENT2, and NOW1, but not OLD1
|
|
callsigns = Enum.map(results, & &1.sender)
|
|
assert "RECENT1" in callsigns
|
|
assert "RECENT2" in callsigns
|
|
assert "NOW1" in callsigns
|
|
refute "OLD1" in callsigns
|
|
end
|
|
|
|
test "respects hours_back parameter", %{packets: _packets} do
|
|
results = Packets.get_recent_packets(%{hours_back: 8})
|
|
|
|
# Should only include RECENT2 and NOW1
|
|
callsigns = Enum.map(results, & &1.sender)
|
|
refute "RECENT1" in callsigns
|
|
assert "RECENT2" in callsigns
|
|
assert "NOW1" in callsigns
|
|
end
|
|
|
|
test "respects limit parameter", %{packets: _packets} do
|
|
results = Packets.get_recent_packets(%{limit: 2})
|
|
assert length(results) == 2
|
|
end
|
|
|
|
test "filters by bounds" do
|
|
# Create packets inside and outside bounds
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "INSIDE_BOUNDS",
|
|
lat: 39.0,
|
|
lon: -98.0,
|
|
received_at: DateTime.utc_now()
|
|
})
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "OUTSIDE_BOUNDS",
|
|
lat: 41.0,
|
|
lon: -100.0,
|
|
received_at: DateTime.utc_now()
|
|
})
|
|
|
|
# Bounds format: [west, south, east, north]
|
|
bounds = [-99.0, 38.0, -97.0, 40.0]
|
|
|
|
results = Packets.get_recent_packets(%{bounds: bounds})
|
|
callsigns = Enum.map(results, & &1.sender)
|
|
|
|
assert "INSIDE_BOUNDS" in callsigns
|
|
refute "OUTSIDE_BOUNDS" in callsigns
|
|
end
|
|
|
|
test "filters by callsign" do
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "TARGET",
|
|
lat: 39.0,
|
|
lon: -98.0
|
|
})
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "OTHER",
|
|
lat: 39.1,
|
|
lon: -98.1
|
|
})
|
|
|
|
results = Packets.get_recent_packets(%{callsign: "TARGET"})
|
|
assert length(results) == 1
|
|
assert hd(results).sender == "TARGET"
|
|
end
|
|
end
|
|
|
|
describe "get_recent_packets_for_map/1" do
|
|
setup do
|
|
now = DateTime.utc_now()
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "MAP1",
|
|
received_at: DateTime.add(now, -1800, :second),
|
|
lat: 39.8,
|
|
lon: -98.5,
|
|
comment: "test comment",
|
|
symbol_table_id: "/",
|
|
symbol_code: ">",
|
|
temperature: 72.0,
|
|
humidity: 45.0
|
|
})
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "MAP2",
|
|
received_at: DateTime.add(now, -3600, :second),
|
|
lat: 39.9,
|
|
lon: -98.4,
|
|
object_name: "TestObj"
|
|
})
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "OLD_MAP",
|
|
received_at: DateTime.add(now, -48 * 3600, :second),
|
|
lat: 40.0,
|
|
lon: -98.3
|
|
})
|
|
|
|
:ok
|
|
end
|
|
|
|
test "returns maps with only needed fields" do
|
|
results = Packets.get_recent_packets_for_map(%{hours_back: 24})
|
|
|
|
assert length(results) >= 2
|
|
first = hd(results)
|
|
|
|
# Should be a map, not a Packet struct
|
|
refute is_struct(first, Packet)
|
|
assert is_map(first)
|
|
|
|
# Should have the essential fields
|
|
assert Map.has_key?(first, :id)
|
|
assert Map.has_key?(first, :sender)
|
|
assert Map.has_key?(first, :lat)
|
|
assert Map.has_key?(first, :lon)
|
|
assert Map.has_key?(first, :received_at)
|
|
assert Map.has_key?(first, :symbol_table_id)
|
|
assert Map.has_key?(first, :symbol_code)
|
|
assert Map.has_key?(first, :comment)
|
|
assert Map.has_key?(first, :path)
|
|
assert Map.has_key?(first, :object_name)
|
|
assert Map.has_key?(first, :item_name)
|
|
|
|
# Should have weather fields
|
|
assert Map.has_key?(first, :temperature)
|
|
assert Map.has_key?(first, :humidity)
|
|
|
|
# Should NOT have heavy fields
|
|
refute Map.has_key?(first, :raw_packet)
|
|
refute Map.has_key?(first, :information_field)
|
|
refute Map.has_key?(first, :body)
|
|
refute Map.has_key?(first, :data_extended)
|
|
end
|
|
|
|
test "respects time filtering" do
|
|
results = Packets.get_recent_packets_for_map(%{hours_back: 1})
|
|
callsigns = Enum.map(results, & &1.sender)
|
|
|
|
assert "MAP1" in callsigns
|
|
refute "OLD_MAP" in callsigns
|
|
end
|
|
|
|
test "respects bounds filtering" do
|
|
bounds = [-99.0, 39.5, -98.0, 40.5]
|
|
results = Packets.get_recent_packets_for_map(%{hours_back: 24, bounds: bounds})
|
|
callsigns = Enum.map(results, & &1.sender)
|
|
|
|
assert "MAP1" in callsigns
|
|
refute "OLD_MAP" in callsigns
|
|
end
|
|
|
|
test "respects limit and offset" do
|
|
results = Packets.get_recent_packets_for_map(%{hours_back: 24, limit: 1})
|
|
assert length(results) == 1
|
|
end
|
|
|
|
test "filters by callsign" do
|
|
results = Packets.get_recent_packets_for_map(%{hours_back: 24, callsign: "MAP1"})
|
|
assert length(results) == 1
|
|
assert hd(results).sender == "MAP1"
|
|
end
|
|
|
|
test "includes object_name for APRS objects" do
|
|
results = Packets.get_recent_packets_for_map(%{hours_back: 24})
|
|
map2 = Enum.find(results, &(&1.sender == "MAP2"))
|
|
assert map2.object_name == "TestObj"
|
|
end
|
|
end
|
|
|
|
describe "get_nearby_stations/4" do
|
|
setup do
|
|
# Create stations at various distances
|
|
now = DateTime.utc_now()
|
|
|
|
stations = [
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "NEAR1",
|
|
base_callsign: "NEAR1",
|
|
lat: 39.01,
|
|
lon: -98.01,
|
|
received_at: now
|
|
}),
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "NEAR2",
|
|
base_callsign: "NEAR2",
|
|
lat: 39.02,
|
|
lon: -98.02,
|
|
received_at: now
|
|
}),
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "FAR1",
|
|
base_callsign: "FAR1",
|
|
lat: 40.0,
|
|
lon: -99.0,
|
|
received_at: now
|
|
})
|
|
]
|
|
|
|
{:ok, stations: stations}
|
|
end
|
|
|
|
test "returns stations ordered by distance", %{stations: _stations} do
|
|
results = Packets.get_nearby_stations(39.0, -98.0)
|
|
|
|
assert results != []
|
|
# Results should be ordered by distance
|
|
[first | _] = results
|
|
assert first.sender in ["NEAR1", "NEAR2"]
|
|
end
|
|
|
|
test "excludes specified callsign", %{stations: _stations} do
|
|
results = Packets.get_nearby_stations(39.0, -98.0, "NEAR1")
|
|
|
|
callsigns = Enum.map(results, & &1.sender)
|
|
refute "NEAR1" in callsigns
|
|
assert "NEAR2" in callsigns
|
|
end
|
|
|
|
test "respects limit option" do
|
|
results = Packets.get_nearby_stations(39.0, -98.0, nil, %{limit: 1})
|
|
assert length(results) == 1
|
|
end
|
|
end
|
|
|
|
describe "get_weather_packets/4" do
|
|
setup do
|
|
now = DateTime.utc_now()
|
|
start_time = DateTime.add(now, -24 * 3600, :second)
|
|
end_time = now
|
|
|
|
# Create weather packets
|
|
weather_packet1 =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "WX1",
|
|
temperature: 72.5,
|
|
humidity: 65,
|
|
wind_speed: 5,
|
|
received_at: DateTime.add(now, -12 * 3600, :second)
|
|
})
|
|
|
|
weather_packet2 =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "WX1",
|
|
temperature: 73.0,
|
|
humidity: 60,
|
|
wind_speed: 7,
|
|
received_at: DateTime.add(now, -6 * 3600, :second)
|
|
})
|
|
|
|
# Create non-weather packet
|
|
non_weather =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "WX1",
|
|
comment: "No weather data",
|
|
received_at: DateTime.add(now, -3 * 3600, :second)
|
|
})
|
|
|
|
{:ok,
|
|
weather_packets: [weather_packet1, weather_packet2],
|
|
non_weather: non_weather,
|
|
start_time: start_time,
|
|
end_time: end_time}
|
|
end
|
|
|
|
test "returns only weather packets for callsign", %{start_time: start_time, end_time: end_time} do
|
|
results = Packets.get_weather_packets("WX1", start_time, end_time)
|
|
|
|
# Should only include packets with weather data
|
|
assert length(results) == 2
|
|
|
|
assert Enum.all?(results, fn p ->
|
|
not is_nil(p.temperature) or not is_nil(p.humidity) or not is_nil(p.wind_speed)
|
|
end)
|
|
end
|
|
|
|
test "respects time range", %{end_time: end_time} do
|
|
# Query for only last 8 hours
|
|
start_time = DateTime.add(end_time, -8 * 3600, :second)
|
|
results = Packets.get_weather_packets("WX1", start_time, end_time)
|
|
|
|
# Should only include the most recent weather packet
|
|
assert length(results) == 1
|
|
assert hd(results).temperature == 73.0
|
|
end
|
|
end
|
|
|
|
describe "get_total_packet_count/0" do
|
|
test "returns count of all packets" do
|
|
# Just verify it returns a number
|
|
count = Packets.get_total_packet_count()
|
|
assert is_integer(count)
|
|
assert count >= 0
|
|
end
|
|
|
|
test "returns 0 when no packets exist" do
|
|
# Can't easily clear all packets in a test DB that might have other tests running
|
|
# Just verify the function works
|
|
count = Packets.get_total_packet_count()
|
|
assert is_integer(count)
|
|
end
|
|
end
|
|
|
|
describe "get_oldest_packet_timestamp/0" do
|
|
test "returns timestamp of oldest packet" do
|
|
# Create an old packet
|
|
now = DateTime.utc_now()
|
|
old_time = DateTime.add(now, -365 * 24 * 3600, :second)
|
|
|
|
PacketsFixtures.packet_fixture(%{sender: "OLD", received_at: old_time})
|
|
PacketsFixtures.packet_fixture(%{sender: "NEW", received_at: now})
|
|
|
|
oldest = Packets.get_oldest_packet_timestamp()
|
|
# Just verify we got a timestamp back
|
|
assert is_struct(oldest, DateTime)
|
|
end
|
|
|
|
test "returns nil when no packets exist" do
|
|
Repo.delete_all(Packet)
|
|
assert is_nil(Packets.get_oldest_packet_timestamp())
|
|
end
|
|
end
|
|
|
|
describe "clean_old_packets/0" do
|
|
test "deletes packets older than retention period" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Create old packet (400 days ago)
|
|
old_packet =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "OLD",
|
|
received_at: DateTime.add(now, -400 * 24 * 3600, :second)
|
|
})
|
|
|
|
# Create recent packet
|
|
recent_packet =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "RECENT",
|
|
received_at: now
|
|
})
|
|
|
|
# Run cleanup
|
|
deleted_count = Packets.clean_old_packets()
|
|
|
|
assert deleted_count == 1
|
|
assert is_nil(Repo.get(Packet, old_packet.id))
|
|
assert Repo.get(Packet, recent_packet.id)
|
|
end
|
|
end
|
|
|
|
describe "clean_packets_older_than/1" do
|
|
test "deletes packets older than specified days" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Create packets at different ages
|
|
old_packet =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "OLD",
|
|
received_at: DateTime.add(now, -10 * 24 * 3600, :second)
|
|
})
|
|
|
|
recent_packet =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "RECENT",
|
|
received_at: DateTime.add(now, -5 * 24 * 3600, :second)
|
|
})
|
|
|
|
# Clean packets older than 7 days
|
|
assert {:ok, 1} = Packets.clean_packets_older_than(7)
|
|
|
|
assert is_nil(Repo.get(Packet, old_packet.id))
|
|
assert Repo.get(Packet, recent_packet.id)
|
|
end
|
|
|
|
test "validates positive days parameter" do
|
|
# Should guard against invalid params
|
|
assert_raise FunctionClauseError, fn ->
|
|
Packets.clean_packets_older_than(0)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "get_latest_packet_for_callsign/1" do
|
|
test "returns most recent packet for callsign" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Create multiple packets for same callsign
|
|
_old =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "TEST1",
|
|
received_at: DateTime.add(now, -3600, :second),
|
|
comment: "Old"
|
|
})
|
|
|
|
recent =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "TEST1",
|
|
received_at: now,
|
|
comment: "Recent"
|
|
})
|
|
|
|
result = Packets.get_latest_packet_for_callsign("TEST1")
|
|
assert result.id == recent.id
|
|
assert result.comment == "Recent"
|
|
end
|
|
|
|
test "returns nil for non-existent callsign" do
|
|
assert is_nil(Packets.get_latest_packet_for_callsign("NONEXISTENT"))
|
|
end
|
|
end
|
|
|
|
describe "get_latest_weather_packet/1" do
|
|
test "returns most recent weather packet" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Create non-weather packet
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "WX1",
|
|
received_at: DateTime.add(now, -7200, :second),
|
|
comment: "Position only"
|
|
})
|
|
|
|
# Create old weather packet
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "WX1",
|
|
received_at: DateTime.add(now, -3600, :second),
|
|
temperature: 70.0,
|
|
humidity: 50
|
|
})
|
|
|
|
# Create recent weather packet
|
|
recent_wx =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "WX1",
|
|
received_at: now,
|
|
temperature: 72.0,
|
|
humidity: 55,
|
|
wind_speed: 5
|
|
})
|
|
|
|
result = Packets.get_latest_weather_packet("WX1")
|
|
assert result.id == recent_wx.id
|
|
assert result.temperature == 72.0
|
|
end
|
|
|
|
test "returns nil when no weather packets exist" do
|
|
PacketsFixtures.packet_fixture(%{sender: "NOWX", comment: "No weather"})
|
|
assert is_nil(Packets.get_latest_weather_packet("NOWX"))
|
|
end
|
|
end
|
|
|
|
describe "has_weather_packets?/1" do
|
|
test "returns true when weather packets exist" do
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "WX1",
|
|
temperature: 72.0
|
|
})
|
|
|
|
assert Packets.has_weather_packets?("WX1")
|
|
end
|
|
|
|
test "returns false when no weather packets exist" do
|
|
PacketsFixtures.packet_fixture(%{sender: "NOWX", comment: "No weather"})
|
|
refute Packets.has_weather_packets?("NOWX")
|
|
end
|
|
end
|
|
|
|
describe "weather_callsigns/1" do
|
|
test "returns callsigns that have weather packets" do
|
|
PacketsFixtures.packet_fixture(%{sender: "WX-BATCH1", temperature: 72.0})
|
|
PacketsFixtures.packet_fixture(%{sender: "WX-BATCH2", wind_speed: 10.0})
|
|
PacketsFixtures.packet_fixture(%{sender: "NOWX-BATCH", comment: "No weather"})
|
|
|
|
result = Packets.weather_callsigns(["WX-BATCH1", "WX-BATCH2", "NOWX-BATCH"])
|
|
assert MapSet.member?(result, "WX-BATCH1")
|
|
assert MapSet.member?(result, "WX-BATCH2")
|
|
refute MapSet.member?(result, "NOWX-BATCH")
|
|
end
|
|
|
|
test "returns empty set for empty input" do
|
|
assert MapSet.new() == Packets.weather_callsigns([])
|
|
end
|
|
|
|
test "is case-insensitive" do
|
|
PacketsFixtures.packet_fixture(%{sender: "WX-CASE", temperature: 72.0})
|
|
|
|
result = Packets.weather_callsigns(["wx-case"])
|
|
assert MapSet.member?(result, "WX-CASE")
|
|
end
|
|
end
|
|
|
|
describe "get_packets_for_replay/1" do
|
|
setup do
|
|
now = DateTime.utc_now()
|
|
start_time = DateTime.add(now, -3600, :second)
|
|
end_time = now
|
|
|
|
# Create packets in time range
|
|
p1 =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "REPLAY1",
|
|
received_at: DateTime.add(start_time, 900, :second),
|
|
lat: 39.0,
|
|
lon: -98.0
|
|
})
|
|
|
|
p2 =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "REPLAY2",
|
|
received_at: DateTime.add(start_time, 1800, :second),
|
|
lat: 39.1,
|
|
lon: -98.1
|
|
})
|
|
|
|
# Create packet outside time range
|
|
_old =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "OLD",
|
|
received_at: DateTime.add(start_time, -3600, :second),
|
|
lat: 39.2,
|
|
lon: -98.2
|
|
})
|
|
|
|
{:ok, packets: [p1, p2], start_time: start_time, end_time: end_time}
|
|
end
|
|
|
|
test "returns packets in chronological order", %{start_time: start_time, end_time: end_time} do
|
|
results =
|
|
Packets.get_packets_for_replay(%{
|
|
start_time: start_time,
|
|
end_time: end_time
|
|
})
|
|
|
|
assert length(results) == 2
|
|
assert hd(results).sender == "REPLAY1"
|
|
assert List.last(results).sender == "REPLAY2"
|
|
end
|
|
|
|
test "filters by bounds", %{start_time: start_time, end_time: end_time} do
|
|
# Only includes REPLAY1
|
|
bounds = [38.5, -98.5, 39.05, -97.5]
|
|
|
|
results =
|
|
Packets.get_packets_for_replay(%{
|
|
start_time: start_time,
|
|
end_time: end_time,
|
|
bounds: bounds
|
|
})
|
|
|
|
# The bounds filtering may not work exactly as expected in test
|
|
# Just verify we get results
|
|
assert is_list(results)
|
|
end
|
|
end
|
|
|
|
describe "stream_packets_for_replay/1" do
|
|
test "returns stream with timing information" do
|
|
now = DateTime.utc_now()
|
|
start_time = DateTime.add(now, -300, :second)
|
|
|
|
# Create packets with 60 second intervals
|
|
_p1 =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "STREAM1",
|
|
received_at: start_time,
|
|
lat: 39.0,
|
|
lon: -98.0
|
|
})
|
|
|
|
_p2 =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "STREAM2",
|
|
received_at: DateTime.add(start_time, 60, :second),
|
|
lat: 39.1,
|
|
lon: -98.1
|
|
})
|
|
|
|
stream =
|
|
Packets.stream_packets_for_replay(%{
|
|
start_time: start_time,
|
|
end_time: now,
|
|
# 2x speed
|
|
playback_speed: 2.0
|
|
})
|
|
|
|
[{delay1, packet1}, {delay2, packet2}] = Enum.take(stream, 2)
|
|
|
|
# First packet has no delay
|
|
assert delay1 == 0
|
|
assert packet1.sender == "STREAM1"
|
|
|
|
# 60 seconds / 2.0 speed = 30 seconds
|
|
assert_in_delta delay2, 30.0, 0.1
|
|
assert packet2.sender == "STREAM2"
|
|
end
|
|
end
|
|
|
|
describe "get_historical_packet_count/1" do
|
|
test "counts packets matching criteria" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Create packets
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "COUNT1",
|
|
lat: 39.0,
|
|
lon: -98.0,
|
|
received_at: DateTime.add(now, -3600, :second)
|
|
})
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "COUNT2",
|
|
lat: 39.1,
|
|
lon: -98.1,
|
|
received_at: DateTime.add(now, -1800, :second)
|
|
})
|
|
|
|
# Count all recent packets
|
|
count =
|
|
Packets.get_historical_packet_count(%{
|
|
start_time: DateTime.add(now, -7200, :second),
|
|
end_time: now
|
|
})
|
|
|
|
assert count >= 2
|
|
end
|
|
|
|
test "returns 0 on error" do
|
|
# Invalid bounds should not crash
|
|
count = Packets.get_historical_packet_count(%{bounds: "invalid"})
|
|
assert count == 0
|
|
end
|
|
end
|
|
|
|
describe "get_other_ssids/2" do
|
|
test "returns other SSIDs for a base callsign" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Create packets for multiple SSIDs of the same base callsign
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "AE5PL",
|
|
base_callsign: "AE5PL",
|
|
ssid: "0",
|
|
received_at: DateTime.add(now, -1800, :second),
|
|
symbol_table_id: "/",
|
|
symbol_code: "-"
|
|
})
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "AE5PL-1",
|
|
base_callsign: "AE5PL",
|
|
ssid: "1",
|
|
received_at: DateTime.add(now, -900, :second),
|
|
symbol_table_id: "/",
|
|
symbol_code: ">"
|
|
})
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "AE5PL-15",
|
|
base_callsign: "AE5PL",
|
|
ssid: "15",
|
|
received_at: DateTime.add(now, -300, :second),
|
|
symbol_table_id: "/",
|
|
symbol_code: "#"
|
|
})
|
|
|
|
# Query for SSIDs other than AE5PL (the base callsign with no SSID)
|
|
results = Packets.get_other_ssids("AE5PL")
|
|
|
|
callsigns = Enum.map(results, & &1.callsign)
|
|
assert "AE5PL-1" in callsigns
|
|
assert "AE5PL-15" in callsigns
|
|
refute "AE5PL" in callsigns
|
|
end
|
|
|
|
test "returns other SSIDs when querying from an SSID variant" do
|
|
now = DateTime.utc_now()
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "W5ABC",
|
|
base_callsign: "W5ABC",
|
|
ssid: "0",
|
|
received_at: DateTime.add(now, -1800, :second)
|
|
})
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "W5ABC-9",
|
|
base_callsign: "W5ABC",
|
|
ssid: "9",
|
|
received_at: DateTime.add(now, -900, :second)
|
|
})
|
|
|
|
# Query from the -9 SSID should show the base callsign
|
|
results = Packets.get_other_ssids("W5ABC-9")
|
|
|
|
callsigns = Enum.map(results, & &1.callsign)
|
|
assert "W5ABC" in callsigns
|
|
refute "W5ABC-9" in callsigns
|
|
end
|
|
|
|
test "returns empty list when no other SSIDs exist" do
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "LONELY",
|
|
base_callsign: "LONELY",
|
|
ssid: "0"
|
|
})
|
|
|
|
results = Packets.get_other_ssids("LONELY")
|
|
assert results == []
|
|
end
|
|
|
|
test "only returns SSIDs with recent packets" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Create a recent packet
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "RECENT-1",
|
|
base_callsign: "RECENT",
|
|
ssid: "1",
|
|
received_at: DateTime.add(now, -300, :second)
|
|
})
|
|
|
|
# Create an old packet (2 hours ago, outside 1-hour window)
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "RECENT-2",
|
|
base_callsign: "RECENT",
|
|
ssid: "2",
|
|
received_at: DateTime.add(now, -7200, :second)
|
|
})
|
|
|
|
results = Packets.get_other_ssids("RECENT")
|
|
|
|
callsigns = Enum.map(results, & &1.callsign)
|
|
assert "RECENT-1" in callsigns
|
|
refute "RECENT-2" in callsigns
|
|
end
|
|
|
|
test "includes symbol information and received_at in results" do
|
|
now = DateTime.utc_now()
|
|
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "SYM-1",
|
|
base_callsign: "SYM",
|
|
ssid: "1",
|
|
received_at: now,
|
|
symbol_table_id: "/",
|
|
symbol_code: ">"
|
|
})
|
|
|
|
[result] = Packets.get_other_ssids("SYM")
|
|
|
|
assert result.callsign == "SYM-1"
|
|
assert result.ssid == "1"
|
|
assert result.packet.symbol_table_id == "/"
|
|
assert result.packet.symbol_code == ">"
|
|
# Returns raw received_at DateTime, not a formatted map
|
|
assert %DateTime{} = result.received_at
|
|
refute Map.has_key?(result, :last_heard)
|
|
end
|
|
end
|
|
|
|
describe "get_last_hour_packets/0" do
|
|
test "returns packets from last hour" do
|
|
now = DateTime.utc_now()
|
|
|
|
# Create recent packet
|
|
_recent =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "RECENT",
|
|
received_at: DateTime.add(now, -1800, :second),
|
|
lat: 39.0,
|
|
lon: -98.0
|
|
})
|
|
|
|
# Create old packet
|
|
_old =
|
|
PacketsFixtures.packet_fixture(%{
|
|
sender: "OLD",
|
|
received_at: DateTime.add(now, -7200, :second),
|
|
lat: 39.1,
|
|
lon: -98.1
|
|
})
|
|
|
|
results = Packets.get_last_hour_packets()
|
|
callsigns = Enum.map(results, & &1.sender)
|
|
|
|
assert "RECENT" in callsigns
|
|
refute "OLD" in callsigns
|
|
end
|
|
|
|
test "returns empty list on error" do
|
|
# This should not crash even if there's an issue
|
|
results = Packets.get_last_hour_packets()
|
|
assert is_list(results)
|
|
end
|
|
end
|
|
end
|