test: coverage round 2 (82.05% → 83.96%) + 23 new property tests

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.
This commit is contained in:
Graham McIntire 2026-04-24 09:52:41 -05:00
parent e11ebc9af8
commit bbf51544e1
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
13 changed files with 1930 additions and 5 deletions

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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 =

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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*</s, html)
end
test "bearing is rendered as 'Omni' when stored as omni", %{conn: conn} do
beacon = approved_beacon_fixture(user_fixture(), bearing: "omni")
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
assert html =~ "Omni"
end
test "admin sees Approve button on unapproved beacon and the click approves it",
%{conn: conn} do
admin = admin_user_fixture()
pending = beacon_fixture(admin)
conn = log_in_user(conn, admin)
{:ok, view, html} = live(conn, ~p"/beacons/#{pending}")
assert html =~ "Approve"
assert html =~ "Pending approval"
render_click(element(view, "button", "Approve"))
refute render(view) =~ "Pending approval"
assert Beacons.get_beacon!(pending.id).approved == true
end
test "non-admin does not see the Approve button even on unapproved beacons",
%{conn: conn} do
user = user_fixture()
pending = beacon_fixture(user)
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/beacons/#{pending}")
assert html =~ "Pending approval"
refute html =~ ">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

View file

@ -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

View file

@ -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

View file

@ -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