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.
249 lines
8.3 KiB
Elixir
249 lines
8.3 KiB
Elixir
defmodule MicrowavepropWeb.PathLiveTest do
|
|
use MicrowavepropWeb.ConnCase, async: false
|
|
|
|
import Phoenix.LiveViewTest
|
|
|
|
alias Microwaveprop.Ionosphere
|
|
alias Microwaveprop.Propagation
|
|
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
|
|
end
|