prop/test/microwaveprop_web/live/path_live_test.exs
Graham McIntire 83ea123e22
PathLive: ionosphere readout panel for VHF paths
Closes the GIRO → SporadicE → display loop. When a 144 or 440 MHz path
is calculated, PathLive looks up the nearest polled ionosonde's latest
foEs via Ionosphere.nearest_foes/3, runs SporadicE.es_score for the
actual (band, distance) pair, and renders a panel showing:

* Live foEs / foF2 / station-code / timestamp
* Computed single-hop Es MUF for this exact path length
* 0-100 Es score for the selected band
* Human-readable interpretation ("strong opening", "tropo only",
  "out of single-hop window", etc.)

Panel only appears for 144/440 (Es isn't relevant on microwave) and
only when the nearest station has data within the last 2 hours — if
ionosonde data is stale or missing, the panel is omitted entirely
rather than shown with misleading values.

Does not touch the grid scorer on /map — a grid cell has no intrinsic
path length, so Es scoring there is conceptually muddy. PathLive is
the right home for path-specific predictions.
2026-04-15 14:48:47 -05:00

124 lines
4.1 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
describe "propagation_updated handler" do
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
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