Add coverage tests for DevicesSeeder, StatusLive helpers, DataBuilder, and HistoricalLoader

This commit is contained in:
Graham McIntire 2026-05-08 13:04:37 -05:00
parent 1e8e97d5f4
commit aa809976ab
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 259 additions and 0 deletions

View file

@ -0,0 +1,84 @@
defmodule Aprsme.DevicesSeederTest do
use Aprsme.DataCase, async: false
alias Aprsme.Devices
alias Aprsme.DevicesSeeder
alias Aprsme.Repo
setup do
Repo.delete_all(Devices)
:ok
end
describe "seed_from_json/1" do
test "seeds the devices table from the default test JSON file" do
assert {:ok, :seeded} = DevicesSeeder.seed_from_json()
assert Repo.aggregate(Devices, :count) > 0
end
test "returns an error tuple when the file does not exist" do
assert {:error, message} = DevicesSeeder.seed_from_json("nonexistent/file.json")
assert message =~ "Failed to read file"
end
test "returns an error tuple when the JSON is malformed" do
tmp = Path.join(System.tmp_dir!(), "bad_devices_#{System.unique_integer([:positive])}.json")
File.write!(tmp, "{malformed json")
try do
assert {:error, message} = DevicesSeeder.seed_from_json(tmp)
assert message =~ "Failed to decode JSON"
after
File.rm(tmp)
end
end
test "wipes existing devices before seeding" do
Repo.insert!(%Devices{identifier: "TOWIPE", vendor: "v", model: "m"})
assert {:ok, :seeded} = DevicesSeeder.seed_from_json()
assert Repo.get_by(Devices, identifier: "TOWIPE") == nil
end
test "wraps a non-list features value into a single-element list" do
tmp = Path.join(System.tmp_dir!(), "single_feature_#{System.unique_integer([:positive])}.json")
File.write!(
tmp,
Jason.encode!(%{
"tocalls" => %{
"APSGL" => %{"vendor" => "V", "model" => "M", "features" => "single-feature"}
}
})
)
try do
assert {:ok, :seeded} = DevicesSeeder.seed_from_json(tmp)
dev = Repo.get_by(Devices, identifier: "APSGL")
assert dev.features == ["single-feature"]
after
File.rm(tmp)
end
end
test "preserves a list-typed features value as-is" do
tmp = Path.join(System.tmp_dir!(), "list_feature_#{System.unique_integer([:positive])}.json")
File.write!(
tmp,
Jason.encode!(%{
"tocalls" => %{
"APLST" => %{"vendor" => "V", "model" => "M", "features" => ["a", "b"]}
}
})
)
try do
assert {:ok, :seeded} = DevicesSeeder.seed_from_json(tmp)
dev = Repo.get_by(Devices, identifier: "APLST")
assert dev.features == ["a", "b"]
after
File.rm(tmp)
end
end
end
end

View file

@ -199,6 +199,31 @@ defmodule Aprsme.PartitionManagerTest do
end
end
describe "start_link/1" do
test "starts the GenServer process with the module name" do
# If a previous test left one running, just confirm start_link works.
case Process.whereis(PartitionManager) do
nil ->
{:ok, pid} = PartitionManager.start_link([])
assert Process.alive?(pid)
GenServer.stop(pid)
pid ->
assert Process.alive?(pid)
end
end
end
describe "manage_partitions/0 idempotent paths" do
test "running :manage_partitions twice covers the {:ok, []} branches" do
# First run creates today/+1/+2 partitions (or finds them existing) and
# drops nothing old. Second run finds everything in place — both
# ensure_partitions_exist and drop_old_partitions return {:ok, []}.
assert {:noreply, %{}} = PartitionManager.handle_info(:manage_partitions, %{})
assert {:noreply, %{}} = PartitionManager.handle_info(:manage_partitions, %{})
end
end
describe "manage_partitions/0 via :manage_partitions handler with non-trivial work" do
test "logs created+dropped partitions via the success branches" do
# Pre-create a partition far in the past so manage_partitions has

View file

@ -156,6 +156,69 @@ defmodule AprsmeWeb.MapLive.DataBuilderExtrasTest do
end
end
describe "build_packet_result without :id falls back to callsign-based ID" do
test "live packet without :id uses callsign in the generated ID" do
# When packet has no :id key, the build_packet_result helper uses the
# callsign to generate the marker ID (line 473 in data_builder.ex).
packet = %{
"sender" => "NOID-1",
"callsign" => "NOID-1",
"received_at" => DateTime.utc_now(),
"lat" => 33.0,
"lon" => -96.0,
"comment" => "no id"
}
result = DataBuilder.build_packet_data(packet, true)
# Build should succeed with a generated id beginning with "live_".
if is_map(result) do
assert is_binary(result["id"])
assert String.starts_with?(result["id"], "live_")
else
# build_packet_data may filter — that's also OK, the helper still ran.
assert true
end
end
end
describe "convert_tuples_to_strings via popup data with deeply nested tuples" do
test "tuples of size > 3 fall through to inspect/1" do
packet = %{
"id" => "big-tup",
"sender" => "BIG-1",
"callsign" => "BIG-1",
"received_at" => DateTime.utc_now(),
"lat" => 33.0,
"lon" => -96.0,
# Tuples of size 4 and 5 trigger the inspect/1 fallback in
# convert_tuples_to_strings/1 (line 587).
"data" => %{
"quad" => {1, 2, 3, 4},
"five" => {1, 2, 3, 4, 5}
}
}
_ = DataBuilder.build_simple_popup(packet, false)
end
test "list of tuples is processed via the list head" do
packet = %{
"id" => "list-tup",
"sender" => "LST-1",
"callsign" => "LST-1",
"received_at" => DateTime.utc_now(),
"lat" => 33.0,
"lon" => -96.0,
"data" => [
{1, 2},
{3, 4, 5}
]
}
_ = DataBuilder.build_simple_popup(packet, false)
end
end
describe "build_packet_data with string-typed weather values exercises convert_unit" do
test "string numeric weather values parse via Float.parse branch" do
packet = %{

View file

@ -444,6 +444,48 @@ defmodule AprsmeWeb.MapLive.HistoricalLoaderTest do
end
end
describe "schedules next batch when not final" do
test "saturated batch with more rows triggers maybe_schedule_next_batch" do
now = DateTime.utc_now()
# Return a saturated batch so has_more = true and is_final_batch = false.
# Default batch_size for zoom 10 is 500 — return a list of that size.
packets =
for i <- 1..500 do
%{
id: "saturate-#{i}",
sender: "SAT-#{i}",
base_callsign: "SAT",
ssid: "1",
lat: 33.5 + i * 0.0001,
lon: -96.5 + i * 0.0001,
has_position: true,
symbol_table_id: "/",
symbol_code: ">",
received_at: DateTime.add(now, -i, :second),
path: ""
}
end
Mox.stub(Aprsme.PacketsMock, :get_recent_packets, fn _opts -> packets end)
socket =
build_socket(%{
map_bounds: %{north: 34.0, south: 32.0, east: -95.0, west: -97.0},
map_zoom: 10,
historical_loading: true,
loading_generation: 1,
# 4 batches expected, this is batch 0 — so not final
total_batches: 4
})
result = HistoricalLoader.load_historical_batch(socket, 0, nil)
# maybe_schedule_next_batch should have queued a timer.
assert is_list(result.assigns.pending_batch_tasks)
end
end
describe "max packets-for-zoom limit" do
test "finishes loading when batch_offset * batch_size exceeds zoom limit" do
# At zoom 10, limit=1500, batch_size=500. A batch_offset of 3 exceeds

View file

@ -165,6 +165,51 @@ defmodule AprsmeWeb.StatusLive.IndexTest do
end
end
describe "format_uptime/1" do
test "returns 'Not connected' when seconds <= 0" do
assert Index.format_uptime(0) =~ "Not connected"
assert Index.format_uptime(-100) =~ "Not connected"
end
test "formats seconds-only durations" do
assert Index.format_uptime(45) == "45s"
end
test "formats minutes+seconds durations" do
assert Index.format_uptime(125) == "2m 5s"
end
test "formats hours+minutes+seconds durations" do
assert Index.format_uptime(3 * 3600 + 45 * 60 + 30) == "3h 45m 30s"
end
test "formats day+hour+minute+second durations" do
total = 2 * 86_400 + 3 * 3600 + 4 * 60 + 5
assert Index.format_uptime(total) == "2d 3h 4m 5s"
end
end
describe "get_health_description/2" do
test "score 1 returns disconnected" do
assert Index.get_health_description(1, false) =~ "Disconnected"
end
test "score 5 connected returns excellent/healthy text" do
result = Index.get_health_description(5, true)
assert is_binary(result)
end
test "all defined score combinations return non-empty text" do
for score <- 1..5 do
for connected <- [true, false] do
result = Index.get_health_description(score, connected)
assert is_binary(result)
assert byte_size(result) > 0
end
end
end
end
describe "render with cluster_info populated" do
test "renders the Cluster Status section when cluster_info is present", %{conn: conn} do
_ =