From 764643bc62aa88e9fe8cd85a1a517e90468c0466 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 23 Apr 2026 13:28:41 -0500 Subject: [PATCH] test: expand coverage and refactor to idiomatic style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes under lib/ (source): - iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate + `if/else` with a classifier that pattern-matches the error reason directly, returning `:transient | :permanent`, and dispatch via `case`. Three-clause head is clearer than the boolean predicate. - scores_file.extract_points and nexrad_client.extract_box: force `//1` step on the row/col comprehensions so an image box whose centre lands outside the raster collapses to an empty iteration instead of firing Elixir 1.19's ambiguous-range warning. Adds a regression test for the right-edge box. Test coverage added: - Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top, duct_thickness, best_duct_freq, duct_usable_for_band, distance_to_front / parallel_to_front stubs, all_features/0). - NotifyListener handle_info tolerance for malformed payloads + unknown messages, plus the PubSub broadcast side effect. - Viewshed.effective_reach_km across every verdict / diffraction tier / ducting-score tier combination. - SnmpClient parse_af60_output unit conversions + unknown-column handling; parse_snmpget_output OID normalisation and junk-line tolerance. - HrrrNativeClient native_level_count, native_variables, native_messages shape, duct_messages / duct_byte_ranges. - Changeset coverage for GeomagneticObservation, SolarFluxObservation, SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and Metar5minObservation — lifted each from ~50% / 0% to 100%. - Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T, monotonic in RH, finite across the operating envelope) + idempotent nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded output; latlon_to_pixel round-trip for every grid cell. Also cleared several unrelated compiler/linter warnings: - Three private test helpers (create_contact, insert_contact, current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used. - profiles_file_test bound `dir` in a pattern match without using it. Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage 70.38% → 70.89%; credo strict clean. --- lib/microwaveprop/propagation/scores_file.ex | 4 +- lib/microwaveprop/weather/nexrad_client.ex | 8 +- .../workers/iemre_fetch_worker.ex | 41 +-- test/microwaveprop/backtest/features_test.exs | 153 ++++++++++-- .../commercial/snmp_client_test.exs | 71 ++++++ .../propagation/notify_listener_test.exs | 30 +++ .../propagation/profiles_file_test.exs | 2 +- .../score_cache_reconciler_test.exs | 2 +- .../observation_changesets_test.exs | 234 ++++++++++++++++++ test/microwaveprop/terrain/viewshed_test.exs | 52 ++++ .../weather/gefs_client_test.exs | 77 ++++++ .../weather/hrrr_native_client_test.exs | 67 +++++ .../weather/nexrad_client_test.exs | 67 +++++ .../workers/radar_frame_worker_test.exs | 2 +- .../live/admin/contact_edit_live_test.exs | 2 +- 15 files changed, 767 insertions(+), 45 deletions(-) create mode 100644 test/microwaveprop/space_weather/observation_changesets_test.exs diff --git a/lib/microwaveprop/propagation/scores_file.ex b/lib/microwaveprop/propagation/scores_file.ex index 1e232cc8..083735b8 100644 --- a/lib/microwaveprop/propagation/scores_file.ex +++ b/lib/microwaveprop/propagation/scores_file.ex @@ -404,8 +404,8 @@ defmodule Microwaveprop.Propagation.ScoresFile do %{lat_min: lat_min, lon_min: lon_min, step: step, n_rows: n_rows, n_cols: n_cols} = payload {row_lo, row_hi, col_lo, col_hi} = bounds_to_index_range(payload, bounds) - for row <- row_lo..row_hi, - col <- col_lo..col_hi, + for row <- row_lo..row_hi//1, + col <- col_lo..col_hi//1, byte = :binary.at(body, row * n_cols + col), byte != @no_data do # guard against the ignored variables warning diff --git a/lib/microwaveprop/weather/nexrad_client.ex b/lib/microwaveprop/weather/nexrad_client.ex index 495cfecd..94f674eb 100644 --- a/lib/microwaveprop/weather/nexrad_client.ex +++ b/lib/microwaveprop/weather/nexrad_client.ex @@ -224,10 +224,12 @@ defmodule Microwaveprop.Weather.NexradClient do y_min = max(cy - half, 0) y_max = min(cy + half, height - 1) - # Collect non-zero dBZ values + # Collect non-zero dBZ values. `//1` forces an ascending range so + # a box whose center is outside the image (min > max after + # clamping) becomes empty rather than iterating backwards. dbz_values = - for y <- y_min..y_max, - x <- x_min..x_max, + for y <- y_min..y_max//1, + x <- x_min..x_max//1, offset = y * width + x, offset >= 0, offset < byte_size(pixels), diff --git a/lib/microwaveprop/workers/iemre_fetch_worker.ex b/lib/microwaveprop/workers/iemre_fetch_worker.ex index 111e3756..f4f12784 100644 --- a/lib/microwaveprop/workers/iemre_fetch_worker.ex +++ b/lib/microwaveprop/workers/iemre_fetch_worker.ex @@ -55,30 +55,37 @@ defmodule Microwaveprop.Workers.IemreFetchWorker do end defp handle_error(reason, lat, lon, date, date_str) do - if transient_failure?(reason) do - Logger.error("IEMRE transient error for #{lat},#{lon} @ #{date_str}: #{inspect(reason)}") - {:error, reason} - else - # Permanent upstream failure (e.g. 404, 422 out-of-grid). Stub the - # bucket so future backfill enqueues skip the fetch and let - # ContactWeatherEnqueueWorker reconcile the contacts' :queued → - # :complete transition via the empty-jobs branch. - _ = Weather.upsert_iemre_observation(%{lat: lat, lon: lon, date: date, hourly: []}) + case classify_failure(reason) do + :transient -> + Logger.error("IEMRE transient error for #{lat},#{lon} @ #{date_str}: #{inspect(reason)}") + {:error, reason} - Logger.warning("IEMRE permanent failure for #{lat},#{lon} @ #{date_str}: #{inspect(reason)}, stored stub") + :permanent -> + # Permanent upstream failure (e.g. 404, 422 out-of-grid). Stub + # the bucket so future backfill enqueues skip the fetch and + # let ContactWeatherEnqueueWorker reconcile the contacts' + # :queued → :complete transition via the empty-jobs branch. + _ = Weather.upsert_iemre_observation(%{lat: lat, lon: lon, date: date, hourly: []}) - {:cancel, reason} + Logger.warning("IEMRE permanent failure for #{lat},#{lon} @ #{date_str}: #{inspect(reason)}, stored stub") + + {:cancel, reason} end end - defp transient_failure?(%{__exception__: true}), do: true - defp transient_failure?("IEM IEMRE HTTP " <> status), do: server_error?(status) - defp transient_failure?(_), do: false + # Network/transport exceptions are always transient; retry. + defp classify_failure(%{__exception__: true}), do: :transient - defp server_error?(status) do + # IEM surfaces the HTTP status in the reason string. Split the integer + # back out and pattern-match rate-limit / 5xx codes as transient. + defp classify_failure("IEM IEMRE HTTP " <> status) do case Integer.parse(status) do - {code, _} when code in [429, 500, 502, 503, 504] -> true - _ -> false + {code, _} when code in [429, 500, 502, 503, 504] -> :transient + _ -> :permanent end end + + # Anything else (404, 422, unexpected shapes) is permanent — cancel + # the job and stub the bucket. + defp classify_failure(_), do: :permanent end diff --git a/test/microwaveprop/backtest/features_test.exs b/test/microwaveprop/backtest/features_test.exs index b7430273..aedf33cf 100644 --- a/test/microwaveprop/backtest/features_test.exs +++ b/test/microwaveprop/backtest/features_test.exs @@ -63,25 +63,11 @@ defmodule Microwaveprop.Backtest.FeaturesTest do describe "native_surface_refractivity/3" do test "computes N-units from native profile surface scalars" do - {:ok, _} = - %HrrrNativeProfile{} - |> HrrrNativeProfile.changeset(%{ - valid_time: @valid_time, - lat: @point_lat, - lon: @point_lon, - level_count: 1, - heights_m: [10.0], - temp_k: [295.0], - spfh: [0.010], - pressure_pa: [101_325.0], - u_wind_ms: [2.0], - v_wind_ms: [1.0], - tke_m2s2: [0.5], - surface_temp_k: 295.0, - surface_spfh: 0.010, - surface_pressure_pa: 101_325.0 - }) - |> Repo.insert() + insert_native_profile(%{ + surface_temp_k: 295.0, + surface_spfh: 0.010, + surface_pressure_pa: 101_325.0 + }) result = Features.native_surface_refractivity(@point_lat, @point_lon, @valid_time) assert is_float(result) @@ -93,4 +79,133 @@ defmodule Microwaveprop.Backtest.FeaturesTest do assert Features.native_surface_refractivity(@point_lat, @point_lon, @valid_time) == nil end end + + describe "theta_e_jump/3" do + test "returns the native profile's theta_e_jump_k" do + insert_native_profile(%{theta_e_jump_k: 4.75}) + assert Features.theta_e_jump(@point_lat, @point_lon, @valid_time) == 4.75 + end + + test "returns nil when no native profile or no jump value" do + assert Features.theta_e_jump(@point_lat, @point_lon, @valid_time) == nil + insert_native_profile(%{theta_e_jump_k: nil}) + assert Features.theta_e_jump(@point_lat, @point_lon, @valid_time) == nil + end + end + + describe "shear_at_top/3" do + test "returns the native profile's shear_at_top_ms" do + insert_native_profile(%{shear_at_top_ms: 3.2}) + assert Features.shear_at_top(@point_lat, @point_lon, @valid_time) == 3.2 + end + + test "returns nil when the native profile lacks a shear value" do + insert_native_profile(%{shear_at_top_ms: nil}) + assert Features.shear_at_top(@point_lat, @point_lon, @valid_time) == nil + end + end + + describe "duct_thickness/3" do + test "returns the thickest duct's thickness_m" do + insert_native_profile(%{ + ducts: [ + %{"thickness_m" => 120.0, "min_freq_ghz" => 18.0}, + %{"thickness_m" => 340.0, "min_freq_ghz" => 9.5}, + %{"thickness_m" => 210.0, "min_freq_ghz" => 12.0} + ] + }) + + assert Features.duct_thickness(@point_lat, @point_lon, @valid_time) == 340.0 + end + + test "returns nil when there are no ducts" do + insert_native_profile(%{ducts: []}) + assert Features.duct_thickness(@point_lat, @point_lon, @valid_time) == nil + end + + test "returns nil when no native profile exists" do + assert Features.duct_thickness(@point_lat, @point_lon, @valid_time) == nil + end + end + + describe "best_duct_freq/3" do + test "returns the native profile's best_duct_band_ghz" do + insert_native_profile(%{best_duct_band_ghz: 12.5}) + assert Features.best_duct_freq(@point_lat, @point_lon, @valid_time) == 12.5 + end + + test "returns nil when the native profile lacks best_duct_band_ghz" do + insert_native_profile(%{best_duct_band_ghz: nil}) + assert Features.best_duct_freq(@point_lat, @point_lon, @valid_time) == nil + end + end + + describe "duct_usable_for_band/4" do + test "returns 1.0 when the best duct traps the requested band" do + insert_native_profile(%{best_duct_band_ghz: 10.0}) + assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 24.0) == 1.0 + assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 10.0) == 1.0 + end + + test "returns 0.0 when the best duct can't trap the requested band" do + insert_native_profile(%{best_duct_band_ghz: 20.0}) + assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 10.0) == 0.0 + end + + test "returns nil when there's no native profile" do + assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 10.0) == nil + end + end + + describe "distance_to_front/3 and parallel_to_front/3 placeholders" do + test "both return nil and never touch the database" do + assert Features.distance_to_front(@point_lat, @point_lon, @valid_time) == nil + assert Features.parallel_to_front(@point_lat, @point_lon, @valid_time) == nil + end + end + + describe "all_features/0" do + test "returns a name-keyed map of 3-arity feature closures" do + features = Features.all_features() + assert is_map(features) + assert Map.has_key?(features, "naive_gradient") + assert Map.has_key?(features, "td_depression") + assert Map.has_key?(features, "time_of_day") + assert Map.has_key?(features, "duct_thickness") + + # Excluded per the @doc contract + refute Map.has_key?(features, "duct_usable_for_band") + refute Map.has_key?(features, "distance_to_front") + refute Map.has_key?(features, "bulk_richardson") + + # Every value is a 3-arity closure that returns a float or nil + # without raising. + for {_name, fun} <- features do + assert is_function(fun, 3) + result = fun.(@point_lat, @point_lon, @valid_time) + assert is_nil(result) or is_float(result) + end + end + end + + defp insert_native_profile(attrs) do + base = %{ + valid_time: @valid_time, + lat: @point_lat, + lon: @point_lon, + level_count: 1, + heights_m: [10.0], + temp_k: [295.0], + spfh: [0.010], + pressure_pa: [101_325.0], + u_wind_ms: [2.0], + v_wind_ms: [1.0], + tke_m2s2: [0.5] + } + + {:ok, _} = + %HrrrNativeProfile{} + |> HrrrNativeProfile.changeset(Map.merge(base, attrs)) + |> Repo.insert(on_conflict: :replace_all, conflict_target: [:valid_time, :lat, :lon]) + end end diff --git a/test/microwaveprop/commercial/snmp_client_test.exs b/test/microwaveprop/commercial/snmp_client_test.exs index 8ff0e7c1..0f0b579d 100644 --- a/test/microwaveprop/commercial/snmp_client_test.exs +++ b/test/microwaveprop/commercial/snmp_client_test.exs @@ -99,5 +99,76 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do assert result.remote_rx_power_0 == -57 assert result.remote_tx_power == 19 end + + test "returns empty map for empty output" do + assert SnmpClient.parse_af60_output("", "") == %{} + end + + test "converts tx/rx_capacity from kbps to mbps (integer division)" do + # 450_500 kbps should round down to 450 mbps. + station = """ + iso.3.6.1.4.1.41112.1.11.1.3.1.7.170.211.109.42.26.137 = INTEGER: 450500 + iso.3.6.1.4.1.41112.1.11.1.3.1.8.170.211.109.42.26.137 = INTEGER: 999 + """ + + result = SnmpClient.parse_af60_output("", station) + assert result.tx_capacity == 450 + # 999 kbps → 0 mbps (floor division) + assert result.rx_capacity == 0 + end + + test "converts link_uptime from centiseconds to seconds" do + station = """ + iso.3.6.1.4.1.41112.1.11.1.3.1.17.170.211.109.42.26.137 = INTEGER: 12345 + """ + + result = SnmpClient.parse_af60_output("", station) + assert result.link_uptime == 123 + end + + test "ignores unknown OID columns in the station output" do + station = """ + iso.3.6.1.4.1.41112.1.11.1.3.1.3.170.211.109.42.26.137 = INTEGER: -42 + iso.3.6.1.4.1.41112.1.11.1.3.1.99.170.211.109.42.26.137 = INTEGER: 7777 + """ + + result = SnmpClient.parse_af60_output("", station) + assert result.rx_power_0 == -42 + refute Map.has_key?(result, :col_99) + assert map_size(result) == 1 + end + end + + describe "parse_snmpget_output/2 OID normalisation" do + test "accepts SNMPv2-SMI::enterprises.* OID prefix" do + output = """ + SNMPv2-SMI::enterprises.41112.1.3.2.1.11.1 = INTEGER: -61 + """ + + result = SnmpClient.parse_snmpget_output(output, :af11x) + assert result.rx_power_0 == -61 + end + + test "skips lines that don't parse as OID = value" do + output = """ + iso.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -55 + garbage line + """ + + result = SnmpClient.parse_snmpget_output(output, :af11x) + assert result.rx_power_0 == -55 + assert map_size(result) == 1 + end + + test "skips values that aren't parseable as integers" do + output = """ + iso.3.6.1.4.1.41112.1.3.2.1.11.1 = STRING: "not-a-number" + iso.3.6.1.4.1.41112.1.3.2.1.14.1 = INTEGER: -56 + """ + + result = SnmpClient.parse_snmpget_output(output, :af11x) + refute Map.has_key?(result, :rx_power_0) + assert result.rx_power_1 == -56 + end end end diff --git a/test/microwaveprop/propagation/notify_listener_test.exs b/test/microwaveprop/propagation/notify_listener_test.exs index 322a9850..88b78cb4 100644 --- a/test/microwaveprop/propagation/notify_listener_test.exs +++ b/test/microwaveprop/propagation/notify_listener_test.exs @@ -110,5 +110,35 @@ defmodule Microwaveprop.Propagation.NotifyListenerTest do :telemetry.detach(handler_id) end end + + test "broadcasts propagation:updated to PubSub after warming" do + valid_time = ~U[2026-04-21 14:00:00Z] + ScoresFile.write!(10_000, valid_time, sample_scores()) + + Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated") + + :ok = NotifyListener.handle_propagation_ready(valid_time) + + assert_receive {:propagation_updated, [^valid_time]}, 1_000 + end + end + + describe "handle_info/2 raw dispatch" do + test "notification with a malformed payload is tolerated (no crash)" do + # The GenServer callback must not raise even when PostgreSQL emits + # a malformed payload. Direct-dispatch the callback to avoid + # standing up a real Postgrex.Notifications subscriber. + assert {:noreply, %{ref: nil, pid: nil}} = + NotifyListener.handle_info( + {:notification, self(), make_ref(), "propagation_ready", "not-a-datetime"}, + %{ref: nil, pid: nil} + ) + end + + test "unrelated messages fall through without changing state" do + state = %{ref: nil, pid: nil} + assert {:noreply, ^state} = NotifyListener.handle_info(:something_else, state) + assert {:noreply, ^state} = NotifyListener.handle_info({:EXIT, self(), :normal}, state) + end end end diff --git a/test/microwaveprop/propagation/profiles_file_test.exs b/test/microwaveprop/propagation/profiles_file_test.exs index 35805792..0139cab3 100644 --- a/test/microwaveprop/propagation/profiles_file_test.exs +++ b/test/microwaveprop/propagation/profiles_file_test.exs @@ -191,7 +191,7 @@ defmodule Microwaveprop.Propagation.ProfilesFileTest do end describe "MessagePack dual-format reader" do - test "read/1 prefers .mp.gz over .etf.gz when both exist", %{dir: dir} do + test "read/1 prefers .mp.gz over .etf.gz when both exist", %{dir: _dir} do vt = ~U[2026-04-19 14:00:00Z] # Elixir writes the legacy ETF path. diff --git a/test/microwaveprop/propagation/score_cache_reconciler_test.exs b/test/microwaveprop/propagation/score_cache_reconciler_test.exs index f575061c..5f5c2d61 100644 --- a/test/microwaveprop/propagation/score_cache_reconciler_test.exs +++ b/test/microwaveprop/propagation/score_cache_reconciler_test.exs @@ -51,7 +51,7 @@ defmodule Microwaveprop.Propagation.ScoreCacheReconcilerTest do # filters valid_times to `[now-1h, now+18h]`, so hardcoded fixtures # in the past drift out of scope as time passes. `current_hour/1` # returns an hour-aligned DateTime offset by N hours from now. - defp current_hour(offset_hours \\ 0) do + defp current_hour(offset_hours) do DateTime.utc_now() |> Map.put(:minute, 0) |> Map.put(:second, 0) diff --git a/test/microwaveprop/space_weather/observation_changesets_test.exs b/test/microwaveprop/space_weather/observation_changesets_test.exs new file mode 100644 index 00000000..30b53c6b --- /dev/null +++ b/test/microwaveprop/space_weather/observation_changesets_test.exs @@ -0,0 +1,234 @@ +defmodule Microwaveprop.SpaceWeather.ObservationChangesetsTest do + @moduledoc """ + Cast/validate coverage for the three space-weather observation + schemas. These were historically at ~50 % line coverage because + only the happy path of `swpc_client` exercised `changeset/2` — the + required/optional splits and the unique constraints had no direct + assertions. + """ + use ExUnit.Case, async: true + + alias Microwaveprop.Ionosphere.Observation, as: IonoObs + alias Microwaveprop.SpaceWeather.GeomagneticObservation + alias Microwaveprop.SpaceWeather.SolarFluxObservation + alias Microwaveprop.SpaceWeather.SolarXrayObservation + alias Microwaveprop.Weather.HrrrClimatology + alias Microwaveprop.Weather.Metar5minObservation + + describe "GeomagneticObservation.changeset/2" do + test "valid with only valid_time" do + cs = GeomagneticObservation.changeset(%GeomagneticObservation{}, %{valid_time: ~U[2026-04-21 00:00:00Z]}) + assert cs.valid? + end + + test "accepts kp_index and estimated_kp" do + cs = + GeomagneticObservation.changeset(%GeomagneticObservation{}, %{ + valid_time: ~U[2026-04-21 00:00:00Z], + kp_index: 4, + estimated_kp: 4.33 + }) + + assert cs.valid? + assert Ecto.Changeset.get_change(cs, :kp_index) == 4 + assert Ecto.Changeset.get_change(cs, :estimated_kp) == 4.33 + end + + test "invalid when valid_time is missing" do + cs = GeomagneticObservation.changeset(%GeomagneticObservation{}, %{kp_index: 3}) + refute cs.valid? + assert %{valid_time: ["can't be blank"]} = errors_on(cs) + end + end + + describe "SolarFluxObservation.changeset/2" do + test "valid with required valid_time" do + cs = SolarFluxObservation.changeset(%SolarFluxObservation{}, %{valid_time: ~U[2026-04-21 00:00:00Z]}) + assert cs.valid? + end + + test "accepts frequency, flux, schedule, and 90-day mean" do + cs = + SolarFluxObservation.changeset(%SolarFluxObservation{}, %{ + valid_time: ~U[2026-04-21 00:00:00Z], + frequency_mhz: 2800, + flux_sfu: 142.4, + reporting_schedule: "daily", + ninety_day_mean: 150.0 + }) + + assert cs.valid? + end + + test "invalid when valid_time is missing" do + cs = SolarFluxObservation.changeset(%SolarFluxObservation{}, %{flux_sfu: 142.4}) + refute cs.valid? + assert %{valid_time: ["can't be blank"]} = errors_on(cs) + end + end + + describe "SolarXrayObservation.changeset/2" do + test "valid with required valid_time" do + cs = SolarXrayObservation.changeset(%SolarXrayObservation{}, %{valid_time: ~U[2026-04-21 00:00:00Z]}) + assert cs.valid? + end + + test "casts satellite, energy_band, flux, and electron_contaminated" do + cs = + SolarXrayObservation.changeset(%SolarXrayObservation{}, %{ + valid_time: ~U[2026-04-21 00:00:00Z], + satellite: 16, + flux_wm2: 1.23e-6, + energy_band: "0.1-0.8nm", + electron_contaminated: true + }) + + assert cs.valid? + assert Ecto.Changeset.get_change(cs, :electron_contaminated) == true + end + + test "invalid when valid_time is missing" do + cs = SolarXrayObservation.changeset(%SolarXrayObservation{}, %{flux_wm2: 1.0e-7}) + refute cs.valid? + assert %{valid_time: ["can't be blank"]} = errors_on(cs) + end + end + + describe "Ionosphere.Observation.changeset/2" do + test "valid with required station_code + valid_time" do + cs = + IonoObs.changeset(%IonoObs{}, %{ + station_code: "BC840", + valid_time: ~U[2026-04-21 00:00:00Z] + }) + + assert cs.valid? + end + + test "casts scaled characteristics" do + cs = + IonoObs.changeset(%IonoObs{}, %{ + station_code: "BC840", + valid_time: ~U[2026-04-21 00:00:00Z], + confidence_score: 75, + fo_f2_mhz: 10.4, + fo_e_mhz: 3.2, + fo_es_mhz: 6.1, + hm_f2_km: 275.0, + mufd_mhz: 18.5 + }) + + assert cs.valid? + assert Ecto.Changeset.get_change(cs, :fo_f2_mhz) == 10.4 + assert Ecto.Changeset.get_change(cs, :confidence_score) == 75 + end + + test "invalid when either required field is missing" do + cs1 = IonoObs.changeset(%IonoObs{}, %{valid_time: ~U[2026-04-21 00:00:00Z]}) + cs2 = IonoObs.changeset(%IonoObs{}, %{station_code: "BC840"}) + + refute cs1.valid? + refute cs2.valid? + assert %{station_code: ["can't be blank"]} = errors_on(cs1) + assert %{valid_time: ["can't be blank"]} = errors_on(cs2) + end + end + + describe "HrrrClimatology.changeset/2" do + test "valid with the four required keys" do + cs = + HrrrClimatology.changeset(%HrrrClimatology{}, %{ + lat: 32.9, + lon: -97.0, + month: 7, + hour: 15 + }) + + assert cs.valid? + end + + test "casts mean / stddev / sample_count" do + cs = + HrrrClimatology.changeset(%HrrrClimatology{}, %{ + lat: 32.9, + lon: -97.0, + month: 7, + hour: 15, + mean_surface_temp_c: 30.5, + stddev_surface_temp_c: 4.2, + sample_count: 365 + }) + + assert cs.valid? + assert Ecto.Changeset.get_change(cs, :mean_surface_temp_c) == 30.5 + assert Ecto.Changeset.get_change(cs, :sample_count) == 365 + end + + test "invalid when any of the four cell-identity keys is missing" do + for missing <- [:lat, :lon, :month, :hour] do + attrs = Map.delete(%{lat: 32.9, lon: -97.0, month: 7, hour: 15}, missing) + + cs = HrrrClimatology.changeset(%HrrrClimatology{}, attrs) + refute cs.valid?, "expected #{missing} missing to invalidate" + assert errors_on(cs)[missing] == ["can't be blank"] + end + end + end + + describe "Metar5minObservation.changeset/2" do + @station_id Ecto.UUID.generate() + + test "valid with station_id + observed_at" do + cs = + Metar5minObservation.changeset(%Metar5minObservation{}, %{ + station_id: @station_id, + observed_at: ~U[2026-04-21 00:00:00Z] + }) + + assert cs.valid? + end + + test "casts every observation field" do + cs = + Metar5minObservation.changeset(%Metar5minObservation{}, %{ + station_id: @station_id, + observed_at: ~U[2026-04-21 00:05:00Z], + temp_f: 72.5, + dewpoint_f: 55.0, + relative_humidity: 55.0, + wind_speed_kts: 8.0, + wind_direction_deg: 180, + sea_level_pressure_mb: 1013.2, + altimeter_setting: 29.92, + sky_condition: "SCT030", + precip_1h_in: 0.0, + wx_codes: "VCTS" + }) + + assert cs.valid? + end + + test "invalid without station_id" do + cs = Metar5minObservation.changeset(%Metar5minObservation{}, %{observed_at: ~U[2026-04-21 00:00:00Z]}) + refute cs.valid? + assert errors_on(cs)[:station_id] == ["can't be blank"] + end + + test "invalid without observed_at" do + cs = Metar5minObservation.changeset(%Metar5minObservation{}, %{station_id: @station_id}) + refute cs.valid? + assert errors_on(cs)[:observed_at] == ["can't be blank"] + end + end + + # Local copy of the standard Phoenix.DataCase `errors_on/1` so this + # file can stay on the lightweight `ExUnit.Case` (no DB sandbox + # needed for changeset tests). + defp errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/test/microwaveprop/terrain/viewshed_test.exs b/test/microwaveprop/terrain/viewshed_test.exs index 8c495efc..e66691ae 100644 --- a/test/microwaveprop/terrain/viewshed_test.exs +++ b/test/microwaveprop/terrain/viewshed_test.exs @@ -80,6 +80,58 @@ defmodule Microwaveprop.Terrain.ViewshedTest do end end + describe "effective_reach_km/3" do + test "CLEAR path always gets the full range regardless of score" do + analysis = %{verdict: "CLEAR", diffraction_db: 0.0} + assert Viewshed.effective_reach_km(analysis, 50.0, 0) == 50.0 + assert Viewshed.effective_reach_km(analysis, 50.0, 100) == 50.0 + end + + test "FRESNEL_MINOR scales range to 90%" do + analysis = %{verdict: "FRESNEL_MINOR", diffraction_db: 2.0} + assert Viewshed.effective_reach_km(analysis, 50.0, 50) == 45.0 + end + + test "FRESNEL_PARTIAL scales range to 70%" do + analysis = %{verdict: "FRESNEL_PARTIAL", diffraction_db: 4.0} + assert Viewshed.effective_reach_km(analysis, 50.0, 50) == 35.0 + end + + test "BLOCKED with mild diffraction (<=3 dB) keeps 80% via terrain factor" do + analysis = %{verdict: "BLOCKED", diffraction_db: 2.0} + # score=0 so ducting factor is 0.05; terrain factor 0.8 wins. + assert Viewshed.effective_reach_km(analysis, 50.0, 0) == 40.0 + end + + test "BLOCKED reduces monotonically as diffraction_db climbs" do + ranges = + Enum.map([2.0, 5.0, 10.0, 15.0, 30.0], fn db -> + analysis = %{verdict: "BLOCKED", diffraction_db: db} + Viewshed.effective_reach_km(analysis, 100.0, 0) + end) + + assert ranges == Enum.sort(ranges, :desc) + assert List.first(ranges) > List.last(ranges) + end + + test "BLOCKED with high ducting score overrides terrain factor" do + analysis = %{verdict: "BLOCKED", diffraction_db: 25.0} + # At 25 dB, terrain factor is 0.05. A score of 85 gives ducting + # factor 0.7 — that should be the dominant term. + assert Viewshed.effective_reach_km(analysis, 100.0, 85) == 70.0 + end + + test "BLOCKED ducting-score tiers: 80+ / 65+ / 50+ / 33+ / <33" do + analysis = %{verdict: "BLOCKED", diffraction_db: 40.0} + # terrain_reach_factor(40) = 0.05 in every row, so max() == ducting factor. + assert Viewshed.effective_reach_km(analysis, 100.0, 90) == 70.0 + assert Viewshed.effective_reach_km(analysis, 100.0, 65) == 50.0 + assert Viewshed.effective_reach_km(analysis, 100.0, 50) == 30.0 + assert Viewshed.effective_reach_km(analysis, 100.0, 33) == 15.0 + assert Viewshed.effective_reach_km(analysis, 100.0, 10) == 5.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/gefs_client_test.exs b/test/microwaveprop/weather/gefs_client_test.exs index 84044955..d6c9a4ad 100644 --- a/test/microwaveprop/weather/gefs_client_test.exs +++ b/test/microwaveprop/weather/gefs_client_test.exs @@ -1,5 +1,6 @@ defmodule Microwaveprop.Weather.GefsClientTest do use ExUnit.Case, async: true + use ExUnitProperties alias Microwaveprop.Weather.GefsClient @@ -203,5 +204,81 @@ defmodule Microwaveprop.Weather.GefsClientTest do assert is_float(td) assert td < -30.0 end + + property "Td is never greater than T (physics floor)" do + check all( + temp_c <- float(min: -40.0, max: 50.0), + rh_pct <- float(min: 0.01, max: 100.0) + ) do + td = GefsClient.dewpoint_from_rh(temp_c, rh_pct) + # Magnus formula: at RH=100 Td≈T; below saturation Td < T. + # Allow 1.0 °C slack for the Magnus approximation near + # saturation at the extremes of the temperature range. + assert td <= temp_c + 1.0 + end + end + + property "Td increases monotonically with RH at fixed T" do + check all( + temp_c <- float(min: -20.0, max: 40.0), + rh_low <- float(min: 1.0, max: 50.0), + rh_delta <- float(min: 1.0, max: 50.0) + ) do + rh_high = min(rh_low + rh_delta, 100.0) + td_low = GefsClient.dewpoint_from_rh(temp_c, rh_low) + td_high = GefsClient.dewpoint_from_rh(temp_c, rh_high) + + assert td_low <= td_high + end + end + + property "is always finite for any positive RH in [0.01, 100]" do + check all( + temp_c <- float(min: -50.0, max: 60.0), + rh_pct <- float(min: 0.01, max: 100.0) + ) do + td = GefsClient.dewpoint_from_rh(temp_c, rh_pct) + assert is_float(td) + # The bound checks also screen out NaN and ±Inf since both + # `NaN < 1000.0` and `NaN > -1000.0` return false in Elixir. + assert td > -1000.0 + assert td < 1000.0 + end + end + end + + describe "nearest_run/1 property" do + property "is idempotent and output hour is in {0, 6, 12, 18}" do + check all( + year <- integer(2020..2030), + month <- integer(1..12), + day <- integer(1..28), + hour <- integer(0..23), + minute <- integer(0..59), + second <- integer(0..59) + ) do + dt = %DateTime{ + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: {0, 0}, + std_offset: 0, + utc_offset: 0, + zone_abbr: "UTC", + time_zone: "Etc/UTC" + } + + once = GefsClient.nearest_run(dt) + twice = GefsClient.nearest_run(once) + + assert DateTime.compare(once, twice) == :eq + assert once.hour in [0, 6, 12, 18] + assert once.minute == 0 + assert once.second == 0 + end + end end end diff --git a/test/microwaveprop/weather/hrrr_native_client_test.exs b/test/microwaveprop/weather/hrrr_native_client_test.exs index ad851d70..1cd97b79 100644 --- a/test/microwaveprop/weather/hrrr_native_client_test.exs +++ b/test/microwaveprop/weather/hrrr_native_client_test.exs @@ -164,4 +164,71 @@ defmodule Microwaveprop.Weather.HrrrNativeClientTest do HrrrClient.byte_ranges_for_messages(idx_entries, HrrrNativeClient.native_messages()) end end + + describe "native_level_count/0 and native_variables/0" do + test "native_level_count is 50 (full hybrid stack)" do + assert HrrrNativeClient.native_level_count() == 50 + end + + test "native_variables lists exactly the 7 native-grid fields" do + vars = HrrrNativeClient.native_variables() + assert length(vars) == 7 + assert "TMP" in vars + assert "SPFH" in vars + assert "HGT" in vars + assert "PRES" in vars + assert "UGRD" in vars + assert "VGRD" in vars + assert "TKE" in vars + end + end + + describe "native_messages/0 shape" do + test "emits one message per (level, variable) across the 50-level hybrid stack" do + messages = HrrrNativeClient.native_messages() + expected = HrrrNativeClient.native_level_count() * length(HrrrNativeClient.native_variables()) + assert length(messages) == expected + + # Every message carries a non-empty var and a hybrid-level string. + Enum.each(messages, fn %{var: var, level: level} -> + assert var in HrrrNativeClient.native_variables() + assert level =~ ~r/\A\d+ hybrid level\z/ + end) + end + + test "each (var, level) pair is unique" do + messages = HrrrNativeClient.native_messages() + unique = messages |> Enum.uniq_by(fn %{var: v, level: l} -> {v, l} end) |> length() + assert unique == length(messages) + end + end + + describe "duct_messages/0 and duct_byte_ranges/1" do + test "emits one message per (level, duct-var) across the 50-level stack (4 vars)" do + messages = HrrrNativeClient.duct_messages() + assert length(messages) == 50 * 4 + + # Exactly the duct-detection variables — no UGRD/VGRD/TKE. + vars = messages |> Enum.map(& &1.var) |> Enum.uniq() |> Enum.sort() + assert vars == ["HGT", "PRES", "SPFH", "TMP"] + end + + test "duct_byte_ranges/1 returns integer ranges for matching idx entries" do + idx_entries = [ + %{msg: 1, offset: 0, var: "TMP", level: "1 hybrid level"}, + %{msg: 2, offset: 1000, var: "SPFH", level: "1 hybrid level"}, + %{msg: 3, offset: 2000, var: "UGRD", level: "1 hybrid level"}, + %{msg: 4, offset: 3000, var: "HGT", level: "1 hybrid level"} + ] + + ranges = HrrrNativeClient.duct_byte_ranges(idx_entries) + # UGRD is not in the duct set, so only 3 ranges should come back. + assert length(ranges) == 3 + + Enum.each(ranges, fn {s, e} -> + assert is_integer(s) and is_integer(e) + assert s < e + end) + end + end end diff --git a/test/microwaveprop/weather/nexrad_client_test.exs b/test/microwaveprop/weather/nexrad_client_test.exs index e7a93b5b..766f4eb8 100644 --- a/test/microwaveprop/weather/nexrad_client_test.exs +++ b/test/microwaveprop/weather/nexrad_client_test.exs @@ -1,5 +1,6 @@ defmodule Microwaveprop.Weather.NexradClientTest do use ExUnit.Case, async: false + use ExUnitProperties alias Microwaveprop.Weather.NexradCache alias Microwaveprop.Weather.NexradClient @@ -215,5 +216,71 @@ defmodule Microwaveprop.Weather.NexradClientTest do assert dbz > 0.0 assert dbz < 95.0 end + + property "is monotonic non-decreasing across [1, 255]" do + check all( + a <- integer(1..254), + delta <- integer(0..254) + ) do + b = min(a + delta, 255) + assert NexradClient.pixel_to_dbz(a) <= NexradClient.pixel_to_dbz(b) + end + end + + property "output is in [-30.0, 95.0] for any byte value [1, 255]" do + check all(value <- integer(1..255)) do + dbz = NexradClient.pixel_to_dbz(value) + assert dbz >= -30.0 + assert dbz <= 95.0 + 1.0e-9 + end + end + end + + describe "latlon_to_pixel/2 invariants" do + property "matching lat/lon corners snap to pixel corners exactly" do + # The CONUS grid is a 12201×5401 frame anchored at (50N, -126W) + # with 0.005° resolution. Every cell center whose (lat, lon) + # is aligned with the grid must round-trip to the integer pixel + # coordinates the module's constants imply. + check all( + x <- integer(0..12_200), + y <- integer(0..5400) + ) do + lat = 50.0 - y * 0.005 + lon = -126.0 + x * 0.005 + {px, py} = NexradClient.latlon_to_pixel(lat, lon) + assert px == x + assert py == y + end + end + end + + describe "extract_box/5 edge cases" do + test "box partially off the left edge returns whatever pixels fall inside" do + # 4-row × 10-col frame, every pixel hot. + pixels = :binary.copy(<<128>>, 40) + # Centre x=0 → x_min=0, x_max=2. Centre y=0 → y_min=0, y_max=2. + result = NexradClient.extract_box(pixels, 10, 0, 0, 2) + assert result.pixel_count > 0 + assert result.max_reflectivity_dbz > 0.0 + end + + test "box entirely off the right edge returns no-data (no pixels)" do + pixels = :binary.copy(<<128>>, 40) + # width=10, cx=50 → x_min=48, x_max=9 → empty range after clamp. + result = NexradClient.extract_box(pixels, 10, 50, 2, 2) + assert result.pixel_count == 0 + assert result.mean_reflectivity_dbz == 0.0 + assert result.max_reflectivity_dbz == 0.0 + assert result.texture_variance == 0.0 + end + + test "zero pixels in box yields the no-echo stats tuple" do + pixels = :binary.copy(<<0>>, 40) + result = NexradClient.extract_box(pixels, 10, 5, 2, 2) + # Every pixel is 0 → none enter the filtered dbz_values list. + assert result.pixel_count == 0 + assert result.mean_reflectivity_dbz == 0.0 + end end end diff --git a/test/microwaveprop/workers/radar_frame_worker_test.exs b/test/microwaveprop/workers/radar_frame_worker_test.exs index 776c966e..f581e058 100644 --- a/test/microwaveprop/workers/radar_frame_worker_test.exs +++ b/test/microwaveprop/workers/radar_frame_worker_test.exs @@ -13,7 +13,7 @@ defmodule Microwaveprop.Workers.RadarFrameWorkerTest do :ok end - defp insert_contact(attrs \\ %{}) do + defp insert_contact(attrs) do default = %{ station1: "W5XD", station2: "K5TR", diff --git a/test/microwaveprop_web/live/admin/contact_edit_live_test.exs b/test/microwaveprop_web/live/admin/contact_edit_live_test.exs index b4310b31..040114ef 100644 --- a/test/microwaveprop_web/live/admin/contact_edit_live_test.exs +++ b/test/microwaveprop_web/live/admin/contact_edit_live_test.exs @@ -14,7 +14,7 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do user end - defp create_contact(attrs \\ %{}) do + defp create_contact(attrs) do default = %{ station1: "W5XD", station2: "K5TR",