defmodule AprsmeWeb.Live.Shared.PacketUtilsTest do use ExUnit.Case, async: true alias AprsmeWeb.Live.Shared.PacketUtils describe "get_callsign_key/1" do test "returns sender callsign when no object/item name" do assert PacketUtils.get_callsign_key(%{sender: "K5ABC"}) == "K5ABC" end test "prefers object_name when present" do packet = %{sender: "K5ABC", object_name: "REPEATER1", data_type: "object"} assert PacketUtils.get_callsign_key(packet) == "REPEATER1" end test "prefers item_name when present" do packet = %{sender: "K5ABC", item_name: "EVENT", data_type: "item"} assert PacketUtils.get_callsign_key(packet) == "EVENT" end test "object_name wins over item_name" do packet = %{object_name: "OBJ", item_name: "ITEM", sender: "K5ABC"} assert PacketUtils.get_callsign_key(packet) == "OBJ" end test "returns __unknown__ for non-maps" do assert PacketUtils.get_callsign_key(nil) == "__unknown__" end test "returns __unknown__ when every field is empty" do packet = %{sender: "", object_name: "", item_name: ""} assert PacketUtils.get_callsign_key(packet) == "__unknown__" end test "strips whitespace from object/item names" do assert PacketUtils.get_callsign_key(%{object_name: " REPEATER "}) == "REPEATER" end end describe "prune_oldest_packets/2" do test "returns the same map when size is within limit" do packets = %{"a" => %{}, "b" => %{}} assert PacketUtils.prune_oldest_packets(packets, 10) == packets end test "keeps the newest packets by received_at DateTime" do now = DateTime.utc_now() older = DateTime.add(now, -3600, :second) oldest = DateTime.add(now, -7200, :second) packets = %{ "a" => %{received_at: now}, "b" => %{received_at: older}, "c" => %{received_at: oldest} } result = PacketUtils.prune_oldest_packets(packets, 2) assert map_size(result) == 2 assert Map.has_key?(result, "a") assert Map.has_key?(result, "b") refute Map.has_key?(result, "c") end test "supports integer timestamps via string-keyed received_at" do packets = %{ "a" => %{"received_at" => 1000}, "b" => %{"received_at" => 500} } result = PacketUtils.prune_oldest_packets(packets, 1) assert Map.keys(result) == ["a"] end test "treats packets without received_at as oldest (timestamp 0)" do now = DateTime.utc_now() packets = %{"a" => %{received_at: now}, "b" => %{no_date: true}} result = PacketUtils.prune_oldest_packets(packets, 1) assert Map.has_key?(result, "a") end test "prunes to zero when max_count is 0 and size exceeds it" do packets = %{"a" => %{received_at: DateTime.utc_now()}} assert PacketUtils.prune_oldest_packets(packets, 0) == %{} end end describe "parse_trail_duration/1" do test "accepts known values" do for hours <- [1, 6, 12, 24, 48, 168] do assert PacketUtils.parse_trail_duration(to_string(hours)) == hours end end test "rejects arbitrary numbers not in allowlist" do assert PacketUtils.parse_trail_duration("3") == 1 assert PacketUtils.parse_trail_duration("100") == 1 assert PacketUtils.parse_trail_duration("-1") == 1 end test "returns default for garbage and non-string input" do assert PacketUtils.parse_trail_duration("abc") == 1 assert PacketUtils.parse_trail_duration("24abc") == 1 assert PacketUtils.parse_trail_duration(nil) == 1 assert PacketUtils.parse_trail_duration(24) == 1 end end describe "parse_historical_hours/1" do test "accepts known values" do for hours <- [1, 3, 6, 12, 24] do assert PacketUtils.parse_historical_hours(to_string(hours)) == hours end end test "rejects values not in allowlist" do assert PacketUtils.parse_historical_hours("48") == 1 assert PacketUtils.parse_historical_hours("0") == 1 end test "returns default for garbage" do assert PacketUtils.parse_historical_hours(nil) == 1 assert PacketUtils.parse_historical_hours("abc") == 1 end end describe "get_packet_field/3" do test "retrieves atom key" do assert PacketUtils.get_packet_field(%{sender: "K5ABC"}, :sender) == "K5ABC" end test "falls back to string key" do assert PacketUtils.get_packet_field(%{"sender" => "K5ABC"}, :sender) == "K5ABC" end test "falls back to data_extended atom key" do assert PacketUtils.get_packet_field(%{data_extended: %{temperature: 72}}, :temperature) == 72 end test "falls back to data_extended string key" do assert PacketUtils.get_packet_field(%{"data_extended" => %{"humidity" => 50}}, :humidity) == 50 end test "returns default when no match" do assert PacketUtils.get_packet_field(%{}, :whatever, :fallback) == :fallback end test "defaults to nil when no default given" do assert PacketUtils.get_packet_field(%{}, :whatever) == nil end test "handles nil data_extended gracefully" do assert PacketUtils.get_packet_field(%{data_extended: nil}, :temperature, 0) == 0 end end describe "get_weather_field/2" do test "reads from top-level atom key" do assert PacketUtils.get_weather_field(%{temperature: 72}, :temperature) == 72 end test "reads from data_extended" do packet = %{data_extended: %{pressure: 1013}} assert PacketUtils.get_weather_field(packet, :pressure) == 1013 end test "returns nil when absent" do assert PacketUtils.get_weather_field(%{}, :temperature) == nil end end describe "has_weather_data?/1" do test "detects weather data in top-level fields" do assert PacketUtils.has_weather_data?(%{temperature: 72}) assert PacketUtils.has_weather_data?(%{humidity: 50}) end test "detects weather data in data_extended" do assert PacketUtils.has_weather_data?(%{data_extended: %{temperature: 72}}) end test "returns false when no weather fields are present" do refute PacketUtils.has_weather_data?(%{sender: "K5ABC"}) refute PacketUtils.has_weather_data?(%{}) end end describe "packet_within_time_threshold?/2" do test "returns true when packet is after threshold" do now = DateTime.utc_now() older = DateTime.add(now, -3600, :second) assert PacketUtils.packet_within_time_threshold?(%{received_at: now}, older) end test "returns true when packet equals threshold" do now = DateTime.utc_now() assert PacketUtils.packet_within_time_threshold?(%{received_at: now}, now) end test "returns false when packet is older than threshold" do now = DateTime.utc_now() older = DateTime.add(now, -3600, :second) refute PacketUtils.packet_within_time_threshold?(%{received_at: older}, now) end test "treats packet with no received_at as within threshold" do assert PacketUtils.packet_within_time_threshold?(%{}, DateTime.utc_now()) end test "accepts integer epoch threshold" do now = DateTime.utc_now() older_epoch = DateTime.to_unix(now) - 3600 assert PacketUtils.packet_within_time_threshold?(%{received_at: now}, older_epoch) end test "accepts ISO8601 string threshold" do now = DateTime.utc_now() older = now |> DateTime.add(-3600, :second) |> DateTime.to_iso8601() assert PacketUtils.packet_within_time_threshold?(%{received_at: now}, older) end end describe "get_timestamp/1" do test "returns ISO8601 for DateTime" do dt = ~U[2024-01-15 12:00:00Z] assert PacketUtils.get_timestamp(%{received_at: dt}) == "2024-01-15T12:00:00Z" end test "returns ISO8601 for NaiveDateTime" do ndt = ~N[2024-01-15 12:00:00] result = PacketUtils.get_timestamp(%{received_at: ndt}) assert result =~ "2024-01-15T12:00:00" end test "reformats ISO8601 strings" do # Any valid ISO8601 string parses and re-emits. assert PacketUtils.get_timestamp(%{received_at: "2024-01-15T12:00:00Z"}) == "2024-01-15T12:00:00Z" end test "passes through unparseable strings" do assert PacketUtils.get_timestamp(%{received_at: "whenever"}) == "whenever" end test "returns empty string for nil or missing" do assert PacketUtils.get_timestamp(%{received_at: nil}) == "" assert PacketUtils.get_timestamp(%{}) == "" end end describe "generate_callsign/1" do test "joins base_callsign and ssid with a dash" do assert PacketUtils.generate_callsign(%{base_callsign: "K5ABC", ssid: "9"}) == "K5ABC-9" end test "returns base_callsign only when ssid is nil" do assert PacketUtils.generate_callsign(%{base_callsign: "K5ABC", ssid: nil}) == "K5ABC" end test "returns base_callsign only when ssid is empty" do assert PacketUtils.generate_callsign(%{base_callsign: "K5ABC", ssid: ""}) == "K5ABC" end test "returns empty when base_callsign is missing" do assert PacketUtils.generate_callsign(%{}) == "" end end describe "display_name/1" do test "prefers object_name over everything" do packet = %{sender: "K5ABC", object_name: "REP", item_name: "ITEM"} assert PacketUtils.display_name(packet) == "REP" end test "falls back to item_name when no object_name" do packet = %{sender: "K5ABC", item_name: "ITEM"} assert PacketUtils.display_name(packet) == "ITEM" end test "falls back to sender when neither is present" do assert PacketUtils.display_name(%{sender: "K5ABC"}) == "K5ABC" end test "strips whitespace from names" do assert PacketUtils.display_name(%{object_name: " REP "}) == "REP" end test "ignores blank object_name/item_name (treated as absent)" do packet = %{sender: "K5ABC", object_name: " ", item_name: ""} assert PacketUtils.display_name(packet) == "K5ABC" end end describe "map_label/1" do test "returns object_name for data_type=object" do packet = %{data_type: "object", object_name: "REP", sender: "K5ABC"} assert PacketUtils.map_label(packet) == "REP" end test "returns item_name for data_type=item" do packet = %{data_type: "item", item_name: "EVENT", sender: "K5ABC"} assert PacketUtils.map_label(packet) == "EVENT" end test "falls back to sender when object_name empty on object type" do packet = %{data_type: "object", object_name: "", sender: "K5ABC"} assert PacketUtils.map_label(packet) == "K5ABC" end test "returns sender for position packets" do packet = %{data_type: "position", sender: "K5ABC"} assert PacketUtils.map_label(packet) == "K5ABC" end end describe "filter_packets_by_time_and_bounds/3" do test "filters by both predicates" do now = DateTime.utc_now() older = DateTime.add(now, -3600, :second) threshold = DateTime.add(now, -1800, :second) bounds = %{north: 50.0, south: 30.0, east: -90.0, west: -110.0} packets = %{ "in_and_recent" => %{lat: 40.0, lon: -100.0, received_at: now}, "in_but_old" => %{lat: 40.0, lon: -100.0, received_at: older}, "out_and_recent" => %{lat: 0.0, lon: 0.0, received_at: now} } result = PacketUtils.filter_packets_by_time_and_bounds(packets, bounds, threshold) assert Map.keys(result) == ["in_and_recent"] end end end