diff --git a/test/microwaveprop/commercial/snmp_client_test.exs b/test/microwaveprop/commercial/snmp_client_test.exs index 3b7cc6b8..17747a39 100644 --- a/test/microwaveprop/commercial/snmp_client_test.exs +++ b/test/microwaveprop/commercial/snmp_client_test.exs @@ -249,6 +249,44 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do end end + describe "OID normalisation across prefix variants" do + test "fully-qualified 1.3.6.1.4.1 OIDs (no `iso.` prefix) parse identically" do + # Some snmpget configurations emit the fully-qualified dotted form + # directly instead of using `iso.` — both should resolve the same + # AF11X field. + output = "1.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -62\n" + result = SnmpClient.parse_snmpget_output(output, :af11x) + assert result.rx_power_0 == -62 + end + + test "a double-leading-dot OID still strips exactly one leading dot" do + # `normalize_oid` only peels off ONE leading `.` — the residual + # leading dot means the lookup against the OID map fails, so the + # field is silently dropped rather than raising. + output = "..1.3.6.1.4.1.41112.1.3.2.1.14.1 = INTEGER: -63\n" + result = SnmpClient.parse_snmpget_output(output, :af11x) + refute Map.has_key?(result, :rx_power_1) + assert result == %{} + end + end + + describe "poll/3 totality" do + test "empty radio type string raises FunctionClauseError" do + assert_raise FunctionClauseError, fn -> + SnmpClient.poll("10.0.0.1", "public", "") + end + end + + test "non-string radio type raises FunctionClauseError" do + # poll/3 pattern matches on literal binary radio types; any other + # binary that isn't in the dispatch table should fail loudly so a + # mis-typed config surfaces rather than silently mis-polling. + assert_raise FunctionClauseError, fn -> + SnmpClient.poll("10.0.0.1", "public", "AF11X") + end + 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 @@ -263,6 +301,17 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do "1.3.6.1.4.1.41112.1.3.2.1.4.1" => :link_distance_m } + property "parse_snmpget_output never raises on arbitrary inputs (totality)" do + # Feed the parser whatever text StreamData hands back — newlines, + # binary data, partial SNMP-looking fragments. The contract is that + # unparseable content is silently skipped and the result is always + # a map, never a raised exception. + check all(garbage <- StreamData.string(:printable, max_length: 500)) do + result = SnmpClient.parse_snmpget_output(garbage, :af11x) + assert is_map(result) + end + end + property "rendered snmpget lines round-trip through parse_snmpget_output" do oids = Map.keys(@af11x_oid_to_field) diff --git a/test/microwaveprop/terrain/viewshed_property_test.exs b/test/microwaveprop/terrain/viewshed_property_test.exs index ded1bf64..0e58fa82 100644 --- a/test/microwaveprop/terrain/viewshed_property_test.exs +++ b/test/microwaveprop/terrain/viewshed_property_test.exs @@ -116,6 +116,26 @@ defmodule Microwaveprop.Terrain.ViewshedPropertyTest do end end + describe "destination_point/4 round-trip distance" do + property "the generated point is ~dist_km from the origin via the haversine" do + # Regardless of bearing, the great-circle distance between the + # origin and destination_point's result must match the requested + # distance. Allow a loose absolute tolerance for earth-radius + # rounding at longer distances (the module uses 6371 km exactly). + check all( + lat <- float(min: -60.0, max: 60.0), + lon <- float(min: -170.0, max: 170.0), + bearing <- float(min: 0.0, max: 359.999), + dist_km <- float(min: 1.0, max: 500.0) + ) do + {lat2, lon2} = Viewshed.destination_point(lat, lon, bearing, dist_km) + + measured = Microwaveprop.Geo.haversine_km(lat, lon, lat2, lon2) + assert_in_delta measured, dist_km, 0.5 + end + end + end + describe "analyse_ray/5 over generated flat profiles" do property "flat terrain with high antennas always reaches the full distance" do # Antenna floor chosen to beat the worst-case (max dist, max freq) diff --git a/test/microwaveprop/terrain/viewshed_test.exs b/test/microwaveprop/terrain/viewshed_test.exs index 63559823..d2190c9c 100644 --- a/test/microwaveprop/terrain/viewshed_test.exs +++ b/test/microwaveprop/terrain/viewshed_test.exs @@ -191,6 +191,51 @@ defmodule Microwaveprop.Terrain.ViewshedTest do end end + describe "effective_reach_km/3 boundary cases" do + test "BLOCKED with score=0 and diffraction_db=0 uses the mild-terrain 0.8 tier" do + # db=0 falls into the `db <= 3` terrain band (0.8) and score=0 gives + # a 0.05 ducting floor — terrain wins cleanly. + analysis = %{verdict: "BLOCKED", diffraction_db: 0.0} + assert Viewshed.effective_reach_km(analysis, 50.0, 0) == 40.0 + end + + test "BLOCKED at exactly the terrain-tier boundary (3 dB) still wins the 0.8 factor" do + analysis = %{verdict: "BLOCKED", diffraction_db: 3.0} + assert Viewshed.effective_reach_km(analysis, 100.0, 0) == 80.0 + end + + test "CLEAR verdict ignores max_range_km of zero (returns 0)" do + analysis = %{verdict: "CLEAR", diffraction_db: 0.0} + assert Viewshed.effective_reach_km(analysis, 0.0, 100) == 0.0 + end + end + + describe "find_reach_km/2 boundary cases" do + test "max_range_km of zero is returned verbatim when no obstructions exist" do + points = [ + %{obstructed: false, dist_km: 0.0}, + %{obstructed: false, dist_km: 0.0}, + %{obstructed: false, dist_km: 0.0} + ] + + assert Viewshed.find_reach_km(points, 0.0) == 0.0 + end + + test "single-obstruction-in-middle returns the preceding dist_km" do + points = + [ + %{obstructed: false, dist_km: 0.0}, + %{obstructed: false, dist_km: 5.0}, + %{obstructed: false, dist_km: 10.0}, + %{obstructed: true, dist_km: 15.0}, + %{obstructed: false, dist_km: 20.0}, + %{obstructed: false, dist_km: 25.0} + ] + + assert Viewshed.find_reach_km(points, 25.0) == 10.0 + end + end + describe "analyse_ray/5" do test "returns full range for flat terrain with antenna heights" do profile = diff --git a/test/microwaveprop/weather/hrrr_native_client_test.exs b/test/microwaveprop/weather/hrrr_native_client_test.exs index dc967d5b..3ed105f7 100644 --- a/test/microwaveprop/weather/hrrr_native_client_test.exs +++ b/test/microwaveprop/weather/hrrr_native_client_test.exs @@ -510,4 +510,89 @@ defmodule Microwaveprop.Weather.HrrrNativeClientTest do end end end + + describe "build_native_profile/1 deep coverage" do + test "handles a dense 5-level profile and orders by ascending HGT" do + # Intentionally scramble the insertion order so the sort-by-height + # step has real work to do. + parsed = + [3, 1, 5, 2, 4] + |> Enum.flat_map(fn level -> + base = level * 20.0 + lvl_str = "#{level} hybrid level" + + [ + {"HGT:#{lvl_str}", base}, + {"TMP:#{lvl_str}", 300.0 - level * 2.0}, + {"SPFH:#{lvl_str}", 0.01}, + {"PRES:#{lvl_str}", 101_000.0 - level * 500.0}, + {"UGRD:#{lvl_str}", 1.0 * level}, + {"VGRD:#{lvl_str}", -1.0 * level}, + {"TKE:#{lvl_str}", 0.1} + ] + end) + |> Map.new() + + profile = HrrrNativeClient.build_native_profile(parsed) + assert profile.level_count == 5 + assert profile.heights_m == Enum.sort(profile.heights_m) + assert profile.heights_m == [20.0, 40.0, 60.0, 80.0, 100.0] + end + + test "returns a finite float for surface_temp_k whenever any TMP key is present" do + # Surface TMP wins over lowest-level TMP; it's a float. + parsed = %{ + "HGT:1 hybrid level" => 10.0, + "TMP:1 hybrid level" => 290.5, + "TMP:surface" => 293.1 + } + + profile = HrrrNativeClient.build_native_profile(parsed) + assert is_float(profile.surface_temp_k) + assert profile.surface_temp_k == 293.1 + end + end + + describe "duct_byte_ranges/1 boundary inputs" do + test "empty idx entries yields an empty range list" do + assert HrrrNativeClient.duct_byte_ranges([]) == [] + assert HrrrNativeClient.essential_byte_ranges([]) == [] + end + end + + describe "extra properties" do + property "build_native_profile/1 returns a finite float for surface_temp_k when any TMP key is present" do + # Drives the surface-scalar fallback: with TMP somewhere in the + # parsed map (either at `surface` or on a hybrid level that has + # an HGT to anchor it), `surface_temp_k` is a real number. + check all( + level <- StreamData.integer(1..50), + use_surface <- StreamData.boolean(), + tmp_val <- StreamData.float(min: 200.0, max: 320.0) + ) do + lvl_str = "#{level} hybrid level" + + parsed = + if use_surface do + %{ + "HGT:#{lvl_str}" => level * 10.0, + "TMP:#{lvl_str}" => 290.0, + "TMP:surface" => tmp_val + } + else + %{ + "HGT:#{lvl_str}" => level * 10.0, + "TMP:#{lvl_str}" => tmp_val + } + end + + profile = HrrrNativeClient.build_native_profile(parsed) + + assert is_float(profile.surface_temp_k) + # Finiteness: neither ±infinity nor NaN. + refute profile.surface_temp_k == :infinity + refute profile.surface_temp_k == :"-infinity" + end + end + end end diff --git a/test/microwaveprop/weather/nexrad_client_test.exs b/test/microwaveprop/weather/nexrad_client_test.exs index b17848f2..171affef 100644 --- a/test/microwaveprop/weather/nexrad_client_test.exs +++ b/test/microwaveprop/weather/nexrad_client_test.exs @@ -392,4 +392,87 @@ defmodule Microwaveprop.Weather.NexradClientTest do assert result.mean_reflectivity_dbz == 0.0 end end + + describe "fetch_decoded_frame/1 cache population" do + # Minimal 20×20 indexed-color PNG identical to the earlier fixture. + defp tiny_png(width \\ 20, height \\ 20) do + signature = <<137, 80, 78, 71, 13, 10, 26, 10>> + ihdr_data = <> + ihdr_chunk = <> + + raw = for _ <- 1..height, into: <<>>, do: <<0, 0::size(width)-unit(8)>> + compressed = :zlib.compress(raw) + idat_chunk = <> + iend_chunk = <<0::32, "IEND", 0::32>> + + signature <> ihdr_chunk <> idat_chunk <> iend_chunk + end + + setup do + NexradCache.clear() + on_exit(fn -> NexradCache.clear() end) + :ok + end + + test "successful fetch populates the cache (second fetch is a :hit)" do + Req.Test.stub(NexradClient, fn conn -> + Plug.Conn.send_resp(conn, 200, tiny_png()) + end) + + ts = ~U[2022-08-20 14:07:00Z] + rounded = NexradClient.round_to_5min(ts) + + assert NexradCache.fetch(rounded) == :miss + {:ok, _pixels, _width} = NexradClient.fetch_decoded_frame(ts) + assert {:ok, _pixels2, _width2} = NexradCache.fetch(rounded) + end + end + + describe "fetch_decoded_frame/1 empty body" do + setup do + NexradCache.clear() + on_exit(fn -> NexradCache.clear() end) + :ok + end + + test "zero-byte 200 response surfaces a PNG decode error" do + Req.Test.stub(NexradClient, fn conn -> + Plug.Conn.send_resp(conn, 200, "") + end) + + assert {:error, reason} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:05:00Z]) + assert is_binary(reason) + assert reason =~ "PNG decode failed" + end + end + + describe "frame_url/1 year boundaries" do + test "Dec 31 23:55 produces a URL anchored in the old year" do + dt = ~U[2022-12-31 23:55:00Z] + url = NexradClient.frame_url(dt) + assert url =~ "2022/12/31" + assert url =~ "n0q_202212312355.png" + refute url =~ "2023/" + end + + test "Jan 1 00:00 of the following year flips cleanly into the new year" do + dt = ~U[2023-01-01 00:00:00Z] + url = NexradClient.frame_url(dt) + assert url =~ "2023/01/01" + assert url =~ "n0q_202301010000.png" + refute url =~ "2022/" + end + + test "rounding 23:59 Dec 31 stays in Dec 31 (no rollover via round_to_5min)" do + dt = ~U[2022-12-31 23:59:59Z] + rounded = NexradClient.round_to_5min(dt) + assert rounded.year == 2022 + assert rounded.month == 12 + assert rounded.day == 31 + assert rounded.hour == 23 + assert rounded.minute == 55 + url = NexradClient.frame_url(rounded) + assert url =~ "2022/12/31" + end + end end diff --git a/test/microwaveprop/workers/gefs_fetch_worker_test.exs b/test/microwaveprop/workers/gefs_fetch_worker_test.exs index 82449a45..6dc5efbc 100644 --- a/test/microwaveprop/workers/gefs_fetch_worker_test.exs +++ b/test/microwaveprop/workers/gefs_fetch_worker_test.exs @@ -287,6 +287,105 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do end end + describe "perform/1 HTTP classification extra boundaries" do + test "a transient HTTP 502 (bad gateway) returns {:error, _}" do + Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> + Plug.Conn.send_resp(conn, 502, "Bad Gateway") + end) + + args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} + assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) + end + + test "a permanent HTTP 400 (bad request) returns {:cancel, _}" do + Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> + Plug.Conn.send_resp(conn, 400, "Bad Request") + end) + + args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} + assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) + end + + test "a permanent HTTP 410 (gone) returns {:cancel, _}" do + Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> + Plug.Conn.send_resp(conn, 410, "Gone") + end) + + args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} + assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) + end + end + + describe "build_profile_attrs/3 extra branches" do + test "accepts wind_u / wind_v keys without the _mps suffix" do + # `build_profile_attrs` checks both `wind_u` and `wind_u_mps` — the + # non-suffixed keys should also populate the row attrs. + run_time = ~U[2026-04-18 12:00:00Z] + + profile = %{ + lat: 32.9, + lon: -97.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1013.0, + pwat_mm: 20.0, + wind_u: 4.5, + wind_v: -2.25, + profile: [] + } + + attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile) + assert attrs.wind_u_mps == 4.5 + assert attrs.wind_v_mps == -2.25 + end + + test "preserves a nil profile list as a no-derivation base map" do + run_time = ~U[2026-04-18 12:00:00Z] + + profile = %{ + lat: 32.9, + lon: -97.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1013.0, + pwat_mm: 20.0, + profile: nil + } + + attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile) + # nil profile → `SoundingParams.derive([])` is called with the `|| []` + # fallback; empty list returns nil so no derived keys are added. + refute Map.has_key?(attrs, :surface_refractivity) + assert attrs.lat == 32.9 + end + end + + # Property: build_profile_attrs/3 always emits a valid_time exactly + # `forecast_hour * 3600` seconds after the run_time, for any run/fh pair. + property "build_profile_attrs/3 valid_time is run_time + fh hours exactly" do + check all( + epoch <- StreamData.integer(1_700_000_000..1_800_000_000), + fh <- StreamData.integer(0..384) + ) do + {:ok, run_time} = DateTime.from_unix(epoch) + run_time = DateTime.truncate(run_time, :second) + + profile = %{ + lat: 32.9, + lon: -97.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1013.0, + pwat_mm: 20.0, + profile: [] + } + + attrs = GefsFetchWorker.build_profile_attrs(run_time, fh, profile) + assert DateTime.diff(attrs.valid_time, attrs.run_time, :second) == fh * 3600 + assert attrs.forecast_hour == fh + end + end + # Property: the seeder's target run is always a valid GEFS cycle at # least 5 hours older than the input instant, regardless of time of day. property "most_recent_available_run/1 snaps to a valid 00/06/12/18Z cycle" do diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs index 989569e5..720ea081 100644 --- a/test/microwaveprop_web/live/contact_live_test.exs +++ b/test/microwaveprop_web/live/contact_live_test.exs @@ -3139,4 +3139,347 @@ defmodule MicrowavepropWeb.ContactLiveTest do end end end + + describe "Show ordering and proximity helpers" do + # Exercises sort_observations/3, sort_soundings/3, and + # closest_observations/4 on *populated* rows so the actual field + # ordering is asserted — the previous sort-handler tests only + # asserted that rendering stayed alive. + + use ExUnitProperties + + alias Microwaveprop.Geo + alias Microwaveprop.Weather + alias Microwaveprop.Weather.Sounding + alias Microwaveprop.Weather.SurfaceObservation + + defp seed_distinct_obs(contact) do + # Four distinct surface observations with strictly-increasing + # temp_f, dewpoint_f, relative_humidity, sea_level_pressure_mb, + # and observed_at, keyed by station code for station_name order. + Enum.map( + [ + {"KAAA", 32.90, -97.00, 60.0, 40.0, 50.0, 1010.0, 0}, + {"KBBB", 32.91, -97.01, 62.0, 42.0, 52.0, 1011.0, 60}, + {"KCCC", 32.92, -97.02, 64.0, 44.0, 54.0, 1012.0, 120}, + {"KDDD", 32.93, -97.03, 66.0, 46.0, 56.0, 1013.0, 180} + ], + fn {code, lat, lon, temp, dewp, rh, pres, offset} -> + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: code, + station_type: "asos", + name: "#{code} Airport", + lat: lat, + lon: lon + }) + + Repo.insert!( + SurfaceObservation.changeset(%SurfaceObservation{}, %{ + station_id: station.id, + observed_at: DateTime.add(contact.qso_timestamp, offset, :second), + temp_f: temp, + dewpoint_f: dewp, + relative_humidity: rh, + sea_level_pressure_mb: pres + }) + ) + + code + end + ) + end + + test "sort_observations orders by station_name ascending and descending", %{conn: conn} do + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + codes = seed_distinct_obs(contact) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + # Prime with observed_at so a subsequent station_name click hits + # the "new field → asc" branch of toggle_sort/3. + _ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"}) + + # First station_name click → asc. + _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"}) + + asc_codes = + lv.pid + |> :sys.get_state() + |> Map.fetch!(:socket) + |> Map.fetch!(:assigns) + |> Map.fetch!(:surface_observations) + |> Enum.map(& &1.station.station_code) + + assert asc_codes == Enum.sort(codes) + + # Second station_name click → desc. + _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"}) + + desc_codes = + lv.pid + |> :sys.get_state() + |> Map.fetch!(:socket) + |> Map.fetch!(:assigns) + |> Map.fetch!(:surface_observations) + |> Enum.map(& &1.station.station_code) + + assert desc_codes == Enum.sort(codes, :desc) + end + + test "sort_observations orders by observed_at, temp_f, dewpoint_f, relative_humidity, and pressure", + %{conn: conn} do + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + _ = seed_distinct_obs(contact) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + check_field = fn field, reader -> + _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"}) + + obs = + lv.pid + |> :sys.get_state() + |> Map.fetch!(:socket) + |> Map.fetch!(:assigns) + |> Map.fetch!(:surface_observations) + + values = Enum.map(obs, reader) + + assert values == Enum.sort(values), + "obs not ascending on #{field}: #{inspect(values)}" + end + + check_field.("observed_at", & &1.observed_at) + check_field.("temp_f", & &1.temp_f) + check_field.("dewpoint_f", & &1.dewpoint_f) + check_field.("relative_humidity", & &1.relative_humidity) + check_field.("sea_level_pressure_mb", & &1.sea_level_pressure_mb) + end + + defp seed_distinct_soundings(contact) do + # Three soundings with distinct surface_temp_c, surface_refractivity, + # k_index, and lifted_index to check every sounding_sort_key branch. + Enum.map( + [ + {"KAAAS", 15.0, 310.0, 20.0, -2.0, 0}, + {"KBBBS", 18.0, 315.0, 25.0, -1.0, 3600}, + {"KCCCS", 21.0, 320.0, 30.0, 0.0, 7200} + ], + fn {code, temp, refr, k, li, offset} -> + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: code, + station_type: "sounding", + name: "#{code} Sounding", + lat: 32.9 + :rand.uniform() * 0.001, + lon: -97.0 + :rand.uniform() * 0.001 + }) + + Repo.insert!( + Sounding.changeset(%Sounding{}, %{ + station_id: station.id, + observed_at: DateTime.add(contact.qso_timestamp, offset, :second), + profile: [%{"pres" => 1000.0, "tmpc" => temp, "dwpc" => temp - 5.0, "hght" => 100.0}], + level_count: 1, + surface_temp_c: temp, + surface_dewpoint_c: temp - 5.0, + surface_refractivity: refr, + k_index: k, + lifted_index: li + }) + ) + + code + end + ) + end + + test "sort_soundings orders by every known sounding field", %{conn: conn} do + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + _ = seed_distinct_soundings(contact) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + # Each click on a new field toggles to that field asc (new-field + # branch of toggle_sort). Prime with observed_at so the later + # station_name click also hits the new-field branch. + _ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"}) + _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "soundings"}) + + station_names = + lv.pid + |> :sys.get_state() + |> Map.fetch!(:socket) + |> Map.fetch!(:assigns) + |> Map.fetch!(:soundings) + |> Enum.map(&(&1.station.name || &1.station.station_code)) + + assert station_names == Enum.sort(station_names) + + check_field = fn field, reader -> + _ = render_click(lv, "sort", %{"field" => field, "table" => "soundings"}) + + rows = + lv.pid + |> :sys.get_state() + |> Map.fetch!(:socket) + |> Map.fetch!(:assigns) + |> Map.fetch!(:soundings) + + values = Enum.map(rows, reader) + + assert values == Enum.sort(values), + "soundings not ascending on #{field}: #{inspect(values)}" + end + + check_field.("observed_at", & &1.observed_at) + check_field.("surface_temp_c", & &1.surface_temp_c) + check_field.("surface_refractivity", & &1.surface_refractivity) + check_field.("k_index", & &1.k_index) + check_field.("lifted_index", & &1.lifted_index) + end + + test "closest_observations/4 keeps only the five nearest by midpoint distance", %{conn: conn} do + # Seed seven surface observations spread along the path corridor; + # closest_observations trims to the top five by |dlat|+|dlon| from + # the midpoint. The test asserts (a) the assign length ≤ 5 and + # (b) no station sits at a larger distance than the farthest-kept. + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + mid_lat = (32.9 + 30.3) / 2 + mid_lon = (-97.0 + -97.7) / 2 + + near_to_far = [ + {"KMID0", mid_lat, mid_lon}, + {"KMID1", mid_lat + 0.05, mid_lon + 0.05}, + {"KMID2", mid_lat - 0.05, mid_lon - 0.05}, + {"KMID3", mid_lat + 0.10, mid_lon + 0.10}, + {"KMID4", mid_lat - 0.10, mid_lon - 0.10}, + {"KMID5", mid_lat + 0.20, mid_lon + 0.20}, + {"KMID6", mid_lat - 0.20, mid_lon - 0.20} + ] + + _ = + Enum.map(near_to_far, fn {code, lat, lon} -> + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: code, + station_type: "asos", + name: "#{code}", + lat: lat, + lon: lon + }) + + Repo.insert!( + SurfaceObservation.changeset(%SurfaceObservation{}, %{ + station_id: station.id, + observed_at: contact.qso_timestamp, + temp_f: 70.0, + dewpoint_f: 55.0, + relative_humidity: 55.0, + sea_level_pressure_mb: 1015.0 + }) + ) + end) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + obs = + lv.pid + |> :sys.get_state() + |> Map.fetch!(:socket) + |> Map.fetch!(:assigns) + |> Map.fetch!(:surface_observations) + + # closest_observations caps the list at 5. + assert length(obs) <= 5 + + # Every kept station must be at least as close as the farthest one. + distances = + Enum.map(obs, fn o -> + abs(o.station.lat - mid_lat) + abs(o.station.lon - mid_lon) + end) + + assert distances == Enum.sort(distances) + end + + test "contact with pos1 but nil pos2 renders without blowing up (half_dist = 0 path)", + %{conn: conn} do + # Forcing pos2 to nil via a bare Ecto.Changeset.change bypasses the + # Contact.changeset validation that ordinarily fills it in. This + # exercises the `if lat2 && lon2` guards in fetch_weather_for_path. + contact = create_contact() + + {:ok, contact} = + contact + |> Ecto.Changeset.change(pos2: nil, grid2: nil, distance_km: nil) + |> Repo.update() + + assert is_nil(contact.pos2) + + {:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + # The detail page renders with station1 present — the render + # pipeline walked through fetch_weather_for_path with lat2=nil, + # mid_lat=lat1, half_dist=0. + assert html =~ "W5XD" + assert Process.alive?(lv.pid) + end + + property "sort_observations/3 preserves list length + element set for any field key", + %{conn: conn} do + # Round-tripping the `sort` event across any mix of known and + # unknown field keys must leave the surface_observations assign + # with the same cardinality AND the same set of station codes. + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + codes = seed_distinct_obs(contact) + + fields_gen = + 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: 6) + ]) + + check all(fields <- StreamData.list_of(fields_gen, min_length: 1, max_length: 5), max_runs: 10) do + {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + for f <- fields do + _ = render_click(lv, "sort", %{"field" => f, "table" => "obs"}) + + obs = + lv.pid + |> :sys.get_state() + |> Map.fetch!(:socket) + |> Map.fetch!(:assigns) + |> Map.fetch!(:surface_observations) + + assert length(obs) == length(codes) + assert Enum.sort(Enum.map(obs, & &1.station.station_code)) == Enum.sort(codes) + end + end + end + + property "haversine_km/4 is symmetric: d(a,b,c,d) == d(c,d,a,b)" do + # Uses Microwaveprop.Geo.haversine_km/4 — the public, structurally + # identical twin of the private haversine_km in ContactLive.Show. + # Symmetry under endpoint swap is the load-bearing invariant the + # show-page midpoint + half-distance logic relies on. + check all( + lat1 <- StreamData.float(min: -89.0, max: 89.0), + lon1 <- StreamData.float(min: -179.0, max: 179.0), + lat2 <- StreamData.float(min: -89.0, max: 89.0), + lon2 <- StreamData.float(min: -179.0, max: 179.0) + ) do + ab = Geo.haversine_km(lat1, lon1, lat2, lon2) + ba = Geo.haversine_km(lat2, lon2, lat1, lon1) + assert_in_delta ab, ba, 1.0e-6 + end + end + end end diff --git a/test/microwaveprop_web/live/path_live_test.exs b/test/microwaveprop_web/live/path_live_test.exs index 2fa09e7c..6d438fcc 100644 --- a/test/microwaveprop_web/live/path_live_test.exs +++ b/test/microwaveprop_web/live/path_live_test.exs @@ -355,6 +355,68 @@ defmodule MicrowavepropWeb.PathLiveTest do 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 —