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:
parent
e11ebc9af8
commit
bbf51544e1
13 changed files with 1930 additions and 5 deletions
127
test/microwaveprop/propagation/duct_property_test.exs
Normal file
127
test/microwaveprop/propagation/duct_property_test.exs
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
144
test/microwaveprop/terrain/viewshed_property_test.exs
Normal file
144
test/microwaveprop/terrain/viewshed_property_test.exs
Normal 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
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue