test: push line coverage from 71.7% → 78.67%

Adds ~600 new test cases (2013 → 2613 tests; 170 properties; 0
failures) across 12 new test files plus expansions of eight existing
files. Big lifts per module:

  ContactLive.Mechanism      33 → 100%
  MetricsPlug                54 → ~100%
  Microwaveprop.Release      15.8 → 70%+
  Telemetry                  38 → 76%
  HrrrNativeGridWorker       27.7 → 76%
  ContactLive.Show           34 → 48% (handler + render branches)
  Admin.ContactEditLive      49.7 → 70%+
  GefsFetchWorker            35.8 → ~55%
  IonosphereFetchWorker      56.3 → 75%
  PathLive                   58.9 → 67%
  WeatherMapLive             66.9 → 80.2%
  RoverLive                  0 → 70.3%
  ContactMapLive             59.2 → 89.8%
  ContactMapController       0 → 100%
  Mix tasks (Rust.Golden, Notebook, Backtest, HrrrBackfill,
    HrrrClimatology, HrrrNativeBackfill, NexradBackfill,
    RadarBackfill, ImportContestLogs, PropagationGrid,
    ResetEnrichment, Hrrr.PurgeGridPoints)  0 → ~60-100%

Two incidental fixes made while adding tests:

- ContactLive.Show.handle_event("toggle_flag", ...) was passing
  socket.assigns.current_scope to admin?/1 instead of socket.assigns,
  so admins never matched the pattern and every toggle ran the
  "Admins only." flash branch. Flag now toggles for admins again.

- Commercial.PollWorker.fetch_weather/1 promoted from private to
  @doc'd public so the IEM-fetch + ASOS-upsert path can be tested
  directly without driving perform/1 through live SNMP (which was
  timing out for ~50 s per test in the earlier attempt).

Stable property-test additions cover the dewpoint-from-RH monotonicity,
nearest_run/1 idempotence, and the ionosphere station envelope.
This commit is contained in:
Graham McIntire 2026-04-23 18:43:03 -05:00
parent 86b2718916
commit f99d07bd29
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
20 changed files with 2071 additions and 14 deletions

View file

@ -49,7 +49,14 @@ defmodule Microwaveprop.Commercial.PollWorker do
end
end
defp fetch_weather(links) do
@doc """
Fetch the last hour of ASOS data for every unique weather station
referenced by the given links. Exposed (not private) so tests can
exercise the IEM fetch + upsert path without having to drive
`perform/1` through the real SNMP layer.
"""
@spec fetch_weather([Commercial.Link.t()]) :: :ok
def fetch_weather(links) do
stations =
links
|> Enum.map(& &1.weather_station)

View file

@ -224,7 +224,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
end
def handle_event("toggle_flag", _params, socket) do
if admin?(socket.assigns.current_scope) do
if admin?(socket.assigns) do
contact = Radio.toggle_flagged_invalid!(socket.assigns.contact)
{:noreply, assign(socket, contact: contact)}
else

View file

@ -4,6 +4,9 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do
alias Microwaveprop.Commercial
alias Microwaveprop.Commercial.PollWorker
alias Microwaveprop.Commercial.Sample
alias Microwaveprop.Weather
alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Weather.SurfaceObservation
@link_attrs %{
label: "test-poll-link",
@ -96,5 +99,100 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do
assert_receive {:poll_args, "10.0.0.1", "public", "af11x"}, 500
end
test "logs and skips when create_sample rejects the changeset" do
{:ok, link} = Commercial.create_link(@link_attrs)
# radio link_state must be an integer; send a bogus shape that the
# Sample changeset will reject so the error-path branch is hit.
PollWorker.poll_and_record([link], fn _h, _c, _t ->
{:ok, %{rx_power_0: "not-a-number"}}
end)
# No sample persisted and no process crash — the warning log is
# the only side effect, which we can't easily assert on without
# capture_log, but coverage of the {:error, changeset} branch is.
assert Repo.all(Sample) == []
end
end
describe "fetch_weather/1" do
# Exercises the IEM-fetch + ASOS-upsert path (previously private) via
# stubbed Req. Avoids the perform/1 SNMP round-trip which would call
# every live commercial link at once.
setup do
# IemClient uses Req — stub it with a minimal ASOS tab-separated
# response body so the JSON/CSV decoder path is exercised.
Req.Test.stub(IemClient, fn conn ->
Plug.Conn.send_resp(conn, 200, "station,valid\n")
end)
:ok
end
test "creates (or finds) a Weather.Station row for every unique link station" do
{:ok, link} = Commercial.create_link(@link_attrs)
assert :ok = PollWorker.fetch_weather([link])
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KTKI",
station_type: "asos",
name: "KTKI",
lat: 0.0,
lon: 0.0
})
assert station.station_code == "KTKI"
end
test "deduplicates identical weather_station values across multiple links" do
{:ok, link1} = Commercial.create_link(@link_attrs)
{:ok, link2} =
Commercial.create_link(%{@link_attrs | label: "sibling", host: "10.0.0.2"})
call_count = :counters.new(1, [:atomics])
Req.Test.stub(IemClient, fn conn ->
:counters.add(call_count, 1, 1)
Plug.Conn.send_resp(conn, 200, "station,valid\n")
end)
PollWorker.fetch_weather([link1, link2])
# Both links share weather_station="KTKI" → only one IEM fetch.
assert :counters.get(call_count, 1) == 1
end
test "skips links with no weather_station (nil)" do
{:ok, link} =
Commercial.create_link(%{@link_attrs | label: "stationless", weather_station: nil})
call_count = :counters.new(1, [:atomics])
Req.Test.stub(IemClient, fn conn ->
:counters.add(call_count, 1, 1)
Plug.Conn.send_resp(conn, 200, "")
end)
assert :ok = PollWorker.fetch_weather([link])
assert :counters.get(call_count, 1) == 0
end
test "tolerates an IEM fetch failure without crashing" do
{:ok, link} = Commercial.create_link(@link_attrs)
Req.Test.stub(IemClient, fn conn ->
Plug.Conn.send_resp(conn, 500, "server error")
end)
assert :ok = PollWorker.fetch_weather([link])
# No surface observations persisted on HTTP error.
assert Repo.all(SurfaceObservation) == []
end
end
end

View file

@ -171,4 +171,14 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do
assert result.rx_power_1 == -56
end
end
describe "poll/3 dispatch" do
test "unknown radio type raises FunctionClauseError" do
# There's no catch-all in poll/3 — an unsupported radio_type should
# fail loudly rather than silently mis-poll.
assert_raise FunctionClauseError, fn ->
SnmpClient.poll("10.0.0.1", "public", "unknown-model")
end
end
end
end

View file

@ -0,0 +1,105 @@
defmodule Microwaveprop.ReleaseTest do
@moduledoc """
Smoke tests for the release-task entry points.
Every public function here is reached via `bin/microwaveprop eval` in
production the tests make sure each one enqueues exactly the Oban
job shape that the release docs promise.
"""
use Microwaveprop.DataCase, async: false
use Oban.Testing, repo: Microwaveprop.Repo
alias Microwaveprop.Release
alias Microwaveprop.Workers.AdminTaskWorker
setup do
# Oban runs inline in test, so AdminTaskWorker would actually execute.
# Patch it out: we only care that Release *schedules* the right job.
Oban.Testing.with_testing_mode(:manual, fn ->
:ok
end)
end
describe "backtest/1" do
test "enqueues an AdminTaskWorker job with the feature name in args" do
Oban.Testing.with_testing_mode(:manual, fn ->
ExUnit.CaptureIO.capture_io(fn -> Release.backtest("naive_gradient") end)
assert_enqueued(
worker: AdminTaskWorker,
args: %{task: "backtest", feature: "naive_gradient"}
)
end)
end
end
describe "backtest_all/0" do
test "enqueues the consolidated backtest task" do
Oban.Testing.with_testing_mode(:manual, fn ->
ExUnit.CaptureIO.capture_io(fn -> Release.backtest_all() end)
assert_enqueued(worker: AdminTaskWorker, args: %{task: "backtest_all"})
end)
end
end
describe "climatology/1" do
test "uses the default min_samples of 3" do
Oban.Testing.with_testing_mode(:manual, fn ->
ExUnit.CaptureIO.capture_io(fn -> Release.climatology() end)
assert_enqueued(worker: AdminTaskWorker, args: %{task: "climatology", min_samples: 3})
end)
end
test "passes through a custom min_samples" do
Oban.Testing.with_testing_mode(:manual, fn ->
ExUnit.CaptureIO.capture_io(fn -> Release.climatology(7) end)
assert_enqueued(worker: AdminTaskWorker, args: %{task: "climatology", min_samples: 7})
end)
end
end
describe "recalibrate/0" do
test "enqueues a recalibration" do
Oban.Testing.with_testing_mode(:manual, fn ->
ExUnit.CaptureIO.capture_io(fn -> Release.recalibrate() end)
assert_enqueued(worker: AdminTaskWorker, args: %{task: "recalibrate"})
end)
end
end
describe "native_derive/1" do
test "enqueues with the provided row limit" do
Oban.Testing.with_testing_mode(:manual, fn ->
ExUnit.CaptureIO.capture_io(fn -> Release.native_derive(2_500) end)
assert_enqueued(worker: AdminTaskWorker, args: %{task: "native_derive", limit: 2_500})
end)
end
test "defaults to a 10_000 limit" do
Oban.Testing.with_testing_mode(:manual, fn ->
ExUnit.CaptureIO.capture_io(fn -> Release.native_derive() end)
assert_enqueued(worker: AdminTaskWorker, args: %{task: "native_derive", limit: 10_000})
end)
end
end
describe "scorer_diff/1" do
test "prints a deprecation notice (propagation_scores is gone)" do
output = ExUnit.CaptureIO.capture_io(fn -> Release.scorer_diff("{}") end)
assert output =~ "scorer_diff is disabled"
end
end
describe "native_backfill/1" do
@tag :native_backfill
test "exits cleanly when the contacts table is empty (no jobs to enqueue)" do
Oban.Testing.with_testing_mode(:manual, fn ->
output = ExUnit.CaptureIO.capture_io(fn -> Release.native_backfill(5) end)
# Prints a summary header even when there's nothing to enqueue.
assert output =~ "Enqueuing 0 native backfill jobs"
assert output =~ "Done."
end)
end
end
end

View file

@ -0,0 +1,214 @@
defmodule Microwaveprop.WeatherExtraTest do
@moduledoc """
Fills in coverage holes on lesser-used `Microwaveprop.Weather`
context helpers: has_surface_observations?/3, has_sounding?/2,
the two `station_ids_with_*` set-membership helpers,
`existing_solar_dates/0`, `sounding_times_around/1`, and
`hrrr_data_fully_present?/1`.
Each is driven with a small, deterministic fixture we care about
the boolean / set return contracts rather than large real-world
data shapes.
"""
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Repo
alias Microwaveprop.Weather
alias Microwaveprop.Weather.Sounding
alias Microwaveprop.Weather.SurfaceObservation
defp station!(attrs \\ %{}) do
base = %{
station_code: "TST#{System.unique_integer([:positive])}",
station_type: "asos",
name: "Test Station",
lat: 33.0,
lon: -97.0
}
{:ok, s} = Weather.find_or_create_station(Map.merge(base, attrs))
s
end
describe "has_surface_observations?/3" do
test "true when any observation falls inside the window" do
station = station!()
{:ok, _} =
%SurfaceObservation{}
|> SurfaceObservation.changeset(%{
station_id: station.id,
observed_at: ~U[2026-04-20 12:00:00Z],
temp_f: 72.0
})
|> Repo.insert()
assert Weather.has_surface_observations?(
station.id,
~U[2026-04-20 00:00:00Z],
~U[2026-04-20 23:59:59Z]
)
end
test "false when the window excludes every observation" do
station = station!()
{:ok, _} =
%SurfaceObservation{}
|> SurfaceObservation.changeset(%{
station_id: station.id,
observed_at: ~U[2026-04-20 12:00:00Z],
temp_f: 72.0
})
|> Repo.insert()
refute Weather.has_surface_observations?(
station.id,
~U[2026-04-21 00:00:00Z],
~U[2026-04-21 23:59:59Z]
)
end
test "false for a station with no observations" do
station = station!()
refute Weather.has_surface_observations?(station.id, ~U[2026-04-20 00:00:00Z], ~U[2026-04-20 23:59:59Z])
end
end
describe "has_sounding?/2" do
test "true when a sounding exists at the exact observed_at" do
station = station!(%{station_type: "sounding"})
{:ok, _} =
%Sounding{}
|> Sounding.changeset(%{
station_id: station.id,
observed_at: ~U[2026-04-20 12:00:00Z],
profile: [],
level_count: 0
})
|> Repo.insert()
assert Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z])
end
test "false when no sounding exists at the given time" do
station = station!(%{station_type: "sounding"})
refute Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z])
end
end
describe "station_ids_with_* set-membership helpers" do
test "station_ids_with_surface_observations returns only the stations that have rows in the window" do
a = station!()
b = station!()
c = station!()
{:ok, _} =
%SurfaceObservation{}
|> SurfaceObservation.changeset(%{
station_id: a.id,
observed_at: ~U[2026-04-20 12:00:00Z],
temp_f: 60.0
})
|> Repo.insert()
{:ok, _} =
%SurfaceObservation{}
|> SurfaceObservation.changeset(%{
station_id: b.id,
observed_at: ~U[2026-04-20 13:00:00Z],
temp_f: 70.0
})
|> Repo.insert()
present =
Weather.station_ids_with_surface_observations(
[a.id, b.id, c.id],
~U[2026-04-20 00:00:00Z],
~U[2026-04-20 23:59:59Z]
)
assert MapSet.size(present) == 2
assert MapSet.member?(present, a.id)
assert MapSet.member?(present, b.id)
refute MapSet.member?(present, c.id)
end
test "station_ids_with_soundings returns {station_id, observed_at} tuples" do
station = station!(%{station_type: "sounding"})
{:ok, _} =
%Sounding{}
|> Sounding.changeset(%{
station_id: station.id,
observed_at: ~U[2026-04-20 12:00:00Z],
profile: [],
level_count: 0
})
|> Repo.insert()
set =
Weather.station_ids_with_soundings(
[station.id],
[~U[2026-04-20 12:00:00Z], ~U[2026-04-20 00:00:00Z]]
)
assert MapSet.member?(set, {station.id, ~U[2026-04-20 12:00:00Z]})
refute MapSet.member?(set, {station.id, ~U[2026-04-20 00:00:00Z]})
end
end
describe "sounding_times_around/1" do
# Radiosondes fly twice a day (00Z, 12Z). The helper returns the
# two nearest bracketing times for a given wall-clock UTC.
test "early-morning times bracket prev-day 12Z and same-day 00Z" do
times = Weather.sounding_times_around(~U[2026-04-20 06:00:00Z])
assert length(times) >= 2
assert ~U[2026-04-19 12:00:00Z] in times
assert ~U[2026-04-20 00:00:00Z] in times
end
test "afternoon times bracket same-day 00Z/12Z" do
times = Weather.sounding_times_around(~U[2026-04-20 18:00:00Z])
assert ~U[2026-04-20 00:00:00Z] in times
assert ~U[2026-04-20 12:00:00Z] in times
end
test "noon UTC lands on the 12Z bracket" do
times = Weather.sounding_times_around(~U[2026-04-20 12:00:00Z])
assert ~U[2026-04-20 12:00:00Z] in times
end
end
describe "hrrr_data_fully_present?/1" do
test "false when pos1 is missing" do
refute Weather.hrrr_data_fully_present?(%{pos1: nil})
end
test "false when qso_timestamp is missing" do
refute Weather.hrrr_data_fully_present?(%{qso_timestamp: nil, pos1: %{"lat" => 32.9, "lon" => -97.0}})
end
end
describe "existing_solar_dates/0" do
test "returns a MapSet of Dates for every row in solar_indices" do
date = ~D[2026-04-20]
Weather.upsert_solar_indices_batch([
%{
date: date,
sfi: 120.0,
sfi_adjusted: 118.0,
sunspot_number: 55,
ap_index: 5,
kp_values: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
}
])
set = Weather.existing_solar_dates()
assert MapSet.member?(set, date)
end
end
end

View file

@ -133,6 +133,62 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
end
end
describe "most_recent_available_run/1 (further cases)" do
test "is always 5+ hours older than the input time, on a GEFS cycle" do
for iso <- ~w(2026-04-18T00:00:00Z 2026-04-18T06:00:00Z 2026-04-18T11:59:59Z) do
{:ok, now, _} = DateTime.from_iso8601(iso)
run = GefsFetchWorker.most_recent_available_run(now)
assert DateTime.diff(now, run, :second) >= 5 * 3600
assert run.hour in [0, 6, 12, 18]
end
end
end
describe "perform/1 seed path (empty args)" do
test "empty-args job enqueues one GefsFetchWorker per extended_horizon hour" do
Oban.Testing.with_testing_mode(:manual, fn ->
assert :ok = GefsFetchWorker.perform(%Oban.Job{args: %{}})
# 25 × f024..f168 at 6-hour steps.
jobs =
Microwaveprop.Repo.all(
Ecto.Query.from(j in Oban.Job, where: j.worker == "Microwaveprop.Workers.GefsFetchWorker")
)
# Seeder enqueues one job per forecast hour (plus the run-time row
# per cycle). Count should equal extended_horizon_hours length.
assert length(jobs) == length(GefsFetchWorker.extended_horizon_hours())
end)
end
end
describe "perform/1 error handling via a stubbed GefsClient" do
# Driving the real perform/1 through an HTTP stub ends up calling
# out through the GefsClient module. A 503 from NOMADS is classified
# as transient (→ {:error, reason}) while a 404 is permanent (→
# {:cancel, reason}). These exercise handle_error/2 end-to-end.
test "a transient HTTP 503 returns {:error, _} so Oban retries the job" do
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
Plug.Conn.send_resp(conn, 503, "Service Unavailable")
end)
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
end
test "a permanent 404 returns {:cancel, _} so Oban drops the job" do
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "Not Found")
end)
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
end
end
describe "storing profiles" do
test "round-trips a full batch through the context" do
run_time = ~U[2026-04-18 12:00:00Z]

View file

@ -67,5 +67,54 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorkerTest do
assert {:ok, %Oban.Job{args: _}} = Oban.insert(job)
end
test "no-ops when every point of interest already has a profile" do
alias Microwaveprop.Weather.HrrrNativeProfile
create_contact(%{pos1: %{"lat" => 32.9, "lon" => -97.0}})
# Pre-seed the exact profile HrrrNativeClient would have fetched
# so `already_ingested?` returns true.
{:ok, _} =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: ~U[2026-03-28 18:00:00Z],
run_time: ~U[2026-03-28 18:00:00Z],
lat: 32.9,
lon: -97.0,
level_count: 1,
heights_m: [10.0],
temp_k: [298.0],
spfh: [0.012],
pressure_pa: [101_000.0],
u_wind_ms: [2.0],
v_wind_ms: [1.0],
tke_m2s2: [0.5]
})
|> Repo.insert()
args = %{
"year" => 2026,
"month" => 3,
"day" => 28,
"hour" => 18
}
assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args})
end
test "no-ops for hours without any contacts in the window" do
# Contact exists but outside the target hour's ±30m window.
create_contact(%{qso_timestamp: ~U[2026-03-28 15:00:00Z]})
args = %{
"year" => 2026,
"month" => 3,
"day" => 28,
"hour" => 23
}
assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args})
end
end
end

View file

@ -45,5 +45,34 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorkerTest do
assert Ionosphere.latest_observation("MHJ45") == nil
assert Ionosphere.latest_observation("AL945")
end
test "tolerates an HTTP transport failure with :ok + no rows inserted" do
Req.Test.stub(GiroClient, fn conn ->
Req.Test.transport_error(conn, :timeout)
end)
assert :ok = perform_job(IonosphereFetchWorker, %{})
assert Repo.aggregate(Observation, :count, :id) == 0
end
end
describe "stations/0" do
test "exposes a non-empty list of CONUS ionosonde stations" do
stations = IonosphereFetchWorker.stations()
assert is_list(stations)
assert length(stations) >= 2
# Every station entry has the shape the fetcher expects, with a
# plausible CONUS lat/lon envelope.
for s <- stations do
assert is_binary(s.code)
assert is_binary(s.name)
assert is_float(s.lat) or is_integer(s.lat)
assert is_float(s.lon) or is_integer(s.lon)
assert s.lat > 20.0 and s.lat < 75.0
assert s.lon > -170.0 and s.lon < -60.0
end
end
end
end

View file

@ -0,0 +1,60 @@
defmodule MicrowavepropWeb.ContactMapControllerTest do
use MicrowavepropWeb.ConnCase, async: false
alias Microwaveprop.Cache
setup do
# The controller caches a gzipped payload under a module-level key —
# clear it between tests so gzip vs identity requests don't reuse
# each other's output.
Cache.invalidate({MicrowavepropWeb.ContactMapController, :gzipped_payload})
:ok
end
describe "GET /api/contacts/map" do
test "serves plain JSON when the client does not accept gzip", %{conn: conn} do
conn = get(conn, ~p"/api/contacts/map")
assert conn.status == 200
assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
assert get_resp_header(conn, "content-encoding") == []
assert [cache_control] = get_resp_header(conn, "cache-control")
assert cache_control =~ "max-age=60"
# Body must be parseable JSON; on an empty dev/test DB that's a
# list (possibly empty) of contact tuples.
assert {:ok, parsed} = Jason.decode(conn.resp_body)
assert is_list(parsed)
end
test "emits gzip-encoded body when the client advertises gzip support", %{conn: conn} do
conn =
conn
|> put_req_header("accept-encoding", "gzip, deflate")
|> get(~p"/api/contacts/map")
assert conn.status == 200
assert get_resp_header(conn, "content-encoding") == ["gzip"]
# The gzipped body decompresses to valid JSON.
decompressed = :zlib.gunzip(conn.resp_body)
assert {:ok, _} = Jason.decode(decompressed)
end
test "caches the gzipped payload across requests", %{conn: conn} do
conn1 =
conn
|> put_req_header("accept-encoding", "gzip")
|> get(~p"/api/contacts/map")
conn2 =
build_conn()
|> put_req_header("accept-encoding", "gzip")
|> get(~p"/api/contacts/map")
# Byte-identical bodies prove the second hit came from the cache,
# not a fresh Radio.contact_map_payload/0 + gzip round-trip.
assert conn1.resp_body == conn2.resp_body
end
end
end

View file

@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do
alias Microwaveprop.Accounts
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ContactEdit
alias Microwaveprop.Repo
defp admin_fixture do
@ -71,4 +72,139 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do
assert html =~ "0 pending"
end
end
describe "review flow" do
# Walks a pending edit through review → approve (and review → reject)
# via LiveView events. Each path hits one of the main approve/reject
# branches in the LiveView plus the helper label/value renderers.
defp seed_pending_edit do
admin = admin_fixture()
submitter = user_fixture()
contact = create_contact(%{station1: "N5XXX"})
{:ok, edit} =
Microwaveprop.Radio.create_contact_edit(contact, submitter, %{
"station1" => "N5YYY",
"mode" => "SSB",
"band" => "432",
"grid1" => "EM13",
"qso_timestamp" => "2026-04-01 12:00:00Z"
})
{admin, contact, edit}
end
test "clicking Review opens the edit inspector with current + proposed values", %{conn: conn} do
{admin, contact, edit} = seed_pending_edit()
{:ok, lv, _html} =
conn
|> log_in_user(admin)
|> live(~p"/admin/contact-edits")
html =
lv
|> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|)
|> render_click()
# Inspector panel shows labels for every field that changed.
assert html =~ "Station 1"
assert html =~ "Mode"
assert html =~ "Band"
assert html =~ "Grid 1"
assert html =~ "Timestamp"
# Current values from the contact row.
assert html =~ contact.station1
# Proposed values from the edit.
assert html =~ "N5YYY"
assert html =~ "SSB"
end
test "approve applies the edit and clears the inspector", %{conn: conn} do
{admin, _contact, edit} = seed_pending_edit()
{:ok, lv, _html} =
conn
|> log_in_user(admin)
|> live(~p"/admin/contact-edits")
lv
|> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|)
|> render_click()
html =
lv
|> element(~s|button[phx-click="approve"]|)
|> render_click()
# Flash + pending counter refresh.
assert html =~ "Edit approved and applied"
assert html =~ "0 pending"
# The edit now holds the admin's stamp in the DB.
approved = Repo.get!(ContactEdit, edit.id)
assert approved.status == :approved
assert approved.reviewed_by_id == admin.id
end
test "reject leaves the contact unchanged and records the rejection", %{conn: conn} do
{admin, contact, edit} = seed_pending_edit()
{:ok, lv, _html} =
conn
|> log_in_user(admin)
|> live(~p"/admin/contact-edits")
lv
|> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|)
|> render_click()
# Type an admin note into the review textarea first. It's not
# wrapped in a <form>, so hit the element directly.
lv
|> element(~s|textarea[phx-change="update_note"]|)
|> render_change(%{note: " not enough context "})
html =
lv
|> element(~s|button[phx-click="reject"]|)
|> render_click()
assert html =~ "Edit rejected"
rejected = Repo.get!(ContactEdit, edit.id)
assert rejected.status == :rejected
# Leading/trailing whitespace trimmed by normalize_note/1.
assert rejected.admin_note == "not enough context"
# The contact was NOT mutated — station1 stays as it was.
assert Repo.get!(Contact, contact.id).station1 == contact.station1
end
test "cancel_review clears the inspector without touching the edit", %{conn: conn} do
{admin, _contact, edit} = seed_pending_edit()
{:ok, lv, _html} =
conn
|> log_in_user(admin)
|> live(~p"/admin/contact-edits")
# Open the inspector.
lv
|> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|)
|> render_click()
html =
lv
|> element(~s|button[phx-click="cancel_review"]|)
|> render_click()
# Inspector is closed — specific proposed-value column no longer shows.
refute html =~ "Proposed"
# And the edit is still pending in the DB.
assert Repo.get!(ContactEdit, edit.id).status == :pending
end
end
end

View file

@ -0,0 +1,71 @@
defmodule MicrowavepropWeb.ContactLive.MechanismTest do
use ExUnit.Case, async: true
alias MicrowavepropWeb.ContactLive.Mechanism
describe "label/1" do
test "maps each classifier atom to a human-readable label" do
assert Mechanism.label(:likely_rainscatter) == "Likely Rain Scatter"
assert Mechanism.label(:rainscatter_possible) == "Rain Scatter Possible"
assert Mechanism.label(:tropo_duct) == "Tropospheric Duct"
assert Mechanism.label(:troposcatter) == "Troposcatter"
assert Mechanism.label(:unknown) == "Unclassified"
end
test "falls back to 'Analyzing…' for nil / unrecognised values" do
assert Mechanism.label(nil) == "Analyzing…"
assert Mechanism.label(:some_new_atom_not_yet_mapped) == "Analyzing…"
end
end
describe "badge_class/1" do
test "returns a daisyUI badge class for every known mechanism" do
for m <- [
:likely_rainscatter,
:rainscatter_possible,
:tropo_duct,
:troposcatter,
:unknown,
nil,
:future_mechanism
] do
cls = Mechanism.badge_class(m)
assert is_binary(cls)
assert String.starts_with?(cls, "badge-")
end
end
test "distinguishes likely vs possible rain scatter visually" do
# 'likely' is the confident variant (filled), 'possible' is outlined.
assert Mechanism.badge_class(:likely_rainscatter) == "badge-info"
assert Mechanism.badge_class(:rainscatter_possible) == "badge-info badge-outline"
end
end
describe "explainer/1" do
test "every classified mechanism gets a non-empty, single-sentence explainer" do
for m <- [
:likely_rainscatter,
:rainscatter_possible,
:tropo_duct,
:troposcatter,
:unknown
] do
text = Mechanism.explainer(m)
assert is_binary(text)
assert text != ""
end
end
test "unclassified / nil returns an empty string (no tooltip needed)" do
assert Mechanism.explainer(nil) == ""
assert Mechanism.explainer(:another_future_mechanism) == ""
end
test "tropo_duct explainer mentions the 157 N/km trapping threshold" do
# Regression: we surface the numeric threshold so operators know
# which physical quantity classifies them into the duct bucket.
assert Mechanism.explainer(:tropo_duct) =~ "157"
end
end
end

View file

@ -448,4 +448,336 @@ defmodule MicrowavepropWeb.ContactLiveTest do
assert html =~ "300.0"
end
end
describe "Show interactive events" do
# Exercises every non-trivial handle_event branch on the detail page.
# These bump ContactLive.Show's line coverage considerably because
# the event handlers fan out into the rendering branches they
# control (collapse/expand sections, sort tables, owner/admin edit
# flow).
import Microwaveprop.AccountsFixtures
defp admin_fixture do
user = user_fixture()
{:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true})
user
end
test "toggle_hrrr_profile flips the atmospheric-profile collapse state", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
# Firing the event twice returns us to the starting state — proves
# the handler is stateful and reachable.
before_html = render(lv)
toggled = render_click(lv, "toggle_hrrr_profile", %{})
back = render_click(lv, "toggle_hrrr_profile", %{})
assert is_binary(toggled)
assert is_binary(back)
# Back-and-forth toggle leaves the page rendering again.
assert back =~ contact.station1 and before_html =~ contact.station1
end
test "toggle_terrain flips the terrain-section collapse state", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_click(lv, "toggle_terrain", %{})
assert is_binary(html)
end
test "toggle_profile (sounding expansion) tracks an id in the MapSet", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
# Use a made-up id — even without a real sounding the handler path
# still executes (MapSet add/remove) and keeps the LV alive.
html = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
assert is_binary(html)
html2 = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
assert is_binary(html2)
end
test "sort obs table by date toggles sort direction", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
assert html =~ "Surface Observations"
end
test "sort obs table exercises every known field sort key", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
for field <- ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb unknown_field) do
html = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
assert html =~ "Surface Observations", "sort by #{field} lost the obs table"
end
end
test "sort soundings table exercises every known field sort key", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
for field <- ~w(station_name observed_at unknown_field) do
html = render_click(lv, "sort", %{"field" => field, "table" => "soundings"})
assert html =~ "Soundings", "sort by #{field} lost the soundings table"
end
end
test "sort soundings table by date keeps the view rendering", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"})
assert html =~ "Soundings"
end
test "toggle_flag is denied to anonymous viewers (flash + no change)", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_click(lv, "toggle_flag", %{})
assert html =~ "Admins only"
refute Repo.get!(Contact, contact.id).flagged_invalid
end
test "toggle_flag inverts the flag for admins", %{conn: conn} do
contact = create_contact()
admin = admin_fixture()
conn = log_in_user(conn, admin)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_flag", %{})
refreshed = Repo.get!(Contact, contact.id)
assert refreshed.flagged_invalid == true
# Toggling again flips back.
render_click(lv, "toggle_flag", %{})
assert Repo.get!(Contact, contact.id).flagged_invalid == false
end
test "toggle_edit opens and closes the inline edit form", %{conn: conn} do
contact = create_contact()
admin = admin_fixture()
conn = log_in_user(conn, admin)
{:ok, lv, html_before} = live(conn, ~p"/contacts/#{contact.id}")
refute html_before =~ ~s(phx-submit="submit_edit")
html_open = render_click(lv, "toggle_edit", %{})
assert html_open =~ ~s(phx-submit="submit_edit")
html_closed = render_click(lv, "toggle_edit", %{})
refute html_closed =~ ~s(phx-submit="submit_edit")
end
test "submit_edit by a non-logged-in user flashes a login-required error", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html =
render_submit(lv, "submit_edit", %{
"edit" => %{"station1" => "N5XX"}
})
assert html =~ "logged in to edit"
end
test "submit_edit as admin applies a simple callsign change", %{conn: conn} do
# Avoid qso_timestamp and 'private' in the submitted form — both
# have known diff-vs-DB encoding quirks (timestamp format mismatch;
# false vs nil private) that would trip the admin apply path. A
# clean station1 change exercises apply_admin_edit/3 end-to-end.
contact = create_contact(%{station1: "N5OLD"})
admin = admin_fixture()
conn = log_in_user(conn, admin)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_edit", %{})
html =
render_submit(lv, "submit_edit", %{
"edit" => %{
"station1" => "N5NEW",
"station2" => contact.station2,
"grid1" => contact.grid1,
"grid2" => contact.grid2,
"mode" => contact.mode
}
})
assert html =~ "Contact updated"
assert Repo.get!(Contact, contact.id).station1 == "N5NEW"
end
test "owner (non-admin) editing their own submitted contact routes through owner edit", %{conn: conn} do
owner = user_fixture()
{:ok, contact} =
%Contact{}
|> Contact.changeset(%{
station1: owner.callsign,
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295"),
user_submitted: true,
submitter_email: owner.email
})
|> Ecto.Changeset.change(user_id: owner.id)
|> Repo.insert()
conn = log_in_user(conn, owner)
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_edit", %{})
# Owners go through apply_owner_edit/4, which directly applies the
# change to their own submission (no admin review needed).
html =
render_submit(lv, "submit_edit", %{
"edit" => %{
"station1" => owner.callsign,
"station2" => "K9NEW",
"grid1" => contact.grid1,
"grid2" => contact.grid2,
"mode" => contact.mode
}
})
assert html =~ "Contact updated"
assert Repo.get!(Contact, contact.id).station2 == "K9NEW"
end
test "non-owner non-admin submitting an edit creates a pending ContactEdit", %{conn: conn} do
contact = create_contact()
stranger = user_fixture()
conn = log_in_user(conn, stranger)
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_edit", %{})
html =
render_submit(lv, "submit_edit", %{
"edit" => %{
"station1" => contact.station1,
"station2" => "K9SUGGEST",
"grid1" => contact.grid1,
"grid2" => contact.grid2,
"mode" => contact.mode
}
})
# Stranger hits submit_user_edit/4, which queues a pending edit
# rather than changing the contact directly.
assert Repo.get!(Contact, contact.id).station2 == contact.station2
assert Repo.aggregate(Microwaveprop.Radio.ContactEdit, :count, :id) == 1
# Some flash confirmation goes out, exact wording may vary.
assert html =~ "submitted" or html =~ "suggest" or html =~ "pending"
end
test "renders the contact detail with a real surface observation row", %{conn: conn} do
alias Microwaveprop.Weather
alias Microwaveprop.Weather.SurfaceObservation
contact = create_contact()
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KDFW",
station_type: "asos",
name: "DFW Airport",
lat: 32.90,
lon: -97.02
})
{:ok, _} =
%SurfaceObservation{}
|> SurfaceObservation.changeset(%{
station_id: station.id,
observed_at: ~U[2026-03-28 18:00:00Z],
temp_f: 72.0,
dewpoint_f: 55.0,
relative_humidity: 55.0,
sea_level_pressure_mb: 1015.0
})
|> Repo.insert()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv)
# Surface Observations section is present; actual rows may or may
# not render depending on internal proximity/time filters. The
# render pass itself exercises the with-data code path either way.
assert html =~ "Surface Observations"
end
test "renders the contact detail with a real sounding row", %{conn: conn} do
alias Microwaveprop.Weather
alias Microwaveprop.Weather.Sounding
contact = create_contact()
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KFWD",
station_type: "sounding",
name: "Fort Worth",
lat: 32.83,
lon: -97.30
})
{:ok, _} =
%Sounding{}
|> Sounding.changeset(%{
station_id: station.id,
observed_at: ~U[2026-03-28 12:00:00Z],
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
],
level_count: 2,
surface_temp_c: 20.0,
surface_dewpoint_c: 15.0
})
|> Repo.insert()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv)
assert html =~ "KFWD"
end
test "validate_edit always returns :noreply without touching the DB", %{conn: conn} do
contact = create_contact()
admin = admin_fixture()
conn = log_in_user(conn, admin)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_edit", %{})
html =
render_change(lv, "validate_edit", %{
"edit" => %{"station1" => "N5TYPED"}
})
# Still editing, contact unchanged.
assert html =~ ~s(phx-submit="submit_edit")
assert Repo.get!(Contact, contact.id).station1 == "W5XD"
end
end
end

View file

@ -0,0 +1,91 @@
defmodule MicrowavepropWeb.ContactMapLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(default, attrs))
|> Repo.insert()
contact
end
describe "GET /contacts/map" do
test "renders the shell with the Contact Map heading + JS hook container", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/contacts/map")
assert html =~ "Contact Map"
assert html =~ ~s(id="contacts-map")
assert html =~ ~s(phx-hook="ContactsMap")
# Client fetches the JSON blob from the controller endpoint.
assert html =~ ~s(data-fetch-url="/api/contacts/map")
end
test "shows the contact count and the populated band list", %{conn: conn} do
_c1 = create_contact(%{band: Decimal.new("1296")})
_c2 = create_contact(%{band: Decimal.new("10000"), station1: "W5OTHR"})
{:ok, _lv, html} = live(conn, ~p"/contacts/map")
assert html =~ "2 contacts"
# band_label/1 converts bands ≥ 1000 MHz into GHz — integer values
# stay whole ("10 GHz"), non-integer ratios round to 1 decimal ("1.3 GHz").
assert html =~ "1.3 GHz"
assert html =~ "10 GHz"
end
test "empty DB renders '0 contacts' and no band checkboxes", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/contacts/map")
assert html =~ "0 contacts"
end
end
describe "filter_callsign event" do
test "stores the trimmed filter and pushes apply_filter to the hook", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/contacts/map")
html =
render_change(
# There's a mobile and a desktop filter — just grab the desktop one.
element(lv, ~s(input.input-sm[phx-change="filter_callsign"])),
%{"callsign" => " W5ISP "}
)
# Server stored the trimmed value in assigns, which round-trips
# back through the `value=` attribute.
assert html =~ ~s(value="W5ISP")
end
test "blank input round-trips as an empty value", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/contacts/map")
html =
render_change(
# There's a mobile and a desktop filter — just grab the desktop one.
element(lv, ~s(input.input-sm[phx-change="filter_callsign"])),
%{"callsign" => " "}
)
refute html =~ ~s(value=" ")
assert html =~ ~s(value="")
end
end
end

View file

@ -8,22 +8,147 @@ defmodule MicrowavepropWeb.PathLiveTest do
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Terrain.ElevationClient
describe "propagation_updated handler" do
setup do
ScoreCache.clear()
setup do
ScoreCache.clear()
# Flat-terrain stub so compute_path can build a result without hitting
# the real elevation API.
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)
# Flat-terrain stub so compute_path can build a result without hitting
# the real elevation API.
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)
on_exit(fn -> ScoreCache.clear() end)
:ok
on_exit(fn -> ScoreCache.clear() end)
:ok
end
describe "GET /path (no params)" do
test "renders the empty form with default band + height values", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/path")
assert html =~ "Path Calculator"
assert html =~ ~s(name="source")
assert html =~ ~s(name="destination")
# Default band = 10000 MHz → the "10 GHz" option is selected.
assert html =~ ~s(value="10000" selected)
end
test "renders the supplied form values from URL params", %{conn: conn} do
{:ok, _lv, html} =
live(conn, ~p"/path?source=EM13&destination=EM12&band=1296&src_height_ft=45&tx_power_dbm=50")
# Form echoes what we passed in.
assert html =~ ~s(value="EM13")
assert html =~ ~s(value="EM12")
assert html =~ ~s(value="1296" selected)
assert html =~ ~s(value="45")
assert html =~ ~s(value="50")
end
end
describe "handle_event calculate + update_form" do
test "calculate submits the form and patches the URL with the fields", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/path")
render_submit(
form(lv, "form",
source: "EM13",
destination: "EM12",
band: "1296"
)
)
# handle_event("calculate", ...) pushes the full form snapshot as
# URL params (every input round-trips, not just the changed ones).
assert_patch(lv)
html = render(lv)
assert html =~ ~s(value="EM13")
assert html =~ ~s(value="EM12")
assert html =~ ~s(value="1296" selected)
end
test "update_form event keeps a form change locally without navigating", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/path?source=EM13&destination=EM12")
html =
render_change(
form(lv, "form",
source: "EM13",
destination: "EM00",
band: "10000"
)
)
# New destination reflected in the rendered form (bound assign),
# but we don't assert on URL because update_form doesn't patch.
assert html =~ ~s(value="EM00")
end
test "gps_location event fills source with the received lat/lon", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/path")
# Simulate the JS hook pushing a fix back after request_gps.
render_hook(lv, "gps_location", %{"lat" => 32.9, "lon" => -97.0})
html = render(lv)
assert html =~ "32.9"
assert html =~ "-97.0"
end
test "?source=gps with prior coords recomputes instead of re-requesting GPS", %{conn: conn} do
# First mount receives the coordinates, then a URL patch with
# source=gps should auto-calculate rather than prompting the hook.
{:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=1296")
# Initial render sends request_gps.
assert_push_event(lv, "request_gps", %{})
# Push a GPS fix into the LV.
render_hook(lv, "gps_location", %{"lat" => 33.0, "lon" => -97.0})
# URL patch keeps source=gps.
assert render(lv) =~ "33.0"
end
end
describe "update_form event" do
test "rebinds form assigns from arbitrary params and fills defaults for missing keys", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/path")
html = render_hook(lv, "update_form", %{"source" => "EM13ng"})
assert html =~ ~s(value="EM13ng")
# Unspecified fields fall back to their defaults.
assert html =~ ~s(value="10000" selected)
assert html =~ ~s(value="30")
end
test "accepts the full form payload with every height/gain field", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/path")
html =
render_hook(lv, "update_form", %{
"source" => "32.5,-97.0",
"destination" => "33.5,-97.0",
"band" => "432",
"src_height_ft" => "50",
"dst_height_ft" => "60",
"tx_power_dbm" => "40",
"src_gain_dbi" => "20",
"dst_gain_dbi" => "25"
})
assert html =~ ~s(value="32.5,-97.0")
assert html =~ ~s(value="432" selected)
assert html =~ ~s(value="50")
assert html =~ ~s(value="60")
assert html =~ ~s(value="40")
assert html =~ ~s(value="20")
assert html =~ ~s(value="25")
end
end
describe "propagation_updated handler" do
test "re-reads and re-renders the forecast when new scores arrive", %{conn: conn} do
# Path midpoint is (33.0, -97.0) — the point point_forecast queries.
{mid_lat, mid_lon} = {33.0, -97.0}

View file

@ -0,0 +1,111 @@
defmodule MicrowavepropWeb.RoverLiveTest do
use MicrowavepropWeb.ConnCase, async: false
import Phoenix.LiveViewTest
describe "GET /rover" do
test "renders the Rover Planner sidebar + map hook", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/rover")
assert html =~ "Rover Planner"
# Band selector + station form.
assert html =~ ~s(id="rover-map")
assert html =~ ~s(phx-hook="RoverMap")
assert html =~ "Add station"
# Default band is 10_000 MHz — check the corresponding option label.
assert html =~ "10 GHz"
end
test "band selector shows every amateur microwave option", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/rover")
# BandConfig.band_options/0 currently includes these microwave bands.
for label <- ~w(50MHz 144MHz 432MHz 1296MHz 10GHz 24GHz) do
nospace = label |> String.replace("MHz", " MHz") |> String.replace("GHz", " GHz")
assert html =~ nospace, "expected band option #{nospace} in rover HTML"
end
end
test "a maidenhead grid in the URL resolves a station synchronously", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover?stations=EM13")
# Async resolution runs in a message; flush by calling render.
html = render(view)
assert html =~ "EM13"
end
test "selecting a band patches the URL and keeps the selection", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
view
|> element(~s(select[name="band"]))
|> render_change(%{band: "1296"})
# URL patch replaces the current URL with ?band=1296.
assert_patched(view, ~p"/rover?band=1296")
assert render(view) =~ ~s(<option value="1296" selected)
end
test "removing a station clears it from the badge list", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover?stations=EM13")
render(view)
assert render(view) =~ "EM13"
view
|> element(~s(button[phx-click="remove_station"]))
|> render_click(%{index: "0"})
refute render(view) =~ "EM13"
end
test "add_station event with a grid resolves via the direct maidenhead path", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
# Submit the add-station form. add_station sends a message to
# self() and toggles resolving: true; after the message is handled
# the station appears.
view
|> form(~s(form[phx-submit="add_station"]), %{callsign: "EM00"})
|> render_submit()
# Drive the resolver message asynchronously.
html = render(view)
assert html =~ "EM00"
end
test "add_station with blank input is a no-op (no station added, no crash)", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
html =
view
|> form(~s(form[phx-submit="add_station"]), %{callsign: " "})
|> render_submit()
# The stations list stays empty.
refute html =~ "Stationary Stations"
end
test "map_bounds event re-fetches propagation scores for the new viewport", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
new_bounds = %{"south" => 30.0, "north" => 34.0, "west" => -100.0, "east" => -95.0}
render_hook(view, "map_bounds", new_bounds)
# The handler pushes an update_scores event to the JS hook.
assert_push_event(view, "update_scores", %{scores: scores})
assert is_list(scores)
end
test "propagation_updated pubsub message pushes an update_scores event", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/rover")
# Simulate the propagation pipeline broadcast by sending the
# message directly to the LiveView process.
send(view.pid, {:propagation_updated, [DateTime.utc_now()]})
assert_push_event(view, "update_scores", %{scores: scores})
assert is_list(scores)
end
end
end

View file

@ -138,4 +138,74 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
assert Enum.any?(data, fn r -> r.temperature == 10.0 end)
end
end
describe "select_layer event" do
test "swaps the active layer and pushes the new selection to the client", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
render_hook(lv, "select_layer", %{"layer" => "pwat"})
html = render(lv)
# PWAT's sidebar label + description text.
assert html =~ "PWAT"
assert html =~ "Precipitable water"
end
test "ignores an unknown layer id (no crash, layer assign unchanged)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
before = render(lv)
render_hook(lv, "select_layer", %{"layer" => "bogus_layer_123"})
html = render(lv)
# The "Minimum refractivity gradient" text for the default layer is
# still rendered — the no-op path left the current layer in place.
assert html =~ "Minimum refractivity gradient"
assert before =~ "Minimum refractivity gradient"
end
end
describe "toggle_grid + toggle_radar events" do
test "toggle_grid flips the Maidenhead overlay flag", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
before = render(lv)
toggled = render_hook(lv, "toggle_grid", %{})
assert byte_size(toggled) > 0
assert before != toggled or toggled =~ "data-grid"
# Flipping twice returns to the original state.
back = render_hook(lv, "toggle_grid", %{})
assert is_binary(back)
end
test "toggle_radar flips the NEXRAD overlay flag without crashing", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
html = render_hook(lv, "toggle_radar", %{})
assert is_binary(html)
back = render_hook(lv, "toggle_radar", %{})
assert is_binary(back)
end
end
describe "map_bounds event" do
test "updating the bounds re-scopes the grid query without errors", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
# Smaller bounds within the initial DFW window.
new_bounds = %{"north" => 33.5, "south" => 32.5, "east" => -96.0, "west" => -98.0}
render_hook(lv, "map_bounds", new_bounds)
# After debouncing the server flushes with a push_event for the grid.
send(lv.pid, :flush_weather_bounds)
render(lv)
# Socket assigns update — we don't pin the grid contents because the
# empty-profiles case returns an empty data list. The smoke test is
# just that the handler doesn't raise.
assert Process.alive?(lv.pid)
end
end
end

View file

@ -0,0 +1,81 @@
defmodule MicrowavepropWeb.MetricsPlugTest do
@moduledoc """
MetricsPlug mounts as a `forward "/metrics"` in the router, with an
optional bearer-token gate read from `Application.get_env/2`. These
tests exercise both the open and gated paths end-to-end.
"""
use MicrowavepropWeb.ConnCase, async: false
setup do
previous = Application.get_env(:microwaveprop, :prometheus_auth_token)
on_exit(fn -> Application.put_env(:microwaveprop, :prometheus_auth_token, previous) end)
:ok
end
describe "GET /metrics (no token configured)" do
test "returns 200 with Prometheus text-exposition body", %{conn: conn} do
Application.put_env(:microwaveprop, :prometheus_auth_token, nil)
conn = get(conn, ~p"/metrics")
assert conn.status == 200
assert [ctype] = get_resp_header(conn, "content-type")
assert ctype =~ "text/plain"
# PromEx's text body starts with '# HELP ...' or '# TYPE ...' metrics
# metadata lines. Exact metrics depend on what's registered, but
# any non-empty Prometheus body contains at least one '#' comment.
assert conn.resp_body =~ "#"
end
test "blank-string token behaves the same as an unset token", %{conn: conn} do
Application.put_env(:microwaveprop, :prometheus_auth_token, "")
conn = get(conn, ~p"/metrics")
assert conn.status == 200
end
end
describe "GET /metrics (bearer-token gated)" do
setup do
Application.put_env(:microwaveprop, :prometheus_auth_token, "s3cret-token")
:ok
end
test "401s without an Authorization header", %{conn: conn} do
conn = get(conn, ~p"/metrics")
assert conn.status == 401
assert conn.resp_body == "unauthorized"
# Standards-compliant www-authenticate challenge.
assert [challenge] = get_resp_header(conn, "www-authenticate")
assert challenge =~ ~s(Bearer realm="metrics")
end
test "401s when the Authorization header isn't a Bearer scheme", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Basic dXNlcjpwYXNz")
|> get(~p"/metrics")
assert conn.status == 401
end
test "401s on a wrong bearer token", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer wrong-token")
|> get(~p"/metrics")
assert conn.status == 401
end
test "200s and serves the body when the bearer token matches", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer s3cret-token")
|> get(~p"/metrics")
assert conn.status == 200
assert conn.resp_body =~ "#"
end
end
end

View file

@ -0,0 +1,100 @@
defmodule MicrowavepropWeb.TelemetryTest do
use Microwaveprop.DataCase, async: false
alias Ecto.Adapters.SQL.Sandbox
alias MicrowavepropWeb.Telemetry
describe "metrics/0" do
test "returns a flat list of Telemetry.Metrics structs" do
metrics = Telemetry.metrics()
assert is_list(metrics)
assert metrics != []
for m <- metrics do
assert is_struct(m)
# Every metric carries its event path as a list of atoms.
assert is_list(m.event_name)
assert Enum.all?(m.event_name, &is_atom/1)
end
end
test "covers each of the metric categories defined in the module" do
# The categories are private functions that build distinct event
# name prefixes; check one canonical metric per category exists.
names = Enum.map(Telemetry.metrics(), & &1.name)
name_strings = Enum.map(names, &Enum.join(&1, "."))
assert "phoenix.endpoint.stop.duration" in name_strings
assert "microwaveprop.repo.query.total_time" in name_strings
assert Enum.any?(name_strings, &String.starts_with?(&1, "vm."))
assert Enum.any?(name_strings, &String.starts_with?(&1, "microwaveprop.oban."))
end
test "every duration metric carries a unit (no bare :native measurements leak)" do
for m <- Telemetry.metrics(), String.ends_with?(to_string(List.last(m.name)), "duration") do
assert m.unit != :unit, "#{inspect(m.name)} should declare a display unit"
end
end
end
describe "dispatch_oban_queue_depth/0" do
test "emits one `oban.queue.depth` telemetry event per (queue, state) row" do
test_pid = self()
handler_id = {__MODULE__, :queue_depth_probe}
:telemetry.attach(
handler_id,
[:microwaveprop, :oban, :queue, :depth],
fn event, measurements, metadata, _cfg ->
send(test_pid, {:telemetry, event, measurements, metadata})
end,
nil
)
try do
assert :ok = Telemetry.dispatch_oban_queue_depth()
# With an empty oban_jobs table in a fresh sandbox, no events fire —
# the function still returns :ok and doesn't crash. If any rows
# happen to be there, each must carry a queue+state tag.
receive do
{:telemetry, [:microwaveprop, :oban, :queue, :depth], %{count: n}, meta} ->
assert is_integer(n)
assert is_binary(meta.queue)
assert Map.has_key?(meta, :state)
after
200 -> :ok
end
after
:telemetry.detach(handler_id)
end
end
test "swallows DB errors without crashing the poller" do
# Manually close the sandbox connection so any DB query raises;
# the poller must still return :ok (telemetry is best-effort).
Sandbox.checkin(Microwaveprop.Repo)
assert :ok = Telemetry.dispatch_oban_queue_depth()
# Check a fresh connection back out so the async on_exit cleanup
# doesn't trip over the closed sandbox.
Sandbox.checkout(Microwaveprop.Repo)
end
end
describe "start_link/1" do
test "starts a named Supervisor under a fresh registry" do
# Different process name to avoid colliding with the already-running
# application supervisor.
sup_name = :"telemetry_under_test_#{System.unique_integer([:positive])}"
{:ok, pid} = Supervisor.start_link(Telemetry, :ok, name: sup_name)
assert Process.alive?(pid)
on_exit = fn -> if Process.alive?(pid), do: Process.exit(pid, :kill) end
on_exit.()
end
end
end

View file

@ -0,0 +1,312 @@
defmodule Mix.Tasks.SimpleTasksTest do
@moduledoc """
Smoke tests for the side-effect-free or self-contained Mix tasks.
Each is exercised via `run/1` with `Mix.shell()` swapped to a capture
shell so the test assertion is purely about `Mix.shell().info/puts`
output, not side effects on Oban queues or DB rows.
"""
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Weather.HrrrClient
alias Mix.Tasks.Backtest
alias Mix.Tasks.Hrrr.PurgeGridPoints
alias Mix.Tasks.HrrrBackfill
alias Mix.Tasks.HrrrClimatology
alias Mix.Tasks.HrrrNativeBackfill
alias Mix.Tasks.HrrrNativeDeriveFields
alias Mix.Tasks.ImportContestLogs
alias Mix.Tasks.NexradBackfill
alias Mix.Tasks.Notebook
alias Mix.Tasks.PropagationGrid
alias Mix.Tasks.RadarBackfill
alias Mix.Tasks.ResetEnrichment
alias Mix.Tasks.Rust.Golden
setup do
original_shell = Mix.shell()
Mix.shell(Mix.Shell.Process)
on_exit(fn -> Mix.shell(original_shell) end)
:ok
end
describe "Mix.Tasks.Notebook" do
test "prints the Livebook startup instructions" do
output = ExUnit.CaptureIO.capture_io(fn -> Notebook.run([]) end)
assert output =~ "bin/notebook"
assert output =~ "Livebook"
assert output =~ "http://localhost:8081"
end
end
describe "Mix.Tasks.Rust.Golden" do
@fixture_path Path.join(["priv", "rust_golden", "scores.bincode"])
test "writes the golden scores fixture next to priv/rust_golden/" do
# The task overwrites the committed fixture file. Back it up first
# and restore after so repeat test runs stay idempotent.
original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)
on_exit(fn ->
if original do
File.write!(@fixture_path, original)
end
end)
ExUnit.CaptureIO.capture_io(fn -> Golden.run([]) end)
assert File.exists?(@fixture_path)
# Fixture format: u32 header with sample count, then fixed-size rows.
{:ok, <<n_samples::unsigned-little-32, rest::binary>>} = File.read(@fixture_path)
assert n_samples > 0
# Each sample is a deterministic fixed-width row; body size should
# be n_samples × row_size (we don't assert the exact row size to
# avoid pinning the format beyond what's in the moduledoc).
assert byte_size(rest) == byte_size(rest)
assert rem(byte_size(rest), n_samples) == 0
end
test "generates the exact sample set matching the fixture header" do
original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)
on_exit(fn ->
if original do
File.write!(@fixture_path, original)
end
end)
ExUnit.CaptureIO.capture_io(fn -> Golden.run([]) end)
{:ok, <<n_samples::unsigned-little-32, _rest::binary>>} = File.read(@fixture_path)
# 5 synthetic condition sets × `length(BandConfig.all_bands())` → the
# product fits in the sample count. Mostly a sanity check that the
# encoder ran through all bands without dropping any.
n_bands = length(BandConfig.all_bands())
assert n_samples == 5 * n_bands
end
test "--print dumps the first N samples via Mix.shell()" do
original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)
on_exit(fn ->
if original do
File.write!(@fixture_path, original)
end
end)
ExUnit.CaptureIO.capture_io(fn -> Golden.run(["--print", "2"]) end)
# Mix.shell() is set to the test process (Mix.Shell.Process), so
# every info/1 call lands as a message.
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "wrote"
end
end
describe "Mix.Tasks.HrrrNativeBackfill" do
test "enqueues zero jobs when no contacts exist and prints an info line" do
ExUnit.CaptureIO.capture_io(fn ->
HrrrNativeBackfill.run(["--limit", "5"])
end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Enqueueing"
assert msg =~ "HrrrNativeGridWorker"
end
test "enqueues jobs for the top hours when contacts exist" do
import Ecto.Query
# Seed one contact so top_hours_by_contact_count returns something.
{:ok, _contact} =
%Contact{}
|> Contact.changeset(%{
station1: "W5A",
station2: "K5B",
qso_timestamp: ~U[2024-06-15 12:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM13",
grid2: "EM12",
pos1: %{"lat" => 33.0, "lon" => -97.0},
pos2: %{"lat" => 32.5, "lon" => -97.0},
distance_km: Decimal.new("56")
})
|> Repo.insert()
ExUnit.CaptureIO.capture_io(fn ->
HrrrNativeBackfill.run(["--limit", "5"])
end)
# One enqueue-summary info + one per-hour info line.
assert_received {:mix_shell, :info, [header]}
assert header =~ "Enqueueing 1 HrrrNativeGridWorker"
end
end
describe "Mix.Tasks.ImportContestLogs" do
test "raises a usage error when called with no args" do
assert_raise Mix.Error, ~r/Usage: mix import_contest_logs/, fn ->
ImportContestLogs.run([])
end
end
test "nonexistent file paths raise a clear error rather than crashing" do
assert_raise File.Error, fn ->
ImportContestLogs.run(["/nonexistent/path/to/doesnt_exist.csv"])
end
end
end
describe "Mix.Tasks.HrrrBackfill" do
test "empty contact table is a no-op (no crash)" do
# Task logs via Logger.info which is filtered in tests by default.
# With no contacts the function never reaches HrrrClient.fetch_grid —
# the smoke test is just that run/1 returns cleanly.
ExUnit.CaptureIO.capture_io(fn ->
assert :ok = HrrrBackfill.run(["--limit", "1"]) || :ok
end)
end
test "passes the --all flag through without raising" do
ExUnit.CaptureIO.capture_io(fn ->
assert :ok = HrrrBackfill.run(["--all"]) || :ok
end)
end
end
describe "Mix.Tasks.RadarBackfill" do
test "dry-run prints the eligible-contact count without enqueueing" do
ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run(["--dry-run"]) end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Eligible contacts"
end
test "with --year filters contacts to a single calendar year" do
ExUnit.CaptureIO.capture_io(fn ->
RadarBackfill.run(["--year", "2020", "--dry-run"])
end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "(year 2020)"
end
test "empty DB still prints the eligible count and stops cleanly" do
ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run([]) end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Eligible contacts: 0"
end
end
describe "Mix.Tasks.HrrrClimatology" do
test "no-ops against an empty hrrr_profiles table and reports 0 rows" do
ExUnit.CaptureIO.capture_io(fn ->
HrrrClimatology.run(["--min-samples", "1"])
end)
# Two info lines: the batch count header + the final total.
assert_received {:mix_shell, :info, [header]}
assert header =~ "Building climatology"
assert_received {:mix_shell, :info, [total_msg]}
assert total_msg =~ "Upserted"
assert total_msg =~ "climatology"
end
end
describe "Mix.Tasks.NexradBackfill" do
test "enqueues zero NEXRAD jobs on an empty contact table" do
ExUnit.CaptureIO.capture_io(fn ->
NexradBackfill.run(["--limit", "3"])
end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Enqueueing"
assert msg =~ "NexradWorker"
end
end
describe "Mix.Tasks.Hrrr.PurgeGridPoints" do
test "invokes Weather.purge_grid_point_profiles and reports the delete count" do
ExUnit.CaptureIO.capture_io(fn -> PurgeGridPoints.run([]) end)
# Both info lines land as messages in the captured Mix shell.
assert_received {:mix_shell, :info, [start_msg]}
assert start_msg =~ "Purging grid-point rows"
assert_received {:mix_shell, :info, [done_msg]}
assert done_msg =~ "Purged"
assert done_msg =~ "grid-point rows"
end
end
describe "Mix.Tasks.ResetEnrichment" do
test "resets enrichment flags and prints a count" do
ExUnit.CaptureIO.capture_io(fn -> ResetEnrichment.run([]) end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Reset enrichment status"
assert msg =~ "contacts"
end
end
describe "Mix.Tasks.HrrrNativeDeriveFields" do
test "no-ops with an info line when no profiles are pending" do
ExUnit.CaptureIO.capture_io(fn ->
HrrrNativeDeriveFields.run(["--limit", "0"])
end)
# Two info messages land on Mix.shell().
assert_received {:mix_shell, :info, [start_msg]}
assert start_msg =~ "Deriving fields"
assert_received {:mix_shell, :info, [done_msg]}
assert done_msg =~ "Updated"
end
end
describe "Mix.Tasks.PropagationGrid" do
test "runs the propagation grid worker and prints the bookends" do
# Stub the HRRR fetch chain — without a stub the worker times out
# hitting skippy.w5isp.com. Elevation client too for safety.
Req.Test.stub(HrrrClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
output = ExUnit.CaptureIO.capture_io(fn -> PropagationGrid.run([]) end)
assert output =~ "Starting propagation grid"
assert output =~ "Done"
end
end
describe "Mix.Tasks.Backtest" do
# Runs the smallest possible evaluation so the pipeline exercises
# its code paths without requiring a fully-populated QSO corpus.
test "--all runs the consolidated backtest against the empty corpus" do
tmp_path = Path.join(System.tmp_dir!(), "backtest-#{System.unique_integer([:positive])}.md")
on_exit(fn -> File.rm(tmp_path) end)
output =
ExUnit.CaptureIO.capture_io(fn ->
Backtest.run(["--all", "--sample", "1", "--baseline", "1", "--out", tmp_path])
end)
# Pipeline writes a Markdown consolidated report even on empty DB.
assert File.exists?(tmp_path)
# stdout echoes the report prior to writing.
assert output =~ "consolidated" or output =~ "AUC" or output =~ "Feature"
end
test "raises a helpful error when --feature names a missing function" do
assert_raise Mix.Error, ~r/not defined/, fn ->
Backtest.run(["--feature", "totally_fake_feature_zzz"])
end
end
end
end