prop/test/microwaveprop_web/live/path_live_test.exs
Graham McIntire c514626a62
test: coverage round 3 (83.96% → 84.39%) + 10 new property tests
64 unit tests + 10 property tests across three parallel agents.

- ContactLive.Show round 3 (72% → ~80%): enrichment failure
  surfaces (terrain/iemre/weather :failed, hrrr :unavailable NARR
  fallback), radar-only mechanism, load_solar + enqueue_missing_solar
  paths, blocked_message nil obs_dist, band_summary for 144M/1296M/
  2304M/47G/75G, admin edit no-op, mode changes, fetch_queue_counts
  with seeded Oban jobs. Properties: propagation_mechanism summary is
  always a binary, obs_sort_key totality over arbitrary field keys.

- Weather clients: iem_client transport timeouts + CSV edge cases,
  rtma_client idx url property, swpc_client HTTP 503/404 + empty-
  array parses + JSON/CSV totality properties, ncei_metar_client
  parse edge cases, snmp_client OID-roundtrip property + parse
  edge cases.

- Mid-coverage LiveViews: UserProfileLive avatar/render branches,
  EmeLive invalid-grid/unknown-callsign + form-validation property,
  PathLive zero-distance + 3000km + height/power round-trip property,
  BeaconLive numeric bearing + non-admin approve denied, MapLive
  select_band/toggle_radar/toggle_grid events + band-URL property.

Fix: propagation_mechanism property test uses System.unique_integer
for lat + valid_time nonces instead of StreamData-shrinkable jitter;
the shrink toward 0 was tripping the hrrr_profiles unique index.

205 → 215 properties, 2743 → 2812 tests, 0 failures.
2026-04-24 10:15:37 -05:00

422 lines
15 KiB
Elixir

defmodule MicrowavepropWeb.PathLiveTest do
use MicrowavepropWeb.ConnCase, async: false
use ExUnitProperties
import Phoenix.LiveViewTest
alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Terrain.ElevationClient
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)
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}
# Seed three hourly valid_times with a LOW score of 30.
base =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
times = for h <- 0..2, do: DateTime.add(base, h * 3600, :second)
Enum.each(times, fn t ->
Propagation.replace_scores(
[%{lat: mid_lat, lon: mid_lon, valid_time: t, band_mhz: 10_000, score: 30, factors: nil}],
t
)
end)
{:ok, lv, _html} =
live(conn, ~p"/path?source=32.5,-97.0&destination=33.5,-97.0&band=10000")
initial = render(lv)
assert initial =~ "Propagation Forecast"
assert initial =~ ~r/Best:.*?<span[^>]*>\s*30\s*<\/span>/s
# Publish a new forecast: same times, new score of 80.
Enum.each(times, fn t ->
Propagation.replace_scores(
[%{lat: mid_lat, lon: mid_lon, valid_time: t, band_mhz: 10_000, score: 80, factors: nil}],
t
)
end)
send(lv.pid, {:propagation_updated, times})
refreshed = render(lv)
assert refreshed =~ ~r/Best:.*?<span[^>]*>\s*80\s*<\/span>/s
refute refreshed =~ ~r/Best:.*?<span[^>]*>\s*30\s*<\/span>/s
end
end
describe "ionosphere panel" do
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
test "renders Es panel with live foEs and score when nearest station has fresh data", %{conn: conn} do
# Seed Millstone Hill with an extreme-Es observation (foEs = 18 MHz).
recent = DateTime.truncate(DateTime.utc_now(), :second)
{:ok, _} =
Ionosphere.upsert_observations("MHJ45", [
%{valid_time: recent, fo_es_mhz: 18.0, fo_f2_mhz: 9.0, mufd_mhz: 28.0}
])
# 52.6N, -71.5W to 32.6N, -71.5W → 2220 km pure N-S across
# Millstone Hill's latitude (midpoint 42.6N).
{:ok, lv, _} = live(conn, ~p"/path?source=52.6,-71.5&destination=32.6,-71.5&band=144")
html = render(lv)
assert html =~ "Ionosphere"
assert html =~ "MHJ45"
assert html =~ "foEs"
assert html =~ ~r/18\.\d/
end
test "renders 'tropo only' notice when path is outside the single-hop Es window", %{conn: conn} do
recent = DateTime.truncate(DateTime.utc_now(), :second)
{:ok, _} =
Ionosphere.upsert_observations("MHJ45", [
%{valid_time: recent, fo_es_mhz: 18.0, fo_f2_mhz: 9.0, mufd_mhz: 28.0}
])
# ~100 km path — way under the 500 km Es minimum.
{:ok, lv, _} = live(conn, ~p"/path?source=42.6,-71.5&destination=43.5,-71.5&band=144")
html = render(lv)
assert html =~ "Ionosphere"
assert html =~ ~r/out of range|not applicable|tropo only/i
end
test "omits the Es panel entirely when nearest ionosonde has no recent data", %{conn: conn} do
{:ok, lv, _} = live(conn, ~p"/path?source=42.6,-71.5&destination=32.6,-71.5&band=144")
html = render(lv)
refute html =~ "Ionosphere"
end
end
describe "input parsing + rendering edge cases" do
test "renders with coordinate-pair source + destination (no geocoding)", %{conn: conn} do
{:ok, lv, _html} =
live(conn, ~p"/path?source=33.0,-97.0&destination=33.5,-97.5&band=10000")
# A second render after the initial mount drains the self-sent
# {:compute_path, ...} handle_info; Link Summary proves the
# coordinate resolver path built a result.
html = render(lv)
assert html =~ "Link Summary"
assert html =~ "Distance"
end
test "invalid grid square in destination surfaces an error", %{conn: conn} do
# "EM12ZZ" passes the LV's lenient `maidenhead?` prefix check but
# fails Maidenhead.to_latlon because subsquare chars must be A-X.
{:ok, lv, _html} =
live(conn, ~p"/path?source=33.0,-97.0&destination=EM12ZZ&band=10000")
html = render(lv)
assert html =~ "Invalid grid square"
end
test "missing destination keeps the form visible with no result panel", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/path?source=33.0,-97.0&band=10000")
assert html =~ "Path Calculator"
refute html =~ "Link Summary"
end
test "missing keys on update_form fall back to defaults (nil-safe assignment)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/path?source=EM13&destination=EM12&band=1296")
# update_form with ONLY source — missing band/heights/etc. default
# because `params["band"] || "10000"` hits the nil branch.
html = render_hook(lv, "update_form", %{"source" => "EM99"})
assert html =~ ~s(value="EM99")
assert html =~ ~s(value="10000" selected)
assert html =~ ~s(value="30")
assert html =~ ~s(value="20")
end
end
describe "propagation_updated with no result" do
test "is a no-op when @result is nil (no Form submitted yet)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/path")
send(lv.pid, {:propagation_updated, []})
# The process stays alive, the empty form still renders.
assert render(lv) =~ "Path Calculator"
end
end
describe "compute_path edge cases" do
test "identical source and destination renders with Distance 0 km", %{conn: conn} do
{:ok, lv, _html} =
live(conn, ~p"/path?source=33.0,-97.0&destination=33.0,-97.0&band=10000")
html = render(lv)
assert html =~ "Link Summary"
assert html =~ "Distance"
# Haversine of identical points is exactly 0 — distance_km formats
# small distances with one decimal place ("0.0 km").
assert html =~ ~r/0\.0 km/
end
test "long path > 3000 km still renders a result without crashing", %{conn: conn} do
# New York City → Los Angeles (~3940 km). The path calculator
# doesn't filter by distance the way the scoring set does, so the
# result panel should build even for coast-to-coast ranges.
{:ok, lv, _html} =
live(conn, ~p"/path?source=40.7,-74.0&destination=34.0,-118.0&band=10000")
html = render(lv)
assert html =~ "Link Summary"
assert html =~ "Distance"
# Somewhere in the 3000s or 4000s — distance_km renders >10mi
# as an integer "NNNN km" rather than decimal.
assert html =~ ~r/\d{4} km/
end
test "blank source with a filled destination keeps the empty form (auto_calculate skipped)",
%{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/path?destination=EM12&band=10000")
assert html =~ "Path Calculator"
refute html =~ "Link Summary"
end
test "My Location GPS fix is echoed back into the source input", %{conn: conn} do
# The hook normally emits gps_location after the user clicks the
# button. The handler should echo the rounded coords into the
# source input AND leave source_is_gps true for the URL.
{:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=10000")
assert_push_event(lv, "request_gps", %{})
render_hook(lv, "gps_location", %{"lat" => 32.897, "lon" => -97.038})
html = render(lv)
# The input shows the rounded coordinates, not the literal "gps".
assert html =~ ~s(value="32.897)
assert html =~ "-97.038"
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 —
# Req.Test.stub is scoped to the async: false test pid.
check all(
band <- StreamData.member_of(Enum.map(BandConfig.band_options(), fn {_l, v} -> v end)),
max_runs: 10
) 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)
conn = Phoenix.ConnTest.build_conn()
{:ok, lv, _html} = live(conn, ~p"/path")
html =
render_change(
form(lv, "form",
source: "EM13",
destination: "EM12",
band: band
)
)
assert html =~ ~s(value="#{band}" selected)
end
end
end
describe "property: height/power field round-trip through update_form" do
property "update_form preserves arbitrary numeric heights and TX power through the render" do
check all(
src_height <- StreamData.integer(1..300),
dst_height <- StreamData.integer(1..300),
tx_power <- StreamData.integer(-10..60),
max_runs: 10
) 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)
conn = Phoenix.ConnTest.build_conn()
{:ok, lv, _html} = live(conn, ~p"/path")
html =
render_hook(lv, "update_form", %{
"source" => "EM13",
"destination" => "EM12",
"band" => "10000",
"src_height_ft" => to_string(src_height),
"dst_height_ft" => to_string(dst_height),
"tx_power_dbm" => to_string(tx_power)
})
assert html =~ ~s(value="#{src_height}")
assert html =~ ~s(value="#{dst_height}")
assert html =~ ~s(value="#{tx_power}")
end
end
end
end