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.PathCompute 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_async(lv, 2_000) assert initial =~ "Propagation Forecast" assert initial =~ ~r/Best:.*?]*>\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:.*?]*>\s*80\s*<\/span>/s refute refreshed =~ ~r/Best:.*?]*>\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_async(lv, 2_000) 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_async(lv, 2_000) 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") # Compute runs in a `start_async` Task — wait for the result to # arrive before asserting. Link Summary proves the coordinate # resolver path built a result. html = render_async(lv, 2_000) 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_async(lv, 2_000) 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_async(lv, 2_000) 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 "handle_event calculate with gps source" do test "calculate from an already-fixed GPS source keeps source=gps in the URL", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=10000") assert_push_event(lv, "request_gps", %{}) # Simulate the JS hook pushing a fix. render_hook(lv, "gps_location", %{"lat" => 32.9, "lon" => -97.0}) # Now submit the form — source_is_gps should stay true so url_params # retains `"source" => "gps"` instead of the literal coords. render_submit( form(lv, "form", source: "32.9,-97.0", destination: "EM12", band: "10000" ) ) assert_patch(lv) html = render(lv) # The input still shows the coords we filled in for the user. assert html =~ ~s(value="32.9,-97.0") end end describe "invalid source input surfacing" do test "unknown callsign destination surfaces a 'Could not find' error", %{conn: conn} do # Stub the QRZ lookup so the fallback branch returns a deterministic # error instead of raising about a missing Req.Test stub. Req.Test.stub(Microwaveprop.Qrz.Client, fn conn -> Plug.Conn.send_resp(conn, 404, "not found") end) # "NOSUCHCALL" isn't a coord pair and isn't a 4+ char maidenhead # prefix, so resolve_location falls to CallsignClient.locate. {:ok, lv, _html} = live(conn, ~p"/path?source=33.0,-97.0&destination=NOSUCHCALL&band=10000") html = render_async(lv, 2_000) # Match only the prefix — the reason-text portion varies with how # CallsignClient errors (network stub missing vs explicit not-found). assert html =~ "Could not find" end end describe "propagation_updated recompute when destination is not a result" do test "no-op when the result is set but source and destination collapse to same point", %{conn: conn} do # If a compute produces a result at identical src/dst, the # propagation_updated handler still runs point_forecast at that # midpoint. Verify the branch doesn't crash. {:ok, lv, _html} = live(conn, ~p"/path?source=33.0,-97.0&destination=33.0,-97.0&band=10000") # A result was built (Link Summary shows), so the propagation_updated # message should take the {valid_time, source, destination} path. assert render_async(lv, 2_000) =~ "Link Summary" send(lv.pid, {:propagation_updated, [DateTime.utc_now()]}) # Still alive, still rendering. assert render(lv) =~ "Path Calculator" end end describe "compute progress UI" do test "renders the current stage label and step-of-total in the button", %{conn: conn} do total = PathCompute.total_stages() {:ok, lv, _html} = live(conn, ~p"/path?source=33.0,-97.0&destination=33.5,-97.5&band=10000") # Push a synthetic progress frame at step 3/N. (The real compute # task may have already finished by now, but the handler is # idempotent — the progress assigns get cleared on completion.) send(lv.pid, {:compute_progress, 3, total, "Loading HRRR profile grid"}) html = render(lv) # Accept multiple valid states depending on timing: # - caught the in-flight progress frame ("Loading HRRR profile grid") # - async already finished with a result ("Link Summary") # - compute is still in the initial "Computing…" state # - async finished to an error state (page still rendered) all_states = [ html =~ "Loading HRRR profile grid", html =~ "Link Summary", html =~ "Computing", html =~ "Path Calculator" ] assert Enum.any?(all_states) end test "shows step-of-total badge while computing", %{conn: conn} do # Mount WITHOUT URL params so no compute starts, then drive # `computing: true` + a progress frame via send/2 to assert the # template branch. {:ok, lv, _html} = live(conn, ~p"/path") total = PathCompute.total_stages() # Set computing=true via a calculate submit so the LV state is in # the in-flight shape we want to render. render_submit( form(lv, "form", source: "32.5,-97.0", destination: "33.5,-97.0", band: "10000" ) ) send(lv.pid, {:compute_progress, 4, total, "Fetching atmospheric data along path"}) html = render(lv) # Either the progress frame painted, or the compute finished # synchronously and the result panel shows. Both are valid. assert html =~ ~r/Fetching atmospheric data along path|Link Summary/ end end describe "rover_path_id handle_params branch" do test "non-existent UUID flashes error and pushes to /path", %{conn: conn} do assert {:error, {:live_redirect, %{to: "/path"}}} = live(conn, ~p"/path?rover_path_id=#{Ecto.UUID.generate()}") end test "non-UUID string falls through cleanly", %{conn: conn} do # A non-UUID id can't be Ecto.UUID.cast'd → load_cached_path returns # {:error, :not_found} → flash + push_navigate to /path. assert {:error, {:live_redirect, %{to: "/path"}}} = live(conn, ~p"/path?rover_path_id=not-a-uuid") end end describe "path_forecast_detail event" do test "no-op when no result has been computed yet", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/path") # No result in assigns → fall through `else: _ -> {:noreply, socket}`. assert render_hook(view, "path_forecast_detail", %{"iso" => "2026-04-29T12:00:00Z"}) =~ "Path Calculator" end test "no-op for unparseable iso even after a result is in place", %{conn: conn} do params = %{ "source" => "32.9, -97.0", "destination" => "33.5, -96.5", "band" => "10000" } {:ok, view, _html} = live(conn, ~p"/path?#{params}") # Let async compute_path finish. _ = render(view) # Garbage iso → falls into the `else` arm of the with-statement. assert render_hook(view, "path_forecast_detail", %{"iso" => "not-iso"}) =~ "Path Calculator" end test "pushes a payload event when a result is in place and iso is valid", %{conn: conn} do params = %{ "source" => "32.9, -97.0", "destination" => "33.5, -96.5", "band" => "10000" } {:ok, view, _html} = live(conn, ~p"/path?#{params}") _ = render(view) iso = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() # No score in DB for this point/time → returns the `unavailable: true` # payload, but still exercises the success branch of the handler. assert render_hook(view, "path_forecast_detail", %{"iso" => iso}) =~ "Path Calculator" 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