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:.*?]*>\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(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 "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(lv) # 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(lv) =~ "Link Summary" send(lv.pid, {:propagation_updated, [DateTime.utc_now()]}) # Still alive, still rendering. assert render(lv) =~ "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