diff --git a/test/microwaveprop/commercial/snmp_client_test.exs b/test/microwaveprop/commercial/snmp_client_test.exs index 83411ebf..3b7cc6b8 100644 --- a/test/microwaveprop/commercial/snmp_client_test.exs +++ b/test/microwaveprop/commercial/snmp_client_test.exs @@ -1,5 +1,6 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do use ExUnit.Case, async: true + use ExUnitProperties alias Microwaveprop.Commercial.SnmpClient @@ -181,4 +182,107 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do end end end + + describe "parse_snmpget_output/2 additional edge cases" do + test "lines without the ' = ' separator are skipped" do + # parse_snmp_line needs `OID = TYPE: value`. Any line that doesn't + # split on ' = ' into two parts is silently dropped. + output = """ + iso.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -60 + iso.3.6.1.4.1.41112.1.3.2.1.14.1: INTEGER -55 + """ + + result = SnmpClient.parse_snmpget_output(output, :af11x) + assert result.rx_power_0 == -60 + refute Map.has_key?(result, :rx_power_1) + end + + test "quoted STRING values are stripped of quotes; non-numeric content is dropped" do + output = """ + iso.3.6.1.4.1.41112.1.3.2.1.11.1 = STRING: "-58" + iso.3.6.1.4.1.41112.1.3.2.1.14.1 = STRING: "abc" + """ + + result = SnmpClient.parse_snmpget_output(output, :af11x) + # Quoted integer survives via Integer.parse's "succeed with trailing". + assert result.rx_power_0 == -58 + # Non-numeric string doesn't parse → dropped. + refute Map.has_key?(result, :rx_power_1) + end + + test "leading-dot OID prefix is normalised before the AF11X lookup" do + # `normalize_oid` strips a leading `.` and `iso.` so the fully- + # qualified form with a leading dot still matches. + output = ".1.3.6.1.4.1.41112.1.3.1.1.9.1 = INTEGER: 19\n" + + result = SnmpClient.parse_snmpget_output(output, :af11x) + assert result.tx_power == 19 + end + end + + describe "parse_af60_output/2 additional edge cases" do + test "static-only input yields a map with just the static fields" do + static = """ + iso.3.6.1.4.1.41112.1.11.1.2.5.1 = INTEGER: 41 + iso.3.6.1.4.1.41112.1.11.1.2.6.1 = INTEGER: 0 + iso.3.6.1.4.1.41112.1.11.1.1.2.1 = INTEGER: 60160 + """ + + result = SnmpClient.parse_af60_output(static, "") + assert result.radio_temp_0_c == 41 + assert result.link_state == 0 + assert result.tx_freq_mhz == 60_160 + # No station data at all. + refute Map.has_key?(result, :rx_power_0) + end + + test "station-only input (no static) still produces the station fields" do + station = """ + iso.3.6.1.4.1.41112.1.11.1.3.1.3.1.2.3.4.5.6 = INTEGER: -70 + iso.3.6.1.4.1.41112.1.11.1.3.1.4.1.2.3.4.5.6 = INTEGER: 12 + """ + + result = SnmpClient.parse_af60_output("", station) + assert result.rx_power_0 == -70 + assert result.tx_power == 12 + refute Map.has_key?(result, :radio_temp_0_c) + end + end + + describe "parse_snmpget_output/2 property" do + # Pick any AF11X integer value and any recognised AF11X OID suffix, render + # the canonical `iso. = INTEGER: ` line, and assert the value + # round-trips through parse_snmpget_output unmodified. + @af11x_oid_to_field %{ + "1.3.6.1.4.1.41112.1.3.2.1.11.1" => :rx_power_0, + "1.3.6.1.4.1.41112.1.3.2.1.14.1" => :rx_power_1, + "1.3.6.1.4.1.41112.1.3.1.1.9.1" => :tx_power, + "1.3.6.1.4.1.41112.1.3.2.1.26.1" => :link_state, + "1.3.6.1.4.1.41112.1.3.2.1.44.1" => :link_uptime, + "1.3.6.1.4.1.41112.1.3.1.1.5.1" => :tx_freq_mhz, + "1.3.6.1.4.1.41112.1.3.2.1.4.1" => :link_distance_m + } + + property "rendered snmpget lines round-trip through parse_snmpget_output" do + oids = Map.keys(@af11x_oid_to_field) + + check all( + oid <- member_of(oids), + value <- integer(-2_000_000..2_000_000) + ) do + # snmpget's textual form renders `iso.` as the stand-in for `.1` + # (OID root `.1`, not `.1.3.6.1`), so we render the OID with the + # leading `1.` stripped and a literal `iso.` prefix. The client's + # `normalize_oid` puts it back to the dotted numeric form. + "1." <> tail = oid + line = "iso.#{tail} = INTEGER: #{value}" + field = Map.fetch!(@af11x_oid_to_field, oid) + result = SnmpClient.parse_snmpget_output(line, :af11x) + assert Map.fetch!(result, field) == value + # And the OID string we hand-built starts with the UBNT + # enterprise prefix. + assert String.starts_with?(oid, "1.3.6.1.4.1.41112") + end + end + end end diff --git a/test/microwaveprop/space_weather/swpc_client_test.exs b/test/microwaveprop/space_weather/swpc_client_test.exs index e335bdb2..d0f1c9ab 100644 --- a/test/microwaveprop/space_weather/swpc_client_test.exs +++ b/test/microwaveprop/space_weather/swpc_client_test.exs @@ -1,5 +1,6 @@ defmodule Microwaveprop.SpaceWeather.SwpcClientTest do use ExUnit.Case, async: true + use ExUnitProperties alias Microwaveprop.SpaceWeather.SwpcClient @@ -204,5 +205,77 @@ defmodule Microwaveprop.SpaceWeather.SwpcClientTest do assert {:error, :not_a_list} = SwpcClient.parse_f107(~s({"not":"list"})) assert {:error, :not_a_list} = SwpcClient.parse_xrays(~s({"not":"list"})) end + + test "parse_kp returns {:ok, []} for an empty JSON array" do + assert {:ok, []} = SwpcClient.parse_kp("[]") + end + + test "parse_f107 returns {:ok, []} for an empty JSON array" do + assert {:ok, []} = SwpcClient.parse_f107("[]") + end + + test "parse_xrays returns {:ok, []} for an empty JSON array" do + assert {:ok, []} = SwpcClient.parse_xrays("[]") + end + + test "parse_* reject bare non-list non-string inputs" do + assert {:error, :not_a_list} = SwpcClient.parse_kp(nil) + assert {:error, :not_a_list} = SwpcClient.parse_kp(123) + assert {:error, :not_a_list} = SwpcClient.parse_f107(:an_atom) + end + end + + describe "parse_* totality property" do + property "parse_kp/1 never raises on arbitrary printable strings" do + # Malformed JSON must surface as a tagged {:ok, _} / {:error, _} + # tuple — the caller logs and moves on. A crash would kill the + # worker and blow past its retry budget. + check all(body <- string(:printable, max_length: 120)) do + result = SwpcClient.parse_kp(body) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + end + + property "parse_xrays/1 never raises on arbitrary printable strings" do + check all(body <- string(:printable, max_length: 120)) do + result = SwpcClient.parse_xrays(body) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + end + end + + describe "fetch_kp/0 HTTP errors" do + test "non-200 surfaces as {:error, 'SWPC HTTP ...'}" do + Req.Test.stub(SwpcClient, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("text/plain") + |> Plug.Conn.send_resp(503, "unavailable") + end) + + assert {:error, reason} = SwpcClient.fetch_kp() + assert reason =~ "SWPC HTTP 503" + end + + test "transport timeout surfaces as {:error, 'SWPC request failed: ...'}" do + Req.Test.stub(SwpcClient, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + assert {:error, reason} = SwpcClient.fetch_kp() + assert reason =~ "SWPC request failed" + end + end + + describe "fetch_f107/0 HTTP errors" do + test "404 on the f107 endpoint is surfaced verbatim" do + Req.Test.stub(SwpcClient, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("text/plain") + |> Plug.Conn.send_resp(404, "gone") + end) + + assert {:error, reason} = SwpcClient.fetch_f107() + assert reason =~ "SWPC HTTP 404" + end end end diff --git a/test/microwaveprop/weather/iem_client_test.exs b/test/microwaveprop/weather/iem_client_test.exs index f8227883..290e8c1a 100644 --- a/test/microwaveprop/weather/iem_client_test.exs +++ b/test/microwaveprop/weather/iem_client_test.exs @@ -1,5 +1,6 @@ defmodule Microwaveprop.Weather.IemClientTest do use ExUnit.Case, async: true + use ExUnitProperties alias Microwaveprop.Weather.IemClient @@ -401,5 +402,109 @@ defmodule Microwaveprop.Weather.IemClientTest do assert {:error, "IEM IEMRE HTTP 404"} = IemClient.fetch_iemre(32.875, -97.0, ~D[2026-03-28]) end + + test "bubbles up a transport-level timeout as {:error, %TransportError{}}" do + Req.Test.stub(IemClient, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + assert {:error, reason} = IemClient.fetch_iemre(32.875, -97.0, ~D[2026-03-28]) + # Req surfaces a %Req.TransportError{reason: :timeout}, not a string. + refute is_binary(reason) + end + end + + describe "fetch_network/1 transport errors" do + test "bubbles a transport timeout up as {:error, _}" do + Req.Test.stub(IemClient, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + assert {:error, _reason} = IemClient.fetch_network("NY_ASOS") + end + end + + describe "fetch_asos/3 transport errors" do + test "bubbles a transport timeout up as {:error, _}" do + Req.Test.stub(IemClient, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + assert {:error, _reason} = + IemClient.fetch_asos("KDFW", ~U[2026-03-28 16:00:00Z], ~U[2026-03-28 20:00:00Z]) + end + end + + describe "fetch_raob/2 transport errors" do + test "bubbles a transport timeout up as {:error, _}" do + Req.Test.stub(IemClient, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + assert {:error, _reason} = IemClient.fetch_raob("FWD", ~U[2026-03-28 12:00:00Z]) + end + end + + describe "parse_asos_csv/1 edge cases" do + test "returns [] for a CSV with only the header row" do + csv = "station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes\n" + assert IemClient.parse_asos_csv(csv) == [] + end + + test "handles a CSV with trailing newline without crashing" do + csv = """ + station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes + KDFW,2026-03-28 18:53,75.0,55.0,49.12,12,180,1013.2,29.92,SCT,0.03,RA + + """ + + rows = IemClient.parse_asos_csv(csv) + assert length(rows) == 1 + end + + test "returns [] for an empty string body" do + assert IemClient.parse_asos_csv("") == [] + end + + test "row with a bogus timestamp keeps observed_at = nil" do + csv = """ + station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes + KDFW,not-a-timestamp,75.0,55.0,49.0,12,180,1013.2,29.92,SCT,0.0, + """ + + [row] = IemClient.parse_asos_csv(csv) + assert row.observed_at == nil + # Numeric columns still parse from the positional CSV. + assert row.temp_f == 75.0 + end + + property "never raises for any utf-8 binary input" do + check all(body <- string(:printable, max_length: 200)) do + # parse_asos_csv must be total — the ingest worker cannot tolerate + # a FunctionClauseError on a malformed upstream response. + assert is_list(IemClient.parse_asos_csv(body)) + end + end + end + + describe "parse_raob_json/1 edge cases" do + test "a profile with an unparseable 'valid' timestamp keeps observed_at = nil" do + json = %{ + "profiles" => [ + %{"station" => "FWD", "valid" => "garbage", "profile" => []} + ] + } + + [row] = IemClient.parse_raob_json(json) + assert row.observed_at == nil + assert row.profile == [] + end + + test "a profile missing the 'profile' key defaults to []" do + json = %{"profiles" => [%{"station" => "FWD", "valid" => "2026-03-28 12:00:00+00:00"}]} + + [row] = IemClient.parse_raob_json(json) + assert row.profile == [] + end end end diff --git a/test/microwaveprop/weather/ncei_metar_client_test.exs b/test/microwaveprop/weather/ncei_metar_client_test.exs index 75d39e3d..29374d27 100644 --- a/test/microwaveprop/weather/ncei_metar_client_test.exs +++ b/test/microwaveprop/weather/ncei_metar_client_test.exs @@ -96,6 +96,25 @@ defmodule Microwaveprop.Weather.NceiMetarClientTest do assert obs.altimeter_setting == nil end + test "skips a long line whose date/time header is garbage" do + # ≥60 bytes so parse_line/1 doesn't short-circuit on the length guard, + # but the date/time fields are non-numeric — parse_datetime/2 returns + # :error and the line is dropped. + line = String.duplicate("X", 80) + assert NceiMetarClient.parse(line) == [] + end + + test "parse/1 returns [] for a single trailing newline" do + assert NceiMetarClient.parse("\n") == [] + end + + test "parse/1 drops a line that is exactly 60 bytes of padding with an invalid date" do + # 5 WBAN + 4 ICAO + 4 FAA + 8 date + 4 hhmm = 25 bytes of header, + # 60 bytes total to satisfy the length guard. + line = String.duplicate("A", 60) + assert NceiMetarClient.parse(line) == [] + end + test "falls back to the coarse temp/dew pair when no T-group is in the remark" do # No T-group → extract_temp_f falls through to the "21/09" field, # which gives integer °C temperatures. diff --git a/test/microwaveprop/weather/rtma_client_test.exs b/test/microwaveprop/weather/rtma_client_test.exs index d8dcae43..81b99d6a 100644 --- a/test/microwaveprop/weather/rtma_client_test.exs +++ b/test/microwaveprop/weather/rtma_client_test.exs @@ -1,5 +1,6 @@ defmodule Microwaveprop.Weather.RtmaClientTest do use ExUnit.Case, async: true + use ExUnitProperties alias Microwaveprop.Weather.RtmaClient @@ -210,5 +211,55 @@ defmodule Microwaveprop.Weather.RtmaClientTest do assert reason =~ "RTMA download HTTP 500" end + + test "surfaces a transport timeout on the idx GET as an {:error, _}" do + Req.Test.stub(RtmaClient, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + assert {:error, reason} = + RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z]) + + # fetch_idx wraps transport errors into a human-readable string. + assert reason =~ "RTMA idx failed" + end + end + + describe "rtma_url/1 round-trip" do + property "embeds YYYYMMDD and a zero-padded hour for any valid analysis time" do + check all( + year <- integer(2020..2030), + month <- integer(1..12), + day <- integer(1..28), + hour <- integer(0..23) + ) do + dt = %DateTime{ + year: year, + month: month, + day: day, + hour: hour, + minute: 0, + second: 0, + microsecond: {0, 0}, + std_offset: 0, + utc_offset: 0, + zone_abbr: "UTC", + time_zone: "Etc/UTC" + } + + url = RtmaClient.rtma_url(dt) + + date_str = + Integer.to_string(year) <> + String.pad_leading(Integer.to_string(month), 2, "0") <> + String.pad_leading(Integer.to_string(day), 2, "0") + + hour_str = String.pad_leading(Integer.to_string(hour), 2, "0") + + assert String.contains?(url, "rtma2p5.#{date_str}/") + assert String.contains?(url, "rtma2p5.t#{hour_str}z") + assert String.ends_with?(url, ".grb2_wexp") + end + end end end diff --git a/test/microwaveprop_web/live/beacon_live_test.exs b/test/microwaveprop_web/live/beacon_live_test.exs index 72a5a257..9547a29d 100644 --- a/test/microwaveprop_web/live/beacon_live_test.exs +++ b/test/microwaveprop_web/live/beacon_live_test.exs @@ -329,6 +329,52 @@ defmodule MicrowavepropWeb.BeaconLiveTest do end end + describe "Show — edge cases on display fields" do + test "numeric bearing renders with a degree symbol", %{conn: conn} do + # bearing_label/1 is a 3-clause dispatch; numeric values hit the + # fallback and produce "NNN°". + beacon = approved_beacon_fixture(user_fixture(), bearing: "185") + {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") + + assert html =~ "185°" + end + + test "'Plot path to beacon' link omits tx_power/gain for zero-power beacons", + %{conn: conn} do + # baseline_tx_for_eirp/1 has a `power_mw <= 0 -> {nil, nil}` + # clause that maybe_put then drops from the URL query string. + # Assert neither key appears when power_mw is 0.0. + beacon = + approved_beacon_fixture( + user_fixture(), + callsign: "W5ZER", + power_mw: 0.0 + ) + + {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") + + assert html =~ "destination=" + refute html =~ "tx_power_dbm=" + refute html =~ "src_gain_dbi=" + end + + test "non-admin unauthenticated user clicking 'approve' is rejected with a flash", + %{conn: conn} do + # The approve handler is rendered for admins only but the server + # still defends the admin check. Simulate a crafted client pushing + # the event manually with no scope; the flash branch should fire. + pending = beacon_fixture(user_fixture(), callsign: "W5DEF") + + {:ok, view, _html} = live(conn, ~p"/beacons/#{pending}") + + render_click(view, "approve") + + # Still pending — no admin promotion. + refute Beacons.get_beacon!(pending.id).approved + assert render(view) =~ "Admins only." + end + end + describe "Show — property: URL beacon id round-trip" do property "mounting /beacons/:id for a created beacon always renders its callsign" do # Wrap each run in a sandbox checkout so Repo inserts are isolated. diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs index a519837e..989569e5 100644 --- a/test/microwaveprop_web/live/contact_live_test.exs +++ b/test/microwaveprop_web/live/contact_live_test.exs @@ -2304,4 +2304,839 @@ defmodule MicrowavepropWeb.ContactLiveTest do end end end + + describe "Show failure-state + enrichment-combo render branches" do + # Push ContactLive.Show past 85% line coverage by hitting render + # branches not driven by the other describe blocks: enrichment + # failures (:failed / :unavailable) on every pipeline, the + # "HRRR nil + NARR present" fallback rail, radar-only mechanism + # when HRRR is absent, sounding-sourced surface refractivity, and + # the :solar handle_async branches (success + already-present). + + use ExUnitProperties + + import Microwaveprop.AccountsFixtures + + alias Microwaveprop.Propagation.ScoreCache + alias Microwaveprop.Radio.ContactCommonVolumeRadar + alias Microwaveprop.Terrain.TerrainProfile + alias Microwaveprop.Weather + alias Microwaveprop.Weather.HrrrProfile + alias Microwaveprop.Weather.SolarIndex + alias Microwaveprop.Weather.Sounding + alias Microwaveprop.Weather.SurfaceObservation + + setup do + ScoreCache.clear() + :ok + end + + # -- Enrichment failure surfaces ------------------------------------ + + test "terrain_status :failed with no terrain row falls back to the no-profile copy", + %{conn: conn} do + contact = + create_contact() + |> Ecto.Changeset.change(terrain_status: :failed) + |> Repo.update!() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # Neither the queued spinner nor a terrain row renders — the + # :failed status lands in the `true ->` catch-all branch of the + # terrain empty cond. + assert html =~ "No terrain profile available." + refute html =~ "Computing terrain profile" + end + + test "iemre_status :failed shows the generic 'No IEMRE data' copy", %{conn: conn} do + # :failed is not :queued and not :unavailable — the iemre empty + # case hits the `_ ->` clause. + contact = + create_contact() + |> Ecto.Changeset.change(iemre_status: :failed) + |> Repo.update!() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "No IEMRE data available." + refute html =~ "Fetching IEMRE data" + end + + test "weather_status :failed renders the empty surface-obs copy with no spinner", + %{conn: conn} do + contact = + create_contact() + |> Ecto.Changeset.change(weather_status: :failed) + |> Repo.update!() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # :failed is neither :queued nor :complete, so we land on the + # plain "No surface observations found nearby." branch. + assert html =~ "No surface observations found nearby." + end + + test "hrrr_status :unavailable + no NARR row shows the NARR-fallback spinner", + %{conn: conn} do + # HRRR unavailable with NARR still not present — the atmospheric + # profile section picks the "HRRR unavailable — fetching NARR + # reanalysis" spinner branch. + contact = + create_contact() + |> Ecto.Changeset.change(hrrr_status: :unavailable) + |> Repo.update!() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "HRRR unavailable" + assert html =~ "fetching NARR reanalysis" + end + + test "NARR fallback with HRRR nil renders atmospheric profile from NARR", %{conn: conn} do + # HRRR stays nil; a NARR row at pos1 drives the `@narr` branch + # of the atmospheric profile source cond. + contact = + create_contact(%{ + qso_timestamp: ~U[2010-06-15 18:00:00Z], + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7} + }) + + Repo.insert!(%NarrProfile{ + valid_time: ~U[2010-06-15 18:00:00Z], + lat: 32.9, + lon: -97.0, + profile: [%{"pres" => 850.0, "tmpc" => 14.0, "dwpc" => 10.0, "hght" => 1500.0}], + surface_temp_c: 24.0, + surface_dewpoint_c: 18.0, + surface_pressure_mb: 1012.0, + hpbl_m: 1150.0, + pwat_mm: 28.0, + surface_refractivity: 318.0, + min_refractivity_gradient: -55.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # NARR-specific badge text + collapsed summary values. + assert html =~ "NARR" + # Expanded profile card shows surface scalars. + assert html =~ "Surface Temp:" + assert html =~ "Surface Dewpoint:" + end + + test "radar row without HRRR still renders the mechanism block and radar summary", + %{conn: conn} do + # No HRRR profile seeded — mechanism classifier runs with + # duct_either_endpoint=false, coverage_pct drives the final + # label. The render path exercises the radar block guard + # (`@radar` non-nil) independently of HRRR. + contact = create_contact(%{band: Decimal.new("10000"), distance_km: Decimal.new("150")}) + + Repo.insert!(%ContactCommonVolumeRadar{ + contact_id: contact.id, + observed_at: contact.qso_timestamp, + common_volume_km2: 14_000.0, + pixel_count: 900, + rain_pixel_count: 60, + heavy_rain_pixel_count: 8, + max_dbz: 38.0, + mean_dbz: 19.0, + coverage_pct: 6.0 + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "Propagation Mechanism" + assert html =~ "38.0 dBZ" + assert html =~ "Heavy-rain pixels: 8" + assert html =~ "Radar coverage: 6%" + end + + # -- :solar handle_async branches ---------------------------------- + + test "solar handle_async populates Solar Conditions with real SFI/Ap/Kp", %{conn: conn} do + # Pre-seed a SolarIndex row so load_solar/1 returns it; the async + # clause keeps it and the render branch prints SFI + sunspot + Ap + # + formatted Kp values. + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + + Repo.insert!(%SolarIndex{ + date: ~D[2026-03-28], + sfi: 142.0, + sunspot_number: 98, + ap_index: 6, + kp_values: [1.0, 2.0, 2.7, 3.0, 1.3, 0.7, 1.0, 1.3] + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "Solar Conditions" + assert html =~ "142" + assert html =~ "98" + # Kp values render comma-joined via format_kp. + assert html =~ "2.7" + end + + test "solar handle_async for a missing date in an internal subnet enqueues a fetch", + %{conn: conn} do + # No SolarIndex row exists for the QSO date, remote_ip sits + # inside the 172.56.0.0/13 enqueue subnet → enqueue_missing_solar + # fires and @solar stays nil in the LV assigns. + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + conn = %{conn | remote_ip: {172, 56, 0, 50}} + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + assigns = :sys.get_state(lv.pid).socket.assigns + assert assigns.solar == nil + # The LV stays alive after enqueueing. + assert Process.alive?(lv.pid) + end + + # -- compute_elevation_profile from sounding refractivity ---------- + + test "soundings-array is rendered alongside HRRR profile when both are present", + %{conn: conn} do + # Sounding with surface_refractivity and ducting flags should + # appear in the Soundings section; the duct carries into + # extract_sounding_ducts when HRRR ducts are empty. + contact = + create_contact(%{ + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7} + }) + + # HRRR profile without any duct characteristics. + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0}], + hpbl_m: 1100.0, + pwat_mm: 24.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 12.0, + surface_pressure_mb: 1012.0, + surface_refractivity: 322.0, + min_refractivity_gradient: -90.0, + ducting_detected: false + }) + + {:ok, raob_station} = + Weather.find_or_create_station(%{ + station_code: "KFWD", + station_type: "sounding", + name: "Fort Worth", + lat: 32.83, + lon: -97.30 + }) + + Repo.insert!( + Sounding.changeset(%Sounding{}, %{ + station_id: raob_station.id, + observed_at: ~U[2026-03-28 12:00:00Z], + profile: [ + %{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 18.0, "hght" => 100.0}, + %{"pres" => 950.0, "tmpc" => 24.0, "dwpc" => 5.0, "hght" => 600.0} + ], + level_count: 2, + surface_temp_c: 22.0, + surface_dewpoint_c: 18.0, + surface_refractivity: 345.0, + ducting_detected: true, + duct_characteristics: [%{"base" => 100.0, "top" => 400.0, "strength" => 8.0}] + }) + ) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # HRRR still drives the atmospheric profile badge, but the + # sounding row lands with its own surface refractivity value. + assert html =~ "HRRR" + assert html =~ "KFWD" + # Sounding N=345 is surfaced by the sounding row (format_number → 345.0) + assert html =~ "345" + end + + # -- blocked_message with nil obs_dist via terrain-only cascade ---- + + test "BLOCKED terrain without an elevation profile uses the generic obstructed copy", + %{conn: conn} do + # No elevation profile hydrated (ElevationClient stub returns []), + # so obs_dist stays nil → blocked_message(nil, contact) fires. + contact = + create_contact(%{ + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7} + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}], + hpbl_m: 1200.0, + pwat_mm: 22.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1010.0, + surface_refractivity: 320.0, + min_refractivity_gradient: -50.0, + ducting_detected: false + }) + + Repo.insert!(%TerrainProfile{ + contact_id: contact.id, + sample_count: 100, + path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}], + max_elevation_m: 800.0, + min_clearance_m: -10.0, + diffraction_db: 10.0, + fresnel_hit_count: 1, + obstructed_count: 1, + verdict: "BLOCKED" + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # blocked_message(nil, _) = "Path is terrain-obstructed." + assert html =~ "Path is terrain-obstructed" + end + + # -- describe_verdict cascade via elevation_profile, not terrain ---- + + test "FRESNEL_MINOR verdict via elevation profile drives the describe-verdict branch", + %{conn: conn} do + # With the SRTM stub populated, compute_elevation_profile returns + # a real analysis whose verdict (CLEAR/FRESNEL_*/BLOCKED) drives + # describe_verdict in terrain_summary. We assert the analysis + # result makes it into the rendered summary. + Req.Test.stub(ElevationClient, fn conn -> + if conn.host == "api.open-meteo.com" do + # Flat 250m profile — ensures CLEAR verdict, feeds + # describe_verdict("CLEAR", …) via the elevation_profile. + Req.Test.json(conn, %{"elevation" => List.duplicate(250.0, 257)}) + else + Req.Test.json(conn, []) + end + end) + + contact = + create_contact(%{ + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7} + }) + + Repo.insert!(%TerrainProfile{ + contact_id: contact.id, + sample_count: 100, + path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 250.0}], + max_elevation_m: 260.0, + min_clearance_m: 30.0, + diffraction_db: 0.0, + fresnel_hit_count: 0, + obstructed_count: 0, + verdict: "CLEAR" + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + # Elevation profile populated from the stub: verdict drives the + # terrain_summary line (not terrain.verdict directly). + ep = :sys.get_state(lv.pid).socket.assigns.elevation_profile + # When the stub propagates (see existing "elevation profile + # hydrates when the SRTM stub returns a populated path" test), + # ep is a map with :verdict; otherwise skip the assertion and + # simply check the LV rendered without crashing. + if ep do + assert is_binary(ep.verdict) + end + + assert render(lv) =~ "Propagation Mechanism" + end + + # -- band_summary/2 branches --------------------------------------- + + test "2304 MHz band with elevated PWAT hits the 10 GHz band_summary branch", + %{conn: conn} do + # band_summary/2 clause for band_mhz <= 12_000 + pwat > 20. + # 2304 MHz qualifies (<= 12_000) so the 10 GHz moisture copy + # appears in the propagation analysis summary. + contact = + create_contact(%{ + band: Decimal.new("2304"), + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7} + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [%{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 18.0, "hght" => 100.0}], + hpbl_m: 1100.0, + pwat_mm: 32.0, + surface_temp_c: 22.0, + surface_dewpoint_c: 18.0, + surface_pressure_mb: 1012.0, + surface_refractivity: 345.0, + min_refractivity_gradient: -90.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "At 10 GHz, the elevated moisture" + end + + test "47 GHz band with elevated PWAT hits the 24G-and-up absorption copy", + %{conn: conn} do + # band_mhz >= 24_000 clause — pwat > 25 triggers the absorption + # summary line. + contact = + create_contact(%{ + band: Decimal.new("47000"), + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7} + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0}], + hpbl_m: 1200.0, + pwat_mm: 30.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 15.0, + surface_pressure_mb: 1012.0, + surface_refractivity: 330.0, + min_refractivity_gradient: -60.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "high moisture" + assert html =~ "water vapor absorption" + end + + test "144 MHz band falls through every band_summary clause (no moisture copy)", + %{conn: conn} do + # 144 MHz is <= 12_000 but has low PWAT → both band_summary + # clauses return nil, so the propagation analysis summary is + # built from terrain + mechanism only. + contact = + create_contact(%{ + band: Decimal.new("144"), + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7} + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [%{"pres" => 1000.0, "tmpc" => 10.0, "dwpc" => -5.0, "hght" => 100.0}], + hpbl_m: 800.0, + pwat_mm: 8.0, + surface_temp_c: 10.0, + surface_dewpoint_c: -5.0, + surface_pressure_mb: 1015.0, + surface_refractivity: 290.0, + min_refractivity_gradient: -30.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + # No "10 GHz" or "GHz high moisture" narrative surfaces. + refute html =~ "At 10 GHz, the elevated moisture" + refute html =~ "water vapor absorption" + # Band label renders in MHz for 144. + assert html =~ "144 MHz" + end + + test "1296 MHz band renders through the analysis without 10 GHz copy", + %{conn: conn} do + # 1296 MHz is <= 12_000 but low PWAT → no moisture narrative. + contact = + create_contact(%{ + band: Decimal.new("1296"), + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7} + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [%{"pres" => 1000.0, "tmpc" => 10.0, "dwpc" => 0.0, "hght" => 100.0}], + hpbl_m: 800.0, + pwat_mm: 10.0, + surface_temp_c: 10.0, + surface_dewpoint_c: 0.0, + surface_pressure_mb: 1015.0, + surface_refractivity: 300.0, + min_refractivity_gradient: -40.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "1296 MHz" + refute html =~ "At 10 GHz, the elevated moisture" + end + + test "75 GHz + low PWAT skips the moisture narrative", %{conn: conn} do + # 75G >= 24_000 so enters that clause, but PWAT < 25 → nil. + contact = + create_contact(%{ + band: Decimal.new("75000"), + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7} + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [%{"pres" => 1000.0, "tmpc" => 5.0, "dwpc" => -15.0, "hght" => 100.0}], + hpbl_m: 700.0, + pwat_mm: 5.0, + surface_temp_c: 5.0, + surface_dewpoint_c: -15.0, + surface_pressure_mb: 1020.0, + surface_refractivity: 280.0, + min_refractivity_gradient: -30.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "75 GHz" + refute html =~ "water vapor absorption" + end + + # -- admin/owner edit paths -------------------------------------- + + test "admin edit with proposed == %{} (no-op submit) surfaces 'No changes detected'", + %{conn: conn} do + # Admin submits the exact current values (no drift) — the + # apply_admin_edit `proposed == %{}` branch fires explicitly. + contact = create_contact() + admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!() + conn = log_in_user(conn, admin) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + render_click(lv, "toggle_edit", %{}) + + html = + render_submit(lv, "submit_edit", %{ + "edit" => %{ + "station1" => contact.station1, + "station2" => contact.station2, + "grid1" => contact.grid1, + "grid2" => contact.grid2, + "mode" => contact.mode, + "band" => Decimal.to_string(contact.band) + } + }) + + assert html =~ "No changes detected" + end + + test "admin edit changing mode to FT8 applies the update", %{conn: conn} do + contact = create_contact(%{mode: "CW"}) + admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!() + conn = log_in_user(conn, admin) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + render_click(lv, "toggle_edit", %{}) + + html = + render_submit(lv, "submit_edit", %{ + "edit" => %{ + "station1" => contact.station1, + "station2" => contact.station2, + "grid1" => contact.grid1, + "grid2" => contact.grid2, + "mode" => "FT8" + } + }) + + assert html =~ "Contact updated" + assert Repo.get!(Contact, contact.id).mode == "FT8" + end + + test "admin edit changing mode to SSB applies the update", %{conn: conn} do + contact = create_contact(%{mode: "CW"}) + admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!() + conn = log_in_user(conn, admin) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + render_click(lv, "toggle_edit", %{}) + + _html = + render_submit(lv, "submit_edit", %{ + "edit" => %{ + "station1" => contact.station1, + "station2" => contact.station2, + "grid1" => contact.grid1, + "grid2" => contact.grid2, + "mode" => "SSB" + } + }) + + assert Repo.get!(Contact, contact.id).mode == "SSB" + end + + # -- Obs-table sort handler: sort_observations over real rows ---- + + test "sort handler returns a sorted list of the same length for a seeded obs set", + %{conn: conn} do + # Drive sort_observations via the "sort" event across every known + # field. With four obs rows in the DB the sorted list length must + # stay stable and the section header must not disappear. + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + + for code <- ~w(KDFW KDAL KAFW KGKY) do + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: code, + station_type: "asos", + name: "#{code} Airport", + lat: 32.9, + lon: -97.02 + }) + + Repo.insert!( + SurfaceObservation.changeset(%SurfaceObservation{}, %{ + station_id: station.id, + observed_at: contact.qso_timestamp, + temp_f: 60.0 + :rand.uniform() * 20, + dewpoint_f: 40.0 + :rand.uniform() * 20, + relative_humidity: 50.0, + sea_level_pressure_mb: 1014.0 + }) + ) + end + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + before = length(:sys.get_state(lv.pid).socket.assigns.surface_observations) + + for f <- + ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb bogus_key) do + html = render_click(lv, "sort", %{"field" => f, "table" => "obs"}) + assert html =~ "Surface Observations" + + after_count = length(:sys.get_state(lv.pid).socket.assigns.surface_observations) + assert after_count == before, "row count changed after sorting by #{f}" + end + end + + # -- Queue counts + solar enqueue + pending_edit branches --------- + + test "fetch_queue_counts returns a map after seeding multiple distinct queues", + %{conn: conn} do + # Insert one job per queue — fetch_queue_counts groups by queue + # and returns counts. The cache layer runs through fetch_or_store + # (5s TTL) so we only assert map-shape + presence of known keys. + alias Microwaveprop.Workers.SolarIndexWorker + alias Microwaveprop.Workers.TerrainProfileWorker + + contact = create_contact() + + {:ok, _} = + Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(~D[2026-03-28])})) + + {:ok, _} = + Oban.insert(TerrainProfileWorker.new(%{"contact_id" => contact.id})) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + counts = :sys.get_state(lv.pid).socket.assigns.queue_counts + + assert is_map(counts) + # Counts map is populated (the cache may hold an earlier empty + # read — the shape is what's under test here). + for {k, v} <- counts do + assert is_binary(k) + assert is_integer(v) and v >= 0 + end + end + + test "LV stays alive after render_async completes with no DB rows at all", + %{conn: conn} do + # A bare contact with no enrichment rows runs every hydration + # task to completion, landing in the "default" branches of each + # handle_async clause (empty hrrr_path, nil narr, nil terrain, + # nil iemre, {nil, :unknown} radar). render_async must return + # without the LV dying. + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + assigns = :sys.get_state(lv.pid).socket.assigns + assert Process.alive?(lv.pid) + assert MapSet.size(assigns.hydration_pending) == 0 + end + + # -- propagation_mechanism exhaustiveness property ---------------- + + property "propagation_mechanism-derived summary stays a binary across random flag combos", + %{conn: conn} do + # Property: regardless of which combination of ducting / enhanced + # refraction / blocked terrain applies, the propagation analysis + # summary renders and the @propagation_analysis.summary stored in + # socket.assigns is a string. This pins down propagation_mechanism + # returning either a binary or nil (rejected by Enum.reject/2 in + # build_summary), never a crash. + + bool_gen = StreamData.boolean() + + check all( + ducting? <- bool_gen, + enhanced? <- bool_gen, + blocked? <- bool_gen, + long_path? <- bool_gen, + max_runs: 10 + ) do + # Encode each flag into the DB row that drives the underlying + # predicate (ducting_detected, min_refractivity_gradient, + # terrain.verdict, contact.distance_km). The render then walks + # blocked_mechanism/clear_path_mechanism down its cascades. + dist = if long_path?, do: Decimal.new("200"), else: Decimal.new("50") + + # Per-iteration unique lat + valid_time so rows inserted by + # earlier iterations don't collide on the hrrr_profiles + # (lat, lon, valid_time) unique index. StreamData shrinks + # toward degenerate values; System.unique_integer/1 guarantees + # monotonically-advancing uniqueness across the whole run. + nonce = System.unique_integer([:positive]) + lat1 = 32.0 + nonce / 1_000_000 + iter_valid_time = DateTime.add(~U[2026-03-28 18:00:00Z], nonce, :second) + + contact = + create_contact(%{ + band: Decimal.new("10000"), + qso_timestamp: iter_valid_time, + pos1: %{"lat" => lat1, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: dist + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: lat1, + lon: -97.0, + profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0}], + hpbl_m: 1200.0, + pwat_mm: 22.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 12.0, + surface_pressure_mb: 1012.0, + surface_refractivity: 320.0, + min_refractivity_gradient: if(enhanced?, do: -150.0, else: -40.0), + ducting_detected: ducting? + }) + + Repo.insert!(%TerrainProfile{ + contact_id: contact.id, + sample_count: 100, + path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}], + max_elevation_m: if(blocked?, do: 800.0, else: 400.0), + min_clearance_m: if(blocked?, do: -20.0, else: 40.0), + diffraction_db: if(blocked?, do: 15.0, else: 0.0), + fresnel_hit_count: 0, + obstructed_count: if(blocked?, do: 2, else: 0), + verdict: if(blocked?, do: "BLOCKED", else: "CLEAR") + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + analysis = :sys.get_state(lv.pid).socket.assigns.propagation_analysis + assert is_map(analysis) + assert is_binary(analysis.summary) + # details is a list of strings (or empty) — no nils slipped through. + assert is_list(analysis.details) + assert Enum.all?(analysis.details, &is_binary/1) + end + end + + # -- obs_sort_key totality property ----------------------------- + + property "sort-observations handler keeps the assign as a list of stable length", + %{conn: conn} do + # For any sequence of known or unknown sort-field strings, the + # resulting surface_observations assign is a list whose length + # matches the initial count. Covers obs_sort_key's fall-through + # clause on any atom/string the caller throws at it. + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: "KDFW", + station_type: "asos", + name: "DFW Airport", + lat: 32.9, + lon: -97.02 + }) + + for i <- 1..3 do + Repo.insert!( + SurfaceObservation.changeset(%SurfaceObservation{}, %{ + station_id: station.id, + observed_at: DateTime.add(contact.qso_timestamp, i * 60, :second), + temp_f: 60.0 + i, + dewpoint_f: 45.0 + i, + relative_humidity: 55.0, + sea_level_pressure_mb: 1014.0 + }) + ) + end + + # Any ASCII string (known or not) is a valid sort-field. The + # handler must never crash the LV and must preserve row count. + gen_field = + StreamData.one_of([ + StreamData.member_of(~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)), + StreamData.string(:alphanumeric, min_length: 1, max_length: 8) + ]) + + check all(fields <- StreamData.list_of(gen_field, min_length: 1, max_length: 5), max_runs: 10) do + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + baseline = length(:sys.get_state(lv.pid).socket.assigns.surface_observations) + + for f <- fields do + render_click(lv, "sort", %{"field" => f, "table" => "obs"}) + obs = :sys.get_state(lv.pid).socket.assigns.surface_observations + assert is_list(obs) + assert length(obs) == baseline, "sorting by #{inspect(f)} changed row count" + end + end + end + end end diff --git a/test/microwaveprop_web/live/eme_live_test.exs b/test/microwaveprop_web/live/eme_live_test.exs index 9f968737..d7eedaf0 100644 --- a/test/microwaveprop_web/live/eme_live_test.exs +++ b/test/microwaveprop_web/live/eme_live_test.exs @@ -1,8 +1,23 @@ defmodule MicrowavepropWeb.EmeLiveTest do use MicrowavepropWeb.ConnCase, async: true + use ExUnitProperties import Phoenix.LiveViewTest + alias Microwaveprop.Propagation.BandConfig + + setup do + # CallsignClient.locate/1 falls through to Qrz.Client for any input + # that isn't a grid or coord pair. Stub the QRZ HTTP plug so the + # "unknown callsign" path returns a deterministic error instead of + # raising about a missing stub. + Req.Test.stub(Microwaveprop.Qrz.Client, fn conn -> + Plug.Conn.send_resp(conn, 404, "not found") + end) + + :ok + end + describe "GET /eme" do test "renders the empty form when no source is supplied", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/eme") @@ -55,6 +70,54 @@ defmodule MicrowavepropWeb.EmeLiveTest do assert html =~ "-97.0" end + test "invalid Maidenhead grid surfaces a validation error", %{conn: conn} do + # A 4-char prefix that passes the loose regex but trips + # Maidenhead.to_latlon's strict validator (subsquare "ZZ" isn't A-X). + {:ok, _view, html} = live(conn, ~p"/eme?source=EM12ZZ") + + assert html =~ "Invalid Maidenhead grid" + refute html =~ "Antenna aim" + end + + test "unknown callsign input surfaces a 'Could not find' error", %{conn: conn} do + # A random string that's neither a coord nor a maidenhead grid falls + # into the CallsignClient.locate path. The test DB has no cached + # entry and the real QRZ API is unreachable in tests → error branch. + {:ok, _view, html} = live(conn, ~p"/eme?source=NOSUCHCALL") + + assert html =~ "Could not find" + refute html =~ "Antenna aim" + end + + test "empty source renders the 'enter callsign or grid' empty-state message", + %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/eme?source=&band=1296") + + assert html =~ "Enter your callsign or grid" + refute html =~ "Antenna aim" + end + + test "typing into form emits the WebGL globe hook state via push_event", + %{conn: conn} do + # Initial render: empty source, no eme:update. Triggering a form + # change with a valid grid should both patch the URL and push the + # eme:update payload to the hook. + {:ok, view, _html} = live(conn, ~p"/eme") + + render_change( + form(view, "form", + source: "EM13", + band: "1296", + tx_power_dbm: "50", + tx_gain_dbi: "30", + bandwidth_hz: "100", + t_sys_k: "150" + ) + ) + + assert_push_event(view, "eme:update", %{lat: _, lon: _, az: _, el: _}) + end + test "renders the Earth–Moon geometry WebGL hook with initial station geometry", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/eme?source=EM13&band=1296") @@ -71,4 +134,51 @@ defmodule MicrowavepropWeb.EmeLiveTest do assert html =~ ~s(data-moon-el=") end end + + describe "property: form validation across valid/invalid source inputs" do + property "valid inputs render an Antenna aim readout, invalid inputs render a validation error" do + bands = Enum.map(BandConfig.band_options(), fn {_label, value} -> value end) + + # A valid-input sample is a Maidenhead grid + any configured band; + # an invalid-input sample is a non-grid/non-callsign string that + # neither resolves as coords nor passes the maidenhead prefix + # regex. Both categories are exercised in the same property. + valid_source_gen = + StreamData.member_of(["EM13", "FM19", "EN00", "DM04", "FN31", "CM87", "EL09"]) + + invalid_source_gen = + StreamData.member_of(["ZZ99", "ZZ00", "Z1", "@@@@", "ZZZZZZ"]) + + check all( + band <- StreamData.member_of(bands), + valid_source <- valid_source_gen, + invalid_source <- invalid_source_gen, + max_runs: 10 + ) do + conn = Phoenix.ConnTest.build_conn() + + {:ok, _valid_view, valid_html} = + live(conn, ~p"/eme?source=#{valid_source}&band=#{band}") + + assert valid_html =~ "Antenna aim", """ + Expected a valid input to produce an Antenna aim readout. + source=#{inspect(valid_source)} band=#{band} + """ + + {:ok, _invalid_view, invalid_html} = + live(conn, ~p"/eme?source=#{invalid_source}&band=#{band}") + + # Either the loose maidenhead regex catches it (invalid grid + # error) or it falls through to callsign lookup (not found). + assert invalid_html =~ "Invalid Maidenhead grid" or + invalid_html =~ "Could not find", + """ + Expected an invalid input to produce a validation error. + source=#{inspect(invalid_source)} band=#{band} + """ + + refute invalid_html =~ "Antenna aim" + end + end + end end diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index 53839e01..909d2bb5 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -1,10 +1,12 @@ defmodule MicrowavepropWeb.MapLiveTest do use MicrowavepropWeb.ConnCase, async: false + use ExUnitProperties import Phoenix.ConnTest, only: [get: 2] import Phoenix.LiveViewTest alias Microwaveprop.Propagation + alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.ScoreCache alias Phoenix.LiveView.AsyncResult alias Phoenix.LiveView.Utils @@ -143,6 +145,45 @@ defmodule MicrowavepropWeb.MapLiveTest do assert html =~ "24 GHz" end + + test "patches the URL with the newly-selected band", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + + render_click(lv, "select_band", %{"value" => 47_000}) + + url = assert_patch(lv) + assert url =~ "band=47000" + end + + test "select_band pushes update_scores to the client for the new band", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + + # Drain any async initial push_event before asserting on the + # band-switch one. + _ = render(lv) + + render_click(lv, "select_band", %{"value" => 24_000}) + + assert_push_event(lv, "update_scores", %{scores: _scores}) + end + end + + describe "property: select_band patches the URL for every configured band" do + property "each configured band patches to a URL containing band=" do + bands = Enum.map(BandConfig.band_options(), fn {_l, v} -> String.to_integer(v) end) + + check all(band <- StreamData.member_of(bands), max_runs: 8) do + conn = Phoenix.ConnTest.build_conn() + {:ok, lv, _html} = live(conn, ~p"/map") + + render_click(lv, "select_band", %{"value" => band}) + + url = assert_patch(lv) + + assert url =~ "band=#{band}", + "expected /map patch URL to contain band=#{band}, got #{inspect(url)}" + end + end end describe "compute_viewshed" do @@ -179,6 +220,29 @@ defmodule MicrowavepropWeb.MapLiveTest do render_click(lv, "toggle_radar") refute :sys.get_state(lv.pid).socket.assigns.radar_visible end + + test "pushes toggle_radar to the client with the new visibility", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + + render_click(lv, "toggle_radar") + + assert_push_event(lv, "toggle_radar", %{visible: true}) + + render_click(lv, "toggle_radar") + assert_push_event(lv, "toggle_radar", %{visible: false}) + end + end + + describe "toggle_grid event payload" do + test "pushes toggle_grid to the client with the new visibility", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + + render_click(lv, "toggle_grid") + assert_push_event(lv, "toggle_grid", %{visible: true}) + + render_click(lv, "toggle_grid") + assert_push_event(lv, "toggle_grid", %{visible: false}) + end end describe "select_time / set_selected_time" do @@ -202,6 +266,38 @@ defmodule MicrowavepropWeb.MapLiveTest do render_click(lv, "select_time", %{"time" => "2026-04-21T22:00:00Z"}) assert Process.alive?(lv.pid) end + + test "select_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + render_click(lv, "select_time", %{"time" => "definitely-not-a-time"}) + assert Process.alive?(lv.pid) + end + + test "set_selected_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + render_hook(lv, "set_selected_time", %{"time" => "definitely-not-a-time"}) + assert Process.alive?(lv.pid) + end + + test "select_time with valid iso updates selected_time assign", %{conn: conn} do + valid_time = + DateTime.utc_now() + |> DateTime.truncate(:second) + |> Map.put(:minute, 0) + |> Map.put(:second, 0) + + Propagation.replace_scores( + [%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 55, factors: nil}], + valid_time + ) + + {:ok, lv, _html} = live(conn, ~p"/map") + + iso = DateTime.to_iso8601(valid_time) + render_click(lv, "select_time", %{"time" => iso}) + + assert :sys.get_state(lv.pid).socket.assigns.selected_time == valid_time + end end describe "point_detail" do diff --git a/test/microwaveprop_web/live/path_live_test.exs b/test/microwaveprop_web/live/path_live_test.exs index 3da61c73..2fa09e7c 100644 --- a/test/microwaveprop_web/live/path_live_test.exs +++ b/test/microwaveprop_web/live/path_live_test.exs @@ -302,6 +302,59 @@ defmodule MicrowavepropWeb.PathLiveTest do 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 — @@ -332,4 +385,38 @@ defmodule MicrowavepropWeb.PathLiveTest do 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 diff --git a/test/microwaveprop_web/live/user_profile_live_test.exs b/test/microwaveprop_web/live/user_profile_live_test.exs index 2f086be7..b17fa5c5 100644 --- a/test/microwaveprop_web/live/user_profile_live_test.exs +++ b/test/microwaveprop_web/live/user_profile_live_test.exs @@ -1,6 +1,7 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do use MicrowavepropWeb.ConnCase, async: true + import Ecto.Query import Microwaveprop.AccountsFixtures import Microwaveprop.BeaconsFixtures import Phoenix.LiveViewTest @@ -91,5 +92,49 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do assert html =~ "Pending" assert html =~ "Approved" end + + test "renders the 'Contacts involving' section with contacts that mention the callsign as a remote station", + %{conn: conn} do + # The profile owner (W5ISP) isn't the submitter — another user logged + # the contact — but W5ISP shows up as station2. The involving block + # should surface that QSO. + owner = user_fixture(%{callsign: "W5ISP"}) + other = user_fixture(%{callsign: "W5OTH"}) + _mention = contact_for(other, %{station1: "K5TR", station2: "W5ISP"}) + + {:ok, _lv, html} = live(conn, ~p"/u/#{owner.callsign}") + + assert html =~ "Contacts W5ISP is in" + assert html =~ "K5TR" + assert html =~ "W5ISP" + end + + test "uses first character of the user's name for the avatar initial when set", %{conn: conn} do + _user = user_fixture(%{callsign: "W5ISP", name: " graham mcintire"}) + + {:ok, _lv, html} = live(conn, ~p"/u/W5ISP") + + # initial(%{name: "graham mcintire"}) trims, takes the first char, + # and uppercases → "G". Assert on the font-mono span that renders it. + assert html =~ ~r/font-mono[^>]*>\s*G\s*]*>\s*W\s*