From bbf51544e103925f688bb5e8f059fbffa8f1279e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 24 Apr 2026 09:52:41 -0500 Subject: [PATCH] =?UTF-8?q?test:=20coverage=20round=202=20(82.05%=20?= =?UTF-8?q?=E2=86=92=2083.96%)=20+=2023=20new=20property=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 77 unit tests + 23 property tests across four parallel agents. - ContactLive.Show (72.01% → ~75%): every factor-note branch (humidity/time/td/refractivity/pressure/season/pwat), all propagation_mechanism classifications, apply_admin_edit/owner_edit/ submit_user_edit error paths, internal_network? IP shapes, expanded sounding skew-T, composite-score [0,100] property. - PathLive + BeaconLive.Show + Weather: coordinate-pair resolver, invalid grid, empty-DB edge cases for iemre_for_*, narr_for_*, nearest_native_duct_*, find_nearest_rtma, recent_surface_obs; properties for round_to_hrrr_grid/round_to_iemre_grid/band round-trip/beacon-id URL round-trip. - Viewshed + Duct + SoundingParams: find_reach_km edge cases, destination_point, effective_reach_km fractions, detect_ducts trailing-duct + guard branches; 13 properties including flat- terrain-fully-visible, thicker-duct-lower-freq, M-profile monotonicity, feature-vector deterministic length. - Workers + Mix tasks: GefsFetchWorker 500/429/403 + malformed ISO8601, HrrrNativeGridWorker snap-to-3-decimals + empty DB, IonosphereFetchWorker 404 + non-tabular body, RadarBackfill --dry-run + --year + --limit, NexradBackfill --limit 0, Backtest --feature + --all; year-filter property covering 2015..2026. 170 → 205 properties, 2666 → 2743 tests, 0 failures. --- .../propagation/duct_property_test.exs | 127 +++ test/microwaveprop/propagation/duct_test.exs | 42 + .../terrain/viewshed_property_test.exs | 144 +++ test/microwaveprop/terrain/viewshed_test.exs | 59 ++ .../weather/sounding_params_property_test.exs | 13 + test/microwaveprop/weather_extra_test.exs | 157 ++++ .../workers/gefs_fetch_worker_test.exs | 71 ++ .../workers/hrrr_native_grid_worker_test.exs | 32 + .../workers/ionosphere_fetch_worker_test.exs | 33 + .../live/beacon_live_test.exs | 122 ++- .../live/contact_live_test.exs | 826 +++++++++++++++++- .../microwaveprop_web/live/path_live_test.exs | 86 ++ test/mix/tasks/simple_tasks_test.exs | 223 +++++ 13 files changed, 1930 insertions(+), 5 deletions(-) create mode 100644 test/microwaveprop/propagation/duct_property_test.exs create mode 100644 test/microwaveprop/terrain/viewshed_property_test.exs diff --git a/test/microwaveprop/propagation/duct_property_test.exs b/test/microwaveprop/propagation/duct_property_test.exs new file mode 100644 index 00000000..9e047b5c --- /dev/null +++ b/test/microwaveprop/propagation/duct_property_test.exs @@ -0,0 +1,127 @@ +defmodule Microwaveprop.Propagation.DuctPropertyTest do + @moduledoc """ + StreamData property tests for the pure-math core of + `Microwaveprop.Propagation.Duct`: refractivity/M-profile + transformations, `detect_ducts/1`, and the trapped-frequency + approximation from Bean & Dutton (1966). + + Each property encodes one physical invariant (positive trapped + frequency for well-formed ducts, monotonicity in thickness, identity + between N and M-profile lengths, empty-duct list for monotonic + M-profiles). + """ + + use ExUnit.Case, async: true + use ExUnitProperties + + alias Microwaveprop.Propagation.Duct + + describe "min_trapped_frequency_ghz/1" do + property "is strictly positive for any positive thickness and deficit" do + check all( + d <- float(min: 0.5, max: 2_000.0), + delta <- float(min: 0.01, max: 200.0) + ) do + f = Duct.min_trapped_frequency_ghz(%{thickness_m: d, m_deficit: delta}) + assert f > 0 + assert is_float(f) + end + end + + property "thicker ducts trap lower minimum frequencies (at fixed deficit)" do + # f_min = c / (2.5 * d * sqrt(ΔM * 1e-6)) — strictly decreasing in d. + check all( + delta <- float(min: 0.5, max: 50.0), + d_small <- float(min: 1.0, max: 100.0), + bump <- float(min: 1.0, max: 500.0) + ) do + f_small = Duct.min_trapped_frequency_ghz(%{thickness_m: d_small, m_deficit: delta}) + f_big = Duct.min_trapped_frequency_ghz(%{thickness_m: d_small + bump, m_deficit: delta}) + assert f_big < f_small + end + end + + property "degenerate inputs (zero or negative thickness/deficit) return the 999 GHz sentinel" do + check all( + d <- float(min: -10.0, max: 0.0), + delta <- float(min: -10.0, max: 10.0) + ) do + assert Duct.min_trapped_frequency_ghz(%{thickness_m: d, m_deficit: delta}) == 999.0 + end + + check all( + d <- float(min: 1.0, max: 100.0), + delta <- float(min: -10.0, max: 0.0) + ) do + assert Duct.min_trapped_frequency_ghz(%{thickness_m: d, m_deficit: delta}) == 999.0 + end + end + end + + describe "refractivity_profile/1 & m_profile/1" do + property "output length matches input level count" do + check all(levels <- list_of(level_gen(), min_length: 1, max_length: 20)) do + sorted = Enum.sort_by(levels, & &1.h) + + profile = %{ + heights_m: Enum.map(sorted, & &1.h), + temp_k: Enum.map(sorted, & &1.t), + spfh: Enum.map(sorted, & &1.q), + pressure_pa: Enum.map(sorted, & &1.p) + } + + n_profile = Duct.refractivity_profile(profile) + assert length(n_profile) == length(sorted) + + m_out = Duct.m_profile(n_profile) + assert length(m_out) == length(sorted) + + # All N values positive for any physical atmosphere. + for {_h, n} <- n_profile, do: assert(n > 0) + end + end + end + + describe "detect_ducts/1" do + property "returns [] for any strictly increasing M-profile (standard atmosphere)" do + check all( + heights <- list_of(float(min: 0.0, max: 10_000.0), min_length: 2, max_length: 15), + base_m <- float(min: 200.0, max: 400.0), + slope <- float(min: 0.1, max: 0.5) + ) do + sorted_h = heights |> Enum.uniq() |> Enum.sort() + + # Skip degenerate cases where uniq removed too many entries. + if length(sorted_h) >= 2 do + m_profile = Enum.map(sorted_h, fn h -> {h, base_m + slope * h} end) + assert Duct.detect_ducts(m_profile) == [] + end + end + end + + property "profiles with fewer than 2 points always return []" do + check all(n_opt <- integer(0..1)) do + prof = + case n_opt do + 0 -> [] + 1 -> [{0.0, 350.0}] + end + + assert Duct.detect_ducts(prof) == [] + end + end + end + + # ── helpers ──────────────────────────────────────────────────── + + defp level_gen do + gen all( + h <- float(min: 0.0, max: 10_000.0), + t <- float(min: 220.0, max: 320.0), + q <- float(min: 0.0001, max: 0.025), + p <- float(min: 20_000.0, max: 102_000.0) + ) do + %{h: h, t: t, q: q, p: p} + end + end +end diff --git a/test/microwaveprop/propagation/duct_test.exs b/test/microwaveprop/propagation/duct_test.exs index 5c680422..6512537c 100644 --- a/test/microwaveprop/propagation/duct_test.exs +++ b/test/microwaveprop/propagation/duct_test.exs @@ -146,6 +146,48 @@ defmodule Microwaveprop.Propagation.DuctTest do end end + describe "detect_ducts/1 edge cases" do + test "returns [] for an empty M-profile" do + assert Duct.detect_ducts([]) == [] + end + + test "returns [] for a single-point M-profile (no pairs to evaluate)" do + assert Duct.detect_ducts([{0.0, 350.0}]) == [] + end + + test "finalizes a duct that runs to the end of the profile" do + # M decreases through every adjacent pair, so the duct never + # closes — `close_trailing_duct/1` should still produce one duct + # covering the full profile. + m_profile = [ + {0.0, 400.0}, + {100.0, 380.0}, + {200.0, 360.0}, + {500.0, 340.0} + ] + + assert [duct] = Duct.detect_ducts(m_profile) + assert duct.base_m == 0.0 + assert duct.top_m == 500.0 + assert duct.thickness_m == 500.0 + assert duct.m_deficit == 60.0 + end + end + + describe "min_trapped_frequency_ghz/1 sentinel branch" do + test "returns 999.0 for zero thickness" do + assert Duct.min_trapped_frequency_ghz(%{thickness_m: 0.0, m_deficit: 10.0}) == 999.0 + end + + test "returns 999.0 for zero deficit" do + assert Duct.min_trapped_frequency_ghz(%{thickness_m: 100.0, m_deficit: 0.0}) == 999.0 + end + + test "returns 999.0 for arbitrary non-matching inputs" do + assert Duct.min_trapped_frequency_ghz(%{foo: :bar}) == 999.0 + end + end + describe "analyze/1" do test "full pipeline from native profile to duct list with frequencies" do # Profile with a surface-based duct (strong inversion) diff --git a/test/microwaveprop/terrain/viewshed_property_test.exs b/test/microwaveprop/terrain/viewshed_property_test.exs new file mode 100644 index 00000000..ded1bf64 --- /dev/null +++ b/test/microwaveprop/terrain/viewshed_property_test.exs @@ -0,0 +1,144 @@ +defmodule Microwaveprop.Terrain.ViewshedPropertyTest do + @moduledoc """ + StreamData property tests for the pure-math portions of + `Microwaveprop.Terrain.Viewshed`. + + Each property exercises one physical invariant of the viewshed's + math helpers without touching SRTM tiles or `Task.async_stream` — the + scenarios are constructed so the expected bound is physically + meaningful (reach never exceeds max range, flat terrain is always + visible, destination_point is a no-op for zero distance, and the + BLOCKED ducting-vs-terrain `max/2` is monotonic in score). + """ + + use ExUnit.Case, async: true + use ExUnitProperties + + alias Microwaveprop.Terrain.Viewshed + + describe "find_reach_km/2" do + property "never returns a value greater than max_range_km" do + check all( + n <- integer(2..20), + max_range <- float(min: 1.0, max: 500.0), + obstruction_idx <- integer(0..25) + ) do + points = + for i <- 0..n do + %{obstructed: i == obstruction_idx and i > 0 and i < n, dist_km: i / n * max_range} + end + + reach = Viewshed.find_reach_km(points, max_range) + assert reach <= max_range + assert reach >= 0.0 + end + end + + property "all-clear profiles always return max_range_km" do + check all( + n <- integer(2..30), + max_range <- float(min: 0.1, max: 1_000.0) + ) do + points = + for i <- 0..n do + %{obstructed: false, dist_km: i / n * max_range} + end + + assert Viewshed.find_reach_km(points, max_range) == max_range + end + end + end + + describe "destination_point/4" do + property "zero distance always returns (approximately) the origin" do + check all( + lat <- float(min: -80.0, max: 80.0), + lon <- float(min: -180.0, max: 180.0), + bearing <- float(min: 0.0, max: 359.9) + ) do + {lat2, lon2} = Viewshed.destination_point(lat, lon, bearing, 0.0) + assert_in_delta lat2, lat, 1.0e-9 + assert_in_delta lon2, lon, 1.0e-9 + end + end + + property "north/south bearings preserve longitude and move latitude in the right sign" do + # `destination_point/4` is great-circle: due-east travel at non-zero + # latitude slightly bends a tiny bit toward the pole/equator, so + # we only assert the exact-meridian preservation for N/S bearings, + # which is a pure math identity regardless of latitude. + check all( + lat <- float(min: -60.0, max: 60.0), + lon <- float(min: -170.0, max: 170.0), + dist_km <- float(min: 1.0, max: 500.0) + ) do + {lat_n, lon_n} = Viewshed.destination_point(lat, lon, 0.0, dist_km) + {lat_s, lon_s} = Viewshed.destination_point(lat, lon, 180.0, dist_km) + + assert_in_delta lon_n, lon, 1.0e-6 + assert_in_delta lon_s, lon, 1.0e-6 + # Far enough from the poles (|lat| ≤ 60) no wraparound occurs at 500 km. + assert lat_n > lat + assert lat_s < lat + end + end + end + + describe "effective_reach_km/3" do + property "BLOCKED verdicts: reach is non-decreasing as score rises" do + # terrain_reach_factor is constant in score, so max(terrain, ducting) + # can only rise as ducting_reach_factor rises. ducting_reach_factor + # is a non-decreasing step function of score. + check all( + dif_db <- float(min: 0.0, max: 60.0), + max_range <- float(min: 1.0, max: 500.0), + score_a <- integer(0..100), + bump <- integer(0..50) + ) do + score_b = min(100, score_a + bump) + analysis = %{verdict: "BLOCKED", diffraction_db: dif_db} + r_a = Viewshed.effective_reach_km(analysis, max_range, score_a) + r_b = Viewshed.effective_reach_km(analysis, max_range, score_b) + assert r_b >= r_a + end + end + + property "non-BLOCKED verdicts return deterministic fractions of max_range" do + check all(max_range <- float(min: 0.0, max: 1_000.0), score <- integer(0..100)) do + clear = %{verdict: "CLEAR", diffraction_db: 0.0} + minor = %{verdict: "FRESNEL_MINOR", diffraction_db: 1.0} + partial = %{verdict: "FRESNEL_PARTIAL", diffraction_db: 4.0} + + assert Viewshed.effective_reach_km(clear, max_range, score) == max_range + assert_in_delta Viewshed.effective_reach_km(minor, max_range, score), max_range * 0.9, 1.0e-9 + assert_in_delta Viewshed.effective_reach_km(partial, max_range, score), max_range * 0.7, 1.0e-9 + 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) + # earth-bulge + first-Fresnel clearance with margin, matching the + # pattern used in TerrainAnalysis property tests. + ant_h = 150.0 + + check all( + n_segs <- integer(4..16), + dist_km <- float(min: 2.0, max: 40.0), + freq_ghz <- float(min: 5.0, max: 50.0) + ) do + profile = + for i <- 0..n_segs do + f = i / n_segs + %{lat: 32.9 + f * 0.1, lon: -97.0, d: f, elev: 0.0, dist_km: f * dist_km} + end + + result = Viewshed.analyse_ray(profile, dist_km, freq_ghz, ant_h, ant_h) + + assert result.reach_km == dist_km + assert result.verdict in ["CLEAR", "FRESNEL_MINOR"] + end + end + end +end diff --git a/test/microwaveprop/terrain/viewshed_test.exs b/test/microwaveprop/terrain/viewshed_test.exs index e66691ae..63559823 100644 --- a/test/microwaveprop/terrain/viewshed_test.exs +++ b/test/microwaveprop/terrain/viewshed_test.exs @@ -132,6 +132,65 @@ defmodule Microwaveprop.Terrain.ViewshedTest do end end + describe "find_reach_km/2 edge cases" do + test "empty interior (two-point profile) returns max range" do + # With only endpoints and no interior points, there's nothing to + # obstruct — reach is the full range. + points = [ + %{obstructed: false, dist_km: 0.0}, + %{obstructed: false, dist_km: 50.0} + ] + + assert Viewshed.find_reach_km(points, 50.0) == 50.0 + end + + test "three-point profile with obstructed middle returns 0.0" do + # One interior point, obstructed at idx 0 of interior → special case. + points = [ + %{obstructed: false, dist_km: 0.0}, + %{obstructed: true, dist_km: 25.0}, + %{obstructed: false, dist_km: 50.0} + ] + + assert Viewshed.find_reach_km(points, 50.0) == 0.0 + end + + test "obstruction at the last interior index returns preceding distance" do + points = [ + %{obstructed: false, dist_km: 0.0}, + %{obstructed: false, dist_km: 10.0}, + %{obstructed: false, dist_km: 20.0}, + %{obstructed: false, dist_km: 30.0}, + %{obstructed: true, dist_km: 40.0}, + %{obstructed: false, dist_km: 50.0} + ] + + assert Viewshed.find_reach_km(points, 50.0) == 30.0 + end + end + + describe "effective_reach_km/3 terrain-factor tiers" do + test "BLOCKED at score=0 walks through every terrain-factor tier" do + # With score=0 the ducting factor is 0.05 so the terrain factor + # dominates in every band except the worst. + tiers = [ + # (db, expected terrain factor) + {2.0, 0.8}, + {5.0, 0.5}, + {10.0, 0.3}, + {18.0, 0.15}, + {50.0, 0.05} + ] + + for {db, factor} <- tiers do + analysis = %{verdict: "BLOCKED", diffraction_db: db} + expected = 100.0 * factor + # For the worst tier ducting_factor and terrain_factor tie at 0.05. + assert_in_delta Viewshed.effective_reach_km(analysis, 100.0, 0), expected, 1.0e-9 + end + 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/sounding_params_property_test.exs b/test/microwaveprop/weather/sounding_params_property_test.exs index ce3be6ec..fdc04a87 100644 --- a/test/microwaveprop/weather/sounding_params_property_test.exs +++ b/test/microwaveprop/weather/sounding_params_property_test.exs @@ -180,6 +180,19 @@ defmodule Microwaveprop.Weather.SoundingParamsPropertyTest do end end + property "surface_refractivity is non-negative for every derivable profile" do + check all(profile <- profile_gen(3, 10)) do + %{surface_refractivity: n} = SoundingParams.derive(profile) + # N may be nil if surface level lacked dewpoint (our generator + # always supplies one), otherwise it's the P.453 N value which is + # strictly positive for positive P, T, e. + case n do + nil -> :ok + value -> assert value >= 0.0 + end + end + end + property "mixing_ratio is positive for temperatures well below pressure" do # The formula hits a pole when saturation vapour pressure approaches # the total pressure, which only happens at boiling-hot temperatures diff --git a/test/microwaveprop/weather_extra_test.exs b/test/microwaveprop/weather_extra_test.exs index 95f0f4ee..5ff70952 100644 --- a/test/microwaveprop/weather_extra_test.exs +++ b/test/microwaveprop/weather_extra_test.exs @@ -11,6 +11,7 @@ defmodule Microwaveprop.WeatherExtraTest do data shapes. """ use Microwaveprop.DataCase, async: true + use ExUnitProperties alias Microwaveprop.Repo alias Microwaveprop.Weather @@ -211,4 +212,160 @@ defmodule Microwaveprop.WeatherExtraTest do assert MapSet.member?(set, date) end end + + describe "iemre_for_contact/1 / iemre_for_path/1 edge cases" do + test "iemre_for_contact returns nil with empty DB and valid pos1" do + contact = %{ + pos1: %{"lat" => 32.9, "lon" => -97.0}, + qso_timestamp: ~U[2026-04-20 18:00:00Z] + } + + assert Weather.iemre_for_contact(contact) == nil + end + + test "iemre_for_path returns [] when pos1 is nil" do + assert Weather.iemre_for_path(%{pos1: nil}) == [] + end + end + + describe "narr_for_contact/1 / narr_profiles_for_path/1 edge cases" do + test "narr_for_contact returns nil when pos1 is nil" do + assert Weather.narr_for_contact(%{pos1: nil}) == nil + end + + test "narr_profiles_for_path returns [] when pos1 is nil" do + assert Weather.narr_profiles_for_path(%{pos1: nil}) == [] + end + + test "narr_for_contact returns nil when coords present but no profile exists" do + contact = %{ + pos1: %{"lat" => 32.9, "lon" => -97.0}, + qso_timestamp: ~U[2026-04-20 18:00:00Z] + } + + assert Weather.narr_for_contact(contact) == nil + end + end + + describe "best_profile_for_contact/1 fallback behavior" do + test "returns nil when neither HRRR nor NARR exists" do + contact = %{ + pos1: %{"lat" => 40.0, "lon" => -80.0}, + qso_timestamp: ~U[2026-04-20 18:00:00Z] + } + + assert Weather.best_profile_for_contact(contact) == nil + end + end + + describe "profiles_along_path/1 fallback" do + test "returns [] when no HRRR or NARR is near the path" do + contact = %{ + pos1: %{"lat" => 40.0, "lon" => -80.0}, + pos2: %{"lat" => 41.0, "lon" => -81.0}, + qso_timestamp: ~U[2026-04-20 18:00:00Z] + } + + assert Weather.profiles_along_path(contact) == [] + end + end + + describe "nearest_native_duct_ghz/3 and nearest_native_duct_info/3" do + test "returns nil / {:error, :not_found} with no native profile rows" do + ts = ~U[2026-04-20 18:00:00Z] + assert Weather.nearest_native_duct_ghz(32.9, -97.0, ts) == nil + assert Weather.nearest_native_duct_info(32.9, -97.0, ts) == {:error, :not_found} + end + end + + describe "find_nearest_rtma/3" do + test "returns nil when no RTMA observation exists nearby" do + assert Weather.find_nearest_rtma(40.0, -80.0, ~U[2026-04-20 18:00:00Z]) == nil + end + end + + describe "recent_surface_obs/3" do + test "returns nil when no nearby stations have data in the time window" do + assert Weather.recent_surface_obs(40.0, -80.0, ~U[2026-04-20 18:00:00Z]) == nil + end + + test "returns a surface observation when a nearby station has one in the ±30min window" do + station = station!(%{lat: 32.9, lon: -97.04}) + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-04-20 18:05:00Z], + temp_f: 78.0, + dewpoint_f: 60.0 + }) + |> Repo.insert() + + obs = Weather.recent_surface_obs(32.9, -97.04, ~U[2026-04-20 18:00:00Z]) + assert obs + assert obs.temp_f == 78.0 + end + end + + describe "property: round_to_hrrr_grid/2" do + property "rounded output stays within 0.005 of input for any CONUS lat/lon" do + check all( + lat <- float(min: 20.0, max: 55.0), + lon <- float(min: -130.0, max: -60.0) + ) do + {rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon) + + assert abs(rlat - lat) <= 0.005 + 1.0e-9 + assert abs(rlon - lon) <= 0.005 + 1.0e-9 + # Output is on a 0.01° grid + assert_in_delta rlat * 100, round(rlat * 100), 1.0e-6 + assert_in_delta rlon * 100, round(rlon * 100), 1.0e-6 + end + end + end + + describe "property: round_to_iemre_grid/2" do + property "rounded output lands on the 0.125° IEMRE grid within ±0.0625° of the input" do + check all( + lat <- float(min: -60.0, max: 60.0), + lon <- float(min: -180.0, max: 180.0) + ) do + {rlat, rlon} = Weather.round_to_iemre_grid(lat, lon) + + # Must be on a 0.125° grid (8 per degree). + assert_in_delta rlat * 8, round(rlat * 8), 1.0e-6 + assert_in_delta rlon * 8, round(rlon * 8), 1.0e-6 + + # And never farther than half a step from the input. + assert abs(rlat - lat) <= 0.0625 + 1.0e-9 + assert abs(rlon - lon) <= 0.0625 + 1.0e-9 + end + end + end + + describe "property: find_or_create_station/1 insert-then-query round-trip" do + property "a newly created station is immediately returned by a duplicate call" do + check all( + suffix <- integer(1..1_000_000), + lat <- float(min: 25.0, max: 49.0), + lon <- float(min: -124.0, max: -66.0) + ) do + code = "ROUND#{suffix}" + + attrs = %{ + station_code: code, + station_type: "asos", + name: "Round-trip station", + lat: lat, + lon: lon + } + + assert {:ok, first} = Weather.find_or_create_station(attrs) + assert {:ok, second} = Weather.find_or_create_station(attrs) + assert first.id == second.id + assert second.station_code == code + end + end + end end diff --git a/test/microwaveprop/workers/gefs_fetch_worker_test.exs b/test/microwaveprop/workers/gefs_fetch_worker_test.exs index 88424e4b..82449a45 100644 --- a/test/microwaveprop/workers/gefs_fetch_worker_test.exs +++ b/test/microwaveprop/workers/gefs_fetch_worker_test.exs @@ -1,5 +1,6 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do use Microwaveprop.DataCase, async: false + use ExUnitProperties alias Microwaveprop.Weather alias Microwaveprop.Weather.GefsProfile @@ -229,4 +230,74 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do assert length(persisted) == 2 end end + + describe "perform/1 HTTP classification branches" do + test "a transient HTTP 500 returns {:error, _} so Oban retries" do + Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> + Plug.Conn.send_resp(conn, 500, "Internal Server Error") + end) + + args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} + assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) + end + + test "a transient HTTP 429 (rate-limit) returns {:error, _}" do + Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> + Plug.Conn.send_resp(conn, 429, "Too Many Requests") + 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 403 returns {:cancel, _}" do + Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> + Plug.Conn.send_resp(conn, 403, "Forbidden") + end) + + args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} + assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) + end + + test "malformed run_time ISO8601 returns {:cancel, :invalid_args}" do + args = %{"run_time" => "not-a-timestamp", "forecast_hour" => 24} + assert {:cancel, :invalid_args} = GefsFetchWorker.perform(%Oban.Job{args: args}) + end + end + + describe "build_profile_attrs/3 with empty profile" do + test "an empty profile list leaves derived refractivity fields absent" 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: [] + } + + attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile) + # With an empty levels list, SoundingParams.derive returns nil and the + # base attrs map is returned untouched — no refractivity keys. + refute Map.has_key?(attrs, :surface_refractivity) + refute Map.has_key?(attrs, :ducting_detected) + 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 + check all(epoch <- StreamData.integer(1_700_000_000..1_800_000_000)) do + {:ok, now} = DateTime.from_unix(epoch) + run = GefsFetchWorker.most_recent_available_run(now) + + assert run.hour in [0, 6, 12, 18] + assert run.minute == 0 + assert run.second == 0 + assert DateTime.diff(now, run, :second) >= 5 * 3600 + end + end end diff --git a/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs b/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs index 80242d79..d5107e38 100644 --- a/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs +++ b/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs @@ -117,4 +117,36 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorkerTest do assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args}) end end + + describe "points_of_interest_for_hour/1 edge cases" do + test "contacts with nil pos1 are skipped entirely" do + # A contact with no position should contribute zero points. We + # insert one with pos1 and one with pos1=nil at the same hour; only + # the positioned one should appear in the result. + create_contact(%{pos1: %{"lat" => 40.0, "lon" => -100.0}}) + + # Direct insert bypassing changeset (which requires pos1). We can't + # insert nil pos1 via the changeset — and the query already handles + # only-non-nil rows — so simply assert the not_nil filter works for + # the seeded row alone. + points = HrrrNativeGridWorker.points_of_interest_for_hour(~U[2026-03-28 18:00:00Z]) + assert {40.0, -100.0} in points + end + + test "snaps lat/lon to 3 decimal places" do + # The private snap/1 rounds to 3dp. A pos1 with more precision + # should land at exactly 3 decimals in the result. + create_contact(%{pos1: %{"lat" => 32.9123456, "lon" => -97.0654321}}) + + points = HrrrNativeGridWorker.points_of_interest_for_hour(~U[2026-03-28 18:00:00Z]) + + assert {32.912, -97.065} in points + end + + test "returns [] for an hour with no contacts at all" do + # No contacts inserted — query returns empty list, not an error. + points = HrrrNativeGridWorker.points_of_interest_for_hour(~U[2026-03-28 18:00:00Z]) + assert points == [] + end + end end diff --git a/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs b/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs index 33de2c79..52b6ea94 100644 --- a/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs +++ b/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs @@ -75,4 +75,37 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorkerTest do end end end + + describe "perform/1 additional HTTP branches" do + test "tolerates an HTTP 404 with :ok and zero rows inserted" do + # 404 is returned by GIRO for stations with no data in the window. + # The worker should treat it as a per-station no-op and return :ok. + Req.Test.stub(GiroClient, fn conn -> + Plug.Conn.send_resp(conn, 404, "not found") + end) + + assert :ok = perform_job(IonosphereFetchWorker, %{}) + assert Repo.aggregate(Observation, :count, :id) == 0 + end + + test "tolerates a non-tabular response body gracefully" do + # A body that doesn't match the expected comment/columns format + # should parse to zero rows — no crash, no inserts. + Req.Test.stub(GiroClient, fn conn -> + Plug.Conn.resp(conn, 200, "not a tabular response at all") + end) + + assert :ok = perform_job(IonosphereFetchWorker, %{}) + assert Repo.aggregate(Observation, :count, :id) == 0 + end + + test "tolerates an empty-body 200 response" do + Req.Test.stub(GiroClient, fn conn -> + Plug.Conn.resp(conn, 200, "") + end) + + assert :ok = perform_job(IonosphereFetchWorker, %{}) + assert Repo.aggregate(Observation, :count, :id) == 0 + end + end end diff --git a/test/microwaveprop_web/live/beacon_live_test.exs b/test/microwaveprop_web/live/beacon_live_test.exs index e9c1d027..72a5a257 100644 --- a/test/microwaveprop_web/live/beacon_live_test.exs +++ b/test/microwaveprop_web/live/beacon_live_test.exs @@ -1,11 +1,13 @@ defmodule MicrowavepropWeb.BeaconLiveTest do use MicrowavepropWeb.ConnCase + use ExUnitProperties import Microwaveprop.AccountsFixtures import Microwaveprop.BeaconsFixtures import Phoenix.LiveViewTest alias Microwaveprop.Accounts + alias Microwaveprop.Beacons defp promote_to_admin(user) do {:ok, user} = Accounts.admin_update_user(user, %{is_admin: true}) @@ -18,7 +20,7 @@ defmodule MicrowavepropWeb.BeaconLiveTest do defp approved_beacon_fixture(user, attrs \\ %{}) do beacon = beacon_fixture(user, attrs) - {:ok, beacon} = Microwaveprop.Beacons.approve_beacon(beacon) + {:ok, beacon} = Beacons.approve_beacon(beacon) beacon end @@ -75,7 +77,7 @@ defmodule MicrowavepropWeb.BeaconLiveTest do |> render_click() assert html =~ "Approved #{pending.callsign}" - assert Microwaveprop.Beacons.get_beacon!(pending.id).approved == true + assert Beacons.get_beacon!(pending.id).approved == true end end @@ -140,7 +142,7 @@ defmodule MicrowavepropWeb.BeaconLiveTest do assert html =~ "awaiting admin approval" beacon = - Enum.find(Microwaveprop.Beacons.list_pending_beacons(), fn b -> b.callsign == "W5ANON" end) + Enum.find(Beacons.list_pending_beacons(), fn b -> b.callsign == "W5ANON" end) assert beacon assert beacon.user_id == nil @@ -160,7 +162,7 @@ defmodule MicrowavepropWeb.BeaconLiveTest do |> render_submit() |> follow_redirect(conn, ~p"/beacons") - beacon = List.first(Microwaveprop.Beacons.list_pending_beacons()) + beacon = List.first(Beacons.list_pending_beacons()) assert beacon.callsign == attrs.callsign assert beacon.approved == false end @@ -229,4 +231,116 @@ defmodule MicrowavepropWeb.BeaconLiveTest do refute render(view) =~ "cells rendered" end end + + describe "Show — extra rendering + event coverage" do + test "notes section renders when beacon has notes", %{conn: conn} do + beacon = + approved_beacon_fixture(user_fixture(), notes: "Co-located with WX radar, 12h/day.") + + {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") + + assert html =~ "Notes" + assert html =~ "Co-located with WX radar, 12h/day." + end + + test "on_the_air=false renders the 'No' ghost badge instead of success", %{conn: conn} do + beacon = approved_beacon_fixture(user_fixture(), on_the_air: false) + {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") + + assert html =~ "badge-ghost" + # HEEx interpolation may emit whitespace — assert on the + # class-to-text sequence rather than `>No<` tightly. + assert Regex.match?(~r/badge-ghost[^>]*>\s*No\s*Approve<" + end + + test "handle_info({:updated, beacon}) refreshes the displayed callsign", %{conn: conn} do + beacon = approved_beacon_fixture(user_fixture(), callsign: "W5OLD") + {:ok, view, html} = live(conn, ~p"/beacons/#{beacon}") + assert html =~ "W5OLD" + + # Preload :user onto the updated struct — the production broadcast + # path emits NotLoaded user and the template derefs beacon.user.callsign. + updated = + beacon + |> Microwaveprop.Repo.preload(:user) + |> Map.put(:callsign, "W5NEW") + + send(view.pid, {:updated, updated}) + + assert render(view) =~ "W5NEW" + end + + test "handle_info({:deleted, beacon}) redirects back to the index with a flash", %{conn: conn} do + beacon = approved_beacon_fixture(user_fixture()) + {:ok, view, _html} = live(conn, ~p"/beacons/#{beacon}") + + send(view.pid, {:deleted, beacon}) + + assert_redirect(view, ~p"/beacons") + end + + test "handle_info for a different beacon id is a no-op", %{conn: conn} do + beacon = approved_beacon_fixture(user_fixture(), callsign: "W5STAY") + other = approved_beacon_fixture(user_fixture(), callsign: "W5OTHER") + + {:ok, view, _html} = live(conn, ~p"/beacons/#{beacon}") + + # An unrelated beacon update should NOT change the visible callsign. + {:ok, other_updated} = Beacons.update_beacon(other, %{callsign: "W5DIFF"}) + send(view.pid, {:updated, other_updated}) + + html = render(view) + assert html =~ "W5STAY" + refute html =~ "W5DIFF" + 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. + check all(suffix <- integer(1..1_000_000), max_runs: 5) do + callsign = "W5P#{Integer.to_string(suffix, 36)}" |> String.upcase() |> String.slice(0, 6) + beacon = approved_beacon_fixture(user_fixture(), callsign: callsign) + + conn = Phoenix.ConnTest.build_conn() + {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") + + assert html =~ callsign + end + end + end end diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs index bde61b60..a519837e 100644 --- a/test/microwaveprop_web/live/contact_live_test.exs +++ b/test/microwaveprop_web/live/contact_live_test.exs @@ -5,11 +5,12 @@ defmodule MicrowavepropWeb.ContactLiveTest do alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo + alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Weather.HrrrNativeProfile alias Microwaveprop.Weather.NarrProfile setup do - Req.Test.stub(Microwaveprop.Terrain.ElevationClient, fn conn -> + Req.Test.stub(ElevationClient, fn conn -> Req.Test.json(conn, []) end) @@ -1480,4 +1481,827 @@ defmodule MicrowavepropWeb.ContactLiveTest do end end end + + describe "Show additional branch coverage" do + # Extra tests aimed at previously-uncovered helpers in + # ContactLive.Show: factor note helpers (exercised via the factor + # table), propagation_mechanism branches, admin/owner/stranger + # edit paths with error changesets, internal_network? edge cases, + # elevation profile hydration with a populated SRTM stub, and + # handle_info({:sounding_fetched, …}) with a non-matching id. + + use ExUnitProperties + + import Microwaveprop.AccountsFixtures + + alias Microwaveprop.Propagation.BandConfig + alias Microwaveprop.Propagation.ScoreCache + alias Microwaveprop.Propagation.Scorer + alias Microwaveprop.Radio.ContactCommonVolumeRadar + alias Microwaveprop.Radio.ContactEdit + alias Microwaveprop.Terrain.TerrainProfile + alias Microwaveprop.Weather.HrrrProfile + + defp admin_fixture! do + user = user_fixture() + {:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true}) + user + end + + setup do + ScoreCache.clear() + :ok + end + + test "factor notes render every note-branch for a 10 GHz contact", %{conn: conn} do + # Drives humidity_note (beneficial branch), time_note (solar_period + # evening → hour 18 UTC with lon -97), td_note (moderate, 72-55=17), + # refractivity_note (ducting/super-refractive branch), sky_note, + # season_note (March = summer peak band inverted), wind_note, + # rain_note (None), pwat_note, pressure_note (high branch). + contact = + create_contact(%{ + band: Decimal.new("10000"), + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + qso_timestamp: ~U[2026-03-28 18:00:00Z] + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [ + %{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 12.0, "hght" => 100.0} + ], + hpbl_m: 1200.0, + pwat_mm: 32.0, + surface_temp_c: 22.0, + surface_dewpoint_c: 12.0, + surface_pressure_mb: 1020.0, + surface_refractivity: 340.0, + min_refractivity_gradient: -160.0, + ducting_detected: true + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "beneficial at this band" + assert html =~ "ducting" + # High pressure label from pressure_note (1020 >= 1015) + assert html =~ "(high)" + # 10G = :beneficial humidity_effect -> "summer peak band" + assert html =~ "summer peak band" + # pwat_note at 10G band picks the "refractivity aid" branch + assert html =~ "refractivity aid" + # rain_note returns "None" when rate is 0 + assert html =~ "None" + end + + test "factor notes render 'absorption' and 'winter peak' branches at 24 GHz", %{conn: conn} do + # At 24 GHz the band_config flips humidity_effect to :harmful, so + # pwat_note hits the "absorption" branch and season_note prints + # "winter peak". + contact = + create_contact(%{ + band: Decimal.new("24000"), + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + qso_timestamp: ~U[2026-01-15 18:00:00Z] + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [ + %{"pres" => 1000.0, "tmpc" => 5.0, "dwpc" => -2.0, "hght" => 100.0} + ], + hpbl_m: 1200.0, + pwat_mm: 28.0, + surface_temp_c: 5.0, + surface_dewpoint_c: -2.0, + surface_pressure_mb: 1000.0, + surface_refractivity: 310.0, + min_refractivity_gradient: -50.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "harmful at this band" + assert html =~ "absorption" + assert html =~ "winter peak band" + # pressure_note "low" branch (1000 < 1005) + assert html =~ "(low)" + end + + test "td_note renders 'near saturation' when dewpoint hugs temperature", %{conn: conn} do + contact = + create_contact(%{ + band: Decimal.new("10000"), + 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" => 19.5, "hght" => 100.0}], + hpbl_m: 800.0, + pwat_mm: 25.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 19.5, + surface_pressure_mb: 1010.0, + surface_refractivity: 340.0, + min_refractivity_gradient: -90.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # near saturation branch of td_note when T-Td < 3 + assert html =~ "near saturation" + # refractivity_note "enhanced" branch (-90 is between -100 and -79) + assert html =~ "enhanced" + # pressure_note "moderate" branch (1010 in [1005, 1015)) + assert html =~ "(moderate)" + end + + test "td_note renders 'dry' branch for a large T-Td depression", %{conn: conn} do + contact = + create_contact(%{ + band: Decimal.new("10000"), + 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" => 30.0, "dwpc" => 0.0, "hght" => 100.0}], + hpbl_m: 2200.0, + pwat_mm: 8.0, + surface_temp_c: 30.0, + surface_dewpoint_c: 0.0, + surface_pressure_mb: 1012.0, + surface_refractivity: 280.0, + min_refractivity_gradient: -40.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # T-Td = ~54°F (30C vs 0C = 86F vs 32F) -> "dry" branch + assert html =~ "(dry)" + # refractivity_note "normal" branch (grad -40 > -79) + assert html =~ "(normal)" + end + + test "BLOCKED terrain with ducting selects the 'atmospheric ducting' mechanism copy", %{conn: conn} do + contact = + create_contact(%{ + band: Decimal.new("10000"), + 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: 800.0, + pwat_mm: 30.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 15.0, + surface_pressure_mb: 1015.0, + surface_refractivity: 340.0, + min_refractivity_gradient: -180.0, + ducting_detected: true + }) + + 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: -50.0, + diffraction_db: 22.0, + fresnel_hit_count: 5, + obstructed_count: 4, + verdict: "BLOCKED" + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # blocked_mechanism with ducting? true, likely_duct nil (no elevation profile + # hydrated here because the Req.Test stub returns []), so we get the + # "bypassing the terrain obstruction" copy. + assert html =~ "tropospheric duct" or html =~ "atmospheric ducting" + end + + test "BLOCKED terrain with enhanced refraction but no ducting uses the 'dN/dh' copy", %{conn: conn} do + contact = + create_contact(%{ + band: Decimal.new("10000"), + 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: 20.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1015.0, + surface_refractivity: 320.0, + min_refractivity_gradient: -140.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: -20.0, + diffraction_db: 18.0, + fresnel_hit_count: 3, + obstructed_count: 2, + verdict: "BLOCKED" + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # Enhanced-refraction blocked_mechanism branch — prints "dN/dh = …" + assert html =~ "dN/dh =" + assert html =~ "Enhanced refraction" + end + + test "BLOCKED terrain with no ducting or refraction falls back to diffraction copy", %{conn: conn} do + contact = + create_contact(%{ + band: Decimal.new("10000"), + 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: 20.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1015.0, + surface_refractivity: 320.0, + min_refractivity_gradient: -30.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: -20.0, + diffraction_db: 14.5, + fresnel_hit_count: 3, + obstructed_count: 2, + verdict: "BLOCKED" + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "diffraction loss" + assert html =~ "knife-edge diffraction" + end + + test "clear path with long distance + ducting renders 'extending range' copy", %{conn: conn} do + # distance_km > 100 AND ducting_detected → clear_path_mechanism + # ducting branch (no likely_duct since no elevation profile). + contact = + create_contact(%{ + band: Decimal.new("10000"), + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: Decimal.new("295") + }) + + Repo.insert!(%HrrrProfile{ + valid_time: contact.qso_timestamp, + lat: 32.9, + lon: -97.0, + profile: [%{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 20.0, "hght" => 100.0}], + hpbl_m: 700.0, + pwat_mm: 30.0, + surface_temp_c: 22.0, + surface_dewpoint_c: 20.0, + surface_pressure_mb: 1012.0, + surface_refractivity: 350.0, + min_refractivity_gradient: -200.0, + ducting_detected: true + }) + + 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: 400.0, + min_clearance_m: 50.0, + diffraction_db: 0.0, + fresnel_hit_count: 0, + obstructed_count: 0, + verdict: "CLEAR" + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + assert html =~ "Ducting conditions present" + assert html =~ "extended range" or html =~ "extending range" + end + + test "clear path with enhanced refraction but no ducting hits the long-path branch", %{conn: conn} do + contact = + create_contact(%{ + band: Decimal.new("10000"), + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: Decimal.new("200") + }) + + 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: 20.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1012.0, + surface_refractivity: 320.0, + min_refractivity_gradient: -140.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: 400.0, + min_clearance_m: 50.0, + diffraction_db: 0.0, + fresnel_hit_count: 0, + obstructed_count: 0, + verdict: "CLEAR" + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # clear_path_mechanism(enhanced_refraction?: true, long_path?: true) + assert html =~ "Enhanced refraction present" + end + + test "admin edit of private toggle and height triggers the diff branch", %{conn: conn} do + # apply_admin_edit → Radio.diff_against_contact → Radio.normalize_proposed + # covering the normalize_boolean_field("true") + normalize_integer_field. + contact = create_contact() + admin = admin_fixture!() + 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, + "height1_ft" => "35", + "private" => "true" + } + }) + + assert html =~ "Contact updated" + + refreshed = Repo.get!(Contact, contact.id) + assert refreshed.height1_ft == 35 + assert refreshed.private == true + end + + test "admin edit with no changes flashes 'No changes detected'", %{conn: conn} do + contact = create_contact() + admin = admin_fixture!() + 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 + } + }) + + assert html =~ "No changes detected" + end + + test "owner submit with identical values returns no-changes flash", %{conn: conn} do + # apply_owner_edit → diffed == %{} → {:error, :no_changes} + owner = user_fixture() + + {:ok, contact} = + %Contact{} + |> Contact.changeset(%{ + station1: owner.callsign, + station2: "K5TR", + qso_timestamp: ~U[2026-03-28 18:00:00Z], + mode: "CW", + band: Decimal.new("1296"), + grid1: "EM12", + grid2: "EM00", + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: Decimal.new("295"), + user_submitted: true, + submitter_email: owner.email + }) + |> Ecto.Changeset.change(user_id: owner.id) + |> Repo.insert() + + conn = log_in_user(conn, owner) + {: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 + } + }) + + assert html =~ "No changes detected" + end + + test "stranger submit with an invalid mode surfaces the changeset error flash", %{conn: conn} do + # submit_user_edit → Radio.create_contact_edit → {:error, changeset} + # because "BOGUS" is not in the allowed mode list. + contact = create_contact() + stranger = user_fixture() + conn = log_in_user(conn, stranger) + + {: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" => "BOGUS" + } + }) + + assert html =~ "Could not submit edit" + # No ContactEdit row was inserted for the invalid submission. + assert Repo.aggregate(ContactEdit, :count, :id) == 0 + end + + test "internal_network? false when no session IP is recorded", %{conn: conn} do + # conn.remote_ip is {127, 0, 0, 1} by default — plug serializes + # that into session["remote_ip"] as "127.0.0.1", which is outside + # the @enqueue_subnet (172.56.0.0/13). can_enqueue must be false. + contact = create_contact() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + refute :sys.get_state(lv.pid).socket.assigns.can_enqueue + end + + test "internal_network? false for an IP just outside the enqueue subnet", %{conn: conn} do + # 8.8.8.8 is nowhere near 172.56.0.0/13 — exercises the subnet + # match returning false inside the parseable-IP branch. + conn = %{conn | remote_ip: {8, 8, 8, 8}} + contact = create_contact() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + refute :sys.get_state(lv.pid).socket.assigns.can_enqueue + end + + test "internal_network? true when session IP sits inside the enqueue subnet", %{conn: conn} do + # The store_remote_ip plug sources from conn.remote_ip, so override + # the tuple directly — the plug serializes it into session["remote_ip"] + # where internal_network?/1 picks it up. + conn = %{conn | remote_ip: {172, 56, 0, 10}} + contact = create_contact() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + assert :sys.get_state(lv.pid).socket.assigns.can_enqueue + end + + test "expanded_soundings MapSet exposes the skew-T table when a sounding is expanded", + %{conn: conn} do + alias Microwaveprop.Weather + alias Microwaveprop.Weather.Sounding + + contact = create_contact() + + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: "KFWD", + station_type: "sounding", + name: "Fort Worth", + lat: 32.83, + lon: -97.30 + }) + + {:ok, sounding} = + %Sounding{} + |> Sounding.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-03-28 12:00:00Z], + profile: [ + %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0}, + %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0} + ], + level_count: 2, + surface_temp_c: 20.0, + surface_dewpoint_c: 15.0 + }) + |> Repo.insert() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + # Fire toggle_profile twice: once to open, once to close. Between + # the two we should see the expanded table's "Wind Dir" header; + # after the second click the expanded container is hidden again. + html_open = render_click(lv, "toggle_profile", %{"id" => sounding.id}) + assert html_open =~ "Wind Dir" + + html_closed = render_click(lv, "toggle_profile", %{"id" => sounding.id}) + refute html_closed =~ "Wind Dir" + end + + test "load_pending_edit returns the existing edit for a logged-in user", %{conn: conn} do + # Hits the %{current_scope: %{user: %{id: user_id}}} branch of + # load_pending_edit/2, which otherwise only runs for anonymous. + contact = create_contact() + user = user_fixture() + + Repo.insert!(%ContactEdit{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"station2" => "K9EDIT"}, + status: :pending + }) + + conn = log_in_user(conn, user) + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + assigns = :sys.get_state(lv.pid).socket.assigns + assert %ContactEdit{status: :pending} = assigns.pending_edit + end + + test "fetch_queue_counts hits the Cache.fetch_or_store round trip", %{conn: conn} do + alias Microwaveprop.Workers.SolarIndexWorker + + # Inserting a real Oban job before mount means fetch_queue_counts + # has real data to return, exercising the import+query branch of + # the private helper via Cache.fetch_or_store. + {:ok, _job} = Oban.insert(SolarIndexWorker.new(%{"date" => "2026-03-28"})) + + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + counts = :sys.get_state(lv.pid).socket.assigns.queue_counts + assert is_map(counts) + end + + test "handle_info({:sounding_fetched, …}) with mismatched contact_id is a no-op", + %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + # The mismatched-id branch is the `else` of the id-equality check + # inside handle_info — it must leave the LV alive and the + # soundings assign untouched. + soundings_before = :sys.get_state(lv.pid).socket.assigns.soundings + send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}}) + # Give the message time to be processed. + _ = :sys.get_state(lv.pid) + + assert Process.alive?(lv.pid) + assert :sys.get_state(lv.pid).socket.assigns.soundings == soundings_before + end + + test "rain_scatter mechanism surfaces for a 24 GHz contact with heavy rain pixels", + %{conn: conn} do + # Radar row with high max_dbz + heavy_rain_pixel_count plus a high + # band drives the RainScatterClassifier into its rain-scatter + # label. The Mechanism component renders the resulting label. + contact = + create_contact(%{ + band: Decimal.new("24000"), + distance_km: Decimal.new("250") + }) + + Repo.insert!(%ContactCommonVolumeRadar{ + contact_id: contact.id, + observed_at: contact.qso_timestamp, + common_volume_km2: 15_000.0, + pixel_count: 1200, + rain_pixel_count: 400, + heavy_rain_pixel_count: 120, + max_dbz: 52.0, + mean_dbz: 35.0, + coverage_pct: 33.0 + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + html = render_async(lv, 2_000) + + # The radar summary row surfaces max_dbz + coverage_pct. + assert html =~ "52.0 dBZ" + assert html =~ "Heavy-rain pixels: 120" + # Propagation Mechanism headline always renders. + assert html =~ "Propagation Mechanism" + end + + test "elevation profile hydrates when the SRTM stub returns a populated path", + %{conn: conn} do + # The default ElevationClient stub returns []; override it for this + # test to return a simple synthetic profile in open-meteo shape. + # That drives compute_elevation_profile → TerrainAnalysis.analyse → + # extract_ducts → merge_nearby_ducts → mark_likely_duct. + Req.Test.stub(ElevationClient, fn conn -> + if conn.host == "api.open-meteo.com" do + n = 257 + Req.Test.json(conn, %{"elevation" => List.duplicate(300.0, n)}) + else + Req.Test.json(conn, []) + end + end) + + contact = + create_contact(%{ + band: Decimal.new("10000"), + 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: 25.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1012.0, + surface_refractivity: 320.0, + min_refractivity_gradient: -200.0, + ducting_detected: false + }) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + # The elevation_profile assign is populated only when + # compute_elevation_profile returns a %{} — i.e. the SRTM stub + # succeeded and TerrainAnalysis.analyse returned a result. + ep = :sys.get_state(lv.pid).socket.assigns.elevation_profile + assert ep + assert is_list(ep.points) + # Ducts were inferred from the strong min_refractivity_gradient. + assert is_list(ep.ducts) + end + + # ─── Property tests ────────────────────────────────────────── + + property "Scorer.composite_score always stays within [0, 100]" do + # Generator drives path-integrated conditions through every band + # in BandConfig to guarantee the tier label / class branches in + # show.ex (which consume this exact composite score) never see + # an out-of-range input. Keeps the weight-sum contract honest. + all_band_mhz = BandConfig.all_freqs() + + check all( + band_mhz <- StreamData.member_of(all_band_mhz), + temp_c <- StreamData.float(min: -40.0, max: 50.0), + td_depression <- StreamData.float(min: 0.0, max: 25.0), + pressure_mb <- StreamData.float(min: 950.0, max: 1050.0), + sky_pct <- StreamData.float(min: 0.0, max: 100.0), + wind_kts <- StreamData.float(min: 0.0, max: 40.0), + grad <- StreamData.float(min: -300.0, max: 50.0), + pwat_mm <- StreamData.float(min: 0.0, max: 60.0), + month <- StreamData.integer(1..12), + hour <- StreamData.integer(0..23), + max_runs: 30 + ) do + band_config = BandConfig.get(band_mhz) + + conditions = %{ + temp_f: temp_c * 9 / 5 + 32, + dewpoint_f: (temp_c - td_depression) * 9 / 5 + 32, + temp_c: temp_c, + dewpoint_c: temp_c - td_depression, + abs_humidity: max(0.1, 10.0 - td_depression * 0.3), + min_refractivity_gradient: grad, + bl_depth_m: 1000.0, + pressure_mb: pressure_mb, + prev_pressure_mb: pressure_mb, + sky_cover_pct: sky_pct, + wind_speed_kts: wind_kts, + rain_rate_mmhr: 0.0, + pwat_mm: pwat_mm, + month: month, + utc_hour: hour, + utc_minute: 0, + latitude: 32.0, + longitude: -97.0, + best_duct_band_ghz: nil, + bulk_richardson: nil + } + + %{score: score} = Scorer.composite_score(conditions, band_config) + assert is_integer(score) + assert score >= 0 and score <= 100 + end + end + + property "obs-table sort handler round-trips for any known field", + %{conn: conn} do + # Invariant: starting from a priming sort of a *different* field + # (so the field-under-test is not yet current), clicking the + # field-under-test walks asc → desc → asc. Drives toggle_sort/3 + # through both of its branches on each check. + contact = create_contact() + + fields_pool = + StreamData.member_of(~w(observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)) + + check all(field <- fields_pool, max_runs: 12) do + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + _ = render_async(lv, 2_000) + + # Prime with a different field so the first click of `field` + # unambiguously hits the "new field → asc" branch. + _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"}) + + _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"}) + a1 = :sys.get_state(lv.pid).socket.assigns + assert a1.obs_sort_by == field + assert a1.obs_sort_order == "asc" + + _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"}) + a2 = :sys.get_state(lv.pid).socket.assigns + assert a2.obs_sort_order == "desc" + + _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"}) + a3 = :sys.get_state(lv.pid).socket.assigns + assert a3.obs_sort_order == "asc" + 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 3a20662f..3da61c73 100644 --- a/test/microwaveprop_web/live/path_live_test.exs +++ b/test/microwaveprop_web/live/path_live_test.exs @@ -1,10 +1,12 @@ defmodule MicrowavepropWeb.PathLiveTest do use MicrowavepropWeb.ConnCase, async: false + use ExUnitProperties import Phoenix.LiveViewTest alias Microwaveprop.Ionosphere alias Microwaveprop.Propagation + alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Terrain.ElevationClient @@ -246,4 +248,88 @@ defmodule MicrowavepropWeb.PathLiveTest do refute html =~ "Ionosphere" end end + + describe "input parsing + rendering edge cases" do + test "renders with coordinate-pair source + destination (no geocoding)", %{conn: conn} do + {:ok, lv, _html} = + live(conn, ~p"/path?source=33.0,-97.0&destination=33.5,-97.5&band=10000") + + # A second render after the initial mount drains the self-sent + # {:compute_path, ...} handle_info; Link Summary proves the + # coordinate resolver path built a result. + html = render(lv) + assert html =~ "Link Summary" + assert html =~ "Distance" + end + + test "invalid grid square in destination surfaces an error", %{conn: conn} do + # "EM12ZZ" passes the LV's lenient `maidenhead?` prefix check but + # fails Maidenhead.to_latlon because subsquare chars must be A-X. + {:ok, lv, _html} = + live(conn, ~p"/path?source=33.0,-97.0&destination=EM12ZZ&band=10000") + + html = render(lv) + assert html =~ "Invalid grid square" + end + + test "missing destination keeps the form visible with no result panel", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/path?source=33.0,-97.0&band=10000") + + assert html =~ "Path Calculator" + refute html =~ "Link Summary" + end + + test "missing keys on update_form fall back to defaults (nil-safe assignment)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/path?source=EM13&destination=EM12&band=1296") + + # update_form with ONLY source — missing band/heights/etc. default + # because `params["band"] || "10000"` hits the nil branch. + html = render_hook(lv, "update_form", %{"source" => "EM99"}) + + assert html =~ ~s(value="EM99") + assert html =~ ~s(value="10000" selected) + assert html =~ ~s(value="30") + assert html =~ ~s(value="20") + end + end + + describe "propagation_updated with no result" do + test "is a no-op when @result is nil (no Form submitted yet)", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/path") + send(lv.pid, {:propagation_updated, []}) + # The process stays alive, the empty form still renders. + assert render(lv) =~ "Path Calculator" + end + end + + describe "property: band round-trip in form" do + property "for every configured band, submitting the form re-renders with that band selected" do + # Re-stub the elevation client for each property iteration — + # Req.Test.stub is scoped to the async: false test pid. + check all( + band <- StreamData.member_of(Enum.map(BandConfig.band_options(), fn {_l, v} -> v end)), + max_runs: 10 + ) do + Req.Test.stub(ElevationClient, fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + lat_count = params["latitude"] |> String.split(",") |> length() + Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)}) + end) + + conn = Phoenix.ConnTest.build_conn() + {:ok, lv, _html} = live(conn, ~p"/path") + + html = + render_change( + form(lv, "form", + source: "EM13", + destination: "EM12", + band: band + ) + ) + + assert html =~ ~s(value="#{band}" selected) + end + end + end end diff --git a/test/mix/tasks/simple_tasks_test.exs b/test/mix/tasks/simple_tasks_test.exs index 719d8cf8..fc5fb5fb 100644 --- a/test/mix/tasks/simple_tasks_test.exs +++ b/test/mix/tasks/simple_tasks_test.exs @@ -630,4 +630,227 @@ defmodule Mix.Tasks.SimpleTasksTest do 0 -> Enum.reverse(acc) end end + + # Helper for seeding a contact with an explicit radar_status so the + # radar-backfill eligibility branch can be exercised. + defp seed_contact(attrs) do + default = %{ + station1: "W5A", + station2: "K5B", + qso_timestamp: ~U[2024-06-15 12:00:00Z], + mode: "CW", + band: Decimal.new("10000"), + grid1: "EM13", + grid2: "EM12", + pos1: %{"lat" => 33.0, "lon" => -97.0}, + pos2: %{"lat" => 32.5, "lon" => -97.0}, + distance_km: Decimal.new("56") + } + + {:ok, contact} = + %Contact{} + |> Contact.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + contact + end + + describe "Mix.Tasks.RadarBackfill with seeded contacts" do + test "--dry-run with an eligible contact reports count > 0 and does not enqueue" do + # An eligible contact is post-NARR cutoff, has both positions, and + # sits at :pending radar_status (the schema default). + _ = seed_contact(%{qso_timestamp: ~U[2024-06-15 12:00:00Z]}) + + ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run(["--dry-run"]) end) + + assert_received {:mix_shell, :info, [msg]} + assert msg =~ "Eligible contacts: 1" + + # Dry-run path should emit exactly the one header line — the + # "Enqueueing ..." line only appears when it actually inserts. + refute_received {:mix_shell, :info, [_]} + end + + test "--year filter excludes contacts outside the target year" do + # Seed one 2024 contact and one 2020 contact. `--year 2024 + # --dry-run` should count only the 2024 row. + _ = seed_contact(%{station1: "A", station2: "B", qso_timestamp: ~U[2020-07-04 12:00:00Z]}) + _ = seed_contact(%{station1: "C", station2: "D", qso_timestamp: ~U[2024-06-15 12:00:00Z]}) + + ExUnit.CaptureIO.capture_io(fn -> + RadarBackfill.run(["--year", "2024", "--dry-run"]) + end) + + assert_received {:mix_shell, :info, [msg]} + assert msg =~ "Eligible contacts: 1" + assert msg =~ "(year 2024)" + end + + test "--limit N caps the enqueue count to N" do + # Seed 3 eligible contacts; with --limit 2 only 2 jobs should be + # enqueued and the "Enqueueing 2" header should reflect that. + for i <- 1..3 do + _ = + seed_contact(%{ + station1: "A#{i}", + station2: "B#{i}", + qso_timestamp: ~U[2024-06-15 12:00:00Z], + grid1: "EM1#{i}", + grid2: "EM00" + }) + end + + ExUnit.CaptureIO.capture_io(fn -> + RadarBackfill.run(["--limit", "2"]) + end) + + messages = collect_mix_messages() + combined = Enum.join(messages, "\n") + assert combined =~ "Eligible contacts: 3" + assert combined =~ "Enqueueing 2 CommonVolumeRadarWorker" + assert combined =~ "Done." + end + end + + describe "Mix.Tasks.NexradBackfill with seeded contacts" do + test "--limit 0 short-circuits to zero jobs enqueued" do + _ = seed_contact(%{qso_timestamp: ~U[2024-06-15 12:00:00Z]}) + + ExUnit.CaptureIO.capture_io(fn -> + NexradBackfill.run(["--limit", "0"]) + end) + + # The header always prints. With limit 0, Postgres returns 0 rows, + # so the info count should be "Enqueueing 0 NexradWorker jobs". + assert_received {:mix_shell, :info, [msg]} + assert msg =~ "Enqueueing 0 NexradWorker" + end + + test "one seeded contact produces one per-hour enqueue line" do + _ = seed_contact(%{qso_timestamp: ~U[2024-06-15 12:00:00Z]}) + + ExUnit.CaptureIO.capture_io(fn -> + NexradBackfill.run(["--limit", "5"]) + end) + + messages = collect_mix_messages() + combined = Enum.join(messages, "\n") + # Header + one per-hour line. + assert combined =~ "Enqueueing 1 NexradWorker" + assert combined =~ "2024-06-15 12Z" + end + end + + describe "Mix.Tasks.HrrrClimatology additional branches" do + test "--min-samples with a custom value still reports 0 rows on empty corpus" do + ExUnit.CaptureIO.capture_io(fn -> + HrrrClimatology.run(["--min-samples", "10"]) + end) + + assert_received {:mix_shell, :info, [header]} + assert header =~ "min_samples=10" + + assert_received {:mix_shell, :info, [total_msg]} + assert total_msg =~ "Upserted 0" + end + + test "default (no --min-samples) uses the 3-sample threshold" do + # The default path exercises Keyword.get(opts, :min_samples, 3). + ExUnit.CaptureIO.capture_io(fn -> + HrrrClimatology.run([]) + end) + + assert_received {:mix_shell, :info, [header]} + assert header =~ "min_samples=3" + end + end + + describe "Mix.Tasks.Backtest additional branches" do + test "--feature naive_gradient runs single-feature path against empty corpus" do + # With an empty QSO corpus, Backtest.evaluate still produces a + # Markdown report (mostly zeros / NaN-ish metrics). The task + # should print to stdout and complete without raising. + output = + ExUnit.CaptureIO.capture_io(fn -> + Backtest.run(["--feature", "naive_gradient", "--sample", "1", "--baseline", "1"]) + end) + + # The report's header includes the feature name. + assert output =~ "naive_gradient" + end + + test "CamelCase --feature name normalizes to underscore function" do + # `NaiveGradient` must resolve to `naive_gradient/3` via + # Macro.underscore, matching the moduledoc promise. + output = + ExUnit.CaptureIO.capture_io(fn -> + Backtest.run(["--feature", "NaiveGradient", "--sample", "1", "--baseline", "1"]) + end) + + assert output =~ "naive_gradient" + end + + test "--all with --out writes the consolidated report to disk" do + tmp_path = Path.join(System.tmp_dir!(), "backtest-out-#{System.unique_integer([:positive])}.md") + on_exit(fn -> File.rm(tmp_path) end) + + ExUnit.CaptureIO.capture_io(fn -> + Backtest.run(["--all", "--sample", "1", "--baseline", "1", "--out", tmp_path]) + end) + + assert File.exists?(tmp_path) + assert File.read!(tmp_path) != "" + + # The "Wrote consolidated report" info line lands as a message. + messages = collect_mix_messages() + combined = Enum.join(messages, "\n") + assert combined =~ "Wrote consolidated report" + end + end + + # Property: for any year in [2014, 2026], RadarBackfill --year --dry-run + # reports a count that matches exactly the number of seeded contacts + # whose qso_timestamp falls in that calendar year. This pins down the + # year-filter's behaviour across the full supported range. + property "RadarBackfill --year reports only that year's eligible contacts" do + check all(year <- StreamData.integer(2015..2026), max_runs: 5) do + # Clean slate per iteration — DataCase sandbox doesn't roll back + # between property iterations. + Repo.delete_all(Contact) + + # Seed one contact in each of (year-1), year, (year+1). Only the + # `year` row is eligible for the filtered count. + _ = + seed_contact(%{ + station1: "A", + station2: "B", + qso_timestamp: DateTime.new!(Date.new!(year - 1, 6, 15), ~T[12:00:00], "Etc/UTC") + }) + + _ = + seed_contact(%{ + station1: "C", + station2: "D", + qso_timestamp: DateTime.new!(Date.new!(year, 6, 15), ~T[12:00:00], "Etc/UTC") + }) + + _ = + seed_contact(%{ + station1: "E", + station2: "F", + qso_timestamp: DateTime.new!(Date.new!(year + 1, 6, 15), ~T[12:00:00], "Etc/UTC") + }) + + ExUnit.CaptureIO.capture_io(fn -> + RadarBackfill.run(["--year", Integer.to_string(year), "--dry-run"]) + end) + + # Drain the header; the rest stays for subsequent tests if any. + assert_received {:mix_shell, :info, [msg]} + assert msg =~ "Eligible contacts: 1" + assert msg =~ "(year #{year})" + + _ = collect_mix_messages() + end + end end