test: push coverage 79.64% -> 80.04% across multiple modules

New test files: RoverPlanning.Station, LiveStashGuard, RebatchAsos,
RoverMissionReconcileWorker, RoverPathProfileWorker. Extended existing
suites for PathCompute (resolve_location, build_ionosphere_readout),
HrdpsClient (DEPR fallback, missing-level skip), NexradClient
(fetch_rain_cells HTTP error paths), UserHomeQthLookupWorker (error +
Maidenhead-fallback paths), RoverPlanning.Path (statuses/0 + schema).

3512 tests passing.
This commit is contained in:
Graham McIntire 2026-05-08 08:42:03 -05:00
parent 06a54075f8
commit f644d1796f
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 612 additions and 2 deletions

View file

@ -1,5 +1,5 @@
defmodule Microwaveprop.Propagation.PathComputeTest do
use ExUnit.Case, async: true
use Microwaveprop.DataCase, async: false
use ExUnitProperties
alias Microwaveprop.Propagation.BandConfig
@ -96,6 +96,42 @@ defmodule Microwaveprop.Propagation.PathComputeTest do
end
end
describe "resolve_location/1" do
test "parses a coordinate pair" do
assert {:ok, %{lat: 32.9, lon: -96.8}} = PathCompute.resolve_location("32.9, -96.8")
end
test "parses a Maidenhead grid" do
assert {:ok, %{kind: :grid, label: "EM12"}} = PathCompute.resolve_location("EM12")
end
test "blank input returns the location-required error" do
assert {:error, "Location is required"} = PathCompute.resolve_location("")
assert {:error, "Location is required"} = PathCompute.resolve_location(nil)
end
end
describe "build_ionosphere_readout/4" do
test "returns nil for non-ionosphere bands (microwave)" do
assert PathCompute.build_ionosphere_readout(10_000, 32.9, -96.8, 100.0) == nil
assert PathCompute.build_ionosphere_readout(24_000, 32.9, -96.8, 100.0) == nil
end
test "returns nil for bands the readout does not handle (e.g. 902 MHz)" do
# The function only opens up for [50, 144, 222, 432]; 902 falls
# through to the catch-all clause.
assert PathCompute.build_ionosphere_readout(902, 32.9, -96.8, 100.0) == nil
end
test "returns nil when band is supported but no ionosphere data is available" do
# 6m and 2m hit the `Ionosphere.nearest_foes/2` path; with an empty
# test DB the function returns `{:error, :no_data}` and the
# readout falls through to the catch-all `nil` arm.
assert PathCompute.build_ionosphere_readout(50, 32.9, -96.8, 100.0) == nil
assert PathCompute.build_ionosphere_readout(144, 32.9, -96.8, 100.0) == nil
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

@ -3,6 +3,26 @@ defmodule Microwaveprop.RoverPlanning.PathTest do
alias Microwaveprop.RoverPlanning.Path
describe "statuses/0" do
test "returns the full enum list" do
assert Path.statuses() == [:pending, :computing, :complete, :failed]
end
end
describe "schema metadata" do
test "has the expected fields" do
fields = Path.__schema__(:fields)
assert :band_mhz in fields
assert :status in fields
assert :result in fields
assert :error in fields
assert :computed_at in fields
assert :mission_id in fields
assert :rover_location_id in fields
assert :station_id in fields
end
end
describe "changeset/2" do
test "valid attrs produce a valid changeset" do
attrs = %{
@ -69,6 +89,19 @@ defmodule Microwaveprop.RoverPlanning.PathTest do
end
end
test "accepts :computing status" do
attrs = %{
mission_id: Ecto.UUID.generate(),
rover_location_id: Ecto.UUID.generate(),
station_id: Ecto.UUID.generate(),
band_mhz: 10_000,
status: :computing
}
changeset = Path.changeset(%Path{}, attrs)
assert changeset.valid?
end
test "accepts optional result and error fields" do
attrs = %{
mission_id: Ecto.UUID.generate(),

View file

@ -0,0 +1,104 @@
defmodule Microwaveprop.RoverPlanning.StationTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.RoverPlanning.Station
describe "changeset/2" do
test "resolves coordinate input into lat/lon" do
changeset = Station.changeset(%Station{}, %{input: "32.9, -96.8"})
assert changeset.valid?
assert get_change(changeset, :lat) == 32.9
assert get_change(changeset, :lon) == -96.8
end
test "resolves Maidenhead grid into lat/lon and grid field" do
changeset = Station.changeset(%Station{}, %{input: "EM12kx"})
assert changeset.valid?
assert get_change(changeset, :grid) == "EM12KX"
assert is_float(get_change(changeset, :lat))
assert is_float(get_change(changeset, :lon))
end
test "blank input is invalid" do
changeset = Station.changeset(%Station{}, %{input: ""})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).input
end
test "missing input is invalid" do
changeset = Station.changeset(%Station{}, %{})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).input
end
test "out-of-range latitude is rejected" do
# Stuff a coordinate input that LocationResolver returns happily
# but is out of physical range — validate_number should catch it.
changeset = Station.changeset(%Station{}, %{input: "95.0, 0.0"})
refute changeset.valid?
assert Map.has_key?(errors_on(changeset), :lat)
end
test "out-of-range longitude is rejected" do
changeset = Station.changeset(%Station{}, %{input: "0.0, 200.0"})
refute changeset.valid?
assert Map.has_key?(errors_on(changeset), :lon)
end
test "re-typing input wipes previously resolved fields before re-resolving" do
stale = %Station{
callsign: "OLDCALL",
grid: "FN42",
lat: 40.0,
lon: -74.0,
input: "FN42"
}
changeset = Station.changeset(stale, %{input: "32.9, -96.8"})
assert changeset.valid?
# New coordinate input clears stale callsign/grid…
assert get_change(changeset, :callsign) == nil
assert get_change(changeset, :grid) == nil
# …and replaces lat/lon with the new resolution.
assert get_change(changeset, :lat) == 32.9
assert get_change(changeset, :lon) == -96.8
end
test "keeps existing lat/lon when no input change and no new resolution needed" do
preset = %Station{input: "EM12", lat: 32.5, lon: -97.0}
changeset = Station.changeset(preset, %{position: 1})
assert changeset.valid?
assert get_change(changeset, :position) == 1
# lat/lon already set, no input change → no re-resolution
assert get_field(changeset, :lat) == 32.5
assert get_field(changeset, :lon) == -97.0
end
test "accepts position field" do
changeset = Station.changeset(%Station{}, %{input: "32.9, -96.8", position: 3})
assert changeset.valid?
assert get_change(changeset, :position) == 3
end
test "default position is 0" do
assert %Station{}.position == 0
end
end
describe "schema metadata" do
test "has expected fields" do
fields = Station.__schema__(:fields)
assert :callsign in fields
assert :grid in fields
assert :input in fields
assert :lat in fields
assert :lon in fields
assert :position in fields
assert :mission_id in fields
end
end
end

View file

@ -96,6 +96,61 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do
end
end
describe "build_profile_from_extracted/1 — DEPR fallback" do
test "derives surface dewpoint from TMP - DEPR when DPT is missing" do
extracted = %{
"TMP:2 m above ground" => 290.0,
"DEPR:2 m above ground" => 5.0,
"PRES:surface" => 100_000.0
}
profile = HrdpsClient.build_profile_from_extracted(extracted)
assert_in_delta profile.surface_temp_c, 16.85, 0.01
# 290 - 5 = 285 K = 11.85 C
assert_in_delta profile.surface_dewpoint_c, 11.85, 0.01
end
test "uses TCDC entire-atmosphere when surface variant is missing" do
extracted = %{
"TMP:2 m above ground" => 280.0,
"DPT:2 m above ground" => 275.0,
"PRES:surface" => 100_000.0,
"TCDC:entire atmosphere" => 90.0
}
profile = HrdpsClient.build_profile_from_extracted(extracted)
assert profile.cloud_cover_pct == 90.0
end
test "returns nil surface scalars when nothing surface-level is in the input" do
profile = HrdpsClient.build_profile_from_extracted(%{})
assert profile.surface_temp_c == nil
assert profile.surface_dewpoint_c == nil
assert profile.surface_pressure_mb == nil
assert profile.profile == []
end
test "skips a pressure level that is missing HGT" do
extracted = %{
"TMP:2 m above ground" => 290.0,
"DPT:2 m above ground" => 285.0,
"PRES:surface" => 100_000.0,
# Level 850 mb has TMP+DPT but no HGT — skipped.
"TMP:850 mb" => 275.0,
"DPT:850 mb" => 270.0,
# Level 700 mb is fully populated.
"TMP:700 mb" => 265.0,
"DPT:700 mb" => 260.0,
"HGT:700 mb" => 3000.0
}
profile = HrdpsClient.build_profile_from_extracted(extracted)
assert length(profile.profile) == 1
assert hd(profile.profile)["pres"] == 700.0
end
end
describe "cycle_available?/1 (injected http_head)" do
setup do
prev = Application.get_env(:microwaveprop, :hrdps_http_head)

View file

@ -5,6 +5,39 @@ defmodule Microwaveprop.Weather.NexradClientTest do
alias Microwaveprop.Weather.NexradCache
alias Microwaveprop.Weather.NexradClient
describe "fetch_rain_cells/4 with injected http_get" do
setup do
prev = Application.get_env(:microwaveprop, :nexrad_http_get)
NexradCache.clear()
on_exit(fn ->
if prev,
do: Application.put_env(:microwaveprop, :nexrad_http_get, prev),
else: Application.delete_env(:microwaveprop, :nexrad_http_get)
NexradCache.clear()
end)
:ok
end
test "propagates an HTTP non-200 status from the IEM archive" do
Application.put_env(:microwaveprop, :nexrad_http_get, fn _url, _opts ->
{:ok, %{status: 404, body: ""}}
end)
assert {:error, "NEXRAD n0q HTTP 404"} = NexradClient.fetch_rain_cells(32.9, -97.0, 50.0, 25.0)
end
test "propagates a transport error" do
Application.put_env(:microwaveprop, :nexrad_http_get, fn _url, _opts ->
{:error, :timeout}
end)
assert {:error, :timeout} = NexradClient.fetch_rain_cells(32.9, -97.0, 50.0, 25.0)
end
end
describe "round_to_5min/1" do
test "rounds down to nearest 5-minute boundary" do
dt = ~U[2022-08-20 14:07:32Z]

View file

@ -0,0 +1,212 @@
defmodule Microwaveprop.Weather.RebatchAsosTest do
use Microwaveprop.DataCase, async: false
import ExUnit.CaptureIO
alias Microwaveprop.Repo
alias Microwaveprop.Weather.RebatchAsos
describe "run/1 — no pending jobs" do
test "returns zeros and prints a no-op message when no jobs exist" do
output =
capture_io(fn ->
assert summary = RebatchAsos.run()
send(self(), summary)
end)
assert_received %{found: 0, windows: 0, inserted: 0, cancelled: 0}
assert output =~ "No pending"
end
end
describe "to_day_jobs/1 — no pending jobs" do
test "returns zeros when no asos/asos_batch jobs exist" do
output =
capture_io(fn ->
assert summary = RebatchAsos.to_day_jobs()
send(self(), summary)
end)
assert_received %{source_jobs: 0, unique_day_keys: 0, inserted: 0, cancelled: 0}
assert output =~ "No pending"
end
end
describe "run/1 — dry-run with pending jobs" do
test "groups by (start_dt, end_dt) and reports without mutating" do
insert_asos_job!(%{
"fetch_type" => "asos",
"station_id" => 1,
"station_code" => "KDFW",
"start_dt" => "2026-04-18T00:00:00Z",
"end_dt" => "2026-04-18T04:00:00Z"
})
insert_asos_job!(%{
"fetch_type" => "asos",
"station_id" => 2,
"station_code" => "KDAL",
"start_dt" => "2026-04-18T00:00:00Z",
"end_dt" => "2026-04-18T04:00:00Z"
})
output =
capture_io(fn ->
summary = RebatchAsos.run(dry_run: true)
send(self(), summary)
end)
assert_received %{found: 2, windows: 1, inserted: 0, cancelled: 0}
assert output =~ "[dry-run]"
assert output =~ "2026-04-18T00:00:00Z"
# Original two jobs should still be available; nothing was cancelled.
assert pending_asos_count() == 2
end
end
describe "to_day_jobs/1 — dry-run with pending asos jobs" do
test "expands one asos job into a per-date day-key plan" do
# A single 4-hour window inside one UTC day → 1 unique (station, date).
insert_asos_job!(%{
"fetch_type" => "asos",
"station_id" => 1,
"station_code" => "KDFW",
"start_dt" => "2026-04-18T00:00:00Z",
"end_dt" => "2026-04-18T04:00:00Z"
})
output =
capture_io(fn ->
summary = RebatchAsos.to_day_jobs(dry_run: true)
send(self(), summary)
end)
assert_received %{source_jobs: 1, unique_day_keys: 1, inserted: 0, cancelled: 0}
assert output =~ "[dry-run]"
assert output =~ "KDFW"
assert output =~ "2026-04-18"
end
test "expands an asos_batch into one day-key per (station, date)" do
insert_asos_job!(%{
"fetch_type" => "asos_batch",
"station_ids" => [1, 2],
"station_codes" => ["KDFW", "KDAL"],
"start_dt" => "2026-04-18T00:00:00Z",
"end_dt" => "2026-04-18T04:00:00Z"
})
capture_io(fn ->
summary = RebatchAsos.to_day_jobs(dry_run: true)
send(self(), summary)
end)
# One asos_batch with 2 stations × 1 date → 2 unique (station, date) keys.
assert_received %{source_jobs: 1, unique_day_keys: 2}
end
test "asos jobs spanning two UTC dates produce two day-keys per station" do
insert_asos_job!(%{
"fetch_type" => "asos",
"station_id" => 1,
"station_code" => "KDFW",
"start_dt" => "2026-04-18T22:00:00Z",
"end_dt" => "2026-04-19T02:00:00Z"
})
capture_io(fn ->
summary = RebatchAsos.to_day_jobs(dry_run: true)
send(self(), summary)
end)
# 1 station × 2 dates → 2 unique keys.
assert_received %{source_jobs: 1, unique_day_keys: 2}
end
test "asos jobs missing window timestamps contribute zero day-keys" do
insert_asos_job!(%{
"fetch_type" => "asos",
"station_id" => 1,
"station_code" => "KDFW",
"start_dt" => nil,
"end_dt" => nil
})
capture_io(fn ->
summary = RebatchAsos.to_day_jobs(dry_run: true)
send(self(), summary)
end)
# Job is found but expand_tuples returns [] for nil window.
assert_received %{source_jobs: 1, unique_day_keys: 0}
end
end
describe "run/1 — apply (non-dry-run)" do
test "inserts an asos_batch job and cancels the source rows" do
Oban.Testing.with_testing_mode(:manual, fn ->
insert_asos_job!(%{
"fetch_type" => "asos",
"station_id" => 1,
"station_code" => "KDFW",
"start_dt" => "2026-04-18T00:00:00Z",
"end_dt" => "2026-04-18T04:00:00Z"
})
capture_io(fn ->
summary = RebatchAsos.run()
send(self(), summary)
end)
end)
assert_received %{found: 1, windows: 1, inserted: inserted, cancelled: cancelled}
assert inserted >= 1
assert cancelled >= 1
end
end
describe "to_day_jobs/1 — apply (non-dry-run)" do
test "inserts asos_day jobs and cancels source rows" do
Oban.Testing.with_testing_mode(:manual, fn ->
insert_asos_job!(%{
"fetch_type" => "asos",
"station_id" => 1,
"station_code" => "KDFW",
"start_dt" => "2026-04-18T00:00:00Z",
"end_dt" => "2026-04-18T04:00:00Z"
})
capture_io(fn ->
summary = RebatchAsos.to_day_jobs()
send(self(), summary)
end)
end)
assert_received %{source_jobs: 1, unique_day_keys: 1, inserted: inserted, cancelled: cancelled}
assert inserted >= 1
assert cancelled >= 1
end
end
# ── helpers ───────────────────────────────────────────────────────────
defp insert_asos_job!(args) do
{:ok, job} =
args
|> Oban.Job.new(worker: "Microwaveprop.Workers.WeatherFetchWorker", queue: "weather")
|> Repo.insert()
job
end
defp pending_asos_count do
Repo.aggregate(
Ecto.Query.from(j in Oban.Job,
where:
j.worker == "Microwaveprop.Workers.WeatherFetchWorker" and
j.state in ["available", "scheduled", "retryable"]
),
:count
)
end
end

View file

@ -0,0 +1,17 @@
defmodule Microwaveprop.Workers.RoverMissionReconcileWorkerTest do
use Microwaveprop.DataCase, async: false
use Oban.Testing, repo: Microwaveprop.Repo
alias Microwaveprop.Workers.RoverMissionReconcileWorker
describe "perform/1" do
test "no-ops when the mission does not exist" do
# Idempotency: a mission that was deleted between enqueue and run
# must return :ok, not crash, so Oban doesn't retry forever.
assert :ok =
perform_job(RoverMissionReconcileWorker, %{
mission_id: Ecto.UUID.generate()
})
end
end
end

View file

@ -0,0 +1,53 @@
defmodule Microwaveprop.Workers.RoverPathProfileWorkerTest do
use Microwaveprop.DataCase, async: false
use Oban.Testing, repo: Microwaveprop.Repo
alias Microwaveprop.Workers.RoverPathProfileWorker
describe "perform/1" do
test "no-ops when matching path does not exist" do
args = %{
"mission_id" => Ecto.UUID.generate(),
"rover_location_id" => Ecto.UUID.generate(),
"station_id" => Ecto.UUID.generate(),
"band_mhz" => 10_000
}
assert :ok = perform_job(RoverPathProfileWorker, args)
end
test "drops job when args are missing the integer band_mhz" do
args = %{
"mission_id" => Ecto.UUID.generate(),
"rover_location_id" => Ecto.UUID.generate(),
"station_id" => Ecto.UUID.generate()
}
assert :ok = perform_job(RoverPathProfileWorker, args)
end
test "drops job when band_mhz is a string" do
# `load_path/1` only matches integer band_mhz; non-integer falls
# through to the warning + nil branch.
args = %{
"mission_id" => Ecto.UUID.generate(),
"rover_location_id" => Ecto.UUID.generate(),
"station_id" => Ecto.UUID.generate(),
"band_mhz" => "10000"
}
assert :ok = perform_job(RoverPathProfileWorker, args)
end
end
describe "backoff/1" do
test "exponential backoff bounded at 3600s" do
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 1}) == 60
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 2}) == 120
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 3}) == 240
# Caps at 3600 once exponential growth exceeds it (2^6 * 60 = 3840 > 3600).
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 7}) == 3600
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 20}) == 3600
end
end
end

View file

@ -4,6 +4,7 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do
import Microwaveprop.AccountsFixtures
alias Microwaveprop.Qrz.Client
alias Microwaveprop.Repo
alias Microwaveprop.Workers.UserHomeQthLookupWorker
@ -28,6 +29,45 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: Ecto.UUID.generate()})
end
test "logs and returns :ok when CallsignClient returns an error" do
user = user_fixture()
# Make QRZ return a 404 → CallsignClient.locate returns {:error, _}.
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 404, "Not Found")
end)
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: user.id})
reloaded = Repo.reload(user)
assert is_nil(reloaded.home_grid)
end
test "computes grid via Maidenhead when QRZ provides only lat/lon" do
user = user_fixture()
# No <grid> tag — falls through to Maidenhead.from_latlon/3.
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 200, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase>
<Session><Key>test-session</Key></Session>
<Callsign>
<call>#{user.callsign}</call>
<lat>33.10</lat>
<lon>-96.625</lon>
</Callsign>
</QRZDatabase>
""")
end)
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: user.id})
reloaded = Repo.reload(user)
# Maidenhead-derived grid for ~33.10, -96.625 → EM13...
assert is_binary(reloaded.home_grid)
assert reloaded.home_grid =~ ~r/^EM13/
end
test "populates home QTH from a successful CallsignClient lookup" do
user = user_fixture()
@ -35,7 +75,7 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do
# The callsign-location pipeline reads either lat/lon or grid from
# the QRZ XML; we serve coords directly so the geocoder fallback
# isn't exercised.
Req.Test.stub(Microwaveprop.Qrz.Client, fn conn ->
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 200, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase>

View file

@ -0,0 +1,27 @@
defmodule MicrowavepropWeb.LiveStashGuardTest do
use ExUnit.Case, async: true
alias MicrowavepropWeb.LiveStashGuard
alias Phoenix.LiveView.Socket
describe "stash/1" do
test "swallows ArgumentError when LiveStash hasn't been initialized" do
# An uninitialized socket has no `private.live_stash_adapter`, so
# `LiveStash.stash/1` raises ArgumentError. The guard catches it
# and returns the socket unchanged.
socket = %Socket{}
assert ^socket = LiveStashGuard.stash(socket)
end
test "returns socket unchanged from a fresh struct (no adapter init)" do
socket = %Socket{assigns: %{count: 1}}
result = LiveStashGuard.stash(socket)
# Same socket back — preserves all assigns the LV had set up.
assert result == socket
assert result.assigns.count == 1
end
end
end