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:
parent
35a064b1a4
commit
dc8353a9e9
8 changed files with 786 additions and 0 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue