test: coverage round 1 (80.45% → 82.05%) + 12 new property tests
Adds 67 test cases + 12 property tests across four surfaces — parallel agents targeted the lowest-coverage files in the tree. - ContactLive.Show (48.56% → 72.01%): 16 unit tests + 2 properties covering IEMRE/radar/ducting/propagation-analysis render branches, every terrain-verdict class, expanded HRRR/terrain sections, pending- edit banner, internal-network conn, and total-over-fields properties for obs + sounding sort handlers. - HrrrNativeClient (33% → 58%): 8 unit tests + 4 properties covering partial-surface fallback, empty input, optional-var nils, bogus binary dispatch, and level-count + monotonic-height + URL-encoding + duct-subset invariants. - NexradClient / HrrrClient / GefsClient: 13 unit tests + 4 properties covering 500/404/timeout paths, malformed idx bodies, parse_idx totality, byte-range invariants, and URL-builder round-trips. - HrrrBackfill / HrrrNativeDeriveFields / ImportContestLogs: 7 unit tests + 1 property. Seeds contacts + native profiles, stubs HRRR with Req.Test, exercises the `--limit 0` and `filter_points_needing_ backfill` paths, CSV import happy + malformed paths, and a band-string round-trip property. 2599 → 2666 tests, 170 → 182 properties, 0 failures.
This commit is contained in:
parent
825d2baa10
commit
e11ebc9af8
6 changed files with 1339 additions and 0 deletions
|
|
@ -247,6 +247,77 @@ defmodule Microwaveprop.Weather.GefsClientTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "fetch_grid_profiles/3 error paths" do
|
||||
test "idx HTTP 500 returns {:error, _} with status in the message" do
|
||||
Req.Test.stub(GefsClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 500, "boom")
|
||||
end)
|
||||
|
||||
assert {:error, msg} = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 24)
|
||||
assert is_binary(msg)
|
||||
assert msg =~ "HTTP 500"
|
||||
end
|
||||
|
||||
test "idx HTTP 404 returns {:error, _}" do
|
||||
Req.Test.stub(GefsClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 404, "not found")
|
||||
end)
|
||||
|
||||
assert {:error, msg} = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 24)
|
||||
assert msg =~ "HTTP 404"
|
||||
end
|
||||
|
||||
test "idx transport timeout returns {:error, _}" do
|
||||
Req.Test.stub(GefsClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, reason} = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 24)
|
||||
assert reason
|
||||
end
|
||||
|
||||
test "malformed idx body is parsed as empty and surfaces a wgrib2/extraction error rather than raising" do
|
||||
# Non-idx-looking body causes HrrrClient.parse_idx to return [],
|
||||
# then byte_ranges_for_messages -> [], then download_grib_ranges
|
||||
# returns {:ok, <<>>}, and wgrib2 extraction fails on empty input.
|
||||
# The important invariant is that the pipeline surfaces an error
|
||||
# tuple — it must not raise.
|
||||
Req.Test.stub(GefsClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 200, "<html>not idx</html>")
|
||||
end)
|
||||
|
||||
result = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 24)
|
||||
assert match?({:error, _}, result) or match?({:ok, _}, result)
|
||||
end
|
||||
end
|
||||
|
||||
describe "ensmean_url/3 round-trip property" do
|
||||
property "date, run hour and forecast hour all appear in the built URL" do
|
||||
check all(
|
||||
year <- integer(2022..2030),
|
||||
month <- integer(1..12),
|
||||
day <- integer(1..28),
|
||||
run_hour <- member_of([0, 6, 12, 18]),
|
||||
forecast_hour <- integer(0..384)
|
||||
) do
|
||||
{:ok, date} = Date.new(year, month, day)
|
||||
url = GefsClient.ensmean_url(date, run_hour, forecast_hour)
|
||||
|
||||
date_str =
|
||||
[year, month, day]
|
||||
|> Enum.zip([4, 2, 2])
|
||||
|> Enum.map_join(fn {n, w} -> n |> Integer.to_string() |> String.pad_leading(w, "0") end)
|
||||
|
||||
hh = run_hour |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
fff = forecast_hour |> Integer.to_string() |> String.pad_leading(3, "0")
|
||||
|
||||
assert String.contains?(url, "gefs.#{date_str}")
|
||||
assert String.contains?(url, "/#{hh}/atmos/pgrb2ap5/")
|
||||
assert String.contains?(url, "geavg.t#{hh}z.pgrb2a.0p50.f#{fff}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "nearest_run/1 property" do
|
||||
property "is idempotent and output hour is in {0, 6, 12, 18}" do
|
||||
check all(
|
||||
|
|
|
|||
|
|
@ -490,6 +490,142 @@ defmodule Microwaveprop.Weather.HrrrClientTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "parse_idx/1 property" do
|
||||
use ExUnitProperties
|
||||
|
||||
property "never raises on arbitrary ascii-ish input and always returns a list of maps" do
|
||||
check all(
|
||||
lines <-
|
||||
list_of(
|
||||
string(:printable, max_length: 80),
|
||||
max_length: 12
|
||||
)
|
||||
) do
|
||||
text = Enum.join(lines, "\n")
|
||||
result = HrrrClient.parse_idx(text)
|
||||
|
||||
assert is_list(result)
|
||||
|
||||
for entry <- result do
|
||||
assert is_map(entry)
|
||||
assert is_integer(entry.msg)
|
||||
assert is_integer(entry.offset)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
property "only parses lines whose first two fields are integers" do
|
||||
check all(
|
||||
msg <- integer(1..200),
|
||||
offset <- integer(0..1_000_000_000),
|
||||
var <- string(:alphanumeric, min_length: 1, max_length: 6),
|
||||
level <- string(:alphanumeric, min_length: 1, max_length: 12)
|
||||
) do
|
||||
line = "#{msg}:#{offset}:d=2026032818:#{var}:#{level}:anl:"
|
||||
[entry] = HrrrClient.parse_idx(line)
|
||||
|
||||
assert entry.msg == msg
|
||||
assert entry.offset == offset
|
||||
assert entry.var == var
|
||||
assert entry.level == level
|
||||
end
|
||||
end
|
||||
|
||||
test "returns [] for input that is only whitespace / comment garbage" do
|
||||
assert HrrrClient.parse_idx(" \n\n\n") == []
|
||||
assert HrrrClient.parse_idx("# this is a comment\nno-colons-here") == []
|
||||
end
|
||||
|
||||
test "returns [] for a line whose offset field is non-numeric" do
|
||||
assert HrrrClient.parse_idx("1:notanumber:d=x:TMP:surface:anl:") == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_profile/3 error paths" do
|
||||
# Use a distinct date/hour from the success-path tests above so that
|
||||
# `Microwaveprop.Cache` entries written by these error cases don't
|
||||
# poison the shared ETS cache and flake the async success tests.
|
||||
@error_date ~D[2024-01-15]
|
||||
@error_dt ~U[2024-01-15 06:00:00Z]
|
||||
|
||||
setup do
|
||||
# Invalidate before and after so reruns of a single test and
|
||||
# neighbouring async suites both start from a clean cache.
|
||||
invalidate = fn ->
|
||||
for product <- [:surface, :pressure] do
|
||||
url = HrrrClient.hrrr_url(@error_date, 6, product)
|
||||
Microwaveprop.Cache.invalidate({:hrrr_idx, url <> ".idx"})
|
||||
end
|
||||
end
|
||||
|
||||
invalidate.()
|
||||
on_exit(invalidate)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "idx HTTP 500 on the surface product returns {:error, _}" do
|
||||
Req.Test.stub(HrrrClient, fn conn ->
|
||||
if String.ends_with?(conn.request_path, ".idx") do
|
||||
Plug.Conn.send_resp(conn, 500, "boom")
|
||||
else
|
||||
Plug.Conn.send_resp(conn, 200, "")
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:error, reason} = HrrrClient.fetch_profile(32.90, -97.04, @error_dt)
|
||||
assert is_binary(reason)
|
||||
assert reason =~ "HTTP 500"
|
||||
end
|
||||
|
||||
test "idx transport timeout returns {:error, _}" do
|
||||
Req.Test.stub(HrrrClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, reason} = HrrrClient.fetch_profile(32.90, -97.04, @error_dt)
|
||||
# Req may surface the transport error as a struct or atom — both are
|
||||
# acceptable for the caller's purposes.
|
||||
assert reason
|
||||
end
|
||||
end
|
||||
|
||||
describe "byte_ranges_for_messages/2 property" do
|
||||
use ExUnitProperties
|
||||
|
||||
property "every produced range has start <= stop and stop > 0 for a sorted offset list" do
|
||||
check all(
|
||||
# Generate a strictly increasing offset sequence for a small
|
||||
# idx, then ask for a random subset of it back.
|
||||
count <- integer(1..8),
|
||||
gaps <- list_of(integer(1..10_000), length: count),
|
||||
wanted_idxs <- list_of(integer(0..(count - 1)), max_length: count)
|
||||
) do
|
||||
entries =
|
||||
gaps
|
||||
|> Enum.scan(&(&1 + &2))
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.map(fn {offset, msg} ->
|
||||
%{msg: msg, offset: offset, var: "V#{msg}", level: "L#{msg}"}
|
||||
end)
|
||||
|
||||
wanted =
|
||||
wanted_idxs
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn i ->
|
||||
e = Enum.at(entries, i)
|
||||
%{var: e.var, level: e.level}
|
||||
end)
|
||||
|
||||
ranges = HrrrClient.byte_ranges_for_messages(entries, wanted)
|
||||
|
||||
for {start, stop} <- ranges do
|
||||
assert start <= stop
|
||||
assert stop > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "merge_grid_data/2" do
|
||||
test "merges surface and pressure grids by point key" do
|
||||
point_a = {32.90, -97.04}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Microwaveprop.Weather.HrrrNativeClientTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Weather.HrrrClient
|
||||
alias Microwaveprop.Weather.HrrrNativeClient
|
||||
|
|
@ -252,4 +253,261 @@ defmodule Microwaveprop.Weather.HrrrNativeClientTest do
|
|||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_native_profile/1 with partial surface coverage" do
|
||||
test "uses parsed TMP:surface but falls back to lowest level for SPFH/PRES" do
|
||||
parsed = %{
|
||||
"HGT:1 hybrid level" => 10.0,
|
||||
"TMP:1 hybrid level" => 294.0,
|
||||
"SPFH:1 hybrid level" => 0.009,
|
||||
"PRES:1 hybrid level" => 101_200.0,
|
||||
"TMP:surface" => 296.2
|
||||
# No "SPFH:2 m above ground", no "PRES:surface" — both fall back.
|
||||
}
|
||||
|
||||
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||
|
||||
assert profile.surface_temp_k == 296.2
|
||||
assert profile.surface_spfh == 0.009
|
||||
assert profile.surface_pressure_pa == 101_200.0
|
||||
end
|
||||
|
||||
test "returns nil surface scalars when there are no levels and no surface entries" do
|
||||
profile = HrrrNativeClient.build_native_profile(%{})
|
||||
|
||||
assert profile.level_count == 0
|
||||
assert profile.heights_m == []
|
||||
assert profile.surface_temp_k == nil
|
||||
assert profile.surface_spfh == nil
|
||||
assert profile.surface_pressure_pa == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "extract_native_profiles/2 dispatch" do
|
||||
test "returns {:ok, map} or {:error, _} for a bogus binary with multiple points" do
|
||||
points = [{32.9, -97.0}, {33.5, -96.5}]
|
||||
|
||||
case HrrrNativeClient.extract_native_profiles(<<1, 2, 3, 4>>, points) do
|
||||
{:ok, %{} = by_point} ->
|
||||
# Either empty or keyed by points. Profiles either have level_count
|
||||
# present or are nil-level; either is a valid contract.
|
||||
Enum.each(by_point, fn {pt, profile} ->
|
||||
assert pt in points
|
||||
assert is_map(profile)
|
||||
end)
|
||||
|
||||
{:error, _reason} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "extract_native_profiles_from_file/2" do
|
||||
test "returns {:error, _} when the GRIB2 path does not exist" do
|
||||
missing = Path.join(System.tmp_dir!(), "hrrr_native_client_missing_#{System.unique_integer([:positive])}.grib2")
|
||||
refute File.exists?(missing)
|
||||
|
||||
assert {:error, _reason} = HrrrNativeClient.extract_native_profiles_from_file(missing, [{32.9, -97.0}])
|
||||
end
|
||||
|
||||
test "returns an ok-map-with-empty-profiles or error tuple for a non-GRIB file" do
|
||||
# wgrib2 may silently succeed with no matching messages, producing
|
||||
# {:ok, %{point => %{level_count: 0}}}; the pure-Elixir decoder
|
||||
# surfaces the parse failure as {:error, _}. Accept both shapes.
|
||||
bogus_path = Path.join(System.tmp_dir!(), "hrrr_native_client_bogus_#{System.unique_integer([:positive])}.grib2")
|
||||
File.write!(bogus_path, <<0, 1, 2, 3>>)
|
||||
point = {32.9, -97.0}
|
||||
|
||||
try do
|
||||
case HrrrNativeClient.extract_native_profiles_from_file(bogus_path, [point]) do
|
||||
{:ok, %{} = by_point} ->
|
||||
assert Map.has_key?(by_point, point)
|
||||
profile = Map.fetch!(by_point, point)
|
||||
assert is_map(profile)
|
||||
assert Map.get(profile, :level_count, 0) == 0
|
||||
|
||||
{:error, _reason} ->
|
||||
:ok
|
||||
end
|
||||
after
|
||||
File.rm(bogus_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_native_profile/1 additional cases" do
|
||||
test "preserves nil per-level values for optional variables (UGRD/VGRD/TKE)" do
|
||||
# Only HGT + TMP present on level 1 — all other per-level arrays
|
||||
# should contain nil in that slot, keeping array length = level_count.
|
||||
parsed = %{
|
||||
"HGT:1 hybrid level" => 12.0,
|
||||
"TMP:1 hybrid level" => 290.0
|
||||
}
|
||||
|
||||
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||
|
||||
assert profile.level_count == 1
|
||||
assert profile.heights_m == [12.0]
|
||||
assert profile.temp_k == [290.0]
|
||||
assert profile.spfh == [nil]
|
||||
assert profile.pressure_pa == [nil]
|
||||
assert profile.u_wind_ms == [nil]
|
||||
assert profile.v_wind_ms == [nil]
|
||||
assert profile.tke_m2s2 == [nil]
|
||||
end
|
||||
|
||||
test "tolerates extra noise keys in the parsed map" do
|
||||
parsed = %{
|
||||
"HGT:1 hybrid level" => 10.0,
|
||||
"TMP:1 hybrid level" => 291.0,
|
||||
# Noise that build_native_profile should simply ignore.
|
||||
"REFC:entire atmosphere" => 30.0,
|
||||
"TMP:500 mb" => 260.0,
|
||||
"garbage" => :whatever
|
||||
}
|
||||
|
||||
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||
assert profile.level_count == 1
|
||||
assert profile.heights_m == [10.0]
|
||||
assert profile.temp_k == [291.0]
|
||||
end
|
||||
end
|
||||
|
||||
describe "properties" do
|
||||
# Generators -----------------------------------------------------------
|
||||
|
||||
# Random subset of hybrid levels (1..50) to exercise build_native_profile
|
||||
# against sparse parsed maps.
|
||||
defp level_subset_generator do
|
||||
1..50
|
||||
|> StreamData.integer()
|
||||
|> StreamData.list_of(min_length: 0, max_length: 10)
|
||||
|> StreamData.map(&Enum.uniq/1)
|
||||
end
|
||||
|
||||
# Build a parsed map for a set of levels with a random subset of vars.
|
||||
defp parsed_map_generator do
|
||||
gen all(
|
||||
levels <- level_subset_generator(),
|
||||
include_hgt <- StreamData.boolean(),
|
||||
include_tmp <- StreamData.boolean(),
|
||||
include_spfh <- StreamData.boolean(),
|
||||
include_pres <- StreamData.boolean(),
|
||||
include_ugrd <- StreamData.boolean(),
|
||||
include_vgrd <- StreamData.boolean(),
|
||||
include_tke <- StreamData.boolean()
|
||||
) do
|
||||
Enum.reduce(levels, %{}, fn level, acc ->
|
||||
lvl_str = "#{level} hybrid level"
|
||||
|
||||
# Use the level number as a monotonic-ish HGT seed so order-by-hgt
|
||||
# aligns with level order, making the monotonic property meaningful.
|
||||
acc
|
||||
|> maybe_put(include_hgt, "HGT:#{lvl_str}", level * 10.0)
|
||||
|> maybe_put(include_tmp, "TMP:#{lvl_str}", 290.0 - level * 0.5)
|
||||
|> maybe_put(include_spfh, "SPFH:#{lvl_str}", 0.01 / level)
|
||||
|> maybe_put(include_pres, "PRES:#{lvl_str}", 101_325.0 - level * 100.0)
|
||||
|> maybe_put(include_ugrd, "UGRD:#{lvl_str}", 1.0 * level)
|
||||
|> maybe_put(include_vgrd, "VGRD:#{lvl_str}", -1.0 * level)
|
||||
|> maybe_put(include_tke, "TKE:#{lvl_str}", 0.1 * level)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put(map, true, key, value), do: Map.put(map, key, value)
|
||||
defp maybe_put(map, false, _key, _value), do: map
|
||||
|
||||
# Generator for arbitrary idx entries. Mixes duct-vars with non-duct
|
||||
# vars at a variety of hybrid levels so duct_byte_ranges ⊂ essential
|
||||
# is actually exercised.
|
||||
defp idx_entries_generator do
|
||||
all_vars = ~w(TMP SPFH HGT PRES UGRD VGRD TKE REFC)
|
||||
|
||||
gen all(
|
||||
entries <-
|
||||
StreamData.list_of(
|
||||
StreamData.tuple({
|
||||
StreamData.member_of(all_vars),
|
||||
StreamData.integer(1..50)
|
||||
}),
|
||||
min_length: 1,
|
||||
max_length: 30
|
||||
)
|
||||
) do
|
||||
entries
|
||||
|> Enum.uniq()
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {{var, level}, i} ->
|
||||
%{msg: i + 1, offset: i * 1000, var: var, level: "#{level} hybrid level"}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Properties -----------------------------------------------------------
|
||||
|
||||
property "build_native_profile/1 array lengths all equal level_count" do
|
||||
check all(parsed <- parsed_map_generator()) do
|
||||
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||
|
||||
lc = profile.level_count
|
||||
assert length(profile.heights_m) == lc
|
||||
assert length(profile.temp_k) == lc
|
||||
assert length(profile.spfh) == lc
|
||||
assert length(profile.pressure_pa) == lc
|
||||
assert length(profile.u_wind_ms) == lc
|
||||
assert length(profile.v_wind_ms) == lc
|
||||
assert length(profile.tke_m2s2) == lc
|
||||
end
|
||||
end
|
||||
|
||||
property "build_native_profile/1 heights_m is monotonic non-decreasing" do
|
||||
check all(parsed <- parsed_map_generator()) do
|
||||
profile = HrrrNativeClient.build_native_profile(parsed)
|
||||
|
||||
profile.heights_m
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.each(fn [a, b] -> assert a <= b end)
|
||||
end
|
||||
end
|
||||
|
||||
property "hrrr_native_url/3 round-trips date, hour, and forecast hour into the URL" do
|
||||
check all(
|
||||
year <- StreamData.integer(2015..2100),
|
||||
month <- StreamData.integer(1..12),
|
||||
day <- StreamData.integer(1..28),
|
||||
hour <- StreamData.integer(0..23),
|
||||
fh <- StreamData.integer(0..48)
|
||||
) do
|
||||
{:ok, date} = Date.new(year, month, day)
|
||||
url = HrrrNativeClient.hrrr_native_url(date, hour, fh)
|
||||
|
||||
date_str = Calendar.strftime(date, "%Y%m%d")
|
||||
hour_str = hour |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
fh_str = fh |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
|
||||
assert String.starts_with?(url, "https://")
|
||||
assert url =~ "hrrr.#{date_str}"
|
||||
assert url =~ "t#{hour_str}z"
|
||||
assert url =~ "wrfnatf#{fh_str}"
|
||||
assert String.ends_with?(url, ".grib2")
|
||||
end
|
||||
end
|
||||
|
||||
property "duct_byte_ranges/1 returns a subset-count of essential_byte_ranges/1" do
|
||||
check all(idx_entries <- idx_entries_generator()) do
|
||||
duct = HrrrNativeClient.duct_byte_ranges(idx_entries)
|
||||
essential = HrrrNativeClient.essential_byte_ranges(idx_entries)
|
||||
|
||||
# Duct vars are a proper subset of native vars, same levels.
|
||||
# Therefore: every matching (var, level) pair counted for duct
|
||||
# also counts for essential.
|
||||
assert length(duct) <= length(essential)
|
||||
|
||||
# And every duct range appears somewhere in essential.
|
||||
Enum.each(duct, fn range ->
|
||||
assert range in essential
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -255,6 +255,115 @@ defmodule Microwaveprop.Weather.NexradClientTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "fetch_frame/2 error paths" do
|
||||
setup do
|
||||
NexradCache.clear()
|
||||
on_exit(fn -> NexradCache.clear() end)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "HTTP 500 returns {:error, _} with status message" do
|
||||
Req.Test.stub(NexradClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 500, "boom")
|
||||
end)
|
||||
|
||||
assert {:error, msg} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}])
|
||||
assert msg =~ "500"
|
||||
end
|
||||
|
||||
test "HTTP 404 returns {:error, _}" do
|
||||
Req.Test.stub(NexradClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 404, "not found")
|
||||
end)
|
||||
|
||||
assert {:error, msg} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}])
|
||||
assert msg =~ "404"
|
||||
end
|
||||
|
||||
test "transport timeout bubbles up as {:error, _}" do
|
||||
Req.Test.stub(NexradClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, reason} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}])
|
||||
assert reason
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_decoded_frame/1 error paths" do
|
||||
setup do
|
||||
NexradCache.clear()
|
||||
on_exit(fn -> NexradCache.clear() end)
|
||||
:ok
|
||||
end
|
||||
|
||||
test "HTTP 503 returns {:error, _} and does not populate cache" do
|
||||
Req.Test.stub(NexradClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 503, "unavailable")
|
||||
end)
|
||||
|
||||
ts = ~U[2022-08-20 14:05:00Z]
|
||||
assert {:error, msg} = NexradClient.fetch_decoded_frame(ts)
|
||||
assert msg =~ "503"
|
||||
|
||||
# Cache must not carry forward a 503 — next call must hit the network
|
||||
# again (the stub is still in place, so we still get the same error).
|
||||
assert {:error, _} = NexradClient.fetch_decoded_frame(ts)
|
||||
end
|
||||
|
||||
test "malformed PNG body returns {:error, _} from the decoder" do
|
||||
Req.Test.stub(NexradClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 200, "not a png")
|
||||
end)
|
||||
|
||||
assert {:error, reason} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:05:00Z])
|
||||
# decode_png_to_pixels wraps any exception into this string
|
||||
assert is_binary(reason)
|
||||
assert reason =~ "PNG decode failed"
|
||||
end
|
||||
end
|
||||
|
||||
describe "frame_url/1 round-trip property" do
|
||||
property "url embeds the date path and a 12-digit timestamp for any rounded 5-min input" do
|
||||
check all(
|
||||
year <- integer(2015..2030),
|
||||
month <- integer(1..12),
|
||||
day <- integer(1..28),
|
||||
hour <- integer(0..23),
|
||||
minute_bucket <- integer(0..11)
|
||||
) do
|
||||
dt = %DateTime{
|
||||
year: year,
|
||||
month: month,
|
||||
day: day,
|
||||
hour: hour,
|
||||
minute: minute_bucket * 5,
|
||||
second: 0,
|
||||
microsecond: {0, 0},
|
||||
std_offset: 0,
|
||||
utc_offset: 0,
|
||||
zone_abbr: "UTC",
|
||||
time_zone: "Etc/UTC"
|
||||
}
|
||||
|
||||
url = NexradClient.frame_url(dt)
|
||||
|
||||
date_path =
|
||||
"#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}/" <>
|
||||
String.pad_leading(Integer.to_string(day), 2, "0")
|
||||
|
||||
file_ts =
|
||||
[year, month, day, hour, minute_bucket * 5]
|
||||
|> Enum.map(&Integer.to_string/1)
|
||||
|> Enum.zip([4, 2, 2, 2, 2])
|
||||
|> Enum.map_join(fn {str, w} -> String.pad_leading(str, w, "0") end)
|
||||
|
||||
assert String.contains?(url, date_path)
|
||||
assert String.contains?(url, "n0q_#{file_ts}.png")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "extract_box/5 edge cases" do
|
||||
test "box partially off the left edge returns whatever pixels fall inside" do
|
||||
# 4-row × 10-col frame, every pixel hot.
|
||||
|
|
|
|||
|
|
@ -1019,4 +1019,465 @@ defmodule MicrowavepropWeb.ContactLiveTest do
|
|||
refute html =~ "Suggest Edit"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Show render-branch coverage" do
|
||||
# Coverage-focused tests for ContactLive.Show — each test drives the
|
||||
# LiveView far enough to render a template branch that the other
|
||||
# describe blocks don't reach. Together they push show.ex line
|
||||
# coverage well past 48% by forcing build_propagation_analysis,
|
||||
# build_data_sources, terrain_summary, the iemre render block, and
|
||||
# the can_enqueue=true enrichment kick-offs through every reachable
|
||||
# factor/verdict combination.
|
||||
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Propagation.ScoreCache
|
||||
alias Microwaveprop.Radio.ContactCommonVolumeRadar
|
||||
alias Microwaveprop.Radio.ContactEdit
|
||||
alias Microwaveprop.Terrain.TerrainProfile
|
||||
alias Microwaveprop.Weather
|
||||
alias Microwaveprop.Weather.HrrrProfile
|
||||
alias Microwaveprop.Weather.IemreObservation
|
||||
alias Microwaveprop.Weather.Sounding
|
||||
alias Microwaveprop.Weather.SurfaceObservation
|
||||
|
||||
# Re-seed a richer variant of seed_fully_hydrated/0 so each branch test
|
||||
# can override the single row it cares about (terrain verdict, iemre
|
||||
# hour, propagation tier). Keeps a single insertion per source so the
|
||||
# page renders through build_propagation_analysis exactly once.
|
||||
defp seed_contact_with_overrides(opts \\ []) do
|
||||
contact_overrides = Keyword.get(opts, :contact, %{})
|
||||
terrain_overrides = Keyword.get(opts, :terrain, %{})
|
||||
hrrr_overrides = Keyword.get(opts, :hrrr, %{})
|
||||
|
||||
contact =
|
||||
create_contact(
|
||||
Map.merge(
|
||||
%{
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
},
|
||||
contact_overrides
|
||||
)
|
||||
)
|
||||
|
||||
hrrr_row =
|
||||
Map.merge(
|
||||
%{
|
||||
valid_time: contact.qso_timestamp,
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
profile: [
|
||||
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
|
||||
%{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
|
||||
],
|
||||
hpbl_m: 1200.0,
|
||||
pwat_mm: 25.0,
|
||||
surface_temp_c: 20.0,
|
||||
surface_dewpoint_c: 12.0,
|
||||
surface_pressure_mb: 1012.0,
|
||||
surface_refractivity: 320.0,
|
||||
min_refractivity_gradient: -120.0,
|
||||
ducting_detected: false
|
||||
},
|
||||
hrrr_overrides
|
||||
)
|
||||
|
||||
Repo.insert!(struct!(HrrrProfile, hrrr_row))
|
||||
|
||||
terrain_row =
|
||||
Map.merge(
|
||||
%{
|
||||
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: 450.0,
|
||||
min_clearance_m: 20.0,
|
||||
diffraction_db: 6.5,
|
||||
fresnel_hit_count: 0,
|
||||
obstructed_count: 0,
|
||||
verdict: "CLEAR"
|
||||
},
|
||||
terrain_overrides
|
||||
)
|
||||
|
||||
Repo.insert!(struct!(TerrainProfile, terrain_row))
|
||||
|
||||
contact
|
||||
end
|
||||
|
||||
setup do
|
||||
ScoreCache.clear()
|
||||
:ok
|
||||
end
|
||||
|
||||
test "IEMRE section renders nearest-hour temperature and dew point", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
|
||||
# IEMRE lookup snaps pos1 (32.9, -97.0) to the nearest 1/8° grid:
|
||||
# (32.875, -97.0). Seed exactly that grid point.
|
||||
Repo.insert!(%IemreObservation{
|
||||
date: DateTime.to_date(contact.qso_timestamp),
|
||||
lat: 32.875,
|
||||
lon: -97.0,
|
||||
hourly: [
|
||||
%{
|
||||
"utc_hour" => 18,
|
||||
"air_temp_f" => 73.4,
|
||||
"dew_point_f" => 54.3,
|
||||
"skyc_%" => 42.0,
|
||||
"uwnd_mps" => 3.0,
|
||||
"vwnd_mps" => 4.0,
|
||||
"hourly_precip_in" => 0.0
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# Header label + the nearest-hour temperature value make it
|
||||
# through format_number. 73.4°F confirms the right hour bucket
|
||||
# came out of the Enum.min_by reduction.
|
||||
assert html =~ "IEMRE Gridded Reanalysis"
|
||||
assert html =~ "73.4"
|
||||
assert html =~ "54.3"
|
||||
end
|
||||
|
||||
test "IEMRE section shows 'unavailable' status when marked as such", %{conn: conn} do
|
||||
contact =
|
||||
create_contact()
|
||||
|> Ecto.Changeset.change(iemre_status: :unavailable)
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
assert html =~ "IEMRE data unavailable for this contact."
|
||||
end
|
||||
|
||||
test "BLOCKED terrain verdict shows the obstruction badge class", %{conn: conn} do
|
||||
contact =
|
||||
seed_contact_with_overrides(
|
||||
terrain: %{
|
||||
verdict: "BLOCKED",
|
||||
obstructed_count: 4,
|
||||
fresnel_hit_count: 2,
|
||||
diffraction_db: 18.2
|
||||
}
|
||||
)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# badge-error is the BLOCKED class; the diffraction_db value
|
||||
# surfaces through format_number in the verdict row.
|
||||
assert html =~ "BLOCKED"
|
||||
assert html =~ "badge-error"
|
||||
assert html =~ "obstructed"
|
||||
end
|
||||
|
||||
test "FRESNEL_MINOR terrain verdict renders with warning styling", %{conn: conn} do
|
||||
contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_MINOR"})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "FRESNEL_MINOR"
|
||||
assert html =~ "badge-warning"
|
||||
end
|
||||
|
||||
test "FRESNEL_PARTIAL terrain verdict renders with warning styling", %{conn: conn} do
|
||||
contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_PARTIAL"})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "FRESNEL_PARTIAL"
|
||||
assert html =~ "badge-warning"
|
||||
end
|
||||
|
||||
test "expanded terrain table renders all path points", %{conn: conn} do
|
||||
contact =
|
||||
seed_contact_with_overrides(
|
||||
terrain: %{
|
||||
verdict: "CLEAR",
|
||||
path_points: [
|
||||
%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0},
|
||||
%{"lat" => 31.6, "lon" => -97.35, "dist_km" => 147.5, "elev" => 320.0},
|
||||
%{"lat" => 30.3, "lon" => -97.7, "dist_km" => 295.0, "elev" => 250.0}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
# Expand the terrain section by firing toggle_terrain; the table
|
||||
# header "Dist (km)" only appears when @terrain_expanded is true.
|
||||
html = render_click(lv, "toggle_terrain", %{})
|
||||
assert html =~ "Dist (km)"
|
||||
end
|
||||
|
||||
test "Propagation Analysis renders factor rows and composite tier label", %{conn: conn} do
|
||||
contact = seed_contact_with_overrides()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "Propagation Analysis"
|
||||
# Factor table header — only rendered when factors != nil.
|
||||
assert html =~ "Contribution"
|
||||
# Humidity is always in the factor_rows list; its row label proves
|
||||
# build_propagation_analysis -> compute_factors -> humidity_note
|
||||
# all executed.
|
||||
assert html =~ "Humidity"
|
||||
# composite_score is rendered as N/100; at minimum the "/100" marker
|
||||
# plus one of the tier labels must appear.
|
||||
assert html =~ "/100"
|
||||
|
||||
assert html =~ "EXCELLENT" or html =~ "GOOD" or html =~ "MARGINAL" or
|
||||
html =~ "POOR" or html =~ "NEGLIGIBLE"
|
||||
end
|
||||
|
||||
test "Data Sources card renders HRRR, Terrain, and Elevation blocks", %{conn: conn} do
|
||||
contact = seed_contact_with_overrides()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "Data Sources"
|
||||
assert html =~ "HRRR Model"
|
||||
assert html =~ "Terrain Analysis"
|
||||
assert html =~ "Elevation Profile"
|
||||
end
|
||||
|
||||
test "internal-network conn kicks off enqueue path and transitions pending status", %{conn: conn} do
|
||||
# A contact with only coords + pending everything triggers every
|
||||
# maybe_enqueue_* branch once can_enqueue is true. We never run
|
||||
# Oban inline during the test; we just assert the hydration
|
||||
# finishes without crashing the LiveView.
|
||||
contact = create_contact()
|
||||
|
||||
# `store_remote_ip` pulls conn.remote_ip into the session. Seeding
|
||||
# it into the @enqueue_subnet CIDR makes internal_network?/1 true.
|
||||
conn = %{conn | remote_ip: {172, 56, 0, 1}}
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
assert Process.alive?(lv.pid)
|
||||
# The page still renders both station callsigns; no enrichment
|
||||
# row exists, so the fallback spinners / "No *" copy is fine.
|
||||
assert render(lv) =~ contact.station1
|
||||
end
|
||||
|
||||
test "radar row surfaces max_dbz and coverage_pct in the mechanism block", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
|
||||
Repo.insert!(%ContactCommonVolumeRadar{
|
||||
contact_id: contact.id,
|
||||
observed_at: contact.qso_timestamp,
|
||||
common_volume_km2: 12_500.0,
|
||||
pixel_count: 800,
|
||||
rain_pixel_count: 150,
|
||||
heavy_rain_pixel_count: 25,
|
||||
max_dbz: 47.5,
|
||||
mean_dbz: 22.3,
|
||||
coverage_pct: 18.0
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "Propagation Mechanism"
|
||||
# Max reflectivity + heavy-rain pixel count only render when
|
||||
# @radar is populated — i.e. after handle_async(:radar, …) lands.
|
||||
assert html =~ "47.5 dBZ"
|
||||
assert html =~ "Heavy-rain pixels: 25"
|
||||
end
|
||||
|
||||
test "non-owner with a pending edit sees the pending-edit banner", %{conn: conn} do
|
||||
import Microwaveprop.AccountsFixtures
|
||||
|
||||
contact = create_contact()
|
||||
stranger = user_fixture()
|
||||
|
||||
Repo.insert!(%ContactEdit{
|
||||
contact_id: contact.id,
|
||||
user_id: stranger.id,
|
||||
proposed_changes: %{"station2" => "K9EDIT"},
|
||||
status: :pending
|
||||
})
|
||||
|
||||
conn = log_in_user(conn, stranger)
|
||||
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
assert html =~ "pending edit for this contact awaiting admin review"
|
||||
end
|
||||
|
||||
test "full Atmospheric Profile block renders surface temp/dewpoint/pressure", %{conn: conn} do
|
||||
contact = seed_contact_with_overrides()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# All three surface scalars are only rendered once the atmospheric
|
||||
# profile card expands — hrrr_profile_expanded defaults to true.
|
||||
assert html =~ "Surface Temp:"
|
||||
assert html =~ "Surface Dewpoint:"
|
||||
assert html =~ "Surface Pressure:"
|
||||
end
|
||||
|
||||
test "soundings-empty state shows the 1000 km fallback copy", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# No soundings within radius + nothing queued => final <% true -> %>
|
||||
# branch of the soundings empty cond.
|
||||
assert html =~ "No soundings found within 1000 km."
|
||||
end
|
||||
|
||||
test "surface-observations queued status shows spinner copy", %{conn: conn} do
|
||||
contact =
|
||||
create_contact()
|
||||
|> Ecto.Changeset.change(weather_status: :queued)
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
# Initial render (before render_async) sees @surface_observations=[]
|
||||
# and @contact.weather_status == :queued — the surface observations
|
||||
# spinner copy is reachable there.
|
||||
assert html =~ "Fetching surface observations" or html =~ "Surface Observations"
|
||||
end
|
||||
|
||||
test "ducting detected on the HRRR profile shows the Ducting badge", %{conn: conn} do
|
||||
contact =
|
||||
seed_contact_with_overrides(
|
||||
hrrr: %{
|
||||
ducting_detected: true,
|
||||
min_refractivity_gradient: -200.0
|
||||
}
|
||||
)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# Ducting badge appears next to the HRRR metadata line when the
|
||||
# profile reports ducting_detected=true.
|
||||
assert html =~ "Ducting"
|
||||
end
|
||||
|
||||
test "sounding row with a real station shows the ducting detection badge", %{conn: conn} do
|
||||
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
|
||||
})
|
||||
|
||||
Repo.insert!(
|
||||
Sounding.changeset(%Sounding{}, %{
|
||||
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,
|
||||
surface_refractivity: 340.0,
|
||||
ducting_detected: true
|
||||
})
|
||||
)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# Ducting badge renders inside the sounding button only for
|
||||
# rows with ducting_detected=true.
|
||||
assert html =~ "KFWD"
|
||||
assert html =~ "Ducting"
|
||||
end
|
||||
|
||||
# ─── Property tests ──────────────────────────────────────────
|
||||
|
||||
property "obs-table sort handler is total over every known field and direction", %{conn: conn} do
|
||||
# Drive the sort handler with every documented obs field in
|
||||
# arbitrary order. The LV must keep the table rendered and not
|
||||
# drop / duplicate any seeded row. Field coverage here exercises
|
||||
# each clause of obs_sort_key/1 plus the fall-through default.
|
||||
contact = create_contact(%{station1: "W5PROP", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
||||
|
||||
{:ok, station} =
|
||||
Weather.find_or_create_station(%{
|
||||
station_code: "KDFW",
|
||||
station_type: "asos",
|
||||
name: "DFW Airport",
|
||||
lat: 32.9,
|
||||
lon: -97.02
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
)
|
||||
|
||||
known_fields =
|
||||
~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb zzz_unknown)
|
||||
|
||||
field_stream = StreamData.member_of(known_fields)
|
||||
sequence_gen = StreamData.list_of(field_stream, min_length: 1, max_length: 6)
|
||||
|
||||
check all(fields <- sequence_gen, max_runs: 15) do
|
||||
# One LV per run keeps each iteration's sort history isolated —
|
||||
# the sort handler flips between asc/desc based on prior state.
|
||||
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
for f <- fields do
|
||||
html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
|
||||
assert html =~ "Surface Observations", "sort by #{f} lost the obs table"
|
||||
end
|
||||
|
||||
assert Process.alive?(lv.pid)
|
||||
end
|
||||
end
|
||||
|
||||
property "soundings-table sort handler tolerates arbitrary field keys", %{conn: conn} do
|
||||
# Mirrors the obs property — any sequence of known + unknown
|
||||
# sounding sort fields should leave the LV alive with the
|
||||
# Soundings heading intact.
|
||||
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
||||
|
||||
fields_pool =
|
||||
~w(station_name observed_at surface_temp_c surface_refractivity k_index lifted_index unknown)
|
||||
|
||||
sequence_gen =
|
||||
StreamData.list_of(StreamData.member_of(fields_pool), min_length: 1, max_length: 4)
|
||||
|
||||
check all(fields <- sequence_gen, max_runs: 10) do
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
for f <- fields do
|
||||
html = render_click(lv, "sort", %{"field" => f, "table" => "soundings"})
|
||||
assert html =~ "Soundings", "sort by #{f} lost the soundings section"
|
||||
end
|
||||
|
||||
assert Process.alive?(lv.pid)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ defmodule Mix.Tasks.SimpleTasksTest do
|
|||
output, not side effects on Oban queues or DB rows.
|
||||
"""
|
||||
use Microwaveprop.DataCase, async: false
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Weather.HrrrClient
|
||||
alias Microwaveprop.Weather.HrrrNativeProfile
|
||||
alias Mix.Tasks.Backtest
|
||||
alias Mix.Tasks.Hrrr.PurgeGridPoints
|
||||
alias Mix.Tasks.HrrrBackfill
|
||||
|
|
@ -326,4 +328,306 @@ defmodule Mix.Tasks.SimpleTasksTest do
|
|||
assert msg =~ "Recalibrating"
|
||||
end
|
||||
end
|
||||
|
||||
# --- Coverage-raising tests for under-tested Mix tasks ---
|
||||
|
||||
describe "Mix.Tasks.HrrrBackfill with stubbed HRRR" do
|
||||
test "seeded contact + 404 HTTP stub walks the fetch path and logs the failure branch" do
|
||||
# Stub HRRR so fetch_grid returns {:error, _} — exercises the
|
||||
# warning-log branch of fetch_and_store_hour/4 without hitting
|
||||
# the network.
|
||||
Req.Test.stub(HrrrClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 404, "not found")
|
||||
end)
|
||||
|
||||
{:ok, _contact} =
|
||||
%Contact{}
|
||||
|> Contact.changeset(%{
|
||||
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")
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
# The task uses Logger rather than Mix.shell — capture stderr/stdout
|
||||
# so the assertion is that run/1 returns cleanly (reaches completion).
|
||||
ExUnit.CaptureIO.capture_io(:stderr, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
HrrrBackfill.run(["--all", "--limit", "1"])
|
||||
end)
|
||||
end)
|
||||
|
||||
# The contact is still there and untouched — the error branch is
|
||||
# a no-op on state.
|
||||
assert Repo.aggregate(Contact, :count, :id) == 1
|
||||
end
|
||||
|
||||
test "--limit 0 short-circuits the iteration before any HTTP call" do
|
||||
# No stub installed — if the task tried to fetch, the test would
|
||||
# fail with a Req.Test error. --limit 0 means Enum.take(by_hour, 0)
|
||||
# produces an empty list, so fetch_and_store_hour/4 never runs.
|
||||
{:ok, _contact} =
|
||||
%Contact{}
|
||||
|> Contact.changeset(%{
|
||||
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")
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
ExUnit.CaptureIO.capture_io(:stderr, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
HrrrBackfill.run(["--limit", "0"])
|
||||
end)
|
||||
end)
|
||||
|
||||
# Task completes cleanly with zero iterations.
|
||||
assert Repo.aggregate(Contact, :count, :id) == 1
|
||||
end
|
||||
|
||||
test "pre-existing profile with >= 13 levels is filtered out (default, no --all)" do
|
||||
# With --all omitted and an existing profile that already has enough
|
||||
# levels at the contact's rounded hour, filter_points_needing_backfill/2
|
||||
# drops the point and the task does zero fetches.
|
||||
contact_time = ~U[2024-06-15 12:00:00Z]
|
||||
|
||||
{:ok, _contact} =
|
||||
%Contact{}
|
||||
|> Contact.changeset(%{
|
||||
station1: "W5A",
|
||||
station2: "K5B",
|
||||
qso_timestamp: contact_time,
|
||||
mode: "CW",
|
||||
band: Decimal.new("10000"),
|
||||
grid1: "EM13",
|
||||
grid2: "EM12",
|
||||
pos1: %{"lat" => 33.0, "lon" => -97.0},
|
||||
pos2: nil,
|
||||
distance_km: Decimal.new("10")
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
# Pre-seed a full-resolution profile so the level-count filter kicks in.
|
||||
hour = HrrrClient.nearest_hrrr_hour(contact_time)
|
||||
{rlat, rlon} = Microwaveprop.Weather.round_to_hrrr_grid(33.0, -97.0)
|
||||
|
||||
full_profile =
|
||||
Enum.map(1..14, fn i ->
|
||||
%{
|
||||
"pressure_mb" => 1000 - i * 50,
|
||||
"height_m" => i * 100,
|
||||
"temp_c" => 20 - i,
|
||||
"dewpoint_c" => 10 - i
|
||||
}
|
||||
end)
|
||||
|
||||
{:ok, _} =
|
||||
Microwaveprop.Weather.upsert_hrrr_profile(%{
|
||||
valid_time: hour,
|
||||
lat: rlat,
|
||||
lon: rlon,
|
||||
run_time: hour,
|
||||
profile: full_profile,
|
||||
surface_temp_c: 20.0,
|
||||
surface_dewpoint_c: 10.0,
|
||||
surface_pressure_mb: 1013.0
|
||||
})
|
||||
|
||||
# No HRRR stub: asserting the task runs without attempting any
|
||||
# HTTP request because filter_points_needing_backfill returned [].
|
||||
ExUnit.CaptureIO.capture_io(:stderr, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
HrrrBackfill.run([])
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.HrrrNativeDeriveFields with a seeded profile" do
|
||||
test "seeded profile (level_count > 2, bulk_richardson nil) is updated" do
|
||||
{:ok, _} =
|
||||
%HrrrNativeProfile{}
|
||||
|> HrrrNativeProfile.changeset(%{
|
||||
valid_time: ~U[2026-03-28 18:00:00Z],
|
||||
run_time: ~U[2026-03-28 18:00:00Z],
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
level_count: 3,
|
||||
heights_m: [10.0, 100.0, 500.0],
|
||||
temp_k: [298.0, 296.0, 290.0],
|
||||
spfh: [0.012, 0.010, 0.006],
|
||||
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
|
||||
u_wind_ms: [2.0, 3.0, 4.0],
|
||||
v_wind_ms: [1.0, 1.5, 2.0],
|
||||
tke_m2s2: [0.5, 0.4, 0.3]
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
HrrrNativeDeriveFields.run([])
|
||||
end)
|
||||
|
||||
assert_received {:mix_shell, :info, [start_msg]}
|
||||
assert start_msg =~ "Deriving fields for 1 profiles"
|
||||
|
||||
assert_received {:mix_shell, :info, [done_msg]}
|
||||
assert done_msg =~ "Updated 1 profiles"
|
||||
|
||||
# A second run should find zero pending profiles (assuming the
|
||||
# derive path populated bulk_richardson OR the profile's inversion
|
||||
# search returned :none — either way, is_nil(bulk_richardson) stays
|
||||
# true if :none, so both branches are reachable). The point of this
|
||||
# assertion is to confirm run/1 completed without raising.
|
||||
profile = Repo.one(HrrrNativeProfile)
|
||||
assert profile.ducts != nil or profile.ducts == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.ImportContestLogs success path" do
|
||||
# CsvImport.commit synchronously enqueues enrichment workers via
|
||||
# ContactWeatherEnqueueWorker.enqueue_for_contact/1. Oban runs those
|
||||
# inline in test, and they each hit their own Req.Test stub namespace.
|
||||
# Blanket-stub every client the enrichment chain touches so the
|
||||
# workers complete (with trivial 404/empty payloads) rather than
|
||||
# crashing and propagating up into our task run.
|
||||
defp stub_all_enrichment_clients do
|
||||
for module <- [
|
||||
Microwaveprop.Terrain.ElevationClient,
|
||||
Microwaveprop.Terrain.Srtm,
|
||||
HrrrClient,
|
||||
Microwaveprop.Weather.IemClient,
|
||||
Microwaveprop.Weather.NexradClient,
|
||||
Microwaveprop.Weather.SolarClient
|
||||
] do
|
||||
Req.Test.stub(module, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 404, "not found")
|
||||
end)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "reads a minimal valid CSV and imports one contact" do
|
||||
stub_all_enrichment_clients()
|
||||
|
||||
tmp_path =
|
||||
Path.join(System.tmp_dir!(), "contest-import-#{System.unique_integer([:positive])}.csv")
|
||||
|
||||
on_exit(fn -> File.rm(tmp_path) end)
|
||||
|
||||
File.write!(tmp_path, """
|
||||
station1,station2,grid1,grid2,band,mode,qso_timestamp
|
||||
W5A,K5B,EM13,EM12,10000,CW,2024-06-15T12:00:00Z
|
||||
""")
|
||||
|
||||
ExUnit.CaptureIO.capture_io(:stderr, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
ImportContestLogs.run([tmp_path])
|
||||
end)
|
||||
end)
|
||||
|
||||
# The task emits several info lines through Mix.shell(). Flush them
|
||||
# and check at least the opener + the preview summary are present.
|
||||
messages = collect_mix_messages()
|
||||
combined = Enum.join(messages, "\n")
|
||||
|
||||
assert combined =~ "Combined 1 files"
|
||||
assert combined =~ "Preview:"
|
||||
assert combined =~ "Done!"
|
||||
|
||||
# One contact landed in the DB.
|
||||
assert Repo.aggregate(Contact, :count, :id) == 1
|
||||
end
|
||||
|
||||
test "malformed CSV missing required columns raises a helpful error" do
|
||||
tmp_path =
|
||||
Path.join(System.tmp_dir!(), "contest-bad-#{System.unique_integer([:positive])}.csv")
|
||||
|
||||
on_exit(fn -> File.rm(tmp_path) end)
|
||||
|
||||
# Only has station1/station2 — missing grid1/grid2/band/qso_timestamp.
|
||||
File.write!(tmp_path, """
|
||||
station1,station2
|
||||
W5A,K5B
|
||||
""")
|
||||
|
||||
assert_raise Mix.Error, ~r/Failed:/, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
ImportContestLogs.run([tmp_path])
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
property "band string from a valid CSV round-trips into the inserted contact's decimal band" do
|
||||
# The BandResolver canonicalizes any of the allowed band strings
|
||||
# (e.g. "10000", "24000") into an integer MHz string, which
|
||||
# Contact.submission_changeset then casts to :decimal. This property
|
||||
# asserts the round-trip for every allowed band.
|
||||
allowed_bands = ~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 68000 75000 122000 134000 241000)
|
||||
|
||||
stub_all_enrichment_clients()
|
||||
|
||||
check all(band <- StreamData.member_of(allowed_bands), max_runs: 10) do
|
||||
# Each iteration needs a fresh DB state — the outer DataCase
|
||||
# sandbox rolls back at the end of the test, so manually clear
|
||||
# contacts between iterations.
|
||||
Repo.delete_all(Contact)
|
||||
|
||||
tmp_path =
|
||||
Path.join(
|
||||
System.tmp_dir!(),
|
||||
"contest-prop-#{System.unique_integer([:positive])}.csv"
|
||||
)
|
||||
|
||||
File.write!(tmp_path, """
|
||||
station1,station2,grid1,grid2,band,mode,qso_timestamp
|
||||
W5A,K5B,EM13,EM12,#{band},CW,2024-06-15T12:00:00Z
|
||||
""")
|
||||
|
||||
try do
|
||||
ExUnit.CaptureIO.capture_io(:stderr, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
ImportContestLogs.run([tmp_path])
|
||||
end)
|
||||
end)
|
||||
|
||||
# Flush the accumulated Mix.shell messages so the next iteration
|
||||
# starts clean.
|
||||
_ = collect_mix_messages()
|
||||
|
||||
contacts = Repo.all(Contact)
|
||||
assert length(contacts) == 1
|
||||
|
||||
[contact] = contacts
|
||||
assert Decimal.equal?(contact.band, Decimal.new(band))
|
||||
after
|
||||
File.rm(tmp_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Drain any pending Mix.shell(Mix.Shell.Process) messages from the
|
||||
# mailbox so each test/iteration only sees its own output.
|
||||
defp collect_mix_messages(acc \\ []) do
|
||||
receive do
|
||||
{:mix_shell, :info, [msg]} -> collect_mix_messages([msg | acc])
|
||||
after
|
||||
0 -> Enum.reverse(acc)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue