test: bump coverage 80% -> 81.3% via more LiveView + module tests

- Pskr.Client: 0% -> 18% (init/standby/handle_info/terminate)
- RoverPlanningLive.PathShow: 0% -> 82% (mount/load_path)
- Rover.CandidateDetail: 0% -> partial (summarize/5 with stations)
- Mix.Tasks.Weather.RebatchAsos: 0% -> covered (wrapper run/1)
- HrrrNativeClient: 57% -> 67% (fetch_native_duct_grid error paths)
- RoverLive: 40% -> 48% (toggle/delete/update_station_grid logged-in)
- PathLive: 62% -> 63% (path_forecast_detail + rover_path_id branches)
- PathCompute: 64% -> 64% (compute/4 full pipeline + resolve_location)
- SkewtLive: 29% -> 31% (search/select_time event no-ops)
- SkewtLocationResolver: 38% -> 60%+ (callsign cache + error paths)

3557 tests passing.
This commit is contained in:
Graham McIntire 2026-05-08 09:10:34 -05:00
parent f644d1796f
commit 888701e627
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 645 additions and 0 deletions

View file

@ -132,6 +132,80 @@ defmodule Microwaveprop.Propagation.PathComputeTest do
end
end
describe "compute/4 — full pipeline" do
setup do
Req.Test.stub(Microwaveprop.Terrain.ElevationClient, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
end)
:ok
end
@station_params %{
src_height_m: 10.0,
dst_height_m: 10.0,
tx_power_dbm: 30.0,
src_gain_dbi: 20.0,
dst_gain_dbi: 20.0
}
test "computes a result for a coordinate-pair input" do
assert {:ok, result} =
PathCompute.compute("32.9, -97.0", "33.5, -96.5", 10_000, @station_params)
assert result.band_mhz == 10_000
assert result.dist_km > 0
assert is_float(result.bearing)
assert is_map(result.loss_budget)
assert is_map(result.power_budget)
# No HRRR profiles in the test DB → empty hrrr_points.
assert is_list(result.hrrr_points)
# Microwave band → no ionosphere readout.
assert result.ionosphere == nil
# No sounding rows in the test DB.
assert result.sounding == nil
end
test "returns {:error, _} when source resolution fails" do
assert {:error, _msg} =
PathCompute.compute("", "32.9, -97.0", 10_000, @station_params)
end
test "returns {:error, _} when destination resolution fails" do
assert {:error, _msg} =
PathCompute.compute("32.9, -97.0", "", 10_000, @station_params)
end
test "falls back to a default band config for unknown band_mhz" do
# Unknown band falls back to BandConfig.get(10_000).
assert {:ok, result} =
PathCompute.compute("32.9, -97.0", "33.5, -96.5", 9_999, @station_params)
assert result.band_mhz == 9_999
# Result band_config should be the 10G default.
assert result.band_config
end
test "60 GHz band exercises the high-h2o-absorption path" do
assert {:ok, result} =
PathCompute.compute("32.9, -97.0", "33.5, -96.5", 47_000, @station_params)
# 47 GHz has substantially more h2o absorption than 10 GHz.
assert result.loss_budget.h2o > 0
end
test "Maidenhead grid input round-trips through resolve" do
assert {:ok, result} =
PathCompute.compute("EM12", "EM13", 10_000, @station_params)
assert result.dist_km > 0
assert result.source.kind == :grid
assert result.destination.kind == :grid
end
end
describe "compute_power_budget/2" do
test "computes rx power and margins" do
loss = %{total: 140.0, fspl: 130.0, o2: 3.0, h2o: 5.0, rain: 0.0, diffraction: 2.0}

View file

@ -0,0 +1,113 @@
defmodule Microwaveprop.Pskr.ClientTest do
use ExUnit.Case, async: false
alias Microwaveprop.Pskr.Client
setup do
# Pre-register a placeholder under the global name so the Client
# under test loses leader election and goes to :standby — this
# avoids any outbound MQTT connection in the test environment.
{:ok, placeholder} = Task.start_link(fn -> Process.sleep(:infinity) end)
:global.unregister_name(Client)
:yes = :global.register_name(Client, placeholder)
on_exit(fn ->
:global.unregister_name(Client)
Process.exit(placeholder, :kill)
end)
%{placeholder: placeholder}
end
describe "start_link + init (standby branch)" do
test "starts as :standby when the global name is already taken" do
{:ok, pid} = GenServer.start_link(Client, name: __MODULE__.NotRegistered)
state = :sys.get_state(pid)
assert state.role == :standby
assert state.socket == nil
assert state.buffer == <<>>
assert is_list(state.bands)
assert is_binary(state.client_id)
GenServer.stop(pid)
end
test "uses defaults for bands when none are passed" do
{:ok, pid} = GenServer.start_link(Client, [])
state = :sys.get_state(pid)
assert "6m" in state.bands
assert "2m" in state.bands
assert "70cm" in state.bands
GenServer.stop(pid)
end
test "honors custom bands list and aggregator name from opts" do
{:ok, pid} = GenServer.start_link(Client, bands: ["2m"], aggregator: SomeOtherAggregator)
state = :sys.get_state(pid)
assert state.bands == ["2m"]
assert state.aggregator == SomeOtherAggregator
GenServer.stop(pid)
end
test "honors custom client_id" do
{:ok, pid} = GenServer.start_link(Client, client_id: "test-client-42")
state = :sys.get_state(pid)
assert state.client_id == "test-client-42"
GenServer.stop(pid)
end
end
describe "terminate/2" do
test "is graceful when state has no socket" do
{:ok, pid} = GenServer.start_link(Client, [])
# Just normal stop — terminate(_, %{socket: nil_or_other})
GenServer.stop(pid)
refute Process.alive?(pid)
end
end
describe "standby handle_info" do
test "nodeup re-runs election (stays standby while name is taken)" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:nodeup, :test@nowhere})
Process.sleep(50)
state = :sys.get_state(pid)
assert state.role == :standby
GenServer.stop(pid)
end
test "nodedown re-runs election (stays standby while name is taken)" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:nodedown, :test@nowhere})
Process.sleep(50)
state = :sys.get_state(pid)
assert state.role == :standby
GenServer.stop(pid)
end
test "unrelated info messages are ignored" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, :unrelated_message)
Process.sleep(20)
assert Process.alive?(pid)
GenServer.stop(pid)
end
test ":ping with no socket is a no-op" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, :ping)
Process.sleep(20)
state = :sys.get_state(pid)
assert state.socket == nil
GenServer.stop(pid)
end
end
end

View file

@ -0,0 +1,43 @@
defmodule Microwaveprop.Rover.CandidateDetailTest do
use ExUnit.Case, async: true
alias Microwaveprop.Rover.CandidateDetail
describe "summarize/5" do
test "returns the candidate's grid label and an empty links list when no stations are passed" do
candidate = %{lat: 32.9, lon: -97.0}
result = CandidateDetail.summarize(candidate, [], 10_000, ~U[2026-04-29 12:00:00Z], :ssb)
assert is_binary(result.grid)
# 10-character grid for that point.
assert String.length(result.grid) == 10
assert result.links == []
end
test "computes per-link summary fields when stations are supplied" do
# SRTM tiles are not present in test, so elev profile returns [].
# `summarize/5` handles that gracefully and still returns a link.
candidate = %{lat: 32.9, lon: -97.0}
stations = [
%{callsign: "W5LUA", lat: 33.05, lon: -96.6},
%{callsign: "W5HN", lat: 32.7, lon: -97.4}
]
result =
CandidateDetail.summarize(candidate, stations, 10_000, ~U[2026-04-29 12:00:00Z], :ssb)
assert length(result.links) == 2
Enum.each(result.links, fn link ->
assert is_binary(link.callsign)
assert is_float(link.distance_km)
assert is_binary(link.bearing)
assert is_float(link.bearing_deg)
# Profile is a list (may be empty if no SRTM tiles in test, or
# populated if a tiles_dir env var is set).
assert is_list(link.profile)
end)
end
end
end

View file

@ -553,6 +553,47 @@ defmodule Microwaveprop.Weather.HrrrNativeClientTest do
end
end
describe "fetch_native_duct_grid/4 with injected http_get" do
setup do
prev = Application.get_env(:microwaveprop, :hrrr_native_http_get)
on_exit(fn ->
if prev,
do: Application.put_env(:microwaveprop, :hrrr_native_http_get, prev),
else: Application.delete_env(:microwaveprop, :hrrr_native_http_get)
end)
:ok
end
@grid_spec %{
lon_start: -97.0,
lon_count: 2,
lon_step: 0.1,
lat_start: 32.7,
lat_count: 2,
lat_step: 0.1
}
test "propagates idx HTTP non-200 status as an error tuple" do
Application.put_env(:microwaveprop, :hrrr_native_http_get, fn _url, _opts ->
{:ok, %{status: 404, body: ""}}
end)
assert {:error, "HRRR native idx HTTP 404"} =
HrrrNativeClient.fetch_native_duct_grid(~D[2026-04-29], 12, @grid_spec)
end
test "propagates idx transport error" do
Application.put_env(:microwaveprop, :hrrr_native_http_get, fn _url, _opts ->
{:error, :timeout}
end)
assert {:error, :timeout} =
HrrrNativeClient.fetch_native_duct_grid(~D[2026-04-29], 12, @grid_spec)
end
end
describe "duct_byte_ranges/1 boundary inputs" do
test "empty idx entries yields an empty range list" do
assert HrrrNativeClient.duct_byte_ranges([]) == []

View file

@ -417,6 +417,66 @@ defmodule MicrowavepropWeb.PathLiveTest do
end
end
describe "rover_path_id handle_params branch" do
test "non-existent UUID flashes error and pushes to /path", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/path"}}} =
live(conn, ~p"/path?rover_path_id=#{Ecto.UUID.generate()}")
end
test "non-UUID string falls through cleanly", %{conn: conn} do
# A non-UUID id can't be Ecto.UUID.cast'd → load_cached_path returns
# {:error, :not_found} → flash + push_navigate to /path.
assert {:error, {:live_redirect, %{to: "/path"}}} =
live(conn, ~p"/path?rover_path_id=not-a-uuid")
end
end
describe "path_forecast_detail event" do
test "no-op when no result has been computed yet", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/path")
# No result in assigns → fall through `else: _ -> {:noreply, socket}`.
assert render_hook(view, "path_forecast_detail", %{"iso" => "2026-04-29T12:00:00Z"}) =~
"Path Calculator"
end
test "no-op for unparseable iso even after a result is in place", %{conn: conn} do
params = %{
"source" => "32.9, -97.0",
"destination" => "33.5, -96.5",
"band" => "10000"
}
{:ok, view, _html} = live(conn, ~p"/path?#{params}")
# Let async compute_path finish.
_ = render(view)
# Garbage iso → falls into the `else` arm of the with-statement.
assert render_hook(view, "path_forecast_detail", %{"iso" => "not-iso"}) =~
"Path Calculator"
end
test "pushes a payload event when a result is in place and iso is valid", %{conn: conn} do
params = %{
"source" => "32.9, -97.0",
"destination" => "33.5, -96.5",
"band" => "10000"
}
{:ok, view, _html} = live(conn, ~p"/path?#{params}")
_ = render(view)
iso =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> DateTime.to_iso8601()
# No score in DB for this point/time → returns the `unavailable: true`
# payload, but still exercises the success branch of the handler.
assert render_hook(view, "path_forecast_detail", %{"iso" => iso}) =~ "Path Calculator"
end
end
describe "property: band round-trip in form" do
property "for every configured band, submitting the form re-renders with that band selected" do
# Re-stub the elevation client for each property iteration —

View file

@ -105,6 +105,47 @@ defmodule MicrowavepropWeb.RoverLiveTest do
end
end
describe "additional event handlers (anonymous)" do
test "toggle_only_good flips the filter assign without recomputing", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
render_hook(view, "toggle_only_good", %{})
# Page still renders.
assert render(view) =~ "Rover planning"
end
test "toggle_station flips a station's selected flag", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
# Anonymous mount uses "call:CALLSIGN" ids for default stations.
render_hook(view, "toggle_station", %{"id" => "call:W5LUA"})
assert render(view) =~ "Rover planning"
end
test "delete_station removes a station from anonymous list", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
render_hook(view, "delete_station", %{"id" => "call:W5LUA"})
html = render(view)
assert html =~ "W5HN"
end
test "close_candidate_detail clears the detail panel", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
render_hook(view, "close_candidate_detail", %{})
assert render(view) =~ "Rover planning"
end
test "update_station_grid edits a station's grid in the anonymous list", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
render_hook(view, "update_station_grid", %{"station_id" => "call:W5LUA", "grid" => "EM13xx"})
# No crash; page still renders.
assert render(view) =~ "Rover planning"
end
end
describe "logged-in mount" do
setup :register_and_log_in_user
@ -139,6 +180,58 @@ defmodule MicrowavepropWeb.RoverLiveTest do
assert Enum.any?(Rover.list_stations(user), &(&1.callsign == "K5TX"))
end
test "delete_station removes a persisted station for the logged-in user", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/rover")
[first | _] = Rover.list_stations(user)
render_hook(view, "delete_station", %{"id" => to_string(first.id)})
assert length(Rover.list_stations(user)) == 2
end
test "toggle_station flips the persisted selected flag", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/rover")
[first | _] = Rover.list_stations(user)
render_hook(view, "toggle_station", %{"id" => to_string(first.id)})
reloaded = Repo.reload(first)
assert reloaded.selected != first.selected
end
test "update_station_grid updates the grid for a persisted station", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/rover")
[first | _] = Rover.list_stations(user)
render_hook(view, "update_station_grid", %{"station_id" => to_string(first.id), "grid" => "EM12kp"})
reloaded = Repo.reload(first)
assert String.upcase(reloaded.grid) == "EM12KP"
end
test "update_station_grid surfaces error for invalid grid input", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/rover")
[first | _] = Rover.list_stations(user)
html =
render_hook(view, "update_station_grid", %{"station_id" => to_string(first.id), "grid" => "ZZZ99"})
assert html =~ "invalid grid"
end
test "update_station_grid is a no-op for blank input", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/rover")
[first | _] = Rover.list_stations(user)
original_grid = first.grid
render_hook(view, "update_station_grid", %{"station_id" => to_string(first.id), "grid" => ""})
reloaded = Repo.reload(first)
assert reloaded.grid == original_grid
end
test "set_home_qth persists the user's home QTH", %{conn: conn, user: user} do
{:ok, view, _html} = live(conn, ~p"/rover")

View file

@ -0,0 +1,95 @@
defmodule MicrowavepropWeb.RoverPlanningLive.PathShowTest do
use MicrowavepropWeb.ConnCase, async: true
import Ecto.Query
import Phoenix.LiveViewTest
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Repo
alias Microwaveprop.Rover
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Path, as: RoverPath
alias Microwaveprop.Terrain.ElevationClient
setup do
Req.Test.stub(ElevationClient, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
lat_count = params["latitude"] |> String.split(",") |> length()
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
end)
:ok
end
defp create_mission(user) do
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :good})
{:ok, mission} =
RoverPlanning.create_mission(user, %{
"name" => "Test mission",
"band_mhz" => 10_000,
"only_known_good" => true,
"rover_height_ft" => 8.0,
"station_height_ft" => 30.0,
"stations" => %{"0" => %{"input" => "EM12kp", "position" => 0}}
})
mission
end
describe "mount" do
test "redirects to mission show with flash when path is not found", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn = log_in_user(conn, user)
mission = create_mission(user)
assert {:error, {:live_redirect, %{to: redirect_to, flash: flash}}} =
live(conn, ~p"/rover-planning/#{mission.id}/paths/#{Ecto.UUID.generate()}")
assert redirect_to == "/rover-planning/#{mission.id}"
assert flash["error"] =~ "not found"
end
test "renders the path detail page when given a valid path id", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn = log_in_user(conn, user)
mission = create_mission(user)
[first_path | _] =
Repo.all(from(p in RoverPath, where: p.mission_id == ^mission.id))
# Populate result so the page renders the body.
result_payload = %{
"verdict" => "CLEAR",
"distance_km" => 25.5,
"path_points" => [
%{"dist_km" => 0.0, "elev" => 200, "beam" => 200, "r1" => 0},
%{"dist_km" => 12.5, "elev" => 220, "beam" => 220, "r1" => 5},
%{"dist_km" => 25.5, "elev" => 240, "beam" => 240, "r1" => 0}
],
"verdict_label" => "CLEAR"
}
{:ok, path} =
first_path
|> RoverPath.changeset(%{result: result_payload, status: :complete})
|> Repo.update()
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}/paths/#{path.id}")
assert html =~ "Test mission"
end
test "redirects when mission_id is not a UUID", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn = log_in_user(conn, user)
mission = create_mission(user)
assert {:error, {:live_redirect, %{to: redirect_to}}} =
live(conn, ~p"/rover-planning/not-uuid/paths/#{Ecto.UUID.generate()}")
# When mission_id can't be cast, push_navigate goes to /rover-planning/<input>.
assert redirect_to =~ "rover-planning"
_ = mission
end
end
end

View file

@ -3,6 +3,81 @@ defmodule MicrowavepropWeb.SkewtLiveTest do
import Phoenix.LiveViewTest
alias Microwaveprop.Weather.HrrrProfile
describe "with HRRR profile data near the queried location" do
setup do
profile_data = [
%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0},
%{"pres" => 925.0, "hght" => 770, "tmpc" => 18.0, "dwpc" => 12.0},
%{"pres" => 850.0, "hght" => 1500, "tmpc" => 14.0, "dwpc" => 8.0},
%{"pres" => 700.0, "hght" => 3000, "tmpc" => 0.0, "dwpc" => -10.0},
%{"pres" => 500.0, "hght" => 5500, "tmpc" => -20.0, "dwpc" => -30.0}
]
# Insert near EM12kp (32.646, -97.125)
now = DateTime.truncate(DateTime.utc_now(), :second)
%HrrrProfile{}
|> HrrrProfile.changeset(%{
valid_time: now,
run_time: now,
lat: 32.65,
lon: -97.12,
profile: profile_data,
hpbl_m: 1500.0,
pwat_mm: 25.0,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 320.5,
min_refractivity_gradient: -45.0,
ducting_detected: false
})
|> Microwaveprop.Repo.insert!()
:ok
end
test "renders the time selector when valid_times list is non-empty", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/skewt?q=EM12kp")
_ = render_async(view)
# We can't reliably assert HRRR data renders since fetch_rich_cell
# tries an HTTP call that fails — but the page should not crash.
assert render(view) =~ "Skew-T"
end
end
describe "select_time event" do
test "ignored when no location is set yet", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/skewt")
# No location resolved → select_time falls through to the no-op.
result = render_hook(view, "select_time", %{"valid_time" => "2026-04-29T12:00:00Z"})
# Render still works.
assert result =~ "Skew-T"
end
test "ignored when valid_time is unparseable", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/skewt?q=EM12kp")
render_async(view)
result = render_hook(view, "select_time", %{"valid_time" => "not-a-timestamp"})
assert result =~ "Skew-T"
end
end
describe "search event" do
test "patches URL when user submits the form", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/skewt")
render_hook(view, "search", %{"q" => "EM13"})
assert render_async(view) =~ "EM13"
end
end
describe "GET /skewt" do
test "renders search bar with no results when no query is supplied", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/skewt")

View file

@ -1,6 +1,7 @@
defmodule MicrowavepropWeb.SkewtLocationResolverTest do
use Microwaveprop.DataCase, async: false
alias Microwaveprop.CallsignLocation.Location
alias MicrowavepropWeb.SkewtLocationResolver
describe "classify/1" do
@ -29,6 +30,36 @@ defmodule MicrowavepropWeb.SkewtLocationResolverTest do
end
end
describe "resolve/1 — callsign branch" do
test "resolves a cached callsign via CallsignLocation lookup" do
# Seed the cache so the resolver doesn't hit QRZ.
%Location{}
|> Location.changeset(%{
callsign: "W5ISP",
latitude: 33.0,
longitude: -97.0,
gridsquare: "EM13QC"
})
|> Microwaveprop.Repo.insert!()
assert {:ok, resolved} = SkewtLocationResolver.resolve("W5ISP")
assert resolved.source == :callsign
assert resolved.lat == 33.0
assert resolved.lon == -97.0
assert resolved.label =~ "W5ISP"
end
test "surfaces the lookup error message for an unknown callsign" do
# No cached row + a QRZ stub that 404s.
Req.Test.stub(Microwaveprop.Qrz.Client, fn conn ->
Plug.Conn.send_resp(conn, 404, "Not Found")
end)
assert {:error, msg} = SkewtLocationResolver.resolve("XX9XYZ")
assert msg =~ "callsign lookup failed"
end
end
describe "resolve/1 — grid square branch" do
test "returns lat/lon for a valid grid square" do
assert {:ok, %{lat: lat, lon: lon, label: "EM12KP", source: :grid}} =

View file

@ -86,4 +86,24 @@ defmodule Mix.Tasks.Weather.RebatchAsosTest do
output = ExUnit.CaptureIO.capture_io(fn -> RebatchAsos.run([]) end)
assert output =~ "Nothing to do"
end
describe "Mix task wrapper" do
test "run/1 with no args is a no-op" do
output =
ExUnit.CaptureIO.capture_io(fn ->
assert :ok = Mix.Tasks.Weather.RebatchAsos.run([])
end)
assert output =~ "Nothing to do"
end
test "run/1 with --dry-run flag" do
output =
ExUnit.CaptureIO.capture_io(fn ->
assert :ok = Mix.Tasks.Weather.RebatchAsos.run(["--dry-run"])
end)
assert output =~ "Nothing to do"
end
end
end