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
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 describe "analyze/1" do
test "full pipeline from native profile to duct list with frequencies" do test "full pipeline from native profile to duct list with frequencies" do
# Profile with a surface-based duct (strong inversion) # 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
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 describe "analyse_ray/5" do
test "returns full range for flat terrain with antenna heights" do test "returns full range for flat terrain with antenna heights" do
profile = profile =

View file

@ -180,6 +180,19 @@ defmodule Microwaveprop.Weather.SoundingParamsPropertyTest do
end end
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 property "mixing_ratio is positive for temperatures well below pressure" do
# The formula hits a pole when saturation vapour pressure approaches # The formula hits a pole when saturation vapour pressure approaches
# the total pressure, which only happens at boiling-hot temperatures # the total pressure, which only happens at boiling-hot temperatures

View file

@ -11,6 +11,7 @@ defmodule Microwaveprop.WeatherExtraTest do
data shapes. data shapes.
""" """
use Microwaveprop.DataCase, async: true use Microwaveprop.DataCase, async: true
use ExUnitProperties
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Weather alias Microwaveprop.Weather
@ -211,4 +212,160 @@ defmodule Microwaveprop.WeatherExtraTest do
assert MapSet.member?(set, date) assert MapSet.member?(set, date)
end end
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 end

View file

@ -1,5 +1,6 @@
defmodule Microwaveprop.Workers.GefsFetchWorkerTest do defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
use Microwaveprop.DataCase, async: false use Microwaveprop.DataCase, async: false
use ExUnitProperties
alias Microwaveprop.Weather alias Microwaveprop.Weather
alias Microwaveprop.Weather.GefsProfile alias Microwaveprop.Weather.GefsProfile
@ -229,4 +230,74 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
assert length(persisted) == 2 assert length(persisted) == 2
end end
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 end

View file

@ -117,4 +117,36 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorkerTest do
assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args}) assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args})
end end
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 end

View file

@ -75,4 +75,37 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorkerTest do
end end
end 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 end

View file

@ -1,11 +1,13 @@
defmodule MicrowavepropWeb.BeaconLiveTest do defmodule MicrowavepropWeb.BeaconLiveTest do
use MicrowavepropWeb.ConnCase use MicrowavepropWeb.ConnCase
use ExUnitProperties
import Microwaveprop.AccountsFixtures import Microwaveprop.AccountsFixtures
import Microwaveprop.BeaconsFixtures import Microwaveprop.BeaconsFixtures
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Microwaveprop.Accounts alias Microwaveprop.Accounts
alias Microwaveprop.Beacons
defp promote_to_admin(user) do defp promote_to_admin(user) do
{:ok, user} = Accounts.admin_update_user(user, %{is_admin: true}) {: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 defp approved_beacon_fixture(user, attrs \\ %{}) do
beacon = beacon_fixture(user, attrs) beacon = beacon_fixture(user, attrs)
{:ok, beacon} = Microwaveprop.Beacons.approve_beacon(beacon) {:ok, beacon} = Beacons.approve_beacon(beacon)
beacon beacon
end end
@ -75,7 +77,7 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
|> render_click() |> render_click()
assert html =~ "Approved #{pending.callsign}" assert html =~ "Approved #{pending.callsign}"
assert Microwaveprop.Beacons.get_beacon!(pending.id).approved == true assert Beacons.get_beacon!(pending.id).approved == true
end end
end end
@ -140,7 +142,7 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
assert html =~ "awaiting admin approval" assert html =~ "awaiting admin approval"
beacon = 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
assert beacon.user_id == nil assert beacon.user_id == nil
@ -160,7 +162,7 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
|> render_submit() |> render_submit()
|> follow_redirect(conn, ~p"/beacons") |> 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.callsign == attrs.callsign
assert beacon.approved == false assert beacon.approved == false
end end
@ -229,4 +231,116 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
refute render(view) =~ "cells rendered" refute render(view) =~ "cells rendered"
end end
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 end

View file

@ -5,11 +5,12 @@ defmodule MicrowavepropWeb.ContactLiveTest do
alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather.HrrrNativeProfile alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.NarrProfile alias Microwaveprop.Weather.NarrProfile
setup do setup do
Req.Test.stub(Microwaveprop.Terrain.ElevationClient, fn conn -> Req.Test.stub(ElevationClient, fn conn ->
Req.Test.json(conn, []) Req.Test.json(conn, [])
end) end)
@ -1480,4 +1481,827 @@ defmodule MicrowavepropWeb.ContactLiveTest do
end end
end 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 end

View file

@ -1,10 +1,12 @@
defmodule MicrowavepropWeb.PathLiveTest do defmodule MicrowavepropWeb.PathLiveTest do
use MicrowavepropWeb.ConnCase, async: false use MicrowavepropWeb.ConnCase, async: false
use ExUnitProperties
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Microwaveprop.Ionosphere alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.ElevationClient
@ -246,4 +248,88 @@ defmodule MicrowavepropWeb.PathLiveTest do
refute html =~ "Ionosphere" refute html =~ "Ionosphere"
end end
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 end

View file

@ -630,4 +630,227 @@ defmodule Mix.Tasks.SimpleTasksTest do
0 -> Enum.reverse(acc) 0 -> Enum.reverse(acc)
end end
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 end