test: coverage round 4 (84.39% → 84.44%) + 6 new property tests

30 unit tests + 6 property tests across two parallel agents.

- ContactLive.Show + HrrrNativeClient + NexradClient: sort_observations
  + sort_soundings actual ordering per field, closest_observations
  capped-at-5 proximity, nil-pos2 half_dist=0 path, HrrrNativeClient
  scrambled-level density + surface finiteness, NexradClient cache
  population + zero-byte + year-boundary URL rounding. Properties:
  sort_observations preservation, haversine symmetry, build_native
  surface_temp_k finiteness.

- PathLive + Viewshed + GefsFetchWorker + SnmpClient: GPS source
  URL preservation, QRZ 404 surfacing, propagation_updated same-midpoint
  no-op; Viewshed effective_reach_km BLOCKED boundaries + find_reach_km
  zero max_range; GefsFetchWorker 502/400/410 + wind_u/wind_v aliases
  + nil profile; SnmpClient fully-qualified OID + double-dot drop +
  empty poll + unknown radio type. Properties: destination_point
  round-trip < 0.5 km, valid_time = run_time + fh*3600 invariant,
  parse_snmpget_output totality.
This commit is contained in:
Graham McIntire 2026-04-24 10:27:14 -05:00
parent 35a064b1a4
commit dc8353a9e9
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
8 changed files with 786 additions and 0 deletions

View file

@ -249,6 +249,44 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do
end
end
describe "OID normalisation across prefix variants" do
test "fully-qualified 1.3.6.1.4.1 OIDs (no `iso.` prefix) parse identically" do
# Some snmpget configurations emit the fully-qualified dotted form
# directly instead of using `iso.` — both should resolve the same
# AF11X field.
output = "1.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -62\n"
result = SnmpClient.parse_snmpget_output(output, :af11x)
assert result.rx_power_0 == -62
end
test "a double-leading-dot OID still strips exactly one leading dot" do
# `normalize_oid` only peels off ONE leading `.` — the residual
# leading dot means the lookup against the OID map fails, so the
# field is silently dropped rather than raising.
output = "..1.3.6.1.4.1.41112.1.3.2.1.14.1 = INTEGER: -63\n"
result = SnmpClient.parse_snmpget_output(output, :af11x)
refute Map.has_key?(result, :rx_power_1)
assert result == %{}
end
end
describe "poll/3 totality" do
test "empty radio type string raises FunctionClauseError" do
assert_raise FunctionClauseError, fn ->
SnmpClient.poll("10.0.0.1", "public", "")
end
end
test "non-string radio type raises FunctionClauseError" do
# poll/3 pattern matches on literal binary radio types; any other
# binary that isn't in the dispatch table should fail loudly so a
# mis-typed config surfaces rather than silently mis-polling.
assert_raise FunctionClauseError, fn ->
SnmpClient.poll("10.0.0.1", "public", "AF11X")
end
end
end
describe "parse_snmpget_output/2 property" do
# Pick any AF11X integer value and any recognised AF11X OID suffix, render
# the canonical `iso.<oid> = INTEGER: <value>` line, and assert the value
@ -263,6 +301,17 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do
"1.3.6.1.4.1.41112.1.3.2.1.4.1" => :link_distance_m
}
property "parse_snmpget_output never raises on arbitrary inputs (totality)" do
# Feed the parser whatever text StreamData hands back — newlines,
# binary data, partial SNMP-looking fragments. The contract is that
# unparseable content is silently skipped and the result is always
# a map, never a raised exception.
check all(garbage <- StreamData.string(:printable, max_length: 500)) do
result = SnmpClient.parse_snmpget_output(garbage, :af11x)
assert is_map(result)
end
end
property "rendered snmpget lines round-trip through parse_snmpget_output" do
oids = Map.keys(@af11x_oid_to_field)

View file

@ -116,6 +116,26 @@ defmodule Microwaveprop.Terrain.ViewshedPropertyTest do
end
end
describe "destination_point/4 round-trip distance" do
property "the generated point is ~dist_km from the origin via the haversine" do
# Regardless of bearing, the great-circle distance between the
# origin and destination_point's result must match the requested
# distance. Allow a loose absolute tolerance for earth-radius
# rounding at longer distances (the module uses 6371 km exactly).
check all(
lat <- float(min: -60.0, max: 60.0),
lon <- float(min: -170.0, max: 170.0),
bearing <- float(min: 0.0, max: 359.999),
dist_km <- float(min: 1.0, max: 500.0)
) do
{lat2, lon2} = Viewshed.destination_point(lat, lon, bearing, dist_km)
measured = Microwaveprop.Geo.haversine_km(lat, lon, lat2, lon2)
assert_in_delta measured, dist_km, 0.5
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)

View file

@ -191,6 +191,51 @@ defmodule Microwaveprop.Terrain.ViewshedTest do
end
end
describe "effective_reach_km/3 boundary cases" do
test "BLOCKED with score=0 and diffraction_db=0 uses the mild-terrain 0.8 tier" do
# db=0 falls into the `db <= 3` terrain band (0.8) and score=0 gives
# a 0.05 ducting floor — terrain wins cleanly.
analysis = %{verdict: "BLOCKED", diffraction_db: 0.0}
assert Viewshed.effective_reach_km(analysis, 50.0, 0) == 40.0
end
test "BLOCKED at exactly the terrain-tier boundary (3 dB) still wins the 0.8 factor" do
analysis = %{verdict: "BLOCKED", diffraction_db: 3.0}
assert Viewshed.effective_reach_km(analysis, 100.0, 0) == 80.0
end
test "CLEAR verdict ignores max_range_km of zero (returns 0)" do
analysis = %{verdict: "CLEAR", diffraction_db: 0.0}
assert Viewshed.effective_reach_km(analysis, 0.0, 100) == 0.0
end
end
describe "find_reach_km/2 boundary cases" do
test "max_range_km of zero is returned verbatim when no obstructions exist" do
points = [
%{obstructed: false, dist_km: 0.0},
%{obstructed: false, dist_km: 0.0},
%{obstructed: false, dist_km: 0.0}
]
assert Viewshed.find_reach_km(points, 0.0) == 0.0
end
test "single-obstruction-in-middle returns the preceding dist_km" do
points =
[
%{obstructed: false, dist_km: 0.0},
%{obstructed: false, dist_km: 5.0},
%{obstructed: false, dist_km: 10.0},
%{obstructed: true, dist_km: 15.0},
%{obstructed: false, dist_km: 20.0},
%{obstructed: false, dist_km: 25.0}
]
assert Viewshed.find_reach_km(points, 25.0) == 10.0
end
end
describe "analyse_ray/5" do
test "returns full range for flat terrain with antenna heights" do
profile =

View file

@ -510,4 +510,89 @@ defmodule Microwaveprop.Weather.HrrrNativeClientTest do
end
end
end
describe "build_native_profile/1 deep coverage" do
test "handles a dense 5-level profile and orders by ascending HGT" do
# Intentionally scramble the insertion order so the sort-by-height
# step has real work to do.
parsed =
[3, 1, 5, 2, 4]
|> Enum.flat_map(fn level ->
base = level * 20.0
lvl_str = "#{level} hybrid level"
[
{"HGT:#{lvl_str}", base},
{"TMP:#{lvl_str}", 300.0 - level * 2.0},
{"SPFH:#{lvl_str}", 0.01},
{"PRES:#{lvl_str}", 101_000.0 - level * 500.0},
{"UGRD:#{lvl_str}", 1.0 * level},
{"VGRD:#{lvl_str}", -1.0 * level},
{"TKE:#{lvl_str}", 0.1}
]
end)
|> Map.new()
profile = HrrrNativeClient.build_native_profile(parsed)
assert profile.level_count == 5
assert profile.heights_m == Enum.sort(profile.heights_m)
assert profile.heights_m == [20.0, 40.0, 60.0, 80.0, 100.0]
end
test "returns a finite float for surface_temp_k whenever any TMP key is present" do
# Surface TMP wins over lowest-level TMP; it's a float.
parsed = %{
"HGT:1 hybrid level" => 10.0,
"TMP:1 hybrid level" => 290.5,
"TMP:surface" => 293.1
}
profile = HrrrNativeClient.build_native_profile(parsed)
assert is_float(profile.surface_temp_k)
assert profile.surface_temp_k == 293.1
end
end
describe "duct_byte_ranges/1 boundary inputs" do
test "empty idx entries yields an empty range list" do
assert HrrrNativeClient.duct_byte_ranges([]) == []
assert HrrrNativeClient.essential_byte_ranges([]) == []
end
end
describe "extra properties" do
property "build_native_profile/1 returns a finite float for surface_temp_k when any TMP key is present" do
# Drives the surface-scalar fallback: with TMP somewhere in the
# parsed map (either at `surface` or on a hybrid level that has
# an HGT to anchor it), `surface_temp_k` is a real number.
check all(
level <- StreamData.integer(1..50),
use_surface <- StreamData.boolean(),
tmp_val <- StreamData.float(min: 200.0, max: 320.0)
) do
lvl_str = "#{level} hybrid level"
parsed =
if use_surface do
%{
"HGT:#{lvl_str}" => level * 10.0,
"TMP:#{lvl_str}" => 290.0,
"TMP:surface" => tmp_val
}
else
%{
"HGT:#{lvl_str}" => level * 10.0,
"TMP:#{lvl_str}" => tmp_val
}
end
profile = HrrrNativeClient.build_native_profile(parsed)
assert is_float(profile.surface_temp_k)
# Finiteness: neither ±infinity nor NaN.
refute profile.surface_temp_k == :infinity
refute profile.surface_temp_k == :"-infinity"
end
end
end
end

View file

@ -392,4 +392,87 @@ defmodule Microwaveprop.Weather.NexradClientTest do
assert result.mean_reflectivity_dbz == 0.0
end
end
describe "fetch_decoded_frame/1 cache population" do
# Minimal 20×20 indexed-color PNG identical to the earlier fixture.
defp tiny_png(width \\ 20, height \\ 20) do
signature = <<137, 80, 78, 71, 13, 10, 26, 10>>
ihdr_data = <<width::32, height::32, 8, 3, 0, 0, 0>>
ihdr_chunk = <<byte_size(ihdr_data)::32, "IHDR", ihdr_data::binary, 0::32>>
raw = for _ <- 1..height, into: <<>>, do: <<0, 0::size(width)-unit(8)>>
compressed = :zlib.compress(raw)
idat_chunk = <<byte_size(compressed)::32, "IDAT", compressed::binary, 0::32>>
iend_chunk = <<0::32, "IEND", 0::32>>
signature <> ihdr_chunk <> idat_chunk <> iend_chunk
end
setup do
NexradCache.clear()
on_exit(fn -> NexradCache.clear() end)
:ok
end
test "successful fetch populates the cache (second fetch is a :hit)" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 200, tiny_png())
end)
ts = ~U[2022-08-20 14:07:00Z]
rounded = NexradClient.round_to_5min(ts)
assert NexradCache.fetch(rounded) == :miss
{:ok, _pixels, _width} = NexradClient.fetch_decoded_frame(ts)
assert {:ok, _pixels2, _width2} = NexradCache.fetch(rounded)
end
end
describe "fetch_decoded_frame/1 empty body" do
setup do
NexradCache.clear()
on_exit(fn -> NexradCache.clear() end)
:ok
end
test "zero-byte 200 response surfaces a PNG decode error" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 200, "")
end)
assert {:error, reason} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:05:00Z])
assert is_binary(reason)
assert reason =~ "PNG decode failed"
end
end
describe "frame_url/1 year boundaries" do
test "Dec 31 23:55 produces a URL anchored in the old year" do
dt = ~U[2022-12-31 23:55:00Z]
url = NexradClient.frame_url(dt)
assert url =~ "2022/12/31"
assert url =~ "n0q_202212312355.png"
refute url =~ "2023/"
end
test "Jan 1 00:00 of the following year flips cleanly into the new year" do
dt = ~U[2023-01-01 00:00:00Z]
url = NexradClient.frame_url(dt)
assert url =~ "2023/01/01"
assert url =~ "n0q_202301010000.png"
refute url =~ "2022/"
end
test "rounding 23:59 Dec 31 stays in Dec 31 (no rollover via round_to_5min)" do
dt = ~U[2022-12-31 23:59:59Z]
rounded = NexradClient.round_to_5min(dt)
assert rounded.year == 2022
assert rounded.month == 12
assert rounded.day == 31
assert rounded.hour == 23
assert rounded.minute == 55
url = NexradClient.frame_url(rounded)
assert url =~ "2022/12/31"
end
end
end

View file

@ -287,6 +287,105 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
end
end
describe "perform/1 HTTP classification extra boundaries" do
test "a transient HTTP 502 (bad gateway) returns {:error, _}" do
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
Plug.Conn.send_resp(conn, 502, "Bad Gateway")
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 400 (bad request) returns {:cancel, _}" do
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
Plug.Conn.send_resp(conn, 400, "Bad Request")
end)
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
end
test "a permanent HTTP 410 (gone) returns {:cancel, _}" do
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
Plug.Conn.send_resp(conn, 410, "Gone")
end)
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
end
end
describe "build_profile_attrs/3 extra branches" do
test "accepts wind_u / wind_v keys without the _mps suffix" do
# `build_profile_attrs` checks both `wind_u` and `wind_u_mps` — the
# non-suffixed keys should also populate the row attrs.
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,
wind_u: 4.5,
wind_v: -2.25,
profile: []
}
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
assert attrs.wind_u_mps == 4.5
assert attrs.wind_v_mps == -2.25
end
test "preserves a nil profile list as a no-derivation base map" 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: nil
}
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
# nil profile → `SoundingParams.derive([])` is called with the `|| []`
# fallback; empty list returns nil so no derived keys are added.
refute Map.has_key?(attrs, :surface_refractivity)
assert attrs.lat == 32.9
end
end
# Property: build_profile_attrs/3 always emits a valid_time exactly
# `forecast_hour * 3600` seconds after the run_time, for any run/fh pair.
property "build_profile_attrs/3 valid_time is run_time + fh hours exactly" do
check all(
epoch <- StreamData.integer(1_700_000_000..1_800_000_000),
fh <- StreamData.integer(0..384)
) do
{:ok, run_time} = DateTime.from_unix(epoch)
run_time = DateTime.truncate(run_time, :second)
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, fh, profile)
assert DateTime.diff(attrs.valid_time, attrs.run_time, :second) == fh * 3600
assert attrs.forecast_hour == fh
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

View file

@ -3139,4 +3139,347 @@ defmodule MicrowavepropWeb.ContactLiveTest do
end
end
end
describe "Show ordering and proximity helpers" do
# Exercises sort_observations/3, sort_soundings/3, and
# closest_observations/4 on *populated* rows so the actual field
# ordering is asserted — the previous sort-handler tests only
# asserted that rendering stayed alive.
use ExUnitProperties
alias Microwaveprop.Geo
alias Microwaveprop.Weather
alias Microwaveprop.Weather.Sounding
alias Microwaveprop.Weather.SurfaceObservation
defp seed_distinct_obs(contact) do
# Four distinct surface observations with strictly-increasing
# temp_f, dewpoint_f, relative_humidity, sea_level_pressure_mb,
# and observed_at, keyed by station code for station_name order.
Enum.map(
[
{"KAAA", 32.90, -97.00, 60.0, 40.0, 50.0, 1010.0, 0},
{"KBBB", 32.91, -97.01, 62.0, 42.0, 52.0, 1011.0, 60},
{"KCCC", 32.92, -97.02, 64.0, 44.0, 54.0, 1012.0, 120},
{"KDDD", 32.93, -97.03, 66.0, 46.0, 56.0, 1013.0, 180}
],
fn {code, lat, lon, temp, dewp, rh, pres, offset} ->
{:ok, station} =
Weather.find_or_create_station(%{
station_code: code,
station_type: "asos",
name: "#{code} Airport",
lat: lat,
lon: lon
})
Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id,
observed_at: DateTime.add(contact.qso_timestamp, offset, :second),
temp_f: temp,
dewpoint_f: dewp,
relative_humidity: rh,
sea_level_pressure_mb: pres
})
)
code
end
)
end
test "sort_observations orders by station_name ascending and descending", %{conn: conn} do
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
codes = seed_distinct_obs(contact)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
# Prime with observed_at so a subsequent station_name click hits
# the "new field → asc" branch of toggle_sort/3.
_ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
# First station_name click → asc.
_ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"})
asc_codes =
lv.pid
|> :sys.get_state()
|> Map.fetch!(:socket)
|> Map.fetch!(:assigns)
|> Map.fetch!(:surface_observations)
|> Enum.map(& &1.station.station_code)
assert asc_codes == Enum.sort(codes)
# Second station_name click → desc.
_ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"})
desc_codes =
lv.pid
|> :sys.get_state()
|> Map.fetch!(:socket)
|> Map.fetch!(:assigns)
|> Map.fetch!(:surface_observations)
|> Enum.map(& &1.station.station_code)
assert desc_codes == Enum.sort(codes, :desc)
end
test "sort_observations orders by observed_at, temp_f, dewpoint_f, relative_humidity, and pressure",
%{conn: conn} do
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
_ = seed_distinct_obs(contact)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
check_field = fn field, reader ->
_ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
obs =
lv.pid
|> :sys.get_state()
|> Map.fetch!(:socket)
|> Map.fetch!(:assigns)
|> Map.fetch!(:surface_observations)
values = Enum.map(obs, reader)
assert values == Enum.sort(values),
"obs not ascending on #{field}: #{inspect(values)}"
end
check_field.("observed_at", & &1.observed_at)
check_field.("temp_f", & &1.temp_f)
check_field.("dewpoint_f", & &1.dewpoint_f)
check_field.("relative_humidity", & &1.relative_humidity)
check_field.("sea_level_pressure_mb", & &1.sea_level_pressure_mb)
end
defp seed_distinct_soundings(contact) do
# Three soundings with distinct surface_temp_c, surface_refractivity,
# k_index, and lifted_index to check every sounding_sort_key branch.
Enum.map(
[
{"KAAAS", 15.0, 310.0, 20.0, -2.0, 0},
{"KBBBS", 18.0, 315.0, 25.0, -1.0, 3600},
{"KCCCS", 21.0, 320.0, 30.0, 0.0, 7200}
],
fn {code, temp, refr, k, li, offset} ->
{:ok, station} =
Weather.find_or_create_station(%{
station_code: code,
station_type: "sounding",
name: "#{code} Sounding",
lat: 32.9 + :rand.uniform() * 0.001,
lon: -97.0 + :rand.uniform() * 0.001
})
Repo.insert!(
Sounding.changeset(%Sounding{}, %{
station_id: station.id,
observed_at: DateTime.add(contact.qso_timestamp, offset, :second),
profile: [%{"pres" => 1000.0, "tmpc" => temp, "dwpc" => temp - 5.0, "hght" => 100.0}],
level_count: 1,
surface_temp_c: temp,
surface_dewpoint_c: temp - 5.0,
surface_refractivity: refr,
k_index: k,
lifted_index: li
})
)
code
end
)
end
test "sort_soundings orders by every known sounding field", %{conn: conn} do
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
_ = seed_distinct_soundings(contact)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
# Each click on a new field toggles to that field asc (new-field
# branch of toggle_sort). Prime with observed_at so the later
# station_name click also hits the new-field branch.
_ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"})
_ = render_click(lv, "sort", %{"field" => "station_name", "table" => "soundings"})
station_names =
lv.pid
|> :sys.get_state()
|> Map.fetch!(:socket)
|> Map.fetch!(:assigns)
|> Map.fetch!(:soundings)
|> Enum.map(&(&1.station.name || &1.station.station_code))
assert station_names == Enum.sort(station_names)
check_field = fn field, reader ->
_ = render_click(lv, "sort", %{"field" => field, "table" => "soundings"})
rows =
lv.pid
|> :sys.get_state()
|> Map.fetch!(:socket)
|> Map.fetch!(:assigns)
|> Map.fetch!(:soundings)
values = Enum.map(rows, reader)
assert values == Enum.sort(values),
"soundings not ascending on #{field}: #{inspect(values)}"
end
check_field.("observed_at", & &1.observed_at)
check_field.("surface_temp_c", & &1.surface_temp_c)
check_field.("surface_refractivity", & &1.surface_refractivity)
check_field.("k_index", & &1.k_index)
check_field.("lifted_index", & &1.lifted_index)
end
test "closest_observations/4 keeps only the five nearest by midpoint distance", %{conn: conn} do
# Seed seven surface observations spread along the path corridor;
# closest_observations trims to the top five by |dlat|+|dlon| from
# the midpoint. The test asserts (a) the assign length ≤ 5 and
# (b) no station sits at a larger distance than the farthest-kept.
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
mid_lat = (32.9 + 30.3) / 2
mid_lon = (-97.0 + -97.7) / 2
near_to_far = [
{"KMID0", mid_lat, mid_lon},
{"KMID1", mid_lat + 0.05, mid_lon + 0.05},
{"KMID2", mid_lat - 0.05, mid_lon - 0.05},
{"KMID3", mid_lat + 0.10, mid_lon + 0.10},
{"KMID4", mid_lat - 0.10, mid_lon - 0.10},
{"KMID5", mid_lat + 0.20, mid_lon + 0.20},
{"KMID6", mid_lat - 0.20, mid_lon - 0.20}
]
_ =
Enum.map(near_to_far, fn {code, lat, lon} ->
{:ok, station} =
Weather.find_or_create_station(%{
station_code: code,
station_type: "asos",
name: "#{code}",
lat: lat,
lon: lon
})
Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id,
observed_at: contact.qso_timestamp,
temp_f: 70.0,
dewpoint_f: 55.0,
relative_humidity: 55.0,
sea_level_pressure_mb: 1015.0
})
)
end)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
obs =
lv.pid
|> :sys.get_state()
|> Map.fetch!(:socket)
|> Map.fetch!(:assigns)
|> Map.fetch!(:surface_observations)
# closest_observations caps the list at 5.
assert length(obs) <= 5
# Every kept station must be at least as close as the farthest one.
distances =
Enum.map(obs, fn o ->
abs(o.station.lat - mid_lat) + abs(o.station.lon - mid_lon)
end)
assert distances == Enum.sort(distances)
end
test "contact with pos1 but nil pos2 renders without blowing up (half_dist = 0 path)",
%{conn: conn} do
# Forcing pos2 to nil via a bare Ecto.Changeset.change bypasses the
# Contact.changeset validation that ordinarily fills it in. This
# exercises the `if lat2 && lon2` guards in fetch_weather_for_path.
contact = create_contact()
{:ok, contact} =
contact
|> Ecto.Changeset.change(pos2: nil, grid2: nil, distance_km: nil)
|> Repo.update()
assert is_nil(contact.pos2)
{:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
# The detail page renders with station1 present — the render
# pipeline walked through fetch_weather_for_path with lat2=nil,
# mid_lat=lat1, half_dist=0.
assert html =~ "W5XD"
assert Process.alive?(lv.pid)
end
property "sort_observations/3 preserves list length + element set for any field key",
%{conn: conn} do
# Round-tripping the `sort` event across any mix of known and
# unknown field keys must leave the surface_observations assign
# with the same cardinality AND the same set of station codes.
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
codes = seed_distinct_obs(contact)
fields_gen =
StreamData.one_of([
StreamData.member_of(~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)),
StreamData.string(:alphanumeric, min_length: 1, max_length: 6)
])
check all(fields <- StreamData.list_of(fields_gen, min_length: 1, max_length: 5), max_runs: 10) do
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
for f <- fields do
_ = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
obs =
lv.pid
|> :sys.get_state()
|> Map.fetch!(:socket)
|> Map.fetch!(:assigns)
|> Map.fetch!(:surface_observations)
assert length(obs) == length(codes)
assert Enum.sort(Enum.map(obs, & &1.station.station_code)) == Enum.sort(codes)
end
end
end
property "haversine_km/4 is symmetric: d(a,b,c,d) == d(c,d,a,b)" do
# Uses Microwaveprop.Geo.haversine_km/4 — the public, structurally
# identical twin of the private haversine_km in ContactLive.Show.
# Symmetry under endpoint swap is the load-bearing invariant the
# show-page midpoint + half-distance logic relies on.
check all(
lat1 <- StreamData.float(min: -89.0, max: 89.0),
lon1 <- StreamData.float(min: -179.0, max: 179.0),
lat2 <- StreamData.float(min: -89.0, max: 89.0),
lon2 <- StreamData.float(min: -179.0, max: 179.0)
) do
ab = Geo.haversine_km(lat1, lon1, lat2, lon2)
ba = Geo.haversine_km(lat2, lon2, lat1, lon1)
assert_in_delta ab, ba, 1.0e-6
end
end
end
end

View file

@ -355,6 +355,68 @@ defmodule MicrowavepropWeb.PathLiveTest do
end
end
describe "handle_event calculate with gps source" do
test "calculate from an already-fixed GPS source keeps source=gps in the URL", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=10000")
assert_push_event(lv, "request_gps", %{})
# Simulate the JS hook pushing a fix.
render_hook(lv, "gps_location", %{"lat" => 32.9, "lon" => -97.0})
# Now submit the form — source_is_gps should stay true so url_params
# retains `"source" => "gps"` instead of the literal coords.
render_submit(
form(lv, "form",
source: "32.9,-97.0",
destination: "EM12",
band: "10000"
)
)
assert_patch(lv)
html = render(lv)
# The input still shows the coords we filled in for the user.
assert html =~ ~s(value="32.9,-97.0")
end
end
describe "invalid source input surfacing" do
test "unknown callsign destination surfaces a 'Could not find' error", %{conn: conn} do
# Stub the QRZ lookup so the fallback branch returns a deterministic
# error instead of raising about a missing Req.Test stub.
Req.Test.stub(Microwaveprop.Qrz.Client, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
# "NOSUCHCALL" isn't a coord pair and isn't a 4+ char maidenhead
# prefix, so resolve_location falls to CallsignClient.locate.
{:ok, lv, _html} = live(conn, ~p"/path?source=33.0,-97.0&destination=NOSUCHCALL&band=10000")
html = render(lv)
# Match only the prefix — the reason-text portion varies with how
# CallsignClient errors (network stub missing vs explicit not-found).
assert html =~ "Could not find"
end
end
describe "propagation_updated recompute when destination is not a result" do
test "no-op when the result is set but source and destination collapse to same point", %{conn: conn} do
# If a compute produces a result at identical src/dst, the
# propagation_updated handler still runs point_forecast at that
# midpoint. Verify the branch doesn't crash.
{:ok, lv, _html} =
live(conn, ~p"/path?source=33.0,-97.0&destination=33.0,-97.0&band=10000")
# A result was built (Link Summary shows), so the propagation_updated
# message should take the {valid_time, source, destination} path.
assert render(lv) =~ "Link Summary"
send(lv.pid, {:propagation_updated, [DateTime.utc_now()]})
# Still alive, still rendering.
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 —